Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc60cbfeb5 |
@@ -63,46 +63,3 @@ data/
|
||||
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
|
||||
hermes-config/
|
||||
runtime/
|
||||
|
||||
# ---------- Not needed inside the Docker image ----------
|
||||
|
||||
# Desktop app source (Tauri/Electron); never installed in the container.
|
||||
# apps/shared is the dashboard↔desktop websocket helper and is linked from
|
||||
# web/package.json as a file: workspace dep — keep it in the build context.
|
||||
apps/
|
||||
!apps/shared/
|
||||
!apps/shared/**
|
||||
|
||||
# Test suite — not shipped in production images
|
||||
tests/
|
||||
|
||||
# Documentation site (Docusaurus) and supplementary docs
|
||||
website/
|
||||
docs/
|
||||
|
||||
# Assets only used by the GitHub README
|
||||
assets/
|
||||
infographic/
|
||||
|
||||
# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them)
|
||||
plugins/hermes-achievements/docs/
|
||||
|
||||
# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker
|
||||
nix/
|
||||
flake.nix
|
||||
flake.lock
|
||||
packaging/
|
||||
|
||||
# Design and planning documents
|
||||
plans/
|
||||
.plans/
|
||||
|
||||
# ACP registry manifest (icon + agent.json) — not consumed at runtime
|
||||
acp_registry/
|
||||
|
||||
# Repo-level dotfiles that are git-only or dev-tooling config
|
||||
.env.example
|
||||
.envrc
|
||||
.gitattributes
|
||||
.hadolint.yaml
|
||||
.mailmap
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
# Get your token at: https://huggingface.co/settings/tokens
|
||||
# Required permission: "Make calls to Inference Providers"
|
||||
# HF_TOKEN=
|
||||
# HF_BASE_URL=https://router.huggingface.co/v1 # Override default base URL
|
||||
# OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL
|
||||
|
||||
# =============================================================================
|
||||
@@ -412,9 +411,6 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# Groq API key (free tier — used for Whisper STT in voice mode)
|
||||
# GROQ_API_KEY=
|
||||
|
||||
# ElevenLabs API key (cloud STT/TTS — Scribe transcription)
|
||||
# ELEVENLABS_API_KEY=
|
||||
|
||||
# =============================================================================
|
||||
# STT PROVIDER SELECTION
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
watch_file pyproject.toml uv.lock
|
||||
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
|
||||
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix
|
||||
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix
|
||||
|
||||
use flake
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
name: Detect affected areas
|
||||
description: >-
|
||||
Classify a PR's changed files into CI work lanes (python, frontend, site,
|
||||
scan, deps, mcp_catalog) so the orchestrator can conditionally call only
|
||||
the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch
|
||||
events and fail open (everything "true") when the diff cannot be computed.
|
||||
|
||||
outputs:
|
||||
python:
|
||||
description: Run Python tests / ruff / ty / windows-footguns.
|
||||
value: ${{ steps.classify.outputs.python }}
|
||||
frontend:
|
||||
description: Run the TypeScript typecheck matrix + desktop build.
|
||||
value: ${{ steps.classify.outputs.frontend }}
|
||||
docker_meta:
|
||||
description: Docker setup and meta files have changed.
|
||||
value: ${{ steps.classify.outputs.docker_meta }}
|
||||
site:
|
||||
description: Build the Docusaurus docs site.
|
||||
value: ${{ steps.classify.outputs.site }}
|
||||
scan:
|
||||
description: Run the supply-chain critical-pattern scanner.
|
||||
value: ${{ steps.classify.outputs.scan }}
|
||||
deps:
|
||||
description: Check pyproject.toml dependency upper bounds.
|
||||
value: ${{ steps.classify.outputs.deps }}
|
||||
mcp_catalog:
|
||||
description: Require MCP catalog security review label.
|
||||
value: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Classify changed files
|
||||
id: classify
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Only pull_request events are gated. Other events (push, release,
|
||||
# dispatch) leave CHANGED empty, so the classifier fails open and every
|
||||
# lane runs. Post-merge / on-demand validation is never weakened.
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
# Use the compare endpoint with the pinned base/head SHAs from the
|
||||
# event payload instead of the "current PR files" endpoint. The SHAs
|
||||
# are frozen at trigger time, so the file list is deterministic even
|
||||
# if the PR receives a new push between trigger and detect.
|
||||
CHANGED="$(gh api \
|
||||
--paginate \
|
||||
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
|
||||
--jq '.files[].filename' || true)"
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
printf '%s\n' "${CHANGED:-(none)}"
|
||||
printf '%s\n' "${CHANGED:-}" | python3 scripts/ci/classify_changes.py
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Hermes smoke test
|
||||
description: >
|
||||
Run the image's built-in entrypoint against `--help` and `dashboard --help`
|
||||
to catch basic runtime regressions before publishing. Requires the image
|
||||
to already be loaded into the local Docker daemon under `image`.
|
||||
|
||||
Works identically on amd64 and arm64 runners.
|
||||
|
||||
inputs:
|
||||
image:
|
||||
description: Fully-qualified image tag (e.g. nousresearch/hermes-agent:test)
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Ensure /tmp/hermes-test is hermes-writable
|
||||
shell: bash
|
||||
run: |
|
||||
# The image runs as the hermes user (UID 10000). GitHub Actions
|
||||
# creates /tmp/hermes-test root-owned by default, which hermes
|
||||
# can't write to — chown it to match the in-container UID before
|
||||
# bind-mounting. Real users doing `docker run -v ~/.hermes:...`
|
||||
# with their own UID hit the same issue and have their own
|
||||
# remediations (HERMES_UID env var, or chown locally).
|
||||
mkdir -p /tmp/hermes-test
|
||||
sudo chown -R 10000:10000 /tmp/hermes-test
|
||||
|
||||
- name: hermes --help
|
||||
shell: bash
|
||||
run: |
|
||||
# Use the image's real ENTRYPOINT (/init + main-wrapper.sh) so
|
||||
# this exercises the actual production startup path. PR #30136
|
||||
# review caught that an --entrypoint override here had been
|
||||
# silently neutered by the s6-overlay migration — stage2-hook
|
||||
# ignores its CMD args, so the smoke test was a no-op.
|
||||
docker run --rm \
|
||||
-v /tmp/hermes-test:/opt/data \
|
||||
"${{ inputs.image }}" --help
|
||||
|
||||
- name: hermes dashboard --help
|
||||
shell: bash
|
||||
run: |
|
||||
# Regression guard for #9153: dashboard was present in source but
|
||||
# missing from the published image. If this fails, something in
|
||||
# the Dockerfile is excluding the dashboard subcommand from the
|
||||
# installed package.
|
||||
docker run --rm \
|
||||
-v /tmp/hermes-test:/opt/data \
|
||||
"${{ inputs.image }}" dashboard --help
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Retry a flaky command
|
||||
description: >-
|
||||
Run a shell command, retrying on non-zero exit. For dependency installs
|
||||
(npm ci, uv sync) whose only failures are transient network/toolchain
|
||||
flakes — a node-gyp header fetch, a registry blip — so CI self-heals
|
||||
instead of needing a manual re-run.
|
||||
|
||||
inputs:
|
||||
command:
|
||||
description: Shell command to run (and retry).
|
||||
required: true
|
||||
attempts:
|
||||
description: Max attempts before giving up.
|
||||
default: "3"
|
||||
delay:
|
||||
description: Seconds to wait between attempts.
|
||||
default: "10"
|
||||
working-directory:
|
||||
description: Directory to run in.
|
||||
default: "."
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
# command goes through env, never interpolated into the script body, so
|
||||
# a command with quotes/specials can't break or inject into the runner.
|
||||
env:
|
||||
_CMD: ${{ inputs.command }}
|
||||
_ATTEMPTS: ${{ inputs.attempts }}
|
||||
_DELAY: ${{ inputs.delay }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
n=0
|
||||
while :; do
|
||||
n=$((n + 1))
|
||||
echo "::group::attempt $n/$_ATTEMPTS: $_CMD"
|
||||
if bash -c "$_CMD"; then
|
||||
echo "::endgroup::"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
if [ "$n" -ge "$_ATTEMPTS" ]; then
|
||||
echo "::error::failed after $n attempts: $_CMD"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::attempt $n failed; retrying in ${_DELAY}s: $_CMD"
|
||||
sleep "$_DELAY"
|
||||
done
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 428 KiB |
@@ -0,0 +1,100 @@
|
||||
name: Build Windows Installer
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Gate: workflow_dispatch is already restricted to users with write access,
|
||||
# but we want ADMIN-only. Explicitly check the triggering actor's repo
|
||||
# permission via the API and fail fast for anyone below admin.
|
||||
authorize:
|
||||
name: Authorize (admins only)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check actor is a repo admin
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
perm=$(gh api \
|
||||
"repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
|
||||
--jq '.permission')
|
||||
echo "Actor '${ACTOR}' has permission: ${perm}"
|
||||
if [ "${perm}" != "admin" ]; then
|
||||
echo "::error::'${ACTOR}' is not a repo admin (permission=${perm}). Refusing to build/sign."
|
||||
exit 1
|
||||
fi
|
||||
echo "Authorized: '${ACTOR}' is an admin."
|
||||
|
||||
build:
|
||||
name: Hermes-Setup.exe
|
||||
needs: authorize
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
# Required for OIDC auth to Azure (azure/login federated credentials).
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
|
||||
- name: Cache Rust targets
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: apps/bootstrap-installer/src-tauri
|
||||
|
||||
- name: Build installer
|
||||
run: npm run tauri:build
|
||||
working-directory: apps/bootstrap-installer
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Hermes-Setup.exe with Azure Artifact Signing
|
||||
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
|
||||
with:
|
||||
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ vars.AZURE_SIGNING_CERTIFICATE_PROFILE }}
|
||||
# Sign both the raw exe and the bundled NSIS installer.
|
||||
files-folder: ${{ github.workspace }}\apps\bootstrap-installer\src-tauri\target\release
|
||||
files-folder-filter: exe
|
||||
files-folder-recurse: true
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Upload NSIS installer
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Hermes-Setup-installer
|
||||
path: apps/bootstrap-installer/src-tauri/target/release/bundle/nsis/*.exe
|
||||
|
||||
- name: Upload raw exe
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Hermes-Setup-exe
|
||||
path: apps/bootstrap-installer/src-tauri/target/release/Hermes-Setup.exe
|
||||
@@ -1,231 +0,0 @@
|
||||
name: CI
|
||||
|
||||
# Orchestrator workflow. Runs ``detect-changes`` once, then conditionally
|
||||
# calls the sub-workflows that a PR can actually affect. A final
|
||||
# ``all-checks-pass`` gate job aggregates results so branch protection only
|
||||
# needs to require a single check.
|
||||
#
|
||||
# Sub-workflows are triggered via ``workflow_call`` and keep their own job
|
||||
# definitions, matrices, and concurrency settings. They no longer have
|
||||
# ``push:`` / ``pull_request:`` triggers of their own — everything flows
|
||||
# through this file.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
|
||||
actions: read # needed by osv-scanner (SARIF upload)
|
||||
security-events: write # needed by osv-scanner (SARIF upload)
|
||||
packages: write # needed by docker build
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# detect: run the classifier once. Every downstream job reads its outputs
|
||||
# to decide whether to run. On push/dispatch the classifier fails open
|
||||
# (all lanes true) so post-merge validation is never weakened.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
detect:
|
||||
name: Detect affected areas
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
python: ${{ steps.classify.outputs.python }}
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
site: ${{ steps.classify.outputs.site }}
|
||||
scan: ${{ steps.classify.outputs.scan }}
|
||||
deps: ${{ steps.classify.outputs.deps }}
|
||||
docker_meta: ${{ steps.classify.outputs.docker_meta }}
|
||||
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
event_name: ${{ github.event_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Detect affected areas
|
||||
id: classify
|
||||
uses: ./.github/actions/detect-changes
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
|
||||
# Skipped workflows (if condition is false) don't spin up runners.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
tests:
|
||||
name: Python tests
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
slice_count: 8
|
||||
|
||||
lint:
|
||||
name: Python lints
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
|
||||
typecheck:
|
||||
name: TypeScript
|
||||
needs: detect
|
||||
if: needs.detect.outputs.frontend == 'true'
|
||||
uses: ./.github/workflows/typecheck.yml
|
||||
|
||||
docs-site:
|
||||
name: Docs Site
|
||||
needs: detect
|
||||
if: needs.detect.outputs.site == 'true'
|
||||
uses: ./.github/workflows/docs-site-checks.yml
|
||||
|
||||
history-check:
|
||||
name: Deny unrelated histories
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/history-check.yml
|
||||
|
||||
contributor-check:
|
||||
name: Check contributors
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/contributor-check.yml
|
||||
|
||||
uv-lockfile:
|
||||
name: Check uv.lock
|
||||
needs: detect
|
||||
uses: ./.github/workflows/uv-lockfile-check.yml
|
||||
|
||||
docker-lint:
|
||||
name: Lint Docker scripts
|
||||
needs: detect
|
||||
if: needs.detect.outputs.docker_meta == 'true'
|
||||
uses: ./.github/workflows/docker-lint.yml
|
||||
|
||||
docker:
|
||||
name: Build&Test Docker image
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true'
|
||||
uses: ./.github/workflows/docker.yml
|
||||
secrets: inherit
|
||||
|
||||
supply-chain:
|
||||
name: Supply-chain scan
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
|
||||
uses: ./.github/workflows/supply-chain-audit.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
scan: ${{ needs.detect.outputs.scan == 'true' }}
|
||||
deps: ${{ needs.detect.outputs.deps == 'true' }}
|
||||
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
|
||||
|
||||
osv-scanner:
|
||||
name: OSV scan
|
||||
uses: ./.github/workflows/osv-scanner.yml
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Gate: runs after everything. ``if: always()`` ensures it reports a
|
||||
# status even when some deps were skipped. Only actual ``failure``
|
||||
# results cause it to fail; ``skipped`` is treated as success.
|
||||
#
|
||||
# Branch protection should require ONLY this check.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
all-checks-pass:
|
||||
name: All required checks pass
|
||||
needs:
|
||||
- tests
|
||||
- lint
|
||||
- typecheck
|
||||
- docs-site
|
||||
- history-check
|
||||
- contributor-check
|
||||
- uv-lockfile
|
||||
- docker-lint
|
||||
- supply-chain
|
||||
- osv-scanner
|
||||
# we don't require docker to pass rn because it's so slow lol
|
||||
# - docker
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate job results
|
||||
env:
|
||||
RESULTS: ${{ toJSON(needs.*.result) }}
|
||||
run: |
|
||||
echo "$RESULTS" | python3 -c "
|
||||
import json, sys
|
||||
results = json.load(sys.stdin)
|
||||
failed = [r for r in results if r == 'failure']
|
||||
if failed:
|
||||
print(f'::error::{len(failed)} job(s) failed')
|
||||
sys.exit(1)
|
||||
print('All checks passed (or were skipped)')
|
||||
"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# CI timing report: collect per-job/step durations from the GitHub API,
|
||||
# cache them on main (as a baseline), and on PRs generate an HTML diff
|
||||
# report with a gantt chart + per-step breakdown. The report is uploaded
|
||||
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
ci-timings:
|
||||
name: CI timing report
|
||||
needs: [all-checks-pass, docker]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore baseline cache (PR only)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ci-timings-baseline.json
|
||||
# Prefix-match: exact key will never hit (run_id differs), so
|
||||
# restore-keys finds the most recent baseline from main.
|
||||
key: ci-timings-baseline-never-exact
|
||||
restore-keys: |
|
||||
ci-timings-baseline-
|
||||
|
||||
- name: Collect timings and generate report
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python3 scripts/ci/timings_report.py \
|
||||
--baseline ci-timings-baseline.json \
|
||||
--output ci-timings-report.html \
|
||||
--json-out ci-timings.json \
|
||||
--summary-out ci-timings-summary.md
|
||||
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
id: ci-timings-artifact
|
||||
with:
|
||||
name: ci-timings-report
|
||||
path: ci-timings-report.html
|
||||
retention-days: 14
|
||||
archive: false
|
||||
|
||||
- name: Output summary
|
||||
env:
|
||||
REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}}
|
||||
run: |
|
||||
echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY"
|
||||
cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save baseline cache (main only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: cp ci-timings.json ci-timings-baseline.json
|
||||
|
||||
- name: Upload baseline to cache (main only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ci-timings-baseline.json
|
||||
key: ci-timings-baseline-${{ github.run_id }}
|
||||
@@ -1,7 +1,11 @@
|
||||
name: Contributor Attribution Check
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -14,7 +18,21 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check if relevant files changed
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Python files changed, skipping attribution check."
|
||||
fi
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
if: steps.filter.outputs.run == 'true'
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
|
||||
@@ -11,20 +11,8 @@ on:
|
||||
- 'optional-skills/**'
|
||||
- '.github/workflows/deploy-site.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skills_index_run_id:
|
||||
description: 'Optional Build Skills Index run ID whose skills-index artifact should be deployed'
|
||||
required: false
|
||||
type: string
|
||||
rebuild_skills_index:
|
||||
description: 'Force a fresh multi-source crawl instead of reusing the latest healthy index'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
@@ -56,7 +44,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
@@ -67,81 +55,26 @@ jobs:
|
||||
- name: Install PyYAML for skill extraction
|
||||
run: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
|
||||
- name: Prepare skills index (unified multi-source catalog)
|
||||
- name: Build skills index (unified multi-source catalog)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
|
||||
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# The unified external catalog is expensive to crawl and can burn
|
||||
# through the repository installation's GitHub API quota when several
|
||||
# docs deploys land close together. Normal docs deploys therefore
|
||||
# reuse the latest healthy catalog: first the artifact from a
|
||||
# scheduled skills-index run, then the currently live index. Only a
|
||||
# manual force rebuild does a fresh crawl here.
|
||||
# Rebuild the unified catalog. The file is gitignored, so a fresh
|
||||
# checkout starts without it and we want the freshest crawl in
|
||||
# every deploy.
|
||||
#
|
||||
# If we do crawl, the build remains fatal. build_skills_index.py runs
|
||||
# the health check BEFORE writing and exits non-zero on source
|
||||
# collapse, keeping the last good Pages deployment live instead of
|
||||
# publishing a degenerate catalog.
|
||||
set -euo pipefail
|
||||
INDEX_PATH="website/static/api/skills-index.json"
|
||||
mkdir -p "$(dirname "$INDEX_PATH")"
|
||||
|
||||
validate_index() {
|
||||
python3 - "$INDEX_PATH" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
print(f"invalid skills index JSON: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
skills = data.get("skills")
|
||||
if not isinstance(skills, list) or len(skills) < 1500:
|
||||
count = len(skills) if isinstance(skills, list) else "missing"
|
||||
print(f"skills index too small: {count}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"skills index ready: {len(skills)} skills")
|
||||
PY
|
||||
}
|
||||
|
||||
if [ "$REBUILD_SKILLS_INDEX" = "true" ]; then
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLS_INDEX_RUN_ID" ]; then
|
||||
tmpdir="$(mktemp -d)"
|
||||
echo "Downloading skills-index artifact from run $SKILLS_INDEX_RUN_ID"
|
||||
if gh run download "$SKILLS_INDEX_RUN_ID" --name skills-index --dir "$tmpdir"; then
|
||||
candidate="$(find "$tmpdir" -name skills-index.json -type f | head -n 1 || true)"
|
||||
if [ -n "$candidate" ]; then
|
||||
cp "$candidate" "$INDEX_PATH"
|
||||
if validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "::warning::Could not use skills-index artifact from run $SKILLS_INDEX_RUN_ID; trying live index"
|
||||
fi
|
||||
|
||||
echo "Downloading currently live skills index"
|
||||
if curl -fsSL --retry 3 --retry-delay 5 \
|
||||
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json" \
|
||||
-o "$INDEX_PATH" && validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::Live skills index unavailable or unhealthy; falling back to a fresh crawl"
|
||||
rm -f "$INDEX_PATH"
|
||||
# This MUST be fatal. build_skills_index.py runs a health check and
|
||||
# exits non-zero WITHOUT writing the output file when a source
|
||||
# collapses (e.g. a GitHub API rate limit zeroes the github /
|
||||
# claude-marketplace / well-known taps all at once). Letting the
|
||||
# deploy continue would either (a) ship a degenerate index missing
|
||||
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
|
||||
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
|
||||
# local-only catalog. Failing here keeps the last good deployment
|
||||
# live (GitHub Pages serves the previous build) instead of
|
||||
# publishing a broken catalog. Re-run the workflow once the
|
||||
# transient rate limit clears.
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Docker / shell lint
|
||||
|
||||
# Lints the container build inputs: Dockerfile (via hadolint) and any shell
|
||||
# scripts under docker/ (via shellcheck). These catch the class of regression
|
||||
# the behavioral docker smoke test can't — unquoted variable
|
||||
# the behavioral docker-publish smoke test can't — unquoted variable
|
||||
# expansions, silently-failing RUN commands, etc.
|
||||
#
|
||||
# Rules and ignores are documented in .hadolint.yaml at the repo root.
|
||||
@@ -11,7 +11,20 @@ name: Docker / shell lint
|
||||
# activate script doesn't exist at lint time.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- Dockerfile
|
||||
- docker/**
|
||||
- .hadolint.yaml
|
||||
- .github/workflows/docker-lint.yml
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- Dockerfile
|
||||
- docker/**
|
||||
- .hadolint.yaml
|
||||
- .github/workflows/docker-lint.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
name: Docker Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- 'Dockerfile'
|
||||
- 'docker/**'
|
||||
- '.github/workflows/docker-publish.yml'
|
||||
- '.github/actions/hermes-smoke-test/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- 'Dockerfile'
|
||||
- 'docker/**'
|
||||
- '.github/workflows/docker-publish.yml'
|
||||
- '.github/actions/hermes-smoke-test/**'
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed so the arm64 job can push/pull its registry-backed build cache
|
||||
# to ghcr.io (cache-to/cache-from type=registry). See the build-arm64
|
||||
# job for why registry cache replaced the gha cache on that arch.
|
||||
packages: write
|
||||
|
||||
# Concurrency: push/release runs are NEVER cancelled so every merge gets
|
||||
# its own image. 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' }}
|
||||
|
||||
env:
|
||||
IMAGE_NAME: nousresearch/hermes-agent
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build amd64 natively. This job also runs the smoke tests (basic --help
|
||||
# and the dashboard subcommand regression guard from #9153), because amd64
|
||||
# is the only arch we can `load` into the local daemon on an amd64 runner.
|
||||
# ---------------------------------------------------------------------------
|
||||
build-amd64:
|
||||
# Only run on the upstream repository, not on forks
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
digest: ${{ steps.push.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Build once, load into the local daemon for smoke testing. Cached
|
||||
# to gha with a per-arch scope; the push step below reuses every
|
||||
# layer from this build.
|
||||
- name: Build image (amd64, smoke test)
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
|
||||
- name: Smoke test image
|
||||
uses: ./.github/actions/hermes-smoke-test
|
||||
with:
|
||||
image: ${{ env.IMAGE_NAME }}:test
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Run the docker-integration test suite against the freshly-built
|
||||
# image already loaded into the local daemon (`:test`). These tests
|
||||
# are excluded from the sharded `tests.yml :: test` matrix on purpose
|
||||
# (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each
|
||||
# shard would otherwise reach the session-scoped ``built_image``
|
||||
# fixture in ``tests/docker/conftest.py`` and start a 3-7min
|
||||
# ``docker build`` under a 180s pytest-timeout cap — guaranteed to
|
||||
# die in fixture setup.
|
||||
#
|
||||
# Piggybacking here avoids a second image build: the smoke test
|
||||
# already proved the image loads + runs, so the daemon has it under
|
||||
# `${IMAGE_NAME}:test` and we just point ``HERMES_TEST_IMAGE`` at
|
||||
# that. The fixture's ``HERMES_TEST_IMAGE`` branch (see
|
||||
# tests/docker/conftest.py:62-63) short-circuits the rebuild.
|
||||
#
|
||||
# Why this job and not a standalone one: the image is 5GB+; passing
|
||||
# it between jobs via ``docker save``/``upload-artifact`` is slower
|
||||
# than the build itself. Reusing the existing daemon state is the
|
||||
# cheapest path to coverage on every PR that touches docker code.
|
||||
# ---------------------------------------------------------------------
|
||||
- name: Install uv (for docker tests)
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Set up Python 3.11 (for docker tests)
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install Python dependencies (for docker tests)
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout —
|
||||
# everything tests/docker/ needs. We deliberately avoid ``all``
|
||||
# here because the docker tests only drive the container via
|
||||
# subprocess and don't import hermes_agent's optional deps.
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run docker integration tests
|
||||
env:
|
||||
# Skip rebuild; use the image already loaded by the build step.
|
||||
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
|
||||
# Match the policy in tests.yml :: test job — no accidental
|
||||
# real-API calls from inside the harness.
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python -m pytest tests/docker/ -v --tb=short
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Push amd64 by digest only (no tag). The merge job assembles the
|
||||
# tagged manifest list. `push-by-digest=true` is docker's recommended
|
||||
# pattern for multi-runner multi-platform builds.
|
||||
- name: Push amd64 by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
|
||||
# Write the digest to a file and upload it as an artifact so the
|
||||
# merge job can stitch both per-arch digests into a manifest list.
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: digest-amd64
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build arm64 natively on GitHub's free arm64 runner. This replaces the
|
||||
# previous QEMU-emulated arm64 build, which was ~5-10x slower and shared
|
||||
# a cache scope with amd64. Matches the amd64 job's shape: build+load,
|
||||
# smoke test, then on push/release push by digest.
|
||||
# ---------------------------------------------------------------------------
|
||||
build-arm64:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
digest: ${{ steps.push.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Log in to ghcr.io so the registry-backed build cache below can be
|
||||
# read (cache-from) on every event and written (cache-to) on
|
||||
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
|
||||
# the whole job — unlike the gha cache backend's short-lived Azure SAS
|
||||
# token, which expired mid-build on slow cold-cache arm64 runs and
|
||||
# crashed the build before the smoke test (the reason the gha cache
|
||||
# was removed from arm64 PRs in the first place).
|
||||
- name: Log in to ghcr.io (build cache)
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Build once, load into the local daemon for smoke testing.
|
||||
#
|
||||
# PR builds use the registry-backed cache READ-ONLY (cache-from only):
|
||||
# they pull warm layers pushed by the most recent main build but never
|
||||
# write, so rapid PR pushes don't race on cache writes or pollute the
|
||||
# cache ref. This restores warm-cache speed to arm64 PR builds (which
|
||||
# were running fully uncached and were ~45% slower than amd64, making
|
||||
# them the job most often cancelled on supersede).
|
||||
#
|
||||
# Registry cache (type=registry on ghcr.io) is used instead of the gha
|
||||
# cache that previously broke here: its credential is the job-lifetime
|
||||
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
|
||||
# token failure mode cannot recur.
|
||||
- name: Build image (arm64, smoke test, cache read-only PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
|
||||
# Main/release builds read AND write the registry cache so the digest
|
||||
# push below reuses layers from this smoke-test build, and so the next
|
||||
# PR/main build starts warm.
|
||||
- name: Build image (arm64, smoke test, cached publish)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
|
||||
- name: Smoke test image
|
||||
uses: ./.github/actions/hermes-smoke-test
|
||||
with:
|
||||
image: ${{ env.IMAGE_NAME }}:test
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Push arm64 by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/arm64
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: digest-arm64
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stitch both per-arch digests into a single tagged multi-arch manifest.
|
||||
# This is a registry-side operation — no building, no layer re-push —
|
||||
# so it runs in ~30 seconds.
|
||||
#
|
||||
# On main pushes: tags both :main and :latest.
|
||||
# On releases: tags :<release_tag_name>.
|
||||
# ---------------------------------------------------------------------------
|
||||
merge:
|
||||
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-amd64, build-arm64]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
for digest_file in *; do
|
||||
args+=("${IMAGE_NAME}@sha256:${digest_file}")
|
||||
done
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:${TAG}" \
|
||||
"${args[@]}"
|
||||
else
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:main" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
"${args[@]}"
|
||||
fi
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${{ github.event.release.tag_name }}"
|
||||
else
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:main"
|
||||
fi
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
@@ -1,209 +0,0 @@
|
||||
name: Docker Build, Test, and Publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency: push/release runs are NEVER cancelled so every merge gets
|
||||
# its own image. 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' }}
|
||||
|
||||
env:
|
||||
IMAGE_NAME: nousresearch/hermes-agent
|
||||
|
||||
jobs:
|
||||
# Build, test, and optionally push the image for each architecture.
|
||||
build:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Build once, load into the local daemon for testing. Cached
|
||||
# per-arch; the push step below reuses every layer from this build.
|
||||
- name: Build image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Push by digest only (no tag). The merge job assembles the
|
||||
# tagged manifest list. `push-by-digest=true` is docker's recommended
|
||||
# pattern for multi-runner multi-platform builds.
|
||||
- name: Push ${{ matrix.arch }} by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ matrix.cache-to }}
|
||||
|
||||
# Write the digest to a file and upload it as an artifact so the
|
||||
# merge job can stitch both per-arch digests into a manifest list.
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Run the docker-integration test suite against the freshly-built
|
||||
# image already loaded into the local daemon (`:test`).
|
||||
#
|
||||
# Piggybacking here avoids a second image build: the build step
|
||||
# already loaded the image into the daemon under
|
||||
# `${IMAGE_NAME}:test`, so we just point ``HERMES_TEST_IMAGE`` at
|
||||
# that. The fixture's ``HERMES_TEST_IMAGE`` branch (see
|
||||
# tests/docker/conftest.py:62-63) short-circuits the rebuild.
|
||||
#
|
||||
# Why this job and not a standalone one: the image is 5GB+; passing
|
||||
# it between jobs via ``docker save``/``upload-artifact`` is slower
|
||||
# than the build itself. Reusing the existing daemon state is the
|
||||
# cheapest path to coverage on every PR that touches docker code.
|
||||
# ---------------------------------------------------------------------
|
||||
- name: Install uv (for docker tests)
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
|
||||
- name: Set up Python 3.11 (for docker tests)
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install Python dependencies (for docker tests)
|
||||
run: |
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||
# everything tests/docker/ needs. We deliberately avoid ``all``
|
||||
# here because the docker tests only drive the container via
|
||||
# subprocess and don't import hermes_agent's optional deps.
|
||||
uv sync --locked --python 3.11 --extra dev
|
||||
|
||||
- name: Run docker integration tests
|
||||
env:
|
||||
# Skip rebuild; use the image already loaded by the build step.
|
||||
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
|
||||
# Match the policy in tests.yml :: test job — no accidental
|
||||
# real-API calls from inside the harness.
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
run: |
|
||||
scripts/run_tests.sh tests/docker/ --file-timeout 600
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stitch both per-arch digests into a single tagged multi-arch manifest.
|
||||
# This is a registry-side operation — no building, no layer re-push —
|
||||
# so it runs in ~30 seconds.
|
||||
#
|
||||
# On main pushes: tags both :main and :latest.
|
||||
# On releases: tags :<release_tag_name>.
|
||||
# ---------------------------------------------------------------------------
|
||||
merge:
|
||||
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
for digest_file in *; do
|
||||
args+=("${IMAGE_NAME}@sha256:${digest_file}")
|
||||
done
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:${TAG}" \
|
||||
"${args[@]}"
|
||||
else
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:main" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
"${args[@]}"
|
||||
fi
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${{ github.event.release.tag_name }}"
|
||||
else
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:main"
|
||||
fi
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
@@ -1,7 +1,11 @@
|
||||
name: Docs Site Checks
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'website/**'
|
||||
- '.github/workflows/docs-site-checks.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -10,28 +14,24 @@ jobs:
|
||||
docs-site-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- name: Install website dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
working-directory: website
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install ascii-guard
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
|
||||
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
@@ -14,7 +14,8 @@ name: History Check
|
||||
# the PR head and main to be non-empty.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -23,9 +24,9 @@ jobs:
|
||||
check-common-ancestor:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # full history both sides for merge-base
|
||||
fetch-depth: 0 # full history both sides for merge-base
|
||||
|
||||
- name: Reject PRs with no common ancestor on main
|
||||
run: |
|
||||
|
||||
+61
-17
@@ -9,12 +9,18 @@ name: Lint (ruff + ty)
|
||||
# enforcement fails.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
event_name:
|
||||
description: The event name from the calling orchestrator (pull_request or push).
|
||||
type: string
|
||||
required: true
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "website/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "website/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,7 +33,6 @@ concurrency:
|
||||
jobs:
|
||||
lint-diff:
|
||||
name: ruff + ty diff
|
||||
if: inputs.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -37,19 +42,19 @@ jobs:
|
||||
fetch-depth: 0 # need full history for merge-base + worktree
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Install ruff + ty
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv tool install ruff && uv tool install ty
|
||||
run: |
|
||||
uv tool install ruff
|
||||
uv tool install ty
|
||||
|
||||
- name: Determine base ref
|
||||
id: base
|
||||
run: |
|
||||
# For PRs, diff against the merge base with the target branch.
|
||||
# For pushes to main, diff against the previous commit on main.
|
||||
if [ "${{ inputs.event_name }}" = "pull_request" ]; then
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
||||
BASE_REF="origin/${{ github.base_ref }}"
|
||||
else
|
||||
@@ -105,10 +110,51 @@ jobs:
|
||||
--base-ty .lint-reports/base/ty.json \
|
||||
--head-ty .lint-reports/head/ty.json \
|
||||
--base-ref "${{ steps.base.outputs.ref }}" \
|
||||
--head-ref "${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
|
||||
--head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
|
||||
--output .lint-reports/summary.md
|
||||
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload reports as artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: lint-reports
|
||||
path: .lint-reports/
|
||||
retention-days: 14
|
||||
|
||||
- name: Post / update PR comment
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const body = fs.readFileSync('.lint-reports/summary.md', 'utf8');
|
||||
const marker = '<!-- lint-diff-summary -->';
|
||||
const fullBody = marker + '\n' + body;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: fullBody,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: fullBody,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
ruff-blocking:
|
||||
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
|
||||
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
|
||||
@@ -124,12 +170,10 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Install ruff
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv tool install ruff
|
||||
run: uv tool install ruff
|
||||
|
||||
- name: ruff check .
|
||||
# No --exit-zero, no || true. Exit code propagates to the job,
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
name: Nix Lockfile Fix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'package-lock.json'
|
||||
- 'package.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'apps/desktop/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to fix (leave empty to run on the selected branch)'
|
||||
required: false
|
||||
type: string
|
||||
issue_comment:
|
||||
types: [edited]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-lockfile-fix-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json.
|
||||
# Runs fix-lockfiles and pushes the hash update commit directly to main
|
||||
# so Nix builds never stay broken.
|
||||
#
|
||||
# Safety invariants:
|
||||
# 1. The fix commit only touches nix/*.nix files, which are NOT in
|
||||
# the paths filter above, so this cannot re-trigger itself.
|
||||
# 2. An explicit file-whitelist check before commit aborts if
|
||||
# fix-lockfiles ever modifies unexpected files.
|
||||
# 3. Job-level concurrency with cancel-in-progress: true ensures
|
||||
# back-to-back pushes collapse to the newest; ref: main checkout
|
||||
# always operates on the latest branch state.
|
||||
# 4. Uses a GitHub App token (not GITHUB_TOKEN) so the fix commit
|
||||
# triggers downstream nix.yml verification.
|
||||
auto-fix-main:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
concurrency:
|
||||
group: auto-fix-main
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@7bfa3a4717ef143a604ee0a99d859b8886a96d00 # v1.9.3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
run: nix run .#fix-lockfiles -- --apply
|
||||
|
||||
- name: Commit & push
|
||||
if: steps.apply.outputs.changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure only nix/lib.nix (home of the single npmDepsHash) was
|
||||
# modified — prevents accidental self-triggering if fix-lockfiles
|
||||
# ever touches package files.
|
||||
unexpected="$(git diff --name-only | grep -Ev '^nix/lib\.nix$' || true)"
|
||||
if [ -n "$unexpected" ]; then
|
||||
echo "::error::Unexpected modified files: $unexpected"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Record the base SHA before committing — used to detect package
|
||||
# file changes if we need to rebase after a non-fast-forward push.
|
||||
BASE_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git add nix/lib.nix
|
||||
git commit -m "fix(nix): auto-refresh npm lockfile hashes" \
|
||||
-m "Source: $GITHUB_SHA" \
|
||||
-m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
||||
|
||||
# Retry push with rebase in case main advanced with an unrelated
|
||||
# commit during the nix build. Without this, a non-fast-forward
|
||||
# rejection silently loses the fix. If package files changed during
|
||||
# the rebase, abort — a fresh auto-fix run will handle the new state.
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin HEAD:main; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::Push attempt $attempt failed (non-fast-forward?), rebasing…"
|
||||
git fetch origin main
|
||||
|
||||
# If package files changed between our base and the new main,
|
||||
# our computed hashes are stale. Abort and let the next triggered
|
||||
# run recompute from the correct package-lock state.
|
||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||
'package-lock.json' 'package.json' \
|
||||
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
|
||||
if [ -n "$pkg_changed" ]; then
|
||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git rebase origin/main
|
||||
done
|
||||
echo "::error::Failed to push after 3 rebase attempts"
|
||||
exit 1
|
||||
|
||||
# ── PR fix (manual / checkbox) ─────────────────────────────────────
|
||||
# Existing behavior: run on manual dispatch OR when a task-list
|
||||
# checkbox in the sticky lockfile-check comment flips from [ ] to [x].
|
||||
fix:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request != null
|
||||
&& contains(github.event.comment.body, '[x] **Apply lockfile fix**')
|
||||
&& !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Authorize & resolve PR
|
||||
id: resolve
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
// 1. Verify the actor has write access — applies to both checkbox
|
||||
// clicks and manual dispatch.
|
||||
const { data: perm } =
|
||||
await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.actor,
|
||||
});
|
||||
if (!['admin', 'write', 'maintain'].includes(perm.permission)) {
|
||||
core.setFailed(
|
||||
`${context.actor} lacks write access (has: ${perm.permission})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Resolve which ref to check out.
|
||||
let prNumber = '';
|
||||
if (context.eventName === 'issue_comment') {
|
||||
prNumber = String(context.payload.issue.number);
|
||||
} else if (context.eventName === 'workflow_dispatch') {
|
||||
prNumber = context.payload.inputs.pr_number || '';
|
||||
}
|
||||
|
||||
if (!prNumber) {
|
||||
core.setOutput('ref', context.ref.replace(/^refs\/heads\//, ''));
|
||||
core.setOutput('repo', context.repo.repo);
|
||||
core.setOutput('owner', context.repo.owner);
|
||||
core.setOutput('pr', '');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: Number(prNumber),
|
||||
});
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
core.setOutput('repo', pr.head.repo.name);
|
||||
core.setOutput('owner', pr.head.repo.owner.login);
|
||||
core.setOutput('pr', String(pr.number));
|
||||
|
||||
# Wipe the sticky lockfile-check comment to a "running" state as soon
|
||||
# as the job is authorized, so the user sees their click was picked up
|
||||
# before the ~minute of nix build work.
|
||||
- name: Mark sticky as running
|
||||
if: steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### 🔄 Applying lockfile fix…
|
||||
|
||||
Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
run: nix run .#fix-lockfiles
|
||||
|
||||
- name: Commit & push
|
||||
if: steps.apply.outputs.changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git add nix/lib.nix
|
||||
git commit -m "fix(nix): refresh npm lockfile hashes"
|
||||
git push
|
||||
|
||||
- name: Update sticky (applied)
|
||||
if: steps.apply.outputs.changed == 'true' && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ✅ Lockfile fix applied
|
||||
|
||||
Pushed a commit refreshing the npm lockfile hashes — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- name: Update sticky (already current)
|
||||
if: steps.apply.outputs.changed == 'false' && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ✅ Lockfile hashes already current
|
||||
|
||||
Nothing to commit — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- name: Update sticky (failed)
|
||||
if: failure() && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ❌ Lockfile fix failed
|
||||
|
||||
See the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for logs.
|
||||
@@ -0,0 +1,105 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Resolve head SHA
|
||||
if: github.event_name == 'pull_request'
|
||||
id: sha
|
||||
shell: bash
|
||||
run: |
|
||||
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "full=$FULL" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check flake
|
||||
id: flake
|
||||
continue-on-error: true
|
||||
run: nix flake check --print-build-logs
|
||||
|
||||
# When the flake check fails, run a targeted diagnostic to see if
|
||||
# the failure is specifically a stale npm lockfile hash in one of the
|
||||
# known npm subpackages (tui / web). This avoids surfacing a generic
|
||||
# "build failed" message when the fix is a single known command.
|
||||
- name: Diagnose npm lockfile hashes
|
||||
id: hash_check
|
||||
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
env:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
run: nix run .#fix-lockfiles -- --check
|
||||
|
||||
# If fix-lockfiles itself crashes (infrastructure blip, cache throttle,
|
||||
# etc.) it won't set stale=true/false. Treat that as a distinct failure
|
||||
# mode rather than silently ignoring it.
|
||||
- name: Fail if hash check crashed without reporting
|
||||
if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false'
|
||||
run: |
|
||||
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
|
||||
exit 1
|
||||
|
||||
- name: Post sticky PR comment (stale hashes)
|
||||
if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request'
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
message: |
|
||||
### ⚠️ npm lockfile hash out of date
|
||||
|
||||
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
|
||||
|
||||
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
|
||||
|
||||
${{ steps.hash_check.outputs.report }}
|
||||
|
||||
#### Apply the fix
|
||||
|
||||
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
|
||||
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
|
||||
- Or locally: `nix run .#fix-lockfiles` and commit the diff
|
||||
|
||||
# Clear the sticky comment when either the flake check passed outright (no
|
||||
# hash check needed) or the hash check explicitly returned stale=false
|
||||
# (check failed for a non-hash reason).
|
||||
- name: Clear sticky PR comment (resolved)
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
(steps.hash_check.outputs.stale == 'false' ||
|
||||
steps.flake.outcome == 'success')
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
delete: true
|
||||
|
||||
- name: Final fail if flake check failed
|
||||
if: steps.flake.outcome == 'failure'
|
||||
run: |
|
||||
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
|
||||
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
|
||||
else
|
||||
echo "::error::Nix flake check failed. See logs above."
|
||||
fi
|
||||
exit 1
|
||||
@@ -1,8 +1,8 @@
|
||||
name: OSV-Scanner
|
||||
|
||||
# Scans lockfiles (uv.lock, package-lock.json) against the OSV vulnerability
|
||||
# database. Runs on every PR/push (via the ci.yml orchestrator's workflow_call)
|
||||
# and on a weekly schedule against main.
|
||||
# database. Runs on every PR that touches a lockfile and on a weekly schedule
|
||||
# against main.
|
||||
#
|
||||
# This is detection-only — OSV-Scanner does NOT open PRs or modify pins.
|
||||
# It reports known CVEs in currently-pinned dependency versions so we can
|
||||
@@ -10,9 +10,9 @@ name: OSV-Scanner
|
||||
# (full SHA / exact version) is preserved; only the notification signal
|
||||
# is added.
|
||||
#
|
||||
# Complements the supply-chain-audit.yml workflow (which scans for malicious
|
||||
# code patterns in PR diffs) by covering the orthogonal "currently-pinned
|
||||
# dep became known-vulnerable" case.
|
||||
# Complements the existing supply-chain-audit.yml workflow (which scans
|
||||
# for malicious code patterns in PR diffs) by covering the orthogonal
|
||||
# "currently-pinned dep became known-vulnerable" case.
|
||||
#
|
||||
# Uses Google's officially-recommended reusable workflow, pinned by SHA.
|
||||
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
|
||||
@@ -20,11 +20,29 @@ name: OSV-Scanner
|
||||
# vulnerabilities in pinned deps that we may need to patch deliberately.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'uv.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'website/package.json'
|
||||
- 'website/package-lock.json'
|
||||
- '.github/workflows/osv-scanner.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'uv.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'website/package-lock.json'
|
||||
schedule:
|
||||
# Weekly scan against main — catches CVEs published after merge for
|
||||
# deps that haven't changed since.
|
||||
- cron: "0 9 * * 1"
|
||||
- cron: '0 9 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -36,7 +54,7 @@ permissions:
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan lockfiles
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
with:
|
||||
# Scan explicit lockfiles rather than recursing, so we only look at
|
||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||
|
||||
@@ -3,17 +3,17 @@ name: Build Skills Index
|
||||
on:
|
||||
schedule:
|
||||
# Run twice daily: 6 AM and 6 PM UTC
|
||||
- cron: "0 6,18 * * *"
|
||||
workflow_dispatch: # Manual trigger
|
||||
- cron: '0 6,18 * * *'
|
||||
workflow_dispatch: # Manual trigger
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/build_skills_index.py"
|
||||
- ".github/workflows/skills-index.yml"
|
||||
- 'scripts/build_skills_index.py'
|
||||
- '.github/workflows/skills-index.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # to trigger deploy-site.yml on schedule
|
||||
actions: write # to trigger deploy-site.yml on schedule
|
||||
|
||||
jobs:
|
||||
build-index:
|
||||
@@ -21,11 +21,11 @@ jobs:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install httpx==0.28.1 pyyaml==6.0.2
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
run: python scripts/build_skills_index.py
|
||||
|
||||
- name: Upload index artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: skills-index
|
||||
path: website/static/api/skills-index.json
|
||||
@@ -53,4 +53,4 @@ jobs:
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
name: Supply Chain Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
# No paths filter — the jobs must always run so required checks
|
||||
# report a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
# Narrow, high-signal scanner. Only fires on critical indicators of supply
|
||||
# chain attacks (e.g. the litellm-style payloads). Low-signal heuristics
|
||||
# (plain base64, plain exec/eval, dependency/Dockerfile/workflow edits,
|
||||
@@ -8,44 +19,50 @@ name: Supply Chain Audit
|
||||
# the scanner. Keep this file's checks ruthlessly narrow: if you find
|
||||
# yourself adding WARNING-tier patterns here again, make a separate
|
||||
# advisory-only workflow instead.
|
||||
#
|
||||
# Path-gating is handled centrally by the ``ci.yml`` orchestrator's
|
||||
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` /
|
||||
# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
|
||||
# inputs instead of re-computing the diff.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
event_name:
|
||||
description: The event name from the calling orchestrator.
|
||||
type: string
|
||||
required: true
|
||||
scan:
|
||||
description: Whether supply-chain-relevant files changed.
|
||||
type: boolean
|
||||
required: true
|
||||
deps:
|
||||
description: Whether pyproject.toml changed.
|
||||
type: boolean
|
||||
required: true
|
||||
mcp_catalog:
|
||||
description: Whether the MCP catalog / installer changed.
|
||||
type: boolean
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── Path filter (shared by both scan and dep-bounds) ───────────────
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
# True when any file the scanner cares about changed in this PR
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'*.py' '**/*.py' '*.pth' '**/*.pth' \
|
||||
'setup.py' 'setup.cfg' \
|
||||
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
|
||||
'pyproject.toml' || true)
|
||||
if [ -n "$SCAN_FILES" ]; then
|
||||
echo "scan=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "scan=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
|
||||
if [ -n "$DEPS_FILES" ]; then
|
||||
echo "deps=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
if: inputs.scan
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scan == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -84,7 +101,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# --- base64 decode + exec/eval on the same line (the litellm attack pattern) ---
|
||||
B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
|
||||
B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
|
||||
if [ -n "$B64_EXEC_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: base64 decode + exec/eval combo
|
||||
@@ -98,7 +115,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# --- subprocess with encoded/obfuscated command argument ---
|
||||
PROC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
|
||||
PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
|
||||
if [ -n "$PROC_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: subprocess with encoded/obfuscated command
|
||||
@@ -160,13 +177,27 @@ jobs:
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when scan was skipped (no relevant files changed).
|
||||
# This ensures the required check always gets a status.
|
||||
scan-gate:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.scan != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No supply-chain-relevant files changed, skipping scan."
|
||||
|
||||
dep-bounds:
|
||||
name: Check PyPI dependency upper bounds
|
||||
if: inputs.deps
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deps == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -212,7 +243,7 @@ jobs:
|
||||
$(cat /tmp/unbounded.txt)
|
||||
\`\`\`
|
||||
|
||||
**Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
|
||||
**Fix:** Add an upper bound, e.g. \`\"package>=1.2.0,<2\"\`
|
||||
|
||||
---
|
||||
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
|
||||
@@ -225,40 +256,15 @@ jobs:
|
||||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
mcp-catalog-review:
|
||||
name: MCP catalog security review
|
||||
if: inputs.mcp_catalog
|
||||
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
|
||||
# This ensures the required check always gets a status.
|
||||
dep-bounds-gate:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.deps != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require explicit MCP catalog review label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
|
||||
echo "MCP catalog review label present."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BODY="## ⚠️ MCP catalog security review required
|
||||
|
||||
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
|
||||
|
||||
A maintainer should verify:
|
||||
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
|
||||
- stdio transports do not use shell+egress/exfiltration payloads,
|
||||
- git install refs are pinned and bootstrap commands are minimal,
|
||||
- requested env vars/secrets match the upstream MCP's documented needs.
|
||||
|
||||
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
|
||||
|
||||
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
|
||||
exit 1
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
+63
-96
@@ -1,54 +1,43 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
slice_count:
|
||||
description: Number of parallel test slices
|
||||
type: number
|
||||
default: 8
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel in-progress runs for the same ref
|
||||
# Cancel in-progress runs for the same PR/branch
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
name: "Generate slices"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore duration cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations
|
||||
|
||||
- name: Generate test slices
|
||||
id: matrix
|
||||
run: |
|
||||
MATRIX=$(python3 scripts/run_tests_parallel.py --generate-slices ${{ inputs.slice_count }})
|
||||
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test:
|
||||
name: Run tests slice ${{ matrix.slice.index }}/${{ inputs.slice_count }}
|
||||
needs: generate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.generate.outputs.matrix) }}
|
||||
matrix:
|
||||
slice: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore duration cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
# Single stable key. main always overwrites, PRs always find it.
|
||||
key: test-durations
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
run: |
|
||||
@@ -56,7 +45,7 @@ jobs:
|
||||
RG_VERSION=15.1.0
|
||||
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
|
||||
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
|
||||
curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
|
||||
curl -sSfL -o "$RG_TARBALL" \
|
||||
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
|
||||
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
|
||||
tar -xzf "$RG_TARBALL"
|
||||
@@ -65,48 +54,44 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
# pyproject.toml or uv.lock changes. `uv sync` still runs every
|
||||
# time, but resolves from the warm cache instead of re-downloading
|
||||
# and re-building wheels.
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
# `uv sync --locked` installs the exact pinned set from uv.lock (and
|
||||
# fails if the lock is out of sync with pyproject.toml), giving a
|
||||
# reproducible env. It also creates .venv itself, so no separate
|
||||
# `uv venv` step is needed.
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
uv pip install -e ".[all,dev]"
|
||||
|
||||
- name: Minimize uv cache
|
||||
# Optimized for CI: prunes pre-built wheels that are cheap to
|
||||
# re-download, keeping the persisted cache small and fast to restore.
|
||||
run: uv cache prune --ci
|
||||
|
||||
- name: Run tests (slice ${{ matrix.slice.index }}/${{ inputs.slice_count }})
|
||||
# Per-file isolation via scripts/run_tests.sh: each test file runs
|
||||
# in its own freshly-spawned `python -m pytest <file>` subprocess
|
||||
- name: Run tests (slice ${{ matrix.slice }}/6)
|
||||
# Per-file isolation via scripts/run_tests_parallel.py: discovers
|
||||
# every test_*.py file under tests/ (excluding integration/ + e2e/),
|
||||
# then runs `python -m pytest <file>` in a freshly-spawned subprocess
|
||||
# with bounded parallelism. No xdist, no shared workers, no
|
||||
# module-level state leakage between files.
|
||||
#
|
||||
# File list is pre-computed by the generate job (--generate-slices)
|
||||
# which runs LPT distribution once and passes the file list to each
|
||||
# matrix job via --files. Previously each job re-discovered files and
|
||||
# re-ran LPT independently — redundant N times.
|
||||
# Why per-file (not per-test): per-test spawn cost (~250ms × 17k
|
||||
# tests = 70min CPU minimum) blew the wall-clock budget. Per-file
|
||||
# spawn (~250ms × ~850 files = ~3.5min) fits while still giving
|
||||
# every file a fresh interpreter — the only isolation boundary
|
||||
# that matters in practice (cross-file leakage was the original
|
||||
# flake source; intra-file is the test author's responsibility).
|
||||
#
|
||||
# Why drop xdist entirely: xdist's persistent workers accumulate
|
||||
# state across files, which is exactly the leakage we wanted to
|
||||
# fix. ThreadPoolExecutor + subprocess.run is ~60 lines and does
|
||||
# the job with cleaner semantics.
|
||||
#
|
||||
# Matrix slicing (--slice I/N): files are distributed across 6
|
||||
# jobs by cached duration (LPT algorithm) so each job gets
|
||||
# roughly equal wall time. Without a cache, files default to 2s
|
||||
# estimate and get split roughly evenly by count — still correct,
|
||||
# just not perfectly balanced.
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
scripts/run_tests.sh --files '${{ matrix.slice.files }}'
|
||||
python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6
|
||||
env:
|
||||
# Ensure tests don't accidentally call real APIs
|
||||
OPENROUTER_API_KEY: ""
|
||||
@@ -114,9 +99,9 @@ jobs:
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Upload per-slice durations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-durations-slice-${{ matrix.slice.index }}
|
||||
name: test-durations-slice-${{ matrix.slice }}
|
||||
path: test_durations.json
|
||||
retention-days: 1
|
||||
|
||||
@@ -124,11 +109,11 @@ jobs:
|
||||
# (including PRs) get balanced slicing.
|
||||
save-durations:
|
||||
needs: test
|
||||
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
|
||||
if: always() && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all slice durations
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-durations-slice-*
|
||||
path: durations
|
||||
@@ -148,17 +133,17 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Save merged duration cache
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations-${{ github.run_id }}
|
||||
key: test-durations
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
run: |
|
||||
@@ -166,7 +151,7 @@ jobs:
|
||||
RG_VERSION=15.1.0
|
||||
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
|
||||
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
|
||||
curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
|
||||
curl -sSfL -o "$RG_TARBALL" \
|
||||
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
|
||||
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
|
||||
tar -xzf "$RG_TARBALL"
|
||||
@@ -175,34 +160,16 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
# pyproject.toml or uv.lock changes. `uv sync` still runs every
|
||||
# time, but resolves from the warm cache instead of re-downloading
|
||||
# and re-building wheels.
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install dependencies
|
||||
# `uv sync --locked` installs the exact pinned set from uv.lock (and
|
||||
# fails if the lock is out of sync with pyproject.toml), giving a
|
||||
# reproducible env. It also creates .venv itself, so no separate
|
||||
# `uv venv` step is needed.
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
|
||||
- name: Minimize uv cache
|
||||
# Optimized for CI: prunes pre-built wheels that are cheap to
|
||||
# re-download, keeping the persisted cache small and fast to restore.
|
||||
run: uv cache prune --ci
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
uv pip install -e ".[all,dev]"
|
||||
|
||||
- name: Packaged-wheel i18n smoke test
|
||||
run: |
|
||||
@@ -216,4 +183,4 @@ jobs:
|
||||
env:
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
@@ -1,51 +0,0 @@
|
||||
# .github/workflows/typecheck.yml
|
||||
name: Typecheck
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Check TypeScript
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
package:
|
||||
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# --ignore-scripts: typecheck only needs the TS sources + type defs, not
|
||||
# native builds. Skipping install scripts drops node-pty's node-gyp
|
||||
# header fetch — the transient flake that killed this job pre-`tsc` — and
|
||||
# is faster. retry covers the remaining registry blips.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||
|
||||
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
|
||||
# which does NOT exercise Vite/Rolldown module resolution — so an
|
||||
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
|
||||
# longer exports "./react-shim") slips past typecheck and only explodes when
|
||||
# users build apps/desktop from source on install/update. Run the real
|
||||
# `vite build` here so that class of break fails in CI instead.
|
||||
desktop-build:
|
||||
name: Build desktop app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# Keep install scripts here: the production build may need node-pty's
|
||||
# native binary. retry handles the transient install-time fetch flakes.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
- run: npm run --prefix apps/desktop build
|
||||
@@ -5,11 +5,11 @@ name: Publish to PyPI
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
|
||||
- 'v20*' # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_tag:
|
||||
description: "Tag to publish (e.g. v2026.5.15). Must already exist."
|
||||
description: 'Tag to publish (e.g. v2026.5.15). Must already exist.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
name: Build distribution 📦
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# On workflow_dispatch, check out the confirmed tag.
|
||||
@@ -43,17 +43,17 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: '22'
|
||||
|
||||
- name: Build web dashboard
|
||||
run: cd web && npm ci && npm run build
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
run: uv build --sdist --wheel
|
||||
|
||||
- name: Upload distribution artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
@@ -94,17 +94,17 @@ jobs:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/hermes-agent
|
||||
permissions:
|
||||
id-token: write # OIDC trusted publishing
|
||||
id-token: write # OIDC trusted publishing
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -116,12 +116,12 @@ jobs:
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # attach assets to the existing release
|
||||
id-token: write # sigstore signing
|
||||
contents: write # attach assets to the existing release
|
||||
id-token: write # sigstore signing
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
|
||||
- name: Sign with Sigstore
|
||||
if: env.skip_sign != 'true'
|
||||
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
|
||||
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
|
||||
with:
|
||||
inputs: >-
|
||||
./dist/*.tar.gz
|
||||
|
||||
@@ -4,7 +4,7 @@ name: uv.lock check
|
||||
# that modify pyproject.toml without regenerating uv.lock (or vice versa)
|
||||
# must not merge, because the Docker build's `uv sync --frozen` step will
|
||||
# fail on a stale lockfile and we'd rather catch it here than in the
|
||||
# docker workflow on main.
|
||||
# docker-publish workflow on main.
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# IMPORTANT: this check runs against the MERGED state, not just your branch
|
||||
@@ -44,14 +44,25 @@ name: uv.lock check
|
||||
# the same way. Better to catch it here than after merge.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/uv-lockfile-check.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/uv-lockfile-check.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: uv-lockfile-check-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
@@ -60,10 +71,10 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
# `uv lock --check` re-resolves the project from pyproject.toml and
|
||||
# compares the result to uv.lock, exiting non-zero if they disagree.
|
||||
@@ -100,7 +111,7 @@ jobs:
|
||||
|
||||
This check is blocking because the Docker image build uses
|
||||
`uv sync --frozen --extra all`, which rejects stale lockfiles
|
||||
— catching it here avoids a ~15 min failed docker run
|
||||
— catching it here avoids a ~15 min failed docker-publish run
|
||||
on `main` post-merge.
|
||||
EOF
|
||||
echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first."
|
||||
|
||||
-20
@@ -5,7 +5,6 @@
|
||||
*.pyc*
|
||||
__pycache__/
|
||||
.venv/
|
||||
.venv
|
||||
.vscode/
|
||||
.env
|
||||
.env.local
|
||||
@@ -90,9 +89,6 @@ website/static/api/skills-index.json
|
||||
# every build).
|
||||
website/static/api/skills.json
|
||||
website/static/api/skills-meta.json
|
||||
# automation-blueprints-index.json is a build artifact emitted by
|
||||
# website/scripts/extract-automation-blueprints.py during prebuild.
|
||||
website/static/api/automation-blueprints-index.json
|
||||
models-dev-upstream/
|
||||
|
||||
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
|
||||
@@ -118,12 +114,6 @@ docs/superpowers/*
|
||||
# treat it as a local edit and autostash it on every run (#38529).
|
||||
.hermes-bootstrap-complete
|
||||
|
||||
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
|
||||
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
|
||||
# — ignore so `git status` stays clean and update's autostash skips them.
|
||||
.update-incomplete
|
||||
.update-incomplete.lock
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
@@ -133,13 +123,3 @@ scripts/out/
|
||||
# stores the published notes. They are not a build artifact and must never be
|
||||
# committed to the repo root. See the hermes-release skill.
|
||||
RELEASE_v*.md
|
||||
|
||||
# Desktop demo-run scratch output (hermes writes demo/*.txt during recorded
|
||||
# walkthroughs). Throwaway artifacts, never part of the app.
|
||||
apps/desktop/demo/
|
||||
|
||||
# PR infographics are rendered locally and embedded in PR descriptions via the
|
||||
# image-provider (fal.media) URL — they are NEVER committed to the repo. The
|
||||
# PR body is the archive. See the hermes-agent-dev skill's
|
||||
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
|
||||
infographic/
|
||||
|
||||
@@ -4,212 +4,6 @@ Instructions for AI coding assistants and developers working on the hermes-agent
|
||||
|
||||
**Never give up on the right solution.**
|
||||
|
||||
## What Hermes Is
|
||||
|
||||
Hermes is a personal AI agent that runs the same agent core across a CLI, a
|
||||
messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI,
|
||||
and an Electron desktop app. It learns across sessions (memory + skills),
|
||||
delegates to subagents, runs scheduled jobs, and drives a real terminal and
|
||||
browser. It is extended primarily through **plugins and skills**, not by
|
||||
growing the core.
|
||||
|
||||
Two properties shape almost every design decision and are the lens for
|
||||
reviewing any change:
|
||||
|
||||
- **Per-conversation prompt caching is sacred.** A long-lived conversation
|
||||
reuses a cached prefix every turn. Anything that mutates past context,
|
||||
swaps toolsets, or rebuilds the system prompt mid-conversation invalidates
|
||||
that cache and multiplies the user's cost. We do not do it (the one
|
||||
exception is context compression).
|
||||
- **The core is a narrow waist; capability lives at the edges.** Every model
|
||||
tool we add is sent on every API call, so the bar for a new *core* tool is
|
||||
high. Most new capability should arrive as a CLI command + skill, a
|
||||
service-gated tool, or a plugin — not as core surface.
|
||||
|
||||
## Contribution Rubric — What We Want / What We Don't
|
||||
|
||||
This is the project's intent layer. Use it two ways:
|
||||
|
||||
1. **For humans and for your own work** — what gets merged and what gets
|
||||
rejected, so a contribution aims at the target.
|
||||
2. **For automated review (the triage sweeper)** — guidance on when a PR is
|
||||
safe to close on the three allowed reasons (`implemented_on_main`,
|
||||
`cannot_reproduce`, `incoherent`) and, just as important, **when NOT to
|
||||
close** one. Taste-based "we don't want this / out of scope" closes are NOT
|
||||
an automated decision — those stay with a human maintainer. The sweeper's
|
||||
job here is to recognize design intent and *avoid wrongly closing a
|
||||
legitimate contribution*, not to make the won't-implement call itself.
|
||||
|
||||
Read the balance right: Hermes ships a **lot** — most merges are bug fixes to
|
||||
real reported behavior, and the product surface (platforms, channels,
|
||||
providers, models, desktop/TUI features) expands aggressively and on purpose.
|
||||
The restraint below is aimed squarely at the **core agent + the model tool
|
||||
schema**, the one place where every addition is paid for on every API call.
|
||||
"Smallest footprint" governs *how a capability is wired into the core*, NOT
|
||||
whether the product is allowed to grow. We are expansive at the edges and
|
||||
conservative at the waist.
|
||||
|
||||
### What we want
|
||||
|
||||
- **Fix real bugs, well.** The bulk of what lands is `fix(...)` against an
|
||||
actual reported symptom. A good fix reproduces the symptom on current
|
||||
`main`, points to the exact line where it manifests, and fixes the whole bug
|
||||
class — sibling call paths included — not just the one site the reporter hit.
|
||||
- **Expand reach at the edges.** New platform adapters, channels, providers,
|
||||
models, and desktop/TUI/dashboard features are welcome and land routinely,
|
||||
including large ones (a new messaging channel, a session-cap feature, a
|
||||
Windows PTY bridge). Breadth in the product is a goal, not a footprint
|
||||
concern — as long as it integrates with the existing setup/config UX
|
||||
(`hermes tools`, `hermes setup`, auto-install) rather than bolting on a raw
|
||||
env var.
|
||||
- **Refactor god-files into clean modules.** Extracting a multi-thousand-line
|
||||
cluster out of `cli.py` / `run_agent.py` / `gateway/run.py` into a focused
|
||||
mixin or module is wanted work, even when the diff is huge and mechanical
|
||||
(large `+N/-N` refactors merge regularly). The "every line traces to the
|
||||
request" test applies to *feature* PRs; a declared refactor's request IS the
|
||||
extraction.
|
||||
- **Keep the core narrow.** New *model tools* are the expensive exception —
|
||||
every tool ships on every API call. Prefer, in order: extend existing code →
|
||||
CLI command + skill → service-gated tool (`check_fn`) → plugin → MCP server
|
||||
in the catalog → new core tool (last resort). See "The Footprint Ladder."
|
||||
- **Extend, don't duplicate.** Before adding a module/manager/hook, check
|
||||
whether existing infrastructure already covers the use case. When several PRs
|
||||
integrate the same *category*, design one shared interface instead of merging
|
||||
them one at a time (see the ABC + orchestrator note under the Footprint
|
||||
Ladder).
|
||||
- **Behavior contracts over snapshots.** Tests should assert how two pieces of
|
||||
data must relate (invariants), not freeze a current value (model lists,
|
||||
config version literals, enumeration counts). See "Don't write
|
||||
change-detector tests."
|
||||
- **E2E validation, not just green unit mocks.** For anything touching
|
||||
resolution chains, config propagation, security boundaries, remote
|
||||
backends, or file/network I/O, exercise the real path with real imports
|
||||
against a temp `HERMES_HOME`. Mocks hide integration bugs.
|
||||
- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict
|
||||
message role alternation (never two same-role messages in a row; never a
|
||||
synthetic user message injected mid-loop), and a system prompt that is
|
||||
byte-stable for the life of a conversation.
|
||||
- **Contributor credit preserved.** Salvage external work by cherry-picking
|
||||
(rebase-merge) so authorship survives in git history; don't reimplement from
|
||||
scratch when you can build on top.
|
||||
|
||||
### What we don't want (rejected even when well-built)
|
||||
|
||||
- **Speculative infrastructure.** Hooks, callbacks, or extension points with no
|
||||
concrete consumer. Adding a hook is easy; removing one after plugins depend
|
||||
on it is hard. A hook is NOT speculative if a contributor has a real, stated
|
||||
use case — even if the consumer ships separately.
|
||||
- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets
|
||||
only (API keys, tokens, passwords). All behavioral settings — timeouts,
|
||||
thresholds, feature flags, display prefs — go in `config.yaml`. Bridge to an
|
||||
internal env var if the mechanism needs one, but user-facing docs point to
|
||||
`config.yaml`. Reject PRs that tell users to "set X in your .env" unless X
|
||||
is a credential.
|
||||
- **A new core tool when terminal + file already do the job, or when a skill
|
||||
would.** If the only barrier is file visibility on a remote backend, fix the
|
||||
mount, not the toolset.
|
||||
- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit`
|
||||
pagination on tools that load content the agent must read fully (skills,
|
||||
prompts, playbooks). Models will read page 1 and skip the rest.
|
||||
- **"Fixes" that destroy the feature they secure.** A mitigation that kills the
|
||||
feature's purpose is the wrong mitigation. Read the original commit's intent
|
||||
(`git log -p -S`) before restricting behavior; find a fix that preserves the
|
||||
feature.
|
||||
- **Outbound telemetry / usage attribution without opt-in gating.** No new
|
||||
analytics, third-party identifier tagging, or attribution tags until a
|
||||
generic user-facing opt-in (config gate + setup prompt + `hermes tools`
|
||||
toggle) exists. Park behind a label, do not merge.
|
||||
- **Change-detector tests, cache-breaking mid-conversation, dead code wired in
|
||||
without E2E proof, and plugins that touch core files.** Plugins live in their
|
||||
own directory and work within the ABCs/hooks we provide; if a plugin needs
|
||||
more, widen the generic plugin surface, don't special-case it in core.
|
||||
- **Third-party products / other people's projects integrated into the core
|
||||
tree.** Observability backends, vendor SaaS integrations, analytics dashboards,
|
||||
and similar "someone else's product" plugins do NOT land under `plugins/` in
|
||||
this repo. They place an ongoing maintenance burden on us to keep them working
|
||||
against a fast-moving core, for a backend we don't own. Ship them as a
|
||||
**standalone plugin repo** users install into `~/.hermes/plugins/` (or via a
|
||||
pip entry point), and promote them in the Nous Research Discord
|
||||
(`#plugins-skills-and-skins`). This is a coupling-and-maintenance decision, not
|
||||
a quality bar — the plugin can be excellent and still be a close. PRs that add
|
||||
such a directory to the tree are closed with a pointer to publish it as its own
|
||||
repo.
|
||||
|
||||
### Before you call it a bug — verify the premise (and when NOT to close)
|
||||
|
||||
The most common reason a well-written PR gets closed is not code quality — it
|
||||
is that the change is built on a **wrong premise**, or it treats an
|
||||
**intentional design as a gap**. These patterns cut both ways: they tell a
|
||||
human reviewer what to scrutinize, and they tell the automated sweeper when a
|
||||
PR is NOT safe to close as `implemented_on_main` / `cannot_reproduce` (when in
|
||||
doubt, leave it open for a human). They are distilled from real closes.
|
||||
|
||||
- **"Intentional design, not a gap."** A limitation that looks like an
|
||||
oversight is often deliberate. Before "fixing" a missing link or a
|
||||
restriction, ask whether the isolation IS the design. Example: profiles are
|
||||
independent islands on purpose — a PR adding live config inheritance from the
|
||||
default profile was closed because coupling profiles together is exactly what
|
||||
the design prevents (the copy-at-creation `--clone` path already covers the
|
||||
legitimate "start from my default" case). Read the original commit's intent
|
||||
(`git log -p -S "<symbol>"`) before assuming something is unfinished.
|
||||
- **"The premise doesn't hold against how X actually works."** A PR's
|
||||
justification frequently rests on a wrong mental model of an existing
|
||||
mechanism. Trace the real code/runtime before accepting the rationale. Two
|
||||
real closes: a rate-limit "re-probe during cooldown" PR (the breaker only
|
||||
trips on a *confirmed-empty* account bucket, so re-probing just hammers a
|
||||
bucket we've already proven empty); a usage-accumulation fix whose new branch
|
||||
**never executes at runtime** because an earlier guard already popped the
|
||||
state it depended on. If you can't point to the exact line where the bug
|
||||
manifests AND show the fix changes that line's behavior, you haven't verified
|
||||
the premise.
|
||||
- **"This fix was wrong — the absence/omission was deliberate."** Adding the
|
||||
obvious-looking missing piece can break things the omission was protecting.
|
||||
Example: restoring "missing" `__init__.py` files made a test tree importable
|
||||
as a dotted package that shadowed the real plugin, deleting its `register()`
|
||||
at import time. The absence was load-bearing.
|
||||
- **"Overreached / resurrected an approach we'd moved past."** Scope creep that
|
||||
supersedes an agreed-on base, or revives a direction the maintainers
|
||||
deliberately closed, gets rejected even when the code works. Keep the change
|
||||
to the narrow piece that was actually agreed; offer the rest as a focused
|
||||
follow-up.
|
||||
|
||||
The throughline: **verify the claim AND the intent against the codebase before
|
||||
writing or merging a fix.** A confirmed reproduction on current `main` plus a
|
||||
line-level account of where the fix acts beats a plausible-sounding rationale
|
||||
every time. When in doubt about intent, it is cheaper to ask than to ship a
|
||||
fix that fights the design.
|
||||
|
||||
### The Footprint Ladder (new capability decision)
|
||||
|
||||
Each rung adds more permanent surface than the one above. Choose the highest
|
||||
(least-footprint) rung that correctly solves the problem:
|
||||
|
||||
1. **Extend existing code** — the capability is a variation of something that
|
||||
already exists. Zero new surface.
|
||||
2. **CLI command + skill** — manages config/state/infra expressible as shell
|
||||
commands. The agent runs `hermes <subcommand>` guided by a skill. Zero
|
||||
model-tool footprint. Default choice for subscriptions, scheduled tasks,
|
||||
service setup. Examples: `hermes webhook`, `hermes cron`, `hermes tools`.
|
||||
3. **Service-gated tool (`check_fn`)** — needs structured params/returns AND
|
||||
only appears when a prerequisite is configured. Zero footprint otherwise.
|
||||
Examples: Home Assistant tools (gated on token), memory-provider tools.
|
||||
4. **Plugin** — third-party/niche/user-specific capability that doesn't ship in
|
||||
core. Lives in `~/.hermes/plugins/` or a pip package, discovered at runtime.
|
||||
5. **MCP server (in the catalog)** — if the capability genuinely needs to be a
|
||||
tool (structured I/O the agent invokes) but isn't core-fundamental, prefer
|
||||
building it as an MCP server and adding it to the MCP catalog over growing
|
||||
the core toolset. The agent connects to it through the built-in MCP client;
|
||||
zero permanent core-schema footprint, and it's reusable by any MCP host.
|
||||
6. **New core tool** — only when the capability is fundamental, broadly useful
|
||||
to nearly every user, and unreachable via terminal + file (or an MCP server).
|
||||
Examples of correct core tools: terminal, read_file, web_search,
|
||||
browser_navigate.
|
||||
|
||||
When 3+ open PRs try to integrate the same *category* of thing (memory
|
||||
backends, providers, notifiers), don't merge them one at a time — design an
|
||||
ABC + orchestrator, wrap the existing built-in as the first provider, and turn
|
||||
the competing PRs into plugins against that interface.
|
||||
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
@@ -470,7 +264,7 @@ npm install # first time
|
||||
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
|
||||
npm start # production
|
||||
npm run build # full build (hermes-ink + tsc)
|
||||
npm run typecheck # typecheck only (tsc --noEmit)
|
||||
npm run type-check # typecheck only (tsc --noEmit)
|
||||
npm run lint # eslint
|
||||
npm run fmt # prettier
|
||||
npm test # vitest
|
||||
@@ -491,7 +285,7 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
|
||||
|
||||
### Electron Desktop Chat App (`apps/desktop/`)
|
||||
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
|
||||
|
||||
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
|
||||
|
||||
@@ -508,11 +302,9 @@ A **separate** chat surface from both the classic CLI and the dashboard's embedd
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
Before adding any tool, settle the footprint question first (see "The
|
||||
Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
|
||||
be core tools. For custom or local-only tools, do **not** edit Hermes core.
|
||||
Use the plugin route instead: create `~/.hermes/plugins/<name>/plugin.yaml`
|
||||
and `~/.hermes/plugins/<name>/__init__.py`, then register tools with
|
||||
For most custom or local-only tools, do **not** edit Hermes core. Use the plugin
|
||||
route instead: create `~/.hermes/plugins/<name>/plugin.yaml` and
|
||||
`~/.hermes/plugins/<name>/__init__.py`, then register tools with
|
||||
`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
|
||||
enabled or disabled without touching `tools/` or `toolsets.py`.
|
||||
|
||||
@@ -794,24 +586,6 @@ landing in this tree. PRs that add a new directory under
|
||||
provider as its own repo. Existing in-tree providers stay; bug fixes
|
||||
to them are welcome.
|
||||
|
||||
**No new third-party-product plugins in-tree (policy, June 2026):** the
|
||||
same rule applies beyond memory providers. Plugins that integrate
|
||||
someone else's product or project — observability/metrics backends,
|
||||
vendor SaaS connectors, analytics dashboards, paid-service tie-ins —
|
||||
must ship as **standalone plugin repos** that users install into
|
||||
`~/.hermes/plugins/` (or via pip entry points). They register through
|
||||
the existing plugin discovery path and use the ABCs/hooks/ctx surface
|
||||
we expose; nothing special is needed in core. The reason is
|
||||
maintenance load: every product we absorb into the tree becomes our
|
||||
burden to keep working against a fast-moving core, for a backend we
|
||||
don't own. Promote standalone plugins in the Nous Research Discord
|
||||
(`#plugins-skills-and-skins`). PRs that add such a directory under
|
||||
`plugins/` are closed with a pointer to publish it as its own repo —
|
||||
this is a coupling decision, not a quality judgment. (The
|
||||
`observability/`, `kanban/`, `disk-cleanup/`, etc. directories already
|
||||
in the tree are existing precedent, not an invitation to add more
|
||||
third-party-product plugins alongside them.)
|
||||
|
||||
### Model-provider plugins (`plugins/model-providers/<name>/`)
|
||||
|
||||
Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …)
|
||||
@@ -983,10 +757,9 @@ Enable/disable per platform via `hermes tools` (the curses UI) or the
|
||||
## Delegation (`delegate_task`)
|
||||
|
||||
`tools/delegate_tool.py` spawns a subagent with an isolated
|
||||
context + terminal session. By default the parent waits for the
|
||||
child's summary before continuing its own loop. With `background=true`,
|
||||
Hermes returns a delegation id immediately and the result re-enters the
|
||||
conversation later through the async-delegation completion queue.
|
||||
context + terminal session. Synchronous: the parent waits for the
|
||||
child's summary before continuing its own loop — if the parent is
|
||||
interrupted, the child is cancelled.
|
||||
|
||||
Two shapes:
|
||||
|
||||
@@ -1008,9 +781,9 @@ Key config knobs (under `delegation:` in `config.yaml`):
|
||||
`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
|
||||
`max_iterations`.
|
||||
|
||||
Durability rule: background `delegate_task` is detached from the current
|
||||
turn but still process-local. For work that must survive process restart, use
|
||||
`cronjob` or `terminal(background=True, notify_on_complete=True)` instead.
|
||||
Synchronicity rule: delegate_task is **not** durable. For long-running
|
||||
work that must outlive the current turn, use `cronjob` or
|
||||
`terminal(background=True, notify_on_complete=True)` instead.
|
||||
|
||||
---
|
||||
|
||||
@@ -1204,7 +977,7 @@ automatically scope to the active profile.
|
||||
a unique credential (bot token, API key), call `acquire_scoped_lock()` from
|
||||
`gateway.status` in the `connect()`/`start()` method and `release_scoped_lock()` in
|
||||
`disconnect()`/`stop()`. This prevents two profiles from using the same credential.
|
||||
See `plugins/platforms/irc/adapter.py` for the canonical pattern.
|
||||
See `gateway/platforms/telegram.py` for the canonical pattern.
|
||||
|
||||
6. **Profile operations are HOME-anchored, not HERMES_HOME-anchored** — `_get_profiles_root()`
|
||||
returns `Path.home() / ".hermes" / "profiles"`, NOT `get_hermes_home() / "profiles"`.
|
||||
@@ -1289,22 +1062,65 @@ scripts/run_tests.sh # full suite, CI-parity
|
||||
scripts/run_tests.sh tests/gateway/ # one directory
|
||||
scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
|
||||
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
|
||||
scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging)
|
||||
```
|
||||
|
||||
### Subprocess-per-test-file isolation
|
||||
### Subprocess-per-test isolation
|
||||
|
||||
Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
|
||||
ContextVars from one test file cannot leak into the next.
|
||||
Every test runs in a freshly-spawned Python subprocess via the in-tree plugin
|
||||
at `tests/_isolate_plugin.py`. This means module-level dicts/sets and
|
||||
ContextVars from one test cannot leak into the next — the historic
|
||||
`_reset_module_state` autouse fixture is gone.
|
||||
|
||||
### Why the wrapper
|
||||
Implementation notes:
|
||||
|
||||
| | Without wrapper | With wrapper |
|
||||
| ------------------- | ------------------------------------------- | ----------------------------------------- |
|
||||
| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. |
|
||||
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
|
||||
| Timezone | Local TZ (PDT etc.) | UTC |
|
||||
| Locale | Whatever is set | C.UTF-8 |
|
||||
- The plugin uses `multiprocessing.get_context("spawn")`, which works on
|
||||
Linux, macOS, and Windows alike (POSIX `fork` is not used).
|
||||
- Per-test overhead is ~0.5–1.0s (Python startup + pytest collection). xdist
|
||||
parallelism amortizes this across cores; on a 20-core box the full suite
|
||||
finishes in roughly the same wall time as before, but flake-free.
|
||||
- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s.
|
||||
Hangs are killed and surfaced as a failure report.
|
||||
- Pass `--no-isolate` to disable isolation — useful when debugging a single
|
||||
test interactively, or when you specifically want to verify state leakage.
|
||||
- The plugin disables itself in child processes (sentinel envvar
|
||||
`HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk.
|
||||
|
||||
### Why the wrapper (and why the old "just call pytest" doesn't work)
|
||||
|
||||
Five real sources of local-vs-CI drift the script closes:
|
||||
|
||||
| | Without wrapper | With wrapper |
|
||||
|---|---|---|
|
||||
| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. unset |
|
||||
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
|
||||
| Timezone | Local TZ (PDT etc.) | UTC |
|
||||
| Locale | Whatever is set | C.UTF-8 |
|
||||
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
|
||||
|
||||
`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
|
||||
invocation (including IDE integrations) gets hermetic behavior — but the wrapper
|
||||
is belt-and-suspenders.
|
||||
|
||||
### Running without the wrapper (only if you must)
|
||||
|
||||
If you can't use the wrapper (e.g. inside an IDE that shells pytest directly),
|
||||
at minimum activate the venv. The isolation plugin loads automatically from
|
||||
`addopts` in `pyproject.toml`, so you get the same per-test process isolation
|
||||
either way.
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate # or: source venv/bin/activate
|
||||
python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
If you need to bypass isolation for fast feedback while debugging:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/agent/test_foo.py -q --no-isolate
|
||||
```
|
||||
|
||||
Always run the full suite before pushing changes.
|
||||
|
||||
### Don't write change-detector tests
|
||||
|
||||
|
||||
@@ -1,602 +0,0 @@
|
||||
# Contribuir a Hermes Agent
|
||||
|
||||
¡Gracias por contribuir a Hermes Agent! Esta guía cubre todo lo que necesitas: configurar tu entorno de desarrollo, entender la arquitectura, decidir qué construir y conseguir que tu PR sea aceptado.
|
||||
|
||||
---
|
||||
|
||||
## Prioridades de Contribución
|
||||
|
||||
Valoramos las contribuciones en este orden:
|
||||
|
||||
1. **Correcciones de errores** — bloqueos, comportamiento incorrecto, pérdida de datos. Siempre la máxima prioridad.
|
||||
2. **Compatibilidad entre plataformas** — macOS, diferentes distribuciones de Linux y WSL2 en Windows. Queremos que Hermes funcione en todas partes.
|
||||
3. **Fortalecimiento de seguridad** — inyección de shell, inyección de prompts, traversal de rutas, escalada de privilegios. Ver [Consideraciones de Seguridad](#consideraciones-de-seguridad).
|
||||
4. **Rendimiento y robustez** — lógica de reintento, manejo de errores, degradación elegante.
|
||||
5. **Nuevas habilidades** — pero solo las ampliamente útiles. Ver [¿Debería ser una Habilidad o una Herramienta?](#debería-ser-una-habilidad-o-una-herramienta)
|
||||
6. **Nuevas herramientas** — raramente necesarias. La mayoría de las capacidades deberían ser habilidades. Ver más abajo.
|
||||
7. **Documentación** — correcciones, aclaraciones, nuevos ejemplos.
|
||||
|
||||
---
|
||||
|
||||
## ¿Debería ser una Habilidad o una Herramienta?
|
||||
|
||||
Esta es la pregunta más común para los nuevos colaboradores. La respuesta casi siempre es **habilidad**.
|
||||
|
||||
### Hazlo una Habilidad cuando:
|
||||
|
||||
- La capacidad se puede expresar como instrucciones + comandos de shell + herramientas existentes
|
||||
- Envuelve una CLI externa o API que el agente puede llamar a través de `terminal` o `web_extract`
|
||||
- No necesita integración personalizada de Python ni gestión de claves API integrada en el agente
|
||||
- Ejemplos: búsqueda en arXiv, flujos de trabajo de git, gestión de Docker, procesamiento de PDF, email a través de herramientas CLI
|
||||
|
||||
### Hazlo una Herramienta cuando:
|
||||
|
||||
- Requiere integración de extremo a extremo con claves API, flujos de autenticación o configuración de múltiples componentes gestionada por el harness del agente
|
||||
- Necesita lógica de procesamiento personalizada que debe ejecutarse con precisión en cada ocasión (no "mejor esfuerzo" de la interpretación del LLM)
|
||||
- Maneja datos binarios, streaming o eventos en tiempo real que no pueden pasar por el terminal
|
||||
- Ejemplos: automatización de navegador (gestión de sesiones Browserbase), TTS (codificación de audio + entrega en plataforma), análisis de visión (manejo de imágenes base64)
|
||||
|
||||
### ¿Debería la Habilidad estar incluida?
|
||||
|
||||
Las habilidades incluidas (en `skills/`) se envían con cada instalación de Hermes. Deben ser **ampliamente útiles para la mayoría de los usuarios**:
|
||||
|
||||
- Manejo de documentos, investigación web, flujos de trabajo de desarrollo comunes, administración de sistemas
|
||||
- Usadas regularmente por una amplia gama de personas
|
||||
|
||||
Si tu habilidad es oficial y útil pero no universalmente necesaria (ej., una integración de servicio de pago, una dependencia pesada), ponla en **`optional-skills/`** — se envía con el repositorio pero no está activada por defecto. Los usuarios pueden descubrirla a través de `hermes skills browse` (etiquetada como "oficial") e instalarla con `hermes skills install` (sin advertencia de terceros, confianza integrada).
|
||||
|
||||
Si tu habilidad es especializada, contribuida por la comunidad o de nicho, es mejor para un **Skills Hub** — súbela a un registro de habilidades y compártela en el [Discord de Nous Research](https://discord.gg/NousResearch). Los usuarios pueden instalarla con `hermes skills install`.
|
||||
|
||||
---
|
||||
|
||||
## Proveedores de Memoria: Publicar como Plugin Independiente
|
||||
|
||||
**Ya no aceptamos nuevos proveedores de memoria en este repositorio.** El conjunto de proveedores integrados en `plugins/memory/` (honcho, mem0, supermemory, byterover, hindsight, holographic, openviking, retaindb) está cerrado. Si quieres añadir un nuevo backend de memoria, publícalo como un **repositorio de plugin independiente** que los usuarios instalen en `~/.hermes/plugins/` (o a través de un entry point de pip).
|
||||
|
||||
Los plugins de memoria independientes:
|
||||
|
||||
- Implementan el mismo ABC `MemoryProvider` (`agent/memory_provider.py`) — `sync_turn`, `prefetch`, `shutdown` y opcionalmente `post_setup(hermes_home, config)` para integración con el asistente de configuración
|
||||
- Usan el mismo sistema de descubrimiento — `discover_memory_providers()` los recoge desde directorios de plugins de usuario/proyecto y entry points de pip
|
||||
- Se integran con `hermes memory setup` a través de `post_setup()` — sin necesidad de tocar el código base
|
||||
- Pueden registrar sus propios subcomandos CLI a través de `register_cli(subparser)` en un archivo `cli.py`
|
||||
- Obtienen todos los mismos hooks de ciclo de vida y plomería de configuración que los proveedores incluidos en el árbol
|
||||
|
||||
Los PRs que añadan un nuevo directorio bajo `plugins/memory/` serán cerrados con un puntero para publicar el proveedor como su propio repositorio. Los proveedores en árbol existentes se mantienen; las correcciones de errores para ellos son bienvenidas.
|
||||
|
||||
Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimiento. Los proveedores de memoria son el tipo de plugin más común y no deberían vivir todos en este árbol.
|
||||
|
||||
---
|
||||
|
||||
## Configuración del Desarrollo
|
||||
|
||||
### Prerequisitos
|
||||
|
||||
| Requisito | Notas |
|
||||
|-----------|-------|
|
||||
| **Git** | Con la extensión `git-lfs` instalada |
|
||||
| **Python 3.11+** | uv lo instalará si falta |
|
||||
| **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) |
|
||||
|
||||
### Clonar e instalar
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
|
||||
# Crear venv con Python 3.11
|
||||
uv venv venv --python 3.11
|
||||
export VIRTUAL_ENV="$(pwd)/venv"
|
||||
|
||||
# Instalar con todos los extras (mensajería, cron, menús CLI, herramientas de desarrollo)
|
||||
uv pip install -e ".[all,dev]"
|
||||
|
||||
# Opcional: herramientas de navegador
|
||||
npm install
|
||||
```
|
||||
|
||||
### Configurar para desarrollo
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/{cron,sessions,logs,memories,skills}
|
||||
cp cli-config.yaml.example ~/.hermes/config.yaml
|
||||
touch ~/.hermes/.env
|
||||
|
||||
# Añadir al menos una clave de proveedor LLM:
|
||||
echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
### Ejecutar
|
||||
|
||||
```bash
|
||||
# Enlace simbólico para acceso global
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
|
||||
|
||||
# Verificar
|
||||
hermes doctor
|
||||
hermes chat -q "Hola"
|
||||
```
|
||||
|
||||
### Ejecutar tests
|
||||
|
||||
```bash
|
||||
# Preferido — coincide con CI (entorno hermético, 4 workers xdist); ver AGENTS.md
|
||||
scripts/run_tests.sh
|
||||
|
||||
# Alternativa (activa el venv primero). El wrapper sigue recomendándose
|
||||
# para paridad con GitHub Actions antes de abrir un PR:
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
hermes-agent/
|
||||
├── run_agent.py # Clase AIAgent — bucle de conversación central, despacho de herramientas, persistencia de sesión
|
||||
├── cli.py # Clase HermesCLI — TUI interactiva, integración prompt_toolkit
|
||||
├── model_tools.py # Orquestación de herramientas (capa delgada sobre tools/registry.py)
|
||||
├── toolsets.py # Agrupaciones y presets de herramientas (hermes-cli, hermes-telegram, etc.)
|
||||
├── hermes_state.py # Base de datos de sesiones SQLite con búsqueda de texto completo FTS5, títulos de sesión
|
||||
├── batch_runner.py # Procesamiento en lote paralelo para generación de trayectorias
|
||||
│
|
||||
├── agent/ # Internos del agente (módulos extraídos)
|
||||
│ ├── prompt_builder.py # Ensamblaje del prompt del sistema (identidad, habilidades, archivos de contexto, memoria)
|
||||
│ ├── context_compressor.py # Auto-resumición al acercarse a los límites de contexto
|
||||
│ ├── auxiliary_client.py # Resuelve clientes OpenAI auxiliares (resumición, visión)
|
||||
│ ├── display.py # KawaiiSpinner, formateo del progreso de herramientas
|
||||
│ ├── model_metadata.py # Longitudes de contexto del modelo, estimación de tokens
|
||||
│ └── trajectory.py # Ayudantes para guardar trayectorias
|
||||
│
|
||||
├── hermes_cli/ # Implementaciones de comandos CLI
|
||||
│ ├── main.py # Punto de entrada, análisis de argumentos, despacho de comandos
|
||||
│ ├── config.py # Gestión de configuración, migración, definiciones de variables de entorno
|
||||
│ ├── setup.py # Asistente de configuración interactivo
|
||||
│ ├── auth.py # Resolución de proveedor, OAuth, Nous Portal
|
||||
│ ├── models.py # Listas de selección de modelos de OpenRouter
|
||||
│ ├── banner.py # Banner de bienvenida, arte ASCII
|
||||
│ ├── commands.py # Registro central de comandos de barra (CommandDef), autocompletado, ayudantes del gateway
|
||||
│ ├── callbacks.py # Callbacks interactivos (aclarar, sudo, aprobación)
|
||||
│ ├── doctor.py # Diagnósticos
|
||||
│ ├── skills_hub.py # CLI del Skills Hub + comando de barra /skills
|
||||
│ └── skin_engine.py # Motor de skins/temas — personalización visual de CLI basada en datos
|
||||
│
|
||||
├── tools/ # Implementaciones de herramientas (auto-registradas)
|
||||
│ ├── registry.py # Registro central de herramientas (esquemas, manejadores, despacho)
|
||||
│ ├── approval.py # Detección de comandos peligrosos + aprobación por sesión
|
||||
│ ├── terminal_tool.py # Orquestación del terminal (sudo, ciclo de vida del entorno, backends)
|
||||
│ ├── file_operations.py # read_file, write_file, búsqueda, patch, etc.
|
||||
│ ├── web_tools.py # web_search, web_extract (Paralelo/Firecrawl + resumición Gemini)
|
||||
│ ├── vision_tools.py # Análisis de imágenes a través de modelos multimodales
|
||||
│ ├── delegate_tool.py # Lanzamiento de subagentes y ejecución paralela de tareas
|
||||
│ ├── code_execution_tool.py # Python sandboxado con acceso a herramientas vía RPC
|
||||
│ ├── session_search_tool.py # Búsqueda en conversaciones pasadas con FTS5 + ventanas ancladas
|
||||
│ ├── cronjob_tools.py # Gestión de tareas programadas
|
||||
│ ├── skill_tools.py # Búsqueda, carga y gestión de habilidades
|
||||
│ └── environments/ # Backends de ejecución del terminal
|
||||
│ ├── base.py # ABC BaseEnvironment
|
||||
│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py
|
||||
│
|
||||
├── gateway/ # Gateway de mensajería
|
||||
│ ├── run.py # GatewayRunner — ciclo de vida de plataformas, enrutamiento de mensajes, cron
|
||||
│ ├── config.py # Resolución de configuración de plataformas
|
||||
│ ├── session.py # Almacén de sesiones, prompts de contexto, políticas de reset
|
||||
│ └── platforms/ # Adaptadores de plataformas
|
||||
│ ├── telegram.py, discord_adapter.py, slack.py, whatsapp.py
|
||||
│
|
||||
├── scripts/ # Scripts del instalador y puente
|
||||
│ ├── install.sh # Instalador Linux/macOS
|
||||
│ ├── install.ps1 # Instalador Windows PowerShell
|
||||
│ └── whatsapp-bridge/ # Puente WhatsApp Node.js (Baileys)
|
||||
│
|
||||
├── skills/ # Habilidades incluidas (copiadas a ~/.hermes/skills/ en la instalación)
|
||||
├── optional-skills/ # Habilidades opcionales oficiales (descubribles vía hub, no activadas por defecto)
|
||||
├── tests/ # Suite de tests
|
||||
├── website/ # Sitio de documentación (hermes-agent.nousresearch.com)
|
||||
│
|
||||
├── cli-config.yaml.example # Configuración de ejemplo (copiada a ~/.hermes/config.yaml)
|
||||
└── AGENTS.md # Guía de desarrollo para asistentes de codificación IA
|
||||
```
|
||||
|
||||
### Configuración del usuario (almacenada en `~/.hermes/`)
|
||||
|
||||
| Ruta | Propósito |
|
||||
|------|-----------|
|
||||
| `~/.hermes/config.yaml` | Configuración (modelo, terminal, toolsets, compresión, etc.) |
|
||||
| `~/.hermes/.env` | Claves API y secretos |
|
||||
| `~/.hermes/auth.json` | Credenciales OAuth (Nous Portal) |
|
||||
| `~/.hermes/skills/` | Todas las habilidades activas (incluidas + instaladas desde hub + creadas por el agente) |
|
||||
| `~/.hermes/memories/` | Memoria persistente (MEMORY.md, USER.md) |
|
||||
| `~/.hermes/state.db` | Base de datos de sesiones SQLite |
|
||||
| `~/.hermes/sessions/` | Índice de enrutamiento del gateway (`sessions.json`), migas de pan de solicitudes, transcripciones `*.jsonl` del gateway y (opcionalmente) snapshots JSON por sesión cuando `sessions.write_json_snapshots: true` está configurado. Los snapshots por sesión están desactivados por defecto; state.db es canónica. |
|
||||
| `~/.hermes/cron/` | Datos de trabajos programados |
|
||||
| `~/.hermes/whatsapp/session/` | Credenciales del puente WhatsApp |
|
||||
|
||||
---
|
||||
|
||||
## Descripción General de la Arquitectura
|
||||
|
||||
### Bucle Central
|
||||
|
||||
```
|
||||
Mensaje del usuario → AIAgent._run_agent_loop()
|
||||
├── Construir prompt del sistema (prompt_builder.py)
|
||||
├── Construir kwargs de API (modelo, mensajes, herramientas, configuración de razonamiento)
|
||||
├── Llamar al LLM (API compatible con OpenAI)
|
||||
├── Si tool_calls en la respuesta:
|
||||
│ ├── Ejecutar cada herramienta a través del despacho del registro
|
||||
│ ├── Añadir resultados de herramientas a la conversación
|
||||
│ └── Volver a la llamada al LLM
|
||||
├── Si respuesta de texto:
|
||||
│ ├── Persistir sesión en DB
|
||||
│ └── Devolver final_response
|
||||
└── Compresión de contexto si se acerca al límite de tokens
|
||||
```
|
||||
|
||||
### Patrones de Diseño Clave
|
||||
|
||||
- **Herramientas auto-registradas**: Cada archivo de herramienta llama a `registry.register()` en el momento de importación. `model_tools.py` activa el descubrimiento importando todos los módulos de herramientas.
|
||||
- **Agrupación en toolsets**: Las herramientas se agrupan en toolsets (`web`, `terminal`, `file`, `browser`, etc.) que pueden habilitarse/deshabilitarse por plataforma.
|
||||
- **Persistencia de sesión**: Todas las conversaciones se almacenan en SQLite (`hermes_state.py`) con búsqueda de texto completo y títulos de sesión únicos.
|
||||
- **Inyección efímera**: Los prompts del sistema y los mensajes de relleno se inyectan en el momento de la llamada API, nunca se persisten en la base de datos ni en los logs.
|
||||
- **Abstracción de proveedor**: El agente funciona con cualquier API compatible con OpenAI. La resolución del proveedor ocurre en el momento de la inicialización.
|
||||
- **Enrutamiento de proveedor**: Al usar OpenRouter, `provider_routing` en config.yaml controla la selección del proveedor.
|
||||
|
||||
---
|
||||
|
||||
## Estilo de Código
|
||||
|
||||
- **PEP 8** con excepciones prácticas (no imponemos longitud de línea estricta)
|
||||
- **Comentarios**: Solo cuando se explica la intención no obvia, compromisos o peculiaridades de API. No narres lo que hace el código
|
||||
- **Manejo de errores**: Captura excepciones específicas. Registra con `logger.warning()`/`logger.error()` — usa `exc_info=True` para errores inesperados
|
||||
- **Multiplataforma**: Nunca asumas Unix. Ver [Compatibilidad Multiplataforma](#compatibilidad-multiplataforma)
|
||||
|
||||
---
|
||||
|
||||
## Añadir una Nueva Herramienta
|
||||
|
||||
Antes de escribir una herramienta, pregúntate: [¿debería ser una habilidad en su lugar?](#debería-ser-una-habilidad-o-una-herramienta)
|
||||
|
||||
Las herramientas se auto-registran en el registro central. Cada archivo de herramienta co-localiza su esquema, manejador y registro:
|
||||
|
||||
```python
|
||||
"""my_tool — Breve descripción de lo que hace esta herramienta."""
|
||||
|
||||
import json
|
||||
from tools.registry import registry
|
||||
|
||||
|
||||
def my_tool(param1: str, param2: int = 10, **kwargs) -> str:
|
||||
"""Manejador. Devuelve un resultado en cadena (a menudo JSON)."""
|
||||
result = do_work(param1, param2)
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
MY_TOOL_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "my_tool",
|
||||
"description": "Qué hace esta herramienta y cuándo debería usarla el agente.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"param1": {"type": "string", "description": "Qué es param1"},
|
||||
"param2": {"type": "integer", "description": "Qué es param2", "default": 10},
|
||||
},
|
||||
"required": ["param1"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _check_requirements() -> bool:
|
||||
"""Devuelve True si las dependencias de esta herramienta están disponibles."""
|
||||
return True
|
||||
|
||||
|
||||
registry.register(
|
||||
name="my_tool",
|
||||
toolset="my_toolset",
|
||||
schema=MY_TOOL_SCHEMA,
|
||||
handler=lambda args, **kw: my_tool(**args, **kw),
|
||||
check_fn=_check_requirements,
|
||||
)
|
||||
```
|
||||
|
||||
**Conectar a un toolset (requerido):** Las herramientas integradas se auto-descubren: cualquier
|
||||
archivo `tools/*.py` que contenga una llamada de nivel superior `registry.register(...)` es
|
||||
importado por `discover_builtin_tools()` en `tools/registry.py` cuando `model_tools`
|
||||
se carga. **No** hay una lista de importaciones manual en `model_tools.py` que mantener.
|
||||
|
||||
Todavía debes añadir el nombre de la herramienta a la lista apropiada en `toolsets.py`
|
||||
(por ejemplo `_HERMES_CORE_TOOLS` o un toolset dedicado); de lo contrario la herramienta
|
||||
se registra pero nunca se expone al agente.
|
||||
|
||||
Consulta `AGENTS.md` (sección **Adding New Tools**) para rutas conscientes del perfil y
|
||||
orientación sobre plugins vs. núcleo.
|
||||
|
||||
---
|
||||
|
||||
## Añadir una Habilidad
|
||||
|
||||
Las habilidades incluidas viven en `skills/` organizadas por categoría. Las habilidades opcionales oficiales usan la misma estructura en `optional-skills/`:
|
||||
|
||||
```
|
||||
skills/
|
||||
├── research/
|
||||
│ └── arxiv/
|
||||
│ ├── SKILL.md # Requerido: instrucciones principales
|
||||
│ └── scripts/ # Opcional: scripts auxiliares
|
||||
│ └── search_arxiv.py
|
||||
├── productivity/
|
||||
│ └── ocr-and-documents/
|
||||
│ ├── SKILL.md
|
||||
│ ├── scripts/
|
||||
│ └── references/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Formato de SKILL.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: Breve descripción (mostrada en los resultados de búsqueda de habilidades)
|
||||
version: 1.0.0
|
||||
author: Tu Nombre
|
||||
license: MIT
|
||||
platforms: [macos, linux] # Opcional — restringir a plataformas de SO específicas
|
||||
required_environment_variables: # Opcional — metadatos de configuración segura al cargar
|
||||
- name: MY_API_KEY
|
||||
prompt: Clave API
|
||||
help: Dónde obtenerla
|
||||
required_for: funcionalidad completa
|
||||
prerequisites: # Requisitos de tiempo de ejecución heredados opcionales
|
||||
env_vars: [MY_API_KEY]
|
||||
commands: [curl, jq]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Categoría, Subcategoría, Palabras clave]
|
||||
related_skills: [other-skill-name]
|
||||
fallback_for_toolsets: [web]
|
||||
requires_toolsets: [terminal]
|
||||
---
|
||||
|
||||
# Título de la Habilidad
|
||||
|
||||
Introducción breve.
|
||||
|
||||
## Cuándo Usar
|
||||
Condiciones de activación — ¿cuándo debería el agente cargar esta habilidad?
|
||||
|
||||
## Referencia Rápida
|
||||
Tabla de comandos o llamadas API comunes.
|
||||
|
||||
## Procedimiento
|
||||
Instrucciones paso a paso que el agente sigue.
|
||||
|
||||
## Problemas Conocidos
|
||||
Modos de fallo conocidos y cómo manejarlos.
|
||||
|
||||
## Verificación
|
||||
Cómo confirma el agente que funcionó.
|
||||
```
|
||||
|
||||
### Estándares de autoría de habilidades (OBLIGATORIOS)
|
||||
|
||||
Todo skill nuevo o modernizado — incluido, opcional o contribuido — debe cumplir estos estándares antes del merge:
|
||||
|
||||
1. **`description` ≤ 60 caracteres, una oración, termina con punto.** Las descripciones largas saturan la UI de listado de habilidades. Indica la capacidad, no la implementación. Sin palabras de marketing ("potente", "completo", "fluido", "avanzado").
|
||||
|
||||
2. **Las herramientas referenciadas en el cuerpo de SKILL.md deben ser herramientas nativas de Hermes o servidores MCP que la habilidad espere explícitamente.** Usa los nombres de herramientas en comillas invertidas: `` `terminal` ``, `` `web_extract` ``, `` `web_search` ``, `` `read_file` ``, `` `write_file` ``, etc.
|
||||
|
||||
3. **El campo `platforms:` auditado contra las importaciones reales del script.** Las habilidades que usen primitivos solo de POSIX deben declarar sus plataformas soportadas.
|
||||
|
||||
4. **`author` da crédito primero al colaborador humano.**
|
||||
|
||||
5. **El cuerpo de SKILL.md usa el orden moderno de secciones:** título, intro de 2-3 oraciones, luego: `## Cuándo Usar`, `## Prerequisitos`, `## Cómo Ejecutar`, `## Referencia Rápida`, `## Procedimiento`, `## Problemas Conocidos`, `## Verificación`.
|
||||
|
||||
6. **Los scripts van en `scripts/`, las referencias en `references/`, las plantillas en `templates/`.**
|
||||
|
||||
7. **Los tests viven en `tests/skills/test_<skill>_skill.py`** y usan solo stdlib + pytest + `unittest.mock`. Sin llamadas de red en vivo.
|
||||
|
||||
8. **Las adiciones a `.env.example` están aisladas en un bloque claramente delimitado.**
|
||||
|
||||
---
|
||||
|
||||
## Añadir una Skin / Tema
|
||||
|
||||
Hermes usa un sistema de skins basado en datos — no se necesitan cambios de código para añadir una nueva skin.
|
||||
|
||||
**Opción A: Skin de usuario (archivo YAML)**
|
||||
|
||||
Crea `~/.hermes/skins/<nombre>.yaml`:
|
||||
|
||||
```yaml
|
||||
name: mitema
|
||||
description: Breve descripción del tema
|
||||
|
||||
colors:
|
||||
banner_border: "#HEX"
|
||||
banner_title: "#HEX"
|
||||
banner_accent: "#HEX"
|
||||
banner_dim: "#HEX"
|
||||
banner_text: "#HEX"
|
||||
response_border: "#HEX"
|
||||
|
||||
spinner:
|
||||
waiting_faces: ["(⚔)", "(⛨)"]
|
||||
thinking_faces: ["(⚔)", "(⌁)"]
|
||||
thinking_verbs: ["forjando", "planeando"]
|
||||
|
||||
branding:
|
||||
agent_name: "Mi Agente"
|
||||
welcome: "Mensaje de bienvenida"
|
||||
response_label: " ⚔ Agente "
|
||||
prompt_symbol: "⚔"
|
||||
|
||||
tool_prefix: "╎"
|
||||
```
|
||||
|
||||
Todos los campos son opcionales — los valores faltantes se heredan de la skin predeterminada.
|
||||
|
||||
**Opción B: Skin integrada**
|
||||
|
||||
Añade al dict `_BUILTIN_SKINS` en `hermes_cli/skin_engine.py`. Usa el mismo esquema que arriba pero como dict de Python.
|
||||
|
||||
**Activar:**
|
||||
- CLI: `/skin mitema` o establece `display.skin: mitema` en config.yaml
|
||||
|
||||
---
|
||||
|
||||
## Compatibilidad Multiplataforma
|
||||
|
||||
Hermes se ejecuta en Linux, macOS y Windows nativo (además de WSL2). Al escribir código
|
||||
que toca el SO, asume que *cualquier* plataforma puede alcanzar tu ruta de código.
|
||||
|
||||
> **Antes de hacer PR:** ejecuta `scripts/check-windows-footguns.py` para detectar
|
||||
> los patrones inseguros comunes de Windows en tu diff. Es basado en grep y barato;
|
||||
> CI también lo ejecuta en cada PR.
|
||||
|
||||
### Reglas críticas
|
||||
|
||||
1. **Nunca llames `os.kill(pid, 0)` para comprobaciones de liveness.** En Windows **NO es una operación sin efecto**. Usa `psutil.pid_exists(pid)` en su lugar.
|
||||
|
||||
2. **Usa `shutil.which()` antes de hacer shell — no asumas que Windows tiene las herramientas que tiene Linux.** `ps`, `kill`, `grep`, `awk`, etc. simplemente no existen en Windows.
|
||||
|
||||
3. **`termios` y `fcntl` son solo de Unix.** Siempre captura tanto `ImportError` como `NotImplementedError`.
|
||||
|
||||
4. **Codificación de archivos.** Windows puede guardar archivos `.env` en `cp1252`. Siempre maneja errores de codificación.
|
||||
|
||||
5. **Gestión de procesos.** `os.setsid()`, `os.killpg()`, `os.fork()`, `os.getuid()` y el manejo de señales POSIX difieren en Windows.
|
||||
|
||||
6. **Señales que no existen en Windows:** `SIGALRM`, `SIGCHLD`, `SIGHUP`, `SIGUSR1`, `SIGUSR2`, etc.
|
||||
|
||||
7. **Separadores de ruta.** Usa `pathlib.Path` en lugar de concatenación de cadenas con `/`.
|
||||
|
||||
8. **Los enlaces simbólicos necesitan privilegios elevados en Windows** (a menos que el Modo Desarrollador esté activado).
|
||||
|
||||
9. **Los modos de archivo POSIX (0o600, 0o644, etc.) NO se aplican en NTFS** por defecto.
|
||||
|
||||
10. **Los daemons de fondo desacoplados en Windows necesitan `pythonw.exe`, NO `python.exe`.**
|
||||
|
||||
---
|
||||
|
||||
## Consideraciones de Seguridad
|
||||
|
||||
Hermes tiene acceso al terminal. La seguridad importa.
|
||||
|
||||
### Protecciones existentes
|
||||
|
||||
| Capa | Implementación |
|
||||
|------|---------------|
|
||||
| **Piping de contraseña sudo** | Usa `shlex.quote()` para prevenir inyección de shell |
|
||||
| **Detección de comandos peligrosos** | Patrones regex en `tools/approval.py` con flujo de aprobación del usuario |
|
||||
| **Inyección de prompts en cron** | Escáner en `tools/cronjob_tools.py` bloquea patrones de anulación de instrucciones |
|
||||
| **Lista de denegación de escritura** | Rutas protegidas resueltas a través de `os.path.realpath()` para prevenir bypass de enlaces simbólicos |
|
||||
| **Skills Guard** | Escáner de seguridad para habilidades instaladas desde el hub (`tools/skills_guard.py`) |
|
||||
| **Sandbox de ejecución de código** | El proceso hijo `execute_code` se ejecuta con claves API eliminadas del entorno |
|
||||
| **Fortalecimiento de contenedor** | Docker: todas las capacidades eliminadas, sin escalada de privilegios, límites de PID, tmpfs de tamaño limitado |
|
||||
|
||||
### Al contribuir código sensible a la seguridad
|
||||
|
||||
- **Siempre usa `shlex.quote()`** al interpolar entrada del usuario en comandos de shell
|
||||
- **Resuelve enlaces simbólicos** con `os.path.realpath()` antes de comprobaciones de control de acceso basadas en rutas
|
||||
- **No registres secretos.** Las claves API, tokens y contraseñas nunca deben aparecer en la salida de log
|
||||
- **Captura excepciones amplias** alrededor de la ejecución de herramientas para que un solo fallo no bloquee el bucle del agente
|
||||
- **Prueba en todas las plataformas** si tu cambio toca rutas de archivos, gestión de procesos o comandos de shell
|
||||
|
||||
### Política de fijación de dependencias (fortalecimiento de la cadena de suministro)
|
||||
|
||||
Tras el [compromiso de la cadena de suministro de litellm](https://github.com/BerriAI/litellm/issues/24512) en marzo de 2026 y la [campaña del gusano Mini Shai-Hulud](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) en mayo de 2026, todas las dependencias deben seguir estas reglas:
|
||||
|
||||
| Tipo de fuente | Tratamiento requerido | Justificación |
|
||||
|---|---|---|
|
||||
| **Paquete PyPI** | `>=suelo,<siguiente_mayor` | Las versiones de PyPI son inmutables una vez publicadas, pero pueden empujarse nuevas versiones en tu rango. |
|
||||
| **URL de Git** | SHA completo del commit | Las ramas y etiquetas son refs mutables; el SHA está direccionado por contenido. |
|
||||
| **GitHub Actions** | SHA completo del commit + comentario de versión | Las etiquetas de acción son refs mutables. Fija como `uses: owner/action@<sha> # vX.Y.Z` |
|
||||
| **Instalaciones pip solo de CI** | `==exacto` | Builds de CI herméticos; el cambio es aceptable. |
|
||||
|
||||
**Cada nueva dependencia de PyPI en un PR debe tener un límite superior `<siguiente_mayor`.** Los PRs que añadan especificaciones `>=X.Y.Z` sin límite superior serán rechazados.
|
||||
|
||||
---
|
||||
|
||||
## Proceso de Pull Request
|
||||
|
||||
### Nomenclatura de ramas
|
||||
|
||||
```
|
||||
fix/descripcion # Correcciones de errores
|
||||
feat/descripcion # Nuevas funcionalidades
|
||||
docs/descripcion # Documentación
|
||||
test/descripcion # Tests
|
||||
refactor/descripcion # Reestructuración de código
|
||||
```
|
||||
|
||||
### Antes de enviar
|
||||
|
||||
1. **Ejecutar tests**: `scripts/run_tests.sh` (recomendado; igual que CI) o `pytest tests/ -v` con el venv del proyecto activado
|
||||
2. **Probar manualmente**: Ejecuta `hermes` y ejercita la ruta de código que cambiaste
|
||||
3. **Verificar impacto multiplataforma**: Si tocas E/S de archivos, gestión de procesos o manejo del terminal, considera macOS, Linux y WSL2
|
||||
4. **Mantén los PRs enfocados**: Un cambio lógico por PR. No mezcles una corrección de error con una refactorización con una nueva funcionalidad.
|
||||
|
||||
### Descripción del PR
|
||||
|
||||
Incluye:
|
||||
- **Qué** cambió y **por qué**
|
||||
- **Cómo probarlo** (pasos de reproducción para errores, ejemplos de uso para funcionalidades)
|
||||
- **Qué plataformas** probaste
|
||||
- Referencia cualquier issue relacionado
|
||||
|
||||
### Mensajes de commit
|
||||
|
||||
Usamos [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<tipo>(<alcance>): <descripción>
|
||||
```
|
||||
|
||||
| Tipo | Usar para |
|
||||
|------|-----------|
|
||||
| `fix` | Correcciones de errores |
|
||||
| `feat` | Nuevas funcionalidades |
|
||||
| `docs` | Documentación |
|
||||
| `test` | Tests |
|
||||
| `refactor` | Reestructuración de código (sin cambio de comportamiento) |
|
||||
| `chore` | Build, CI, actualizaciones de dependencias |
|
||||
|
||||
Alcances: `cli`, `gateway`, `tools`, `skills`, `agent`, `install`, `whatsapp`, `security`, etc.
|
||||
|
||||
Ejemplos:
|
||||
```
|
||||
fix(cli): prevenir bloqueo en save_config_value cuando el modelo es una cadena
|
||||
feat(gateway): añadir aislamiento de sesión multi-usuario de WhatsApp
|
||||
fix(security): prevenir inyección de shell en el piping de contraseña sudo
|
||||
test(tools): añadir tests unitarios para file_operations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reportar Issues
|
||||
|
||||
- Usa [GitHub Issues](https://github.com/NousResearch/hermes-agent/issues)
|
||||
- Incluye: SO, versión de Python, versión de Hermes (`hermes version`), traza de error completa
|
||||
- Incluye pasos para reproducir
|
||||
- Verifica los issues existentes antes de crear duplicados
|
||||
- Para vulnerabilidades de seguridad, por favor reporta de forma privada
|
||||
|
||||
---
|
||||
|
||||
## Comunidad
|
||||
|
||||
- **Discord**: [discord.gg/NousResearch](https://discord.gg/NousResearch) — para preguntas, mostrar proyectos y compartir habilidades
|
||||
- **GitHub Discussions**: Para propuestas de diseño y discusiones de arquitectura
|
||||
- **Skills Hub**: Sube habilidades especializadas a un registro y compártelas con la comunidad
|
||||
|
||||
---
|
||||
|
||||
## Licencia
|
||||
|
||||
Al contribuir, aceptas que tus contribuciones serán licenciadas bajo la [Licencia MIT](LICENSE).
|
||||
+9
-95
@@ -18,24 +18,6 @@ We value contributions in this order:
|
||||
|
||||
---
|
||||
|
||||
## Before You Start: Search First
|
||||
|
||||
A quick search before you build saves your time and keeps the PR queue clean — duplicates are common here, so it's worth a minute up front.
|
||||
|
||||
- **Search both open *and* merged PRs and issues** for your topic or error symptom — the duplicate-check in the PR template fires at review time, after you've already done the work:
|
||||
```bash
|
||||
gh search issues --repo NousResearch/hermes-agent "<your terms>"
|
||||
gh search prs --repo NousResearch/hermes-agent --state all "<your terms>"
|
||||
```
|
||||
Or use the web UI: [issues](https://github.com/NousResearch/hermes-agent/issues?q=) · [PRs (all states)](https://github.com/NousResearch/hermes-agent/pulls?q=is%3Apr).
|
||||
- **The issue tracker can lag the code.** Many requested features are already implemented in-tree, so also search the source (`search_files`, or your editor's grep) for the capability before proposing it.
|
||||
- **If an open PR already addresses it**, consider reviewing or improving that one instead of opening a competing duplicate.
|
||||
- **For larger work**, comment on the issue to signal you're working on it, so others don't start the same thing.
|
||||
|
||||
Related: #38284 covers the agent-side analog — Hermes itself checking existing issues and PRs before deep self-troubleshooting. This section is the human-contributor complement.
|
||||
|
||||
---
|
||||
|
||||
## Should it be a Skill or a Tool?
|
||||
|
||||
This is the most common question for new contributors. The answer is almost always **skill**.
|
||||
@@ -85,23 +67,6 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
|
||||
|
||||
---
|
||||
|
||||
## Third-Party Product Integrations: Ship as a Standalone Plugin
|
||||
|
||||
The same rule extends to **any plugin that integrates someone else's product or project** — observability/metrics backends, vendor SaaS connectors, analytics dashboards, paid-service tie-ins, and similar third-party integrations. **These do not land in this repo.**
|
||||
|
||||
The reason is maintenance load, not quality. Every external product absorbed into the core tree becomes ours to keep working against a fast-moving codebase, for a backend we don't own and can't control. Hermes ships a lot and the core moves quickly; coupling third-party products into it creates an open-ended burden on the maintainers.
|
||||
|
||||
Publish these as a **standalone plugin repo** instead:
|
||||
|
||||
- Implement the relevant ABC and use the existing plugin discovery path (`~/.hermes/plugins/`, project `.hermes/plugins/`, or a pip entry point) — see [Build a Hermes Plugin](https://hermes-agent.nousresearch.com/docs/guides/build-a-hermes-plugin)
|
||||
- Register lifecycle hooks (`pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`, `on_session_start`, `on_session_end`), tools (`ctx.register_tool`), and CLI subcommands (`ctx.register_cli_command`) through the surface we already expose — no core changes needed
|
||||
- If your plugin needs a capability the framework doesn't expose, that's a feature request to **widen the generic plugin surface** (a new hook or `ctx` method) — never special-case your plugin in core
|
||||
- Promote it in the [Nous Research Discord](https://discord.gg/NousResearch) `#plugins-skills-and-skins` channel so users can find and install it
|
||||
|
||||
A well-built third-party-product plugin can clear automated review and still be closed for this reason — it's a placement decision, not a verdict on the code. PRs that add such a directory under `plugins/` will be closed with a pointer to publish it as its own repo.
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
@@ -113,56 +78,15 @@ A well-built third-party-product plugin can clear automated review and still be
|
||||
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
|
||||
|
||||
### Install with the standard installer
|
||||
|
||||
For most contributors, the best development bootstrap is the same path users
|
||||
take: run the standard installer, then work inside the repository it cloned.
|
||||
The installer creates the Hermes venv, wires the `hermes` command, stamps the
|
||||
install method for `hermes update`, and clones the full git project into
|
||||
`$HERMES_HOME/hermes-agent` (usually `~/.hermes/hermes-agent`). That keeps your
|
||||
development environment on the same layout the CLI, updater, lazy dependency
|
||||
installer, gateway, and docs assume.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
|
||||
# Add dev/test extras on top of the standard install.
|
||||
uv pip install -e ".[all,dev]"
|
||||
|
||||
# Optional: browser tools / docs site dependencies.
|
||||
npm install
|
||||
```
|
||||
|
||||
After that, create branches and run tests from that checkout:
|
||||
|
||||
```bash
|
||||
git checkout -b fix/description
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
### Manual clone fallback
|
||||
|
||||
Use this only if you intentionally do not want Hermes' managed install layout
|
||||
(for example, a throwaway clone inside a container or CI job). If you install
|
||||
this way, make sure you run the `hermes` entrypoint from this venv; running the
|
||||
system `python3 -m hermes_cli.main` can pick up unrelated system Python
|
||||
packages.
|
||||
|
||||
Create the venv **outside** the cloned source tree. A venv that lives inside
|
||||
the directory the agent operates from can be wiped by a relative-path command
|
||||
the agent runs against its own checkout (`rm -rf venv`, `uv venv venv`, etc.),
|
||||
which silently destroys the running runtime mid-session. Keeping it outside the
|
||||
tree means no relative path from the workspace resolves to it.
|
||||
### Clone and install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
|
||||
# Create venv with Python 3.11, OUTSIDE the source tree
|
||||
uv venv ~/.hermes/venvs/hermes-dev --python 3.11
|
||||
export VIRTUAL_ENV="$HOME/.hermes/venvs/hermes-dev"
|
||||
export PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
# Create venv with Python 3.11
|
||||
uv venv venv --python 3.11
|
||||
export VIRTUAL_ENV="$(pwd)/venv"
|
||||
|
||||
# Install with all extras (messaging, cron, CLI menus, dev tools)
|
||||
uv pip install -e ".[all,dev]"
|
||||
@@ -185,17 +109,13 @@ echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
|
||||
### Run
|
||||
|
||||
```bash
|
||||
# The standard installer already put `hermes` on PATH.
|
||||
hermes doctor
|
||||
hermes chat -q "Hello"
|
||||
```
|
||||
|
||||
If you used the manual clone fallback, run `./hermes` from the checkout or
|
||||
symlink this clone's venv explicitly:
|
||||
|
||||
```bash
|
||||
# Symlink for global access
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
|
||||
|
||||
# Verify
|
||||
hermes doctor
|
||||
hermes chat -q "Hello"
|
||||
```
|
||||
|
||||
### Run tests
|
||||
@@ -454,12 +374,6 @@ Brief intro.
|
||||
## When to Use
|
||||
Trigger conditions — when should the agent load this skill?
|
||||
|
||||
## Prerequisites
|
||||
Env vars, install steps, MCP setup, API key sourcing.
|
||||
|
||||
## How to Run
|
||||
Canonical invocation through the `terminal` tool.
|
||||
|
||||
## Quick Reference
|
||||
Table of common commands or API calls.
|
||||
|
||||
|
||||
+36
-71
@@ -9,11 +9,8 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df228
|
||||
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
|
||||
FROM debian:13.4
|
||||
|
||||
# Disable Python stdout buffering to ensure logs are printed immediately.
|
||||
# Do not write .pyc files at runtime: /opt/hermes is immutable in the
|
||||
# published container and writable state belongs under /opt/data.
|
||||
# Disable Python stdout buffering to ensure logs are printed immediately
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Store Playwright browsers outside the volume mount so the build-time
|
||||
# install survives the /opt/data volume overlay at runtime.
|
||||
@@ -28,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
@@ -119,9 +116,6 @@ COPY package.json package-lock.json ./
|
||||
COPY web/package.json web/
|
||||
COPY ui-tui/package.json ui-tui/
|
||||
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
# apps/shared/ is copied IN FULL because web/package.json references it as a
|
||||
# `file:` workspace dependency (same pattern as hermes-ink above).
|
||||
COPY apps/shared/ apps/shared/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
# symlinks instead of copies. This is the default since npm 10+, which is
|
||||
@@ -152,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
|
||||
#
|
||||
# `uv sync --frozen --no-install-project --extra all --extra messaging`
|
||||
# installs the deps reachable through the composite `[all]` extra
|
||||
# (handpicked set intended for the production image — excludes `[dev]`),
|
||||
# plus gateway messaging adapters that should work in the published image
|
||||
# without a first-boot lazy install. We do NOT use `--all-extras`:
|
||||
# (handpicked set intended for the production image), plus gateway
|
||||
# messaging adapters that should work in the published image without a
|
||||
# first-boot lazy install. We do NOT use `--all-extras`:
|
||||
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
|
||||
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
|
||||
# redundancy), none of which belong in the published container.
|
||||
@@ -170,63 +164,47 @@ RUN npm install --prefer-offline --no-audit && \
|
||||
# image update and recall/retain then fails with
|
||||
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
|
||||
#
|
||||
# The Matrix gateway's deps ([matrix] extra) are baked in because
|
||||
# python-olm (transitive via mautrix[encryption]) builds from source on
|
||||
# Python/image combinations without usable wheels. The Docker image is
|
||||
# Linux-only, so keeping the native libolm/build-toolchain packages here
|
||||
# avoids the cross-platform failures that kept [matrix] out of [all]
|
||||
# while still making Matrix work in the published container. Fixes #30399.
|
||||
#
|
||||
# The editable link is created after the source copy below.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN touch ./README.md
|
||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
|
||||
|
||||
# ---------- Frontend build (cached independently from Python source) ----------
|
||||
# Copy only the frontend source trees first so that Python-only changes don't
|
||||
# invalidate the (relatively slow) web + ui-tui build layer.
|
||||
COPY web/ web/
|
||||
COPY ui-tui/ ui-tui/
|
||||
COPY apps/shared/ apps/shared/
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
|
||||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
# --link decouples this layer from parents for cache purposes; --chmod bakes
|
||||
# the final read-only permissions at copy time so we skip the separate
|
||||
# `chmod -R` pass that previously walked ~30k files across the venv +
|
||||
# node_modules + source (21s amd64 / 222s arm64 — #49113). `a+rX,go-w`
|
||||
# gives the non-root hermes user read + traverse but no write; root retains
|
||||
# write so the build steps below don't need chmod u+w dances.
|
||||
COPY --link --chmod=a+rX,go-w . .
|
||||
COPY --chown=hermes:hermes . .
|
||||
|
||||
# Build browser dashboard and terminal UI assets.
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
|
||||
# ---------- Permissions ----------
|
||||
# Link hermes-agent itself (editable). Deps are already installed in the
|
||||
# cached layer above; `--no-deps` makes this a fast egg-link creation with no
|
||||
# resolution or downloads.
|
||||
RUN uv pip install --no-cache-dir --no-deps -e "."
|
||||
|
||||
# Wire the exec shim and install-method stamp. Files under /opt/hermes are
|
||||
# already root-owned (COPY, uv sync, npm install all run as root) and
|
||||
# read-only for the hermes user (go-w from the --chmod above).
|
||||
|
||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||
# The venv needs to be traversable too.
|
||||
# node_modules trees additionally need to be writable by the hermes user
|
||||
# so the runtime `npm install` triggered by _tui_need_npm_install() in
|
||||
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
|
||||
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
|
||||
# not chowned here.
|
||||
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
|
||||
# gateway state artifacts beneath the package after services drop privileges,
|
||||
# especially when the hermes UID is remapped at boot (#27221).
|
||||
# The .venv MUST remain hermes-writable so lazy_deps.py can install
|
||||
# remaining optional platform packages and future pin bumps at first use.
|
||||
# Without this, `uv pip install` fails with EACCES and adapters silently
|
||||
# fail to load. See tools/lazy_deps.py.
|
||||
USER root
|
||||
RUN mkdir -p /opt/hermes/bin && \
|
||||
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
|
||||
chmod 0755 /opt/hermes/bin/hermes && \
|
||||
printf 'docker\n' > /opt/hermes/.install_method
|
||||
# The ``.install_method`` stamp is baked next to the running code (the install
|
||||
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
|
||||
# volume that is commonly bind-mounted from the host and even shared with a
|
||||
# host-side Desktop/CLI install; stamping it at boot used to clobber that
|
||||
# host install's marker and wrongly block its ``hermes update``. A code-scoped
|
||||
# stamp is read first by detect_install_method() and is immune to the share.
|
||||
RUN chmod -R a+rX /opt/hermes && \
|
||||
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
|
||||
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
|
||||
# the data volume. Each supervised service then drops to the hermes user via
|
||||
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
|
||||
# run as the default hermes user (UID 10000).
|
||||
|
||||
# ---------- Link hermes-agent itself (editable) ----------
|
||||
# Deps are already installed in the cached layer above; `--no-deps` makes
|
||||
# this a fast (~1s) egg-link creation with no resolution or downloads.
|
||||
RUN uv pip install --no-cache-dir --no-deps -e "."
|
||||
|
||||
# ---------- Bake build-time git revision ----------
|
||||
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
||||
# container always returns nothing — meaning `hermes dump` reports
|
||||
@@ -242,11 +220,12 @@ RUN mkdir -p /opt/hermes/bin && \
|
||||
#
|
||||
# The arg is optional — local `docker build` without --build-arg simply
|
||||
# omits the file, and the runtime falls back to live-git lookup. CI
|
||||
# (.github/workflows/docker.yml) passes ${{ github.sha }} so
|
||||
# (.github/workflows/docker-publish.yml) passes ${{ github.sha }} so
|
||||
# every published image has it.
|
||||
ARG HERMES_GIT_SHA=
|
||||
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
||||
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \
|
||||
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
|
||||
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
|
||||
fi
|
||||
|
||||
# ---------- s6-overlay service wiring ----------
|
||||
@@ -292,21 +271,6 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
|
||||
# check. (A separate launcher hardening is tracked independently.)
|
||||
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
||||
ENV HERMES_HOME=/opt/data
|
||||
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
|
||||
ENV HERMES_DISABLE_LAZY_INSTALLS=1
|
||||
# The published image seals /opt/hermes (root-owned, read-only) so a runtime
|
||||
# lazy install can't mutate the agent's own venv and brick it. But opt-in
|
||||
# backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in
|
||||
# tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml
|
||||
# policy 2026-05-12: one quarantined release must not break every install).
|
||||
# Redirect those lazy installs to a writable dir on the durable data volume.
|
||||
# lazy_deps appends this dir to the END of sys.path, so a package installed
|
||||
# here can only ADD modules — it can never shadow or downgrade a core module,
|
||||
# so the sealed-venv guarantee holds even with installs re-enabled. The dir
|
||||
# is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives
|
||||
# on the /opt/data volume, so it persists across container recreates / image
|
||||
# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter).
|
||||
ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages
|
||||
|
||||
# `docker exec` privilege-drop shim. When operators run
|
||||
# `docker exec <c> hermes ...` they default to root, and any file the
|
||||
@@ -319,6 +283,7 @@ ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages
|
||||
# Recursion is impossible because the shim exec's the venv binary by
|
||||
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
|
||||
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
|
||||
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
|
||||
|
||||
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
|
||||
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
graft skills
|
||||
graft optional-skills
|
||||
graft optional-mcps
|
||||
graft locales
|
||||
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
|
||||
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
|
||||
|
||||
-220
@@ -1,220 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="assets/banner.png" alt="Hermes Agent" width="100%">
|
||||
</p>
|
||||
|
||||
# Hermes Agent ☤
|
||||
<p align="center">
|
||||
<a href="https://hermes-agent.nousresearch.com/">Hermes Agent</a> | <a href="https://hermes-agent.nousresearch.com/">Hermes Desktop</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentación"></a>
|
||||
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/Licencia-MIT-green?style=for-the-badge" alt="Licencia: MIT"></a>
|
||||
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Creado%20por-Nous%20Research-blueviolet?style=for-the-badge" alt="Creado por Nous Research"></a>
|
||||
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-blue?style=for-the-badge" alt="English"></a>
|
||||
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
|
||||
<a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
|
||||
</p>
|
||||
|
||||
**El agente de IA con mejora continua creado por [Nous Research](https://nousresearch.com).** Es el único agente con un bucle de aprendizaje integrado: crea habilidades a partir de la experiencia, las mejora durante el uso, se impulsa a sí mismo a persistir el conocimiento, busca en sus propias conversaciones pasadas y construye un modelo cada vez más profundo de quién eres a lo largo de las sesiones. Ejecútalo en un VPS de $5, un clúster de GPUs o infraestructura sin servidor que cuesta casi nada cuando está inactivo. No está atado a tu laptop — habla con él desde Telegram mientras trabaja en una VM en la nube.
|
||||
|
||||
Usa cualquier modelo que quieras — [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai) (más de 200 modelos), [NovitaAI](https://novita.ai), [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, o tu propio endpoint. Cambia con `hermes model` — sin cambios de código, sin dependencias.
|
||||
|
||||
<table>
|
||||
<tr><td><b>Una interfaz de terminal real</b></td><td>TUI completa con edición multilínea, autocompletado de comandos, historial de conversaciones, interrupción y redirección, y salida de herramientas en streaming.</td></tr>
|
||||
<tr><td><b>Vive donde tú vives</b></td><td>Telegram, Discord, Slack, WhatsApp, Signal y CLI — todo desde un único proceso gateway. Transcripción de notas de voz, continuidad de conversación entre plataformas.</td></tr>
|
||||
<tr><td><b>Un bucle de aprendizaje cerrado</b></td><td>Memoria curada por el agente con recordatorios periódicos. Creación autónoma de habilidades tras tareas complejas. Las habilidades mejoran solas durante el uso. Búsqueda FTS5 de sesiones con resumención por LLM para recuperación entre sesiones. Modelado de usuario dialéctico <a href="https://github.com/plastic-labs/honcho">Honcho</a>. Compatible con el estándar abierto de <a href="https://agentskills.io">agentskills.io</a>.</td></tr>
|
||||
<tr><td><b>Automatizaciones programadas</b></td><td>Planificador cron integrado con entrega a cualquier plataforma. Informes diarios, copias de seguridad nocturnas, auditorías semanales — todo en lenguaje natural, ejecutándose de forma autónoma.</td></tr>
|
||||
<tr><td><b>Delega y paraleliza</b></td><td>Lanza subagentes aislados para flujos de trabajo paralelos. Escribe scripts de Python que llaman a herramientas vía RPC, convirtiendo pipelines de múltiples pasos en turnos de coste cero de contexto.</td></tr>
|
||||
<tr><td><b>Funciona en cualquier lugar, no solo en tu laptop</b></td><td>Seis backends de terminal — local, Docker, SSH, Singularity, Modal y Daytona. Daytona y Modal ofrecen persistencia sin servidor — el entorno de tu agente hiberna cuando está inactivo y se activa bajo demanda, costando casi nada entre sesiones. Ejecútalo en un VPS de $5 o un clúster de GPUs.</td></tr>
|
||||
<tr><td><b>Listo para investigación</b></td><td>Generación de trayectorias en lote, compresión de trayectorias para entrenar la próxima generación de modelos de llamadas a herramientas.</td></tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Instalación rápida
|
||||
|
||||
### Linux, macOS, WSL2, Termux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows (nativo, PowerShell)
|
||||
|
||||
> **Nota:** En Windows nativo, Hermes funciona sin WSL — la CLI, el gateway, la TUI y las herramientas funcionan de forma nativa. Si prefieres usar WSL2, el comando de Linux/macOS de arriba también funciona allí. ¿Encontraste un error? Por favor [crea un issue](https://github.com/NousResearch/hermes-agent/issues).
|
||||
|
||||
Ejecuta esto en PowerShell:
|
||||
|
||||
```powershell
|
||||
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
|
||||
```
|
||||
|
||||
El instalador se encarga de todo: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **y un Git Bash portátil** (MinGit, descomprimido en `%LOCALAPPDATA%\hermes\git` — no requiere administrador, completamente aislado de cualquier instalación de Git del sistema). Hermes usa este Git Bash incluido para ejecutar comandos de shell.
|
||||
|
||||
Si ya tienes Git instalado, el instalador lo detecta y lo usa en su lugar. De lo contrario, una descarga de ~45MB de MinGit es todo lo que necesitas — no tocará ni interferirá con ningún Git del sistema.
|
||||
|
||||
> **Android / Termux:** La ruta manual probada está documentada en la [guía de Termux](https://hermes-agent.nousresearch.com/docs/getting-started/termux). En Termux, Hermes instala el extra `.[termux]` curado porque el extra completo `.[all]` actualmente incluye dependencias de voz incompatibles con Android.
|
||||
>
|
||||
> **Windows:** Windows nativo es totalmente compatible — el comando de PowerShell de arriba instala todo. Si prefieres usar WSL2, el comando de Linux también funciona allí. La instalación nativa de Windows se encuentra en `%LOCALAPPDATA%\hermes`; WSL2 instala en `~/.hermes` como en Linux.
|
||||
|
||||
Después de la instalación:
|
||||
|
||||
```bash
|
||||
source ~/.bashrc # recargar shell (o: source ~/.zshrc)
|
||||
hermes # ¡empieza a chatear!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Primeros pasos
|
||||
|
||||
```bash
|
||||
hermes # CLI interactiva — inicia una conversación
|
||||
hermes model # Elige tu proveedor y modelo LLM
|
||||
hermes tools # Configura qué herramientas están habilitadas
|
||||
hermes config set # Establece valores de configuración individuales
|
||||
hermes gateway # Inicia el gateway de mensajería (Telegram, Discord, etc.)
|
||||
hermes setup # Ejecuta el asistente de configuración completo
|
||||
hermes claw migrate # Migra desde OpenClaw (si vienes de OpenClaw)
|
||||
hermes update # Actualiza a la última versión
|
||||
hermes doctor # Diagnostica cualquier problema
|
||||
```
|
||||
|
||||
📖 **[Documentación completa →](https://hermes-agent.nousresearch.com/docs/)**
|
||||
|
||||
---
|
||||
|
||||
## Evita la colección de claves API — Nous Portal
|
||||
|
||||
Hermes funciona con cualquier proveedor que quieras — eso no cambiará. Pero si prefieres no recopilar cinco claves API separadas para el modelo, búsqueda web, generación de imágenes, TTS y un navegador en la nube, **[Nous Portal](https://portal.nousresearch.com)** las cubre todas bajo una sola suscripción:
|
||||
|
||||
- **Más de 300 modelos** — elige cualquiera con `/model <nombre>`
|
||||
- **Tool Gateway** — búsqueda web (Firecrawl), generación de imágenes (FAL), texto a voz (OpenAI), navegador en la nube (Browser Use), todo enrutado a través de tu suscripción. Sin cuentas adicionales.
|
||||
|
||||
Un comando desde una instalación nueva:
|
||||
|
||||
```bash
|
||||
hermes setup --portal
|
||||
```
|
||||
|
||||
Esto te autentica vía OAuth, establece Nous como tu proveedor y activa el Tool Gateway. Comprueba qué está conectado en cualquier momento con `hermes portal info`. Detalles completos en la [página de documentación del Tool Gateway](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway).
|
||||
|
||||
Puedes seguir usando tus propias claves por herramienta cuando quieras — el gateway es por backend, no todo o nada.
|
||||
|
||||
---
|
||||
|
||||
## Referencia rápida: CLI vs Mensajería
|
||||
|
||||
Hermes tiene dos puntos de entrada: inicia la interfaz de terminal con `hermes`, o ejecuta el gateway y habla con él desde Telegram, Discord, Slack, WhatsApp, Signal o Email. Una vez en una conversación, muchos comandos de barra son compartidos entre ambas interfaces.
|
||||
|
||||
| Acción | CLI | Plataformas de mensajería |
|
||||
| ----------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Empezar a chatear | `hermes` | Ejecuta `hermes gateway setup` + `hermes gateway start`, luego envía un mensaje al bot |
|
||||
| Nueva conversación | `/new` o `/reset` | `/new` o `/reset` |
|
||||
| Cambiar modelo | `/model [proveedor:modelo]` | `/model [proveedor:modelo]` |
|
||||
| Establecer personalidad | `/personality [nombre]` | `/personality [nombre]` |
|
||||
| Reintentar o deshacer último turno | `/retry`, `/undo` | `/retry`, `/undo` |
|
||||
| Comprimir contexto / ver uso | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` |
|
||||
| Explorar habilidades | `/skills` o `/<nombre-habilidad>` | `/<nombre-habilidad>` |
|
||||
| Interrumpir trabajo actual | `Ctrl+C` o enviar un nuevo mensaje | `/stop` o enviar un nuevo mensaje |
|
||||
| Estado específico de plataforma | `/platforms` | `/status`, `/sethome` |
|
||||
|
||||
Para las listas de comandos completas, consulta la [guía de CLI](https://hermes-agent.nousresearch.com/docs/user-guide/cli) y la [guía del Gateway de Mensajería](https://hermes-agent.nousresearch.com/docs/user-guide/messaging).
|
||||
|
||||
---
|
||||
|
||||
## Documentación
|
||||
|
||||
Toda la documentación está en **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)**:
|
||||
|
||||
| Sección | Contenido |
|
||||
| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| [Inicio rápido](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | Instalar → configurar → primera conversación en 2 minutos |
|
||||
| [Uso de CLI](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | Comandos, atajos de teclado, personalidades, sesiones |
|
||||
| [Configuración](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | Archivo de configuración, proveedores, modelos, todas las opciones |
|
||||
| [Gateway de Mensajería](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant |
|
||||
| [Seguridad](https://hermes-agent.nousresearch.com/docs/user-guide/security) | Aprobación de comandos, emparejamiento por DM, aislamiento en contenedor |
|
||||
| [Herramientas y Toolsets](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | Más de 40 herramientas, sistema de toolsets, backends de terminal |
|
||||
| [Sistema de Habilidades](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | Memoria procedimental, Skills Hub, creación de habilidades |
|
||||
| [Memoria](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | Memoria persistente, perfiles de usuario, mejores prácticas |
|
||||
| [Integración MCP](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Conecta cualquier servidor MCP para capacidades extendidas |
|
||||
| [Programación Cron](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Tareas programadas con entrega a plataforma |
|
||||
| [Archivos de Contexto](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Contexto de proyecto que da forma a cada conversación |
|
||||
| [Arquitectura](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Estructura del proyecto, bucle del agente, clases principales |
|
||||
| [Contribuir](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Configuración de desarrollo, proceso de PR, estilo de código |
|
||||
| [Referencia de CLI](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | Todos los comandos y flags |
|
||||
| [Variables de Entorno](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | Referencia completa de variables de entorno |
|
||||
|
||||
---
|
||||
|
||||
## Migración desde OpenClaw
|
||||
|
||||
Si vienes de OpenClaw, Hermes puede importar automáticamente tu configuración, memorias, habilidades y claves API.
|
||||
|
||||
**Durante la configuración inicial:** El asistente de configuración (`hermes setup`) detecta automáticamente `~/.openclaw` y ofrece migrar antes de que comience la configuración.
|
||||
|
||||
**En cualquier momento después de instalar:**
|
||||
|
||||
```bash
|
||||
hermes claw migrate # Migración interactiva (preset completo)
|
||||
hermes claw migrate --dry-run # Vista previa de qué se migraría
|
||||
hermes claw migrate --preset user-data # Migrar sin secretos
|
||||
hermes claw migrate --overwrite # Sobreescribir conflictos existentes
|
||||
```
|
||||
|
||||
Qué se importa:
|
||||
|
||||
- **SOUL.md** — archivo de personalidad
|
||||
- **Memorias** — entradas de MEMORY.md y USER.md
|
||||
- **Habilidades** — habilidades creadas por el usuario → `~/.hermes/skills/openclaw-imports/`
|
||||
- **Lista de comandos permitidos** — patrones de aprobación
|
||||
- **Configuración de mensajería** — configuración de plataformas, usuarios permitidos, directorio de trabajo
|
||||
- **Claves API** — secretos en lista de permitidos (Telegram, OpenRouter, OpenAI, Anthropic, ElevenLabs)
|
||||
- **Assets de TTS** — archivos de audio del espacio de trabajo
|
||||
- **Instrucciones del espacio de trabajo** — AGENTS.md (con `--workspace-target`)
|
||||
|
||||
Consulta `hermes claw migrate --help` para todas las opciones, o usa la habilidad `openclaw-migration` para una migración guiada interactiva por el agente con vistas previas de dry-run.
|
||||
|
||||
---
|
||||
|
||||
## Contribuir
|
||||
|
||||
¡Las contribuciones son bienvenidas! Consulta la [Guía de Contribución](CONTRIBUTING.es.md) para la configuración del desarrollo, el estilo de código y el proceso de PR.
|
||||
|
||||
Inicio rápido para colaboradores — clona y comienza con `setup-hermes.sh`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
./setup-hermes.sh # instala uv, crea venv, instala .[all], enlaza ~/.local/bin/hermes
|
||||
./hermes # detecta automáticamente el venv, no necesitas hacer `source` primero
|
||||
```
|
||||
|
||||
Ruta manual (equivalente a lo anterior):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
uv pip install -e ".[all,dev]"
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comunidad
|
||||
|
||||
- 💬 [Discord](https://discord.gg/NousResearch)
|
||||
- 📚 [Skills Hub](https://agentskills.io)
|
||||
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
|
||||
- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — Servidor MCP de control de escritorio Linux para Hermes y otros hosts MCP, con árboles de accesibilidad AT-SPI, entrada Wayland/X11, capturas de pantalla y targeting de ventanas del compositor.
|
||||
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Puente WeChat comunitario: Ejecuta Hermes Agent y OpenClaw en la misma cuenta de WeChat.
|
||||
|
||||
---
|
||||
|
||||
## Licencia
|
||||
|
||||
MIT — ver [LICENSE](LICENSE).
|
||||
|
||||
Creado por [Nous Research](https://nousresearch.com).
|
||||
@@ -3,9 +3,7 @@
|
||||
</p>
|
||||
|
||||
# Hermes Agent ☤
|
||||
<p align="center">
|
||||
<a href="https://hermes-agent.nousresearch.com/">Hermes Agent</a> | <a href="https://hermes-agent.nousresearch.com/">Hermes Desktop</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
|
||||
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
@@ -13,12 +11,11 @@
|
||||
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
|
||||
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
|
||||
<a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
|
||||
<a href="README.es.md"><img src="https://img.shields.io/badge/Lang-Español-orange?style=for-the-badge" alt="Español"></a>
|
||||
</p>
|
||||
|
||||
**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, OpenAI, your own endpoint, and [many others](https://hermes-agent.nousresearch.com/docs/integrations/providers). 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.
|
||||
|
||||
<table>
|
||||
<tr><td><b>A real terminal interface</b></td><td>Full TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.</td></tr>
|
||||
@@ -65,41 +62,6 @@ source ~/.bashrc # reload shell (or: source ~/.zshrc)
|
||||
hermes # start chatting!
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Windows Defender or antivirus flags `uv.exe` as malware
|
||||
|
||||
If your antivirus (Bitdefender, Windows Defender, etc.) quarantines `uv.exe` from the Hermes `bin` folder (`%LOCALAPPDATA%\hermes\bin\uv.exe`), this is a **false positive**. The file is Astral's `uv` — the Rust Python package manager Hermes bundles to manage its Python environment. ML-based antivirus engines commonly flag unsigned Rust binaries that download and install packages.
|
||||
|
||||
**To verify your copy is authentic:**
|
||||
|
||||
```powershell
|
||||
# Install GitHub CLI if needed
|
||||
winget install --id GitHub.cli
|
||||
|
||||
# Login to GitHub
|
||||
gh auth login
|
||||
|
||||
# Run verification
|
||||
$uv = "$env:LOCALAPPDATA\hermes\bin\uv.exe"
|
||||
$ver = (& $uv --version).Split(' ')[1]
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$zip = "$env:TEMP\uv.zip"
|
||||
Invoke-WebRequest "https://github.com/astral-sh/uv/releases/download/$ver/uv-x86_64-pc-windows-msvc.zip" -OutFile $zip -UseBasicParsing
|
||||
gh attestation verify $zip --repo astral-sh/uv
|
||||
Expand-Archive $zip "$env:TEMP\uv_x" -Force
|
||||
(Get-FileHash "$env:TEMP\uv_x\uv.exe").Hash -eq (Get-FileHash $uv).Hash
|
||||
```
|
||||
|
||||
If attestation says "Verification succeeded" and the last line prints `True`, you're good.
|
||||
|
||||
**To whitelist Hermes:**
|
||||
- **Windows Defender:** Run PowerShell as Admin → `Add-MpPreference -ExclusionPath "$env:LOCALAPPDATA\hermes\bin"`
|
||||
- **Bitdefender:** Add an exception in the Bitdefender console (Protection > Antivirus > Settings > Manage Exceptions)
|
||||
- Whitelist the **folder**, not the file hash — Hermes updates `uv` and the hash changes every version
|
||||
|
||||
For more context, see the upstream Astral reports: [astral-sh/uv#13553](https://github.com/astral-sh/uv/issues/13553), [astral-sh/uv#15011](https://github.com/astral-sh/uv/issues/15011), [astral-sh/uv#10079](https://github.com/astral-sh/uv/issues/10079).
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
@@ -217,29 +179,21 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration
|
||||
|
||||
We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process.
|
||||
|
||||
Quick start for contributors — use the standard installer, then work from the
|
||||
full git checkout it creates at `$HERMES_HOME/hermes-agent` (usually
|
||||
`~/.hermes/hermes-agent`). This matches the layout used by `hermes update`, the
|
||||
managed venv, lazy dependencies, gateway, and docs tooling.
|
||||
Quick start for contributors — clone and go with `setup-hermes.sh`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
uv pip install -e ".[all,dev]"
|
||||
scripts/run_tests.sh
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes
|
||||
./hermes # auto-detects the venv, no need to `source` first
|
||||
```
|
||||
|
||||
Manual clone fallback (for throwaway clones/CI where you intentionally do not
|
||||
want the managed install layout):
|
||||
|
||||
Create the venv outside the cloned source tree — a venv inside the directory
|
||||
the agent operates from can be wiped by a relative-path command the agent runs
|
||||
against its own checkout, destroying the running runtime mid-session.
|
||||
Manual path (equivalent to the above):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
uv venv ~/.hermes/venvs/hermes-dev --python 3.11
|
||||
source ~/.hermes/venvs/hermes-dev/bin/activate
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
uv pip install -e ".[all,dev]"
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
+7
-13
@@ -39,11 +39,7 @@ curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
|
||||
> **Android / Termux:** 已测试的手动安装路径请参考 [Termux 指南](https://hermes-agent.nousresearch.com/docs/getting-started/termux)。在 Termux 上,Hermes 会安装精选的 `.[termux]` 扩展,因为完整的 `.[all]` 扩展会拉取 Android 不兼容的语音依赖。
|
||||
>
|
||||
> **Windows:** 在 PowerShell 中运行:
|
||||
> ```powershell
|
||||
> iex (irm https://hermes-agent.nousresearch.com/install.ps1)
|
||||
> ```
|
||||
> 安装完成后,可能需要重启终端,然后运行 `hermes` 开始对话。
|
||||
> **Windows:** 原生 Windows 不受支持。请安装 [WSL2](https://learn.microsoft.com/zh-cn/windows/wsl/install) 并运行上述命令。
|
||||
|
||||
安装后:
|
||||
|
||||
@@ -168,18 +164,16 @@ hermes claw migrate --overwrite # 覆盖已有冲突
|
||||
|
||||
欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。
|
||||
|
||||
贡献者快速开始——使用标准安装器,然后在它创建的完整 git checkout 中开发:
|
||||
`$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这会匹配
|
||||
`hermes update`、托管 venv、lazy dependencies、gateway 和 docs tooling 使用的布局。
|
||||
贡献者快速开始——克隆并使用 `setup-hermes.sh`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
uv pip install -e ".[all,dev]"
|
||||
scripts/run_tests.sh
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes
|
||||
./hermes # 自动检测 venv,无需先 source
|
||||
```
|
||||
|
||||
手动克隆备用路径(用于一次性 clone / CI,或你明确不想使用 managed install layout 时):
|
||||
手动安装(等效于上述命令):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
-322
@@ -1,322 +0,0 @@
|
||||
# Política de Seguridad de Hermes Agent
|
||||
|
||||
Este documento describe el modelo de confianza de Hermes Agent, identifica el
|
||||
único límite de seguridad que el proyecto trata como estructural y define el
|
||||
alcance para los informes de vulnerabilidades.
|
||||
|
||||
## 1. Reportar una Vulnerabilidad
|
||||
|
||||
Reporta de forma privada a través de [GitHub Security Advisories](https://github.com/NousResearch/hermes-agent/security/advisories/new)
|
||||
o **security@nousresearch.com**. No abras issues públicos para
|
||||
vulnerabilidades de seguridad. **Hermes Agent no opera un programa de
|
||||
recompensas por errores.**
|
||||
|
||||
Un informe útil incluye:
|
||||
|
||||
- Una descripción concisa y evaluación de severidad.
|
||||
- El componente afectado, identificado por ruta de archivo y rango de líneas
|
||||
(ej. `path/to/file.py:120-145`).
|
||||
- Detalles del entorno (`hermes version`, SHA del commit, SO, versión de Python).
|
||||
- Una reproducción contra `main` o el último release.
|
||||
- Una declaración de qué límite de confianza del §2 se cruza.
|
||||
|
||||
Por favor lee el §2 y el §3 antes de enviar. Los informes que demuestren
|
||||
límites de una heurística en proceso que esta política no trate como un
|
||||
límite serán cerrados como fuera de alcance bajo el §3 — pero consulta el §3.2:
|
||||
siguen siendo bienvenidos como issues o pull requests regulares, simplemente no
|
||||
a través del canal de seguridad privado.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modelo de Confianza
|
||||
|
||||
Hermes Agent es un agente personal de un solo inquilino. Su postura es
|
||||
por capas, y las capas no tienen el mismo peso. Los reportadores y
|
||||
operadores deben razonar sobre ellas en los mismos términos.
|
||||
|
||||
### 2.1 Definiciones
|
||||
|
||||
- **Proceso del agente.** El intérprete Python que ejecuta Hermes Agent,
|
||||
incluyendo cualquier módulo Python que haya cargado (habilidades, plugins,
|
||||
manejadores de hooks).
|
||||
- **Backend de terminal.** Un objetivo de ejecución conectado para la
|
||||
herramienta `terminal()`. El predeterminado ejecuta comandos directamente en el host.
|
||||
Otros backends ejecutan comandos dentro de un contenedor, sandbox en la nube o
|
||||
host remoto.
|
||||
- **Superficie de entrada.** Cualquier canal a través del cual el contenido entra en el
|
||||
contexto del agente: entrada del operador, fetches web, email, mensajes del gateway,
|
||||
lecturas de archivos, respuestas del servidor MCP, resultados de herramientas.
|
||||
- **Envolvente de confianza.** El conjunto de recursos a los que un operador ha otorgado
|
||||
implícitamente acceso a Hermes Agent al ejecutarlo — típicamente, todo lo que
|
||||
la propia cuenta de usuario del operador puede alcanzar en el host.
|
||||
- **Postura.** Una declaración explícita en la documentación o código de Hermes Agent
|
||||
sobre cómo una capa consumidora (adaptador, UI, escritor de archivos,
|
||||
shell) debe tratar la salida del agente — ej. "el dashboard renderiza
|
||||
la salida del agente como HTML inerte."
|
||||
|
||||
### 2.2 El Límite: Aislamiento a Nivel de SO
|
||||
|
||||
**El único límite de seguridad contra un LLM adversario es el
|
||||
sistema operativo.** Nada dentro del proceso del agente constituye
|
||||
contención — ni la puerta de aprobación, ni la redacción de salida, ni ningún
|
||||
escáner de patrones, ni ninguna lista de herramientas permitidas. Cualquier componente dentro
|
||||
del proceso que filtre la salida del LLM es una heurística operando sobre una
|
||||
cadena influenciada por el atacante, y esta política lo trata como tal.
|
||||
|
||||
Hermes Agent admite dos posturas de aislamiento a nivel de SO. Abordan
|
||||
diferentes amenazas y un operador debe elegir deliberadamente.
|
||||
|
||||
#### Aislamiento del backend de terminal
|
||||
|
||||
Un backend de terminal no predeterminado ejecuta comandos de shell emitidos por el LLM
|
||||
dentro de un contenedor, host remoto o sandbox en la nube. Las herramientas de archivos
|
||||
(`read_file`, `write_file`, `patch`) también se ejecutan a través de este backend,
|
||||
ya que están implementadas sobre el contrato del shell — no pueden
|
||||
alcanzar rutas que el backend no exponga.
|
||||
|
||||
Lo que confina: todo lo que el agente hace emitiendo operaciones de shell o
|
||||
de archivos. Lo que **no** confina: todo lo que el agente hace en su propio
|
||||
proceso Python. Eso incluye la herramienta de ejecución de código (lanzada como
|
||||
subproceso del host), subprocesos MCP (lanzados desde el entorno del agente),
|
||||
carga de plugins, despacho de hooks y carga de habilidades (todos importados en el
|
||||
intérprete del agente).
|
||||
|
||||
El aislamiento del backend de terminal es la postura correcta cuando la preocupación es
|
||||
que el LLM emita comandos de shell destructivos o escrituras de herramientas de archivo no deseadas, y el
|
||||
operador es de confianza.
|
||||
|
||||
#### Envoltura del proceso completo
|
||||
|
||||
La envoltura del proceso completo ejecuta todo el árbol de procesos del agente dentro de un
|
||||
sandbox. Cada ruta de código — shell, ejecución de código, MCP, herramientas de archivos,
|
||||
plugins, hooks, carga de habilidades — está sujeta a la misma política de sistema de archivos,
|
||||
red, proceso e (donde sea aplicable) inferencia.
|
||||
|
||||
Hermes Agent admite esto de dos maneras:
|
||||
|
||||
- **La propia imagen Docker de Hermes Agent y la configuración de Compose.** Más
|
||||
liviana; el agente se ejecuta en un contenedor estándar con montajes y
|
||||
política de red configurados por el operador.
|
||||
- **[NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell)**.
|
||||
OpenShell proporciona sandboxes por sesión con política declarativa
|
||||
a través de capas de sistema de archivos, red (egreso L7), proceso/syscall e
|
||||
enrutamiento de inferencia. Las políticas de red e inferencia son
|
||||
recargables en caliente. Las credenciales se inyectan desde un almacén de Proveedor
|
||||
y nunca tocan el sistema de archivos del sandbox.
|
||||
|
||||
Bajo una envoltura de proceso completo, las heurísticas en proceso de Hermes Agent
|
||||
(§2.4) funcionan como prevención de accidentes en capas sobre un límite real.
|
||||
Esta es la postura soportada cuando el agente ingiere contenido de superficies
|
||||
que el operador no controla — la web abierta, email entrante, canales de
|
||||
múltiples usuarios, servidores MCP no confiables — y para despliegues en
|
||||
producción o compartidos.
|
||||
|
||||
Los operadores que ejecuten el backend local predeterminado con superficies de entrada
|
||||
no confiables, o que ejecuten un sandbox de backend de terminal esperando que contenga
|
||||
rutas de código que no pasan por el shell, están operando fuera de la postura de
|
||||
seguridad soportada.
|
||||
|
||||
### 2.3 Alcance de Credenciales
|
||||
|
||||
Hermes Agent filtra el entorno que pasa a sus componentes en proceso de
|
||||
menor confianza: subprocesos de shell, subprocesos MCP y el proceso hijo
|
||||
de ejecución de código. Las credenciales como las claves API del proveedor y los
|
||||
tokens del gateway se eliminan por defecto; las variables declaradas explícitamente
|
||||
por el operador o por una habilidad cargada se pasan.
|
||||
|
||||
Esto reduce la exfiltración casual. No es contención. Cualquier
|
||||
componente que se ejecute dentro del proceso del agente (habilidades, plugins, manejadores
|
||||
de hooks) puede leer lo que el agente mismo puede leer, incluidas las
|
||||
credenciales en memoria. La mitigación contra un componente en proceso comprometido
|
||||
es la revisión del operador antes de instalar (§2.4, §2.5), no el
|
||||
saneamiento del entorno.
|
||||
|
||||
### 2.4 Heurísticas en Proceso
|
||||
|
||||
Los siguientes componentes filtran o advierten sobre el comportamiento del LLM. Son
|
||||
útiles. No son límites.
|
||||
|
||||
- La **puerta de aprobación** detecta patrones de shell destructivos comunes
|
||||
y le pide al operador confirmación antes de la ejecución. El shell es Turing-
|
||||
completo; una lista de denegación sobre cadenas de shell es estructuralmente
|
||||
incompleta. La puerta detecta errores en modo cooperativo, no salidas
|
||||
adversariales.
|
||||
- **La redacción de salida** elimina patrones similares a secretos de la visualización.
|
||||
Un productor de salida motivado la evitará.
|
||||
- **Skills Guard** escanea el contenido de habilidades instalables en busca de patrones
|
||||
de inyección. Es una ayuda de revisión; el límite para habilidades de terceros
|
||||
es la revisión del operador antes de instalar. Revisar una habilidad significa
|
||||
leer su código Python y scripts, no solo su descripción SKILL.md —
|
||||
las habilidades ejecutan Python arbitrario en el momento de importación.
|
||||
|
||||
### 2.5 Modelo de Confianza de Plugins
|
||||
|
||||
Los plugins se cargan en el proceso del agente y se ejecutan con todos los privilegios
|
||||
del agente: pueden leer las mismas credenciales, llamar a las mismas
|
||||
herramientas, registrar los mismos hooks e importar los mismos módulos que
|
||||
cualquier cosa incluida en el árbol. El límite para los plugins de terceros es
|
||||
la revisión del operador antes de instalar — la misma regla que las habilidades (§2.4),
|
||||
mencionado por separado porque los plugins son arquitectónicamente más pesados
|
||||
y a menudo incluyen sus propios servicios en segundo plano, oyentes de red
|
||||
y dependencias.
|
||||
|
||||
Un plugin malicioso o con errores no es una vulnerabilidad en Hermes Agent
|
||||
en sí mismo. Los errores en la ruta de instalación o descubrimiento de plugins de Hermes Agent
|
||||
que impidan al operador ver lo que está instalando están en alcance bajo el §3.1.
|
||||
|
||||
### 2.6 Superficies Externas
|
||||
|
||||
Una **superficie externa** es cualquier canal fuera del proceso del agente local
|
||||
a través del cual un llamador puede despachar trabajo del agente, resolver
|
||||
aprobaciones o recibir salida del agente. Cada superficie tiene su propio
|
||||
modelo de autorización, pero las reglas a continuación se aplican uniformemente.
|
||||
|
||||
**Superficies en Hermes Agent:**
|
||||
|
||||
- **Adaptadores de plataforma del gateway.** Integraciones de mensajería en
|
||||
`gateway/platforms/` (Telegram, Discord, Slack, email, SMS, etc.)
|
||||
y adaptadores análogos incluidos como plugins.
|
||||
- **Superficies HTTP expuestas en red.** El adaptador del servidor API, el
|
||||
plugin del dashboard, los endpoints HTTP del plugin kanban, y cualquier
|
||||
otro plugin que vincule un socket de escucha.
|
||||
- **Adaptadores de Editor / IDE.** El adaptador ACP (`acp_adapter/`) e
|
||||
integraciones equivalentes que aceptan solicitudes de un proceso cliente local.
|
||||
- **El gateway TUI (`tui_gateway/`).** Backend JSON-RPC para la
|
||||
UI de terminal Ink, alcanzado a través de IPC local.
|
||||
|
||||
**Reglas uniformes:**
|
||||
|
||||
1. **Se requiere autorización en cada superficie que cruce un límite de confianza.** Para
|
||||
superficies de mensajería y HTTP en red, el límite es la red: la autorización
|
||||
significa una lista de llamadores permitidos configurada por el operador. Para superficies
|
||||
de editor e IPC local (ACP, gateway TUI), el límite es la cuenta de usuario del host:
|
||||
la autorización significa depender del control de acceso a nivel de SO (permisos
|
||||
de archivos, vinculaciones solo a loopback) y no exponer la superficie más allá
|
||||
del usuario local sin una capa de autenticación de red explícita.
|
||||
2. **Se requiere una lista de permitidos para cada adaptador de red habilitado.**
|
||||
Los adaptadores deben rechazar despachar trabajo del agente, resolver
|
||||
aprobaciones o transmitir salida hasta que se establezca una lista de permitidos. Las rutas
|
||||
de código que fallan de forma abierta cuando no hay lista de permitidos configurada son errores de código en
|
||||
alcance bajo el §3.1.
|
||||
3. **Los identificadores de sesión son manejadores de enrutamiento, no límites de autorización.**
|
||||
Conocer el ID de sesión de otro llamador no otorga acceso a sus aprobaciones o salida;
|
||||
la autorización siempre se vuelve a verificar contra la lista de permitidos (o equivalente
|
||||
a nivel de SO).
|
||||
4. **Dentro del conjunto autorizado, todos los llamadores tienen la misma confianza.**
|
||||
Hermes Agent no modela capacidades por llamador dentro de un único adaptador.
|
||||
Los operadores que necesiten separación de capacidades deben ejecutar instancias
|
||||
de agente separadas con listas de permitidos separadas.
|
||||
5. **Vincular una superficie solo local a una interfaz no-loopback es una decisión de
|
||||
operador de emergencia (§3.2).** El dashboard y otros servidores HTTP de plugins
|
||||
son predeterminados a loopback; exponerlos a través de `--host 0.0.0.0` o equivalente
|
||||
hace que el fortalecimiento de exposición pública (§4) sea responsabilidad del operador.
|
||||
|
||||
---
|
||||
|
||||
## 3. Alcance
|
||||
|
||||
### 3.1 En Alcance
|
||||
|
||||
- Escape de una postura de aislamiento a nivel de SO declarada (§2.2): una
|
||||
ruta de código controlada por el atacante alcanzando estado que la postura
|
||||
afirmó confinar.
|
||||
- Acceso no autorizado a superficie externa: un llamador fuera del conjunto de
|
||||
autorización configurado (lista de permitidos, o equivalente a nivel de SO
|
||||
para superficies de IPC local) despachando trabajo, recibiendo salida o
|
||||
resolviendo aprobaciones (§2.6).
|
||||
- Exfiltración de credenciales: filtración de credenciales del operador o
|
||||
material de autorización de sesión a un destino fuera del envolvente de
|
||||
confianza, a través de un mecanismo que debería haberlo prevenido
|
||||
(error de saneamiento de entorno, registro del adaptador, error de transporte
|
||||
que vacía credenciales a un upstream, etc.).
|
||||
- Violaciones de la documentación del modelo de confianza: código que se comporta
|
||||
contrariamente a lo que esta política, la propia documentación de Hermes Agent o
|
||||
las expectativas razonables del operador predecirían — incluyendo casos donde
|
||||
Hermes Agent ha documentado una postura sobre cómo su salida debe ser
|
||||
renderizada por una capa consumidora (dashboard, adaptador de gateway,
|
||||
escritor de archivos, shell) y una ruta de código rompe esa postura.
|
||||
|
||||
### 3.2 Fuera de Alcance
|
||||
|
||||
"Fuera de alcance" aquí significa "no es una vulnerabilidad de seguridad bajo esta
|
||||
política." No significa "no vale la pena reportarlo." Las mejoras a las
|
||||
heurísticas en proceso, ideas de fortalecimiento y correcciones de UX son bienvenidas como
|
||||
issues o pull requests regulares — la puerta de aprobación siempre puede detectar
|
||||
más patrones, la redacción puede volverse más inteligente, el comportamiento del adaptador
|
||||
puede apretarse siempre. Estos elementos simplemente no van a través del canal de
|
||||
divulgación privada y no reciben avisos.
|
||||
|
||||
- **Bypasses de heurísticas en proceso (§2.4)** — bypasses de regex de la puerta de aprobación,
|
||||
bypasses de redacción, bypasses de patrones de Skills Guard, e informes
|
||||
análogos contra heurísticas futuras. Estos componentes no son límites;
|
||||
vencerlos no es una vulnerabilidad bajo esta política.
|
||||
- **Inyección de prompts per se.** Hacer que el LLM emita salida inusual
|
||||
— a través de contenido inyectado, alucinación, artefactos de entrenamiento,
|
||||
o cualquier otra causa — no es en sí mismo una vulnerabilidad. "Logré
|
||||
inyección de prompts" sin un resultado encadenado del §3.1 no es un informe
|
||||
procesable bajo esta política.
|
||||
- **Consecuencias de una postura de aislamiento elegida.** Los informes de que
|
||||
una ruta de código que opera dentro del alcance de su postura puede hacer lo que esa
|
||||
postura permite no son vulnerabilidades. Ejemplos: herramientas de shell o archivos
|
||||
que alcanzan estado del host bajo el backend local; subprocesos de ejecución de código
|
||||
o MCP que alcanzan estado del host bajo aislamiento de backend de terminal que solo
|
||||
sandboxea el shell; informes cuyas precondiciones requieren acceso de escritura preexistente
|
||||
a archivos de configuración o credenciales propiedad del operador (esos ya están dentro
|
||||
del envolvente de confianza).
|
||||
- **Configuraciones documentadas de emergencia.** Compensaciones seleccionadas por el operador
|
||||
que deshabilitan explícitamente protecciones: `--insecure` y flags equivalentes
|
||||
en el dashboard u otros componentes, aprobaciones deshabilitadas,
|
||||
backend local en producción, perfiles de desarrollo que evitan
|
||||
la seguridad de hermes-home, y similares. Los informes contra esas
|
||||
configuraciones no son vulnerabilidades — eso es el trabajo del flag.
|
||||
- **Habilidades y plugins contribuidos por la comunidad.** Las habilidades de terceros
|
||||
(incluyendo el repositorio de habilidades de la comunidad) y los plugins de terceros
|
||||
están en la superficie de revisión del operador, no en la superficie de confianza de Hermes Agent
|
||||
(§2.4, §2.5). Una habilidad o plugin que haga algo
|
||||
malicioso es el modo de falla esperado de uno que no fue
|
||||
revisado, no una vulnerabilidad en Hermes Agent. Los errores en la ruta de
|
||||
instalación de habilidades o plugins de Hermes Agent que impidan al
|
||||
operador ver lo que está instalando están en alcance bajo el §3.1.
|
||||
- **Exposición pública sin controles externos.** Exponer el
|
||||
gateway o la API a la internet pública sin autenticación,
|
||||
VPN o firewall.
|
||||
- **Restricciones de lectura/escritura a nivel de herramienta en una postura donde el shell está
|
||||
permitido.** Si una ruta es alcanzable a través de la herramienta terminal, los informes
|
||||
de que otras herramientas de archivos pueden alcanzarla no añaden nada.
|
||||
|
||||
---
|
||||
|
||||
## 4. Fortalecimiento del Despliegue
|
||||
|
||||
La decisión de fortalecimiento más importante es hacer coincidir el aislamiento
|
||||
(§2.2) con la confianza del contenido que el agente ingerirá. Más allá de eso:
|
||||
|
||||
- Ejecuta el agente como usuario no-root. La imagen de contenedor proporcionada
|
||||
hace esto por defecto.
|
||||
- Mantén las credenciales en el archivo de credenciales del operador con permisos
|
||||
estrictos, nunca en la configuración principal, nunca en control de versiones.
|
||||
Bajo OpenShell, usa el almacén de Proveedores en lugar de un archivo de
|
||||
credenciales en disco.
|
||||
- No expongas el gateway o la API a la internet pública sin
|
||||
VPN, Tailscale o protección de firewall. Bajo OpenShell, usa la
|
||||
capa de política de red para restringir el egreso.
|
||||
- Configura una lista de llamadores permitidos para cada adaptador de red expuesto
|
||||
que habilites (§2.6).
|
||||
- Revisa las habilidades y plugins de terceros antes de instalar (§2.4,
|
||||
§2.5). Para las habilidades, esto significa leer el Python y los scripts,
|
||||
no solo SKILL.md. Los informes de Skills Guard y el registro de auditoría
|
||||
de instalación son la superficie de revisión.
|
||||
- Hermes Agent incluye guardias de cadena de suministro para lanzamientos de servidores
|
||||
MCP y para cambios de dependencias / paquetes incluidos en CI; consulta
|
||||
`CONTRIBUTING.es.md` para más detalles.
|
||||
|
||||
---
|
||||
|
||||
## 5. Divulgación
|
||||
|
||||
- **Ventana de divulgación coordinada:** 90 días desde el informe, o hasta que se
|
||||
publique una corrección, lo que ocurra primero.
|
||||
- **Canal:** el hilo GHSA o correspondencia por email con
|
||||
security@nousresearch.com.
|
||||
- **Crédito:** los reportadores reciben crédito en las notas de versión a menos que
|
||||
se solicite anonimato.
|
||||
+4
-5
@@ -121,11 +121,10 @@ outside the supported security posture.
|
||||
### 2.3 Credential Scoping
|
||||
|
||||
Hermes Agent filters the environment it passes to its lower-trust
|
||||
in-process components: shell subprocesses, MCP subprocesses,
|
||||
cron job scripts, and the code-execution child. Credentials like
|
||||
provider API keys and gateway tokens are stripped by default;
|
||||
variables explicitly declared by the operator or by a loaded
|
||||
skill are passed through.
|
||||
in-process components: shell subprocesses, MCP subprocesses, and
|
||||
the code-execution child. Credentials like provider API keys and
|
||||
gateway tokens are stripped by default; variables explicitly
|
||||
declared by the operator or by a loaded skill are passed through.
|
||||
|
||||
This reduces casual exfiltration. It is not containment. Any
|
||||
component running inside the agent process (skills, plugins, hook
|
||||
|
||||
@@ -23,11 +23,6 @@ except ModuleNotFoundError:
|
||||
# new code but ``uv pip install -e .`` didn't finish. Missing bootstrap
|
||||
# means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected.
|
||||
pass
|
||||
else:
|
||||
# Stop a ``utils/``/``proxy/``/``ui/`` package in the launch directory from
|
||||
# shadowing Hermes's own modules — ``hermes acp`` can be started from any
|
||||
# cwd, including a project that has same-named packages on its path.
|
||||
hermes_bootstrap.harden_import_path()
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
+14
-37
@@ -74,10 +74,6 @@ from acp_adapter.permissions import make_approval_callback
|
||||
from acp_adapter.provenance import session_provenance_meta
|
||||
from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
|
||||
from acp_adapter.tools import build_tool_complete, build_tool_start
|
||||
from tools.approval import (
|
||||
reset_hermes_interactive_context,
|
||||
set_hermes_interactive_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -828,7 +824,6 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
enabled_toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
|
||||
@@ -844,7 +839,6 @@ class HermesACPAgent(acp.Agent):
|
||||
state.agent.valid_tool_names = {
|
||||
tool["function"]["name"] for tool in state.agent.tools or []
|
||||
}
|
||||
inject_memory_provider_tools(state.agent)
|
||||
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
|
||||
if callable(invalidate):
|
||||
invalidate()
|
||||
@@ -1450,23 +1444,20 @@ class HermesACPAgent(acp.Agent):
|
||||
# Approval callback is per-thread (thread-local, GHSA-qg5c-hvr5-hjgr).
|
||||
# Set it INSIDE _run_agent so the TLS write happens in the executor
|
||||
# thread — setting it here would write to the event-loop thread's TLS,
|
||||
# not the executor's. Interactive routing uses a contextvar in
|
||||
# tools.approval (set_hermes_interactive_context) rather than
|
||||
# os.environ["HERMES_INTERACTIVE"], so concurrent executor workers can't
|
||||
# race on a process-global flag — one session's restore can't drop
|
||||
# another onto the non-interactive auto-approve path mid-run
|
||||
# (GHSA-96vc-wcxf-jjff). The contextvar write is isolated by the
|
||||
# contextvars.copy_context() wrapper around the executor call below.
|
||||
# not the executor's. Also set HERMES_INTERACTIVE so approval.py
|
||||
# takes the CLI-interactive path (which calls the registered
|
||||
# callback via prompt_dangerous_approval) instead of the
|
||||
# non-interactive auto-approve branch (GHSA-96vc-wcxf-jjff).
|
||||
# ACP's conn.request_permission maps cleanly to the interactive
|
||||
# callback shape — not the gateway-queue HERMES_EXEC_ASK path,
|
||||
# which requires a notify_cb registered in _gateway_notify_cbs.
|
||||
previous_approval_cb = None
|
||||
interactive_token = None
|
||||
previous_interactive = None
|
||||
edit_approval_token = None
|
||||
previous_session_id = None
|
||||
|
||||
def _run_agent() -> dict:
|
||||
nonlocal previous_approval_cb, interactive_token, edit_approval_token, previous_session_id
|
||||
nonlocal previous_approval_cb, previous_interactive, edit_approval_token, previous_session_id
|
||||
# Bind HERMES_SESSION_KEY for this session so per-session caches
|
||||
# (e.g. the interactive sudo password cache in tools.terminal_tool)
|
||||
# scope to the ACP session rather than leaking across sessions
|
||||
@@ -1498,10 +1489,9 @@ class HermesACPAgent(acp.Agent):
|
||||
except Exception:
|
||||
logger.debug("Could not set ACP edit approval requester", exc_info=True)
|
||||
# Signal to tools.approval that we have an interactive callback
|
||||
# and the non-interactive auto-approve path must not fire. Uses a
|
||||
# contextvar (not os.environ) so concurrent executor workers don't
|
||||
# race on the flag (GHSA-96vc-wcxf-jjff).
|
||||
interactive_token = set_hermes_interactive_context(True)
|
||||
# and the non-interactive auto-approve path must not fire.
|
||||
previous_interactive = os.environ.get("HERMES_INTERACTIVE")
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
# Propagate the originating ACP session id to tools that want to
|
||||
# tag side-effects with it (e.g. ``kanban_create`` stamps it on
|
||||
# the new task so clients can render a per-session board). Save
|
||||
@@ -1521,9 +1511,11 @@ class HermesACPAgent(acp.Agent):
|
||||
logger.exception("Agent error in session %s", session_id)
|
||||
return {"final_response": f"Error: {e}", "messages": state.history}
|
||||
finally:
|
||||
# Restore the interactive contextvar for this context.
|
||||
if interactive_token is not None:
|
||||
reset_hermes_interactive_context(interactive_token)
|
||||
# Restore HERMES_INTERACTIVE.
|
||||
if previous_interactive is None:
|
||||
os.environ.pop("HERMES_INTERACTIVE", None)
|
||||
else:
|
||||
os.environ["HERMES_INTERACTIVE"] = previous_interactive
|
||||
# Restore HERMES_SESSION_ID symmetrically.
|
||||
if previous_session_id is None:
|
||||
os.environ.pop("HERMES_SESSION_ID", None)
|
||||
@@ -1787,25 +1779,10 @@ class HermesACPAgent(acp.Agent):
|
||||
def _cmd_tools(self, args: str, state: SessionState) -> str:
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from types import SimpleNamespace
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
|
||||
)
|
||||
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
|
||||
tool_view = SimpleNamespace(
|
||||
tools=list(tools or []),
|
||||
valid_tool_names={
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools or []
|
||||
if isinstance(tool, dict)
|
||||
},
|
||||
enabled_toolsets=toolsets,
|
||||
_memory_manager=getattr(state.agent, "_memory_manager", None),
|
||||
)
|
||||
inject_memory_provider_tools(tool_view)
|
||||
tools = tool_view.tools
|
||||
if not tools:
|
||||
return "No tools available."
|
||||
lines = [f"Available tools ({len(tools)}):"]
|
||||
|
||||
@@ -617,10 +617,6 @@ class SessionManager:
|
||||
|
||||
_register_task_cwd(session_id, cwd)
|
||||
agent = AIAgent(**kwargs)
|
||||
# Codex app-server sessions are spawned lazily on the first turn. Stamp
|
||||
# the ACP workspace onto the agent so the Codex runtime starts from the
|
||||
# editor/session cwd instead of the Hermes daemon's process cwd.
|
||||
agent.session_cwd = cwd
|
||||
# ACP stdio transport requires stdout to remain protocol-only JSON-RPC.
|
||||
# Route any incidental human-readable agent output to stderr instead.
|
||||
agent._print_fn = _acp_stderr_print
|
||||
|
||||
@@ -74,7 +74,7 @@ _POLISHED_TOOLS = {
|
||||
"kanban_create", "kanban_show", "kanban_comment", "kanban_complete",
|
||||
"kanban_block", "kanban_link", "kanban_heartbeat",
|
||||
"yb_query_group_info", "yb_query_group_members", "yb_search_sticker",
|
||||
"yb_send_dm", "yb_send_sticker",
|
||||
"yb_send_dm", "yb_send_sticker", "mixture_of_agents",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.17.0",
|
||||
"version": "0.16.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",
|
||||
@@ -9,7 +9,7 @@
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.17.0",
|
||||
"package": "hermes-agent[acp]==0.16.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-90
@@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
account info to show (fail-open: caller just shows nothing).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.nous_account import nous_portal_topup_url
|
||||
from hermes_cli.nous_account import nous_portal_billing_url
|
||||
|
||||
if account_info is None or not getattr(account_info, "logged_in", False):
|
||||
return None
|
||||
@@ -213,8 +213,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
if not windows and not details:
|
||||
return None
|
||||
|
||||
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
|
||||
details.append("(or run /credits)")
|
||||
details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
|
||||
|
||||
plan = getattr(sub, "plan", None) if sub is not None else None
|
||||
return AccountUsageSnapshot(
|
||||
@@ -338,93 +337,6 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreditsView:
|
||||
"""Surface-agnostic data for the ``/credits`` command.
|
||||
|
||||
One portal fetch, one parse — consumed identically by the CLI panel, the
|
||||
gateway button, and any other money surface. Fail-open: when not logged in
|
||||
or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None
|
||||
and callers degrade gracefully.
|
||||
"""
|
||||
|
||||
logged_in: bool
|
||||
balance_lines: tuple[str, ...] = ()
|
||||
identity_line: Optional[str] = None
|
||||
topup_url: Optional[str] = None
|
||||
depleted: bool = False
|
||||
|
||||
|
||||
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
|
||||
"""Build the /credits view: balance block + identity line + top-up URL.
|
||||
|
||||
Reuses the same account fetch + snapshot + URL builder as the /usage credits
|
||||
block, so the numbers always match. The balance block is the rendered
|
||||
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
|
||||
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
|
||||
"""
|
||||
not_logged_in = CreditsView(logged_in=False)
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
tok = (get_provider_auth_state("nous") or {}).get("access_token")
|
||||
if not (isinstance(tok, str) and tok.strip()):
|
||||
return not_logged_in
|
||||
except Exception:
|
||||
return not_logged_in
|
||||
|
||||
try:
|
||||
import concurrent.futures
|
||||
|
||||
from hermes_cli.nous_account import (
|
||||
get_nous_portal_account_info,
|
||||
nous_portal_topup_url,
|
||||
)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(
|
||||
timeout=timeout
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
|
||||
return not_logged_in
|
||||
|
||||
if account is None or not getattr(account, "logged_in", False):
|
||||
return not_logged_in
|
||||
|
||||
snapshot = build_nous_credits_snapshot(account)
|
||||
# Balance lines = the snapshot block minus the two trailing affordance lines
|
||||
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
|
||||
# appends for the /usage surface. /credits renders its own button/panel.
|
||||
balance_lines: list[str] = []
|
||||
if snapshot is not None:
|
||||
rendered = render_account_usage_lines(snapshot, markdown=markdown)
|
||||
balance_lines = [
|
||||
line
|
||||
for line in rendered
|
||||
if not line.lstrip().startswith("Top up:")
|
||||
and not line.lstrip().startswith("(or run")
|
||||
]
|
||||
|
||||
# Identity line — shown before any open (roadmap §4.4).
|
||||
email = getattr(account, "email", None)
|
||||
org_name = getattr(account, "org_name", None)
|
||||
who: list[str] = []
|
||||
if email:
|
||||
who.append(str(email))
|
||||
if org_name:
|
||||
who.append(f"org {org_name}")
|
||||
identity_line = ("Topping up as " + " / ".join(who)) if who else None
|
||||
|
||||
return CreditsView(
|
||||
logged_in=True,
|
||||
balance_lines=tuple(balance_lines),
|
||||
identity_line=identity_line,
|
||||
topup_url=nous_portal_topup_url(account),
|
||||
depleted=getattr(account, "paid_service_access", None) is False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_codex_usage_url(base_url: str) -> str:
|
||||
normalized = (base_url or "").strip().rstrip("/")
|
||||
if not normalized:
|
||||
|
||||
+52
-199
@@ -27,7 +27,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
from agent.context_compressor import ContextCompressor
|
||||
@@ -50,7 +50,7 @@ from agent.tool_guardrails import (
|
||||
from hermes_cli.config import cfg_get
|
||||
from hermes_cli.timeouts import get_provider_request_timeout
|
||||
from hermes_constants import get_hermes_home
|
||||
from utils import base_url_host_matches, is_truthy_value
|
||||
from utils import base_url_host_matches
|
||||
|
||||
# Use the same logger name as run_agent so tests patching ``run_agent.logger``
|
||||
# capture our warnings. (run_agent.py also does
|
||||
@@ -106,12 +106,7 @@ def _custom_provider_extra_body_for_agent(
|
||||
base_url: str,
|
||||
custom_providers: List[Dict[str, Any]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
provider_norm = (provider or "").strip().lower()
|
||||
if provider_norm == "custom":
|
||||
provider_key_filter = ""
|
||||
elif provider_norm.startswith("custom:"):
|
||||
provider_key_filter = provider_norm.split(":", 1)[1].strip()
|
||||
else:
|
||||
if (provider or "").strip().lower() != "custom":
|
||||
return None
|
||||
|
||||
target_url = _normalized_custom_base_url(base_url)
|
||||
@@ -122,13 +117,6 @@ def _custom_provider_extra_body_for_agent(
|
||||
for entry in custom_providers or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if provider_key_filter:
|
||||
entry_keys = {
|
||||
str(entry.get("provider_key", "") or "").strip().lower(),
|
||||
str(entry.get("name", "") or "").strip().lower(),
|
||||
}
|
||||
if provider_key_filter not in entry_keys:
|
||||
continue
|
||||
if _normalized_custom_base_url(entry.get("base_url")) != target_url:
|
||||
continue
|
||||
extra_body = entry.get("extra_body")
|
||||
@@ -199,7 +187,6 @@ def init_agent(
|
||||
thinking_callback: callable = None,
|
||||
reasoning_callback: callable = None,
|
||||
clarify_callback: callable = None,
|
||||
read_terminal_callback: callable = None,
|
||||
step_callback: callable = None,
|
||||
stream_delta_callback: callable = None,
|
||||
interim_assistant_callback: callable = None,
|
||||
@@ -207,7 +194,6 @@ def init_agent(
|
||||
status_callback: callable = None,
|
||||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
@@ -277,8 +263,7 @@ def init_agent(
|
||||
output_config.format instead of a trailing-assistant prefill.
|
||||
platform (str): The interface platform the user is on (e.g. "cli", "telegram", "discord", "whatsapp").
|
||||
Used to inject platform-specific formatting hints into the system prompt.
|
||||
skip_context_files (bool): If True, skip auto-injection of project context files
|
||||
(SOUL.md, .hermes.md, AGENTS.md, CLAUDE.md, .cursorrules) from the cwd / HERMES_HOME
|
||||
skip_context_files (bool): If True, skip auto-injection of SOUL.md, AGENTS.md, and .cursorrules
|
||||
into the system prompt. Use this for batch processing and data generation to avoid
|
||||
polluting trajectories with user-specific persona or project instructions.
|
||||
load_soul_identity (bool): If True, still use ~/.hermes/SOUL.md as the primary
|
||||
@@ -313,7 +298,6 @@ def init_agent(
|
||||
# would mangle the escape sequences. None = use builtins.print.
|
||||
agent._print_fn = None
|
||||
agent.background_review_callback = None # Optional sync callback for gateway delivery
|
||||
agent.memory_notifications = "on" # Memory update notifications: "off", "on", "verbose"
|
||||
agent.skip_context_files = skip_context_files
|
||||
agent.load_soul_identity = load_soul_identity
|
||||
agent.pass_session_id = pass_session_id
|
||||
@@ -433,14 +417,12 @@ def init_agent(
|
||||
agent.thinking_callback = thinking_callback
|
||||
agent.reasoning_callback = reasoning_callback
|
||||
agent.clarify_callback = clarify_callback
|
||||
agent.read_terminal_callback = read_terminal_callback
|
||||
agent.step_callback = step_callback
|
||||
agent.stream_delta_callback = stream_delta_callback
|
||||
agent.interim_assistant_callback = interim_assistant_callback
|
||||
agent.status_callback = status_callback
|
||||
agent.notice_callback = notice_callback
|
||||
agent.notice_clear_callback = notice_clear_callback
|
||||
agent.event_callback = event_callback
|
||||
agent.tool_gen_callback = tool_gen_callback
|
||||
|
||||
|
||||
@@ -544,14 +526,7 @@ def init_agent(
|
||||
agent._last_activity_desc: str = "initializing"
|
||||
agent._current_tool: str | None = None
|
||||
agent._api_call_count: int = 0
|
||||
# Opt-out flag for the between-turns MCP tool refresh (build_turn_context).
|
||||
# Set on internal forks (e.g. background_review) that must keep ``tools[]``
|
||||
# byte-identical to a parent for provider cache parity.
|
||||
agent._skip_mcp_refresh = False
|
||||
# Registry generation the current tool snapshot was derived from. Lets a
|
||||
# late/concurrent refresh reject a stale (older-generation) rebuild instead
|
||||
# of clobbering a newer one. Set adjacent to the tool snapshot below.
|
||||
agent._tool_snapshot_generation = 0
|
||||
|
||||
# Rate limit tracking — updated from x-ratelimit-* response headers
|
||||
# after each API call. Accessed by /usage slash command.
|
||||
agent._rate_limit_state: Optional["RateLimitState"] = None
|
||||
@@ -619,7 +594,6 @@ def init_agent(
|
||||
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
|
||||
agent._persist_user_message_idx = None
|
||||
agent._persist_user_message_override = None
|
||||
agent._persist_user_message_timestamp = None
|
||||
|
||||
# Cache anthropic image-to-text fallbacks per image payload/URL so a
|
||||
# single tool loop does not repeatedly re-run auxiliary vision on the
|
||||
@@ -719,55 +693,6 @@ def init_agent(
|
||||
print("🔑 Using credentials: Microsoft Entra ID")
|
||||
elif isinstance(effective_key, str) and len(effective_key) > 12:
|
||||
print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}")
|
||||
elif agent.provider == "moa":
|
||||
from agent.moa_loop import MoAClient
|
||||
agent.api_mode = "chat_completions"
|
||||
|
||||
# Route reference-model outputs to the agent's tool_progress_callback so
|
||||
# every surface that already consumes it (CLI spinner/scrollback, TUI,
|
||||
# desktop, gateway) can show each reference's answer as a labelled block
|
||||
# before the aggregator acts. The facade emits "moa.reference" and
|
||||
# "moa.aggregating" events; we forward them through the same callback
|
||||
# the tool lifecycle uses. Best-effort and cache-safe — these are
|
||||
# display-only events, they never touch the message history.
|
||||
def _moa_reference_relay(event: str, **kwargs: Any) -> None:
|
||||
cb = getattr(agent, "tool_progress_callback", None)
|
||||
if cb is None:
|
||||
return
|
||||
try:
|
||||
if event == "moa.reference":
|
||||
label = str(kwargs.get("label") or "")
|
||||
text = str(kwargs.get("text") or "")
|
||||
idx = kwargs.get("index")
|
||||
count = kwargs.get("count")
|
||||
cb(
|
||||
"moa.reference",
|
||||
label,
|
||||
text,
|
||||
None,
|
||||
moa_index=idx,
|
||||
moa_count=count,
|
||||
)
|
||||
elif event == "moa.aggregating":
|
||||
cb(
|
||||
"moa.aggregating",
|
||||
str(kwargs.get("aggregator") or ""),
|
||||
None,
|
||||
None,
|
||||
moa_ref_count=kwargs.get("ref_count"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
agent.client = MoAClient(
|
||||
agent.model or "default",
|
||||
reference_callback=_moa_reference_relay,
|
||||
)
|
||||
agent._client_kwargs = {}
|
||||
agent.api_key = api_key or "moa-virtual-provider"
|
||||
agent.base_url = "moa://local"
|
||||
if not agent.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with MoA preset: {agent.model}")
|
||||
elif agent.api_mode == "bedrock_converse":
|
||||
# AWS Bedrock — uses boto3 directly, no OpenAI client needed.
|
||||
# Region is extracted from the base_url or defaults to us-east-1.
|
||||
@@ -828,7 +753,7 @@ def init_agent(
|
||||
client_kwargs["default_headers"] = build_nvidia_nim_headers(effective_base)
|
||||
elif base_url_host_matches(effective_base, "api.routermint.com"):
|
||||
client_kwargs["default_headers"] = _ra()._routermint_headers()
|
||||
elif base_url_host_matches(effective_base, "githubcopilot.com"):
|
||||
elif base_url_host_matches(effective_base, "api.githubcopilot.com"):
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
|
||||
client_kwargs["default_headers"] = copilot_default_headers()
|
||||
@@ -869,8 +794,6 @@ def init_agent(
|
||||
# _custom_headers; older/mocked clients may expose
|
||||
# _default_headers instead.
|
||||
_routed_headers = getattr(_routed_client, "_custom_headers", None)
|
||||
if not _routed_headers:
|
||||
_routed_headers = getattr(_routed_client, "default_headers", None)
|
||||
if not _routed_headers:
|
||||
_routed_headers = getattr(_routed_client, "_default_headers", None)
|
||||
if _routed_headers:
|
||||
@@ -924,8 +847,6 @@ def init_agent(
|
||||
if _provider_timeout is not None:
|
||||
client_kwargs["timeout"] = _provider_timeout
|
||||
_fb_headers = getattr(_fb_client, "_custom_headers", None)
|
||||
if not _fb_headers:
|
||||
_fb_headers = getattr(_fb_client, "default_headers", None)
|
||||
if not _fb_headers:
|
||||
_fb_headers = getattr(_fb_client, "_default_headers", None)
|
||||
if _fb_headers:
|
||||
@@ -977,9 +898,6 @@ def init_agent(
|
||||
agent.api_key = client_kwargs.get("api_key", "")
|
||||
agent.base_url = client_kwargs.get("base_url", agent.base_url)
|
||||
try:
|
||||
from agent.ssl_guard import verify_ca_bundle_with_fallback
|
||||
|
||||
verify_ca_bundle_with_fallback()
|
||||
agent.client = agent._create_openai_client(client_kwargs, reason="agent_init", shared=True)
|
||||
if not agent.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with model: {agent.model}")
|
||||
@@ -1026,14 +944,7 @@ def init_agent(
|
||||
print(f"🔄 Fallback chain ({len(agent._fallback_chain)} providers): " +
|
||||
" → ".join(f"{f['model']} ({f['provider']})" for f in agent._fallback_chain))
|
||||
|
||||
# Get available tools with filtering. Capture the registry generation this
|
||||
# snapshot is derived from FIRST, so a later concurrent refresh can tell
|
||||
# whether it holds a newer or staler view (see refresh_agent_mcp_tools).
|
||||
try:
|
||||
from tools.registry import registry as _snapshot_registry
|
||||
agent._tool_snapshot_generation = _snapshot_registry._generation
|
||||
except Exception:
|
||||
agent._tool_snapshot_generation = 0
|
||||
# Get available tools with filtering
|
||||
agent.tools = _ra().get_tool_definitions(
|
||||
enabled_toolsets=enabled_toolsets,
|
||||
disabled_toolsets=disabled_toolsets,
|
||||
@@ -1161,12 +1072,6 @@ def init_agent(
|
||||
agent._parent_session_id = parent_session_id
|
||||
agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes
|
||||
agent._session_db_created = False # DB row deferred to run_conversation()
|
||||
# Most agents own their session row and should finalize it on close().
|
||||
# Some temporary helper agents (manual compression / session-hygiene /
|
||||
# background-review forks) rotate or share the session forward to a
|
||||
# continuation row that must remain open after the helper is torn down;
|
||||
# those callers explicitly set this flag to False.
|
||||
agent._end_session_on_close = True
|
||||
agent._session_init_model_config = {
|
||||
"max_iterations": agent.max_iterations,
|
||||
"reasoning_config": reasoning_config,
|
||||
@@ -1242,9 +1147,6 @@ def init_agent(
|
||||
"hermes_home": str(get_hermes_home()),
|
||||
"agent_context": "primary",
|
||||
}
|
||||
if _init_kwargs["platform"] == "cli":
|
||||
_init_kwargs["warning_callback"] = agent._emit_warning
|
||||
_init_kwargs["status_callback"] = agent._emit_status
|
||||
# Thread session title for memory provider scoping
|
||||
# (e.g. honcho uses this to derive chat-scoped session keys)
|
||||
if agent._session_db:
|
||||
@@ -1289,8 +1191,38 @@ def init_agent(
|
||||
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
|
||||
agent._memory_manager = None
|
||||
|
||||
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
|
||||
_inject_memory_provider_tools(agent)
|
||||
# Inject memory provider tool schemas into the tool surface.
|
||||
# Skip tools whose names already exist (plugins may register the
|
||||
# same tools via ctx.register_tool(), which lands in agent.tools
|
||||
# through _ra().get_tool_definitions()). Duplicate function names cause
|
||||
# 400 errors on providers that enforce unique names (e.g. Xiaomi
|
||||
# MiMo via Nous Portal).
|
||||
#
|
||||
# Respect the platform's enabled_toolsets configuration (#5544):
|
||||
# enabled_toolsets is None → no filter, inject (backward compat)
|
||||
# "memory" in enabled_toolsets → user opted in, inject
|
||||
# otherwise (incl. []) → user excluded memory, skip injection
|
||||
#
|
||||
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
|
||||
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
|
||||
# penalty on local models and a frequent trigger of tool-call loops.
|
||||
if agent._memory_manager and agent.tools is not None and (
|
||||
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
|
||||
):
|
||||
_existing_tool_names = {
|
||||
t.get("function", {}).get("name")
|
||||
for t in agent.tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in agent._memory_manager.get_all_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_tool_names:
|
||||
continue # already registered via plugin path
|
||||
_wrapped = {"type": "function", "function": _schema}
|
||||
agent.tools.append(_wrapped)
|
||||
if _tname:
|
||||
agent.valid_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
|
||||
# Skills config: nudge interval for skill creation reminders
|
||||
agent._skill_nudge_interval = 10
|
||||
@@ -1307,47 +1239,18 @@ def init_agent(
|
||||
_agent_section = {}
|
||||
agent._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto")
|
||||
|
||||
# Intent-ack continuation config: "auto" (default — codex_responses only,
|
||||
# the historical gate), true (all api_modes), false (never), or a list of
|
||||
# model-name substrings. Resolved against the active api_mode/model in the
|
||||
# conversation loop's intent-ack block.
|
||||
agent._intent_ack_continuation = _agent_section.get("intent_ack_continuation", "auto")
|
||||
|
||||
# Universal task-completion guidance toggle. Default True. Surfaced
|
||||
# as a separate flag from tool_use_enforcement because the guidance
|
||||
# applies to ALL models, not just the model families enforcement
|
||||
# targets.
|
||||
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
|
||||
|
||||
# Universal parallel-tool-call guidance toggle. Default True. Separate
|
||||
# flag from task_completion_guidance because a user may want one but not
|
||||
# the other. Steers the model to batch independent tool calls into a
|
||||
# single turn; the runtime already executes such batches concurrently.
|
||||
agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True))
|
||||
|
||||
# Local Python toolchain probe toggle. Default True. When False,
|
||||
# the probe is skipped entirely (no subprocess calls, no system-prompt
|
||||
# line). Useful for users on exotic setups where the probe heuristics
|
||||
# are noisy.
|
||||
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
|
||||
|
||||
# Per-platform prompt-hint overrides (config.yaml → platform_hints).
|
||||
# Lets an enterprise admin append to or replace Hermes' built-in
|
||||
# platform hint for a single messaging platform (e.g. WhatsApp) without
|
||||
# affecting other platforms. Shape:
|
||||
# platform_hints:
|
||||
# whatsapp:
|
||||
# append: "When tabular output would help, invoke the ... skill."
|
||||
# slack:
|
||||
# replace: "Custom Slack hint that fully replaces the default."
|
||||
# Stored verbatim; resolution happens in agent/system_prompt.py against
|
||||
# the active platform. Invalid shapes are ignored defensively so a bad
|
||||
# config entry can never break prompt assembly.
|
||||
_platform_hints_cfg = _agent_cfg.get("platform_hints", {})
|
||||
if not isinstance(_platform_hints_cfg, dict):
|
||||
_platform_hints_cfg = {}
|
||||
agent._platform_hint_overrides = _platform_hints_cfg
|
||||
|
||||
# App-level API retry count (wraps each model API call). Default 3,
|
||||
# overridable via agent.api_max_retries in config.yaml. See #11616.
|
||||
try:
|
||||
@@ -1417,14 +1320,6 @@ def init_agent(
|
||||
compression_abort_on_summary_failure = str(
|
||||
_compression_cfg.get("abort_on_summary_failure", False)
|
||||
).lower() in {"true", "1", "yes"}
|
||||
# In-place compaction: when True, compress_context() rewrites the message
|
||||
# list + rebuilds the system prompt WITHOUT rotating the session id (no
|
||||
# parent_session_id chain, no `name #N` renumber). See #38763 and
|
||||
# agent/conversation_compression.py. Consumed by compress_context(), not the
|
||||
# compressor, so it rides on the agent.
|
||||
compression_in_place = is_truthy_value(
|
||||
_compression_cfg.get("in_place"), default=False
|
||||
)
|
||||
|
||||
# Read optional explicit context_length override for the auxiliary
|
||||
# compression model. Custom endpoints often cannot report this via
|
||||
@@ -1573,7 +1468,6 @@ def init_agent(
|
||||
# 3. Check general plugin system (user-installed plugins)
|
||||
# 4. Fall back to built-in ContextCompressor
|
||||
_selected_engine = None
|
||||
_copy_failed = False
|
||||
_engine_name = "compressor" # default
|
||||
try:
|
||||
_ctx_cfg = _agent_cfg.get("context", {}) if isinstance(_agent_cfg, dict) else {}
|
||||
@@ -1591,35 +1485,15 @@ def init_agent(
|
||||
|
||||
# Try general plugin system as fallback
|
||||
if _selected_engine is None:
|
||||
_candidate = None
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_context_engine
|
||||
_candidate = get_plugin_context_engine()
|
||||
if _candidate and _candidate.name == _engine_name:
|
||||
_selected_engine = _candidate
|
||||
except Exception:
|
||||
_candidate = None
|
||||
if _candidate is not None and _candidate.name == _engine_name:
|
||||
# Deep-copy the shared plugin singleton so a child agent's
|
||||
# update_model() can't mutate the parent's compressor (#42449).
|
||||
# Copy can fail for engines holding uncopyable state (locks, DB
|
||||
# connections, clients); in that case fall back to the built-in
|
||||
# compressor with an ACCURATE message rather than silently
|
||||
# mislabelling it "not found".
|
||||
import copy
|
||||
try:
|
||||
_selected_engine = copy.deepcopy(_candidate)
|
||||
except Exception as _copy_err:
|
||||
_copy_failed = True
|
||||
_ra().logger.warning(
|
||||
"Context engine '%s' could not be safely copied for this "
|
||||
"agent (%s) — falling back to built-in compressor. Plugin "
|
||||
"engines that hold uncopyable state (locks, DB connections) "
|
||||
"should implement __deepcopy__ to copy only mutable budget "
|
||||
"state.",
|
||||
_engine_name, _copy_err,
|
||||
)
|
||||
_selected_engine = None
|
||||
pass
|
||||
|
||||
if _selected_engine is None and not _copy_failed:
|
||||
if _selected_engine is None:
|
||||
_ra().logger.warning(
|
||||
"Context engine '%s' not found — falling back to built-in compressor",
|
||||
_engine_name,
|
||||
@@ -1663,16 +1537,8 @@ def init_agent(
|
||||
provider=agent.provider,
|
||||
api_mode=agent.api_mode,
|
||||
abort_on_summary_failure=compression_abort_on_summary_failure,
|
||||
max_tokens=agent.max_tokens,
|
||||
)
|
||||
_bind_session_state = getattr(agent.context_compressor, "bind_session_state", None)
|
||||
if callable(_bind_session_state):
|
||||
try:
|
||||
_bind_session_state(session_db=session_db, session_id=agent.session_id)
|
||||
except Exception:
|
||||
pass
|
||||
agent.compression_enabled = compression_enabled
|
||||
agent.compression_in_place = compression_in_place
|
||||
|
||||
# Reject models whose context window is below the minimum required
|
||||
# for reliable tool-calling workflows (64K tokens).
|
||||
@@ -1682,10 +1548,8 @@ def init_agent(
|
||||
f"Model {agent.model} has a context window of {_ctx:,} tokens, "
|
||||
f"which is below the minimum {MINIMUM_CONTEXT_LENGTH:,} required "
|
||||
f"by Hermes Agent. Choose a model with at least "
|
||||
f"{MINIMUM_CONTEXT_LENGTH // 1000}K context. If your server "
|
||||
f"reports a window smaller than the model's true window, set "
|
||||
f"model.context_length in config.yaml to the real value "
|
||||
f"(this must be at least {MINIMUM_CONTEXT_LENGTH // 1000}K)."
|
||||
f"{MINIMUM_CONTEXT_LENGTH // 1000}K context, or set "
|
||||
f"model.context_length in config.yaml to override."
|
||||
)
|
||||
|
||||
# Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand).
|
||||
@@ -1717,27 +1581,16 @@ def init_agent(
|
||||
for t in agent.tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
from agent.memory_manager import normalize_tool_schema as _normalize_tool_schema
|
||||
for _raw_schema in agent.context_compressor.get_tool_schemas():
|
||||
_schema = _normalize_tool_schema(_raw_schema)
|
||||
if _schema is None:
|
||||
# A schema with no resolvable name (e.g. an already-wrapped
|
||||
# entry) would append a nameless tool that strict providers
|
||||
# 400 on, disabling the whole toolset (#47707). Skip it.
|
||||
_ra().logger.warning(
|
||||
"Context engine returned a tool schema with no resolvable "
|
||||
"name; skipping to avoid poisoning the request (%r)",
|
||||
_raw_schema,
|
||||
)
|
||||
continue
|
||||
_tname = _schema["name"]
|
||||
if _tname in _existing_tool_names:
|
||||
for _schema in agent.context_compressor.get_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_tool_names:
|
||||
continue # already registered via plugin/cache path
|
||||
_wrapped = {"type": "function", "function": _schema}
|
||||
agent.tools.append(_wrapped)
|
||||
agent.valid_tool_names.add(_tname)
|
||||
agent._context_engine_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
if _tname:
|
||||
agent.valid_tool_names.add(_tname)
|
||||
agent._context_engine_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
|
||||
# Notify context engine of session start
|
||||
if hasattr(agent, "context_compressor") and agent.context_compressor:
|
||||
|
||||
+90
-517
@@ -42,14 +42,6 @@ from utils import base_url_host_matches, base_url_hostname, env_var_enabled, ato
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Max consecutive successful credential-pool token refreshes of the SAME entry
|
||||
# on a persistent auth failure before we give up and let the fallback chain
|
||||
# activate. A single-entry OAuth pool can re-mint a fresh token indefinitely
|
||||
# even when the upstream keeps rejecting it, so without this cap the retry loop
|
||||
# spins forever and never reaches ``_try_activate_fallback``. See #26080.
|
||||
_MAX_AUTH_REFRESH_ATTEMPTS = 2
|
||||
|
||||
|
||||
def _ra():
|
||||
"""Lazy ``run_agent`` reference for test-patch routing."""
|
||||
import run_agent
|
||||
@@ -57,7 +49,7 @@ def _ra():
|
||||
|
||||
|
||||
AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset(
|
||||
{"todo", "session_search", "memory", "clarify", "read_terminal", "delegate_task"}
|
||||
{"todo", "session_search", "memory", "clarify", "delegate_task"}
|
||||
)
|
||||
|
||||
|
||||
@@ -368,18 +360,6 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
|
||||
host code) can feed in already-broken histories.
|
||||
|
||||
Repairs applied:
|
||||
0. Consecutive ``assistant`` messages with no intervening
|
||||
``tool``/``user`` turn — merged into a single assistant turn
|
||||
(union of ``tool_calls``, concatenated ``content``). Strict
|
||||
OpenAI-compatible providers (DeepSeek v4, Moonshot/Kimi) reject
|
||||
a history where an ``assistant`` message carrying ``tool_calls``
|
||||
is immediately followed by another ``assistant`` message instead
|
||||
of its ``tool`` results — HTTP 400 "An assistant message with
|
||||
'tool_calls' must be followed by tool messages…". The split
|
||||
shape is produced by recovery/continuation paths that append an
|
||||
interim assistant turn (thinking-prefill, codex
|
||||
incomplete-continuation) or by host-fed / legacy-persisted /
|
||||
resumed histories. Refs #29148, #49147.
|
||||
1. Stray ``tool`` messages whose ``tool_call_id`` doesn't match
|
||||
any preceding assistant tool_call — dropped.
|
||||
2. Consecutive ``user`` messages — merged with newline separator
|
||||
@@ -399,74 +379,12 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
|
||||
|
||||
repairs = 0
|
||||
|
||||
# Pass 0: merge consecutive assistant messages. Runs BEFORE Pass 1 so
|
||||
# the merged turn's union of tool_call ids is known when Pass 1
|
||||
# validates which tool-result messages are orphans. Two assistant
|
||||
# messages are only adjacent here when nothing (no tool result, no
|
||||
# user turn) separates them — an intervening ``tool`` message means
|
||||
# two distinct, valid tool-call rounds that must NOT be merged.
|
||||
#
|
||||
# Codex Responses interim turns are exempt: the codex_responses
|
||||
# api_mode legitimately keeps multiple consecutive incomplete
|
||||
# assistant turns in history, each carrying its own encrypted
|
||||
# continuation state (codex_reasoning_items / codex_message_items)
|
||||
# that must be replayed verbatim. Collapsing them corrupts the
|
||||
# Responses replay chain (the duplicate-detection logic at
|
||||
# conversation_loop.py already de-dups identical codex interims).
|
||||
def _is_codex_interim(m: Dict) -> bool:
|
||||
return bool(
|
||||
m.get("codex_reasoning_items")
|
||||
or m.get("codex_message_items")
|
||||
or m.get("finish_reason") == "incomplete"
|
||||
)
|
||||
|
||||
collapsed: List[Dict] = []
|
||||
for msg in messages:
|
||||
if (
|
||||
collapsed
|
||||
and isinstance(msg, dict)
|
||||
and msg.get("role") == "assistant"
|
||||
and isinstance(collapsed[-1], dict)
|
||||
and collapsed[-1].get("role") == "assistant"
|
||||
and not _is_codex_interim(msg)
|
||||
and not _is_codex_interim(collapsed[-1])
|
||||
):
|
||||
prev = collapsed[-1]
|
||||
# Union tool_calls (preserve order, both may carry them).
|
||||
prev_calls = list(prev.get("tool_calls") or [])
|
||||
new_calls = list(msg.get("tool_calls") or [])
|
||||
if new_calls:
|
||||
prev["tool_calls"] = prev_calls + new_calls
|
||||
elif prev_calls:
|
||||
prev["tool_calls"] = prev_calls
|
||||
# Concatenate plain-text content; leave multimodal (list)
|
||||
# content on either side alone to avoid mangling attachment
|
||||
# blocks — fall back to keeping the existing content.
|
||||
prev_content = prev.get("content")
|
||||
new_content = msg.get("content")
|
||||
if isinstance(prev_content, str) and isinstance(new_content, str):
|
||||
joined = "\n".join(
|
||||
p for p in (prev_content.strip(), new_content.strip()) if p
|
||||
)
|
||||
prev["content"] = joined
|
||||
elif not prev_content and new_content is not None:
|
||||
prev["content"] = new_content
|
||||
# Carry reasoning_content from the later turn only if the
|
||||
# earlier turn lacks it (strict thinking providers require a
|
||||
# reasoning_content on the merged tool-call turn; the first
|
||||
# non-empty one suffices).
|
||||
if not prev.get("reasoning_content") and msg.get("reasoning_content"):
|
||||
prev["reasoning_content"] = msg["reasoning_content"]
|
||||
repairs += 1
|
||||
continue
|
||||
collapsed.append(msg)
|
||||
|
||||
# Pass 1: drop stray tool messages that don't follow a known
|
||||
# assistant tool_call_id. Uses a rolling set of known ids refreshed
|
||||
# on each assistant message.
|
||||
known_tool_ids: set = set()
|
||||
filtered: List[Dict] = []
|
||||
for msg in collapsed:
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
filtered.append(msg)
|
||||
continue
|
||||
@@ -527,45 +445,6 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
|
||||
return repairs
|
||||
|
||||
|
||||
def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int:
|
||||
"""Run :func:`repair_message_sequence` and keep the SessionDB flush
|
||||
cursor consistent with the compacted list (#44837).
|
||||
|
||||
``repair_message_sequence`` merges/drops messages in place, shrinking
|
||||
the list. ``_last_flushed_db_idx`` (the DB-write cursor) indexes into
|
||||
that list, so after compaction it can point past the new end — the
|
||||
turn-end flush would then skip the assistant/tool chain entirely — or
|
||||
past unflushed messages shifted to lower indexes.
|
||||
|
||||
Repair preserves object identity for surviving messages, so counting
|
||||
the survivors from the previously-flushed prefix gives the exact new
|
||||
cursor even when messages are dropped/merged at indexes *before* the
|
||||
cursor — a plain ``min()`` clamp would silently skip that many
|
||||
unflushed rows. Falls back to the clamp when no prefix snapshot is
|
||||
available.
|
||||
|
||||
Returns the number of repairs made (same as ``repair_message_sequence``).
|
||||
"""
|
||||
pre_repair_flushed_ids = None
|
||||
flush_cursor = getattr(agent, "_last_flushed_db_idx", None)
|
||||
if isinstance(flush_cursor, int) and flush_cursor > 0:
|
||||
pre_repair_flushed_ids = {id(m) for m in messages[:flush_cursor]}
|
||||
|
||||
repairs = repair_message_sequence(agent, messages)
|
||||
|
||||
if repairs > 0 and hasattr(agent, "_last_flushed_db_idx"):
|
||||
if pre_repair_flushed_ids is not None:
|
||||
agent._last_flushed_db_idx = sum(
|
||||
1 for m in messages if id(m) in pre_repair_flushed_ids
|
||||
)
|
||||
else:
|
||||
agent._last_flushed_db_idx = min(
|
||||
agent._last_flushed_db_idx, len(messages)
|
||||
)
|
||||
|
||||
return repairs
|
||||
|
||||
|
||||
|
||||
def strip_think_blocks(agent, content: str) -> str:
|
||||
"""Remove reasoning/thinking blocks from content, returning only visible text.
|
||||
@@ -700,33 +579,12 @@ def recover_with_credential_pool(
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
|
||||
if current_provider and pool_provider and current_provider != pool_provider:
|
||||
# Custom endpoints use two naming conventions for the SAME provider:
|
||||
# the agent carries the generic ``custom`` label while the pool is
|
||||
# keyed ``custom:<name>`` (see CUSTOM_POOL_PREFIX). A literal string
|
||||
# compare treats them as a mismatch and skips recovery for every
|
||||
# custom-provider user — 401s/429s then burn the full retry cycle
|
||||
# with no rotation or refresh. Accept the pair as matching only when
|
||||
# the agent's CURRENT base_url actually resolves to this pool key,
|
||||
# so a fallback provider (or a different custom endpoint) still
|
||||
# triggers the guard.
|
||||
_custom_match = False
|
||||
if current_provider == "custom" and pool_provider.startswith("custom:"):
|
||||
try:
|
||||
from agent.credential_pool import get_custom_provider_pool_key
|
||||
_agent_base = (getattr(agent, "base_url", "") or "").strip()
|
||||
_custom_match = bool(_agent_base) and (
|
||||
(get_custom_provider_pool_key(_agent_base) or "").strip().lower()
|
||||
== pool_provider
|
||||
)
|
||||
except Exception:
|
||||
_custom_match = False
|
||||
if not _custom_match:
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
|
||||
effective_reason = classified_reason
|
||||
if effective_reason is None:
|
||||
@@ -737,25 +595,6 @@ def recover_with_credential_pool(
|
||||
elif status_code in {401, 403}:
|
||||
effective_reason = FailoverReason.auth
|
||||
|
||||
if effective_reason == FailoverReason.upstream_rate_limit:
|
||||
# An upstream provider (e.g. DeepSeek behind OpenRouter) is
|
||||
# rate-limiting the aggregator's traffic — the user's credential is
|
||||
# healthy. Do NOT rotate or mark exhausted; let the caller's fallback
|
||||
# path switch to a different model entirely.
|
||||
upstream = (error_context or {}).get("upstream_provider") if error_context else None
|
||||
if upstream:
|
||||
_ra().logger.info(
|
||||
"Upstream provider %s rate-limited via aggregator — skipping "
|
||||
"credential rotation, deferring to fallback chain",
|
||||
upstream,
|
||||
)
|
||||
else:
|
||||
_ra().logger.info(
|
||||
"Upstream aggregator 429 (provider unknown) — skipping "
|
||||
"credential rotation, deferring to fallback chain"
|
||||
)
|
||||
return False, has_retried_429
|
||||
|
||||
if effective_reason == FailoverReason.billing:
|
||||
rotate_status = status_code if status_code is not None else 402
|
||||
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
|
||||
@@ -840,28 +679,15 @@ def recover_with_credential_pool(
|
||||
# long-running TUI sessions stuck on stale tokens until the user
|
||||
# exited and reopened.
|
||||
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
|
||||
_auth_haystack = " ".join(
|
||||
str(error_context.get(k) or "").lower()
|
||||
for k in ("message", "reason", "code", "error")
|
||||
if isinstance(error_context, dict)
|
||||
)
|
||||
if (
|
||||
not is_entitlement
|
||||
and status_code == 403
|
||||
and "oauth authentication is currently not allowed for this organization" in _auth_haystack
|
||||
):
|
||||
is_entitlement = True
|
||||
if (
|
||||
not is_entitlement
|
||||
and status_code == 403
|
||||
and (agent.provider or "") == "anthropic"
|
||||
and getattr(agent, "api_mode", "") == "anthropic_messages"
|
||||
):
|
||||
is_entitlement = True
|
||||
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
|
||||
_disambiguator_haystack = " ".join(
|
||||
str(error_context.get(k) or "").lower()
|
||||
for k in ("message", "reason", "code", "error")
|
||||
if isinstance(error_context, dict)
|
||||
)
|
||||
_is_xai_auth_failure = (
|
||||
"[wke=unauthenticated:" in _auth_haystack
|
||||
or "oauth2 access token could not be validated" in _auth_haystack
|
||||
"[wke=unauthenticated:" in _disambiguator_haystack
|
||||
or "oauth2 access token could not be validated" in _disambiguator_haystack
|
||||
)
|
||||
if not _is_xai_auth_failure:
|
||||
is_entitlement = True
|
||||
@@ -876,30 +702,6 @@ def recover_with_credential_pool(
|
||||
return False, has_retried_429
|
||||
refreshed = pool.try_refresh_current()
|
||||
if refreshed is not None:
|
||||
# ``try_refresh_current()`` re-mints a fresh OAuth token and reports
|
||||
# success even when the upstream keeps rejecting it — a single-entry
|
||||
# pool (common for OAuth/Max subscribers) has nothing to rotate to,
|
||||
# so a bare "refreshed → retry" loop spins forever on the same dead
|
||||
# token and the configured fallback never activates. Cap consecutive
|
||||
# same-entry refreshes and fall through to fallback once exceeded.
|
||||
# See #26080.
|
||||
refreshed_id = getattr(refreshed, "id", None)
|
||||
if refreshed_id is not None:
|
||||
refresh_counts = getattr(agent, "_auth_pool_refresh_counts", None)
|
||||
if refresh_counts is None:
|
||||
refresh_counts = {}
|
||||
agent._auth_pool_refresh_counts = refresh_counts
|
||||
refresh_key = (agent.provider, refreshed_id)
|
||||
refresh_counts[refresh_key] = refresh_counts.get(refresh_key, 0) + 1
|
||||
if refresh_counts[refresh_key] > _MAX_AUTH_REFRESH_ATTEMPTS:
|
||||
_ra().logger.warning(
|
||||
"Credential auth failure persists after %s refreshes for "
|
||||
"pool entry %s — treating as unrecoverable and allowing "
|
||||
"fallback to activate.",
|
||||
refresh_counts[refresh_key] - 1,
|
||||
refreshed_id,
|
||||
)
|
||||
return False, has_retried_429
|
||||
_ra().logger.info(f"Credential auth failure — refreshed pool entry {getattr(refreshed, 'id', '?')}")
|
||||
agent._swap_credential(refreshed)
|
||||
return True, has_retried_429
|
||||
@@ -1006,8 +808,6 @@ def try_recover_primary_transport(
|
||||
|
||||
def drop_thinking_only_and_merge_users(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
drop_codex_reasoning_items: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Drop thinking-only assistant turns; merge any adjacent user messages left behind.
|
||||
|
||||
@@ -1029,13 +829,7 @@ def drop_thinking_only_and_merge_users(
|
||||
return messages
|
||||
|
||||
# Pass 1: drop thinking-only assistant turns.
|
||||
kept = [
|
||||
m for m in messages
|
||||
if not _ra().AIAgent._is_thinking_only_assistant(
|
||||
m,
|
||||
drop_codex_reasoning_items=drop_codex_reasoning_items,
|
||||
)
|
||||
]
|
||||
kept = [m for m in messages if not _ra().AIAgent._is_thinking_only_assistant(m)]
|
||||
dropped = len(messages) - len(kept)
|
||||
if dropped == 0:
|
||||
return messages
|
||||
@@ -1171,43 +965,10 @@ def restore_primary_runtime(agent) -> bool:
|
||||
api_mode=rt.get("compressor_api_mode", ""),
|
||||
)
|
||||
|
||||
# ── Re-select from the credential pool if one is available ──
|
||||
# The snapshot's api_key was captured at construction time. Across
|
||||
# turns the pool may have rotated (token revocation, billing/rate-limit
|
||||
# exhaustion, cooldown), leaving the snapshot key stale. Restoring it
|
||||
# blindly re-fails on the first request and burns through the remaining
|
||||
# pool entries before cross-provider fallback even gets a chance. Ask
|
||||
# the pool for its current best entry and swap the live credential in.
|
||||
# When the pool is absent, empty, or the entry has no usable key, we
|
||||
# keep the snapshot key (the existing behavior). Fixes #25205.
|
||||
pool = getattr(agent, "_credential_pool", None)
|
||||
if pool is not None and pool.has_available():
|
||||
entry = pool.select()
|
||||
if entry is not None:
|
||||
entry_key = (
|
||||
getattr(entry, "runtime_api_key", None)
|
||||
or getattr(entry, "access_token", "")
|
||||
)
|
||||
if entry_key:
|
||||
# ``_swap_credential`` rebuilds the OpenAI/Anthropic client,
|
||||
# reapplies base-url-scoped headers, and carries the
|
||||
# accumulated base_url / OAuth-detection fixes (#33163).
|
||||
agent._swap_credential(entry)
|
||||
logger.info(
|
||||
"Restore re-selected pool entry %s (%s)",
|
||||
getattr(entry, "id", "?"),
|
||||
getattr(entry, "label", "?"),
|
||||
)
|
||||
|
||||
# ── Reset fallback chain for the new turn ──
|
||||
agent._fallback_activated = False
|
||||
agent._fallback_index = 0
|
||||
|
||||
# Undo the fallback's identity rewrite so the prompt is
|
||||
# byte-identical to the stored copy again (prefix cache match).
|
||||
from agent.chat_completion_helpers import rewrite_prompt_model_identity
|
||||
rewrite_prompt_model_identity(agent, rt["model"], rt["provider"])
|
||||
|
||||
logger.info(
|
||||
"Primary runtime restored for new turn: %s (%s)",
|
||||
agent.model, agent.provider,
|
||||
@@ -1374,28 +1135,13 @@ def dump_api_request_debug(
|
||||
dump_payload["error"] = error_info
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
# Sanitize the session ID into a traversal-free path segment — it can
|
||||
# originate from untrusted input (X-Hermes-Session-Id header), and an
|
||||
# unsanitized "../"-shaped ID would write the dump outside logs_dir.
|
||||
safe_sid = _ra()._safe_session_filename_component(agent.session_id)
|
||||
dump_file = agent.logs_dir / f"request_dump_{safe_sid}_{timestamp}.json"
|
||||
|
||||
# Redact secrets before persisting/printing. This dump captures the
|
||||
# full request body (system prompt, tool defs, context-embedded
|
||||
# values), and this path fires unconditionally on API errors — so it
|
||||
# otherwise lands any context-embedded secret in cleartext on disk.
|
||||
# Run the serialized dump through the same scrubber used for logs/tool
|
||||
# output, then hand the resulting payload back to the shared atomic
|
||||
# JSON writer so request dumps keep the same write semantics as before.
|
||||
from agent.redact import redact_sensitive_text
|
||||
_serialized = json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)
|
||||
_redacted_payload = json.loads(redact_sensitive_text(_serialized, force=True))
|
||||
atomic_json_write(dump_file, _redacted_payload, default=str)
|
||||
dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
|
||||
atomic_json_write(dump_file, dump_payload, default=str)
|
||||
|
||||
agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")
|
||||
|
||||
if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"):
|
||||
print(json.dumps(_redacted_payload, ensure_ascii=False, indent=2, default=str))
|
||||
print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
return dump_file
|
||||
except Exception as dump_error:
|
||||
@@ -1535,6 +1281,22 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
|
||||
agent._client_log_context(),
|
||||
)
|
||||
return client
|
||||
if agent.provider == "google-gemini-cli" or str(client_kwargs.get("base_url", "")).startswith("cloudcode-pa://"):
|
||||
from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient
|
||||
|
||||
# Strip OpenAI-specific kwargs the Gemini client doesn't accept
|
||||
safe_kwargs = {
|
||||
k: v for k, v in client_kwargs.items()
|
||||
if k in {"api_key", "base_url", "default_headers", "project_id", "timeout"}
|
||||
}
|
||||
client = GeminiCloudCodeClient(**safe_kwargs)
|
||||
_ra().logger.info(
|
||||
"Gemini Cloud Code Assist client created (%s, shared=%s) %s",
|
||||
reason,
|
||||
shared,
|
||||
agent._client_log_context(),
|
||||
)
|
||||
return client
|
||||
if agent.provider == "gemini":
|
||||
from agent.gemini_native_adapter import GeminiNativeClient, is_native_gemini_base_url
|
||||
|
||||
@@ -1577,15 +1339,6 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
|
||||
keepalive_http = agent._build_keepalive_http_client(client_kwargs.get("base_url", ""))
|
||||
if keepalive_http is not None:
|
||||
client_kwargs["http_client"] = keepalive_http
|
||||
# Delegate all rate-limit / 5xx retry to hermes's outer conversation loop,
|
||||
# which honors Retry-After and applies adaptive/jittered backoff. The OpenAI
|
||||
# SDK default (max_retries=2) uses its own 1-2s backoff that ignores
|
||||
# Retry-After and double-retries inside our loop — the same deadlock the
|
||||
# Anthropic clients hit (#26293). This is the single chokepoint every primary
|
||||
# OpenAI/aggregator client passes through (init, switch_model, recovery,
|
||||
# restore, request-scoped); auxiliary_client builds its own clients and keeps
|
||||
# SDK retries because it is NOT wrapped by the conversation loop.
|
||||
client_kwargs.setdefault("max_retries", 0)
|
||||
# Uses the module-level `OpenAI` name, resolved lazily on first
|
||||
# access via __getattr__ below. Tests patch via `run_agent.OpenAI`.
|
||||
client = _ra().OpenAI(**client_kwargs)
|
||||
@@ -1665,10 +1418,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
# _client_kwargs is a dict — snapshot a shallow copy so mutating the
|
||||
# live dict doesn't poison the rollback target.
|
||||
_snapshot["_client_kwargs"] = dict(getattr(agent, "_client_kwargs", {}) or {})
|
||||
# Snapshot the credential pool reference so a failed client rebuild can
|
||||
# restore the original pool (issue #52727: pool reload is part of this
|
||||
# switch and must be reversible on rollback).
|
||||
_snapshot["_credential_pool"] = getattr(agent, "_credential_pool", _MISSING)
|
||||
|
||||
try:
|
||||
# Clear the per-config context_length override so the new model's
|
||||
@@ -1693,48 +1442,8 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
if api_key:
|
||||
agent.api_key = api_key
|
||||
|
||||
# ── Reload credential pool for the new provider (issue #52727) ──
|
||||
# Without this, ``recover_with_credential_pool`` sees a
|
||||
# ``pool.provider != agent.provider`` mismatch and short-circuits,
|
||||
# leaving the new provider with no rotation/recovery on 401/429 and
|
||||
# burning the original pool's entries. Only reload when the provider
|
||||
# actually changed (or the pool was missing) — re-selecting the same
|
||||
# provider must not churn the pool reference. A reload failure is
|
||||
# logged + swallowed: the switch itself must still complete.
|
||||
old_norm = (old_provider or "").strip().lower()
|
||||
new_norm = (new_provider or "").strip().lower()
|
||||
if old_norm != new_norm or getattr(agent, "_credential_pool", None) is None:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
agent._credential_pool = load_pool(new_provider)
|
||||
except Exception as _pool_exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"switch_model: credential pool reload failed for %s (%s); "
|
||||
"continuing without pool rotation this turn",
|
||||
new_provider, _pool_exc,
|
||||
)
|
||||
|
||||
# ── Build new client ──
|
||||
if (new_provider or "").strip().lower() == "moa":
|
||||
from agent.moa_loop import MoAClient
|
||||
|
||||
# The MoA virtual provider speaks only chat.completions via the
|
||||
# MoAClient facade — the aggregator's real transport
|
||||
# (codex_responses / anthropic_messages) is resolved and applied
|
||||
# *inside* the reference/aggregator fan-out, never on the outer
|
||||
# primary call. determine_api_mode("moa", ...) above may have left
|
||||
# api_mode set to the aggregator's transport; if the conversation
|
||||
# loop sees that, it dispatches client.responses.create (which the
|
||||
# facade has no .responses for) and the call falls through to the
|
||||
# moa://local placeholder → HTTP 404 → fallback to a reference
|
||||
# model. Pin chat_completions here so the primary call always goes
|
||||
# through MoAClient.chat.completions, matching agent_init.py.
|
||||
agent.api_mode = "chat_completions"
|
||||
agent.api_key = api_key or "moa-virtual-provider"
|
||||
agent.base_url = "moa://local"
|
||||
agent._client_kwargs = {}
|
||||
agent.client = MoAClient(agent.model or "default")
|
||||
elif api_mode == "anthropic_messages":
|
||||
if api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import (
|
||||
build_anthropic_client,
|
||||
resolve_anthropic_token,
|
||||
@@ -1907,27 +1616,6 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
old_model, old_provider, new_model, new_provider,
|
||||
)
|
||||
|
||||
# ── Persist billing route to session DB ──
|
||||
# The agent's _session_db / session_id may not be set in all contexts
|
||||
# (tests, bare agents without a session DB, etc.). This ensures the
|
||||
# dashboard Model cards show the actual provider after a mid-session
|
||||
# /model switch instead of the stale session-creation provider.
|
||||
# See #48248 for the full bug description.
|
||||
_session_db = getattr(agent, "_session_db", None)
|
||||
_session_id = getattr(agent, "session_id", None)
|
||||
if _session_db is not None and _session_id:
|
||||
try:
|
||||
_session_db.update_session_billing_route(
|
||||
_session_id,
|
||||
provider=agent.provider,
|
||||
base_url=agent.base_url,
|
||||
billing_mode=getattr(agent, "api_mode", None),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist billing route after model switch",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def invoke_tool(agent, function_name: str, function_args: dict, effective_task_id: str,
|
||||
@@ -2059,28 +1747,28 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
elif function_name == "memory":
|
||||
def _execute(next_args: dict) -> Any:
|
||||
target = next_args.get("target", "memory")
|
||||
operations = next_args.get("operations")
|
||||
from tools.memory_tool import memory_tool as _memory_tool
|
||||
result = _memory_tool(
|
||||
action=next_args.get("action"),
|
||||
target=target,
|
||||
content=next_args.get("content"),
|
||||
old_text=next_args.get("old_text"),
|
||||
operations=operations,
|
||||
store=agent._memory_store,
|
||||
)
|
||||
# Mirror successful built-in memory writes to external providers.
|
||||
# All gating/op-expansion lives behind the manager interface
|
||||
# (MemoryManager.notify_memory_tool_write).
|
||||
if agent._memory_manager:
|
||||
agent._memory_manager.notify_memory_tool_write(
|
||||
result,
|
||||
next_args,
|
||||
build_metadata=lambda: agent._build_memory_write_metadata(
|
||||
task_id=effective_task_id,
|
||||
tool_call_id=tool_call_id,
|
||||
),
|
||||
)
|
||||
# Bridge: notify external memory provider of built-in memory writes
|
||||
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
|
||||
try:
|
||||
agent._memory_manager.on_memory_write(
|
||||
next_args.get("action", ""),
|
||||
target,
|
||||
next_args.get("content", ""),
|
||||
metadata=agent._build_memory_write_metadata(
|
||||
task_id=effective_task_id,
|
||||
tool_call_id=tool_call_id,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return _finish_agent_tool(result, next_args)
|
||||
elif agent._memory_manager and agent._memory_manager.has_tool(function_name):
|
||||
def _execute(next_args: dict) -> Any:
|
||||
@@ -2096,17 +1784,6 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
||||
),
|
||||
next_args,
|
||||
)
|
||||
elif function_name == "read_terminal":
|
||||
def _execute(next_args: dict) -> Any:
|
||||
from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool
|
||||
return _finish_agent_tool(
|
||||
_read_terminal_tool(
|
||||
start_line=next_args.get("start_line"),
|
||||
count=next_args.get("count"),
|
||||
callback=getattr(agent, "read_terminal_callback", None),
|
||||
),
|
||||
next_args,
|
||||
)
|
||||
elif function_name == "delegate_task":
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
|
||||
@@ -2268,7 +1945,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
|
||||
result_call_ids: set = set()
|
||||
for msg in messages:
|
||||
if msg.get("role") == "tool":
|
||||
cid = (msg.get("tool_call_id") or "").strip()
|
||||
cid = msg.get("tool_call_id")
|
||||
if cid:
|
||||
result_call_ids.add(cid)
|
||||
|
||||
@@ -2277,7 +1954,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
|
||||
if orphaned_results:
|
||||
messages = [
|
||||
m for m in messages
|
||||
if not (m.get("role") == "tool" and (m.get("tool_call_id") or "").strip() in orphaned_results)
|
||||
if not (m.get("role") == "tool" and m.get("tool_call_id") in orphaned_results)
|
||||
]
|
||||
_ra().logger.debug(
|
||||
"Pre-call sanitizer: removed %d orphaned tool result(s)",
|
||||
@@ -2311,24 +1988,11 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
|
||||
|
||||
def looks_like_codex_intermediate_ack(
|
||||
agent,
|
||||
user_message: Any,
|
||||
user_message: str,
|
||||
assistant_content: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
require_workspace: bool = True,
|
||||
) -> bool:
|
||||
"""Detect a planning/ack message that should continue instead of ending the turn.
|
||||
|
||||
``require_workspace`` (default True) keeps the original codex-coding scope:
|
||||
the ack must reference a filesystem/repo workspace. The conversation loop
|
||||
passes ``require_workspace=False`` when the user has explicitly opted into
|
||||
intent-ack continuation for all api_modes (``agent.intent_ack_continuation``
|
||||
is ``true`` or a model-list), so general autonomous workflows ("I'll run a
|
||||
health check on the server", "I'll start the deployment") — which carry a
|
||||
future-ack and an action verb but no filesystem reference — are caught too.
|
||||
The future-ack + short-content + no-prior-tools + action-verb requirements
|
||||
always apply, which is what keeps conversational "I'll help you brainstorm"
|
||||
replies from tripping it.
|
||||
"""
|
||||
"""Detect a planning/ack message that should continue instead of ending the turn."""
|
||||
if any(isinstance(msg, dict) and msg.get("role") == "tool" for msg in messages):
|
||||
return False
|
||||
|
||||
@@ -2381,74 +2045,17 @@ def looks_like_codex_intermediate_ack(
|
||||
"path",
|
||||
)
|
||||
|
||||
assistant_mentions_action = any(marker in assistant_text for marker in action_markers)
|
||||
if not assistant_mentions_action:
|
||||
return False
|
||||
|
||||
# Opted-in (all-api_mode) path: a future-ack + action verb + no prior tool
|
||||
# call is enough — the user asked us to keep going when the model only
|
||||
# announces intent, regardless of whether a filesystem is involved.
|
||||
if not require_workspace:
|
||||
return True
|
||||
|
||||
# ``user_message`` is typed ``str`` but can arrive as an OpenAI-style
|
||||
# multi-part content list (``[{type:"text",...}, {type:"image_url",...}]``)
|
||||
# for vision requests routed through the OpenAI-compat API server. A
|
||||
# truthy list survives ``(user_message or "")`` and then ``.strip()``
|
||||
# raises ``AttributeError`` — flatten to text first.
|
||||
from agent.codex_responses_adapter import _summarize_user_message_for_log
|
||||
|
||||
user_text = _summarize_user_message_for_log(user_message).strip().lower()
|
||||
user_text = (user_message or "").strip().lower()
|
||||
user_targets_workspace = (
|
||||
any(marker in user_text for marker in workspace_markers)
|
||||
or "~/" in user_text
|
||||
or "/" in user_text
|
||||
)
|
||||
assistant_mentions_action = any(marker in assistant_text for marker in action_markers)
|
||||
assistant_targets_workspace = any(
|
||||
marker in assistant_text for marker in workspace_markers
|
||||
)
|
||||
return user_targets_workspace or assistant_targets_workspace
|
||||
|
||||
|
||||
def intent_ack_continuation_mode(agent) -> str:
|
||||
"""Classify the resolved intent-ack continuation mode for this turn.
|
||||
|
||||
Returns one of:
|
||||
* ``"off"`` — never continue.
|
||||
* ``"codex_only"`` — historical scope: continue only on the
|
||||
``codex_responses`` api_mode, and only for codebase/workspace acks
|
||||
(``require_workspace=True``).
|
||||
* ``"all"`` — user opted in for every api_mode; continue on any
|
||||
future-ack + action verb (``require_workspace=False``).
|
||||
|
||||
Mirrors the four-mode shape of ``agent.tool_use_enforcement``: ``"auto"``
|
||||
(default) → codex_only; ``True``/"true"/"always"/"yes"/"on" → all;
|
||||
``False``/"false"/"never"/"no"/"off" → off; ``list`` → all when a substring
|
||||
matches the active model name, else off.
|
||||
"""
|
||||
mode = getattr(agent, "_intent_ack_continuation", "auto")
|
||||
|
||||
if mode is True or (isinstance(mode, str) and mode.lower() in {"true", "always", "yes", "on"}):
|
||||
return "all"
|
||||
if mode is False or (isinstance(mode, str) and mode.lower() in {"false", "never", "no", "off"}):
|
||||
return "off"
|
||||
if isinstance(mode, list):
|
||||
model_lower = (agent.model or "").lower()
|
||||
return "all" if any(p.lower() in model_lower for p in mode if isinstance(p, str)) else "off"
|
||||
# "auto" or any unrecognised value — historical codex-only behavior.
|
||||
return "codex_only" if agent.api_mode == "codex_responses" else "off"
|
||||
|
||||
|
||||
def intent_ack_continuation_enabled(agent) -> bool:
|
||||
"""Whether intent-ack continuation should fire at all for this turn.
|
||||
|
||||
The ``codex_ack_continuations < 2`` per-turn cap and the
|
||||
``looks_like_codex_intermediate_ack`` detector are applied by the caller;
|
||||
this only decides the on/off gate. Callers that also need to know whether
|
||||
the workspace requirement applies should use ``intent_ack_continuation_mode``
|
||||
directly (``"codex_only"`` ⇒ require_workspace=True, ``"all"`` ⇒ False).
|
||||
"""
|
||||
return intent_ack_continuation_mode(agent) != "off"
|
||||
return (user_targets_workspace or assistant_targets_workspace) and assistant_mentions_action
|
||||
|
||||
|
||||
|
||||
@@ -2458,36 +2065,25 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No
|
||||
if source_msg.get("role") != "assistant":
|
||||
return
|
||||
|
||||
needs_thinking_pad = agent._needs_thinking_reasoning_pad()
|
||||
|
||||
# 1. Explicit reasoning_content already set.
|
||||
# 1. Explicit reasoning_content already set — preserve it verbatim
|
||||
# (includes DeepSeek/Kimi's own space-placeholder written at creation
|
||||
# time, and any valid reasoning content from the same provider).
|
||||
#
|
||||
# When the active provider enforces the thinking-mode echo-back
|
||||
# (DeepSeek / Kimi / MiMo), preserve it verbatim — that includes their
|
||||
# own space-placeholder written at creation time and any valid reasoning
|
||||
# from the same provider. Sessions persisted BEFORE #17341 have
|
||||
# empty-string placeholders pinned at creation time; DeepSeek V4 Pro
|
||||
# rejects those with HTTP 400, so upgrade "" → " " on replay.
|
||||
#
|
||||
# When the active provider does NOT enforce echo-back, strip the field
|
||||
# entirely. Strict OpenAI-compatible providers (Mistral, Cerebras, Groq,
|
||||
# SambaNova, …) reject ANY reasoning_content key in input messages with
|
||||
# HTTP 400/422 ("Extra inputs are not permitted"), even an empty string
|
||||
# or a single-space pad. This is the cross-provider fallback case: a
|
||||
# reasoning primary (DeepSeek/Kimi/MiMo) pads history with " ", then a
|
||||
# fallback to a strict provider replays that pad and 422s. Stripping
|
||||
# here covers the rebuild path; reapply_reasoning_echo_for_provider()
|
||||
# covers the already-built api_messages path. Refs #45655.
|
||||
# Exception: sessions persisted BEFORE #17341 have empty-string
|
||||
# placeholders pinned at creation time. DeepSeek V4 Pro rejects
|
||||
# those with HTTP 400. When the active provider enforces the
|
||||
# thinking-mode echo, upgrade "" → " " on replay so stale history
|
||||
# doesn't 400 the user on the next turn.
|
||||
existing = source_msg.get("reasoning_content")
|
||||
if isinstance(existing, str):
|
||||
if not needs_thinking_pad:
|
||||
api_msg.pop("reasoning_content", None)
|
||||
elif existing == "":
|
||||
if existing == "" and agent._needs_thinking_reasoning_pad():
|
||||
api_msg["reasoning_content"] = " "
|
||||
else:
|
||||
api_msg["reasoning_content"] = existing
|
||||
return
|
||||
|
||||
needs_thinking_pad = agent._needs_thinking_reasoning_pad()
|
||||
|
||||
# 2. Cross-provider poisoned history (#15748): on DeepSeek/Kimi,
|
||||
# if the source turn has tool_calls AND a 'reasoning' field but no
|
||||
# 'reasoning_content' key, the 'reasoning' text was written by a
|
||||
@@ -2513,13 +2109,9 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No
|
||||
# for providers that use the internal 'reasoning' key.
|
||||
# This must happen before the unconditional empty-string fallback so
|
||||
# genuine reasoning content is not overwritten (#15812 regression in
|
||||
# PR #15478). Only promote for providers that enforce echo-back —
|
||||
# strict providers reject the field (refs #45655).
|
||||
# PR #15478).
|
||||
if isinstance(normalized_reasoning, str) and normalized_reasoning:
|
||||
if needs_thinking_pad:
|
||||
api_msg["reasoning_content"] = normalized_reasoning
|
||||
else:
|
||||
api_msg.pop("reasoning_content", None)
|
||||
api_msg["reasoning_content"] = normalized_reasoning
|
||||
return
|
||||
|
||||
# 4. DeepSeek / Kimi thinking mode: all assistant messages need
|
||||
@@ -2540,53 +2132,34 @@ def copy_reasoning_content_for_api(agent, source_msg: dict, api_msg: dict) -> No
|
||||
|
||||
|
||||
def reapply_reasoning_echo_for_provider(agent, api_messages: list) -> int:
|
||||
"""Re-pad (or strip) assistant turns' reasoning_content for the active provider.
|
||||
"""Re-pad assistant turns with reasoning_content for the active provider.
|
||||
|
||||
``api_messages`` is built once, before the retry loop, while the *primary*
|
||||
provider is active. A mid-conversation fallback can then switch providers,
|
||||
so the reasoning fields baked into ``api_messages`` are shaped for the
|
||||
*prior* provider and must be reconciled against the *current* one:
|
||||
provider is active. If a mid-conversation fallback then switches to a
|
||||
require-side provider (DeepSeek / Kimi / MiMo thinking mode), assistant
|
||||
turns that were built when the prior provider did NOT need the echo-back go
|
||||
out without ``reasoning_content`` and the new provider rejects them with
|
||||
HTTP 400 ("The reasoning_content in the thinking mode must be passed back").
|
||||
|
||||
* Switching TO a require-side provider (DeepSeek / Kimi / MiMo thinking
|
||||
mode): assistant turns built when the prior provider did NOT need the
|
||||
echo-back go out without ``reasoning_content`` and the new provider
|
||||
rejects them with HTTP 400 ("The reasoning_content in the thinking mode
|
||||
must be passed back"). Re-apply the pad.
|
||||
Calling this immediately before building the request kwargs re-applies the
|
||||
pad against the *current* provider. It is idempotent and a no-op unless
|
||||
``_needs_thinking_reasoning_pad()`` is True for the active provider, so it
|
||||
is safe to call every iteration and covers every fallback path.
|
||||
|
||||
* Switching TO a strict provider that rejects the field (Mistral,
|
||||
Cerebras, Groq, SambaNova, …): assistant turns built under a reasoning
|
||||
primary carry a ``reasoning_content`` pad (often a single space ``" "``),
|
||||
and the strict provider rejects it with HTTP 400/422 ("Extra inputs are
|
||||
not permitted"). Strip the field. This is the exact cross-provider
|
||||
fallback bug from #45655 — a DeepSeek primary pads history with ``" "``,
|
||||
the request falls back to Mistral, and Mistral 422s on the stale pad.
|
||||
|
||||
Calling this immediately before building the request kwargs reconciles the
|
||||
fields against the *current* provider. It is idempotent and safe to call
|
||||
every iteration; it covers every fallback path.
|
||||
|
||||
Returns the number of assistant turns whose reasoning_content was added or
|
||||
removed.
|
||||
Returns the number of assistant turns that gained reasoning_content.
|
||||
"""
|
||||
needs_pad = agent._needs_thinking_reasoning_pad()
|
||||
changed = 0
|
||||
if not agent._needs_thinking_reasoning_pad():
|
||||
return 0
|
||||
padded = 0
|
||||
for api_msg in api_messages:
|
||||
if api_msg.get("role") != "assistant":
|
||||
continue
|
||||
if needs_pad:
|
||||
if api_msg.get("reasoning_content"):
|
||||
continue
|
||||
copy_reasoning_content_for_api(agent, api_msg, api_msg)
|
||||
if api_msg.get("reasoning_content"):
|
||||
changed += 1
|
||||
else:
|
||||
# Strict provider — strip any stale reasoning_content pad left
|
||||
# over from a reasoning primary so the fallback request doesn't
|
||||
# 400/422 on it.
|
||||
if "reasoning_content" in api_msg:
|
||||
api_msg.pop("reasoning_content", None)
|
||||
changed += 1
|
||||
return changed
|
||||
if api_msg.get("reasoning_content"):
|
||||
continue
|
||||
copy_reasoning_content_for_api(agent, api_msg, api_msg)
|
||||
if api_msg.get("reasoning_content"):
|
||||
padded += 1
|
||||
return padded
|
||||
|
||||
|
||||
def _iter_pool_sockets(client: Any):
|
||||
|
||||
+70
-459
@@ -73,50 +73,20 @@ ADAPTIVE_EFFORT_MAP = {
|
||||
"minimal": "low",
|
||||
}
|
||||
|
||||
# ── Anthropic thinking-mode classification ────────────────────────────
|
||||
# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking,
|
||||
# and 4.7 additionally forbids the manual ``thinking`` block entirely and drops
|
||||
# temperature/top_p/top_k. Newer Claude releases (4.8, and named models like
|
||||
# claude-fable-5) follow the same modern contract — but they share no common
|
||||
# version substring, so an allowlist of version numbers ("4.6", "4.7", …) goes
|
||||
# stale the moment a model ships without a recognized number and silently
|
||||
# routes it down the legacy manual-thinking path.
|
||||
#
|
||||
# Instead we DEFAULT unknown Claude models to the modern contract and keep an
|
||||
# explicit *legacy* list of the older Claude families that still require manual
|
||||
# thinking. This mirrors _get_anthropic_max_output's "default to newest" design
|
||||
# (future models are unlikely to regress to the older contract), so each new
|
||||
# Claude release works without a code change.
|
||||
#
|
||||
# Non-Claude Anthropic-Messages models (minimax, qwen3, GLM, …) are NOT Claude,
|
||||
# so they fall through to the legacy path automatically — exactly what those
|
||||
# manual-thinking endpoints need.
|
||||
|
||||
# Older Claude families that DON'T support adaptive thinking (manual thinking
|
||||
# with budget_tokens only). Substring-matched against the model name.
|
||||
_LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = (
|
||||
"claude-3", # 3, 3.5, 3.7
|
||||
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
|
||||
"claude-sonnet-4-0", "claude-sonnet-4.0",
|
||||
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
|
||||
"claude-opus-4-5", "claude-opus-4.5",
|
||||
"claude-sonnet-4-5", "claude-sonnet-4.5",
|
||||
"claude-haiku-4-5", "claude-haiku-4.5",
|
||||
)
|
||||
|
||||
# Older Claude families that DON'T accept the "xhigh" effort level (4.6 only
|
||||
# supports low/medium/high/max). xhigh arrived with Opus 4.7. Adaptive models
|
||||
# not in this list (4.7, 4.8, fable, future) accept xhigh.
|
||||
_NO_XHIGH_CLAUDE_SUBSTRINGS = (
|
||||
"claude-opus-4-6", "claude-opus-4.6",
|
||||
"claude-sonnet-4-6", "claude-sonnet-4.6",
|
||||
)
|
||||
|
||||
|
||||
def _is_claude_model(model: str | None) -> bool:
|
||||
return "claude" in (model or "").lower()
|
||||
# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added
|
||||
# xhigh as a distinct level between high and max; older adaptive-thinking
|
||||
# models (4.6) reject it with a 400. Keep this substring list in sync with
|
||||
# the Anthropic migration guide as new model families ship.
|
||||
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
|
||||
|
||||
# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
|
||||
# is the only supported mode; 4.7 additionally forbids manual thinking entirely
|
||||
# and drops temperature/top_p/top_k).
|
||||
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8")
|
||||
|
||||
# Models where temperature/top_p/top_k return 400 if set to non-default values.
|
||||
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
|
||||
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
|
||||
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
||||
|
||||
# ── Max output token limits per Anthropic model ───────────────────────
|
||||
@@ -124,8 +94,6 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
||||
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
|
||||
# starves thinking-enabled models (thinking tokens count toward the limit).
|
||||
_ANTHROPIC_OUTPUT_LIMITS = {
|
||||
# Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
|
||||
"claude-fable": 128_000,
|
||||
# Claude 4.8
|
||||
"claude-opus-4-8": 128_000,
|
||||
# Claude 4.7
|
||||
@@ -240,17 +208,8 @@ def _resolve_anthropic_messages_max_tokens(
|
||||
|
||||
|
||||
def _supports_adaptive_thinking(model: str) -> bool:
|
||||
"""Return True for Claude models that use adaptive thinking (4.6+).
|
||||
|
||||
Defaults *unknown* Claude models to adaptive (the modern contract) and
|
||||
only returns False for the explicit legacy list of older Claude families
|
||||
that require manual budget-based thinking. Non-Claude Anthropic-Messages
|
||||
models (minimax, qwen3, …) return False so they keep the manual path.
|
||||
"""
|
||||
if not _is_claude_model(model):
|
||||
return False
|
||||
m = model.lower()
|
||||
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
|
||||
"""Return True for Claude 4.6+ models that support adaptive thinking."""
|
||||
return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
|
||||
|
||||
|
||||
def _supports_xhigh_effort(model: str) -> bool:
|
||||
@@ -260,33 +219,18 @@ def _supports_xhigh_effort(model: str) -> bool:
|
||||
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
|
||||
and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max
|
||||
when this returns False.
|
||||
|
||||
Defaults unknown adaptive Claude models to accepting xhigh (4.7+ contract);
|
||||
only the 4.6 family and legacy manual-thinking models are excluded.
|
||||
"""
|
||||
if not _supports_adaptive_thinking(model):
|
||||
return False
|
||||
m = model.lower()
|
||||
return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS)
|
||||
return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS)
|
||||
|
||||
|
||||
def _forbids_sampling_params(model: str) -> bool:
|
||||
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
|
||||
|
||||
Opus 4.7 introduced this restriction; later Claude releases follow it.
|
||||
Defaults unknown Claude models to forbidding sampling params (the modern
|
||||
contract). The 4.6 family still accepts them, and the legacy manual-thinking
|
||||
families (4.5 and older) accept them too, so both are excluded. Non-Claude
|
||||
models are unaffected. Callers should omit these fields entirely rather than
|
||||
passing zero/default values (the API rejects anything non-null).
|
||||
Opus 4.7 explicitly rejects sampling parameters; later Claude releases are
|
||||
expected to follow suit. Callers should omit these fields entirely rather
|
||||
than passing zero/default values (the API rejects anything non-null).
|
||||
"""
|
||||
if not _is_claude_model(model):
|
||||
return False
|
||||
m = model.lower()
|
||||
# 4.6 family is adaptive but still accepts sampling params.
|
||||
if any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS):
|
||||
return False
|
||||
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
|
||||
return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
|
||||
|
||||
|
||||
def _supports_fast_mode(model: str) -> bool:
|
||||
@@ -372,7 +316,7 @@ def _detect_claude_code_version() -> str:
|
||||
|
||||
|
||||
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
_MCP_TOOL_PREFIX = "mcp__"
|
||||
_MCP_TOOL_PREFIX = "mcp_"
|
||||
|
||||
|
||||
def _get_claude_code_version() -> str:
|
||||
@@ -673,9 +617,6 @@ def _build_anthropic_client_with_bearer_hook(
|
||||
kwargs = {
|
||||
"timeout": timeout_obj,
|
||||
"http_client": http_client,
|
||||
# Delegate retry to hermes's outer loop (honors Retry-After); the SDK
|
||||
# default max_retries=2 ignores it and double-retries. (#26293)
|
||||
"max_retries": 0,
|
||||
# The SDK requires *something* for api_key/auth_token. Our
|
||||
# event hook overrides Authorization per request so this value
|
||||
# is never sent. The sentinel string makes accidental leaks
|
||||
@@ -754,18 +695,9 @@ def build_anthropic_client(
|
||||
from httpx import Timeout
|
||||
|
||||
normalized_base_url = _normalize_base_url_text(base_url)
|
||||
if normalized_base_url:
|
||||
import re as _re
|
||||
normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/"))
|
||||
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
|
||||
kwargs = {
|
||||
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0),
|
||||
# Delegate all rate-limit / 5xx retry to hermes's outer conversation
|
||||
# loop, which honors Retry-After. The SDK default (max_retries=2) uses
|
||||
# its own 1-2s backoff that ignores Retry-After and double-retries
|
||||
# inside our loop — burning request slots against a bucket that won't
|
||||
# refill for minutes. (#26293)
|
||||
"max_retries": 0,
|
||||
}
|
||||
if normalized_base_url:
|
||||
# Azure Anthropic endpoints require an ``api-version`` query parameter.
|
||||
@@ -861,9 +793,6 @@ def build_anthropic_bedrock_client(region: str):
|
||||
return _anthropic_sdk.AnthropicBedrock(
|
||||
aws_region=region,
|
||||
timeout=Timeout(timeout=900.0, connect=10.0),
|
||||
# Delegate retry to hermes's outer loop (honors Retry-After); the SDK
|
||||
# default max_retries=2 ignores it and double-retries. (#26293)
|
||||
max_retries=0,
|
||||
default_headers={"anthropic-beta": ",".join([*_COMMON_BETAS, _CONTEXT_1M_BETA])},
|
||||
)
|
||||
|
||||
@@ -892,7 +821,6 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
logger.debug("Keychain: security command not available or timed out")
|
||||
@@ -926,72 +854,44 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]:
|
||||
"""Read Claude Code OAuth credentials from ~/.claude/.credentials.json.
|
||||
|
||||
Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None.
|
||||
"""
|
||||
cred_path = Path.home() / ".claude" / ".credentials.json"
|
||||
if not cred_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(cred_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
logger.debug("Failed to read ~/.claude/.credentials.json: %s", e)
|
||||
return None
|
||||
|
||||
oauth_data = data.get("claudeAiOauth")
|
||||
if not (oauth_data and isinstance(oauth_data, dict)):
|
||||
return None
|
||||
access_token = oauth_data.get("accessToken", "")
|
||||
if not access_token:
|
||||
return None
|
||||
return {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": oauth_data.get("refreshToken", ""),
|
||||
"expiresAt": oauth_data.get("expiresAt", 0),
|
||||
"source": "claude_code_credentials_file",
|
||||
}
|
||||
|
||||
|
||||
def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read refreshable Claude Code OAuth credentials.
|
||||
|
||||
Reads from two possible sources and reconciles them:
|
||||
Checks two sources in order:
|
||||
1. macOS Keychain (Darwin only) — "Claude Code-credentials" entry
|
||||
2. ~/.claude/.credentials.json file
|
||||
|
||||
Selection rules when both are present:
|
||||
- If exactly one is non-expired, prefer that one. (Handles the case
|
||||
where Claude Code refreshes one source but not the other — observed
|
||||
in the wild on Claude Code 2.1.x.)
|
||||
- Otherwise, prefer the source with the later ``expiresAt`` so that
|
||||
any subsequent refresh uses the most recent ``refreshToken``.
|
||||
|
||||
This intentionally excludes ~/.claude.json primaryApiKey. Opencode's
|
||||
subscription flow is OAuth/setup-token based with refreshable credentials,
|
||||
and native direct Anthropic provider usage should follow that path rather
|
||||
than auto-detecting Claude's first-party managed key.
|
||||
|
||||
Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None.
|
||||
Returns dict with {accessToken, refreshToken?, expiresAt?} or None.
|
||||
"""
|
||||
# Try macOS Keychain first (covers Claude Code >=2.1.114)
|
||||
kc_creds = _read_claude_code_credentials_from_keychain()
|
||||
file_creds = _read_claude_code_credentials_from_file()
|
||||
if kc_creds:
|
||||
return kc_creds
|
||||
|
||||
if kc_creds and file_creds:
|
||||
kc_valid = is_claude_code_token_valid(kc_creds)
|
||||
file_valid = is_claude_code_token_valid(file_creds)
|
||||
if kc_valid and not file_valid:
|
||||
return kc_creds
|
||||
if file_valid and not kc_valid:
|
||||
return file_creds
|
||||
# Both valid or both expired: prefer the later expiresAt so the
|
||||
# downstream refresh path uses the freshest refresh_token.
|
||||
kc_exp = kc_creds.get("expiresAt", 0) or 0
|
||||
file_exp = file_creds.get("expiresAt", 0) or 0
|
||||
return kc_creds if kc_exp >= file_exp else file_creds
|
||||
# Fall back to JSON file
|
||||
cred_path = Path.home() / ".claude" / ".credentials.json"
|
||||
if cred_path.exists():
|
||||
try:
|
||||
data = json.loads(cred_path.read_text(encoding="utf-8"))
|
||||
oauth_data = data.get("claudeAiOauth")
|
||||
if oauth_data and isinstance(oauth_data, dict):
|
||||
access_token = oauth_data.get("accessToken", "")
|
||||
if access_token:
|
||||
return {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": oauth_data.get("refreshToken", ""),
|
||||
"expiresAt": oauth_data.get("expiresAt", 0),
|
||||
"source": "claude_code_credentials_file",
|
||||
}
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
logger.debug("Failed to read ~/.claude/.credentials.json: %s", e)
|
||||
|
||||
return kc_creds or file_creds
|
||||
return None
|
||||
|
||||
|
||||
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
|
||||
@@ -1074,40 +974,8 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)
|
||||
|
||||
|
||||
def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
|
||||
"""Attempt to refresh an expired Claude Code OAuth token.
|
||||
|
||||
Claude Code's OAuth refresh tokens are single-use: a successful refresh
|
||||
rotates the pair and invalidates the old refresh token. Claude Code itself
|
||||
also refreshes on its own schedule (IDE/CLI activity), so by the time
|
||||
Hermes notices an expired token, Claude Code may have already rotated it.
|
||||
POSTing our now-stale refresh token in that window races Claude Code and
|
||||
fails with ``invalid_grant``.
|
||||
|
||||
So before refreshing, re-read the live credential sources. If Claude Code
|
||||
has already produced a valid token, adopt it and skip the POST entirely.
|
||||
Only fall back to refreshing ourselves when no fresh credential is found.
|
||||
"""
|
||||
# Claude Code may have already refreshed — adopt its token rather than
|
||||
# racing it with our (possibly already-rotated) refresh token. Only adopt
|
||||
# when the live re-read produced a DIFFERENT token with a real future
|
||||
# expiry: re-adopting the same credential we were just handed would be a
|
||||
# no-op, and a 0/absent ``expiresAt`` means "managed key / unknown expiry"
|
||||
# (see is_claude_code_token_valid) which must NOT be treated as a fresh
|
||||
# refresh here.
|
||||
current = read_claude_code_credentials()
|
||||
if current:
|
||||
current_token = current.get("accessToken", "")
|
||||
current_exp = current.get("expiresAt", 0) or 0
|
||||
if (
|
||||
current_token
|
||||
and current_token != creds.get("accessToken", "")
|
||||
and current_exp > 0
|
||||
and is_claude_code_token_valid(current)
|
||||
):
|
||||
logger.debug("Adopted Claude Code's already-refreshed OAuth token")
|
||||
return current_token
|
||||
|
||||
refresh_token = (current or {}).get("refreshToken", "") or creds.get("refreshToken", "")
|
||||
"""Attempt to refresh an expired Claude Code OAuth token."""
|
||||
refresh_token = creds.get("refreshToken", "")
|
||||
if not refresh_token:
|
||||
logger.debug("No refresh token available — cannot refresh")
|
||||
return None
|
||||
@@ -1231,46 +1099,6 @@ def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[s
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_anthropic_pool_token() -> Optional[str]:
|
||||
"""Return the first available Anthropic OAuth token from credential_pool.
|
||||
|
||||
Read-only: enumerates with ``clear_expired=False, refresh=False`` so a bare
|
||||
token *resolve* (which runs from diagnostic/read-only call sites such as
|
||||
``account_usage`` and ``hermes models``) never mutates ``~/.hermes/auth.json``
|
||||
or makes a network refresh call. Refresh-on-expiry is owned by the API call
|
||||
path's pool recovery, not the resolver.
|
||||
"""
|
||||
try:
|
||||
from agent.credential_pool import AUTH_TYPE_OAUTH, load_pool
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
pool = load_pool("anthropic")
|
||||
# Enumerate read-only (clear_expired=False, refresh=False): never persist
|
||||
# to auth.json or trigger a network refresh from a bare resolve. select()
|
||||
# is deliberately NOT used — it runs clear_expired=True, refresh=True,
|
||||
# which would violate this read-only contract.
|
||||
entries = pool._available_entries(clear_expired=False, refresh=False)
|
||||
except Exception:
|
||||
logger.debug("Failed to read Anthropic credential_pool", exc_info=True)
|
||||
return None
|
||||
|
||||
for entry in entries:
|
||||
if getattr(entry, "auth_type", None) != AUTH_TYPE_OAUTH:
|
||||
continue
|
||||
# access_token is a declared field but a persisted entry can carry an
|
||||
# explicit null (or a partially-written OAuth entry), so coerce before
|
||||
# strip — a bare None.strip() here would escape the try/excepts above
|
||||
# and crash the whole resolver, taking down the source #5 fallback too.
|
||||
# Matches the aux-client analog (auxiliary_client.py: str(key or "")).
|
||||
token = (getattr(entry, "access_token", None) or "").strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_anthropic_token() -> Optional[str]:
|
||||
"""Resolve an Anthropic token from all available sources.
|
||||
|
||||
@@ -1279,8 +1107,7 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||
2. CLAUDE_CODE_OAUTH_TOKEN env var
|
||||
3. Claude Code credentials (~/.claude.json or ~/.claude/.credentials.json)
|
||||
— with automatic refresh if expired and a refresh token is available
|
||||
4. Anthropic credential_pool OAuth entry (~/.hermes/auth.json)
|
||||
5. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
|
||||
4. ANTHROPIC_API_KEY env var (regular API key, or legacy fallback)
|
||||
|
||||
Returns the token string or None.
|
||||
"""
|
||||
@@ -1307,12 +1134,7 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||
if resolved_claude_token:
|
||||
return resolved_claude_token
|
||||
|
||||
# 4. Hermes credential_pool OAuth entry.
|
||||
resolved_pool_token = _resolve_anthropic_pool_token()
|
||||
if resolved_pool_token:
|
||||
return resolved_pool_token
|
||||
|
||||
# 5. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
||||
# 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
||||
# This remains as a compatibility fallback for pre-migration Hermes configs.
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
|
||||
if api_key:
|
||||
@@ -1341,10 +1163,7 @@ def run_oauth_setup_token() -> Optional[str]:
|
||||
"Install it with: npm install -g @anthropic-ai/claude-code"
|
||||
)
|
||||
|
||||
# Run interactively — stdin/stdout/stderr inherited so the user can
|
||||
# complete the OAuth login prompt. Must keep inherited stdin; the TUI-EOF
|
||||
# concern does not apply to an interactive login the user explicitly
|
||||
# invokes. noqa: subprocess-stdin
|
||||
# Run interactively — stdin/stdout/stderr inherited so user can interact
|
||||
try:
|
||||
subprocess.run([claude_path, "setup-token"])
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
@@ -1369,15 +1188,7 @@ def run_oauth_setup_token() -> Optional[str]:
|
||||
# Stores credentials in ~/.hermes/.anthropic_oauth.json (our own file).
|
||||
|
||||
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
|
||||
# console.anthropic.com now 404s. Callers should iterate _OAUTH_TOKEN_URLS
|
||||
# (new host first, console fallback). _OAUTH_TOKEN_URL is kept as the primary
|
||||
# for backward compatibility with existing imports and now points at the live host.
|
||||
_OAUTH_TOKEN_URLS = [
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
]
|
||||
_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
|
||||
_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
||||
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
|
||||
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
|
||||
@@ -1475,34 +1286,18 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
"code_verifier": verifier,
|
||||
}).encode()
|
||||
|
||||
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
|
||||
# console.anthropic.com now 404s. Try the new host first, then fall
|
||||
# back to console for older deployments (mirrors the refresh path).
|
||||
result = None
|
||||
last_error = None
|
||||
for endpoint in _OAUTH_TOKEN_URLS:
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
data=exchange_data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
logger.debug("Anthropic token exchange failed at %s: %s", endpoint, exc)
|
||||
continue
|
||||
req = urllib.request.Request(
|
||||
_OAUTH_TOKEN_URL,
|
||||
data=exchange_data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise last_error if last_error is not None else ValueError(
|
||||
"Anthropic token exchange failed"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
print(f"Token exchange failed: {e}")
|
||||
return None
|
||||
@@ -1716,15 +1511,6 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if ptype == "input_text":
|
||||
block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
|
||||
elif ptype == "text":
|
||||
# A stored Anthropic text block. Rebuild from whitelisted fields only —
|
||||
# SDK response text blocks carry output-only siblings (parsed_output,
|
||||
# citations=None) that the Messages INPUT schema rejects with HTTP 400
|
||||
# "Extra inputs are not permitted". Do NOT dict(part) it verbatim.
|
||||
block = {"type": "text", "text": part.get("text", "")}
|
||||
cits = part.get("citations")
|
||||
if isinstance(cits, list) and cits:
|
||||
block["citations"] = cits
|
||||
elif ptype in {"image_url", "input_image"}:
|
||||
image_value = part.get("image_url", {})
|
||||
url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
|
||||
@@ -1839,58 +1625,6 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Strip output-only fields from a stored Anthropic content block so it is
|
||||
valid as REQUEST input on replay.
|
||||
|
||||
The SDK response objects carry output-only attributes that the Messages
|
||||
*input* schema forbids ("Extra inputs are not permitted"): text blocks get
|
||||
``parsed_output``/``citations`` (when null), tool_use blocks get ``caller``,
|
||||
etc. ``normalize_response`` captured blocks verbatim via ``_to_plain_data``,
|
||||
so these leak back as input on the next turn → HTTP 400.
|
||||
|
||||
Whitelist per type (NOT a blacklist) so future SDK output-only fields can't
|
||||
reintroduce the bug. Returns a clean block, or None to drop it.
|
||||
"""
|
||||
if not isinstance(b, dict):
|
||||
return None
|
||||
btype = b.get("type")
|
||||
if btype == "text":
|
||||
out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")}
|
||||
# citations is input-valid ONLY when it's a non-empty list; the SDK
|
||||
# emits citations=None on responses, which the input schema rejects.
|
||||
cits = b.get("citations")
|
||||
if isinstance(cits, list) and cits:
|
||||
out["citations"] = cits
|
||||
if isinstance(b.get("cache_control"), dict):
|
||||
out["cache_control"] = b["cache_control"]
|
||||
return out
|
||||
if btype == "thinking":
|
||||
out = {"type": "thinking", "thinking": b.get("thinking", "")}
|
||||
if b.get("signature"):
|
||||
out["signature"] = b["signature"]
|
||||
return out
|
||||
if btype == "redacted_thinking":
|
||||
# Only valid with its data payload; drop if missing.
|
||||
return {"type": "redacted_thinking", "data": b["data"]} if b.get("data") else None
|
||||
if btype == "tool_use":
|
||||
out = {
|
||||
"type": "tool_use",
|
||||
"id": _sanitize_tool_id(b.get("id", "")),
|
||||
"name": b.get("name", ""),
|
||||
"input": b.get("input", {}),
|
||||
}
|
||||
if isinstance(b.get("cache_control"), dict):
|
||||
out["cache_control"] = b["cache_control"]
|
||||
return out
|
||||
if btype == "image":
|
||||
src = b.get("source")
|
||||
return {"type": "image", "source": src} if isinstance(src, dict) else None
|
||||
# Unknown/unsupported block type on the input path — drop rather than risk
|
||||
# another "Extra inputs are not permitted".
|
||||
return None
|
||||
|
||||
|
||||
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Convert an assistant message to Anthropic content blocks.
|
||||
|
||||
@@ -1898,55 +1632,6 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
reasoning_content injection for Kimi/DeepSeek endpoints.
|
||||
"""
|
||||
content = m.get("content", "")
|
||||
# Anthropic interleaved-thinking fast path: when this turn carries a
|
||||
# verbatim, order-preserving block list (set by normalize_response only
|
||||
# for turns that interleave SIGNED thinking with tool_use), replay it.
|
||||
# Each block is run through _sanitize_replay_block to strip output-only
|
||||
# SDK fields (parsed_output, caller, citations=None, …) that the Messages
|
||||
# INPUT schema forbids — replaying them verbatim caused HTTP 400 "Extra
|
||||
# inputs are not permitted" (text.parsed_output). Block ORDER is preserved
|
||||
# (the reason this channel exists); only forbidden sibling fields are
|
||||
# dropped, leaving thinking signatures and tool_use id/name/input intact.
|
||||
ordered_blocks = m.get("anthropic_content_blocks")
|
||||
if isinstance(ordered_blocks, list) and ordered_blocks:
|
||||
# Re-source each tool_use input from the stored tool_calls map rather
|
||||
# than the captured block. The ordered-blocks list captures tool_use
|
||||
# input from the RAW API response (normalize_response), which is NOT
|
||||
# credential-redacted; tool_calls[].function.arguments IS redacted at
|
||||
# storage time (build_assistant_message, #19798). Replaying the raw
|
||||
# block input would resurrect a secret the model inlined into a tool
|
||||
# call (e.g. terminal(command="curl -H 'Authorization: Bearer sk-...'")
|
||||
# onto the wire, even though the same value is redacted everywhere else
|
||||
# in history. Keying by sanitized tool id preserves interleave order
|
||||
# (the reason this channel exists) while swapping in the redacted
|
||||
# input. Adapted from #36071 (replay-time tool-input re-sourcing).
|
||||
redacted_input_by_id: Dict[str, Any] = {}
|
||||
for tc in m.get("tool_calls", []) or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function", {}) or {}
|
||||
raw_args = fn.get("arguments", "{}")
|
||||
try:
|
||||
parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed_args = {}
|
||||
redacted_input_by_id[_sanitize_tool_id(tc.get("id", ""))] = parsed_args
|
||||
replayed: List[Dict[str, Any]] = []
|
||||
for b in ordered_blocks:
|
||||
clean = _sanitize_replay_block(b)
|
||||
if clean is None:
|
||||
continue
|
||||
if clean.get("type") == "tool_use":
|
||||
# Override raw (un-redacted) input with the redacted copy when
|
||||
# we have one for this id; fall back to the sanitized block
|
||||
# input only if the tool_call is missing (shape mismatch).
|
||||
redacted = redacted_input_by_id.get(clean.get("id", ""))
|
||||
if redacted is not None:
|
||||
clean["input"] = redacted
|
||||
replayed.append(clean)
|
||||
if replayed:
|
||||
return {"role": "assistant", "content": replayed}
|
||||
|
||||
blocks = _extract_preserved_thinking_blocks(m)
|
||||
if content:
|
||||
if isinstance(content, list):
|
||||
@@ -2491,46 +2176,25 @@ def build_anthropic_kwargs(
|
||||
text = text.replace("Nous Research", "Anthropic")
|
||||
block["text"] = text
|
||||
|
||||
# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
|
||||
# single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
|
||||
# billing classifier treats a single-underscore ``mcp_`` tool name as
|
||||
# a third-party-app fingerprint and rejects the request with HTTP 400
|
||||
# "Third-party apps now draw from extra usage, not plan limits"
|
||||
# (verified empirically: a single ``mcp_foo`` tool flips a request
|
||||
# from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).
|
||||
#
|
||||
# Two cases, both must land on the double-underscore ``mcp__`` form:
|
||||
# a) bare Hermes-native tools (``read_file``) -> ``mcp__read_file``
|
||||
# b) native MCP server tools registered under their full
|
||||
# single-underscore ``mcp_<server>_<tool>`` name
|
||||
# (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``
|
||||
# Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap
|
||||
# left open: those tools were *skipped* and stayed single-underscore,
|
||||
# so any session with an MCP server configured still tripped the
|
||||
# classifier. normalize_response reverses both forms via registry
|
||||
# lookup so the dispatcher still sees the original name. GH-25255.
|
||||
def _to_oauth_wire_name(name: str) -> str:
|
||||
if name.startswith("mcp__"):
|
||||
return name # already correct, don't double-prefix
|
||||
if name.startswith("mcp_"):
|
||||
# single-underscore native MCP tool -> promote to double
|
||||
return "mcp__" + name[len("mcp_"):]
|
||||
return _MCP_TOOL_PREFIX + name # bare name -> mcp__<name>
|
||||
|
||||
# 3. Prefix tool names with mcp_ (Claude Code convention)
|
||||
# Skip names that already begin with the marker — native MCP server
|
||||
# tools (from mcp_servers: in config.yaml) are registered under their
|
||||
# full mcp_<server>_<tool> name and would double-prefix otherwise,
|
||||
# breaking round-trip registry lookup in normalize_response. GH-25255.
|
||||
if anthropic_tools:
|
||||
for tool in anthropic_tools:
|
||||
if "name" in tool:
|
||||
tool["name"] = _to_oauth_wire_name(tool["name"])
|
||||
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
|
||||
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
|
||||
|
||||
# 4. Apply the same normalization to tool names in message history
|
||||
# (tool_use blocks) so replayed turns match the wire names above.
|
||||
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
|
||||
for msg in anthropic_messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "tool_use" and "name" in block:
|
||||
block["name"] = _to_oauth_wire_name(block["name"])
|
||||
if not block["name"].startswith(_MCP_TOOL_PREFIX):
|
||||
block["name"] = _MCP_TOOL_PREFIX + block["name"]
|
||||
elif block.get("type") == "tool_result" and "tool_use_id" in block:
|
||||
pass # tool_result uses ID, not name
|
||||
|
||||
@@ -2677,56 +2341,3 @@ def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any:
|
||||
sorted(leaked),
|
||||
)
|
||||
return api_kwargs
|
||||
|
||||
|
||||
def _is_stream_unavailable_error(exc: Exception) -> bool:
|
||||
"""Return True when an Anthropic stream call should fall back to create()."""
|
||||
err_lower = str(exc).lower()
|
||||
if "stream" in err_lower and "not supported" in err_lower:
|
||||
return True
|
||||
if "invokemodelwithresponsestream" in err_lower:
|
||||
from agent.bedrock_adapter import is_streaming_access_denied_error
|
||||
|
||||
return is_streaming_access_denied_error(exc)
|
||||
return False
|
||||
|
||||
|
||||
def create_anthropic_message(
|
||||
client: Any,
|
||||
api_kwargs: dict,
|
||||
*,
|
||||
log_prefix: str = "",
|
||||
prefer_stream: bool = True,
|
||||
) -> Any:
|
||||
"""Create an Anthropic message, aggregating via stream when available.
|
||||
|
||||
Some Anthropic-compatible gateways are SSE-only: they ignore non-streaming
|
||||
requests and return ``text/event-stream`` even for ``messages.create()``.
|
||||
The SDK can surface that as raw text, so callers that expect a Message then
|
||||
crash on ``.content``. Prefer ``messages.stream().get_final_message()`` to
|
||||
match the main turn path, falling back to ``create()`` only for providers
|
||||
that explicitly do not support streaming, such as restricted Bedrock roles.
|
||||
"""
|
||||
sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix)
|
||||
|
||||
messages_api = getattr(client, "messages", None)
|
||||
stream_fn = getattr(messages_api, "stream", None)
|
||||
if prefer_stream and callable(stream_fn):
|
||||
stream_kwargs = dict(api_kwargs)
|
||||
stream_kwargs.pop("stream", None)
|
||||
try:
|
||||
with stream_fn(**stream_kwargs) as stream:
|
||||
return stream.get_final_message()
|
||||
except Exception as exc:
|
||||
if not _is_stream_unavailable_error(exc):
|
||||
raise
|
||||
logger.debug(
|
||||
"%sAnthropic Messages stream unavailable; falling back to "
|
||||
"messages.create(): %s",
|
||||
log_prefix,
|
||||
exc,
|
||||
)
|
||||
|
||||
create_kwargs = dict(api_kwargs)
|
||||
create_kwargs.pop("stream", None)
|
||||
return messages_api.create(**create_kwargs)
|
||||
|
||||
+101
-894
File diff suppressed because it is too large
Load Diff
+46
-312
@@ -27,131 +27,6 @@ from typing import Any, Dict, List, Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background-review aux-model selector + routed digest.
|
||||
#
|
||||
# The review fork runs on the MAIN model by default ("auto"), replaying the
|
||||
# full conversation — already warm in the prompt cache, so cheap cache reads.
|
||||
# Optimal and unchanged. A user can route the review to a different, cheaper
|
||||
# model via auxiliary.background_review.{provider,model}. A different model
|
||||
# cannot reuse the parent's cache (different key), so the fork is cold
|
||||
# regardless — replaying the full transcript would just cold-write it. So when
|
||||
# (and only when) routed to a different model, we replay a compact DIGEST to
|
||||
# minimise cold-written tokens. Same model -> full replay; different model ->
|
||||
# digest. That's the whole policy.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
|
||||
"""Resolve provider/model/credentials for the review fork.
|
||||
|
||||
Default (auto / unset / same as parent): inherit the parent's live runtime
|
||||
(with codex_app_server -> codex_responses downgrade). ``routed`` is False —
|
||||
the fork uses the main model and the warm cache, exactly as before. When
|
||||
``auxiliary.background_review.{provider,model}`` names a concrete model
|
||||
different from the parent's, resolve that runtime and set ``routed=True``.
|
||||
"""
|
||||
parent_runtime = agent._current_main_runtime()
|
||||
parent_api_mode = parent_runtime.get("api_mode") or None
|
||||
if parent_api_mode == "codex_app_server":
|
||||
parent_api_mode = "codex_responses"
|
||||
parent = {
|
||||
"provider": agent.provider,
|
||||
"model": agent.model,
|
||||
"api_key": parent_runtime.get("api_key") or None,
|
||||
"base_url": parent_runtime.get("base_url") or None,
|
||||
"api_mode": parent_api_mode,
|
||||
"routed": False,
|
||||
}
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
except Exception:
|
||||
return parent
|
||||
aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
|
||||
task = aux.get("background_review", {}) if isinstance(aux.get("background_review"), dict) else {}
|
||||
task_provider = (str(task.get("provider", "")).strip() or None)
|
||||
task_model = (str(task.get("model", "")).strip() or None)
|
||||
task_base_url = (str(task.get("base_url", "")).strip() or None)
|
||||
task_api_key = (str(task.get("api_key", "")).strip() or None)
|
||||
if not (task_provider and task_provider != "auto" and task_model):
|
||||
return parent
|
||||
if task_provider == (agent.provider or "") and task_model == (agent.model or ""):
|
||||
return parent # same model/provider as parent -> not routed
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
rp = resolve_runtime_provider(
|
||||
requested=task_provider,
|
||||
target_model=task_model,
|
||||
explicit_api_key=task_api_key,
|
||||
explicit_base_url=task_base_url,
|
||||
)
|
||||
return {
|
||||
"provider": rp.get("provider") or task_provider,
|
||||
"model": task_model,
|
||||
"api_key": rp.get("api_key"),
|
||||
"base_url": rp.get("base_url"),
|
||||
"api_mode": rp.get("api_mode"),
|
||||
"routed": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug("background-review aux routing failed (%s); using main model", e)
|
||||
return parent
|
||||
|
||||
|
||||
def _msg_text(m: Dict) -> str:
|
||||
c = m.get("content")
|
||||
if isinstance(c, str):
|
||||
return c.strip()
|
||||
if isinstance(c, list):
|
||||
return " ".join(b.get("text", "") for b in c if isinstance(b, dict)).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict]:
|
||||
"""Compact replay for the routed (different-model) path only.
|
||||
|
||||
Keeps the recent ``tail`` messages verbatim, collapses older turns into one
|
||||
synthetic user-role digest, preserving role alternation. Used ONLY when
|
||||
routed to a different model (cache cold regardless, so fewer cold-written
|
||||
tokens is a pure win). Never on the main-model path (full replay stays warm).
|
||||
"""
|
||||
msgs = list(messages_snapshot or [])
|
||||
if len(msgs) <= tail:
|
||||
return msgs
|
||||
keep = msgs[-tail:]
|
||||
while keep and isinstance(keep[0], dict) and keep[0].get("role") == "tool":
|
||||
tail += 1
|
||||
if len(msgs) <= tail:
|
||||
return msgs
|
||||
keep = msgs[-tail:]
|
||||
old = msgs[:-len(keep)]
|
||||
lines: List[str] = []
|
||||
for m in old:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
role = m.get("role")
|
||||
text = _msg_text(m).replace("\n", " ")
|
||||
if role == "user" and text:
|
||||
lines.append(f"USER: {text[:300]}")
|
||||
elif role == "assistant":
|
||||
tcs = m.get("tool_calls") or []
|
||||
if tcs:
|
||||
names = [(tc.get("function") or {}).get("name", "?") for tc in tcs if isinstance(tc, dict)]
|
||||
lines.append(f"ASSISTANT[tools: {', '.join(names)}]")
|
||||
if text:
|
||||
lines.append(f"ASSISTANT: {text[:200]}")
|
||||
digest = {
|
||||
"role": "user",
|
||||
"content": (
|
||||
"[Earlier conversation digest — older turns summarised to bound the "
|
||||
"review's cold-write cost on the routed aux model. Recent turns "
|
||||
"follow verbatim below.]\n" + "\n".join(lines)
|
||||
),
|
||||
}
|
||||
return [digest] + keep
|
||||
|
||||
|
||||
# Review-prompt strings — used by ``spawn_background_review_thread`` to build
|
||||
# the user-message that the forked review agent receives. AIAgent exposes
|
||||
# them as class attributes (``_MEMORY_REVIEW_PROMPT`` etc.) for back-compat;
|
||||
@@ -362,25 +237,18 @@ _COMBINED_REVIEW_PROMPT = (
|
||||
def summarize_background_review_actions(
|
||||
review_messages: List[Dict],
|
||||
prior_snapshot: List[Dict],
|
||||
notification_mode: str = "on",
|
||||
) -> List[str]:
|
||||
"""Build the human-facing action summary for a background review pass.
|
||||
|
||||
Walks the review agent's session messages and collects successful memory
|
||||
and skill-management actions to surface to the user. Tool messages already
|
||||
present in ``prior_snapshot`` are skipped so stale inherited results are
|
||||
not re-surfaced as fresh background work (issue #14944).
|
||||
Walks the review agent's session messages and collects "successful tool
|
||||
action" descriptions to surface to the user (e.g. "Memory updated").
|
||||
Tool messages already present in ``prior_snapshot`` are skipped so we
|
||||
don't re-surface stale results from the prior conversation that the
|
||||
review agent inherited via ``conversation_history`` (issue #14944).
|
||||
|
||||
``notification_mode`` controls display detail:
|
||||
- ``off``: return no actions.
|
||||
- ``on``: generic "Memory updated"/tool messages.
|
||||
- ``verbose``: include compact content previews from tool-call arguments.
|
||||
Matching is by ``tool_call_id`` when available, with a content-equality
|
||||
fallback for tool messages that lack one.
|
||||
"""
|
||||
mode = str(notification_mode or "on").lower()
|
||||
if mode == "off":
|
||||
return []
|
||||
verbose = mode == "verbose"
|
||||
|
||||
existing_tool_call_ids = set()
|
||||
existing_tool_contents = set()
|
||||
for prior in prior_snapshot or []:
|
||||
@@ -394,43 +262,6 @@ def summarize_background_review_actions(
|
||||
if isinstance(content, str):
|
||||
existing_tool_contents.add(content)
|
||||
|
||||
# Map review-agent tool results back to the calls that produced them. The
|
||||
# result JSON only says "Entry added"; the call arguments contain action,
|
||||
# target, and content previews. Restricting to notify_tools also prevents
|
||||
# helper tools from surfacing as memory work just because they succeeded.
|
||||
notify_tools = {"memory", "skill_manage"}
|
||||
all_tool_call_ids: set = set()
|
||||
call_details: dict = {}
|
||||
for msg in review_messages or []:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
continue
|
||||
for tc in msg.get("tool_calls", []) or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function", {}) or {}
|
||||
fn_name = fn.get("name", "")
|
||||
tcid = tc.get("id")
|
||||
if tcid:
|
||||
all_tool_call_ids.add(tcid)
|
||||
if fn_name not in notify_tools:
|
||||
continue
|
||||
try:
|
||||
args = json.loads(fn.get("arguments", "{}"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
if tcid:
|
||||
call_details[tcid] = {
|
||||
"tool": fn_name,
|
||||
"action": args.get("action", "?"),
|
||||
"target": args.get("target", "memory"),
|
||||
"content": args.get("content", ""),
|
||||
"old_text": args.get("old_text", ""),
|
||||
"operations": args.get("operations") or [],
|
||||
"name": args.get("name", ""),
|
||||
"old_string": args.get("old_string", ""),
|
||||
"new_string": args.get("new_string", ""),
|
||||
}
|
||||
|
||||
actions: List[str] = []
|
||||
for msg in review_messages or []:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
||||
@@ -442,8 +273,6 @@ def summarize_background_review_actions(
|
||||
content_str = msg.get("content")
|
||||
if isinstance(content_str, str) and content_str in existing_tool_contents:
|
||||
continue
|
||||
if tcid and all_tool_call_ids and tcid not in call_details:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(msg.get("content", "{}"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
@@ -451,92 +280,19 @@ def summarize_background_review_actions(
|
||||
if not isinstance(data, dict) or not data.get("success"):
|
||||
continue
|
||||
message = data.get("message", "")
|
||||
detail = call_details.get(tcid, {})
|
||||
target = data.get("target", "") or detail.get("target", "")
|
||||
is_skill = detail.get("tool") == "skill_manage"
|
||||
|
||||
message_lower = message.lower()
|
||||
if not verbose:
|
||||
if "created" in message_lower:
|
||||
actions.append(message)
|
||||
continue
|
||||
if "updated" in message_lower:
|
||||
actions.append(message)
|
||||
continue
|
||||
if is_skill and "patched" in message_lower:
|
||||
actions.append(message)
|
||||
continue
|
||||
|
||||
if is_skill:
|
||||
label = "Skill"
|
||||
elif target:
|
||||
target = data.get("target", "")
|
||||
if "created" in message.lower():
|
||||
actions.append(message)
|
||||
elif "updated" in message.lower():
|
||||
actions.append(message)
|
||||
elif "added" in message.lower() or (target and "add" in message.lower()):
|
||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
||||
actions.append(f"{label} updated")
|
||||
elif "Entry added" in message:
|
||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
||||
actions.append(f"{label} updated")
|
||||
elif "removed" in message.lower() or "replaced" in message.lower():
|
||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
||||
else:
|
||||
continue
|
||||
|
||||
if verbose:
|
||||
action = detail.get("action", "")
|
||||
content = detail.get("content", "")
|
||||
old_text = detail.get("old_text", "")
|
||||
skill_name = detail.get("name", "")
|
||||
operations = detail.get("operations") or []
|
||||
max_preview = 120
|
||||
if is_skill:
|
||||
change = data.get("_change", {})
|
||||
old_string = change.get("old", "") or detail.get("old_string", "")
|
||||
new_string = change.get("new", "") or detail.get("new_string", "")
|
||||
description = change.get("description", "")
|
||||
if action == "patch" and (old_string or new_string):
|
||||
old_preview = old_string[:80].replace("\n", " ") + (
|
||||
"…" if len(old_string) > 80 else ""
|
||||
)
|
||||
new_preview = new_string[:80].replace("\n", " ") + (
|
||||
"…" if len(new_string) > 80 else ""
|
||||
)
|
||||
actions.append(
|
||||
f"📝 Skill '{skill_name}' patched: "
|
||||
f"\"{old_preview}\" → \"{new_preview}\""
|
||||
)
|
||||
elif action == "create" and description:
|
||||
actions.append(f"📝 Skill '{skill_name}' created: {description}")
|
||||
elif action == "edit" and description:
|
||||
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
|
||||
else:
|
||||
actions.append(f"📝 {message}" if message else f"Skill {action}")
|
||||
elif operations:
|
||||
for op in operations:
|
||||
op = op or {}
|
||||
op_act = op.get("action", "")
|
||||
op_content = (op.get("content") or "")
|
||||
op_old = (op.get("old_text") or "")
|
||||
if op_act == "add" and op_content:
|
||||
preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
|
||||
actions.append(f"{label} ➕ {preview}")
|
||||
elif op_act == "replace" and op_content:
|
||||
preview = op_content[:max_preview] + ("…" if len(op_content) > max_preview else "")
|
||||
actions.append(f"{label} ✏️ {preview}")
|
||||
elif op_act == "remove" and op_old:
|
||||
preview = op_old[:60] + ("…" if len(op_old) > 60 else "")
|
||||
actions.append(f"{label} ➖ {preview}")
|
||||
elif action == "add" and content:
|
||||
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||||
actions.append(f"{label} ➕ {preview}")
|
||||
elif action == "replace" and content:
|
||||
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||||
actions.append(f"{label} ✏️ {preview}")
|
||||
elif action == "remove" and old_text:
|
||||
preview = old_text[:60] + ("…" if len(old_text) > 60 else "")
|
||||
actions.append(f"{label} ➖ {preview}")
|
||||
else:
|
||||
actions.append(f"{label} updated")
|
||||
elif (
|
||||
"added" in message_lower
|
||||
or "replaced" in message_lower
|
||||
or "removed" in message_lower
|
||||
or "applied" in message_lower
|
||||
or (target and "add" in message.lower())
|
||||
or "Entry added" in message
|
||||
):
|
||||
actions.append(f"{label} updated")
|
||||
return actions
|
||||
|
||||
@@ -613,13 +369,18 @@ def _run_review_in_thread(
|
||||
# creds, or credential-pool setups where the resolver can't
|
||||
# reconstruct auth from scratch -- producing the spurious
|
||||
# "No LLM provider configured" warning at end of turn.
|
||||
# _resolve_review_runtime() returns the parent's live runtime by
|
||||
# default (routed=False; main model, warm cache), or — when the user
|
||||
# set auxiliary.background_review.{provider,model} to a different
|
||||
# model — that model's runtime (routed=True). The codex_app_server
|
||||
# -> codex_responses downgrade is applied inside the resolver.
|
||||
_rt = _resolve_review_runtime(agent)
|
||||
_routed = bool(_rt.get("routed"))
|
||||
_parent_runtime = agent._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"
|
||||
# skip_memory=True keeps the review fork from
|
||||
# touching external memory plugins (honcho, mem0,
|
||||
# supermemory, etc.). Without it, the fork's
|
||||
@@ -639,14 +400,14 @@ def _run_review_in_thread(
|
||||
# in the request body — Anthropic's cache key includes it.
|
||||
# (The runtime whitelist below still restricts dispatch.)
|
||||
review_agent = AIAgent(
|
||||
model=_rt.get("model") or agent.model,
|
||||
model=agent.model,
|
||||
max_iterations=16,
|
||||
quiet_mode=True,
|
||||
platform=agent.platform,
|
||||
provider=_rt.get("provider") or agent.provider,
|
||||
api_mode=_rt.get("api_mode"),
|
||||
base_url=_rt.get("base_url") or None,
|
||||
api_key=_rt.get("api_key") or None,
|
||||
provider=agent.provider,
|
||||
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(agent, "_credential_pool", None),
|
||||
parent_session_id=agent.session_id,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
@@ -655,13 +416,6 @@ def _run_review_in_thread(
|
||||
)
|
||||
review_agent._memory_write_origin = "background_review"
|
||||
review_agent._memory_write_context = "background_review"
|
||||
# The review fork pins the parent's cached system prompt and keeps
|
||||
# ``tools[]`` byte-identical to the parent so its outbound request
|
||||
# hits the same provider cache prefix (see the toolset-parity note
|
||||
# above). The between-turns MCP refresh in build_turn_context would
|
||||
# add late-connecting MCP tools to this fork and break that parity,
|
||||
# so opt the review fork out of it.
|
||||
review_agent._skip_mcp_refresh = True
|
||||
review_agent._memory_store = agent._memory_store
|
||||
review_agent._memory_enabled = agent._memory_enabled
|
||||
review_agent._user_profile_enabled = agent._user_profile_enabled
|
||||
@@ -685,28 +439,16 @@ def _run_review_in_thread(
|
||||
# issue #25322 and PR #17276 for the full analysis +
|
||||
# measured impact (~26% end-to-end cost reduction on
|
||||
# Sonnet 4.5).
|
||||
# Share the parent's warm cached system prompt ONLY when the review
|
||||
# runs on the SAME model (not routed). When routed to a different
|
||||
# model the parent's cached prompt is for the wrong model/cache key
|
||||
# and would miss anyway, so let the routed fork build its own.
|
||||
if not _routed:
|
||||
review_agent._cached_system_prompt = agent._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 = agent.session_start
|
||||
review_agent._cached_system_prompt = agent._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 = agent.session_start
|
||||
review_agent.session_id = agent.session_id
|
||||
# The fork shares the parent's live session_id (pinned above for
|
||||
# prefix-cache parity). It is single-lifecycle and calls close()
|
||||
# right after this run_conversation(); without opting out, close()
|
||||
# would finalize the parent's still-active session row mid
|
||||
# conversation (the review fires every ~10 turns). Leave session
|
||||
# finalization to the real owner (CLI close / gateway reset / cron).
|
||||
review_agent._end_session_on_close = False
|
||||
# Never let the review fork compress. It shares the parent's
|
||||
# session_id, so if it won a compression race it would rotate the
|
||||
# parent into a NEW child that the gateway never adopts (the fork
|
||||
@@ -740,13 +482,6 @@ def _run_review_in_thread(
|
||||
),
|
||||
)
|
||||
try:
|
||||
# Routed to a different model -> replay a digest (cache is cold
|
||||
# on that model anyway, so minimise cold-written tokens). Same
|
||||
# model -> replay the full snapshot (warm cache reads).
|
||||
_review_history = (
|
||||
_digest_history(messages_snapshot) if _routed
|
||||
else messages_snapshot
|
||||
)
|
||||
review_agent.run_conversation(
|
||||
user_message=(
|
||||
prompt
|
||||
@@ -754,7 +489,7 @@ def _run_review_in_thread(
|
||||
"management tools. Other tools will be denied "
|
||||
"at runtime — do not attempt them."
|
||||
),
|
||||
conversation_history=_review_history,
|
||||
conversation_history=messages_snapshot,
|
||||
)
|
||||
finally:
|
||||
clear_thread_tool_whitelist()
|
||||
@@ -787,7 +522,6 @@ def _run_review_in_thread(
|
||||
actions = summarize_background_review_actions(
|
||||
review_messages,
|
||||
messages_snapshot,
|
||||
notification_mode=getattr(agent, "memory_notifications", "on"),
|
||||
)
|
||||
|
||||
if actions:
|
||||
|
||||
@@ -58,34 +58,17 @@ _bedrock_runtime_client_cache: Dict[str, Any] = {}
|
||||
_bedrock_control_client_cache: Dict[str, Any] = {}
|
||||
|
||||
|
||||
_MIN_BOTO3_VERSION = (1, 34, 59)
|
||||
|
||||
|
||||
def _require_boto3():
|
||||
"""Import boto3, raising a clear error if not installed or too old."""
|
||||
"""Import boto3, raising a clear error if not installed."""
|
||||
try:
|
||||
import boto3
|
||||
return boto3
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"The 'boto3' package is required for the AWS Bedrock provider. "
|
||||
"Install it with: pip install boto3\n"
|
||||
"Or install Hermes with Bedrock support: pip install -e '.[bedrock]'"
|
||||
)
|
||||
# converse() / converse_stream() were added in boto3 1.34.59.
|
||||
# When Hermes is installed editable into system Python, the system boto3
|
||||
# (e.g. Ubuntu 24.04 ships 1.34.46) may take precedence over the venv
|
||||
# version pinned in pyproject.toml.
|
||||
try:
|
||||
version = tuple(int(x) for x in boto3.__version__.split(".")[:3])
|
||||
except (AttributeError, ValueError):
|
||||
return boto3 # can't parse — don't block on version check
|
||||
if version < _MIN_BOTO3_VERSION:
|
||||
raise RuntimeError(
|
||||
f"boto3 {boto3.__version__} does not support converse_stream "
|
||||
f"(minimum 1.34.59 required). Upgrade with: "
|
||||
f"pip install --upgrade boto3"
|
||||
)
|
||||
return boto3
|
||||
|
||||
|
||||
def _get_bedrock_runtime_client(region: str):
|
||||
@@ -225,41 +208,6 @@ def is_stale_connection_error(exc: BaseException) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_streaming_access_denied_error(exc: BaseException) -> bool:
|
||||
"""Return True when AWS denied the ``bedrock:InvokeModelWithResponseStream`` action.
|
||||
|
||||
IAM policies scoped to ``bedrock:InvokeModel`` only (a common least-privilege
|
||||
setup) reject ``converse_stream()`` with an ``AccessDeniedException`` whose
|
||||
message names the streaming action, e.g.::
|
||||
|
||||
User: arn:aws:iam::123456789012:user/x is not authorized to perform:
|
||||
bedrock:InvokeModelWithResponseStream on resource: ...
|
||||
|
||||
This is permanent for the session — retrying the stream can never succeed —
|
||||
so callers should flip to the non-streaming ``converse()`` path (which maps
|
||||
to ``bedrock:InvokeModel``) instead of burning retries.
|
||||
|
||||
Detection is deliberately message-based: boto3 surfaces this as a
|
||||
``ClientError`` with ``Error.Code == "AccessDeniedException"``, and the
|
||||
AnthropicBedrock SDK wraps the same AWS response in its own exception
|
||||
types, but both preserve the action name in the message.
|
||||
"""
|
||||
msg = str(exc).lower()
|
||||
if "invokemodelwithresponsestream" not in msg:
|
||||
return False
|
||||
# ClientError with an explicit access-denied code is the canonical form.
|
||||
try:
|
||||
from botocore.exceptions import ClientError
|
||||
except ImportError: # pragma: no cover — botocore always present with boto3
|
||||
ClientError = None # type: ignore[assignment]
|
||||
if ClientError is not None and isinstance(exc, ClientError):
|
||||
code = (getattr(exc, "response", None) or {}).get("Error", {}).get("Code", "")
|
||||
return code in ("AccessDeniedException", "UnauthorizedException")
|
||||
# Wrapped forms (e.g. AnthropicBedrock SDK PermissionDeniedError) — match
|
||||
# on the authorization-failure phrasing AWS uses.
|
||||
return "not authorized" in msg or "accessdenied" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AWS credential detection
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -952,14 +900,11 @@ def build_converse_kwargs(
|
||||
if system_prompt:
|
||||
kwargs["system"] = system_prompt
|
||||
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
|
||||
if not _forbids_sampling_params(model):
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
|
||||
if stop_sequences:
|
||||
kwargs["inferenceConfig"]["stopSequences"] = stop_sequences
|
||||
@@ -1058,16 +1003,6 @@ def call_converse_stream(
|
||||
try:
|
||||
response = client.converse_stream(**kwargs)
|
||||
except Exception as exc:
|
||||
if is_streaming_access_denied_error(exc):
|
||||
# IAM allows bedrock:InvokeModel but not
|
||||
# InvokeModelWithResponseStream — permanent for this session.
|
||||
# Fall back to the non-streaming converse() path.
|
||||
logger.info(
|
||||
"bedrock: converse_stream denied by IAM on (region=%s, model=%s) — "
|
||||
"falling back to non-streaming converse().",
|
||||
region, model,
|
||||
)
|
||||
return normalize_converse_response(client.converse(**kwargs))
|
||||
if is_stale_connection_error(exc):
|
||||
logger.warning(
|
||||
"bedrock: stale-connection error on converse_stream(region=%s, "
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
|
||||
|
||||
One fetch/parse per concern, consumed identically by the CLI handler
|
||||
(``cli.py::_show_billing``), the TUI JSON-RPC methods
|
||||
(``tui_gateway/server.py``), and any other surface. Mirrors the proven
|
||||
``agent/account_usage.py::build_credits_view`` pattern: parse the server payload
|
||||
into a frozen dataclass; **fail open** — when not logged in or the portal is
|
||||
unreachable, return a struct with ``logged_in=False`` and let the surface degrade
|
||||
gracefully (never crash).
|
||||
|
||||
Money discipline: the server emits decimal STRINGS (``"142.5"``, not fixed 2dp).
|
||||
We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Decimal money helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def parse_money(value: Any) -> Optional[Decimal]:
|
||||
"""Parse a server money value (decimal string) into :class:`Decimal`.
|
||||
|
||||
Returns None for missing/invalid input. Never raises. Accepts str/int (and,
|
||||
defensively, float — though the server always sends strings).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
# Decimal(str(...)) avoids binary-float artifacts if a float ever sneaks in.
|
||||
return Decimal(str(value).strip())
|
||||
except (InvalidOperation, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def format_money(value: Optional[Decimal]) -> str:
|
||||
"""Format a Decimal as ``$X`` / ``$X.YY`` for display.
|
||||
|
||||
Whole dollars show no decimals; any fractional amount shows exactly 2dp:
|
||||
``Decimal("142.5")`` → ``"$142.50"``, ``Decimal("100")`` → ``"$100"``,
|
||||
``Decimal("0.01")`` → ``"$0.01"``.
|
||||
"""
|
||||
if value is None:
|
||||
return "—"
|
||||
if value == value.to_integral_value():
|
||||
# Whole dollars — no decimal point. format(..., "f") avoids 1E+3 for 1000.
|
||||
return f"${format(value.to_integral_value(), 'f')}"
|
||||
# Fractional — always show 2dp.
|
||||
return f"${format(value.quantize(Decimal('0.01')), 'f')}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parsed sub-structures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CardInfo:
|
||||
brand: str
|
||||
last4: str
|
||||
|
||||
@property
|
||||
def masked(self) -> str:
|
||||
return f"{self.brand} ····{self.last4}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthlyCap:
|
||||
limit_usd: Optional[Decimal] = None
|
||||
spent_this_month_usd: Optional[Decimal] = None
|
||||
is_default_ceiling: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutoReload:
|
||||
enabled: bool = False
|
||||
threshold_usd: Optional[Decimal] = None
|
||||
reload_to_usd: Optional[Decimal] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BillingState:
|
||||
"""Parsed ``GET /api/billing/state`` — the overview screen's data.
|
||||
|
||||
Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
|
||||
portal is unreachable.
|
||||
"""
|
||||
|
||||
logged_in: bool
|
||||
org_id: Optional[str] = None
|
||||
org_slug: Optional[str] = None
|
||||
org_name: Optional[str] = None
|
||||
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
|
||||
balance_usd: Optional[Decimal] = None
|
||||
cli_billing_enabled: bool = False
|
||||
charge_presets: tuple[Decimal, ...] = ()
|
||||
min_usd: Optional[Decimal] = None
|
||||
max_usd: Optional[Decimal] = None
|
||||
card: Optional[CardInfo] = None
|
||||
monthly_cap: Optional[MonthlyCap] = None
|
||||
auto_reload: Optional[AutoReload] = None
|
||||
portal_url: Optional[str] = None
|
||||
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""True for OWNER/ADMIN — the roles that can manage billing."""
|
||||
return (self.role or "").upper() in ("OWNER", "ADMIN")
|
||||
|
||||
@property
|
||||
def can_charge(self) -> bool:
|
||||
"""True when the UI should offer charge/auto-reload actions.
|
||||
|
||||
Admin role AND the per-org kill-switch on. (The server still enforces;
|
||||
this is just for graying out actions the user can't take.)
|
||||
"""
|
||||
return self.is_admin and self.cli_billing_enabled
|
||||
|
||||
|
||||
def _parse_card(raw: Any) -> Optional[CardInfo]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
brand = raw.get("brand")
|
||||
last4 = raw.get("last4")
|
||||
if isinstance(brand, str) and isinstance(last4, str):
|
||||
return CardInfo(brand=brand, last4=last4)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
return MonthlyCap(
|
||||
limit_usd=parse_money(raw.get("limitUsd")),
|
||||
spent_this_month_usd=parse_money(raw.get("spentThisMonthUsd")),
|
||||
is_default_ceiling=bool(raw.get("isDefaultCeiling")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
return AutoReload(
|
||||
enabled=bool(raw.get("enabled")),
|
||||
threshold_usd=parse_money(raw.get("thresholdUsd")),
|
||||
reload_to_usd=parse_money(raw.get("reloadToUsd")),
|
||||
)
|
||||
|
||||
|
||||
def billing_state_from_payload(
|
||||
payload: dict[str, Any], *, portal_url: Optional[str] = None
|
||||
) -> BillingState:
|
||||
"""Map a raw ``/api/billing/state`` JSON dict into :class:`BillingState`."""
|
||||
raw_org = payload.get("org")
|
||||
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
|
||||
raw_bounds = payload.get("bounds")
|
||||
bounds: dict[str, Any] = raw_bounds if isinstance(raw_bounds, dict) else {}
|
||||
|
||||
presets: list[Decimal] = []
|
||||
for item in payload.get("chargePresets") or ():
|
||||
parsed = parse_money(item)
|
||||
if parsed is not None:
|
||||
presets.append(parsed)
|
||||
|
||||
return BillingState(
|
||||
logged_in=True,
|
||||
org_id=org.get("id"),
|
||||
org_slug=org.get("slug"),
|
||||
org_name=org.get("name"),
|
||||
role=org.get("role"),
|
||||
balance_usd=parse_money(payload.get("balanceUsd")),
|
||||
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
|
||||
charge_presets=tuple(presets),
|
||||
min_usd=parse_money(bounds.get("minUsd")),
|
||||
max_usd=parse_money(bounds.get("maxUsd")),
|
||||
card=_parse_card(payload.get("card")),
|
||||
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
|
||||
auto_reload=_parse_auto_reload(payload.get("autoReload")),
|
||||
portal_url=portal_url,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fail-open builders (the surface front doors)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_billing_state(*, timeout: float = 15.0) -> BillingState:
|
||||
"""Fetch + parse ``/api/billing/state``. Fail-open.
|
||||
|
||||
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
|
||||
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
|
||||
a clear message rather than crashing.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingAuthError,
|
||||
BillingError,
|
||||
_absolutize_portal_url,
|
||||
get_billing_state,
|
||||
resolve_portal_base_url,
|
||||
)
|
||||
except Exception:
|
||||
return BillingState(logged_in=False, error="billing client unavailable")
|
||||
|
||||
try:
|
||||
payload = get_billing_state(timeout=timeout)
|
||||
except BillingAuthError:
|
||||
return BillingState(logged_in=False)
|
||||
except BillingError as exc:
|
||||
logger.debug("billing ▸ /state fetch failed (fail-open)", exc_info=True)
|
||||
return BillingState(logged_in=False, error=str(exc))
|
||||
except Exception:
|
||||
logger.debug("billing ▸ /state unexpected error (fail-open)", exc_info=True)
|
||||
return BillingState(logged_in=False, error="could not load billing state")
|
||||
|
||||
# Prefer a server-supplied portalUrl if present (resolved to absolute in case
|
||||
# it's relative); else build the standard one.
|
||||
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
|
||||
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
|
||||
if not portal_url:
|
||||
try:
|
||||
portal_url = _fallback_portal_url(resolve_portal_base_url())
|
||||
except Exception:
|
||||
portal_url = None
|
||||
|
||||
return billing_state_from_payload(payload, portal_url=portal_url)
|
||||
|
||||
|
||||
def _fallback_portal_url(base: str) -> str:
|
||||
"""Standard billing deep-link when the server omits ``portalUrl``."""
|
||||
return f"{base.rstrip('/')}/billing?topup=open"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Idempotency
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def new_idempotency_key() -> str:
|
||||
"""Fresh UUID for a user-confirmed purchase (reuse on retry of the SAME buy).
|
||||
|
||||
The ``Idempotency-Key`` header is mandatory on ``POST /charge``; generate one
|
||||
per confirmed purchase and reuse it across retries so a double-submit collapses
|
||||
to a single charge. Never reuse a key across different amounts (the server
|
||||
returns 409 idempotency_conflict).
|
||||
"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Amount validation (Screen 3 custom input)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmountValidation:
|
||||
ok: bool
|
||||
amount: Optional[Decimal] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def validate_charge_amount(
|
||||
raw: str, *, min_usd: Optional[Decimal], max_usd: Optional[Decimal]
|
||||
) -> AmountValidation:
|
||||
"""Validate a custom charge amount against bounds + 2dp (multipleOf 0.01).
|
||||
|
||||
Mirrors the server's accept/reject so the UI can give instant feedback rather
|
||||
than round-tripping a sure-to-fail charge. The server is still authoritative.
|
||||
"""
|
||||
cleaned = (raw or "").strip().lstrip("$").strip()
|
||||
amount = parse_money(cleaned)
|
||||
if amount is None:
|
||||
return AmountValidation(ok=False, error="Enter a dollar amount, e.g. 100")
|
||||
if amount <= 0:
|
||||
return AmountValidation(ok=False, error="Amount must be greater than $0")
|
||||
# multipleOf 0.01 — reject sub-cent precision.
|
||||
if amount != amount.quantize(Decimal("0.01")):
|
||||
return AmountValidation(ok=False, error="Amount can't be smaller than a cent")
|
||||
if min_usd is not None and amount < min_usd:
|
||||
return AmountValidation(ok=False, error=f"Minimum is {format_money(min_usd)}")
|
||||
if max_usd is not None and amount > max_usd:
|
||||
return AmountValidation(ok=False, error=f"Maximum is {format_money(max_usd)}")
|
||||
return AmountValidation(ok=True, amount=amount)
|
||||
@@ -28,28 +28,15 @@ from typing import Any, Dict, Optional
|
||||
from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
|
||||
from agent.error_classifier import FailoverReason
|
||||
from agent.gemini_native_adapter import is_native_gemini_base_url
|
||||
from agent.model_metadata import is_local_endpoint
|
||||
from agent.message_sanitization import (
|
||||
_sanitize_surrogates,
|
||||
_repair_tool_call_arguments,
|
||||
)
|
||||
from tools.terminal_tool import is_persistent_env
|
||||
from utils import base_url_host_matches, base_url_hostname, env_float, env_int
|
||||
from utils import base_url_host_matches, base_url_hostname, env_int
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_OPENROUTER_PROVIDER_SORT_VALUES = {"throughput", "latency", "price"}
|
||||
|
||||
# When the fallback chain is fully exhausted on a non-rate-limit failure
|
||||
# (e.g. every provider returns a non-retryable client error like HTTP 400),
|
||||
# arm a short cooldown so the NEXT turn's restore_primary_runtime stays gated
|
||||
# and does not reset _fallback_index=0 to replay the entire chain again.
|
||||
# Without this, a client/gateway that re-submits immediately would re-marshal
|
||||
# the full (potentially 80k-token) context once per provider every turn and
|
||||
# can drive a constrained host into memory/swap exhaustion. Rate-limit /
|
||||
# billing reasons keep their own 60s cooldown (set above); this is the
|
||||
# narrower non-rate-limit case. See issue #24996.
|
||||
_FALLBACK_EXHAUSTED_COOLDOWN_S = 5.0
|
||||
|
||||
|
||||
def _ra():
|
||||
@@ -128,23 +115,6 @@ def _is_openai_codex_backend(agent) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
|
||||
"""Return a normalized OpenRouter provider.sort value or None."""
|
||||
if not isinstance(raw_sort, str):
|
||||
return None
|
||||
sort_value = raw_sort.strip().lower()
|
||||
if not sort_value:
|
||||
return None
|
||||
if sort_value in _OPENROUTER_PROVIDER_SORT_VALUES:
|
||||
return sort_value
|
||||
logger.warning(
|
||||
"Ignoring invalid OpenRouter provider.sort value %r (allowed: %s)",
|
||||
raw_sort,
|
||||
", ".join(sorted(_OPENROUTER_PROVIDER_SORT_VALUES)),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
@@ -259,11 +229,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
invalidate_runtime_client(region)
|
||||
raise
|
||||
result["response"] = normalize_converse_response(raw_response)
|
||||
elif agent.provider == "moa":
|
||||
# MoA is a virtual chat-completions provider backed by the
|
||||
# in-process MoAClient facade. Do not rebuild a request-local
|
||||
# OpenAI client from the virtual runtime metadata.
|
||||
result["response"] = agent.client.chat.completions.create(**api_kwargs)
|
||||
else:
|
||||
request_client = _set_request_client(
|
||||
agent._create_request_openai_client(
|
||||
@@ -632,7 +597,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
||||
_ct = agent._get_transport()
|
||||
is_github_responses = (
|
||||
base_url_host_matches(agent.base_url, "models.github.ai")
|
||||
or base_url_host_matches(agent.base_url, "githubcopilot.com")
|
||||
or base_url_host_matches(agent.base_url, "api.githubcopilot.com")
|
||||
)
|
||||
is_codex_backend = (
|
||||
agent.provider == "openai-codex"
|
||||
@@ -702,7 +667,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
||||
_is_or = agent._is_openrouter_url()
|
||||
_is_gh = (
|
||||
base_url_host_matches(agent._base_url_lower, "models.github.ai")
|
||||
or base_url_host_matches(agent._base_url_lower, "githubcopilot.com")
|
||||
or base_url_host_matches(agent._base_url_lower, "api.githubcopilot.com")
|
||||
)
|
||||
_is_nous = "nousresearch" in agent._base_url_lower
|
||||
_is_nvidia = "integrate.api.nvidia.com" in agent._base_url_lower
|
||||
@@ -733,9 +698,8 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
||||
_prefs["ignore"] = agent.providers_ignored
|
||||
if agent.providers_order:
|
||||
_prefs["order"] = agent.providers_order
|
||||
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
|
||||
if _provider_sort:
|
||||
_prefs["sort"] = _provider_sort
|
||||
if agent.provider_sort:
|
||||
_prefs["sort"] = agent.provider_sort
|
||||
if agent.provider_require_parameters:
|
||||
_prefs["require_parameters"] = True
|
||||
if agent.provider_data_collection:
|
||||
@@ -988,18 +952,6 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
|
||||
if preserved:
|
||||
msg["reasoning_details"] = preserved
|
||||
|
||||
# Anthropic interleaved-thinking replay: when a turn interleaves signed
|
||||
# thinking blocks with tool_use, the parallel reasoning_details +
|
||||
# tool_calls fields lose the cross-type ordering, and reconstruction
|
||||
# front-loads thinking — reordering signed blocks and triggering HTTP 400
|
||||
# ("thinking ... blocks in the latest assistant message cannot be
|
||||
# modified"). Carry the verbatim ordered block list so the adapter can
|
||||
# replay the latest assistant message unchanged. See
|
||||
# agent/transports/anthropic.py and agent/anthropic_adapter.py.
|
||||
ordered_blocks = getattr(assistant_message, "anthropic_content_blocks", None)
|
||||
if ordered_blocks:
|
||||
msg["anthropic_content_blocks"] = ordered_blocks
|
||||
|
||||
# Codex Responses API: preserve encrypted reasoning items for
|
||||
# multi-turn continuity. These get replayed as input on the next turn.
|
||||
codex_items = getattr(assistant_message, "codex_reasoning_items", None)
|
||||
@@ -1051,23 +1003,18 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
|
||||
"arguments": tool_call.function.arguments
|
||||
},
|
||||
}
|
||||
# Tool-call arguments are intentionally NOT redacted here. This
|
||||
# dict enters the in-memory conversation history that is replayed
|
||||
# to the model on every subsequent turn AND persisted to state.db,
|
||||
# which is itself replayed verbatim on session resume
|
||||
# (get_messages_as_conversation). Masking a credential to `***`
|
||||
# here poisons that replay: the model reads back its own
|
||||
# `PGPASSWORD='***' psql ...` call and copies the placeholder into
|
||||
# the next tool call, breaking every credential-dependent command
|
||||
# on the second turn (#43083). The masking also provided no real
|
||||
# protection — the same secret still leaks verbatim through tool
|
||||
# OUTPUT (file contents, command output, diffs, the compaction
|
||||
# block), none of which this pass ever touched. Keeping secrets
|
||||
# out of the replayable store is a separate tokenization/vault
|
||||
# concern, not something arg-redaction can deliver without
|
||||
# breaking replay. Storage-time redaction remains governed by the
|
||||
# `security.redact_secrets` toggle. (#19798 introduced this;
|
||||
# #43083 removed it.)
|
||||
# Defence-in-depth: redact credentials from tool call arguments
|
||||
# before they enter conversation history. Tool execution uses the
|
||||
# raw API response object, not this dict, so redacting the
|
||||
# persisted shape is safe and only affects storage. Catches the
|
||||
# case where a model accidentally inlines a secret into a tool
|
||||
# call (e.g. `terminal(command="curl -H 'Authorization: Bearer
|
||||
# sk-...'")`). (#19798)
|
||||
if isinstance(tc_dict["function"]["arguments"], str):
|
||||
from agent.redact import redact_sensitive_text
|
||||
tc_dict["function"]["arguments"] = redact_sensitive_text(
|
||||
tc_dict["function"]["arguments"]
|
||||
)
|
||||
# Preserve extra_content (e.g. Gemini thought_signature) so it
|
||||
# is sent back on subsequent API calls. Without this, Gemini 3
|
||||
# thinking models reject the request with a 400 error.
|
||||
@@ -1083,35 +1030,6 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
|
||||
|
||||
|
||||
|
||||
def rewrite_prompt_model_identity(agent, model: str, provider: str) -> None:
|
||||
"""Point the cached system prompt's ``Model:``/``Provider:`` lines at
|
||||
the active runtime after a provider switch.
|
||||
|
||||
The system prompt is session-stable and replayed verbatim for prefix-cache
|
||||
warmth, but after a failover the new backend's cache is cold anyway —
|
||||
while a stale identity line makes the agent misreport which model it is
|
||||
when asked. Rewrite the lines in place WITHOUT persisting to the session
|
||||
DB: the stored row keeps the primary's labels, so when the primary is
|
||||
restored the prompt is byte-identical to the stored copy again and its
|
||||
prefix cache still matches.
|
||||
|
||||
Only the LAST occurrence of each line is touched — the identity lines
|
||||
live in the volatile tail of the prompt, and earlier matches could be
|
||||
user content (memory snapshots, context files).
|
||||
"""
|
||||
sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not isinstance(sp, str) or not sp:
|
||||
return
|
||||
for label, value in (("Model", model), ("Provider", provider)):
|
||||
if not value:
|
||||
continue
|
||||
matches = list(re.finditer(rf"(?m)^{label}: .*$", sp))
|
||||
if matches:
|
||||
last = matches[-1]
|
||||
sp = f"{sp[:last.start()]}{label}: {value}{sp[last.end():]}"
|
||||
agent._cached_system_prompt = sp
|
||||
|
||||
|
||||
def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool:
|
||||
"""Switch to the next fallback model/provider in the chain.
|
||||
|
||||
@@ -1124,7 +1042,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
auth resolution and client construction — no duplicated provider→key
|
||||
mappings.
|
||||
"""
|
||||
if reason in {FailoverReason.rate_limit, FailoverReason.billing, FailoverReason.upstream_rate_limit}:
|
||||
if reason in {FailoverReason.rate_limit, FailoverReason.billing}:
|
||||
# Only start cooldown when leaving the primary provider. If we're
|
||||
# already on a fallback and chain-switching, the primary wasn't the
|
||||
# source of the 429 so the cooldown should not be reset/extended.
|
||||
@@ -1134,22 +1052,8 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
if (not fallback_already_active) or (primary_provider and current_provider == primary_provider):
|
||||
agent._rate_limited_until = time.monotonic() + 60
|
||||
if agent._fallback_index >= len(agent._fallback_chain):
|
||||
# Chain exhausted. If we actually walked a non-empty chain and the
|
||||
# failure was NOT a rate-limit/billing event (those already armed
|
||||
# their own 60s cooldown above), arm a short cooldown so the next
|
||||
# turn's restore_primary_runtime stays gated instead of resetting
|
||||
# _fallback_index=0 and re-marshaling the whole context across every
|
||||
# provider again. Guards the cross-turn replay storm in #24996.
|
||||
if (
|
||||
len(agent._fallback_chain) > 0
|
||||
and reason not in {FailoverReason.rate_limit, FailoverReason.billing, FailoverReason.upstream_rate_limit}
|
||||
):
|
||||
_existing_cooldown = getattr(agent, "_rate_limited_until", 0) or 0
|
||||
agent._rate_limited_until = max(
|
||||
_existing_cooldown,
|
||||
time.monotonic() + _FALLBACK_EXHAUSTED_COOLDOWN_S,
|
||||
)
|
||||
return False
|
||||
|
||||
fb = agent._fallback_chain[agent._fallback_index]
|
||||
agent._fallback_index += 1
|
||||
fb_provider = (fb.get("provider") or "").strip().lower()
|
||||
@@ -1265,16 +1169,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
agent._transport_cache.clear()
|
||||
agent._fallback_activated = True
|
||||
|
||||
# Rebind the credential pool to the fallback provider when the provider
|
||||
# changes. Keeping the primary pool attached would make downstream
|
||||
# recovery (rate_limit / billing / auth) mutate the wrong credential
|
||||
# set and can overwrite the fallback's base_url back to the primary
|
||||
# endpoint. See #33163.
|
||||
#
|
||||
# Clear the credential pool when the fallback provider doesn't match
|
||||
# the pool's provider. The pool was seeded for the primary provider;
|
||||
# leaving it attached means downstream recovery (rate_limit / billing /
|
||||
# auth) calls ``_swap_credential`` with a primary entry which overwrites
|
||||
# the agent's ``base_url`` back to the primary's endpoint — every
|
||||
# fallback request then 404s against the wrong host. See #33163.
|
||||
# When the fallback shares the pool's provider (e.g. both openrouter
|
||||
# entries with different routing) the pool is preserved. When the
|
||||
# providers differ, load the fallback provider's own pool if one exists
|
||||
# so provider-specific rotation continues to work after the switch.
|
||||
# entries with different routing) the pool is preserved.
|
||||
_existing_pool = getattr(agent, "_credential_pool", None)
|
||||
if _existing_pool is not None:
|
||||
_pool_provider = (getattr(_existing_pool, "provider", "") or "").strip().lower()
|
||||
@@ -1285,22 +1187,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
fb_provider, fb_model, _pool_provider,
|
||||
)
|
||||
agent._credential_pool = None
|
||||
if getattr(agent, "_credential_pool", None) is None:
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
fallback_pool = load_pool(fb_provider)
|
||||
if fallback_pool and fallback_pool.has_credentials():
|
||||
agent._credential_pool = fallback_pool
|
||||
logger.info(
|
||||
"Fallback to %s/%s: attached fallback credential pool",
|
||||
fb_provider, fb_model,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Fallback to %s/%s: could not attach credential pool: %s",
|
||||
fb_provider, fb_model, exc,
|
||||
)
|
||||
|
||||
# Honor per-provider / per-model request_timeout_seconds for the
|
||||
# fallback target (same knob the primary client uses). None = use
|
||||
@@ -1389,10 +1275,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
api_mode=agent.api_mode,
|
||||
)
|
||||
|
||||
# Keep the prompt's self-identity in sync with the model actually
|
||||
# answering, so "what model are you?" doesn't report the primary.
|
||||
rewrite_prompt_model_identity(agent, fb_model, fb_provider)
|
||||
|
||||
agent._buffer_status(
|
||||
f"🔄 Primary model failed — switching to fallback: "
|
||||
f"{fb_model} via {fb_provider}"
|
||||
@@ -1531,9 +1413,8 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
||||
provider_preferences["ignore"] = agent.providers_ignored
|
||||
if agent.providers_order:
|
||||
provider_preferences["order"] = agent.providers_order
|
||||
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
|
||||
if _provider_sort:
|
||||
provider_preferences["sort"] = _provider_sort
|
||||
if agent.provider_sort:
|
||||
provider_preferences["sort"] = agent.provider_sort
|
||||
if provider_preferences and (
|
||||
(agent.provider or "").strip().lower() == "openrouter"
|
||||
or agent._is_openrouter_url()
|
||||
@@ -1722,8 +1603,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_get_bedrock_runtime_client,
|
||||
invalidate_runtime_client,
|
||||
is_stale_connection_error,
|
||||
is_streaming_access_denied_error,
|
||||
normalize_converse_response,
|
||||
stream_converse_with_callbacks,
|
||||
)
|
||||
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
|
||||
@@ -1732,29 +1611,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
try:
|
||||
raw_response = client.converse_stream(**api_kwargs)
|
||||
except Exception as _bedrock_exc:
|
||||
# IAM policies scoped to bedrock:InvokeModel only (no
|
||||
# InvokeModelWithResponseStream) reject converse_stream()
|
||||
# with AccessDeniedException. That denial is permanent for
|
||||
# the session — fall back to the non-streaming converse()
|
||||
# inline (it maps to bedrock:InvokeModel) and disable
|
||||
# streaming for subsequent calls so we don't re-fail every
|
||||
# turn.
|
||||
if is_streaming_access_denied_error(_bedrock_exc):
|
||||
agent._disable_streaming = True
|
||||
agent._safe_print(
|
||||
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
|
||||
"falling back to non-streaming InvokeModel.\n"
|
||||
" Grant that action to restore streaming output.\n"
|
||||
)
|
||||
logger.info(
|
||||
"bedrock: converse_stream denied by IAM (%s) — "
|
||||
"using non-streaming converse() for this session.",
|
||||
type(_bedrock_exc).__name__,
|
||||
)
|
||||
result["response"] = normalize_converse_response(
|
||||
client.converse(**api_kwargs)
|
||||
)
|
||||
return
|
||||
# Evict the cached client on stale-connection failures
|
||||
# so the outer retry loop builds a fresh client/pool.
|
||||
if is_stale_connection_error(_bedrock_exc):
|
||||
@@ -1842,14 +1698,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
# poll loop uses this to detect stale connections that keep receiving
|
||||
# SSE keep-alive pings but no actual data.
|
||||
last_chunk_time = {"t": time.time()}
|
||||
# Stale-stream patience, shared between the httpx socket read timeout
|
||||
# (built in ``_call_chat_completions`` below) and the stale-stream detector
|
||||
# (computed further down, before the worker thread starts). Initialized
|
||||
# here so the read-timeout builder can floor itself at the stale value and
|
||||
# never fire before the detector. ``None`` until the detector value is
|
||||
# resolved, so the builder degrades to its plain default if it ever runs
|
||||
# first.
|
||||
_stream_stale_timeout = None
|
||||
|
||||
def _fire_first_delta():
|
||||
if not first_delta_fired["done"] and on_first_delta:
|
||||
@@ -1868,14 +1716,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_base_timeout = (
|
||||
_provider_timeout_cfg
|
||||
if _provider_timeout_cfg is not None
|
||||
else env_float("HERMES_API_TIMEOUT", 1800.0)
|
||||
else float(os.getenv("HERMES_API_TIMEOUT", 1800.0))
|
||||
)
|
||||
# Read timeout: config wins here too. Otherwise use
|
||||
# HERMES_STREAM_READ_TIMEOUT (default 120s) for cloud providers.
|
||||
if _provider_timeout_cfg is not None:
|
||||
_stream_read_timeout = _provider_timeout_cfg
|
||||
else:
|
||||
_stream_read_timeout = env_float("HERMES_STREAM_READ_TIMEOUT", 120.0)
|
||||
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 120.0))
|
||||
# Local providers (Ollama, llama.cpp, vLLM) can take minutes for
|
||||
# prefill on large contexts before producing the first token.
|
||||
# Auto-increase the httpx read timeout unless the user explicitly
|
||||
@@ -1886,32 +1734,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
"Local provider detected (%s) — stream read timeout raised to %.0fs",
|
||||
agent.base_url, _stream_read_timeout,
|
||||
)
|
||||
elif (
|
||||
_stream_read_timeout == 120.0
|
||||
and _stream_stale_timeout is not None
|
||||
and _stream_stale_timeout != float("inf")
|
||||
and _stream_stale_timeout > _stream_read_timeout
|
||||
):
|
||||
# Cloud reasoning models (e.g. Opus) routinely pause mid-stream
|
||||
# for minutes during extended thinking. The stale-stream
|
||||
# detector is deliberately scaled up to tolerate this (180–300s,
|
||||
# see the stale-timeout block below), but the raw httpx socket
|
||||
# read timeout defaulted to a flat 120s and fired *first* —
|
||||
# tearing down a healthy reasoning stream before the stale
|
||||
# detector (which owns retry + diagnostics) could act. Keep the
|
||||
# socket read timeout in step with the detector so it no longer
|
||||
# preempts it.
|
||||
_stream_read_timeout = _stream_stale_timeout
|
||||
logger.debug(
|
||||
"Cloud reasoning stream — read timeout raised to %.0fs to "
|
||||
"match stale-stream detector", _stream_read_timeout,
|
||||
)
|
||||
# Cap connect/pool at 60s even when provider timeout is higher.
|
||||
# connect/pool cover TCP handshake, not model inference.
|
||||
_conn_cap = min(_base_timeout, 60.0) if _provider_timeout_cfg is not None else 30.0
|
||||
stream_kwargs = {
|
||||
**api_kwargs,
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
"timeout": _httpx.Timeout(
|
||||
connect=_conn_cap,
|
||||
read=_stream_read_timeout,
|
||||
@@ -1919,14 +1748,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
pool=_conn_cap,
|
||||
),
|
||||
}
|
||||
# OpenAI's `stream_options={"include_usage": True}` drives usage
|
||||
# accounting on OpenAI-compatible endpoints (incl. the Gemini OpenAI
|
||||
# compat shim and aggregators like OpenRouter). Google's *native*
|
||||
# Gemini REST endpoint rejects the keyword outright
|
||||
# (`Completions.create() got an unexpected keyword argument
|
||||
# 'stream_options'`), so omit it only for that endpoint.
|
||||
if not is_native_gemini_base_url(agent.base_url):
|
||||
stream_kwargs["stream_options"] = {"include_usage": True}
|
||||
request_client = _set_request_client(
|
||||
agent._create_request_openai_client(
|
||||
reason="chat_completion_stream_request",
|
||||
@@ -2086,7 +1907,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
entry["function"]["arguments"] += tc_delta.function.arguments
|
||||
extra = getattr(tc_delta, "extra_content", None)
|
||||
if extra is None and hasattr(tc_delta, "model_extra"):
|
||||
extra = (tc_delta.model_extra if isinstance(tc_delta.model_extra, dict) else {}).get("extra_content")
|
||||
extra = (tc_delta.model_extra or {}).get("extra_content")
|
||||
if extra is not None:
|
||||
if hasattr(extra, "model_dump"):
|
||||
extra = extra.model_dump()
|
||||
@@ -2327,15 +2148,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_fire_first_delta()
|
||||
agent._fire_reasoning_delta(thinking_text)
|
||||
|
||||
# Return the native Anthropic Message for downstream processing.
|
||||
# If the stream was interrupted (the event loop broke out above on
|
||||
# agent._interrupt_requested), do NOT call get_final_message() — on
|
||||
# a partially-consumed stream the SDK may hang draining remaining
|
||||
# events or return a Message with incomplete tool_use blocks (partial
|
||||
# JSON in `input`). The outer poll loop raises InterruptedError, so
|
||||
# this return value is discarded anyway.
|
||||
if agent._interrupt_requested:
|
||||
return None
|
||||
# Return the native Anthropic Message for downstream processing
|
||||
return stream.get_final_message()
|
||||
|
||||
def _call():
|
||||
@@ -2480,19 +2293,12 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
diag=request_client_holder.get("diag"),
|
||||
)
|
||||
_close_request_client_once("stream_mid_tool_retry_cleanup")
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
try:
|
||||
agent._anthropic_client.close()
|
||||
agent._rebuild_anthropic_client()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
agent._replace_primary_openai_client(
|
||||
reason="stream_mid_tool_retry_pool_cleanup"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
agent._replace_primary_openai_client(
|
||||
reason="stream_mid_tool_retry_pool_cleanup"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
# SSE error events from proxies (e.g. OpenRouter sends
|
||||
@@ -2540,19 +2346,12 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_close_request_client_once("stream_retry_cleanup")
|
||||
# Also rebuild the primary client to purge
|
||||
# any dead connections from the pool.
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
try:
|
||||
agent._anthropic_client.close()
|
||||
agent._rebuild_anthropic_client()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
agent._replace_primary_openai_client(
|
||||
reason="stream_retry_pool_cleanup"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
agent._replace_primary_openai_client(
|
||||
reason="stream_retry_pool_cleanup"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
# Retries exhausted. Log the final failure with
|
||||
# full diagnostic detail (chain, headers,
|
||||
@@ -2585,34 +2384,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
"stream" in _err_lower
|
||||
and "not supported" in _err_lower
|
||||
)
|
||||
# AWS Bedrock (AnthropicBedrock SDK path): IAM policies
|
||||
# with bedrock:InvokeModel but not
|
||||
# InvokeModelWithResponseStream reject messages.stream()
|
||||
# with a permission error naming the streaming action.
|
||||
# Permanent for the session — flip to non-streaming
|
||||
# (messages.create() maps to bedrock:InvokeModel).
|
||||
_is_bedrock_stream_denied = False
|
||||
if (
|
||||
not _is_stream_unsupported
|
||||
and "invokemodelwithresponsestream" in _err_lower
|
||||
):
|
||||
# Cheap message pre-check before importing the
|
||||
# adapter — bedrock_adapter triggers a lazy boto3
|
||||
# install at import time, which must not run for
|
||||
# unrelated providers' stream errors.
|
||||
from agent.bedrock_adapter import (
|
||||
is_streaming_access_denied_error,
|
||||
)
|
||||
_is_bedrock_stream_denied = (
|
||||
is_streaming_access_denied_error(e)
|
||||
)
|
||||
if _is_stream_unsupported or _is_bedrock_stream_denied:
|
||||
if _is_stream_unsupported:
|
||||
agent._disable_streaming = True
|
||||
agent._safe_print(
|
||||
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream. "
|
||||
"Switching to non-streaming.\n"
|
||||
" Grant that action to restore streaming output.\n"
|
||||
if _is_bedrock_stream_denied else
|
||||
"\n⚠ Streaming is not supported for this "
|
||||
"model/provider. Switching to non-streaming.\n"
|
||||
" To avoid this delay, set display.streaming: false "
|
||||
@@ -2644,7 +2418,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
if _cfg_stale is not None:
|
||||
_stream_stale_timeout_base = _cfg_stale
|
||||
else:
|
||||
_stream_stale_timeout_base = env_float("HERMES_STREAM_STALE_TIMEOUT", 180.0)
|
||||
_stream_stale_timeout_base = float(os.getenv("HERMES_STREAM_STALE_TIMEOUT", 180.0))
|
||||
# Local providers (Ollama, oMLX, llama-cpp) can take 300+ seconds
|
||||
# for prefill on large contexts. Disable the stale detector unless
|
||||
# the user explicitly set HERMES_STREAM_STALE_TIMEOUT.
|
||||
@@ -2664,17 +2438,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
_stream_stale_timeout = max(_stream_stale_timeout_base, 240.0)
|
||||
else:
|
||||
_stream_stale_timeout = _stream_stale_timeout_base
|
||||
# Reasoning-model floor: known reasoning models (Nemotron 3 Ultra,
|
||||
# OpenAI o1/o3, Anthropic Opus 4.x thinking, DeepSeek R1, Qwen QwQ,
|
||||
# xAI Grok reasoning, etc.) routinely exceed the default 180s chat-
|
||||
# model threshold during their thinking phase. The cloud gateway
|
||||
# upstream kills the socket first, surfacing as BrokenPipeError.
|
||||
# Raises the floor only — never overrides explicit user config
|
||||
# (handled by get_provider_stale_timeout above).
|
||||
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
|
||||
_reasoning_floor = get_reasoning_stale_timeout_floor(api_kwargs.get("model"))
|
||||
if _reasoning_floor is not None:
|
||||
_stream_stale_timeout = max(_stream_stale_timeout, _reasoning_floor)
|
||||
|
||||
t = threading.Thread(target=_call, daemon=True)
|
||||
t.start()
|
||||
@@ -2723,17 +2486,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
pass
|
||||
# Rebuild the primary client too — its connection pool
|
||||
# may hold dead sockets from the same provider outage.
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
try:
|
||||
agent._anthropic_client.close()
|
||||
agent._rebuild_anthropic_client()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
|
||||
except Exception:
|
||||
pass
|
||||
# Reset the timer so we don't kill repeatedly while
|
||||
# the inner thread processes the closure.
|
||||
last_chunk_time["t"] = time.time()
|
||||
@@ -2809,30 +2565,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
role="assistant", content=_partial_text, tool_calls=None,
|
||||
reasoning_content=None,
|
||||
)
|
||||
# Detect provider output-layer content filtering (e.g. MiniMax
|
||||
# "output new_sensitive (1027)", Azure/OpenAI content_filter,
|
||||
# Anthropic safety refusal). The raw error is about to be
|
||||
# swallowed into a finish_reason=length stub, so classify it HERE
|
||||
# while we still have it and stamp the stub. Retrying such a
|
||||
# content-deterministic filter on the same primary just re-hits
|
||||
# the filter — the conversation loop reads this tag and activates
|
||||
# the fallback chain instead of burning continuation retries.
|
||||
# error_classifier is the single source of truth for "what counts
|
||||
# as a content filter" (#32421).
|
||||
_content_filter_terminated = False
|
||||
try:
|
||||
from agent.error_classifier import classify_api_error, FailoverReason
|
||||
_cls = classify_api_error(
|
||||
result["error"],
|
||||
provider=str(getattr(agent, "provider", "") or ""),
|
||||
model=str(getattr(agent, "model", "") or ""),
|
||||
)
|
||||
_content_filter_terminated = (
|
||||
_cls.reason == FailoverReason.content_policy_blocked
|
||||
)
|
||||
except Exception:
|
||||
_content_filter_terminated = False
|
||||
_stub = SimpleNamespace(
|
||||
return SimpleNamespace(
|
||||
id=PARTIAL_STREAM_STUB_ID,
|
||||
model=getattr(agent, "model", "unknown"),
|
||||
choices=[SimpleNamespace(
|
||||
@@ -2841,9 +2574,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
usage=None,
|
||||
_dropped_tool_names=_partial_names or None,
|
||||
)
|
||||
if _content_filter_terminated:
|
||||
_stub._content_filter_terminated = True
|
||||
return _stub
|
||||
raise result["error"]
|
||||
return result["response"]
|
||||
|
||||
|
||||
@@ -127,21 +127,14 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis
|
||||
return converted
|
||||
|
||||
|
||||
def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
|
||||
"""Flatten message content to a plain-text summary.
|
||||
def _summarize_user_message_for_log(content: Any) -> str:
|
||||
"""Return a short text summary of a user message for logging/trajectory.
|
||||
|
||||
Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}``
|
||||
parts from the API server. Several consumers want a plain string:
|
||||
|
||||
- Logging, spinner previews, and trajectory files (the default ``sep=" "``).
|
||||
- External memory providers, which feed the text to regexes
|
||||
(``sanitize_context``) and text APIs — a raw list crashes the sync with
|
||||
``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``).
|
||||
|
||||
Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker
|
||||
so the turn isn't recorded as if the attachment never existed. Returns an
|
||||
empty string for empty lists and ``str(content)`` for unexpected scalar
|
||||
types.
|
||||
parts from the API server. Logging, spinner previews, and trajectory
|
||||
files all want a plain string — this helper extracts the first chunk of
|
||||
text and notes any attached images. Returns an empty string for empty
|
||||
lists and ``str(content)`` for unexpected scalar types.
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
@@ -164,7 +157,7 @@ def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
|
||||
text_bits.append(text)
|
||||
elif ptype in {"image_url", "input_image"}:
|
||||
image_count += 1
|
||||
summary = sep.join(text_bits).strip()
|
||||
summary = " ".join(text_bits).strip()
|
||||
if image_count:
|
||||
note = f"[{image_count} image{'s' if image_count != 1 else ''}]"
|
||||
summary = f"{note} {summary}" if summary else note
|
||||
@@ -262,26 +255,6 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
|
||||
return converted or None
|
||||
|
||||
|
||||
# Provider-executed built-in tool *declaration* types accepted on the
|
||||
# Responses ``tools`` array. These are declared by ``type`` alone (no
|
||||
# client-side name/parameters schema) and run server-side — the provider
|
||||
# owns the implementation and reports progress via the matching ``*_call``
|
||||
# output items. Hermes injects xAI's native ``web_search`` for the xAI
|
||||
# transport (see agent/transports/codex.py); the rest are listed so the
|
||||
# preflight validator passes them through rather than rejecting them as
|
||||
# "unsupported type". Mirrors the ``*_call`` item-type set used in
|
||||
# _normalize_codex_response.
|
||||
_RESPONSES_BUILTIN_TOOL_TYPES = {
|
||||
"web_search",
|
||||
"web_search_preview",
|
||||
"file_search",
|
||||
"code_interpreter",
|
||||
"image_generation",
|
||||
"computer_use_preview",
|
||||
"local_shell",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message format conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -822,22 +795,7 @@ def _preflight_codex_api_kwargs(
|
||||
for idx, tool in enumerate(tools):
|
||||
if not isinstance(tool, dict):
|
||||
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
|
||||
|
||||
tool_type = tool.get("type")
|
||||
|
||||
# Provider-executed built-in tools (xAI native web_search, code
|
||||
# interpreter, etc.) are declared by ``type`` alone and carry no
|
||||
# ``name``/``parameters`` schema — the provider owns the
|
||||
# implementation. Pass them through verbatim instead of forcing
|
||||
# them through the function-tool validation below (which would
|
||||
# otherwise reject them with "unsupported type"). See
|
||||
# agent/transports/codex.py for where xAI's native web_search is
|
||||
# injected.
|
||||
if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
|
||||
normalized_tools.append(dict(tool))
|
||||
continue
|
||||
|
||||
if tool_type != "function":
|
||||
if tool.get("type") != "function":
|
||||
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
|
||||
|
||||
name = tool.get("name")
|
||||
@@ -1116,38 +1074,10 @@ def _normalize_codex_response(
|
||||
message_items_raw: List[Dict[str, Any]] = []
|
||||
tool_calls: List[Any] = []
|
||||
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
|
||||
saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
|
||||
saw_commentary_phase = False
|
||||
saw_final_answer_phase = False
|
||||
saw_reasoning_item = False
|
||||
|
||||
# Server-side built-in tool calls (xAI's native web_search, code
|
||||
# interpreter, etc.) are executed by the provider and reported as
|
||||
# discrete ``*_call`` output items. xAI's /v1/responses surface
|
||||
# (e.g. grok-composer-2.5-fast on SuperGrok OAuth) routinely leaves
|
||||
# these items at ``status="in_progress"`` even when the overall
|
||||
# ``response.status == "completed"`` — the search ran to completion
|
||||
# server-side, the per-item status simply isn't reconciled. These
|
||||
# are NOT a signal that the model's turn is unfinished, so they must
|
||||
# not flip ``has_incomplete_items``. Only the response-level status
|
||||
# and genuine model output items (message/reasoning/function_call)
|
||||
# govern the incomplete verdict. Without this guard, any turn where
|
||||
# grok-composer invokes server-side search is misclassified as
|
||||
# ``finish_reason="incomplete"`` and burns 3 fruitless continuation
|
||||
# retries before failing with "Codex response remained incomplete
|
||||
# after 3 continuation attempts". client-side function/custom tool
|
||||
# calls keep their own in_progress handling below (they are skipped,
|
||||
# not awaited).
|
||||
_SERVER_SIDE_TOOL_CALL_TYPES = {
|
||||
"web_search_call",
|
||||
"file_search_call",
|
||||
"code_interpreter_call",
|
||||
"image_generation_call",
|
||||
"computer_call",
|
||||
"local_shell_call",
|
||||
"mcp_call",
|
||||
}
|
||||
|
||||
for item in output:
|
||||
item_type = getattr(item, "type", None)
|
||||
item_status = getattr(item, "status", None)
|
||||
@@ -1156,12 +1086,8 @@ def _normalize_codex_response(
|
||||
else:
|
||||
item_status = None
|
||||
|
||||
if (
|
||||
item_status in {"queued", "in_progress", "incomplete"}
|
||||
and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES
|
||||
):
|
||||
if item_status in {"queued", "in_progress", "incomplete"}:
|
||||
has_incomplete_items = True
|
||||
saw_streaming_or_item_incomplete = True
|
||||
|
||||
if item_type == "message":
|
||||
item_phase = getattr(item, "phase", None)
|
||||
@@ -1319,9 +1245,7 @@ def _normalize_codex_response(
|
||||
finish_reason = "tool_calls"
|
||||
elif leaked_tool_call_text:
|
||||
finish_reason = "incomplete"
|
||||
elif saw_streaming_or_item_incomplete:
|
||||
finish_reason = "incomplete"
|
||||
elif (has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase:
|
||||
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
|
||||
finish_reason = "incomplete"
|
||||
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
|
||||
# Response contains only reasoning (encrypted thinking state and/or
|
||||
|
||||
+2
-229
@@ -25,209 +25,6 @@ from typing import Any, Dict, List
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _codex_note_to_tool_progress(note: dict) -> tuple[str, str, dict] | None:
|
||||
"""Map a Codex app-server ``item/started`` notification to a Hermes
|
||||
tool-progress event ``(tool_name, preview, args)``.
|
||||
|
||||
The Codex app-server runtime processes ``item/started`` notifications for
|
||||
command execution, file changes, and MCP/dynamic tool calls, but never
|
||||
surfaced them as Hermes tool-progress events — so gateways (Telegram, etc.)
|
||||
showed no verbose "running X" breadcrumbs on this route while every other
|
||||
provider did (#38835). Returns None for items that aren't tool-shaped.
|
||||
"""
|
||||
if not isinstance(note, dict) or note.get("method") != "item/started":
|
||||
return None
|
||||
params = note.get("params") or {}
|
||||
item = params.get("item") or {}
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
|
||||
item_type = item.get("type") or ""
|
||||
if item_type == "commandExecution":
|
||||
command = item.get("command") or ""
|
||||
return "exec_command", command, {"command": command, "cwd": item.get("cwd") or ""}
|
||||
|
||||
if item_type == "fileChange":
|
||||
changes = item.get("changes") or []
|
||||
preview = "file changes"
|
||||
if isinstance(changes, list) and changes:
|
||||
paths = [
|
||||
str(change.get("path"))
|
||||
for change in changes
|
||||
if isinstance(change, dict) and change.get("path")
|
||||
]
|
||||
if paths:
|
||||
preview = ", ".join(paths[:3])
|
||||
if len(paths) > 3:
|
||||
preview += f", +{len(paths) - 3} more"
|
||||
return "apply_patch", preview, {"changes": changes}
|
||||
|
||||
if item_type == "mcpToolCall":
|
||||
server = item.get("server") or "mcp"
|
||||
tool = item.get("tool") or "unknown"
|
||||
args = item.get("arguments") or {}
|
||||
if not isinstance(args, dict):
|
||||
args = {"arguments": args}
|
||||
return f"mcp.{server}.{tool}", tool, args
|
||||
|
||||
if item_type == "dynamicToolCall":
|
||||
tool = item.get("tool") or "unknown"
|
||||
args = item.get("arguments") or {}
|
||||
if not isinstance(args, dict):
|
||||
args = {"arguments": args}
|
||||
return tool, tool, args
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_usage_int(value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
if isinstance(value, int):
|
||||
return max(value, 0)
|
||||
if isinstance(value, float):
|
||||
return max(int(value), 0)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return max(int(value), 0)
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
|
||||
"""Translate Codex app-server token usage into Hermes accounting.
|
||||
|
||||
Codex app-server reports usage via thread/tokenUsage/updated as:
|
||||
inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens,
|
||||
totalTokens.
|
||||
|
||||
Hermes' canonical prompt bucket includes uncached input + cached input.
|
||||
The Codex app-server protocol does not currently expose cache-write tokens,
|
||||
so that bucket remains zero on this runtime.
|
||||
|
||||
Even when Codex omits usage for a turn, Hermes should still count that turn
|
||||
as one API call for session/status accounting.
|
||||
"""
|
||||
agent.session_api_calls += 1
|
||||
|
||||
usage = getattr(turn, "token_usage_last", None)
|
||||
if not isinstance(usage, dict) or not usage:
|
||||
if agent._session_db and agent.session_id:
|
||||
try:
|
||||
if not agent._session_db_created:
|
||||
agent._ensure_db_session()
|
||||
agent._session_db.update_token_counts(
|
||||
agent.session_id,
|
||||
model=agent.model,
|
||||
api_call_count=1,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Codex app-server api-call persistence failed (session=%s): %s",
|
||||
agent.session_id, exc,
|
||||
)
|
||||
return {}
|
||||
|
||||
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost
|
||||
|
||||
input_tokens = _coerce_usage_int(usage.get("inputTokens"))
|
||||
cache_read_tokens = _coerce_usage_int(usage.get("cachedInputTokens"))
|
||||
output_tokens = _coerce_usage_int(usage.get("outputTokens"))
|
||||
reasoning_tokens = _coerce_usage_int(usage.get("reasoningOutputTokens"))
|
||||
reported_total = _coerce_usage_int(usage.get("totalTokens"))
|
||||
|
||||
canonical_usage = CanonicalUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_read_tokens=cache_read_tokens,
|
||||
cache_write_tokens=0,
|
||||
reasoning_tokens=reasoning_tokens,
|
||||
raw_usage=usage,
|
||||
)
|
||||
prompt_tokens = canonical_usage.prompt_tokens
|
||||
completion_tokens = canonical_usage.output_tokens
|
||||
total_tokens = reported_total or canonical_usage.total_tokens
|
||||
usage_dict = {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"input_tokens": canonical_usage.input_tokens,
|
||||
"output_tokens": canonical_usage.output_tokens,
|
||||
"cache_read_tokens": canonical_usage.cache_read_tokens,
|
||||
"cache_write_tokens": canonical_usage.cache_write_tokens,
|
||||
"reasoning_tokens": canonical_usage.reasoning_tokens,
|
||||
}
|
||||
|
||||
compressor = getattr(agent, "context_compressor", None)
|
||||
if compressor is not None:
|
||||
try:
|
||||
compressor.update_from_response(usage_dict)
|
||||
context_window = getattr(turn, "model_context_window", None)
|
||||
if isinstance(context_window, int) and context_window > 0:
|
||||
compressor.context_length = context_window
|
||||
except Exception:
|
||||
logger.debug("codex app-server usage update failed", exc_info=True)
|
||||
|
||||
agent.session_prompt_tokens += prompt_tokens
|
||||
agent.session_completion_tokens += completion_tokens
|
||||
agent.session_total_tokens += total_tokens
|
||||
agent.session_input_tokens += canonical_usage.input_tokens
|
||||
agent.session_output_tokens += canonical_usage.output_tokens
|
||||
agent.session_cache_read_tokens += canonical_usage.cache_read_tokens
|
||||
agent.session_cache_write_tokens += canonical_usage.cache_write_tokens
|
||||
agent.session_reasoning_tokens += canonical_usage.reasoning_tokens
|
||||
|
||||
cost_result = estimate_usage_cost(
|
||||
agent.model,
|
||||
canonical_usage,
|
||||
provider=agent.provider,
|
||||
base_url=agent.base_url,
|
||||
api_key=getattr(agent, "api_key", ""),
|
||||
)
|
||||
if cost_result.amount_usd is not None:
|
||||
agent.session_estimated_cost_usd += float(cost_result.amount_usd)
|
||||
agent.session_cost_status = cost_result.status
|
||||
agent.session_cost_source = cost_result.source
|
||||
|
||||
if agent._session_db and agent.session_id:
|
||||
try:
|
||||
if not agent._session_db_created:
|
||||
agent._ensure_db_session()
|
||||
agent._session_db.update_token_counts(
|
||||
agent.session_id,
|
||||
input_tokens=canonical_usage.input_tokens,
|
||||
output_tokens=canonical_usage.output_tokens,
|
||||
cache_read_tokens=canonical_usage.cache_read_tokens,
|
||||
cache_write_tokens=canonical_usage.cache_write_tokens,
|
||||
reasoning_tokens=canonical_usage.reasoning_tokens,
|
||||
estimated_cost_usd=float(cost_result.amount_usd)
|
||||
if cost_result.amount_usd is not None else None,
|
||||
cost_status=cost_result.status,
|
||||
cost_source=cost_result.source,
|
||||
billing_provider=agent.provider,
|
||||
billing_base_url=agent.base_url,
|
||||
billing_mode="subscription_included"
|
||||
if cost_result.status == "included" else None,
|
||||
model=agent.model,
|
||||
api_call_count=1,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Codex app-server token persistence failed (session=%s, tokens=%d): %s",
|
||||
agent.session_id, total_tokens, exc,
|
||||
)
|
||||
|
||||
return {
|
||||
**usage_dict,
|
||||
"last_prompt_tokens": prompt_tokens,
|
||||
"estimated_cost_usd": float(cost_result.amount_usd)
|
||||
if cost_result.amount_usd is not None else None,
|
||||
"cost_status": cost_result.status,
|
||||
"cost_source": cost_result.source,
|
||||
}
|
||||
|
||||
|
||||
def run_codex_app_server_turn(
|
||||
agent,
|
||||
*,
|
||||
@@ -250,9 +47,7 @@ def run_codex_app_server_turn(
|
||||
# Spawned on first turn, reused across turns, closed at AIAgent
|
||||
# shutdown (see _cleanup hook).
|
||||
if not hasattr(agent, "_codex_session") or agent._codex_session is None:
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
|
||||
cwd = getattr(agent, "session_cwd", None) or str(resolve_agent_cwd())
|
||||
cwd = getattr(agent, "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.
|
||||
@@ -261,27 +56,9 @@ def run_codex_app_server_turn(
|
||||
approval_callback = _get_approval_callback()
|
||||
except Exception:
|
||||
approval_callback = None
|
||||
|
||||
def _on_codex_event(note: dict) -> None:
|
||||
# Bridge Codex app-server item/started notifications to Hermes
|
||||
# tool-progress so gateways show verbose "running X" breadcrumbs
|
||||
# on this route too (#38835).
|
||||
progress_callback = getattr(agent, "tool_progress_callback", None)
|
||||
if progress_callback is None:
|
||||
return
|
||||
mapped = _codex_note_to_tool_progress(note)
|
||||
if mapped is None:
|
||||
return
|
||||
tool_name, preview, args = mapped
|
||||
try:
|
||||
progress_callback("tool.started", tool_name, preview, args)
|
||||
except Exception:
|
||||
logger.debug("codex tool-progress callback raised", exc_info=True)
|
||||
|
||||
agent._codex_session = CodexAppServerSession(
|
||||
cwd=cwd,
|
||||
approval_callback=approval_callback,
|
||||
on_event=_on_codex_event,
|
||||
)
|
||||
|
||||
# NOTE: the user message is ALREADY appended to messages by the
|
||||
@@ -343,8 +120,6 @@ def run_codex_app_server_turn(
|
||||
agent._iters_since_skill = (
|
||||
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
|
||||
)
|
||||
usage_result = _record_codex_app_server_usage(agent, turn)
|
||||
api_calls = 1
|
||||
|
||||
# Now check the skill nudge AFTER iters were incremented — same
|
||||
# pattern the chat_completions path uses (line ~15432).
|
||||
@@ -365,7 +140,6 @@ def run_codex_app_server_turn(
|
||||
original_user_message=original_user_message,
|
||||
final_response=turn.final_text,
|
||||
interrupted=False,
|
||||
messages=messages,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("external memory sync raised", exc_info=True)
|
||||
@@ -390,13 +164,12 @@ def run_codex_app_server_turn(
|
||||
return {
|
||||
"final_response": turn.final_text,
|
||||
"messages": messages,
|
||||
"api_calls": api_calls,
|
||||
"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,
|
||||
**usage_result,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,883 +0,0 @@
|
||||
"""Coding-context awareness — base Hermes, every interactive surface.
|
||||
|
||||
When the user runs Hermes inside a code workspace (CLI, TUI, desktop app, or an
|
||||
editor over ACP), Hermes shifts into a **coding posture**. This module is the
|
||||
single place that decides whether we're in that posture and what it implies,
|
||||
so the rest of the codebase never re-derives "are we coding?" on its own.
|
||||
|
||||
Architecture — one seam, many consumers
|
||||
----------------------------------------
|
||||
The posture is modelled as a frozen :class:`RuntimeMode` selected from a small
|
||||
:class:`ContextProfile` registry (today: ``coding`` and ``general``). A profile
|
||||
is *data* — it declares the toolset to collapse to, the operating brief to
|
||||
inject, and hints for other domains (model routing, memory, subagents). Every
|
||||
domain reads the same resolved object instead of probing git/config itself:
|
||||
|
||||
* **System prompt** — ``RuntimeMode.system_blocks()`` → the operating brief +
|
||||
a live git/workspace snapshot (``agent/system_prompt.py``).
|
||||
* **Toolset** — ``RuntimeMode.toolset_selection()`` → the ``coding`` toolset
|
||||
plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Only
|
||||
under the opt-in ``focus`` mode: the default posture is prompt-only and
|
||||
never touches the user's configured toolsets (toolsets like messaging /
|
||||
smart-home / music are off-by-default anyway, and someone who explicitly
|
||||
enabled image-gen or Spotify shouldn't lose it for being in a git repo).
|
||||
* **Delegation** — subagents inherit the parent's toolset and run through the
|
||||
same prompt builder, so the coding posture propagates to children for free.
|
||||
* **Model / memory / compression** — declared on the profile
|
||||
(``model_hint``, ``memory_policy``) as the extension seam; consumers read
|
||||
``mode.profile`` rather than re-deciding.
|
||||
|
||||
Cache safety
|
||||
------------
|
||||
The mode is resolved **once** and is immutable. The workspace snapshot is built
|
||||
once at prompt-build time and baked into the *stable* system-prompt tier — never
|
||||
re-probed per turn (that would shatter the prompt cache). Branch and dirty state
|
||||
drift mid-session, so the brief tells the model to re-check with ``git`` before
|
||||
acting on the snapshot. A ``/coding`` flip therefore only takes effect next
|
||||
session (deferred), the same contract as ``/skills install`` vs ``--now``.
|
||||
|
||||
Activation (config ``agent.coding_context``):
|
||||
|
||||
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
|
||||
surface sitting in a code workspace (git repo or recognised project root).
|
||||
Prompt-only; toolsets and the skill index untouched.
|
||||
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
|
||||
``coding`` set + enabled MCP servers and demotes non-coding skill
|
||||
categories to names-only in the prompt's skill index (no skill is ever
|
||||
hidden). Explicit opt-in for a lean schema.
|
||||
* ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
|
||||
* ``off`` — disable entirely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||
|
||||
logger = logging.getLogger("hermes.coding_context")
|
||||
|
||||
CODING_TOOLSET = "coding"
|
||||
|
||||
# Surfaces where a coding posture makes sense under ``auto``. Messaging
|
||||
# platforms (telegram, discord, slack, …) are intentionally absent — a chat bot
|
||||
# in a group is not pair-programming.
|
||||
INTERACTIVE_CODING_PLATFORMS = {"cli", "tui", "acp", "desktop", ""}
|
||||
|
||||
# Project-root signals that mark a directory as a code workspace even when it
|
||||
# isn't (yet) a git repo. Cheap filename checks — no parsing.
|
||||
_PROJECT_MARKERS = (
|
||||
"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt",
|
||||
"package.json", "tsconfig.json", "deno.json",
|
||||
"Cargo.toml", "go.mod", "pom.xml", "build.gradle", "build.gradle.kts",
|
||||
"Gemfile", "composer.json", "mix.exs", "pubspec.yaml",
|
||||
"CMakeLists.txt", "Makefile", "Dockerfile",
|
||||
"AGENTS.md", "CLAUDE.md", ".cursorrules",
|
||||
)
|
||||
|
||||
# Agent-instruction files surfaced separately from manifests in the snapshot.
|
||||
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
|
||||
|
||||
# Source-file extensions that make a git repo a *code* workspace even with no
|
||||
# manifest. Without this, `git init` on a notes/writing/research folder (a huge
|
||||
# non-coding use case) would flip the whole session into the coding posture just
|
||||
# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`).
|
||||
_CODE_EXTENSIONS = frozenset({
|
||||
".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
||||
".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h",
|
||||
".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs",
|
||||
".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl",
|
||||
".hs", ".clj", ".erl", ".pl",
|
||||
})
|
||||
|
||||
# Dirs never worth scanning for the code check (deps/build/vcs/venv noise).
|
||||
_CODE_SCAN_SKIP_DIRS = frozenset({
|
||||
".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
|
||||
"target", ".next", ".turbo", "vendor",
|
||||
})
|
||||
|
||||
# Bounded sweep: a code workspace reveals itself in the first handful of entries.
|
||||
_CODE_SCAN_MAX_ENTRIES = 500
|
||||
|
||||
|
||||
def _has_code_files(root: Path) -> bool:
|
||||
"""Cheap, bounded check for source files in a repo's top two levels.
|
||||
|
||||
Lets a git repo of loose scripts (no manifest) still read as a code
|
||||
workspace while a bare notes/writing repo does not. Scans the root and its
|
||||
immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats —
|
||||
a handful of readdirs at session start, not a full walk.
|
||||
"""
|
||||
seen = 0
|
||||
stack = [(root, True)]
|
||||
while stack:
|
||||
directory, is_root = stack.pop()
|
||||
try:
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
seen += 1
|
||||
if seen > _CODE_SCAN_MAX_ENTRIES:
|
||||
return False
|
||||
name = entry.name
|
||||
try:
|
||||
if entry.is_file():
|
||||
if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS:
|
||||
return True
|
||||
elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."):
|
||||
stack.append((Path(entry.path), False))
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
# Lockfile → package manager, checked in priority order.
|
||||
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
|
||||
_JS_LOCKFILES = (
|
||||
("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"),
|
||||
("yarn.lock", "yarn"), ("package-lock.json", "npm"),
|
||||
)
|
||||
|
||||
# package.json scripts / Makefile targets worth surfacing as verify commands.
|
||||
_VERIFY_TARGETS = ("test", "tests", "lint", "typecheck", "check", "build", "fmt", "format")
|
||||
_MAX_VERIFY_COMMANDS = 8
|
||||
_MAX_FACT_FILE_BYTES = 256 * 1024
|
||||
|
||||
_GIT_TIMEOUT = 2.5
|
||||
|
||||
|
||||
# Per-model edit-format steering. Matching the edit tool format to how a model
|
||||
# was trained reduces mistakes and wasted reasoning (OpenAI/Codex handle
|
||||
# patch-style diffs best; Anthropic models — and most open-weight coding
|
||||
# models, whose RL scaffolds use str_replace-style editors — do best with
|
||||
# string-replacement). Our `patch` tool exposes both: mode="patch" (V4A
|
||||
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
|
||||
# its native format. Unknown families get nothing (the brief's neutral wording
|
||||
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
|
||||
#
|
||||
# GPT/Codex get V4A for ALL edits, single-file included: in codex-rs,
|
||||
# apply_patch (V4A — apply_patch.lark) is the ONLY file editor, no
|
||||
# str_replace-style tool exists, and the shipped model prompts say to use
|
||||
# apply_patch even "for single file edits" — so a replace-mode nudge would
|
||||
# steer those models toward a format their first-party harness never taught
|
||||
# them.
|
||||
_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = {
|
||||
"patch": (
|
||||
("gpt", "codex"),
|
||||
"- Edit format: author new files with `write_file`; for edits to "
|
||||
"existing code use `patch` with `mode='patch'` (V4A diff) — including "
|
||||
"single-file edits. It's the edit format you handle most reliably.",
|
||||
),
|
||||
"replace": (
|
||||
("claude", "sonnet", "opus", "haiku",
|
||||
"gemini", "gemma", "deepseek", "qwen", "kimi", "glm", "grok",
|
||||
"hermes", "llama", "mistral", "devstral", "minimax"),
|
||||
"- Edit format: author new files with `write_file`; for edits to "
|
||||
"existing code prefer `patch` in `mode='replace'` — match a unique "
|
||||
"snippet and swap it. Reach for `mode='patch'` (V4A) only when an edit "
|
||||
"genuinely spans several files at once.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _model_family(model: Optional[str]) -> Optional[str]:
|
||||
"""Classify a model id into an edit-format family key, or ``None``.
|
||||
|
||||
Used to steer the coding posture toward the edit tool format a model was
|
||||
trained on. Family-agnostic by design: an unrecognised model gets ``None``
|
||||
and the operating brief's neutral edit wording applies.
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
lowered = model.lower()
|
||||
for family, (needles, _line) in _EDIT_FORMAT_GUIDANCE.items():
|
||||
if any(n in lowered for n in needles):
|
||||
return family
|
||||
return None
|
||||
|
||||
|
||||
def _edit_format_line(model: Optional[str]) -> str:
|
||||
"""The edit-format guidance line for this model's family (``""`` if none)."""
|
||||
family = _model_family(model)
|
||||
if family is None:
|
||||
return ""
|
||||
return _EDIT_FORMAT_GUIDANCE[family][1]
|
||||
|
||||
|
||||
# Operating brief for the coding posture. Tool names referenced here (read_file,
|
||||
# search_files, patch, write_file, terminal, todo) are in the coding toolset and
|
||||
# in _HERMES_CORE_TOOLS, so they're present on every surface this fires on.
|
||||
CODING_AGENT_GUIDANCE = (
|
||||
"You are a coding agent pairing with the user inside their codebase. "
|
||||
"Operate like a careful senior engineer.\n"
|
||||
"\n"
|
||||
"Gather context first:\n"
|
||||
"- Read the relevant files with `read_file` and locate code with "
|
||||
"`search_files` before changing anything. Trace a symbol to its definition "
|
||||
"and usages rather than guessing its shape.\n"
|
||||
"- Batch independent lookups: when several reads/searches don't depend on "
|
||||
"each other, issue them together in one turn instead of one at a time.\n"
|
||||
"- Never invent files, symbols, APIs, or imports. If you haven't seen it in "
|
||||
"the repo, go look. Don't assume a library is available — check the project "
|
||||
"manifest (pyproject.toml / package.json / Cargo.toml / go.mod) and how "
|
||||
"neighbouring files import it.\n"
|
||||
"\n"
|
||||
"Make changes through the tools, not the chat:\n"
|
||||
"- Edit with `patch`/`write_file`. Do NOT print code blocks to the user as "
|
||||
"a substitute for editing — apply the change, then summarise it. Only show "
|
||||
"code when the user explicitly asks to see it.\n"
|
||||
"- Match the project's existing style and conventions; AGENTS.md / "
|
||||
"CLAUDE.md / .cursorrules already in context win over your defaults. Touch "
|
||||
"only what the task needs — no drive-by refactors, renames, or reformatting "
|
||||
"— and add any imports/dependencies your code requires.\n"
|
||||
"- If an edit fails to apply, re-read the file to get the current exact "
|
||||
"contents before retrying — don't repeat a stale patch. If the same region "
|
||||
"fails twice, rewrite the enclosing function or file with `write_file` "
|
||||
"instead of attempting a third patch.\n"
|
||||
"\n"
|
||||
"Verify, and know when to stop:\n"
|
||||
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
|
||||
"tests/linter/build and confirm they pass before claiming the work is done.\n"
|
||||
"- Terminal state persists across calls: current directory and exported "
|
||||
"environment variables carry forward. Activate a virtualenv or export setup "
|
||||
"vars once, then reuse that state instead of re-sourcing it before every "
|
||||
"test command.\n"
|
||||
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
|
||||
"paths for the same flaw and fix the class, not just the reported site.\n"
|
||||
"- When fixing linter/type errors on a file, stop after about three "
|
||||
"attempts on the same file and ask the user rather than looping.\n"
|
||||
"- Track multi-step work with `todo`. Reference code as `path:line` instead "
|
||||
"of pasting whole files.\n"
|
||||
"\n"
|
||||
"Respect the user's repo: don't commit, push, or rewrite history unless "
|
||||
"asked, and never read, print, or commit secrets — leave `.env` and "
|
||||
"credential files alone unless the user explicitly asks. The Workspace "
|
||||
"block below is a snapshot from session start — re-run `git status`/"
|
||||
"`git branch` before relying on it. Be concise: lead with the change or "
|
||||
"answer, not a preamble."
|
||||
)
|
||||
|
||||
|
||||
# ── Context profiles (declarative posture definitions) ──────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextProfile:
|
||||
"""A named operating posture. Pure data — consumers read these fields.
|
||||
|
||||
``toolset`` — collapse to this toolset (+ enabled MCP) when no explicit
|
||||
selection is pinned; ``None`` keeps the platform default.
|
||||
``guidance`` — operating brief injected into the stable system prompt;
|
||||
``""`` injects nothing.
|
||||
``model_hint`` — routing preference key for smart model routing
|
||||
(extension seam; not yet consumed by the router).
|
||||
``memory_policy``— memory namespace/weighting hint (extension seam).
|
||||
``compact_skill_categories`` — skill categories DEMOTED to names-only in
|
||||
the system-prompt skill index under the opt-in ``focus``
|
||||
mode. Never hidden: every skill name stays visible
|
||||
(so memory-anchored recall keeps working) — only the
|
||||
descriptions are dropped to cut index noise. Deny-list
|
||||
semantics so unknown/custom categories keep full
|
||||
entries.
|
||||
"""
|
||||
|
||||
name: str
|
||||
toolset: Optional[str] = None
|
||||
guidance: str = ""
|
||||
model_hint: Optional[str] = None
|
||||
memory_policy: str = "default"
|
||||
compact_skill_categories: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# Skill categories that are clearly not part of a coding workflow. Demoted to
|
||||
# names-only in the prompt's skill index under the opt-in ``focus`` mode only
|
||||
# (deny-list — anything not listed here, incl. custom user categories, keeps
|
||||
# full entries). Coding-adjacent categories (devops, github, mcp,
|
||||
# data-science, diagramming, research, security, …) are intentionally absent.
|
||||
_NON_CODING_SKILL_CATEGORIES = (
|
||||
"apple", "communication", "cooking", "creative", "email", "finance",
|
||||
"gaming", "gifs", "health", "media", "music", "note-taking",
|
||||
"productivity", "shopping", "smart-home", "social-media", "travel",
|
||||
"yuanbao",
|
||||
)
|
||||
|
||||
|
||||
GENERAL_PROFILE = ContextProfile(name="general")
|
||||
CODING_PROFILE = ContextProfile(
|
||||
name="coding",
|
||||
toolset=CODING_TOOLSET,
|
||||
guidance=CODING_AGENT_GUIDANCE,
|
||||
model_hint="coding",
|
||||
memory_policy="project",
|
||||
compact_skill_categories=_NON_CODING_SKILL_CATEGORIES,
|
||||
)
|
||||
|
||||
_PROFILES: dict[str, ContextProfile] = {
|
||||
GENERAL_PROFILE.name: GENERAL_PROFILE,
|
||||
CODING_PROFILE.name: CODING_PROFILE,
|
||||
}
|
||||
|
||||
|
||||
def get_profile(name: str) -> ContextProfile:
|
||||
"""Return a registered profile, falling back to ``general``."""
|
||||
return _PROFILES.get(name, GENERAL_PROFILE)
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _coding_mode(config: Optional[dict[str, Any]]) -> str:
|
||||
"""Return the normalized ``agent.coding_context`` mode (auto/focus/on/off)."""
|
||||
if config is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
except Exception:
|
||||
config = {}
|
||||
raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto")
|
||||
mode = str(raw).strip().lower()
|
||||
if mode in {"focus", "strict", "lean"}:
|
||||
return "focus"
|
||||
if mode in {"on", "true", "yes", "1", "always"}:
|
||||
return "on"
|
||||
if mode in {"off", "false", "no", "0", "never"}:
|
||||
return "off"
|
||||
return "auto"
|
||||
|
||||
|
||||
def _coding_instructions(config: Optional[dict[str, Any]]) -> str:
|
||||
"""Standing operator instructions for the coding posture (config).
|
||||
|
||||
``agent.coding_instructions`` — a string or list of strings appended to the
|
||||
coding brief as an extra stable system block, so a user can pin project-wide
|
||||
coding-workflow rules (e.g. "for UI work don't run tsc/lint until I approve;
|
||||
clean the diff before committing") without editing the shipped brief.
|
||||
Cache-safe: resolved once per session into the stable system-prompt tier,
|
||||
like the rest of the posture.
|
||||
"""
|
||||
if config is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
except Exception:
|
||||
config = {}
|
||||
raw = ((config or {}).get("agent", {}) or {}).get("coding_instructions", "")
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return "\n".join(str(item).strip() for item in raw if str(item).strip())
|
||||
return str(raw or "").strip()
|
||||
|
||||
|
||||
def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
|
||||
if cwd:
|
||||
return Path(cwd).expanduser()
|
||||
try:
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
|
||||
return resolve_agent_cwd()
|
||||
except Exception:
|
||||
return Path(os.getcwd())
|
||||
|
||||
|
||||
def _git_root(cwd: Path) -> Optional[Path]:
|
||||
current = cwd.resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _home() -> Optional[Path]:
|
||||
try:
|
||||
return Path.home().resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _marker_root(cwd: Path) -> Optional[Path]:
|
||||
"""Nearest ancestor that looks like a project root, or ``None``.
|
||||
|
||||
Walks up at most a few levels so a manifest in the workspace root counts
|
||||
even when the user is in a subdirectory. ``$HOME`` itself is skipped — a
|
||||
Makefile or AGENTS.md sitting in the home directory is global user config,
|
||||
not a project-root signal.
|
||||
"""
|
||||
current = cwd.resolve()
|
||||
home = _home()
|
||||
for depth, parent in enumerate([current, *current.parents]):
|
||||
if depth > 6:
|
||||
break
|
||||
if parent == home:
|
||||
continue
|
||||
for marker in _PROJECT_MARKERS:
|
||||
if (parent / marker).exists():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
|
||||
"""Resolve which profile applies.
|
||||
|
||||
``auto``/``focus``: coding when the surface is interactive AND the cwd is a
|
||||
code workspace (a git repo or a recognised project root). ``on``: always
|
||||
coding. ``off``: always general.
|
||||
|
||||
A git repo rooted at ``$HOME`` (the dotfiles pattern) is NOT a workspace
|
||||
signal — without the guard, every session anywhere under a dotfiles-managed
|
||||
home directory would silently flip to the coding posture.
|
||||
|
||||
Detection is intentionally not memoized: it's a handful of ``stat`` calls,
|
||||
and callers resolve the mode once per session anyway. Caching here would
|
||||
risk a stale posture if a long-lived process (gateway/TUI) serves sessions
|
||||
from different working directories.
|
||||
"""
|
||||
if mode == "off":
|
||||
return GENERAL_PROFILE.name
|
||||
if mode == "on":
|
||||
return CODING_PROFILE.name
|
||||
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
|
||||
return GENERAL_PROFILE.name
|
||||
cwd = Path(cwd_str)
|
||||
# A recognized project root (manifest / AGENTS.md / .cursorrules) is a code
|
||||
# workspace on its own — cheap stat checks, no scan.
|
||||
if _marker_root(cwd) is not None:
|
||||
return CODING_PROFILE.name
|
||||
git_root = _git_root(cwd)
|
||||
if git_root is not None and git_root == _home():
|
||||
git_root = None # dotfiles repo at $HOME — not a code workspace
|
||||
# A bare git repo only counts when it actually holds code, so `git init` on a
|
||||
# notes/writing/research folder stays in the general posture.
|
||||
if git_root is not None and _has_code_files(git_root):
|
||||
return CODING_PROFILE.name
|
||||
return GENERAL_PROFILE.name
|
||||
|
||||
|
||||
# ── RuntimeMode (the seam) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeMode:
|
||||
"""The resolved operating posture for a session. Immutable by construction.
|
||||
|
||||
Built once via :func:`resolve_runtime_mode` and consumed by every domain
|
||||
that cares about the coding/general distinction. Never mutate or re-resolve
|
||||
mid-session — that would break the prompt cache.
|
||||
"""
|
||||
|
||||
profile: ContextProfile
|
||||
surface: str
|
||||
cwd: Path
|
||||
# The normalized ``agent.coding_context`` mode this posture was resolved
|
||||
# under (auto/focus/on/off). Toolset collapse is gated on ``focus``.
|
||||
config_mode: str = "auto"
|
||||
# The model id this session runs (e.g. "anthropic/claude-opus-4.8"). Used
|
||||
# only to steer edit-format guidance toward the model's family — see
|
||||
# ``_edit_format_line``. Fixed for the session, so cache-safe.
|
||||
model: Optional[str] = None
|
||||
# Standing operator instructions (``agent.coding_instructions``), appended
|
||||
# as an extra stable system block. Empty unless the user configures it.
|
||||
instructions: str = ""
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
return self.profile.name
|
||||
|
||||
@property
|
||||
def is_coding(self) -> bool:
|
||||
return self.profile.name == CODING_PROFILE.name
|
||||
|
||||
def toolset_selection(self, config: Optional[dict[str, Any]] = None) -> Optional[list[str]]:
|
||||
"""Toolset list for this posture, or ``None`` to keep the platform default.
|
||||
|
||||
Non-``None`` only under the opt-in ``focus`` mode. The default posture
|
||||
is prompt-only: most strippable toolsets are off-by-default anyway, and
|
||||
a user who explicitly enabled one (image-gen for frontend/game assets,
|
||||
messaging for build notifications, …) keeps it while coding.
|
||||
|
||||
Callers apply this only when the user hasn't pinned an explicit
|
||||
selection (``--toolsets``, ``HERMES_TUI_TOOLSETS``, …); they never
|
||||
override a pin. Returns the profile's toolset plus enabled MCP servers.
|
||||
"""
|
||||
if self.config_mode != "focus":
|
||||
return None
|
||||
if self.profile.toolset is None:
|
||||
return None
|
||||
return [self.profile.toolset, *_enabled_mcp_servers(config)]
|
||||
|
||||
def system_blocks(self) -> list[str]:
|
||||
"""Stable system-prompt blocks for this posture (brief + workspace).
|
||||
|
||||
The operating brief carries a model-family edit-format nudge appended
|
||||
to it (one cached string, not a separate block) so the model is steered
|
||||
toward the `patch` mode it handles best — see ``_edit_format_line``.
|
||||
"""
|
||||
if not self.is_coding:
|
||||
return []
|
||||
blocks: list[str] = []
|
||||
if self.profile.guidance:
|
||||
brief = self.profile.guidance
|
||||
edit_line = _edit_format_line(self.model)
|
||||
if edit_line:
|
||||
brief = f"{brief}\n{edit_line}"
|
||||
blocks.append(brief)
|
||||
workspace = build_coding_workspace_block(self.cwd)
|
||||
if workspace:
|
||||
blocks.append(workspace)
|
||||
# Operator instructions ride their own block so the brief (block 0) stays
|
||||
# byte-stable and cache-keyed independently of user config.
|
||||
if self.instructions:
|
||||
blocks.append(f"Operator instructions (from config):\n{self.instructions}")
|
||||
return blocks
|
||||
|
||||
def compact_skill_categories(self) -> frozenset[str]:
|
||||
"""Skill categories to demote to names-only in the prompt's skill index.
|
||||
|
||||
Gated on the opt-in ``focus`` mode, like the toolset collapse: the
|
||||
default posture leaves the skill index untouched. Users who didn't ask
|
||||
for a lean prompt keep full entries for every category — index changes
|
||||
under ``auto`` proved too surprising in practice, even names-only ones
|
||||
(a demoted description is information the model no longer weighs when
|
||||
deciding what to load).
|
||||
|
||||
Demoted — never hidden — even under ``focus``. An earlier revision
|
||||
fully pruned these categories from the index, which caused silent
|
||||
capability loss in a real workflow: agent-created skills are the
|
||||
model's accumulated project memory (server-ops runbooks, learned
|
||||
pitfalls, …), and models do not reliably reach for ``skills_list`` to
|
||||
rediscover what the index stopped showing them. Names-only keeps every
|
||||
skill loadable on recall while still cutting the description noise.
|
||||
"""
|
||||
if not self.is_coding or self.config_mode != "focus":
|
||||
return frozenset()
|
||||
return frozenset(self.profile.compact_skill_categories)
|
||||
|
||||
|
||||
def resolve_runtime_mode(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
model: Optional[str] = None,
|
||||
) -> RuntimeMode:
|
||||
"""Resolve the operating posture once. Cheap — a handful of ``stat`` calls.
|
||||
|
||||
This is the single entry point every domain should call. The returned
|
||||
object is immutable and safe to cache for the session. Detection itself is
|
||||
intentionally *not* memoized (see ``_detect_profile_name``) so a long-lived
|
||||
process can't pin a stale posture; callers resolve once per session and
|
||||
hold the result. ``model`` is recorded only to steer edit-format guidance;
|
||||
it never affects detection.
|
||||
"""
|
||||
resolved_cwd = _resolve_cwd(cwd)
|
||||
mode = _coding_mode(config)
|
||||
name = _detect_profile_name(
|
||||
mode, (platform or "").strip().lower(), str(resolved_cwd)
|
||||
)
|
||||
return RuntimeMode(
|
||||
profile=get_profile(name),
|
||||
surface=platform or "",
|
||||
cwd=resolved_cwd,
|
||||
config_mode=mode,
|
||||
model=model,
|
||||
instructions=_coding_instructions(config),
|
||||
)
|
||||
|
||||
|
||||
# ── Back-compat surface (thin wrappers over RuntimeMode) ────────────────────
|
||||
|
||||
|
||||
def is_coding_context(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Whether Hermes should operate in its coding posture right now."""
|
||||
return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).is_coding
|
||||
|
||||
|
||||
def coding_selection(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[list[str]]:
|
||||
"""Toolset selection for the coding posture.
|
||||
|
||||
``None`` unless the user opted into ``focus`` mode AND the posture is
|
||||
active — the default coding posture never overrides configured toolsets.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config
|
||||
).toolset_selection(config)
|
||||
|
||||
|
||||
def coding_system_blocks(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
model: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
"""Stable system-prompt blocks for the current posture (empty when general).
|
||||
|
||||
``model`` steers the brief's edit-format nudge toward the model's family.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config, model=model
|
||||
).system_blocks()
|
||||
|
||||
|
||||
def coding_compact_skill_categories(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> frozenset[str]:
|
||||
"""Skill categories the active posture demotes to names-only in the index.
|
||||
|
||||
Empty outside the coding posture and outside the opt-in ``focus`` mode —
|
||||
the default posture never touches the skill index. Under ``focus``,
|
||||
demoted — never hidden: every skill name stays in the index and remains
|
||||
loadable via ``skill_view`` / ``skills_list``; only descriptions are
|
||||
dropped.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config
|
||||
).compact_skill_categories()
|
||||
|
||||
|
||||
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
|
||||
"""Names of MCP servers the user has enabled — kept in the coding posture.
|
||||
|
||||
MCP servers (figma, browser, tophat, …) are explicitly configured and part
|
||||
of the coding workflow, not noise to strip.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
from hermes_cli.tools_config import _parse_enabled_flag
|
||||
|
||||
servers = read_raw_config().get("mcp_servers") or {}
|
||||
return [
|
||||
str(name)
|
||||
for name, cfg in servers.items()
|
||||
if isinstance(cfg, dict)
|
||||
and _parse_enabled_flag(cfg.get("enabled", True), default=True)
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── git/workspace probe ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(cwd), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
**_popen_kwargs,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return out.stdout.strip() if out.returncode == 0 else ""
|
||||
|
||||
|
||||
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
|
||||
"""Parse ``git status --porcelain=2 --branch`` into branch + counts."""
|
||||
branch: dict[str, str] = {}
|
||||
counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicts": 0}
|
||||
for line in porcelain.splitlines():
|
||||
if line.startswith("# branch.head"):
|
||||
branch["head"] = line.split(maxsplit=2)[-1]
|
||||
elif line.startswith("# branch.upstream"):
|
||||
branch["upstream"] = line.split(maxsplit=2)[-1]
|
||||
elif line.startswith("# branch.ab"):
|
||||
parts = line.split()
|
||||
branch["ahead"], branch["behind"] = parts[2].lstrip("+"), parts[3].lstrip("-")
|
||||
elif line.startswith(("1 ", "2 ")):
|
||||
xy = line.split(maxsplit=2)[1]
|
||||
if xy[0] != ".":
|
||||
counts["staged"] += 1
|
||||
if xy[1] != ".":
|
||||
counts["modified"] += 1
|
||||
elif line.startswith("u "):
|
||||
counts["conflicts"] += 1
|
||||
elif line.startswith("? "):
|
||||
counts["untracked"] += 1
|
||||
return branch, counts
|
||||
|
||||
|
||||
def _read_small(path: Path) -> str:
|
||||
"""Read a small text file, or ``""`` — never raises, never reads huge files."""
|
||||
try:
|
||||
if not path.is_file() or path.stat().st_size > _MAX_FACT_FILE_BYTES:
|
||||
return ""
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectFacts:
|
||||
"""Structured project facts — the model's verify loop, detected once.
|
||||
|
||||
The same data that feeds the workspace snapshot, exposed structurally so
|
||||
non-prompt consumers (e.g. the desktop verify UI) read it instead of
|
||||
re-detecting and drifting from the prompt.
|
||||
"""
|
||||
|
||||
manifests: list[str]
|
||||
package_managers: list[str]
|
||||
verify_commands: list[str]
|
||||
context_files: list[str]
|
||||
|
||||
|
||||
def detect_project_facts(root: Path) -> ProjectFacts:
|
||||
"""Detect manifests, package manager(s), verify commands, and context files.
|
||||
|
||||
Cheap: stat calls plus reads of a couple of small files. The single source
|
||||
of truth for both the prompt snapshot (:func:`_project_facts`) and the
|
||||
gateway's ``project.facts`` — so the UI never re-sniffs verify commands.
|
||||
"""
|
||||
manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
|
||||
package_managers = list(
|
||||
dict.fromkeys(pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file())
|
||||
)
|
||||
|
||||
verify: list[str] = []
|
||||
if (root / "scripts" / "run_tests.sh").is_file():
|
||||
verify.append("scripts/run_tests.sh")
|
||||
if (root / "package.json").is_file():
|
||||
try:
|
||||
scripts = json.loads(_read_small(root / "package.json") or "{}").get("scripts") or {}
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
scripts = {}
|
||||
js_pm = next((pm for lock, pm in _JS_LOCKFILES if (root / lock).is_file()), "npm")
|
||||
verify.extend(f"{js_pm} run {name}" for name in _VERIFY_TARGETS if name in scripts)
|
||||
if (root / "pytest.ini").is_file() or "[tool.pytest" in _read_small(root / "pyproject.toml"):
|
||||
verify.append("pytest")
|
||||
makefile = _read_small(root / "Makefile")
|
||||
if makefile:
|
||||
verify.extend(
|
||||
f"make {name}" for name in _VERIFY_TARGETS
|
||||
if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
|
||||
)
|
||||
|
||||
return ProjectFacts(
|
||||
manifests=manifests,
|
||||
package_managers=package_managers,
|
||||
verify_commands=list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS],
|
||||
context_files=[c for c in _CONTEXT_FILES if (root / c).is_file()],
|
||||
)
|
||||
|
||||
|
||||
def _project_facts(root: Path) -> list[str]:
|
||||
"""Render :func:`detect_project_facts` as workspace-snapshot lines.
|
||||
|
||||
Hands the model its *verify loop* up front — which manifest, which package
|
||||
manager, and the exact test/lint/build commands — instead of making it
|
||||
rediscover them every session. Built once at prompt-build time; the string
|
||||
output must stay byte-stable to preserve the prompt cache.
|
||||
"""
|
||||
f = detect_project_facts(root)
|
||||
facts: list[str] = []
|
||||
|
||||
if f.manifests:
|
||||
line = f"- Project: {', '.join(f.manifests[:6])}"
|
||||
if f.package_managers:
|
||||
line += f" ({'/'.join(f.package_managers)})"
|
||||
facts.append(line)
|
||||
if f.verify_commands:
|
||||
facts.append(f"- Verify: {'; '.join(f.verify_commands)}")
|
||||
if f.context_files:
|
||||
facts.append(f"- Context files: {', '.join(f.context_files)}")
|
||||
|
||||
return facts
|
||||
|
||||
|
||||
def project_facts_for(cwd: Optional[str | Path] = None) -> Optional[dict[str, Any]]:
|
||||
"""Structured project facts for ``cwd`` — ``None`` outside a workspace.
|
||||
|
||||
Same detection the system-prompt snapshot uses (git root, else marker root),
|
||||
exposed for non-prompt consumers (the desktop verify UI) so they never
|
||||
re-derive "are we coding?" or duplicate the verify-command sniffing.
|
||||
"""
|
||||
resolved = _resolve_cwd(cwd)
|
||||
root = _git_root(resolved) or _marker_root(resolved)
|
||||
if root is None:
|
||||
return None
|
||||
|
||||
f = detect_project_facts(root)
|
||||
return {
|
||||
"root": str(root),
|
||||
"manifests": f.manifests,
|
||||
"packageManagers": f.package_managers,
|
||||
"verifyCommands": f.verify_commands,
|
||||
"contextFiles": f.context_files,
|
||||
}
|
||||
|
||||
|
||||
def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
|
||||
"""Workspace snapshot for the system prompt (empty outside a workspace).
|
||||
|
||||
Git state (branch/status/commits) when the cwd is in a repo, plus detected
|
||||
project facts (manifest, package manager, verify commands, context files)
|
||||
— so marker-only (non-git) projects still get a snapshot.
|
||||
"""
|
||||
resolved = _resolve_cwd(cwd)
|
||||
git_root = _git_root(resolved)
|
||||
root = git_root or _marker_root(resolved)
|
||||
if root is None:
|
||||
return ""
|
||||
|
||||
lines = ["Workspace (snapshot at session start — re-check with `git` before acting on it):"]
|
||||
lines.append(f"- Root: {root}")
|
||||
|
||||
if git_root is not None:
|
||||
branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch"))
|
||||
head = branch.get("head", "")
|
||||
if head and head != "(detached)":
|
||||
line = f"- Branch: {head}"
|
||||
if branch.get("upstream"):
|
||||
line += f" \u2192 {branch['upstream']}"
|
||||
ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0")
|
||||
if ahead != "0" or behind != "0":
|
||||
line += f" (ahead {ahead}, behind {behind})"
|
||||
lines.append(line)
|
||||
elif head == "(detached)":
|
||||
lines.append("- Branch: (detached HEAD)")
|
||||
|
||||
# Linked worktree: the per-worktree git dir differs from the shared common dir.
|
||||
# We surface the fact that it's a worktree (so the model knows branches/stashes
|
||||
# are shared state) but deliberately do NOT expose the primary tree path —
|
||||
# giving the model a second absolute path causes it to sometimes run commands
|
||||
# in the wrong directory.
|
||||
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
|
||||
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
|
||||
lines.append("- Worktree: linked (git state shared with primary tree)")
|
||||
|
||||
dirty = [f"{n} {label}" for label, n in (
|
||||
("staged", counts["staged"]), ("modified", counts["modified"]),
|
||||
("untracked", counts["untracked"]), ("conflicts", counts["conflicts"]),
|
||||
) if n]
|
||||
lines.append(f"- Status: {', '.join(dirty) if dirty else 'clean'}")
|
||||
|
||||
recent = _git(root, "log", "-3", "--pretty=%h %s")
|
||||
if recent:
|
||||
lines.append("- Recent commits:")
|
||||
lines.extend(f" {c}" for c in recent.splitlines())
|
||||
|
||||
lines.extend(_project_facts(root))
|
||||
return "\n".join(lines)
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Live session context-window breakdown for UI surfaces.
|
||||
|
||||
Estimates how the next provider request is composed: system prompt tiers,
|
||||
tool schemas, and conversation history. Uses the same rough char/4 heuristic
|
||||
as ``agent.model_metadata.estimate_request_tokens_rough`` so numbers align
|
||||
with compression thresholds — not exact tokenizer counts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
_SKILLS_BLOCK_RE = re.compile(r"<available_skills>.*?</available_skills>", re.DOTALL)
|
||||
|
||||
_SUBAGENT_TOOL_NAMES = frozenset({"delegate_task"})
|
||||
|
||||
_CATEGORY_COLORS = {
|
||||
"system_prompt": "var(--context-usage-system)",
|
||||
"tool_definitions": "var(--context-usage-tools)",
|
||||
"rules": "var(--context-usage-rules)",
|
||||
"skills": "var(--context-usage-skills)",
|
||||
"mcp": "var(--context-usage-mcp)",
|
||||
"subagent_definitions": "var(--context-usage-subagents)",
|
||||
"memory": "var(--context-usage-memory)",
|
||||
"conversation": "var(--context-usage-conversation)",
|
||||
}
|
||||
|
||||
|
||||
def _chars_to_tokens(text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
return (len(text) + 3) // 4
|
||||
|
||||
|
||||
def _json_tokens(value: Any) -> int:
|
||||
if not value:
|
||||
return 0
|
||||
return _chars_to_tokens(json.dumps(value, ensure_ascii=False))
|
||||
|
||||
|
||||
def _tool_name(tool: dict) -> str:
|
||||
fn = tool.get("function") if isinstance(tool, dict) else None
|
||||
if isinstance(fn, dict):
|
||||
return str(fn.get("name") or "")
|
||||
return str(tool.get("name") or "")
|
||||
|
||||
|
||||
def _split_tools(tools: Sequence[dict]) -> Tuple[List[dict], List[dict], List[dict]]:
|
||||
builtin: List[dict] = []
|
||||
mcp: List[dict] = []
|
||||
subagent: List[dict] = []
|
||||
for tool in tools:
|
||||
name = _tool_name(tool)
|
||||
if name.startswith("mcp_"):
|
||||
mcp.append(tool)
|
||||
elif name in _SUBAGENT_TOOL_NAMES:
|
||||
subagent.append(tool)
|
||||
else:
|
||||
builtin.append(tool)
|
||||
return builtin, mcp, subagent
|
||||
|
||||
|
||||
def _memory_blocks(agent: Any) -> Tuple[str, str]:
|
||||
memory_block = ""
|
||||
user_block = ""
|
||||
store = getattr(agent, "_memory_store", None)
|
||||
if store is None:
|
||||
return memory_block, user_block
|
||||
try:
|
||||
if getattr(agent, "_memory_enabled", True):
|
||||
memory_block = store.format_for_system_prompt("memory") or ""
|
||||
if getattr(agent, "_user_profile_enabled", True):
|
||||
user_block = store.format_for_system_prompt("user") or ""
|
||||
except Exception:
|
||||
pass
|
||||
return memory_block, user_block
|
||||
|
||||
|
||||
def _strip_blocks(text: str, *blocks: str) -> str:
|
||||
out = text
|
||||
for block in blocks:
|
||||
if block:
|
||||
out = out.replace(block, "")
|
||||
return out.strip()
|
||||
|
||||
|
||||
def compute_session_context_breakdown(
|
||||
agent: Any,
|
||||
messages: Optional[List[dict]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a Cursor-style context usage breakdown for one live agent."""
|
||||
from agent.model_metadata import estimate_messages_tokens_rough
|
||||
from agent.system_prompt import build_system_prompt_parts
|
||||
|
||||
parts = build_system_prompt_parts(agent)
|
||||
stable = parts.get("stable", "") or ""
|
||||
context = parts.get("context", "") or ""
|
||||
volatile = parts.get("volatile", "") or ""
|
||||
|
||||
skills_match = _SKILLS_BLOCK_RE.search(stable)
|
||||
skills_index = skills_match.group(0) if skills_match else ""
|
||||
|
||||
memory_block, user_block = _memory_blocks(agent)
|
||||
memory_text = "\n\n".join(part for part in (memory_block, user_block) if part).strip()
|
||||
|
||||
system_core = _strip_blocks(stable, skills_index)
|
||||
system_tail = _strip_blocks(volatile, memory_block, user_block)
|
||||
system_prompt_text = "\n\n".join(part for part in (system_core, system_tail) if part).strip()
|
||||
|
||||
tools = list(getattr(agent, "tools", None) or [])
|
||||
builtin_tools, mcp_tools, subagent_tools = _split_tools(tools)
|
||||
|
||||
conversation_tokens = estimate_messages_tokens_rough(messages or [])
|
||||
|
||||
categories = [
|
||||
("system_prompt", "System prompt", _chars_to_tokens(system_prompt_text)),
|
||||
("tool_definitions", "Tool definitions", _json_tokens(builtin_tools)),
|
||||
("rules", "Rules", _chars_to_tokens(context)),
|
||||
("skills", "Skills", _chars_to_tokens(skills_index)),
|
||||
("mcp", "MCP", _json_tokens(mcp_tools)),
|
||||
("subagent_definitions", "Subagent definitions", _json_tokens(subagent_tools)),
|
||||
("memory", "Memory", _chars_to_tokens(memory_text)),
|
||||
("conversation", "Conversation", conversation_tokens),
|
||||
]
|
||||
|
||||
estimated_total = sum(tokens for _, _, tokens in categories)
|
||||
|
||||
comp = getattr(agent, "context_compressor", None)
|
||||
context_max = int(getattr(comp, "context_length", 0) or 0) if comp else 0
|
||||
measured_used = int(getattr(comp, "last_prompt_tokens", 0) or 0) if comp else 0
|
||||
context_used = measured_used if measured_used > 0 else estimated_total
|
||||
context_percent = (
|
||||
max(0, min(100, round(context_used / context_max * 100)))
|
||||
if context_max
|
||||
else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"categories": [
|
||||
{
|
||||
"color": _CATEGORY_COLORS.get(category_id, "var(--ui-text-tertiary)"),
|
||||
"id": category_id,
|
||||
"label": label,
|
||||
"tokens": tokens,
|
||||
}
|
||||
for category_id, label, tokens in categories
|
||||
if tokens > 0
|
||||
],
|
||||
"context_max": context_max,
|
||||
"context_percent": context_percent,
|
||||
"context_used": context_used,
|
||||
"estimated_total": estimated_total,
|
||||
"model": getattr(agent, "model", "") or "",
|
||||
}
|
||||
+107
-721
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,6 @@ from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from agent.model_metadata import estimate_tokens_rough
|
||||
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||
|
||||
_QUOTED_REFERENCE_VALUE = r'(?:`[^`\n]+`|"[^"\n]+"|\'[^\'\n]+\')'
|
||||
REFERENCE_PATTERN = re.compile(
|
||||
@@ -152,24 +151,13 @@ async def preprocess_context_references_async(
|
||||
blocks: list[str] = []
|
||||
injected_tokens = 0
|
||||
|
||||
# Expand all references concurrently. Each _expand_reference is independent
|
||||
# (no shared state during expansion) — a message with several @url: refs
|
||||
# would otherwise pay one full web_extract round-trip per ref in series.
|
||||
# gather preserves positional order, so we reassemble warnings/blocks in the
|
||||
# original ref order exactly as the prior serial loop did; the token-budget
|
||||
# check below is unchanged (it runs once, after all refs are expanded).
|
||||
expanded = await asyncio.gather(
|
||||
*(
|
||||
_expand_reference(
|
||||
ref,
|
||||
cwd_path,
|
||||
url_fetcher=url_fetcher,
|
||||
allowed_root=allowed_root_path,
|
||||
)
|
||||
for ref in refs
|
||||
for ref in refs:
|
||||
warning, block = await _expand_reference(
|
||||
ref,
|
||||
cwd_path,
|
||||
url_fetcher=url_fetcher,
|
||||
allowed_root=allowed_root_path,
|
||||
)
|
||||
)
|
||||
for warning, block in expanded:
|
||||
if warning:
|
||||
warnings.append(warning)
|
||||
if block:
|
||||
@@ -258,14 +246,7 @@ def _expand_file_reference(
|
||||
if not path.is_file():
|
||||
return f"{ref.raw}: path is not a file", None
|
||||
if _is_binary_file(path):
|
||||
# A binary file can't be inlined as text, but it IS on disk (the agent's
|
||||
# tools run where this resolves — the local cwd, or the staged copy in a
|
||||
# remote session workspace). Returning a bare "not supported" warning
|
||||
# with no content was a dead end: the model saw a failure and gave up
|
||||
# (told the user the file type wasn't supported). Instead, hand it an
|
||||
# actionable block — the path, type, size, and a nudge to use its tools —
|
||||
# so it can read/convert/view the file itself.
|
||||
return None, _binary_reference_block(ref, path)
|
||||
return f"{ref.raw}: binary files are not supported", None
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if ref.line_start is not None:
|
||||
@@ -302,7 +283,6 @@ def _expand_git_reference(
|
||||
args: list[str],
|
||||
label: str,
|
||||
) -> tuple[str | None, str | None]:
|
||||
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
@@ -310,8 +290,6 @@ def _expand_git_reference(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
stdin=subprocess.DEVNULL,
|
||||
**_popen_kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"{ref.raw}: git command timed out (30s)", None
|
||||
@@ -339,9 +317,9 @@ async def _fetch_url_content(
|
||||
async def _default_url_fetcher(url: str) -> str:
|
||||
from tools.web_tools import web_extract_tool
|
||||
|
||||
raw = await web_extract_tool([url], format="markdown")
|
||||
raw = await web_extract_tool([url], format="markdown", use_llm_processing=True)
|
||||
payload = json.loads(raw)
|
||||
docs = payload.get("results", [])
|
||||
docs = payload.get("data", {}).get("documents", [])
|
||||
if not docs:
|
||||
return ""
|
||||
doc = docs[0]
|
||||
@@ -497,7 +475,6 @@ def _iter_visible_entries(path: Path, cwd: Path, limit: int) -> list[Path]:
|
||||
|
||||
|
||||
def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
||||
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["rg", "--files", str(path.relative_to(cwd))],
|
||||
@@ -505,8 +482,6 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
**_popen_kwargs,
|
||||
)
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
@@ -516,30 +491,6 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
||||
return files[:limit]
|
||||
|
||||
|
||||
def _human_bytes(n: int) -> str:
|
||||
size = float(n)
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if size < 1024 or unit == "GB":
|
||||
return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} GB"
|
||||
|
||||
|
||||
def _binary_reference_block(ref: ContextReference, path: Path) -> str:
|
||||
mime, _ = mimetypes.guess_type(path.name)
|
||||
mime = mime or "application/octet-stream"
|
||||
try:
|
||||
size = _human_bytes(path.stat().st_size)
|
||||
except OSError:
|
||||
size = "unknown size"
|
||||
return (
|
||||
f"📎 {ref.raw} ({mime}, {size}) — binary file, not inlined as text. "
|
||||
f"It is available on disk at `{path}`. Use your tools to work with it "
|
||||
f"(read or convert it, extract its text, or view/render it as needed); "
|
||||
f"do not tell the user the file type is unsupported."
|
||||
)
|
||||
|
||||
|
||||
def _file_metadata(path: Path) -> str:
|
||||
if _is_binary_file(path):
|
||||
return f"{path.stat().st_size} bytes"
|
||||
|
||||
+221
-613
@@ -32,7 +32,6 @@ import logging
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Tuple
|
||||
@@ -41,16 +40,6 @@ from agent.model_metadata import estimate_request_tokens_rough
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stable marker the gateway matches on to re-tag the auto-compaction lifecycle
|
||||
# status as ``kind="compacting"`` (tui_gateway/server.py::_status_update), so
|
||||
# drivers like the desktop app can show an explicit "Summarizing…" indicator
|
||||
# instead of the transcript appearing to silently reset. Keep the marker phrase
|
||||
# intact if you reword COMPACTION_STATUS.
|
||||
COMPACTION_STATUS_MARKER = "Compacting context"
|
||||
COMPACTION_STATUS = (
|
||||
f"🗜️ {COMPACTION_STATUS_MARKER} — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
@@ -72,85 +61,6 @@ def _compression_lock_holder(agent: Any) -> str:
|
||||
)
|
||||
|
||||
|
||||
class _CompressionLockLeaseRefresher:
|
||||
def __init__(
|
||||
self,
|
||||
db: Any,
|
||||
session_id: str,
|
||||
holder: str,
|
||||
ttl_seconds: float,
|
||||
refresh_interval_seconds: float | None = None,
|
||||
) -> None:
|
||||
self._db = db
|
||||
self._session_id = session_id
|
||||
self._holder = holder
|
||||
self._ttl_seconds = ttl_seconds
|
||||
if refresh_interval_seconds is None:
|
||||
refresh_interval_seconds = max(1.0, min(60.0, ttl_seconds / 2.0))
|
||||
self._refresh_interval_seconds = max(0.1, float(refresh_interval_seconds))
|
||||
# Tolerate transient refresh failures for at most one lease's worth of
|
||||
# time, so the give-up window is genuinely bounded by the TTL the
|
||||
# acquirer set (a single blip recovers on the next tick; a persistent
|
||||
# failure stops before the lease could outlive its TTL). Floor of 1 so a
|
||||
# degenerate interval >= ttl still tolerates one blip.
|
||||
self._max_consecutive_failures = max(
|
||||
1, int(self._ttl_seconds / self._refresh_interval_seconds)
|
||||
)
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name="compression-lock-refresh",
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
def start(self) -> "_CompressionLockLeaseRefresher":
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
# join() may time out while the refresher is mid-UPDATE; that's safe —
|
||||
# it's a daemon thread, and a late refresh on an already-released lock
|
||||
# matches rowcount 0 (a no-op). stop() returning does not guarantee the
|
||||
# thread has fully quiesced, only that we've signalled it and waited
|
||||
# briefly.
|
||||
if self._thread.is_alive() and threading.current_thread() is not self._thread:
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def _run(self) -> None:
|
||||
# A single falsy refresh must NOT permanently kill the lease: a
|
||||
# transient DB blip (write contention escaping _execute_write's retry
|
||||
# budget, a momentary "database is locked") returns False just like a
|
||||
# genuine lost-ownership, but only the latter should stop the loop.
|
||||
# Tolerate consecutive failures for at most one lease's worth of time
|
||||
# (_max_consecutive_failures = ttl / interval), so a one-off blip
|
||||
# recovers on the next tick while the total give-up window stays bounded
|
||||
# by the TTL the acquirer set — the lock can never be held past its TTL
|
||||
# by a stuck refresher.
|
||||
consecutive_failures = 0
|
||||
while not self._stop.wait(self._refresh_interval_seconds):
|
||||
try:
|
||||
refreshed = self._db.refresh_compression_lock(
|
||||
self._session_id,
|
||||
self._holder,
|
||||
ttl_seconds=self._ttl_seconds,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("compression lock refresh raised: %s", exc)
|
||||
refreshed = False
|
||||
if refreshed:
|
||||
consecutive_failures = 0
|
||||
continue
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= self._max_consecutive_failures:
|
||||
logger.debug(
|
||||
"compression lock refresh failed %d times in a row; "
|
||||
"stopping lease refresher for session %s",
|
||||
consecutive_failures, self._session_id,
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
def check_compression_model_feasibility(agent: Any) -> None:
|
||||
"""Warn at session start if the auxiliary compression model's context
|
||||
window is smaller than the main model's compression threshold.
|
||||
@@ -170,7 +80,6 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
try:
|
||||
from agent.auxiliary_client import (
|
||||
_resolve_task_provider_model,
|
||||
_try_configured_fallback_for_unavailable_client,
|
||||
get_text_auxiliary_client,
|
||||
)
|
||||
from agent.model_metadata import (
|
||||
@@ -178,6 +87,10 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
get_model_context_length,
|
||||
)
|
||||
|
||||
client, aux_model = get_text_auxiliary_client(
|
||||
"compression",
|
||||
main_runtime=agent._current_main_runtime(),
|
||||
)
|
||||
# Best-effort aux provider label for the warning message. The
|
||||
# configured provider may be "auto", in which case we fall back
|
||||
# to the client's base_url hostname so the user can still tell
|
||||
@@ -186,19 +99,6 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
_aux_cfg_provider, _, _, _, _ = _resolve_task_provider_model("compression")
|
||||
except Exception:
|
||||
_aux_cfg_provider = ""
|
||||
client, aux_model = get_text_auxiliary_client(
|
||||
"compression",
|
||||
main_runtime=agent._current_main_runtime(),
|
||||
)
|
||||
if client is None or not aux_model:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
|
||||
"compression",
|
||||
_aux_cfg_provider,
|
||||
)
|
||||
if fb_client is not None and fb_model:
|
||||
client, aux_model = fb_client, fb_model
|
||||
if "(" in fb_label and fb_label.endswith(")"):
|
||||
_aux_cfg_provider = fb_label.rsplit("(", 1)[1][:-1]
|
||||
if client is None or not aux_model:
|
||||
if _aux_cfg_provider and _aux_cfg_provider != "auto":
|
||||
msg = (
|
||||
@@ -368,29 +268,6 @@ def replay_compression_warning(agent: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def conversation_history_after_compression(agent: Any, messages: list) -> Optional[list]:
|
||||
"""Return the correct flush baseline after a compression boundary.
|
||||
|
||||
Legacy compression rotates to a fresh child session. That child has not
|
||||
seen the compacted transcript through the normal same-turn flush path yet,
|
||||
so callers must clear ``conversation_history`` to ``None`` and let the next
|
||||
persistence call write the whole compacted list.
|
||||
|
||||
In-place compaction is different: ``archive_and_compact()`` has already
|
||||
soft-archived the previous active rows and inserted ``messages`` as the new
|
||||
active live transcript under the same session id. If the same agent turn
|
||||
continues with ``conversation_history=None``, the identity-based flush path
|
||||
treats those already-persisted compacted dicts as new and appends them a
|
||||
second time, doubling the active context and retriggering compression.
|
||||
|
||||
A shallow copy is intentional: it captures the current compacted dict
|
||||
identities as history while allowing later same-turn appends to remain new.
|
||||
"""
|
||||
if bool(getattr(agent, "_last_compaction_in_place", False)):
|
||||
return list(messages)
|
||||
return None
|
||||
|
||||
|
||||
def compress_context(
|
||||
agent: Any,
|
||||
messages: list,
|
||||
@@ -441,23 +318,15 @@ def compress_context(
|
||||
agent._compression_feasibility_checked = True
|
||||
|
||||
_pre_msg_count = len(messages)
|
||||
# In-place compaction (config: compression.in_place, see #38763). When True,
|
||||
# this compaction rewrites the message list + rebuilds the system prompt but
|
||||
# keeps the SAME session_id — no end_session, no parent_session_id child, no
|
||||
# `name #N` renumber, no contextvar/env/logging re-sync, no memory/context-
|
||||
# engine session-switch. The conversation keeps one durable id for life,
|
||||
# eliminating the session-rotation bug cluster. Default False during rollout.
|
||||
in_place = bool(getattr(agent, "compression_in_place", False))
|
||||
# Set True once the in-place DB write actually completes (the DB block can
|
||||
# raise and skip it). Surfaced to the gateway via agent._last_compaction_in_place.
|
||||
compacted_in_place = False
|
||||
logger.info(
|
||||
"context compression started: session=%s messages=%d tokens=~%s model=%s focus=%r",
|
||||
agent.session_id or "none", _pre_msg_count,
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model,
|
||||
focus_topic,
|
||||
)
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
agent._emit_status(
|
||||
"🗜️ Compacting context — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
|
||||
# ── Compression lock ────────────────────────────────────────────────
|
||||
# Atomic, state.db-backed lock per session_id. Without this, two
|
||||
@@ -500,17 +369,11 @@ def compress_context(
|
||||
# and proceed with compression. Skipping the lock risks a rare
|
||||
# concurrent-compression session fork; an infinite no-progress loop
|
||||
# that never compresses at all is strictly worse.
|
||||
try:
|
||||
_lock_ttl = float(getattr(agent, "_compression_lock_ttl_seconds", 300.0) or 300.0)
|
||||
except (TypeError, ValueError):
|
||||
_lock_ttl = 300.0
|
||||
_lock_refresh_interval = getattr(agent, "_compression_lock_refresh_interval", None)
|
||||
_lock_refresher: Optional[_CompressionLockLeaseRefresher] = None
|
||||
if _lock_db is not None and _lock_sid:
|
||||
_lock_holder = _compression_lock_holder(agent)
|
||||
try:
|
||||
_lock_acquired = _lock_db.try_acquire_compression_lock(
|
||||
_lock_sid, _lock_holder, ttl_seconds=_lock_ttl
|
||||
_lock_sid, _lock_holder
|
||||
)
|
||||
except Exception as _lock_err:
|
||||
# Broken/absent lock subsystem (version skew, etc.). Log once
|
||||
@@ -553,19 +416,9 @@ def compress_context(
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
return messages, _existing_sp
|
||||
if _lock_holder is not None:
|
||||
_lock_refresher = _CompressionLockLeaseRefresher(
|
||||
_lock_db,
|
||||
_lock_sid,
|
||||
_lock_holder,
|
||||
_lock_ttl,
|
||||
_lock_refresh_interval,
|
||||
).start()
|
||||
|
||||
def _release_lock() -> None:
|
||||
"""Release the lock keyed on the OLD session_id (before rotation)."""
|
||||
if _lock_refresher is not None:
|
||||
_lock_refresher.stop()
|
||||
if _lock_db is not None and _lock_sid and _lock_holder:
|
||||
try:
|
||||
_lock_db.release_compression_lock(_lock_sid, _lock_holder)
|
||||
@@ -584,11 +437,7 @@ def compress_context(
|
||||
except TypeError:
|
||||
# Plugin context engine with strict signature that doesn't accept
|
||||
# focus_topic / force — fall back to calling without them.
|
||||
try:
|
||||
compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens)
|
||||
except BaseException:
|
||||
_release_lock()
|
||||
raise
|
||||
compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens)
|
||||
except BaseException:
|
||||
# ANY exception during compress() must release the lock so the
|
||||
# session isn't permanently blocked from future compression.
|
||||
@@ -601,339 +450,188 @@ def compress_context(
|
||||
# session has logically ended), and let auto-compress callers detect
|
||||
# the no-op via len(returned) == len(input).
|
||||
if getattr(agent.context_compressor, "_last_compress_aborted", False):
|
||||
try:
|
||||
_err = getattr(agent.context_compressor, "_last_summary_error", None) or "unknown error"
|
||||
if getattr(agent, "_last_compression_summary_warning", None) != _err:
|
||||
agent._last_compression_summary_warning = _err
|
||||
agent._emit_warning(
|
||||
f"⚠ Compression aborted: {_err}. "
|
||||
"No messages were dropped — conversation continues unchanged. "
|
||||
"Run /compress to retry, or /new to start a fresh session."
|
||||
)
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
return messages, _existing_sp
|
||||
finally:
|
||||
_release_lock()
|
||||
|
||||
try:
|
||||
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
|
||||
if summary_error:
|
||||
if getattr(agent, "_last_compression_summary_warning", None) != summary_error:
|
||||
agent._last_compression_summary_warning = summary_error
|
||||
agent._emit_warning(
|
||||
f"⚠ Compression summary failed: {summary_error}. "
|
||||
"Inserted a fallback context marker."
|
||||
)
|
||||
else:
|
||||
# No hard failure — but did the configured aux model error out
|
||||
# and get recovered by retrying on main? Surface that so users
|
||||
# know their auxiliary.compression.model setting is broken even
|
||||
# though compression succeeded.
|
||||
_aux_fail_model = getattr(agent.context_compressor, "_last_aux_model_failure_model", None)
|
||||
_aux_fail_err = getattr(agent.context_compressor, "_last_aux_model_failure_error", None)
|
||||
if _aux_fail_model:
|
||||
# Dedup on (model, error) so we don't spam on every compaction
|
||||
_aux_key = (_aux_fail_model, _aux_fail_err)
|
||||
if getattr(agent, "_last_aux_fallback_warning_key", None) != _aux_key:
|
||||
agent._last_aux_fallback_warning_key = _aux_key
|
||||
agent._emit_warning(
|
||||
f"ℹ Configured compression model '{_aux_fail_model}' failed "
|
||||
f"({_aux_fail_err or 'unknown error'}). Recovered using main model — "
|
||||
"check auxiliary.compression.model in config.yaml."
|
||||
)
|
||||
|
||||
todo_snapshot = agent._todo_store.format_for_injection()
|
||||
if todo_snapshot:
|
||||
compressed.append({"role": "user", "content": todo_snapshot})
|
||||
|
||||
agent._invalidate_system_prompt()
|
||||
new_system_prompt = agent._build_system_prompt(system_message)
|
||||
agent._cached_system_prompt = new_system_prompt
|
||||
|
||||
if agent._session_db:
|
||||
try:
|
||||
# Trigger memory extraction on the current session before the
|
||||
# transcript is rewritten (runs in BOTH modes — the logical
|
||||
# conversation's pre-compaction turns are about to be summarized
|
||||
# away regardless of whether the id rotates).
|
||||
agent.commit_memory_session(messages)
|
||||
|
||||
if in_place:
|
||||
# ── In-place compaction: keep the same session_id ──────────
|
||||
# No end_session, no new row, no parent_session_id, no title
|
||||
# renumber, no contextvar/env/logging re-sync. The session's
|
||||
# id, title, cwd, /goal, and gateway routing all stay put.
|
||||
#
|
||||
# Durable, NON-DESTRUCTIVE replace: soft-archive the
|
||||
# pre-compaction turns (active=0, kept on disk + FTS-searchable +
|
||||
# recoverable) and insert `compressed` as the new live (active=1)
|
||||
# set, atomically. `compressed` already carries the surviving
|
||||
# tail (current-turn messages the compressor kept via
|
||||
# protect_last_n), so we DON'T pre-flush here — a flush would
|
||||
# INSERT current-turn rows that archive_and_compact would then
|
||||
# archive alongside the rest (harmless but wasted writes). The
|
||||
# live-context load filters active=1, so a resume reloads ONLY
|
||||
# the compacted set; the original turns remain under the SAME id
|
||||
# for search/recovery (Teknium review — keep one durable id
|
||||
# WITHOUT destroying history, unlike a hard replace_messages).
|
||||
# See #38763.
|
||||
agent._session_db.archive_and_compact(agent.session_id, compressed)
|
||||
# Reset the flush identity set so the next turn's appends are
|
||||
# diffed against the COMPACTED transcript: the compacted dicts
|
||||
# are passed as conversation_history next turn and skipped by
|
||||
# identity, so only genuinely new turn messages get appended
|
||||
# (no dup of the summary, no resurrection of dropped turns).
|
||||
agent._flushed_db_message_ids = set()
|
||||
# Rotation-independent signal: the conversation was compacted in
|
||||
# place (id unchanged). The gateway reads this (NOT an id-change
|
||||
# diff) to re-baseline transcript handling.
|
||||
compacted_in_place = True
|
||||
else:
|
||||
# ── Rotation (legacy): end this session, fork a continuation ─
|
||||
# Flush any un-persisted current-turn messages to the OLD
|
||||
# session before ending it, so they survive in the preserved
|
||||
# parent transcript (#47202). (In-place skips this — see above.)
|
||||
try:
|
||||
agent._flush_messages_to_session_db(messages)
|
||||
except Exception:
|
||||
pass # best-effort — don't block compression on a flush error
|
||||
# Propagate title to the new session with auto-numbering
|
||||
old_title = agent._session_db.get_session_title(agent.session_id)
|
||||
agent._session_db.end_session(agent.session_id, "compression")
|
||||
old_session_id = agent.session_id
|
||||
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||
# Ordering contract: the agent thread updates the contextvar here;
|
||||
# the gateway propagates to SessionEntry after run_in_executor returns.
|
||||
try:
|
||||
from gateway.session_context import set_current_session_id
|
||||
|
||||
set_current_session_id(agent.session_id)
|
||||
except Exception:
|
||||
os.environ["HERMES_SESSION_ID"] = agent.session_id
|
||||
# The gateway/tools session context (ContextVar + env) and the
|
||||
# logging session context are SEPARATE mechanisms. The call above
|
||||
# moves the former; the ``[session_id]`` tag on log lines comes
|
||||
# from ``hermes_logging._session_context`` (set once per turn in
|
||||
# conversation_loop.py). Without this, post-rotation log lines in
|
||||
# the same turn keep the STALE old id while the message/DB/gateway
|
||||
# state carry the new one — breaking log correlation exactly at the
|
||||
# compaction boundary (see #34089). Guarded separately so a logging
|
||||
# failure can never regress the routing update above.
|
||||
try:
|
||||
from hermes_logging import set_session_context
|
||||
|
||||
set_session_context(agent.session_id)
|
||||
except Exception:
|
||||
pass
|
||||
agent._session_db_created = False
|
||||
try:
|
||||
agent._session_db.create_session(
|
||||
session_id=agent.session_id,
|
||||
source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
|
||||
model=agent.model,
|
||||
model_config=agent._session_init_model_config,
|
||||
parent_session_id=old_session_id,
|
||||
)
|
||||
except Exception as _cs_err:
|
||||
# The child row could not be created (e.g. FK constraint,
|
||||
# contended write). Previously the outer handler simply
|
||||
# warned and let the agent continue on the NEW id — which
|
||||
# has no row in state.db, producing an orphan: the parent
|
||||
# is ended, the child is never indexed, and every
|
||||
# subsequent message is attributed to a session that
|
||||
# doesn't exist (#33906/#33907). Roll the live id back to
|
||||
# the parent so the conversation stays attached to a real,
|
||||
# indexed session instead of a phantom.
|
||||
logger.warning(
|
||||
"Compression child session create failed (%s) — "
|
||||
"rolling back to parent session %s to avoid an orphan.",
|
||||
_cs_err, old_session_id,
|
||||
)
|
||||
agent.session_id = old_session_id
|
||||
try:
|
||||
from gateway.session_context import set_current_session_id
|
||||
set_current_session_id(agent.session_id)
|
||||
except Exception:
|
||||
os.environ["HERMES_SESSION_ID"] = agent.session_id
|
||||
try:
|
||||
from hermes_logging import set_session_context
|
||||
set_session_context(agent.session_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Re-open the parent: it was ended above, but we're
|
||||
# continuing on it, so it must not stay closed.
|
||||
try:
|
||||
agent._session_db.reopen_session(old_session_id)
|
||||
except Exception:
|
||||
pass
|
||||
old_session_id = None # no rotation happened
|
||||
# The parent row already exists in state.db, so mark the
|
||||
# session as created — _ensure_db_session would otherwise
|
||||
# retry a (harmless INSERT OR IGNORE) create next turn.
|
||||
agent._session_db_created = True
|
||||
raise
|
||||
agent._session_db_created = True
|
||||
# Carry a persistent /goal onto the continuation session.
|
||||
# Compression mints a fresh child id; load_goal does a flat
|
||||
# per-session lookup with no parent walk, so without this an
|
||||
# active goal silently dies at the boundary (#33618).
|
||||
try:
|
||||
from hermes_cli.goals import migrate_goal_to_session
|
||||
migrate_goal_to_session(old_session_id, agent.session_id, reason="compression")
|
||||
except Exception as _goal_err:
|
||||
logger.debug("Could not migrate goal on compression: %s", _goal_err)
|
||||
# Auto-number the title for the continuation session
|
||||
if old_title:
|
||||
try:
|
||||
new_title = agent._session_db.get_next_title_in_lineage(old_title)
|
||||
agent._session_db.set_session_title(agent.session_id, new_title)
|
||||
except (ValueError, Exception) as e:
|
||||
logger.debug("Could not propagate title on compression: %s", e)
|
||||
|
||||
# Shared post-write steps (both modes target agent.session_id, which
|
||||
# in-place keeps and rotation has already reassigned to the new id):
|
||||
# refresh the stored system prompt and reset the flush cursor so the
|
||||
# next turn re-bases its append diff.
|
||||
agent._session_db.update_system_prompt(agent.session_id, new_system_prompt)
|
||||
agent._last_flushed_db_idx = 0
|
||||
except Exception as e:
|
||||
# If the rotation rolled back to the parent (orphan-avoidance
|
||||
# above), agent.session_id is the still-indexed parent and
|
||||
# old_session_id was cleared — so this is recovery, not an
|
||||
# un-indexed orphan. Otherwise an earlier step failed before the
|
||||
# child was created and the warning's original meaning holds.
|
||||
if locals().get("old_session_id") is None and not in_place:
|
||||
logger.warning(
|
||||
"Compression rotation aborted and rolled back to the "
|
||||
"parent session (%s): %s", agent.session_id or "?", e,
|
||||
)
|
||||
else:
|
||||
logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
|
||||
|
||||
# Compaction-boundary bookkeeping, computed once. `old_session_id` is only
|
||||
# bound in the rotation branch; in-place leaves it unset. `_boundary_parent`
|
||||
# is the id the boundary notifications attribute the prior state to: the old
|
||||
# id on rotation, the (unchanged) current id in-place.
|
||||
_old_sid = locals().get("old_session_id")
|
||||
_is_boundary = bool(_old_sid) or in_place
|
||||
_boundary_parent = _old_sid or agent.session_id or ""
|
||||
|
||||
# Notify the context engine that a compaction boundary occurred. Plugin
|
||||
# engines (e.g. hermes-lcm) use boundary_reason="compression" to preserve
|
||||
# DAG lineage / checkpoint per-session state across the boundary instead of
|
||||
# re-initializing fresh. See hermes-lcm#68. Built-in ContextCompressor
|
||||
# ignores kwargs. Fires in BOTH modes: rotation passes old→new ids; in-place
|
||||
# passes the SAME id (the boundary is real even though the id didn't move).
|
||||
try:
|
||||
if _is_boundary and hasattr(agent.context_compressor, "on_session_start"):
|
||||
agent.context_compressor.on_session_start(
|
||||
agent.session_id or "",
|
||||
boundary_reason="compression",
|
||||
old_session_id=_boundary_parent,
|
||||
platform=getattr(agent, "platform", None) or "cli",
|
||||
conversation_id=getattr(agent, "_gateway_session_key", None),
|
||||
)
|
||||
except Exception as _ce_err:
|
||||
logger.debug("context engine on_session_start (compression): %s", _ce_err)
|
||||
|
||||
# Notify memory providers of the compaction boundary so provider-cached
|
||||
# per-session state (Hindsight's _document_id, accumulated turn buffers,
|
||||
# counters) refreshes. reset=False because the logical conversation
|
||||
# continues. See #6672. Fires in BOTH modes: in-place uses the same id as
|
||||
# parent (the conversation didn't fork, but the buffer must still be told
|
||||
# the transcript was compacted so it doesn't double-count dropped turns).
|
||||
try:
|
||||
if _is_boundary and agent._memory_manager:
|
||||
agent._memory_manager.on_session_switch(
|
||||
agent.session_id or "",
|
||||
parent_session_id=_boundary_parent,
|
||||
reset=False,
|
||||
reason="compression",
|
||||
)
|
||||
except Exception as _me_err:
|
||||
logger.debug("memory manager on_session_switch (compression): %s", _me_err)
|
||||
|
||||
# Warn on repeated compressions (quality degrades with each pass).
|
||||
# Route through _emit_status (like the other compression warnings above)
|
||||
# so the warning reaches the TUI / Telegram / Discord via status_callback,
|
||||
# not just CLI stdout. _emit_status still _vprints for the CLI, and
|
||||
# storing it on _compression_warning lets replay_compression_warning
|
||||
# re-deliver it once a late-bound gateway status_callback is wired (#36908).
|
||||
_cc = agent.context_compressor.compression_count
|
||||
if _cc >= 2:
|
||||
_cc_msg = (
|
||||
f"{agent.log_prefix}⚠️ Session compressed {_cc} times — "
|
||||
f"accuracy may degrade. Consider /new to start fresh."
|
||||
_err = getattr(agent.context_compressor, "_last_summary_error", None) or "unknown error"
|
||||
if getattr(agent, "_last_compression_summary_warning", None) != _err:
|
||||
agent._last_compression_summary_warning = _err
|
||||
agent._emit_warning(
|
||||
f"⚠ Compression aborted: {_err}. "
|
||||
"No messages were dropped — conversation continues unchanged. "
|
||||
"Run /compress to retry, or /new to start a fresh session."
|
||||
)
|
||||
agent._compression_warning = _cc_msg
|
||||
agent._emit_status(_cc_msg)
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
_release_lock() # compression aborted — no rotation will happen
|
||||
return messages, _existing_sp
|
||||
|
||||
# Emit session:compress event so hooks (e.g. MemPalace sync) can ingest
|
||||
# the completed old session before its details are lost. In in-place mode
|
||||
# there is no old id (same session); ``in_place=True`` tells hooks the
|
||||
# transcript was compacted on the same id rather than rotated.
|
||||
if getattr(agent, "event_callback", None):
|
||||
try:
|
||||
agent.event_callback("session:compress", {
|
||||
"platform": agent.platform or "",
|
||||
"session_id": agent.session_id,
|
||||
"old_session_id": _old_sid or "",
|
||||
"in_place": in_place,
|
||||
"compression_count": agent.context_compressor.compression_count,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("event_callback error on session:compress: %s", e)
|
||||
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
|
||||
if summary_error:
|
||||
if getattr(agent, "_last_compression_summary_warning", None) != summary_error:
|
||||
agent._last_compression_summary_warning = summary_error
|
||||
agent._emit_warning(
|
||||
f"⚠ Compression summary failed: {summary_error}. "
|
||||
"Inserted a fallback context marker."
|
||||
)
|
||||
else:
|
||||
# No hard failure — but did the configured aux model error out
|
||||
# and get recovered by retrying on main? Surface that so users
|
||||
# know their auxiliary.compression.model setting is broken even
|
||||
# though compression succeeded.
|
||||
_aux_fail_model = getattr(agent.context_compressor, "_last_aux_model_failure_model", None)
|
||||
_aux_fail_err = getattr(agent.context_compressor, "_last_aux_model_failure_error", None)
|
||||
if _aux_fail_model:
|
||||
# Dedup on (model, error) so we don't spam on every compaction
|
||||
_aux_key = (_aux_fail_model, _aux_fail_err)
|
||||
if getattr(agent, "_last_aux_fallback_warning_key", None) != _aux_key:
|
||||
agent._last_aux_fallback_warning_key = _aux_key
|
||||
agent._emit_warning(
|
||||
f"ℹ Configured compression model '{_aux_fail_model}' failed "
|
||||
f"({_aux_fail_err or 'unknown error'}). Recovered using main model — "
|
||||
"check auxiliary.compression.model in config.yaml."
|
||||
)
|
||||
|
||||
# Surface the compaction mode to the caller (run_conversation / gateway)
|
||||
# via a rotation-independent flag. The gateway uses this — NOT an
|
||||
# id-change diff — to re-baseline transcript handling (history_offset=0 +
|
||||
# rewrite on the same id) when compaction happened in place. See #38763.
|
||||
agent._last_compaction_in_place = compacted_in_place
|
||||
todo_snapshot = agent._todo_store.format_for_injection()
|
||||
if todo_snapshot:
|
||||
compressed.append({"role": "user", "content": todo_snapshot})
|
||||
|
||||
# Keep the post-compression rough estimate for diagnostics, but do not
|
||||
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
|
||||
# can remain above threshold even after the next real API request fits.
|
||||
_compressed_est = estimate_request_tokens_rough(
|
||||
compressed,
|
||||
system_prompt=new_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
agent.context_compressor.last_compression_rough_tokens = _compressed_est
|
||||
agent.context_compressor.last_prompt_tokens = -1
|
||||
agent.context_compressor.last_completion_tokens = 0
|
||||
agent.context_compressor.awaiting_real_usage_after_compression = True
|
||||
agent._invalidate_system_prompt()
|
||||
new_system_prompt = agent._build_system_prompt(system_message)
|
||||
agent._cached_system_prompt = new_system_prompt
|
||||
|
||||
# Clear the file-read dedup cache. After compression the original
|
||||
# read content is summarised away — if the model re-reads the same
|
||||
# file it needs the full content, not a "file unchanged" stub.
|
||||
if agent._session_db:
|
||||
try:
|
||||
from tools.file_tools import reset_file_dedup
|
||||
reset_file_dedup(task_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Propagate title to the new session with auto-numbering
|
||||
old_title = agent._session_db.get_session_title(agent.session_id)
|
||||
# Trigger memory extraction on the old session before it rotates.
|
||||
agent.commit_memory_session(messages)
|
||||
agent._session_db.end_session(agent.session_id, "compression")
|
||||
old_session_id = agent.session_id
|
||||
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||
# Ordering contract: the agent thread updates the contextvar here;
|
||||
# the gateway propagates to SessionEntry after run_in_executor returns.
|
||||
try:
|
||||
from gateway.session_context import set_current_session_id
|
||||
|
||||
logger.info(
|
||||
"context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true",
|
||||
agent.session_id or "none", _pre_msg_count, len(compressed),
|
||||
f"{_compressed_est:,}",
|
||||
set_current_session_id(agent.session_id)
|
||||
except Exception:
|
||||
os.environ["HERMES_SESSION_ID"] = agent.session_id
|
||||
# The gateway/tools session context (ContextVar + env) and the
|
||||
# logging session context are SEPARATE mechanisms. The call above
|
||||
# moves the former; the ``[session_id]`` tag on log lines comes
|
||||
# from ``hermes_logging._session_context`` (set once per turn in
|
||||
# conversation_loop.py). Without this, post-rotation log lines in
|
||||
# the same turn keep the STALE old id while the message/DB/gateway
|
||||
# state carry the new one — breaking log correlation exactly at the
|
||||
# compaction boundary (see #34089). Guarded separately so a logging
|
||||
# failure can never regress the routing update above.
|
||||
try:
|
||||
from hermes_logging import set_session_context
|
||||
|
||||
set_session_context(agent.session_id)
|
||||
except Exception:
|
||||
pass
|
||||
agent._session_db_created = False
|
||||
agent._session_db.create_session(
|
||||
session_id=agent.session_id,
|
||||
source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
|
||||
model=agent.model,
|
||||
model_config=agent._session_init_model_config,
|
||||
parent_session_id=old_session_id,
|
||||
)
|
||||
agent._session_db_created = True
|
||||
# Auto-number the title for the continuation session
|
||||
if old_title:
|
||||
try:
|
||||
new_title = agent._session_db.get_next_title_in_lineage(old_title)
|
||||
agent._session_db.set_session_title(agent.session_id, new_title)
|
||||
except (ValueError, Exception) as e:
|
||||
logger.debug("Could not propagate title on compression: %s", e)
|
||||
agent._session_db.update_system_prompt(agent.session_id, new_system_prompt)
|
||||
# Reset flush cursor — new session starts with no messages written
|
||||
agent._last_flushed_db_idx = 0
|
||||
except Exception as e:
|
||||
logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
|
||||
|
||||
# Notify the context engine that the session_id rotated because of
|
||||
# compression (not a fresh /new). Plugin engines (e.g. hermes-lcm) use
|
||||
# boundary_reason="compression" to preserve DAG lineage across the
|
||||
# rollover instead of re-initializing fresh per-session state.
|
||||
# See hermes-lcm#68. Built-in ContextCompressor ignores kwargs.
|
||||
try:
|
||||
_old_sid = locals().get("old_session_id")
|
||||
if _old_sid and hasattr(agent.context_compressor, "on_session_start"):
|
||||
agent.context_compressor.on_session_start(
|
||||
agent.session_id or "",
|
||||
boundary_reason="compression",
|
||||
old_session_id=_old_sid,
|
||||
conversation_id=getattr(agent, "_gateway_session_key", None),
|
||||
)
|
||||
except Exception as _ce_err:
|
||||
logger.debug("context engine on_session_start (compression): %s", _ce_err)
|
||||
|
||||
# Notify memory providers of the compression-driven session_id rotation
|
||||
# so provider-cached per-session state (Hindsight's _document_id,
|
||||
# accumulated turn buffers, counters) refreshes. reset=False because
|
||||
# the logical conversation continues; only the id and DB row rolled
|
||||
# over. See #6672.
|
||||
try:
|
||||
_old_sid = locals().get("old_session_id")
|
||||
if _old_sid and agent._memory_manager:
|
||||
agent._memory_manager.on_session_switch(
|
||||
agent.session_id or "",
|
||||
parent_session_id=_old_sid,
|
||||
reset=False,
|
||||
reason="compression",
|
||||
)
|
||||
except Exception as _me_err:
|
||||
logger.debug("memory manager on_session_switch (compression): %s", _me_err)
|
||||
|
||||
# Warn on repeated compressions (quality degrades with each pass)
|
||||
_cc = agent.context_compressor.compression_count
|
||||
if _cc >= 2:
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Session compressed {_cc} times — "
|
||||
f"accuracy may degrade. Consider /new to start fresh.",
|
||||
force=True,
|
||||
)
|
||||
return compressed, new_system_prompt
|
||||
finally:
|
||||
# Release the lock on the OLD session_id only AFTER rotation completed
|
||||
# and all post-rotation bookkeeping (memory manager, context engine,
|
||||
# file dedup) ran. A concurrent path that wakes up the moment we
|
||||
# release will see the NEW session_id in state.db / SessionEntry and
|
||||
# acquire on that — no race against our just-finished work.
|
||||
_release_lock()
|
||||
|
||||
# Keep the post-compression rough estimate for diagnostics, but do not
|
||||
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
|
||||
# can remain above threshold even after the next real API request fits.
|
||||
_compressed_est = estimate_request_tokens_rough(
|
||||
compressed,
|
||||
system_prompt=new_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
agent.context_compressor.last_compression_rough_tokens = _compressed_est
|
||||
agent.context_compressor.last_prompt_tokens = -1
|
||||
agent.context_compressor.last_completion_tokens = 0
|
||||
agent.context_compressor.awaiting_real_usage_after_compression = True
|
||||
|
||||
# Clear the file-read dedup cache. After compression the original
|
||||
# read content is summarised away — if the model re-reads the same
|
||||
# file it needs the full content, not a "file unchanged" stub.
|
||||
try:
|
||||
from tools.file_tools import reset_file_dedup
|
||||
reset_file_dedup(task_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true",
|
||||
agent.session_id or "none", _pre_msg_count, len(compressed),
|
||||
f"{_compressed_est:,}",
|
||||
)
|
||||
# Release the lock on the OLD session_id only AFTER rotation completed
|
||||
# and all post-rotation bookkeeping (memory manager, context engine,
|
||||
# file dedup) ran. A concurrent path that wakes up the moment we
|
||||
# release will see the NEW session_id in state.db / SessionEntry and
|
||||
# acquire on that — no race against our just-finished work.
|
||||
_release_lock()
|
||||
return compressed, new_system_prompt
|
||||
|
||||
|
||||
def try_shrink_image_parts_in_messages(
|
||||
api_messages: list,
|
||||
*,
|
||||
max_dimension: int = 8000,
|
||||
) -> bool:
|
||||
def try_shrink_image_parts_in_messages(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).
|
||||
|
||||
@@ -942,11 +640,9 @@ def try_shrink_image_parts_in_messages(
|
||||
Pillow couldn't help (caller should surface the original error).
|
||||
|
||||
Strategy: look for ``image_url`` / ``input_image`` parts carrying a
|
||||
``data:image/...;base64,...`` payload, plus Anthropic-native
|
||||
``{"type": "image", "source": {"type": "base64", ...}}`` blocks.
|
||||
For each one whose encoded size exceeds 4 MB (a safe target that slides
|
||||
under Anthropic's 5 MB ceiling with header overhead) or whose longest side
|
||||
exceeds ``max_dimension``, write the base64 to a tempfile, call
|
||||
``data:image/...;base64,...`` payload. For each one whose encoded
|
||||
size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
|
||||
ceiling with header overhead), write the base64 to a tempfile, call
|
||||
``vision_tools._resize_image_for_vision`` to produce a smaller data
|
||||
URL, and substitute it in place.
|
||||
|
||||
@@ -968,9 +664,10 @@ def try_shrink_image_parts_in_messages(
|
||||
# after a confirmed provider rejection, so the alternative is failure.
|
||||
target_bytes = 4 * 1024 * 1024
|
||||
# Anthropic enforces an 8000px per-side dimension cap independently of
|
||||
# the 5 MB byte cap. In many-image requests, the provider can report a
|
||||
# lower cap (observed: 2000px). The caller passes that parsed ceiling
|
||||
# when the rejection includes it.
|
||||
# the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far
|
||||
# over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions
|
||||
# even when the byte budget is fine.
|
||||
max_dimension = 8000
|
||||
changed_count = 0
|
||||
# Track parts that are over the target but could NOT be shrunk under it.
|
||||
# If any survive, retrying is pointless — the same oversized payload will
|
||||
@@ -979,58 +676,33 @@ def try_shrink_image_parts_in_messages(
|
||||
# actually brought under the target.
|
||||
unshrinkable_oversized = 0
|
||||
|
||||
def _decode_pixels(data_url: str) -> Optional[tuple]:
|
||||
"""Return ``(width, height)`` of a base64 data URL, or None on failure.
|
||||
|
||||
Soft-depends on Pillow; returns None (caller falls back to a
|
||||
bytes-only check) if Pillow is missing or the payload is corrupt.
|
||||
"""
|
||||
try:
|
||||
import base64 as _b64_dim
|
||||
import io as _io_dim
|
||||
header_d, _, data_d = data_url.partition(",")
|
||||
if not data_d or not data_url.startswith("data:"):
|
||||
return None
|
||||
from PIL import Image as _PILImage
|
||||
with _PILImage.open(_io_dim.BytesIO(_b64_dim.b64decode(data_d))) as _img:
|
||||
return _img.size
|
||||
except Exception:
|
||||
def _shrink_data_url(url: str) -> Optional[str]:
|
||||
"""Return a smaller data URL, or None if shrink can't help."""
|
||||
if not isinstance(url, str) or not url.startswith("data:"):
|
||||
return None
|
||||
|
||||
def _shrink_data_url(url: str) -> tuple:
|
||||
"""Return ``(resized_url, unshrinkable)`` for a data URL.
|
||||
|
||||
``resized_url`` is a smaller/dimension-correct data URL, or None when
|
||||
no rewrite was applied. ``unshrinkable`` is True only when the image
|
||||
exceeded a constraint (byte-size or dimensions) and the resize failed
|
||||
to satisfy *that same* constraint — so the caller knows retrying is
|
||||
pointless even if a different image in the request shrank.
|
||||
"""
|
||||
if not isinstance(url, str) or not url.startswith("data:"):
|
||||
return None, False
|
||||
|
||||
# Determine which constraint is binding. The accept/reject gate below
|
||||
# MUST be checked against the same axis that triggered the shrink: a
|
||||
# downscaled screenshot PNG routinely re-encodes to *more* bytes than
|
||||
# the original (PNG compression is non-monotonic in image size — a
|
||||
# smaller raster with LANCZOS resampling noise compresses worse than a
|
||||
# larger smooth one). Rejecting a pixel-correct downscale purely
|
||||
# because its bytes grew permanently wedges sessions on the Anthropic
|
||||
# many-image 2000px path (#48013).
|
||||
# Check both byte size AND pixel dimensions.
|
||||
needs_shrink = len(url) > target_bytes # over byte budget
|
||||
triggered_by = "bytes" if needs_shrink else None
|
||||
if not needs_shrink:
|
||||
# Bytes are fine — check pixel dimensions against the provider's
|
||||
# reported per-side cap. A screenshot can be tiny in bytes yet
|
||||
# too large in pixels.
|
||||
dims = _decode_pixels(url)
|
||||
if dims is None:
|
||||
# Pillow missing or corrupt data — fall back to byte-only.
|
||||
return None, False
|
||||
if max(dims) <= max_dimension:
|
||||
return None, False # both bytes and pixels are within limits
|
||||
needs_shrink = True
|
||||
triggered_by = "dimension"
|
||||
# Even if bytes are fine, check pixel dimensions against
|
||||
# Anthropic's 8000px cap. A tall image can be tiny in bytes
|
||||
# yet huge in pixels.
|
||||
try:
|
||||
import base64 as _b64_dim
|
||||
header_d, _, data_d = url.partition(",")
|
||||
if not data_d:
|
||||
return None
|
||||
raw_d = _b64_dim.b64decode(data_d)
|
||||
from PIL import Image as _PILImage
|
||||
import io as _io_dim
|
||||
with _PILImage.open(_io_dim.BytesIO(raw_d)) as _img:
|
||||
if max(_img.size) <= max_dimension:
|
||||
return None # both bytes and pixels are fine
|
||||
needs_shrink = True # pixels exceed limit, force shrink
|
||||
except Exception:
|
||||
# If we can't check dimensions (Pillow unavailable, corrupt
|
||||
# image, etc.), fall back to byte-only check.
|
||||
return None
|
||||
|
||||
try:
|
||||
header, _, data = url.partition(",")
|
||||
@@ -1062,67 +734,13 @@ def try_shrink_image_parts_in_messages(
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if not resized:
|
||||
# Resize returned nothing — Pillow couldn't help.
|
||||
return None, True
|
||||
if triggered_by == "bytes":
|
||||
# Byte budget is the binding constraint — bytes must shrink.
|
||||
if len(resized) >= len(url):
|
||||
return None, True # re-encode made it bigger
|
||||
# The per-side dimension cap is ALSO an active provider
|
||||
# constraint on this request (the caller passes the parsed cap
|
||||
# to both this helper and the resizer). _resize_image_for_vision
|
||||
# returns a best-effort, possibly-over-cap blob when it
|
||||
# exhausts its halving budget — it freezes the long side once
|
||||
# the short side hits its 64px floor, so a very-high-aspect
|
||||
# image can stay over the cap even after bytes shrank. If the
|
||||
# output is still over the cap, retrying would re-400 on
|
||||
# dimensions; treat it as unshrinkable. (Skip when dims can't
|
||||
# be decoded — preserves historical byte-only behaviour.)
|
||||
new_dims = _decode_pixels(resized)
|
||||
if new_dims is not None and max(new_dims) > max_dimension:
|
||||
return None, True
|
||||
return resized, False
|
||||
# triggered_by == "dimension": the per-side cap is binding. The
|
||||
# re-encode may have grown in bytes; accept it as long as it is now
|
||||
# within the dimension cap. Verify the new dimensions when we can.
|
||||
new_dims = _decode_pixels(resized)
|
||||
if new_dims is not None:
|
||||
if max(new_dims) <= max_dimension:
|
||||
return resized, False
|
||||
# Still over the per-side cap — the resize didn't satisfy it.
|
||||
return None, True
|
||||
# Couldn't verify the re-encode's dimensions (corrupt output or
|
||||
# Pillow gone mid-call). Fall back to the historical "bytes must
|
||||
# shrink" gate so we never accept an unverifiable, byte-larger blob.
|
||||
if len(resized) >= len(url):
|
||||
return None, True
|
||||
return resized, False
|
||||
if not resized or len(resized) >= len(url):
|
||||
# Shrink didn't help (or made it bigger — corrupt input?).
|
||||
return None
|
||||
return resized
|
||||
except Exception as exc:
|
||||
logger.warning("image-shrink recovery: re-encode failed — %s", exc)
|
||||
return None, triggered_by is not None
|
||||
|
||||
def _source_to_data_url(source: Any) -> Optional[str]:
|
||||
if not isinstance(source, dict) or source.get("type") != "base64":
|
||||
return None
|
||||
data = source.get("data")
|
||||
if not isinstance(data, str) or not data:
|
||||
return None
|
||||
media_type = str(source.get("media_type") or "image/jpeg").strip()
|
||||
if not media_type.startswith("image/"):
|
||||
media_type = "image/jpeg"
|
||||
return f"data:{media_type};base64,{data}"
|
||||
|
||||
def _write_data_url_to_source(source: dict, data_url: str) -> None:
|
||||
header, _, data = data_url.partition(",")
|
||||
media_type = "image/jpeg"
|
||||
if header.startswith("data:"):
|
||||
candidate = header[len("data:"):].split(";", 1)[0].strip()
|
||||
if candidate.startswith("image/"):
|
||||
media_type = candidate
|
||||
source["type"] = "base64"
|
||||
source["media_type"] = media_type
|
||||
source["data"] = data
|
||||
|
||||
for msg in api_messages:
|
||||
if not isinstance(msg, dict):
|
||||
@@ -1134,16 +752,6 @@ def try_shrink_image_parts_in_messages(
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
ptype = part.get("type")
|
||||
if ptype == "image":
|
||||
source = part.get("source")
|
||||
url = _source_to_data_url(source)
|
||||
resized, unshrinkable = _shrink_data_url(url or "")
|
||||
if resized and isinstance(source, dict):
|
||||
_write_data_url_to_source(source, resized)
|
||||
changed_count += 1
|
||||
elif unshrinkable:
|
||||
unshrinkable_oversized += 1
|
||||
continue
|
||||
if ptype not in {"image_url", "input_image"}:
|
||||
continue
|
||||
image_value = part.get("image_url")
|
||||
@@ -1151,18 +759,20 @@ def try_shrink_image_parts_in_messages(
|
||||
# OpenAI Responses: {"image_url": "data:..."}
|
||||
if isinstance(image_value, dict):
|
||||
url = image_value.get("url", "")
|
||||
resized, unshrinkable = _shrink_data_url(url)
|
||||
resized = _shrink_data_url(url)
|
||||
if resized:
|
||||
image_value["url"] = resized
|
||||
changed_count += 1
|
||||
elif unshrinkable:
|
||||
elif isinstance(url, str) and url.startswith("data:") \
|
||||
and len(url) > target_bytes:
|
||||
unshrinkable_oversized += 1
|
||||
elif isinstance(image_value, str):
|
||||
resized, unshrinkable = _shrink_data_url(image_value)
|
||||
resized = _shrink_data_url(image_value)
|
||||
if resized:
|
||||
part["image_url"] = resized
|
||||
changed_count += 1
|
||||
elif unshrinkable:
|
||||
elif image_value.startswith("data:") \
|
||||
and len(image_value) > target_bytes:
|
||||
unshrinkable_oversized += 1
|
||||
|
||||
if changed_count:
|
||||
@@ -1185,8 +795,6 @@ def try_shrink_image_parts_in_messages(
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COMPACTION_STATUS",
|
||||
"COMPACTION_STATUS_MARKER",
|
||||
"check_compression_model_feasibility",
|
||||
"replay_compression_warning",
|
||||
"compress_context",
|
||||
|
||||
+85
-881
File diff suppressed because it is too large
Load Diff
+20
-86
@@ -21,14 +21,8 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from openai.types.chat.chat_completion_message_tool_call import (
|
||||
ChatCompletionMessageToolCall,
|
||||
Function,
|
||||
)
|
||||
|
||||
from agent.file_safety import get_read_block_error, is_write_denied
|
||||
from agent.redact import redact_sensitive_text
|
||||
from tools.environments.local import hermes_subprocess_env
|
||||
|
||||
ACP_MARKER_BASE_URL = "acp://copilot"
|
||||
_DEFAULT_TIMEOUT_SECONDS = 900.0
|
||||
@@ -76,6 +70,16 @@ def _resolve_args() -> list[str]:
|
||||
|
||||
def _resolve_home_dir() -> str:
|
||||
"""Return a stable HOME for child ACP processes."""
|
||||
|
||||
try:
|
||||
from hermes_constants import get_subprocess_home
|
||||
|
||||
profile_home = get_subprocess_home()
|
||||
if profile_home:
|
||||
return profile_home
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
home = os.environ.get("HOME", "").strip()
|
||||
if home:
|
||||
return home
|
||||
@@ -100,14 +104,8 @@ def _resolve_home_dir() -> str:
|
||||
|
||||
|
||||
def _build_subprocess_env() -> dict[str, str]:
|
||||
# Copilot ACP is a model-driving CLI executor: it legitimately needs LLM
|
||||
# provider credentials. Route through the central helper so Tier-1 secrets
|
||||
# (gateway bot tokens, GitHub auth, infra) are still stripped (#29157).
|
||||
env = hermes_subprocess_env(inherit_credentials=True)
|
||||
home = _resolve_home_dir()
|
||||
env["HOME"] = home
|
||||
from hermes_constants import apply_subprocess_home_env
|
||||
apply_subprocess_home_env(env)
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = _resolve_home_dir()
|
||||
return env
|
||||
|
||||
|
||||
@@ -233,73 +231,11 @@ def _render_message_content(content: Any) -> str:
|
||||
return str(content).strip()
|
||||
|
||||
|
||||
def _build_openai_tool_call(
|
||||
*,
|
||||
call_id: str,
|
||||
name: str,
|
||||
arguments: str,
|
||||
) -> ChatCompletionMessageToolCall:
|
||||
"""Build an OpenAI-compatible tool-call object for downstream handling."""
|
||||
return ChatCompletionMessageToolCall(
|
||||
id=call_id,
|
||||
call_id=call_id,
|
||||
response_item_id=None,
|
||||
type="function",
|
||||
function=Function(name=name, arguments=arguments),
|
||||
)
|
||||
|
||||
|
||||
def _completion_to_stream_chunks(completion: SimpleNamespace) -> list[SimpleNamespace]:
|
||||
"""Convert a one-shot ACP response into OpenAI-style stream chunks."""
|
||||
choice = completion.choices[0]
|
||||
message = choice.message
|
||||
tool_call_deltas = None
|
||||
if message.tool_calls:
|
||||
tool_call_deltas = []
|
||||
for index, tool_call in enumerate(message.tool_calls):
|
||||
tool_call_deltas.append(
|
||||
SimpleNamespace(
|
||||
index=index,
|
||||
id=getattr(tool_call, "id", None),
|
||||
type=getattr(tool_call, "type", "function"),
|
||||
function=SimpleNamespace(
|
||||
name=getattr(tool_call.function, "name", None),
|
||||
arguments=getattr(tool_call.function, "arguments", None),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
delta = SimpleNamespace(
|
||||
role="assistant",
|
||||
content=message.content or None,
|
||||
tool_calls=tool_call_deltas,
|
||||
reasoning_content=message.reasoning_content,
|
||||
reasoning=message.reasoning,
|
||||
)
|
||||
data_chunk = SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
index=0,
|
||||
delta=delta,
|
||||
finish_reason=choice.finish_reason,
|
||||
)
|
||||
],
|
||||
model=completion.model,
|
||||
usage=None,
|
||||
)
|
||||
usage_chunk = SimpleNamespace(
|
||||
choices=[],
|
||||
model=completion.model,
|
||||
usage=completion.usage,
|
||||
)
|
||||
return [data_chunk, usage_chunk]
|
||||
|
||||
|
||||
def _extract_tool_calls_from_text(text: str) -> tuple[list[ChatCompletionMessageToolCall], str]:
|
||||
def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]:
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return [], ""
|
||||
|
||||
extracted: list[ChatCompletionMessageToolCall] = []
|
||||
extracted: list[SimpleNamespace] = []
|
||||
consumed_spans: list[tuple[int, int]] = []
|
||||
|
||||
def _try_add_tool_call(raw_json: str) -> None:
|
||||
@@ -323,10 +259,12 @@ def _extract_tool_calls_from_text(text: str) -> tuple[list[ChatCompletionMessage
|
||||
call_id = f"acp_call_{len(extracted)+1}"
|
||||
|
||||
extracted.append(
|
||||
_build_openai_tool_call(
|
||||
SimpleNamespace(
|
||||
id=call_id,
|
||||
call_id=call_id,
|
||||
name=fn_name.strip(),
|
||||
arguments=fn_args,
|
||||
response_item_id=None,
|
||||
type="function",
|
||||
function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -445,7 +383,6 @@ class CopilotACPClient:
|
||||
timeout: float | None = None,
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
tool_choice: Any = None,
|
||||
stream: bool = False,
|
||||
**_: Any,
|
||||
) -> Any:
|
||||
prompt_text = _format_messages_as_prompt(
|
||||
@@ -492,14 +429,11 @@ class CopilotACPClient:
|
||||
)
|
||||
finish_reason = "tool_calls" if tool_calls else "stop"
|
||||
choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason)
|
||||
completion = SimpleNamespace(
|
||||
return SimpleNamespace(
|
||||
choices=[choice],
|
||||
usage=usage,
|
||||
model=model or "copilot-acp",
|
||||
)
|
||||
if stream:
|
||||
return _completion_to_stream_chunks(completion)
|
||||
return completion
|
||||
|
||||
def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:
|
||||
try:
|
||||
|
||||
+25
-177
@@ -11,12 +11,10 @@ import uuid
|
||||
import re
|
||||
from dataclasses import dataclass, fields, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_cli.config import load_env
|
||||
from agent.secret_scope import get_secret as _get_secret
|
||||
from agent.credential_persistence import (
|
||||
is_borrowed_credential_source,
|
||||
sanitize_borrowed_credential_payload,
|
||||
@@ -448,63 +446,6 @@ def get_pool_strategy(provider: str) -> str:
|
||||
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
|
||||
|
||||
|
||||
def _write_through_provider_state_to_global_root(
|
||||
provider_id: str, state: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Persist a rotated OAuth ``state`` into the global-root auth.json.
|
||||
|
||||
Best-effort write-through for the multi-profile rotation hazard
|
||||
(#48415 / #43589): nous, openai-codex, and xai-oauth rotate the
|
||||
refresh_token on refresh, so when a profile pool refresh rotates a grant
|
||||
it resolved from the root fallback, the rotated chain must land back in
|
||||
root. Otherwise root keeps a now-revoked refresh token and every other
|
||||
profile reading the stale root grant dies with ``refresh_token_reused`` /
|
||||
``invalid_grant`` once its access token expires.
|
||||
|
||||
Only updates ``providers.<provider_id>`` in the root store; never touches
|
||||
the profile store (the caller already saved that). Swallows all errors — a
|
||||
failed write-through degrades to the pre-existing behavior (root stale), it
|
||||
must never break the profile's own successful save. Mirrors
|
||||
``hermes_cli.auth._write_through_xai_oauth_to_global_root`` (which covers
|
||||
the non-pool xAI refresh path) for the credential-pool refresh path.
|
||||
"""
|
||||
try:
|
||||
global_path = auth_mod._global_auth_file_path()
|
||||
except Exception:
|
||||
return
|
||||
if global_path is None:
|
||||
# Classic mode (profile == root); the profile save already hit root.
|
||||
return
|
||||
# Seat belt: under pytest, refuse to write the real user's
|
||||
# ~/.hermes/auth.json even when HERMES_HOME points at a profile path
|
||||
# (mirrors the read-side guard in _load_global_auth_store). Uses the
|
||||
# unmodified HOME env, not Path.home() which fixtures may monkeypatch.
|
||||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
real_home_env = os.environ.get("HOME", "")
|
||||
if real_home_env:
|
||||
real_root = Path(real_home_env) / ".hermes" / "auth.json"
|
||||
try:
|
||||
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
if global_path.exists():
|
||||
global_store = _load_auth_store(global_path)
|
||||
else:
|
||||
global_store = {}
|
||||
if not isinstance(global_store, dict):
|
||||
return
|
||||
_store_provider_state(global_store, provider_id, dict(state), set_active=False)
|
||||
auth_mod._save_auth_store(global_store, global_path)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.debug(
|
||||
"%s pool refresh: write-through to global root failed: %s",
|
||||
provider_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
class CredentialPool:
|
||||
def __init__(self, provider: str, entries: List[PooledCredential]):
|
||||
self.provider = provider
|
||||
@@ -537,11 +478,10 @@ class CredentialPool:
|
||||
self._entries[idx] = new
|
||||
return
|
||||
|
||||
def _persist(self, *, removed_ids: Optional[List[str]] = None) -> None:
|
||||
def _persist(self) -> None:
|
||||
write_credential_pool(
|
||||
self.provider,
|
||||
[entry.to_dict() for entry in self._entries],
|
||||
removed_ids=removed_ids,
|
||||
)
|
||||
|
||||
def _is_terminal_auth_failure(
|
||||
@@ -616,32 +556,17 @@ class CredentialPool:
|
||||
file_refresh = creds.get("refreshToken", "")
|
||||
file_access = creds.get("accessToken", "")
|
||||
file_expires = creds.get("expiresAt", 0)
|
||||
# Sync when either token changed. Access tokens can be re-issued
|
||||
# without a new refresh token (silent re-issue path), so checking
|
||||
# only refresh_token misses that case and leaves a stale
|
||||
# access_token in the pool → 401 on every request until the pool
|
||||
# entry's exhausted TTL expires.
|
||||
entry_access = entry.access_token or ""
|
||||
entry_refresh = entry.refresh_token or ""
|
||||
if (file_access or file_refresh) and (
|
||||
(file_access and file_access != entry_access)
|
||||
or (file_refresh and file_refresh != entry_refresh)
|
||||
):
|
||||
logger.debug(
|
||||
"Pool entry %s: syncing tokens from credentials file (tokens changed)",
|
||||
entry.id,
|
||||
)
|
||||
# If the credentials file has a different token pair, sync it
|
||||
if file_refresh and file_refresh != entry.refresh_token:
|
||||
logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
|
||||
updated = replace(
|
||||
entry,
|
||||
access_token=file_access or entry.access_token,
|
||||
refresh_token=file_refresh or entry.refresh_token,
|
||||
expires_at_ms=file_expires or entry.expires_at_ms,
|
||||
access_token=file_access,
|
||||
refresh_token=file_refresh,
|
||||
expires_at_ms=file_expires,
|
||||
last_status=None,
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
last_error_reason=None,
|
||||
last_error_message=None,
|
||||
last_error_reset_at=None,
|
||||
)
|
||||
self._replace_entry(entry, updated)
|
||||
self._persist()
|
||||
@@ -874,28 +799,6 @@ class CredentialPool:
|
||||
try:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
# Decide BEFORE writing whether this profile is reading the
|
||||
# grant from the global root (no own providers.<id> block) vs.
|
||||
# genuinely shadowing it. A pool refresh rotates single-use
|
||||
# OAuth refresh tokens, so a profile that resolved the grant
|
||||
# from root MUST write the rotated chain back to root too —
|
||||
# otherwise root keeps a revoked refresh token and every other
|
||||
# profile reading the stale root grant dies with
|
||||
# refresh_token_reused / invalid_grant once its access token
|
||||
# expires. This mirrors the xAI write-through in
|
||||
# hermes_cli.auth._save_xai_oauth_tokens (#43589); the pool
|
||||
# refresh path is the Codex/xAI analog reported in #48415.
|
||||
_wt_provider_id = {
|
||||
"nous": "nous",
|
||||
"openai-codex": "openai-codex",
|
||||
"xai-oauth": "xai-oauth",
|
||||
}.get(self.provider)
|
||||
write_through_to_root = bool(_wt_provider_id) and not (
|
||||
isinstance(auth_store.get("providers"), dict)
|
||||
and isinstance(
|
||||
auth_store["providers"].get(_wt_provider_id), dict
|
||||
)
|
||||
)
|
||||
if self.provider == "nous":
|
||||
state = _load_provider_state(auth_store, "nous")
|
||||
if state is None:
|
||||
@@ -951,10 +854,6 @@ class CredentialPool:
|
||||
return
|
||||
|
||||
_save_auth_store(auth_store)
|
||||
if write_through_to_root and _wt_provider_id:
|
||||
_write_through_provider_state_to_global_root(
|
||||
_wt_provider_id, state
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc)
|
||||
|
||||
@@ -1140,17 +1039,13 @@ class CredentialPool:
|
||||
logger.debug(
|
||||
"Failed to clear terminal xAI OAuth state: %s", clear_exc
|
||||
)
|
||||
removed_ids = [
|
||||
item.id for item in self._entries
|
||||
if item.source == "loopback_pkce"
|
||||
]
|
||||
self._entries = [
|
||||
item for item in self._entries
|
||||
if item.source != "loopback_pkce"
|
||||
]
|
||||
if self._current_id == entry.id:
|
||||
self._current_id = None
|
||||
self._persist(removed_ids=removed_ids)
|
||||
self._persist()
|
||||
return None
|
||||
# For openai-codex: same race as xAI/nous — another Hermes process
|
||||
# may have consumed the refresh token between our proactive sync
|
||||
@@ -1210,17 +1105,13 @@ class CredentialPool:
|
||||
logger.debug(
|
||||
"Failed to clear terminal Codex OAuth state: %s", clear_exc
|
||||
)
|
||||
removed_ids = [
|
||||
item.id for item in self._entries
|
||||
if item.source == "device_code"
|
||||
]
|
||||
self._entries = [
|
||||
item for item in self._entries
|
||||
if item.source != "device_code"
|
||||
]
|
||||
if self._current_id == entry.id:
|
||||
self._current_id = None
|
||||
self._persist(removed_ids=removed_ids)
|
||||
self._persist()
|
||||
return None
|
||||
# For nous: another process may have consumed the refresh token
|
||||
# between our proactive sync and the HTTP call. Re-sync from
|
||||
@@ -1277,17 +1168,13 @@ class CredentialPool:
|
||||
auth_mod.NOUS_DEVICE_CODE_SOURCE,
|
||||
f"manual:{auth_mod.NOUS_DEVICE_CODE_SOURCE}",
|
||||
}
|
||||
removed_ids = [
|
||||
item.id for item in self._entries
|
||||
if item.source in singleton_sources
|
||||
]
|
||||
self._entries = [
|
||||
item for item in self._entries
|
||||
if item.source not in singleton_sources
|
||||
]
|
||||
if self._current_id == entry.id:
|
||||
self._current_id = None
|
||||
self._persist(removed_ids=removed_ids)
|
||||
self._persist()
|
||||
return None
|
||||
self._mark_exhausted(entry, None)
|
||||
return None
|
||||
@@ -1449,7 +1336,7 @@ class CredentialPool:
|
||||
pruned_ids = set(entries_to_prune)
|
||||
self._entries = [e for e in self._entries if e.id not in pruned_ids]
|
||||
if cleared_any:
|
||||
self._persist(removed_ids=entries_to_prune)
|
||||
self._persist()
|
||||
return available
|
||||
|
||||
def _select_unlocked(self) -> Optional[PooledCredential]:
|
||||
@@ -1623,11 +1510,7 @@ class CredentialPool:
|
||||
replace(entry, priority=new_priority)
|
||||
for new_priority, entry in enumerate(self._entries)
|
||||
]
|
||||
write_credential_pool(
|
||||
self.provider,
|
||||
[entry.to_dict() for entry in self._entries],
|
||||
removed_ids=[removed.id],
|
||||
)
|
||||
self._persist()
|
||||
if self._current_id == removed.id:
|
||||
self._current_id = None
|
||||
return removed
|
||||
@@ -1783,7 +1666,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
_env_file = load_env()
|
||||
|
||||
def _env_val(key: str) -> str:
|
||||
return (_env_file.get(key) or _get_secret(key, "") or "").strip()
|
||||
return (_env_file.get(key) or os.environ.get(key) or "").strip()
|
||||
|
||||
anthropic_api_key = _env_val("ANTHROPIC_API_KEY")
|
||||
anthropic_oauth_env = (
|
||||
@@ -1899,16 +1782,11 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token
|
||||
token, source = resolve_copilot_token()
|
||||
if token:
|
||||
api_token, enterprise_base_url = get_copilot_api_token(token)
|
||||
api_token = get_copilot_api_token(token)
|
||||
source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}"
|
||||
if not _is_suppressed(provider, source_name):
|
||||
active_sources.add(source_name)
|
||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||
# Use enterprise base URL from token exchange if available,
|
||||
# otherwise fall back to the provider's default.
|
||||
effective_base_url = enterprise_base_url or (
|
||||
pconfig.inference_base_url if pconfig else ""
|
||||
)
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
@@ -1917,7 +1795,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
"source": source_name,
|
||||
"auth_type": AUTH_TYPE_API_KEY,
|
||||
"access_token": api_token,
|
||||
"base_url": effective_base_url,
|
||||
"base_url": pconfig.inference_base_url if pconfig else "",
|
||||
"label": source,
|
||||
},
|
||||
)
|
||||
@@ -2074,7 +1952,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
# changes to the .env file.
|
||||
def _get_env_prefer_dotenv(key: str) -> str:
|
||||
env_file = load_env()
|
||||
val = env_file.get(key) or _get_secret(key, "") or ""
|
||||
val = env_file.get(key) or os.environ.get(key) or ""
|
||||
return val.strip()
|
||||
|
||||
# Honour user suppression — `hermes auth remove <provider> <N>` for an
|
||||
@@ -2183,34 +2061,19 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
return changed, active_sources
|
||||
|
||||
|
||||
def _prune_stale_seeded_entries(
|
||||
entries: List[PooledCredential],
|
||||
active_sources: Set[str],
|
||||
*,
|
||||
prune_env_sources: bool = True,
|
||||
) -> bool:
|
||||
def _is_prunable(entry: PooledCredential) -> bool:
|
||||
# ``env:*`` entries are persisted references that get re-hydrated from
|
||||
# the environment on every load. A process that merely lacks the env
|
||||
# var this call must NOT delete the on-disk entry for every other
|
||||
# process — that destructive read is the bug behind #9331. Only prune
|
||||
# an env source when ``prune_env_sources`` is explicitly requested
|
||||
# (e.g. an `hermes auth` command that confirmed the source is gone).
|
||||
if entry.source.startswith("env:"):
|
||||
return prune_env_sources
|
||||
# File-backed singletons (device-code OAuth, claude_code) and Hermes
|
||||
# PKCE should disappear from the pool when their backing file is gone.
|
||||
return (
|
||||
is_borrowed_credential_source(entry.source, entry.provider)
|
||||
or entry.source == "hermes_pkce"
|
||||
)
|
||||
|
||||
def _prune_stale_seeded_entries(entries: List[PooledCredential], active_sources: Set[str]) -> bool:
|
||||
retained = [
|
||||
entry
|
||||
for entry in entries
|
||||
if _is_manual_source(entry.source)
|
||||
or entry.source in active_sources
|
||||
or not _is_prunable(entry)
|
||||
or not (
|
||||
is_borrowed_credential_source(entry.source, entry.provider)
|
||||
# Hermes PKCE is Hermes-owned/persistable while present, but it is
|
||||
# still a file-backed singleton and should disappear from the pool
|
||||
# when the backing OAuth file is gone.
|
||||
or entry.source == "hermes_pkce"
|
||||
)
|
||||
]
|
||||
if len(retained) == len(entries):
|
||||
return False
|
||||
@@ -2294,11 +2157,6 @@ def _seed_custom_pool(pool_key: str, entries: List[PooledCredential]) -> Tuple[b
|
||||
def load_pool(provider: str) -> CredentialPool:
|
||||
provider = (provider or "").strip().lower()
|
||||
raw_entries = read_credential_pool(provider)
|
||||
disk_ids = {
|
||||
entry.get("id")
|
||||
for entry in raw_entries
|
||||
if isinstance(entry, dict) and entry.get("id")
|
||||
}
|
||||
raw_needs_sanitization = any(
|
||||
isinstance(payload, dict)
|
||||
and sanitize_borrowed_credential_payload(payload, provider) != payload
|
||||
@@ -2315,22 +2173,12 @@ def load_pool(provider: str) -> CredentialPool:
|
||||
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
|
||||
env_changed, env_sources = _seed_from_env(provider, entries)
|
||||
changed = raw_needs_sanitization or singleton_changed or env_changed
|
||||
# ``load_pool()`` is a non-destructive read for env-seeded entries: a
|
||||
# process missing a provider env var must not delete the persisted
|
||||
# pool entry for every other process (#9331). File-backed singletons
|
||||
# still prune when their backing file is gone.
|
||||
changed |= _prune_stale_seeded_entries(
|
||||
entries,
|
||||
singleton_sources | env_sources,
|
||||
prune_env_sources=False,
|
||||
)
|
||||
changed |= _prune_stale_seeded_entries(entries, singleton_sources | env_sources)
|
||||
changed |= _normalize_pool_priorities(provider, entries)
|
||||
|
||||
if changed:
|
||||
new_ids = {entry.id for entry in entries}
|
||||
write_credential_pool(
|
||||
provider,
|
||||
[entry.to_dict() for entry in sorted(entries, key=lambda item: item.priority)],
|
||||
removed_ids=disk_ids - new_ids,
|
||||
)
|
||||
return CredentialPool(provider, entries)
|
||||
|
||||
+13
-84
@@ -194,71 +194,17 @@ class AgentNotice:
|
||||
id: Optional[str] = None
|
||||
|
||||
|
||||
# ── is_free_tier_model (local-data-only free-model check) ────────────────────
|
||||
|
||||
|
||||
def is_free_tier_model(model: str, base_url: str = "") -> bool:
|
||||
"""Return True when *model* is a Nous free-tier model, using ONLY local data.
|
||||
|
||||
Two signals, both zero-network:
|
||||
|
||||
1. The ``:free`` suffix — the canonical Nous free SKU marker (e.g.
|
||||
``nvidia/nemotron-3-ultra:free``). Free by construction on the API side
|
||||
(spend is forced to 0 for ``:free`` ids).
|
||||
2. A peek into the in-process pricing cache in ``hermes_cli.models``
|
||||
(populated when the model picker fetched ``/v1/models`` pricing for
|
||||
*base_url*). PEEK ONLY — a cache miss never triggers a fetch. This is
|
||||
CLI/TUI-session best-effort: gateway sessions never run the picker's
|
||||
pricing fetch, so suppression there rests entirely on the ``:free``
|
||||
suffix (which all Nous free SKUs carry).
|
||||
|
||||
Fail-open to False (the depleted notice still shows) on any error: wrongly
|
||||
showing the warning is recoverable noise; wrongly hiding it on a paid model
|
||||
would mask a real billing block.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
if model.endswith(":free"):
|
||||
return True
|
||||
if not base_url:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.models import _is_model_free, _pricing_cache
|
||||
|
||||
# Mirror get_pricing_for_provider's key normalization: the agent's
|
||||
# Nous base_url is /v1-suffixed (https://inference-api.nousresearch.com/v1)
|
||||
# but the picker keys _pricing_cache on the pre-/v1 root.
|
||||
key = base_url.rstrip("/")
|
||||
if key.endswith("/v1"):
|
||||
key = key[:-3].rstrip("/")
|
||||
pricing = _pricing_cache.get(key)
|
||||
if not pricing:
|
||||
return False
|
||||
return _is_model_free(model, pricing)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── evaluate_credits_notices (pure reconciliation function) ──────────────────
|
||||
|
||||
|
||||
def evaluate_credits_notices(
|
||||
state: CreditsState,
|
||||
latch: dict,
|
||||
*,
|
||||
model_is_free: bool = False,
|
||||
) -> tuple[list[AgentNotice], list[str]]:
|
||||
"""Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE.
|
||||
|
||||
latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}.
|
||||
|
||||
``model_is_free``: True when the session's active model is a Nous free-tier
|
||||
model (see :func:`is_free_tier_model`). Suppresses the ``credits.depleted``
|
||||
notice — a depleted account on a free model can keep inferencing, so the
|
||||
error banner is noise (and confuses free-tier users who never had credits).
|
||||
Suppression does NOT emit the "restored" success notice; that fires only on
|
||||
a genuine ``paid_access`` flip back to True.
|
||||
|
||||
Returns ``(to_show: list[AgentNotice], to_clear: list[str])``.
|
||||
Caller emits to_clear FIRST, then to_show.
|
||||
|
||||
@@ -286,16 +232,6 @@ def evaluate_credits_notices(
|
||||
for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest
|
||||
if uf >= band[0]:
|
||||
current_band = band
|
||||
# Top-up suppression: when the account holds purchased (top-up) credits,
|
||||
# the subscription-cap gauge is the wrong denominator — warning "90% used"
|
||||
# at a user sitting on $50 of top-up is noise (and it previously stuck
|
||||
# PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band
|
||||
# entirely; the cap-reached case is covered by the grant_spent info notice
|
||||
# below, which already names the remaining top-up balance. A top-up landing
|
||||
# mid-session flips current_band → None and the clear path below removes
|
||||
# any showing band line.
|
||||
if state.purchased_micros > 0:
|
||||
current_band = None
|
||||
grant_cond = (
|
||||
state.denominator_kind == "subscription_cap"
|
||||
and uf is not None
|
||||
@@ -348,14 +284,10 @@ def evaluate_credits_notices(
|
||||
active.discard("credits.grant_spent")
|
||||
|
||||
# ── depleted ─────────────────────────────────────────────────────────────
|
||||
# Suppressed while the active model is free: inference still works there,
|
||||
# so the error banner would just alarm users (free-tier users especially,
|
||||
# who never had paid credits to "lose").
|
||||
show_depleted = depleted_cond and not model_is_free
|
||||
if show_depleted and "credits.depleted" not in active:
|
||||
if depleted_cond and "credits.depleted" not in active:
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✕ Credit access paused · run /credits to top up",
|
||||
text="✕ Credit access paused · run /usage for balance",
|
||||
level="error",
|
||||
kind=CREDITS_NOTICE_KIND,
|
||||
key="credits.depleted",
|
||||
@@ -363,23 +295,20 @@ def evaluate_credits_notices(
|
||||
)
|
||||
)
|
||||
active.add("credits.depleted")
|
||||
elif "credits.depleted" in active and not show_depleted:
|
||||
elif "credits.depleted" in active and not depleted_cond:
|
||||
to_clear.append("credits.depleted")
|
||||
active.discard("credits.depleted")
|
||||
if not depleted_cond:
|
||||
# Genuine recovery (paid_access flipped back True): also emit the
|
||||
# success notice. A clear caused by switching to a free model while
|
||||
# still depleted must NOT claim access was restored.
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✓ Credit access restored",
|
||||
level="success",
|
||||
kind="ttl",
|
||||
ttl_ms=CREDITS_RESTORED_TTL_MS,
|
||||
key="credits.restored",
|
||||
id="credits.restored",
|
||||
)
|
||||
# Recovery: also emit the success notice
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✓ Credit access restored",
|
||||
level="success",
|
||||
kind="ttl",
|
||||
ttl_ms=CREDITS_RESTORED_TTL_MS,
|
||||
key="credits.restored",
|
||||
id="credits.restored",
|
||||
)
|
||||
)
|
||||
|
||||
return (to_show, to_clear)
|
||||
|
||||
|
||||
+23
-151
@@ -25,6 +25,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -32,7 +33,6 @@ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools import skill_usage
|
||||
from utils import atomic_json_write
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,11 +57,6 @@ DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
|
||||
DEFAULT_MIN_IDLE_HOURS = 2
|
||||
DEFAULT_STALE_AFTER_DAYS = 30
|
||||
DEFAULT_ARCHIVE_AFTER_DAYS = 90
|
||||
# Consolidation (the LLM umbrella-building fork) is OFF by default. The
|
||||
# deterministic inactivity prune (apply_automatic_transitions) still runs
|
||||
# whenever the curator is enabled; only the opinionated, aux-model-cost
|
||||
# consolidation pass is opt-in.
|
||||
DEFAULT_CONSOLIDATE = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -102,7 +97,20 @@ def load_state() -> Dict[str, Any]:
|
||||
def save_state(data: Dict[str, Any]) -> None:
|
||||
path = _state_file()
|
||||
try:
|
||||
atomic_json_write(path, data, indent=2, sort_keys=True)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("Failed to save curator state: %s", e, exc_info=True)
|
||||
|
||||
@@ -187,22 +195,6 @@ def get_prune_builtins() -> bool:
|
||||
return bool(cfg.get("prune_builtins", True))
|
||||
|
||||
|
||||
def get_consolidate() -> bool:
|
||||
"""Whether the curator runs its LLM consolidation (umbrella-building) pass.
|
||||
|
||||
OFF by default. When off, a curator run does ONLY the deterministic
|
||||
inactivity prune (mark stale / archive long-unused skills) and skips the
|
||||
forked aux-model review entirely — no consolidation, no umbrella-building,
|
||||
no aux-model cost. Set ``curator.consolidate: true`` to opt back into the
|
||||
LLM pass that merges overlapping skills into class-level umbrellas.
|
||||
|
||||
The explicit ``hermes curator run --consolidate`` flag overrides this for
|
||||
a single invocation regardless of the config value.
|
||||
"""
|
||||
cfg = _load_config()
|
||||
return bool(cfg.get("consolidate", DEFAULT_CONSOLIDATE))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idle / interval check
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -273,21 +265,6 @@ def should_run_now(now: Optional[datetime] = None) -> bool:
|
||||
# Automatic state transitions (pure function, no LLM)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cron_referenced_skills() -> Set[str]:
|
||||
"""Skill names referenced by any cron job (incl. paused/disabled).
|
||||
|
||||
Best-effort: a cron-module import error or corrupt jobs store must never
|
||||
break the curator, so any failure yields an empty set (no protection,
|
||||
but no crash).
|
||||
"""
|
||||
try:
|
||||
from cron.jobs import referenced_skill_names as _refs
|
||||
return _refs()
|
||||
except Exception as e:
|
||||
logger.debug("Curator could not read cron skill references: %s", e, exc_info=True)
|
||||
return set()
|
||||
|
||||
|
||||
def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]:
|
||||
"""Walk every curator-managed skill and move active/stale/archived based on
|
||||
the latest real activity timestamp. Pinned skills are never touched.
|
||||
@@ -307,8 +284,6 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
|
||||
stale_cutoff = now - timedelta(days=get_stale_after_days())
|
||||
archive_cutoff = now - timedelta(days=get_archive_after_days())
|
||||
|
||||
cron_referenced = _cron_referenced_skills()
|
||||
|
||||
counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0, "seeded": 0}
|
||||
|
||||
for row in _u.agent_created_report():
|
||||
@@ -317,15 +292,6 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
|
||||
if row.get("pinned"):
|
||||
continue
|
||||
|
||||
# A skill referenced by any cron job (incl. paused/disabled) is in
|
||||
# use by definition — resuming or the next fire must find it. The
|
||||
# scheduler only bumps usage when a job actually fires, so jobs that
|
||||
# fire less often than archive_after_days, paused jobs, and far-future
|
||||
# one-shots would otherwise have their skills aged out from under
|
||||
# them. Treat referenced skills like pinned: never auto-transition.
|
||||
if name in cron_referenced:
|
||||
continue
|
||||
|
||||
# First sight of a curation-eligible skill with no persisted record
|
||||
# (e.g. a newly-eligible built-in): anchor its clock to now and defer.
|
||||
if not row.get("_persisted", True):
|
||||
@@ -342,18 +308,6 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
|
||||
|
||||
current = row.get("state", _u.STATE_ACTIVE)
|
||||
|
||||
# Never-used skills (use_count == 0) get a grace floor: don't archive
|
||||
# one until it is at least stale_after_days old. A use=0 skill is
|
||||
# absence of evidence, not evidence of staleness — a skill created
|
||||
# recently may simply not have had its trigger come up yet.
|
||||
never_used = int(row.get("use_count", 0) or 0) == 0
|
||||
if never_used and anchor > stale_cutoff:
|
||||
# Younger than the stale window — leave it alone entirely.
|
||||
if current == _u.STATE_STALE:
|
||||
_u.set_state(name, _u.STATE_ACTIVE)
|
||||
counts["reactivated"] += 1
|
||||
continue
|
||||
|
||||
if anchor <= archive_cutoff and current != _u.STATE_ARCHIVED:
|
||||
ok, _msg = _u.archive_skill(name)
|
||||
if ok:
|
||||
@@ -415,10 +369,8 @@ CURATOR_REVIEW_PROMPT = (
|
||||
"bodies + `references/`, `templates/`, and `scripts/` subfiles for "
|
||||
"session-specific detail — not one-session-one-skill micro-entries.\n\n"
|
||||
"Hard rules — do not violate:\n"
|
||||
"1. DO NOT touch bundled, hub-installed, or external-dir skills "
|
||||
"(`skills.external_dirs`). The candidate list below is already filtered "
|
||||
"to local curator-managed skills only; external skills are externally "
|
||||
"owned and read-only to this background curator.\n"
|
||||
"1. DO NOT touch bundled or hub-installed skills. The candidate list "
|
||||
"below is already filtered to agent-created skills only.\n"
|
||||
"2. DO NOT delete any skill. Archiving (moving the skill's directory "
|
||||
"into ~/.hermes/skills/.archive/) is the maximum destructive action. "
|
||||
"Archives are recoverable; deletion is not.\n"
|
||||
@@ -428,19 +380,10 @@ CURATOR_REVIEW_PROMPT = (
|
||||
"back load-bearing UX (slash-command entry points referenced in docs and "
|
||||
"tips) and are filtered out of the candidate list below — never resurrect "
|
||||
"one as an archive or absorb target.\n"
|
||||
"3c. DO NOT archive or prune any skill marked `cron=yes` in the candidate "
|
||||
"list. A cron job depends on it and will fail to load it on its next "
|
||||
"run. You MAY still consolidate it into an umbrella — but only because "
|
||||
"the curator rewrites cron job skill references to follow consolidations; "
|
||||
"never simply prune it.\n"
|
||||
"4. DO NOT use usage counters as a reason to skip consolidation. The "
|
||||
"counters are new and often mostly zero. Judge overlap on CONTENT, "
|
||||
"not on use_count. 'use=0' is not evidence a skill is valuable; it's "
|
||||
"absence of evidence either way. Corollary: 'use=0' is ALSO not a "
|
||||
"reason to PRUNE a skill. Never archive a never-used skill (use=0) "
|
||||
"unless it is at least 30 days old (check last_activity / created date) "
|
||||
"AND its content is genuinely obsolete or fully absorbed elsewhere — a "
|
||||
"recently-created skill simply may not have had its trigger come up yet.\n"
|
||||
"absence of evidence either way.\n"
|
||||
"5. DO NOT reject consolidation on the grounds that 'each skill has "
|
||||
"a distinct trigger'. Pairwise distinctness is the wrong bar. The "
|
||||
"right bar is: 'would a human maintainer write this as N separate "
|
||||
@@ -518,9 +461,8 @@ CURATOR_REVIEW_PROMPT = (
|
||||
"skill, or `absorbed_into=\"\"` when you're truly pruning with no "
|
||||
"forwarding target. This drives cron-job skill-reference migration — "
|
||||
"guessing from your YAML summary after the fact is fragile.\n"
|
||||
" - terminal — move LOCAL candidate content into "
|
||||
"a support subfile when package integrity requires it; never mv, cp, rm, "
|
||||
"patch, or rewrite bundled, hub-installed, or external-dir skills\n\n"
|
||||
" - terminal — mv a sibling into the archive "
|
||||
"OR move its content into a support subfile\n\n"
|
||||
"'keep' is a legitimate decision ONLY when the skill is already a "
|
||||
"class-level umbrella and none of the proposed merges would improve "
|
||||
"discoverability. 'This is narrow but distinct from its siblings' "
|
||||
@@ -1460,14 +1402,12 @@ def _render_candidate_list() -> str:
|
||||
rows = skill_usage.agent_created_report()
|
||||
if not rows:
|
||||
return "No agent-created skills to review."
|
||||
cron_referenced = _cron_referenced_skills()
|
||||
lines = [f"Agent-created skills ({len(rows)}):\n"]
|
||||
for r in rows:
|
||||
lines.append(
|
||||
f"- {r['name']} "
|
||||
f"state={r['state']} "
|
||||
f"pinned={'yes' if r.get('pinned') else 'no'} "
|
||||
f"cron={'yes' if r['name'] in cron_referenced else 'no'} "
|
||||
f"activity={r.get('activity_count', 0)} "
|
||||
f"use={r.get('use_count', 0)} "
|
||||
f"view={r.get('view_count', 0)} "
|
||||
@@ -1481,38 +1421,25 @@ def run_curator_review(
|
||||
on_summary: Optional[Callable[[str], None]] = None,
|
||||
synchronous: bool = False,
|
||||
dry_run: bool = False,
|
||||
consolidate: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a single curator review pass.
|
||||
|
||||
Steps:
|
||||
1. Apply automatic state transitions (pure, no LLM).
|
||||
2. If consolidation is enabled AND there are agent-created skills, spawn
|
||||
a forked AIAgent that runs the LLM review prompt against the current
|
||||
candidate list.
|
||||
2. If there are agent-created skills, spawn a forked AIAgent that runs
|
||||
the LLM review prompt against the current candidate list.
|
||||
3. Update .curator_state with last_run_at and a one-line summary.
|
||||
4. Invoke *on_summary* with a user-visible description.
|
||||
|
||||
If *synchronous* is True, the LLM review runs in the calling thread; the
|
||||
default is to spawn a daemon thread so the caller returns immediately.
|
||||
|
||||
*consolidate* gates the LLM umbrella-building pass. ``None`` (the default)
|
||||
reads ``curator.consolidate`` from config (OFF by default). Passing
|
||||
``True``/``False`` overrides the config for this invocation — used by the
|
||||
``hermes curator run --consolidate`` flag. When consolidation is off, only
|
||||
the deterministic inactivity prune runs and the forked aux-model review is
|
||||
skipped entirely (no aux-model cost).
|
||||
|
||||
If *dry_run* is True, the automatic stale/archive transitions are SKIPPED
|
||||
and the LLM review pass is instructed to produce a report only — no
|
||||
skill_manage mutations, no terminal archive moves. The REPORT.md still
|
||||
gets written and ``state.last_report_path`` still records it so users
|
||||
can read what the curator WOULD have done. A dry-run also honors
|
||||
*consolidate*: when consolidation is off, the preview only reports the
|
||||
deterministic prune candidates.
|
||||
can read what the curator WOULD have done.
|
||||
"""
|
||||
if consolidate is None:
|
||||
consolidate = get_consolidate()
|
||||
start = datetime.now(timezone.utc)
|
||||
if dry_run:
|
||||
# Count candidates without mutating state.
|
||||
@@ -1575,53 +1502,6 @@ def run_curator_review(
|
||||
before_report = []
|
||||
before_names = {r.get("name") for r in before_report if isinstance(r, dict)}
|
||||
|
||||
# Consolidation gate. When off (the default), the curator does ONLY the
|
||||
# deterministic inactivity prune above — no forked aux-model review, no
|
||||
# umbrella-building, no aux-model cost. Record the run, write a report
|
||||
# reflecting the prune-only outcome, and return without spawning a fork.
|
||||
if not consolidate:
|
||||
final_summary = (
|
||||
f"{prefix}{auto_summary}; llm: skipped (consolidation off)"
|
||||
)
|
||||
llm_meta = {
|
||||
"final": "",
|
||||
"summary": "skipped (consolidation off)",
|
||||
"model": "",
|
||||
"provider": "",
|
||||
"tool_calls": [],
|
||||
"error": None,
|
||||
}
|
||||
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
|
||||
state2 = load_state()
|
||||
state2["last_run_duration_seconds"] = elapsed
|
||||
state2["last_run_summary"] = final_summary
|
||||
try:
|
||||
after_report = skill_usage.agent_created_report()
|
||||
except Exception:
|
||||
after_report = []
|
||||
try:
|
||||
report_path = _write_run_report(
|
||||
started_at=start,
|
||||
elapsed_seconds=elapsed,
|
||||
auto_counts=counts,
|
||||
auto_summary=auto_summary,
|
||||
before_report=before_report,
|
||||
before_names=before_names,
|
||||
after_report=after_report,
|
||||
llm_meta=llm_meta,
|
||||
)
|
||||
if report_path is not None:
|
||||
state2["last_report_path"] = str(report_path)
|
||||
except Exception as e:
|
||||
logger.debug("Curator report write failed: %s", e, exc_info=True)
|
||||
save_state(state2)
|
||||
if on_summary:
|
||||
try:
|
||||
on_summary(f"curator: {final_summary}")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
llm_meta: Dict[str, Any] = {}
|
||||
try:
|
||||
candidate_list = _render_candidate_list()
|
||||
@@ -1895,14 +1775,6 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
||||
# Disable recursive nudges — the curator must never spawn its own review.
|
||||
review_agent._memory_nudge_interval = 0
|
||||
review_agent._skill_nudge_interval = 0
|
||||
# Tag this fork as autonomous background curation so skill_manage's
|
||||
# background-review write guard fires. Without this the fork inherits
|
||||
# the default "assistant_tool" origin, is_background_review() is False,
|
||||
# and the external/bundled/hub-installed skill_manage guards never
|
||||
# trigger during the curation pass they exist to protect against.
|
||||
# turn_context.py binds this onto the write-origin ContextVar at turn
|
||||
# start (see agent/turn_context.py).
|
||||
review_agent._memory_write_origin = "background_review"
|
||||
|
||||
# Redirect the forked agent's stdout/stderr to /dev/null while it
|
||||
# runs so its tool-call chatter doesn't pollute the foreground
|
||||
|
||||
+10
-26
@@ -46,7 +46,7 @@ import shutil
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from agent.skill_utils import is_excluded_skill_path
|
||||
@@ -208,17 +208,13 @@ def _write_manifest(dest: Path, reason: str, archive_path: Path,
|
||||
)
|
||||
|
||||
|
||||
def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] = None) -> Optional[Path]:
|
||||
def snapshot_skills(reason: str = "manual") -> Optional[Path]:
|
||||
"""Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones.
|
||||
|
||||
Returns the snapshot directory path, or ``None`` if the snapshot was
|
||||
skipped (backup disabled, skills dir missing, or an IO error occurred —
|
||||
in which case we log at debug and return None so the curator never
|
||||
aborts a pass because of a backup failure).
|
||||
|
||||
``protect_ids`` is forwarded to the prune step so callers can guarantee
|
||||
specific snapshot ids survive even when they fall outside the keep
|
||||
window (rollback passes the id it is about to restore from).
|
||||
"""
|
||||
if not is_enabled():
|
||||
logger.debug("Curator backup disabled by config; skipping snapshot")
|
||||
@@ -280,19 +276,15 @@ def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] =
|
||||
pass
|
||||
return None
|
||||
|
||||
_prune_old(keep=get_keep(), protect=protect_ids)
|
||||
_prune_old(keep=get_keep())
|
||||
logger.info("Curator snapshot created: %s (%s)", snap_id, reason)
|
||||
return dest
|
||||
|
||||
|
||||
def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
|
||||
def _prune_old(keep: int) -> List[str]:
|
||||
"""Delete regular snapshots beyond the newest *keep*. Returns deleted
|
||||
ids. Snapshot ids in *protect* are never deleted even when they fall
|
||||
outside the keep window — rollback() uses this so the mandatory
|
||||
pre-rollback safety snapshot can never evict the very snapshot being
|
||||
restored. Staging dirs (``.rollback-staging-*``) are implementation
|
||||
detail and pruned independently on every call."""
|
||||
protect = protect or set()
|
||||
ids. Staging dirs (``.rollback-staging-*``) are implementation detail
|
||||
and pruned independently on every call."""
|
||||
backups = _backups_dir()
|
||||
if not backups.exists():
|
||||
return []
|
||||
@@ -313,8 +305,6 @@ def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
|
||||
entries.sort(key=lambda t: t[0], reverse=True)
|
||||
deleted: List[str] = []
|
||||
for _, path in entries[keep:]:
|
||||
if path.name in protect:
|
||||
continue
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
deleted.append(path.name)
|
||||
@@ -464,16 +454,16 @@ def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]:
|
||||
report["attempted"] = True # we tried but there was nothing to do
|
||||
return report
|
||||
|
||||
# Load and rewrite the live jobs under the scheduler's cross-process lock.
|
||||
# Load and rewrite the live jobs under the scheduler's lock.
|
||||
try:
|
||||
from cron.jobs import load_jobs, save_jobs, _jobs_lock
|
||||
from cron.jobs import load_jobs, save_jobs, _jobs_file_lock
|
||||
except ImportError as e:
|
||||
report["error"] = f"cron module unavailable: {e}"
|
||||
return report
|
||||
|
||||
report["attempted"] = True
|
||||
try:
|
||||
with _jobs_lock():
|
||||
with _jobs_file_lock:
|
||||
live_jobs = load_jobs()
|
||||
changed = False
|
||||
|
||||
@@ -574,13 +564,7 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
|
||||
# out before touching anything — otherwise a failed extract could leave
|
||||
# the user with no skills.
|
||||
try:
|
||||
# Protect the target from this snapshot's prune step: at the steady
|
||||
# keep limit, pruning the oldest snapshot would otherwise delete the
|
||||
# very snapshot we are about to extract from.
|
||||
snapshot_skills(
|
||||
reason=f"pre-rollback to {target.name}",
|
||||
protect_ids={target.name},
|
||||
)
|
||||
snapshot_skills(reason=f"pre-rollback to {target.name}")
|
||||
except Exception as e:
|
||||
return (False, f"pre-rollback safety snapshot failed: {e}", None)
|
||||
|
||||
|
||||
+6
-399
@@ -6,17 +6,14 @@ Used by AIAgent._execute_tool_calls for CLI feedback.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import unified_diff
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from utils import safe_json_loads
|
||||
from agent.redact import redact_sensitive_text
|
||||
from agent.tool_result_classification import file_mutation_result_landed
|
||||
|
||||
# ANSI escape codes for coloring tool failure indicators
|
||||
@@ -171,244 +168,6 @@ def _oneline(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _truncate_preview(text: str, max_len: int | None) -> str:
|
||||
if max_len and max_len > 0 and len(text) > max_len:
|
||||
if max_len <= 3:
|
||||
return "." * max_len
|
||||
return text[:max_len - 3] + "..."
|
||||
return text
|
||||
|
||||
|
||||
_SHELL_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"}
|
||||
_SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"}
|
||||
|
||||
|
||||
def _shell_basename(head: str) -> str:
|
||||
return head.rsplit("/", 1)[-1] if head else ""
|
||||
|
||||
|
||||
def _split_shell_words(segment: str) -> list[str]:
|
||||
words: list[str] = []
|
||||
buf: list[str] = []
|
||||
quote: str | None = None
|
||||
|
||||
for i, ch in enumerate(segment):
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote and (i == 0 or segment[i - 1] != "\\"):
|
||||
quote = None
|
||||
continue
|
||||
|
||||
if ch in {"'", '"'}:
|
||||
quote = ch
|
||||
buf.append(ch)
|
||||
continue
|
||||
|
||||
if ch.isspace():
|
||||
if buf:
|
||||
words.append("".join(buf))
|
||||
buf = []
|
||||
continue
|
||||
|
||||
buf.append(ch)
|
||||
|
||||
if buf:
|
||||
words.append("".join(buf))
|
||||
|
||||
return words
|
||||
|
||||
|
||||
def _strip_shell_pipe_tail(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
out: list[str] = []
|
||||
|
||||
for i, word in enumerate(words):
|
||||
if word == "|" and _shell_basename(words[i + 1] if i + 1 < len(words) else "") in _SHELL_PIPE_TAIL_HEADS:
|
||||
break
|
||||
out.append(word)
|
||||
|
||||
return " ".join(out).strip()
|
||||
|
||||
|
||||
def _split_shell_compound(command: str) -> list[str]:
|
||||
segments: list[str] = []
|
||||
buf: list[str] = []
|
||||
quote: str | None = None
|
||||
i = 0
|
||||
|
||||
while i < len(command):
|
||||
ch = command[i]
|
||||
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote and (i == 0 or command[i - 1] != "\\"):
|
||||
quote = None
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch in {"'", '"'}:
|
||||
quote = ch
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
op_len = 2 if command.startswith("&&", i) or command.startswith("||", i) else 1 if ch in {";", "\n"} else 0
|
||||
if op_len:
|
||||
segment = _strip_shell_pipe_tail("".join(buf).strip())
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
buf = []
|
||||
i += op_len
|
||||
continue
|
||||
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
|
||||
segment = _strip_shell_pipe_tail("".join(buf).strip())
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def _shell_head_word(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
index = 0
|
||||
while index < len(words) and re.match(r"^[A-Za-z_]\w*=", words[index]):
|
||||
index += 1
|
||||
return _shell_basename(words[index] if index < len(words) else "")
|
||||
|
||||
|
||||
def _clean_shell_segment(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(words):
|
||||
word = words[i]
|
||||
if re.match(r"^\d*(?:>>?|<)$", word):
|
||||
i += 2
|
||||
continue
|
||||
if re.match(r"^\d*(?:>&|<&)\d+$", word) or re.match(r"^\d*>&\d+$", word):
|
||||
i += 1
|
||||
continue
|
||||
out.append(word)
|
||||
i += 1
|
||||
return " ".join(out).strip()
|
||||
|
||||
|
||||
def _is_shell_boundary_echo(segment: str) -> bool:
|
||||
words = _split_shell_words(segment)
|
||||
if _shell_basename(words[0] if words else "") != "echo":
|
||||
return False
|
||||
rest = " ".join(words[1:])
|
||||
return bool(re.search(r"-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS", rest))
|
||||
|
||||
|
||||
def summarize_shell_command(command: str) -> str:
|
||||
"""Compact shell wrapper/plumbing for display while preserving raw command elsewhere."""
|
||||
original = _oneline(command)
|
||||
if not original:
|
||||
return ""
|
||||
|
||||
segments = _split_shell_compound(original)
|
||||
if len(segments) <= 1:
|
||||
return _clean_shell_segment(segments[0] if segments else original) or original
|
||||
|
||||
core: list[str] = []
|
||||
for segment in segments:
|
||||
cleaned = _clean_shell_segment(segment)
|
||||
head = _shell_head_word(cleaned)
|
||||
if cleaned and head not in _SHELL_SILENT_HEADS and not _is_shell_boundary_echo(cleaned):
|
||||
core.append(cleaned)
|
||||
|
||||
if not core:
|
||||
return original
|
||||
if len(core) == 1:
|
||||
return core[0]
|
||||
|
||||
count = len(core) - 1
|
||||
return f"{core[0]} + {count} {'command' if count == 1 else 'commands'}"
|
||||
|
||||
|
||||
def _read_file_line_label(args: dict) -> str:
|
||||
offset = args.get("offset")
|
||||
limit = args.get("limit")
|
||||
if not isinstance(offset, int) or offset <= 0:
|
||||
return ""
|
||||
if not isinstance(limit, int) or limit <= 1:
|
||||
return f"L{offset}"
|
||||
return f"L{offset}-{offset + limit - 1}"
|
||||
|
||||
|
||||
def redact_browser_typed_text_for_display(value: Any, typed_text: Any) -> Any:
|
||||
"""Apply secret redaction to browser_type text in display-facing payloads.
|
||||
|
||||
Backends sometimes echo the attempted input in error strings or fallback
|
||||
metadata. When the raw typed value contains a recognizable secret (API
|
||||
key, token, JWT, etc.) the redacted form differs from the raw value, so we
|
||||
replace every occurrence of the raw value with its redacted form before a
|
||||
browser_type result reaches logs, callbacks, the model, or chat history.
|
||||
|
||||
Normal typed text (search queries, addresses, form fields) matches no
|
||||
secret pattern, so it passes through unchanged and stays readable.
|
||||
|
||||
Redaction is forced here regardless of the global ``security.redact_secrets``
|
||||
preference: a typed credential leaking into chat history is a security
|
||||
boundary, not mere log hygiene.
|
||||
"""
|
||||
if typed_text is None:
|
||||
return value
|
||||
needle = str(typed_text)
|
||||
if needle == "":
|
||||
return value
|
||||
redacted = redact_sensitive_text(needle, force=True)
|
||||
if redacted == needle:
|
||||
# Nothing secret-looking in the typed text; leave payload untouched.
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.replace(needle, redacted)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: redact_browser_typed_text_for_display(item, typed_text)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [redact_browser_typed_text_for_display(item, typed_text) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(redact_browser_typed_text_for_display(item, typed_text) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def redact_tool_args_for_display(tool_name: str, args: dict | None) -> dict | None:
|
||||
"""Return a copy of tool args safe for logs/progress UI.
|
||||
|
||||
For ``browser_type`` the ``text`` argument is run through the same
|
||||
secret-pattern redactor used for logs. Recognizable credentials (API
|
||||
keys, tokens) are masked before the value reaches tool progress
|
||||
notifications; normal typed text is left intact for debuggability.
|
||||
"""
|
||||
if not isinstance(args, dict):
|
||||
return args
|
||||
if tool_name == "browser_type" and isinstance(args.get("text"), str):
|
||||
safe_args = dict(args)
|
||||
safe_args["text"] = redact_sensitive_text(args["text"], force=True)
|
||||
return safe_args
|
||||
return args
|
||||
|
||||
|
||||
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
|
||||
if not isinstance(tasks, list):
|
||||
return 0, []
|
||||
goals: list[str] = []
|
||||
for task in tasks:
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
raw_goal = task.get("goal")
|
||||
goal = "?" if raw_goal is None else _oneline(str(raw_goal))
|
||||
goals.append(_truncate_preview(goal or "?", per_goal_len))
|
||||
return len(goals), goals
|
||||
|
||||
|
||||
def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
|
||||
"""Build a short preview of a tool call's primary argument for display.
|
||||
|
||||
@@ -419,36 +178,19 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
max_len = _tool_preview_max_len
|
||||
if not args:
|
||||
return None
|
||||
args = redact_tool_args_for_display(tool_name, args) or args
|
||||
primary_args = {
|
||||
"terminal": "command", "web_search": "query", "web_extract": "urls",
|
||||
"read_file": "path", "write_file": "path", "patch": "path",
|
||||
"search_files": "pattern", "browser_navigate": "url",
|
||||
"browser_click": "ref", "browser_type": "text",
|
||||
"image_generate": "prompt", "text_to_speech": "text",
|
||||
"vision_analyze": "question",
|
||||
"vision_analyze": "question", "mixture_of_agents": "user_prompt",
|
||||
"skill_view": "name", "skills_list": "category",
|
||||
"cronjob": "action",
|
||||
"execute_code": "code", "delegate_task": "goal",
|
||||
"clarify": "question", "skill_manage": "name",
|
||||
}
|
||||
|
||||
# delegate_task: show goal (single) or individual task goals (batch)
|
||||
if tool_name == "delegate_task":
|
||||
tasks = args.get("tasks")
|
||||
if tasks and isinstance(tasks, list):
|
||||
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=40)
|
||||
preview = (
|
||||
f"{task_count} tasks: " + " | ".join(goals)
|
||||
if goals else f"{len(tasks)} parallel tasks"
|
||||
)
|
||||
return _truncate_preview(preview, max_len)
|
||||
goal = args.get("goal", "")
|
||||
if goal is None:
|
||||
return None
|
||||
preview = _oneline(str(goal))
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "process":
|
||||
action = args.get("action", "")
|
||||
sid = args.get("session_id", "")
|
||||
@@ -473,23 +215,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
else:
|
||||
return f"planning {len(todos_arg)} task(s)"
|
||||
|
||||
if tool_name in {"terminal", "execute_code"}:
|
||||
key = "code" if tool_name == "execute_code" else "command"
|
||||
command = args.get(key)
|
||||
if command is None:
|
||||
return None
|
||||
preview = summarize_shell_command(str(command))
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "read_file":
|
||||
path = args.get("path") or args.get("file") or args.get("filepath")
|
||||
if path is None:
|
||||
return None
|
||||
label = Path(str(path).replace("\\", "/")).name or str(path)
|
||||
line_label = _read_file_line_label(args)
|
||||
preview = f"{label} {line_label}".strip()
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "session_search":
|
||||
query = _oneline(args.get("query", ""))
|
||||
return f"recall: \"{query[:25]}{'...' if len(query) > 25 else ''}\""
|
||||
@@ -537,122 +262,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
return preview
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Friendly tool labels (human-phrased verbs for built-in tools)
|
||||
#
|
||||
# Turns "web_search <query>" into "Searching the web for <query>" — the
|
||||
# ChatGPT-style "Searching…/Reading…" surface. Curated and built-in only:
|
||||
# we know each core tool's semantics, so the verb is fixed, not computed.
|
||||
# Custom/plugin/MCP tools have no entry and fall back to the raw preview.
|
||||
# =========================================================================
|
||||
|
||||
# Each entry maps a built-in tool name to its present-participle verb phrase.
|
||||
# A trailing space-then-preview is appended by build_tool_label() when the
|
||||
# tool's argument preview is available (e.g. "Reading docs/api.md").
|
||||
_TOOL_VERBS: dict[str, str] = {
|
||||
"web_search": "Searching the web",
|
||||
"web_extract": "Reading",
|
||||
"browser_navigate": "Browsing",
|
||||
"browser_click": "Clicking",
|
||||
"browser_type": "Typing",
|
||||
"read_file": "Reading",
|
||||
"write_file": "Writing",
|
||||
"patch": "Editing",
|
||||
"search_files": "Searching files",
|
||||
"terminal": "Running",
|
||||
"execute_code": "Running code",
|
||||
"image_generate": "Generating image",
|
||||
"video_generate": "Generating video",
|
||||
"text_to_speech": "Generating speech",
|
||||
"vision_analyze": "Looking at the image",
|
||||
"session_search": "Searching past sessions",
|
||||
"skill_view": "Reading skill",
|
||||
"skills_list": "Listing skills",
|
||||
"skill_manage": "Updating skill",
|
||||
"delegate_task": "Delegating",
|
||||
"cronjob": "Scheduling",
|
||||
"clarify": "Asking",
|
||||
"memory": "Updating memory",
|
||||
"todo": "Updating tasks",
|
||||
}
|
||||
|
||||
# Verbs that read better without the raw argument preview appended.
|
||||
_TOOL_VERBS_NO_PREVIEW: frozenset[str] = frozenset({
|
||||
"skills_list",
|
||||
"session_search",
|
||||
})
|
||||
|
||||
# Verbs that take a "for" connector before the preview (search-style phrasing):
|
||||
# "Searching the web for <query>" reads better than "Searching the web <query>".
|
||||
_TOOL_VERBS_FOR_CONNECTOR: frozenset[str] = frozenset({
|
||||
"web_search",
|
||||
"search_files",
|
||||
})
|
||||
|
||||
_friendly_tool_labels: bool = True
|
||||
|
||||
|
||||
def set_friendly_tool_labels(enabled: bool) -> None:
|
||||
"""Toggle friendly human-phrased tool labels (display.friendly_tool_labels)."""
|
||||
global _friendly_tool_labels
|
||||
_friendly_tool_labels = bool(enabled)
|
||||
|
||||
|
||||
def get_friendly_tool_labels() -> bool:
|
||||
"""Return whether friendly tool labels are enabled."""
|
||||
return _friendly_tool_labels
|
||||
|
||||
|
||||
def get_tool_verb(tool_name: str) -> str | None:
|
||||
"""Return the friendly verb for a built-in tool, or None.
|
||||
|
||||
Returns None when friendly labels are disabled or the tool has no curated
|
||||
verb (custom/plugin/MCP tools). Callers that already hold a computed
|
||||
argument preview can compose ``f"{verb} {preview}"`` themselves; use
|
||||
:func:`tool_verb_connector` to pick the right joiner.
|
||||
"""
|
||||
if not _friendly_tool_labels:
|
||||
return None
|
||||
return _TOOL_VERBS.get(tool_name)
|
||||
|
||||
|
||||
def tool_verb_connector(tool_name: str) -> str:
|
||||
"""Return the connector between a verb and its preview (" for " or " ")."""
|
||||
return " for " if tool_name in _TOOL_VERBS_FOR_CONNECTOR else " "
|
||||
|
||||
|
||||
def verb_drops_preview(tool_name: str) -> bool:
|
||||
"""Whether the verb should render alone, without the argument preview."""
|
||||
return tool_name in _TOOL_VERBS_NO_PREVIEW
|
||||
|
||||
|
||||
def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
|
||||
"""Build a human-phrased status label for a tool call.
|
||||
|
||||
For built-in tools with a known verb (``web_search`` -> "Searching the
|
||||
web for ..."), returns the verb optionally followed by the argument
|
||||
preview. For everything else (custom/plugin/MCP tools, or when friendly
|
||||
labels are disabled) returns the raw preview, so callers can use this as a
|
||||
drop-in replacement for :func:`build_tool_preview`.
|
||||
"""
|
||||
if not _friendly_tool_labels:
|
||||
return build_tool_preview(tool_name, args, max_len=max_len)
|
||||
|
||||
verb = _TOOL_VERBS.get(tool_name)
|
||||
if not verb:
|
||||
return build_tool_preview(tool_name, args, max_len=max_len)
|
||||
|
||||
if tool_name in _TOOL_VERBS_NO_PREVIEW:
|
||||
return verb
|
||||
|
||||
preview = build_tool_preview(tool_name, args, max_len=max_len)
|
||||
if not preview:
|
||||
return verb
|
||||
if tool_name in _TOOL_VERBS_FOR_CONNECTOR:
|
||||
return f"{verb} for {preview}"
|
||||
return f"{verb} {preview}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Inline diff previews for write actions
|
||||
# =========================================================================
|
||||
@@ -1259,7 +868,6 @@ def get_cute_tool_message(
|
||||
When *result* is provided the line is checked for failure indicators.
|
||||
Failed tool calls get a red prefix and an informational suffix.
|
||||
"""
|
||||
args = redact_tool_args_for_display(tool_name, args) or args
|
||||
dur = f"{duration:.1f}s"
|
||||
is_failure, failure_suffix = _detect_tool_failure(tool_name, result)
|
||||
skin_prefix = get_skin_tool_prefix()
|
||||
@@ -1297,7 +905,7 @@ def get_cute_tool_message(
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
return _wrap(f"┊ 💻 $ {_trunc(build_tool_preview(tool_name, args) or args.get('command', ''), 42)} {dur}")
|
||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||
if tool_name == "process":
|
||||
action = args.get("action", "?")
|
||||
sid = args.get("session_id", "")[:12]
|
||||
@@ -1305,7 +913,7 @@ def get_cute_tool_message(
|
||||
"wait": f"wait {sid}", "kill": f"kill {sid}", "write": f"write {sid}", "submit": f"submit {sid}"}
|
||||
return _wrap(f"┊ ⚙️ proc {labels.get(action, f'{action} {sid}')} {dur}")
|
||||
if tool_name == "read_file":
|
||||
return _wrap(f"┊ 📖 read {_trunc(build_tool_preview(tool_name, args) or args.get('path', ''), 42)} {dur}")
|
||||
return _wrap(f"┊ 📖 read {_path(args.get('path', ''))} {dur}")
|
||||
if tool_name == "write_file":
|
||||
return _wrap(f"┊ ✍️ write {_path(args.get('path', ''))} {dur}")
|
||||
if tool_name == "patch":
|
||||
@@ -1391,6 +999,8 @@ def get_cute_tool_message(
|
||||
return _wrap(f"┊ 🔊 speak {_trunc(args.get('text', ''), 30)} {dur}")
|
||||
if tool_name == "vision_analyze":
|
||||
return _wrap(f"┊ 👁️ vision {_trunc(args.get('question', ''), 30)} {dur}")
|
||||
if tool_name == "mixture_of_agents":
|
||||
return _wrap(f"┊ 🧠 reason {_trunc(args.get('user_prompt', ''), 30)} {dur}")
|
||||
if tool_name == "send_message":
|
||||
return _wrap(f"┊ 📨 send {args.get('target', '?')}: \"{_trunc(args.get('message', ''), 25)}\" {dur}")
|
||||
if tool_name == "cronjob":
|
||||
@@ -1409,10 +1019,7 @@ def get_cute_tool_message(
|
||||
if tool_name == "delegate_task":
|
||||
tasks = args.get("tasks")
|
||||
if tasks and isinstance(tasks, list):
|
||||
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=30)
|
||||
detail = " | ".join(goals) if goals else "parallel"
|
||||
count_label = task_count or len(tasks)
|
||||
return _wrap(f"┊ 🔀 delegate {count_label}x: {_trunc(detail, 35)} {dur}")
|
||||
return _wrap(f"┊ 🔀 delegate {len(tasks)} parallel tasks {dur}")
|
||||
return _wrap(f"┊ 🔀 delegate {_trunc(args.get('goal', ''), 35)} {dur}")
|
||||
|
||||
preview = build_tool_preview(tool_name, args) or ""
|
||||
|
||||
+17
-210
@@ -31,9 +31,6 @@ class FailoverReason(enum.Enum):
|
||||
# Billing / quota
|
||||
billing = "billing" # 402 or confirmed credit exhaustion — rotate immediately
|
||||
rate_limit = "rate_limit" # 429 or quota-based throttling — backoff then rotate
|
||||
# Upstream model rate-limited (aggregator 429) — fallback to a different
|
||||
# model, NOT credential rotation. The user's key is healthy.
|
||||
upstream_rate_limit = "upstream_rate_limit"
|
||||
|
||||
# Server-side
|
||||
overloaded = "overloaded" # 503/529 — provider overloaded, backoff
|
||||
@@ -136,31 +133,6 @@ _RATE_LIMIT_PATTERNS = [
|
||||
"servicequotaexceededexception",
|
||||
]
|
||||
|
||||
# Patterns that indicate provider-side overload, NOT a per-credential rate
|
||||
# limit or billing problem. The credential is valid — the server is just
|
||||
# busy — so the correct recovery is "back off and retry the same key", never
|
||||
# "rotate the credential" (rotating exhausts the pool while the endpoint is
|
||||
# still busy; a single-key user has nothing to rotate to). Some providers
|
||||
# (notably Z.AI / Zhipu) reuse HTTP 429 for server-wide overload, so the 429
|
||||
# status path matches the body against this list before falling through to
|
||||
# the rate_limit default. Phrases are kept narrow and overload-flavoured so a
|
||||
# normal rate-limit message ("you have been rate-limited") doesn't hit this
|
||||
# bucket. (#14038, #15297)
|
||||
_OVERLOADED_PATTERNS = [
|
||||
"overloaded",
|
||||
"temporarily overloaded",
|
||||
"service is temporarily overloaded",
|
||||
"service may be temporarily overloaded",
|
||||
"server is overloaded",
|
||||
"server overloaded",
|
||||
"service overloaded",
|
||||
"service is overloaded",
|
||||
"upstream overloaded",
|
||||
"currently overloaded",
|
||||
"at capacity",
|
||||
"over capacity",
|
||||
]
|
||||
|
||||
# Usage-limit patterns that need disambiguation (could be billing OR rate_limit)
|
||||
_USAGE_LIMIT_PATTERNS = [
|
||||
"usage limit",
|
||||
@@ -358,14 +330,6 @@ _CONTENT_POLICY_BLOCKED_PATTERNS = [
|
||||
# echo back; the underscore form is provider-specific enough.
|
||||
"content_filter",
|
||||
"responsibleaipolicyviolation",
|
||||
# MiniMax output-layer safety filter. The error string is surfaced
|
||||
# verbatim by MiniMax SDK / OpenAI-compatible endpoints, usually in the
|
||||
# form "output new_sensitive (1027)" when the model's *output* (often a
|
||||
# large tool-call argument block) trips the upstream safety filter and
|
||||
# the SSE stream is truncated mid-flight. ``new_sensitive`` is the
|
||||
# filter name and is narrow enough that billing / format / auth error
|
||||
# strings will not collide. See #32421.
|
||||
"new_sensitive",
|
||||
]
|
||||
|
||||
# Auth patterns (non-status-code signals)
|
||||
@@ -585,32 +549,14 @@ def classify_api_error(
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Anthropic thinking block recovery (400). Two distinct failure modes,
|
||||
# same recovery (strip all reasoning_details and retry without thinking
|
||||
# blocks — see the thinking_signature handler in conversation_loop.py):
|
||||
# 1. Signature mismatch: a thinking block is signed against the full
|
||||
# turn content; any upstream mutation (context compression, session
|
||||
# truncation, message merging) invalidates the signature.
|
||||
# Pattern: "signature" + "thinking".
|
||||
# 2. Frozen-block mutation: Anthropic rejects any change to the
|
||||
# thinking/redacted_thinking blocks in the *latest* assistant
|
||||
# message — "`thinking` or `redacted_thinking` blocks in the latest
|
||||
# assistant message cannot be modified. These blocks must remain as
|
||||
# they were in the original response." This carries no "signature"
|
||||
# token, so the original pattern missed it and the turn hard-aborted
|
||||
# as a non-retryable client error instead of self-healing.
|
||||
# Pattern: "thinking" + ("cannot be modified" | "must remain as they were").
|
||||
# Anthropic thinking block signature invalid (400).
|
||||
# Don't gate on provider — OpenRouter proxies Anthropic errors, so the
|
||||
# provider may be "openrouter" even though the error is Anthropic-specific.
|
||||
# The combined patterns are unique enough.
|
||||
# The message pattern ("signature" + "thinking") is unique enough.
|
||||
if (
|
||||
status_code == 400
|
||||
and "signature" in error_msg
|
||||
and "thinking" in error_msg
|
||||
and (
|
||||
"signature" in error_msg
|
||||
or "cannot be modified" in error_msg
|
||||
or "must remain as they were" in error_msg
|
||||
)
|
||||
):
|
||||
return _result(
|
||||
FailoverReason.thinking_signature,
|
||||
@@ -753,26 +699,6 @@ def classify_api_error(
|
||||
|
||||
is_disconnect = any(p in error_msg for p in _SERVER_DISCONNECT_PATTERNS)
|
||||
if is_disconnect and not status_code:
|
||||
# Reasoning-model override: a transport disconnect on a reasoning
|
||||
# model is much more likely the upstream proxy idle-killing a
|
||||
# long thinking stream than a true context overflow — even on
|
||||
# large sessions. The default disconnect+large-session routing
|
||||
# below would otherwise send the user into the compression
|
||||
# branch (should_compress=True) and silently delete
|
||||
# conversation history on a phantom context-length error.
|
||||
# Reasoning models have multi-minute thinking phases that
|
||||
# routinely exceed the cloud gateway's idle window (NVIDIA
|
||||
# NIM ~120s — first-party repro at NVIDIA/NemoClaw#4846;
|
||||
# OpenAI worker / Anthropic stream-idle similar). The
|
||||
# per-reasoning-model stale-timeout floor in
|
||||
# agent/reasoning_timeouts.py raises the stale-detector
|
||||
# threshold to tolerate long thinking, so a true
|
||||
# transport-layer failure here is recoverable via the retry
|
||||
# path — not via context compression. Reclassify as timeout.
|
||||
# (Part 1 of Fixes #52310.)
|
||||
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
|
||||
if get_reasoning_stale_timeout_floor(model) is not None:
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
# Absolute token/message-count thresholds are only a proxy for smaller
|
||||
# context windows. Large-context sessions can have hundreds of
|
||||
# messages while still being far below their actual token budget.
|
||||
@@ -899,35 +825,7 @@ def _classify_by_status(
|
||||
)
|
||||
|
||||
if status_code == 429:
|
||||
# Already checked long_context_tier above. Some providers (notably
|
||||
# Z.AI / Zhipu) reuse HTTP 429 for server-wide overload — same status
|
||||
# code as a true per-credential rate limit, but the credential is
|
||||
# valid and the correct recovery is "back off and retry the same key",
|
||||
# NOT "rotate the credential" (which exhausts the pool while the
|
||||
# endpoint is still busy, and does nothing for a single-key user).
|
||||
# Disambiguate on the error body so an overload 429 takes the
|
||||
# transient-overload path instead of burning the pool. (#14038)
|
||||
if any(p in error_msg for p in _OVERLOADED_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.overloaded,
|
||||
retryable=True,
|
||||
)
|
||||
# Distinguish an OpenRouter-aggregator upstream 429 (an upstream model
|
||||
# like DeepSeek rate-limited OpenRouter's aggregate traffic) from an
|
||||
# account-level 429 (the user's key is actually throttled). OpenRouter
|
||||
# wraps upstream errors with the outer message "Provider returned
|
||||
# error" — the user's key is healthy, so marking it exhausted / rotating
|
||||
# is wrong and burns the key for ~24min. Fall back to a different model.
|
||||
if _is_openrouter_upstream_error(body, provider):
|
||||
upstream_provider = _extract_upstream_provider_name(body)
|
||||
ctx = {"upstream_provider": upstream_provider} if upstream_provider else {}
|
||||
return result_fn(
|
||||
FailoverReason.upstream_rate_limit,
|
||||
retryable=True,
|
||||
should_rotate_credential=False,
|
||||
should_fallback=True,
|
||||
error_context=ctx,
|
||||
)
|
||||
# Already checked long_context_tier above; this is a normal rate limit
|
||||
return result_fn(
|
||||
FailoverReason.rate_limit,
|
||||
retryable=True,
|
||||
@@ -1068,34 +966,6 @@ def _classify_400(
|
||||
should_fallback=False,
|
||||
)
|
||||
|
||||
# Request-validation errors (unsupported / unknown parameter) MUST be
|
||||
# checked BEFORE context_overflow. A GPT-5 model rejecting max_tokens
|
||||
# returns:
|
||||
# "Unsupported parameter: 'max_tokens' is not supported with this model.
|
||||
# Use 'max_completion_tokens' instead."
|
||||
# That string contains the literal substring "max_tokens", which is one of
|
||||
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
|
||||
# misclassified as context_overflow, routed into the compression loop,
|
||||
# re-sent with the same bad parameter, and ends in "Cannot compress
|
||||
# further". These errors are deterministic (every retry gets the identical
|
||||
# rejection), so classify as a non-retryable format_error and fall back.
|
||||
#
|
||||
# NOTE: we deliberately do NOT key off the generic ``invalid_request_error``
|
||||
# code here — OpenAI stamps that same code on genuine context-overflow 400s,
|
||||
# so matching it would mis-route real overflows away from compression. The
|
||||
# unambiguous signals are the explicit "unsupported/unknown parameter"
|
||||
# message text and the specific parameter-level error codes.
|
||||
if (
|
||||
any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS
|
||||
if p != "invalid_request_error")
|
||||
or error_code_lower in {"unknown_parameter", "unsupported_parameter"}
|
||||
):
|
||||
return result_fn(
|
||||
FailoverReason.format_error,
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Context overflow from 400
|
||||
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
||||
return result_fn(
|
||||
@@ -1278,17 +1148,6 @@ def _classify_by_message(
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Overloaded / server-busy patterns — must come BEFORE the rate_limit and
|
||||
# billing checks so that a message-only "overloaded" (no 503/529 status,
|
||||
# e.g. some Anthropic-compatible proxies) classifies as a transient
|
||||
# overload (backoff + retry) instead of falling through to `unknown` or
|
||||
# incorrectly triggering credential rotation.
|
||||
if any(p in error_msg for p in _OVERLOADED_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.overloaded,
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
# Billing patterns
|
||||
if any(p in error_msg for p in _BILLING_PATTERNS):
|
||||
return result_fn(
|
||||
@@ -1378,25 +1237,19 @@ def _extract_status_code(error: Exception) -> Optional[int]:
|
||||
|
||||
|
||||
def _extract_error_body(error: Exception) -> dict:
|
||||
"""Extract the structured error body from an SDK exception or its cause chain."""
|
||||
current = error
|
||||
for _ in range(5): # Match _extract_status_code() traversal depth.
|
||||
body = getattr(current, "body", None)
|
||||
if isinstance(body, dict):
|
||||
return body
|
||||
# Some errors have .response.json()
|
||||
response = getattr(current, "response", None)
|
||||
if response is not None:
|
||||
try:
|
||||
json_body = response.json()
|
||||
if isinstance(json_body, dict):
|
||||
return json_body
|
||||
except Exception:
|
||||
pass
|
||||
cause = getattr(current, "__cause__", None) or getattr(current, "__context__", None)
|
||||
if cause is None or cause is current:
|
||||
break
|
||||
current = cause
|
||||
"""Extract the structured error body from an SDK exception."""
|
||||
body = getattr(error, "body", None)
|
||||
if isinstance(body, dict):
|
||||
return body
|
||||
# Some errors have .response.json()
|
||||
response = getattr(error, "response", None)
|
||||
if response is not None:
|
||||
try:
|
||||
json_body = response.json()
|
||||
if isinstance(json_body, dict):
|
||||
return json_body
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
@@ -1464,49 +1317,3 @@ def _extract_message(error: Exception, body: dict) -> str:
|
||||
return msg.strip()[:500]
|
||||
# Fallback to str(error)
|
||||
return str(error)[:500]
|
||||
|
||||
|
||||
def _is_openrouter_upstream_error(body: Any, provider: str) -> bool:
|
||||
"""Detect OpenRouter's aggregator-wrapped upstream provider errors.
|
||||
|
||||
OpenRouter returns errors from upstream model providers (DeepSeek,
|
||||
Anthropic, etc.) wrapped with the outer message "Provider returned error"
|
||||
and the real error nested in ``metadata.raw``. This signal means the
|
||||
user's OpenRouter key is healthy — the upstream provider is the one that
|
||||
failed — so credential rotation is the wrong recovery.
|
||||
"""
|
||||
if not isinstance(body, dict):
|
||||
return False
|
||||
provider_lower = (provider or "").strip().lower()
|
||||
err = body.get("error")
|
||||
if not isinstance(err, dict):
|
||||
return False
|
||||
outer_msg = str(err.get("message") or "").strip().lower()
|
||||
if outer_msg != "provider returned error":
|
||||
return False
|
||||
# Require either the explicit OpenRouter provider OR the metadata shape
|
||||
# that only OpenRouter produces (metadata.raw / metadata.provider_name).
|
||||
if provider_lower == "openrouter":
|
||||
return True
|
||||
metadata = err.get("metadata")
|
||||
if isinstance(metadata, dict) and (
|
||||
"raw" in metadata or "provider_name" in metadata
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extract_upstream_provider_name(body: Any) -> Optional[str]:
|
||||
"""Pull the upstream provider name out of OpenRouter's error metadata."""
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
err = body.get("error")
|
||||
if not isinstance(err, dict):
|
||||
return None
|
||||
metadata = err.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
name = metadata.get("provider_name")
|
||||
if isinstance(name, str) and name.strip():
|
||||
return name.strip()
|
||||
return None
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
class SSLConfigurationError(Exception):
|
||||
"""Raised when SSL/TLS certificate bundle configuration fails."""
|
||||
pass
|
||||
+29
-25
@@ -46,6 +46,11 @@ def build_write_denied_paths(home: str) -> set[str]:
|
||||
# Top-level Anthropic PKCE credential store remains sensitive even
|
||||
# when a profile is active; default/non-profile sessions still read it.
|
||||
str(hermes_root / ".anthropic_oauth.json"),
|
||||
os.path.join(home, ".bashrc"),
|
||||
os.path.join(home, ".zshrc"),
|
||||
os.path.join(home, ".profile"),
|
||||
os.path.join(home, ".bash_profile"),
|
||||
os.path.join(home, ".zprofile"),
|
||||
os.path.join(home, ".netrc"),
|
||||
os.path.join(home, ".pgpass"),
|
||||
os.path.join(home, ".npmrc"),
|
||||
@@ -77,22 +82,15 @@ def build_write_denied_prefixes(home: str) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def get_safe_write_roots() -> set[str]:
|
||||
"""Return resolved HERMES_WRITE_SAFE_ROOT paths. Supports multiple directories
|
||||
separated by ``os.pathsep`` (``:`` on Unix, ``;`` on Windows).
|
||||
E.g., ``/opt/data:/var/www/html`` on Unix, ``C:\\data;D:\\www`` on Windows."""
|
||||
env = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
|
||||
if not env:
|
||||
return set()
|
||||
roots: set[str] = set()
|
||||
for path in env.split(os.pathsep):
|
||||
if path:
|
||||
try:
|
||||
resolved = os.path.realpath(os.path.expanduser(path))
|
||||
roots.add(resolved)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return roots
|
||||
def get_safe_write_root() -> Optional[str]:
|
||||
"""Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset."""
|
||||
root = os.getenv("HERMES_WRITE_SAFE_ROOT", "")
|
||||
if not root:
|
||||
return None
|
||||
try:
|
||||
return os.path.realpath(os.path.expanduser(root))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_write_denied(path: str) -> bool:
|
||||
@@ -106,6 +104,12 @@ def is_write_denied(path: str) -> bool:
|
||||
if resolved.startswith(prefix):
|
||||
return True
|
||||
|
||||
# Hermes control-plane files: block both the ACTIVE profile's view
|
||||
# (hermes_home) AND the global root view. Without the root pass, a
|
||||
# profile-mode session leaves <root>/auth.json + <root>/config.yaml
|
||||
# writable — letting a prompt-injected write_file overwrite the global
|
||||
# files that every profile inherits from (same shape as #15981).
|
||||
control_file_names = ("auth.json", "config.yaml", "webhook_subscriptions.json")
|
||||
mcp_tokens_dir_name = "mcp-tokens"
|
||||
|
||||
hermes_dirs = []
|
||||
@@ -118,6 +122,12 @@ def is_write_denied(path: str) -> bool:
|
||||
continue
|
||||
|
||||
for base_real in hermes_dirs:
|
||||
for name in control_file_names:
|
||||
try:
|
||||
if resolved == os.path.realpath(os.path.join(base_real, name)):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
|
||||
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
|
||||
@@ -131,15 +141,9 @@ def is_write_denied(path: str) -> bool:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
safe_roots = get_safe_write_roots()
|
||||
if safe_roots:
|
||||
allowed = False
|
||||
for safe_root in safe_roots:
|
||||
if resolved == safe_root or resolved.startswith(safe_root + os.sep):
|
||||
allowed = True
|
||||
break
|
||||
if not allowed:
|
||||
return True
|
||||
safe_root = get_safe_write_root()
|
||||
if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -0,0 +1,909 @@
|
||||
"""OpenAI-compatible facade that talks to Google's Cloud Code Assist backend.
|
||||
|
||||
This adapter lets Hermes use the ``google-gemini-cli`` provider as if it were
|
||||
a standard OpenAI-shaped chat completion endpoint, while the underlying HTTP
|
||||
traffic goes to ``cloudcode-pa.googleapis.com/v1internal:{generateContent,
|
||||
streamGenerateContent}`` with a Bearer access token obtained via OAuth PKCE.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
- ``GeminiCloudCodeClient`` exposes ``.chat.completions.create(**kwargs)``
|
||||
mirroring the subset of the OpenAI SDK that ``run_agent.py`` uses.
|
||||
- Incoming OpenAI ``messages[]`` / ``tools[]`` / ``tool_choice`` are translated
|
||||
to Gemini's native ``contents[]`` / ``tools[].functionDeclarations`` /
|
||||
``toolConfig`` / ``systemInstruction`` shape.
|
||||
- The request body is wrapped ``{project, model, user_prompt_id, request}``
|
||||
per Code Assist API expectations.
|
||||
- Responses (``candidates[].content.parts[]``) are converted back to
|
||||
OpenAI ``choices[0].message`` shape with ``content`` + ``tool_calls``.
|
||||
- Streaming uses SSE (``?alt=sse``) and yields OpenAI-shaped delta chunks.
|
||||
|
||||
Attribution
|
||||
-----------
|
||||
Translation semantics follow jenslys/opencode-gemini-auth (MIT) and the public
|
||||
Gemini API docs. Request envelope shape
|
||||
(``{project, model, user_prompt_id, request}``) is documented nowhere; it is
|
||||
reverse-engineered from the opencode-gemini-auth and clawdbot implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent import google_oauth
|
||||
from agent.gemini_schema import sanitize_gemini_tool_parameters
|
||||
from agent.google_code_assist import (
|
||||
CODE_ASSIST_ENDPOINT,
|
||||
CodeAssistError,
|
||||
ProjectContext,
|
||||
resolve_project_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request translation: OpenAI → Gemini
|
||||
# =============================================================================
|
||||
|
||||
_ROLE_MAP_OPENAI_TO_GEMINI = {
|
||||
"user": "user",
|
||||
"assistant": "model",
|
||||
"system": "user", # handled separately via systemInstruction
|
||||
"tool": "user", # functionResponse is wrapped in a user-role turn
|
||||
"function": "user",
|
||||
}
|
||||
|
||||
|
||||
def _coerce_content_to_text(content: Any) -> str:
|
||||
"""OpenAI content may be str or a list of parts; reduce to plain text."""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
pieces: List[str] = []
|
||||
for p in content:
|
||||
if isinstance(p, str):
|
||||
pieces.append(p)
|
||||
elif isinstance(p, dict):
|
||||
if p.get("type") == "text" and isinstance(p.get("text"), str):
|
||||
pieces.append(p["text"])
|
||||
# Multimodal (image_url, etc.) — stub for now; log and skip
|
||||
elif p.get("type") in {"image_url", "input_audio"}:
|
||||
logger.debug("Dropping multimodal part (not yet supported): %s", p.get("type"))
|
||||
return "\n".join(pieces)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _translate_tool_call_to_gemini(tool_call: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""OpenAI tool_call -> Gemini functionCall part."""
|
||||
fn = tool_call.get("function") or {}
|
||||
args_raw = fn.get("arguments", "")
|
||||
try:
|
||||
args = json.loads(args_raw) if isinstance(args_raw, str) and args_raw else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {"_raw": args_raw}
|
||||
if not isinstance(args, dict):
|
||||
args = {"_value": args}
|
||||
return {
|
||||
"functionCall": {
|
||||
"name": fn.get("name") or "",
|
||||
"args": args,
|
||||
},
|
||||
# Sentinel signature — matches opencode-gemini-auth's approach.
|
||||
# Without this, Code Assist rejects function calls that originated
|
||||
# outside its own chain.
|
||||
"thoughtSignature": "skip_thought_signature_validator",
|
||||
}
|
||||
|
||||
|
||||
def _translate_tool_result_to_gemini(message: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""OpenAI tool-role message -> Gemini functionResponse part.
|
||||
|
||||
The function name isn't in the OpenAI tool message directly; it must be
|
||||
passed via the assistant message that issued the call. For simplicity we
|
||||
look up ``name`` on the message (OpenAI SDK copies it there) or on the
|
||||
``tool_call_id`` cross-reference.
|
||||
"""
|
||||
name = str(message.get("name") or message.get("tool_call_id") or "tool")
|
||||
content = _coerce_content_to_text(message.get("content"))
|
||||
# Gemini expects the response as a dict under `response`. We wrap plain
|
||||
# text in {"output": "..."}.
|
||||
try:
|
||||
parsed = json.loads(content) if content.strip().startswith(("{", "[")) else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
response = parsed if isinstance(parsed, dict) else {"output": content}
|
||||
return {
|
||||
"functionResponse": {
|
||||
"name": name,
|
||||
"response": response,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_gemini_contents(
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
"""Convert OpenAI messages[] to Gemini contents[] + systemInstruction."""
|
||||
system_text_parts: List[str] = []
|
||||
contents: List[Dict[str, Any]] = []
|
||||
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
role = str(msg.get("role") or "user")
|
||||
|
||||
if role == "system":
|
||||
system_text_parts.append(_coerce_content_to_text(msg.get("content")))
|
||||
continue
|
||||
|
||||
# Tool result message — emit a user-role turn with functionResponse
|
||||
if role == "tool" or role == "function":
|
||||
contents.append({
|
||||
"role": "user",
|
||||
"parts": [_translate_tool_result_to_gemini(msg)],
|
||||
})
|
||||
continue
|
||||
|
||||
gemini_role = _ROLE_MAP_OPENAI_TO_GEMINI.get(role, "user")
|
||||
parts: List[Dict[str, Any]] = []
|
||||
|
||||
text = _coerce_content_to_text(msg.get("content"))
|
||||
if text:
|
||||
parts.append({"text": text})
|
||||
|
||||
# Assistant messages can carry tool_calls
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
if isinstance(tool_calls, list):
|
||||
for tc in tool_calls:
|
||||
if isinstance(tc, dict):
|
||||
parts.append(_translate_tool_call_to_gemini(tc))
|
||||
|
||||
if not parts:
|
||||
# Gemini rejects empty parts; skip the turn entirely
|
||||
continue
|
||||
|
||||
contents.append({"role": gemini_role, "parts": parts})
|
||||
|
||||
system_instruction: Optional[Dict[str, Any]] = None
|
||||
joined_system = "\n".join(p for p in system_text_parts if p).strip()
|
||||
if joined_system:
|
||||
system_instruction = {
|
||||
"role": "system",
|
||||
"parts": [{"text": joined_system}],
|
||||
}
|
||||
|
||||
return contents, system_instruction
|
||||
|
||||
|
||||
def _translate_tools_to_gemini(tools: Any) -> List[Dict[str, Any]]:
|
||||
"""OpenAI tools[] -> Gemini tools[].functionDeclarations[]."""
|
||||
if not isinstance(tools, list) or not tools:
|
||||
return []
|
||||
declarations: List[Dict[str, Any]] = []
|
||||
for t in tools:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
fn = t.get("function") or {}
|
||||
if not isinstance(fn, dict):
|
||||
continue
|
||||
name = fn.get("name")
|
||||
if not name:
|
||||
continue
|
||||
decl = {"name": str(name)}
|
||||
if fn.get("description"):
|
||||
decl["description"] = str(fn["description"])
|
||||
params = fn.get("parameters")
|
||||
if isinstance(params, dict):
|
||||
decl["parameters"] = sanitize_gemini_tool_parameters(params)
|
||||
declarations.append(decl)
|
||||
if not declarations:
|
||||
return []
|
||||
return [{"functionDeclarations": declarations}]
|
||||
|
||||
|
||||
def _translate_tool_choice_to_gemini(tool_choice: Any) -> Optional[Dict[str, Any]]:
|
||||
"""OpenAI tool_choice -> Gemini toolConfig.functionCallingConfig."""
|
||||
if tool_choice is None:
|
||||
return None
|
||||
if isinstance(tool_choice, str):
|
||||
if tool_choice == "auto":
|
||||
return {"functionCallingConfig": {"mode": "AUTO"}}
|
||||
if tool_choice == "required":
|
||||
return {"functionCallingConfig": {"mode": "ANY"}}
|
||||
if tool_choice == "none":
|
||||
return {"functionCallingConfig": {"mode": "NONE"}}
|
||||
if isinstance(tool_choice, dict):
|
||||
fn = tool_choice.get("function") or {}
|
||||
name = fn.get("name")
|
||||
if name:
|
||||
return {
|
||||
"functionCallingConfig": {
|
||||
"mode": "ANY",
|
||||
"allowedFunctionNames": [str(name)],
|
||||
},
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_thinking_config(config: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Accept thinkingBudget / thinkingLevel / includeThoughts (+ snake_case)."""
|
||||
if not isinstance(config, dict) or not config:
|
||||
return None
|
||||
budget = config.get("thinkingBudget", config.get("thinking_budget"))
|
||||
level = config.get("thinkingLevel", config.get("thinking_level"))
|
||||
include = config.get("includeThoughts", config.get("include_thoughts"))
|
||||
normalized: Dict[str, Any] = {}
|
||||
if isinstance(budget, (int, float)):
|
||||
normalized["thinkingBudget"] = int(budget)
|
||||
if isinstance(level, str) and level.strip():
|
||||
normalized["thinkingLevel"] = level.strip().lower()
|
||||
if isinstance(include, bool):
|
||||
normalized["includeThoughts"] = include
|
||||
return normalized or None
|
||||
|
||||
|
||||
def build_gemini_request(
|
||||
*,
|
||||
messages: List[Dict[str, Any]],
|
||||
tools: Any = None,
|
||||
tool_choice: Any = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
top_p: Optional[float] = None,
|
||||
stop: Any = None,
|
||||
thinking_config: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the inner Gemini request body (goes inside ``request`` wrapper)."""
|
||||
contents, system_instruction = _build_gemini_contents(messages)
|
||||
|
||||
body: Dict[str, Any] = {"contents": contents}
|
||||
if system_instruction is not None:
|
||||
body["systemInstruction"] = system_instruction
|
||||
|
||||
gemini_tools = _translate_tools_to_gemini(tools)
|
||||
if gemini_tools:
|
||||
body["tools"] = gemini_tools
|
||||
tool_cfg = _translate_tool_choice_to_gemini(tool_choice)
|
||||
if tool_cfg is not None:
|
||||
body["toolConfig"] = tool_cfg
|
||||
|
||||
generation_config: Dict[str, Any] = {}
|
||||
if isinstance(temperature, (int, float)):
|
||||
generation_config["temperature"] = float(temperature)
|
||||
if isinstance(max_tokens, int) and max_tokens > 0:
|
||||
generation_config["maxOutputTokens"] = max_tokens
|
||||
if isinstance(top_p, (int, float)):
|
||||
generation_config["topP"] = float(top_p)
|
||||
if isinstance(stop, str) and stop:
|
||||
generation_config["stopSequences"] = [stop]
|
||||
elif isinstance(stop, list) and stop:
|
||||
generation_config["stopSequences"] = [str(s) for s in stop if s]
|
||||
normalized_thinking = _normalize_thinking_config(thinking_config)
|
||||
if normalized_thinking:
|
||||
generation_config["thinkingConfig"] = normalized_thinking
|
||||
if generation_config:
|
||||
body["generationConfig"] = generation_config
|
||||
|
||||
return body
|
||||
|
||||
|
||||
def wrap_code_assist_request(
|
||||
*,
|
||||
project_id: str,
|
||||
model: str,
|
||||
inner_request: Dict[str, Any],
|
||||
user_prompt_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Wrap the inner Gemini request in the Code Assist envelope."""
|
||||
return {
|
||||
"project": project_id,
|
||||
"model": model,
|
||||
"user_prompt_id": user_prompt_id or str(uuid.uuid4()),
|
||||
"request": inner_request,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response translation: Gemini → OpenAI
|
||||
# =============================================================================
|
||||
|
||||
def _translate_gemini_response(
|
||||
resp: Dict[str, Any],
|
||||
model: str,
|
||||
) -> SimpleNamespace:
|
||||
"""Non-streaming Gemini response -> OpenAI-shaped SimpleNamespace.
|
||||
|
||||
Code Assist wraps the actual Gemini response inside ``response``, so we
|
||||
unwrap it first if present.
|
||||
"""
|
||||
inner = resp.get("response") if isinstance(resp.get("response"), dict) else resp
|
||||
|
||||
candidates = inner.get("candidates") or []
|
||||
if not isinstance(candidates, list) or not candidates:
|
||||
return _empty_response(model)
|
||||
|
||||
cand = candidates[0]
|
||||
content_obj = cand.get("content") if isinstance(cand, dict) else {}
|
||||
parts = content_obj.get("parts") if isinstance(content_obj, dict) else []
|
||||
|
||||
text_pieces: List[str] = []
|
||||
reasoning_pieces: List[str] = []
|
||||
tool_calls: List[SimpleNamespace] = []
|
||||
|
||||
for i, part in enumerate(parts or []):
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
# Thought parts are model's internal reasoning — surface as reasoning,
|
||||
# don't mix into content.
|
||||
if part.get("thought") is True:
|
||||
if isinstance(part.get("text"), str):
|
||||
reasoning_pieces.append(part["text"])
|
||||
continue
|
||||
if isinstance(part.get("text"), str):
|
||||
text_pieces.append(part["text"])
|
||||
continue
|
||||
fc = part.get("functionCall")
|
||||
if isinstance(fc, dict) and fc.get("name"):
|
||||
try:
|
||||
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
args_str = "{}"
|
||||
tool_calls.append(SimpleNamespace(
|
||||
id=f"call_{uuid.uuid4().hex[:12]}",
|
||||
type="function",
|
||||
index=i,
|
||||
function=SimpleNamespace(name=str(fc["name"]), arguments=args_str),
|
||||
))
|
||||
|
||||
finish_reason = "tool_calls" if tool_calls else _map_gemini_finish_reason(
|
||||
str(cand.get("finishReason") or "")
|
||||
)
|
||||
|
||||
usage_meta = inner.get("usageMetadata") or {}
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=int(usage_meta.get("promptTokenCount") or 0),
|
||||
completion_tokens=int(usage_meta.get("candidatesTokenCount") or 0),
|
||||
total_tokens=int(usage_meta.get("totalTokenCount") or 0),
|
||||
prompt_tokens_details=SimpleNamespace(
|
||||
cached_tokens=int(usage_meta.get("cachedContentTokenCount") or 0),
|
||||
),
|
||||
)
|
||||
|
||||
message = SimpleNamespace(
|
||||
role="assistant",
|
||||
content="".join(text_pieces) if text_pieces else None,
|
||||
tool_calls=tool_calls or None,
|
||||
reasoning="".join(reasoning_pieces) or None,
|
||||
reasoning_content="".join(reasoning_pieces) or None,
|
||||
reasoning_details=None,
|
||||
)
|
||||
choice = SimpleNamespace(
|
||||
index=0,
|
||||
message=message,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
model=model,
|
||||
choices=[choice],
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
def _empty_response(model: str) -> SimpleNamespace:
|
||||
message = SimpleNamespace(
|
||||
role="assistant", content="", tool_calls=None,
|
||||
reasoning=None, reasoning_content=None, reasoning_details=None,
|
||||
)
|
||||
choice = SimpleNamespace(index=0, message=message, finish_reason="stop")
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=0, completion_tokens=0, total_tokens=0,
|
||||
prompt_tokens_details=SimpleNamespace(cached_tokens=0),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||
object="chat.completion",
|
||||
created=int(time.time()),
|
||||
model=model,
|
||||
choices=[choice],
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
def _map_gemini_finish_reason(reason: str) -> str:
|
||||
mapping = {
|
||||
"STOP": "stop",
|
||||
"MAX_TOKENS": "length",
|
||||
"SAFETY": "content_filter",
|
||||
"RECITATION": "content_filter",
|
||||
"OTHER": "stop",
|
||||
}
|
||||
return mapping.get(reason.upper(), "stop")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Streaming SSE iterator
|
||||
# =============================================================================
|
||||
|
||||
class _GeminiStreamChunk(SimpleNamespace):
|
||||
"""Mimics an OpenAI ChatCompletionChunk with .choices[0].delta."""
|
||||
pass
|
||||
|
||||
|
||||
def _make_stream_chunk(
|
||||
*,
|
||||
model: str,
|
||||
content: str = "",
|
||||
tool_call_delta: Optional[Dict[str, Any]] = None,
|
||||
finish_reason: Optional[str] = None,
|
||||
reasoning: str = "",
|
||||
) -> _GeminiStreamChunk:
|
||||
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:
|
||||
delta_kwargs["tool_calls"] = [SimpleNamespace(
|
||||
index=tool_call_delta.get("index", 0),
|
||||
id=tool_call_delta.get("id") or f"call_{uuid.uuid4().hex[:12]}",
|
||||
type="function",
|
||||
function=SimpleNamespace(
|
||||
name=tool_call_delta.get("name") or "",
|
||||
arguments=tool_call_delta.get("arguments") or "",
|
||||
),
|
||||
)]
|
||||
if reasoning:
|
||||
delta_kwargs["reasoning"] = reasoning
|
||||
delta_kwargs["reasoning_content"] = reasoning
|
||||
delta = SimpleNamespace(**delta_kwargs)
|
||||
choice = SimpleNamespace(index=0, delta=delta, finish_reason=finish_reason)
|
||||
return _GeminiStreamChunk(
|
||||
id=f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||
object="chat.completion.chunk",
|
||||
created=int(time.time()),
|
||||
model=model,
|
||||
choices=[choice],
|
||||
usage=None,
|
||||
)
|
||||
|
||||
|
||||
def _iter_sse_events(response: httpx.Response) -> Iterator[Dict[str, Any]]:
|
||||
"""Parse Server-Sent Events from an httpx streaming response."""
|
||||
buffer = ""
|
||||
for chunk in response.iter_text():
|
||||
if not chunk:
|
||||
continue
|
||||
buffer += chunk
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
line = line.rstrip("\r")
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
yield json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Non-JSON SSE line: %s", data[:200])
|
||||
|
||||
|
||||
def _translate_stream_event(
|
||||
event: Dict[str, Any],
|
||||
model: str,
|
||||
tool_call_counter: List[int],
|
||||
) -> List[_GeminiStreamChunk]:
|
||||
"""Unwrap Code Assist envelope and emit OpenAI-shaped chunk(s).
|
||||
|
||||
``tool_call_counter`` is a single-element list used as a mutable counter
|
||||
across events in the same stream. Each ``functionCall`` part gets a
|
||||
fresh, unique OpenAI ``index`` — keying by function name would collide
|
||||
whenever the model issues parallel calls to the same tool (e.g. reading
|
||||
three files in one turn).
|
||||
"""
|
||||
inner = event.get("response") if isinstance(event.get("response"), dict) else event
|
||||
candidates = inner.get("candidates") or []
|
||||
if not candidates:
|
||||
return []
|
||||
cand = candidates[0]
|
||||
if not isinstance(cand, dict):
|
||||
return []
|
||||
|
||||
chunks: List[_GeminiStreamChunk] = []
|
||||
|
||||
content = cand.get("content") or {}
|
||||
parts = content.get("parts") if isinstance(content, dict) else []
|
||||
for part in parts or []:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("thought") is True and isinstance(part.get("text"), str):
|
||||
chunks.append(_make_stream_chunk(
|
||||
model=model, reasoning=part["text"],
|
||||
))
|
||||
continue
|
||||
if isinstance(part.get("text"), str) and part["text"]:
|
||||
chunks.append(_make_stream_chunk(model=model, content=part["text"]))
|
||||
fc = part.get("functionCall")
|
||||
if isinstance(fc, dict) and fc.get("name"):
|
||||
name = str(fc["name"])
|
||||
idx = tool_call_counter[0]
|
||||
tool_call_counter[0] += 1
|
||||
try:
|
||||
args_str = json.dumps(fc.get("args") or {}, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
args_str = "{}"
|
||||
chunks.append(_make_stream_chunk(
|
||||
model=model,
|
||||
tool_call_delta={
|
||||
"index": idx,
|
||||
"name": name,
|
||||
"arguments": args_str,
|
||||
},
|
||||
))
|
||||
|
||||
finish_reason_raw = str(cand.get("finishReason") or "")
|
||||
if finish_reason_raw:
|
||||
mapped = _map_gemini_finish_reason(finish_reason_raw)
|
||||
if tool_call_counter[0] > 0:
|
||||
mapped = "tool_calls"
|
||||
chunks.append(_make_stream_chunk(model=model, finish_reason=mapped))
|
||||
return chunks
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GeminiCloudCodeClient — OpenAI-compatible facade
|
||||
# =============================================================================
|
||||
|
||||
MARKER_BASE_URL = "cloudcode-pa://google"
|
||||
|
||||
|
||||
class _GeminiChatCompletions:
|
||||
def __init__(self, client: "GeminiCloudCodeClient"):
|
||||
self._client = client
|
||||
|
||||
def create(self, **kwargs: Any) -> Any:
|
||||
return self._client._create_chat_completion(**kwargs)
|
||||
|
||||
|
||||
class _GeminiChatNamespace:
|
||||
def __init__(self, client: "GeminiCloudCodeClient"):
|
||||
self.completions = _GeminiChatCompletions(client)
|
||||
|
||||
|
||||
class GeminiCloudCodeClient:
|
||||
"""Minimal OpenAI-SDK-compatible facade over Code Assist v1internal."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
default_headers: Optional[Dict[str, str]] = None,
|
||||
project_id: str = "",
|
||||
**_: Any,
|
||||
):
|
||||
# `api_key` here is a dummy — real auth is the OAuth access token
|
||||
# fetched on every call via agent.google_oauth.get_valid_access_token().
|
||||
# We accept the kwarg for openai.OpenAI interface parity.
|
||||
self.api_key = api_key or "google-oauth"
|
||||
self.base_url = base_url or MARKER_BASE_URL
|
||||
self._default_headers = dict(default_headers or {})
|
||||
self._configured_project_id = project_id
|
||||
self._project_context: Optional[ProjectContext] = None
|
||||
self._project_context_lock = False # simple single-thread guard
|
||||
self.chat = _GeminiChatNamespace(self)
|
||||
self.is_closed = False
|
||||
self._http = httpx.Client(timeout=httpx.Timeout(connect=15.0, read=600.0, write=30.0, pool=30.0))
|
||||
|
||||
def close(self) -> None:
|
||||
self.is_closed = True
|
||||
try:
|
||||
self._http.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Implement the OpenAI SDK's context-manager-ish closure check
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
def _ensure_project_context(self, access_token: str, model: str) -> ProjectContext:
|
||||
"""Lazily resolve and cache the project context for this client."""
|
||||
if self._project_context is not None:
|
||||
return self._project_context
|
||||
|
||||
env_project = google_oauth.resolve_project_id_from_env()
|
||||
creds = google_oauth.load_credentials()
|
||||
stored_project = creds.project_id if creds else ""
|
||||
|
||||
# Prefer what's already baked into the creds
|
||||
if stored_project:
|
||||
self._project_context = ProjectContext(
|
||||
project_id=stored_project,
|
||||
managed_project_id=creds.managed_project_id if creds else "",
|
||||
tier_id="",
|
||||
source="stored",
|
||||
)
|
||||
return self._project_context
|
||||
|
||||
ctx = resolve_project_context(
|
||||
access_token,
|
||||
configured_project_id=self._configured_project_id,
|
||||
env_project_id=env_project,
|
||||
user_agent_model=model,
|
||||
)
|
||||
# Persist discovered project back to the creds file so the next
|
||||
# session doesn't re-run the discovery.
|
||||
if ctx.project_id or ctx.managed_project_id:
|
||||
google_oauth.update_project_ids(
|
||||
project_id=ctx.project_id,
|
||||
managed_project_id=ctx.managed_project_id,
|
||||
)
|
||||
self._project_context = ctx
|
||||
return ctx
|
||||
|
||||
def _create_chat_completion(
|
||||
self,
|
||||
*,
|
||||
model: str = "gemini-2.5-flash",
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
stream: bool = False,
|
||||
tools: Any = None,
|
||||
tool_choice: Any = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
top_p: Optional[float] = None,
|
||||
stop: Any = None,
|
||||
extra_body: Optional[Dict[str, Any]] = None,
|
||||
timeout: Any = None,
|
||||
**_: Any,
|
||||
) -> Any:
|
||||
access_token = google_oauth.get_valid_access_token()
|
||||
ctx = self._ensure_project_context(access_token, model)
|
||||
|
||||
thinking_config = None
|
||||
if isinstance(extra_body, dict):
|
||||
thinking_config = extra_body.get("thinking_config") or extra_body.get("thinkingConfig")
|
||||
|
||||
inner = build_gemini_request(
|
||||
messages=messages or [],
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
stop=stop,
|
||||
thinking_config=thinking_config,
|
||||
)
|
||||
wrapped = wrap_code_assist_request(
|
||||
project_id=ctx.project_id,
|
||||
model=model,
|
||||
inner_request=inner,
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"User-Agent": "hermes-agent (gemini-cli-compat)",
|
||||
"X-Goog-Api-Client": "gl-python/hermes",
|
||||
"x-activity-request-id": str(uuid.uuid4()),
|
||||
}
|
||||
headers.update(self._default_headers)
|
||||
|
||||
if stream:
|
||||
return self._stream_completion(model=model, wrapped=wrapped, headers=headers)
|
||||
|
||||
url = f"{CODE_ASSIST_ENDPOINT}/v1internal:generateContent"
|
||||
response = self._http.post(url, json=wrapped, headers=headers)
|
||||
if response.status_code != 200:
|
||||
raise _gemini_http_error(response)
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise CodeAssistError(
|
||||
f"Invalid JSON from Code Assist: {exc}",
|
||||
code="code_assist_invalid_json",
|
||||
) from exc
|
||||
return _translate_gemini_response(payload, model=model)
|
||||
|
||||
def _stream_completion(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
wrapped: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
) -> Iterator[_GeminiStreamChunk]:
|
||||
"""Generator that yields OpenAI-shaped streaming chunks."""
|
||||
url = f"{CODE_ASSIST_ENDPOINT}/v1internal:streamGenerateContent?alt=sse"
|
||||
stream_headers = dict(headers)
|
||||
stream_headers["Accept"] = "text/event-stream"
|
||||
|
||||
def _generator() -> Iterator[_GeminiStreamChunk]:
|
||||
try:
|
||||
with self._http.stream("POST", url, json=wrapped, headers=stream_headers) as response:
|
||||
if response.status_code != 200:
|
||||
# Materialize error body for better diagnostics
|
||||
response.read()
|
||||
raise _gemini_http_error(response)
|
||||
tool_call_counter: List[int] = [0]
|
||||
for event in _iter_sse_events(response):
|
||||
for chunk in _translate_stream_event(event, model, tool_call_counter):
|
||||
yield chunk
|
||||
except httpx.HTTPError as exc:
|
||||
raise CodeAssistError(
|
||||
f"Streaming request failed: {exc}",
|
||||
code="code_assist_stream_error",
|
||||
) from exc
|
||||
|
||||
return _generator()
|
||||
|
||||
|
||||
def _gemini_http_error(response: httpx.Response) -> CodeAssistError:
|
||||
"""Translate an httpx response into a CodeAssistError with rich metadata.
|
||||
|
||||
Parses Google's error envelope (``{"error": {"code", "message", "status",
|
||||
"details": [...]}}``) so the agent's error classifier can reason about
|
||||
the failure — ``status_code`` enables the rate_limit / auth classification
|
||||
paths, and ``response`` lets the main loop honor ``Retry-After`` just
|
||||
like it does for OpenAI SDK exceptions.
|
||||
|
||||
Also lifts a few recognizable Google conditions into human-readable
|
||||
messages so the user sees something better than a 500-char JSON dump:
|
||||
|
||||
MODEL_CAPACITY_EXHAUSTED → "Gemini model capacity exhausted for
|
||||
<model>. This is a Google-side throttle..."
|
||||
RESOURCE_EXHAUSTED w/o reason → quota-style message
|
||||
404 → "Model <name> not found at cloudcode-pa..."
|
||||
"""
|
||||
status = response.status_code
|
||||
|
||||
# Parse the body once, surviving any weird encodings.
|
||||
body_text = ""
|
||||
body_json: Dict[str, Any] = {}
|
||||
try:
|
||||
body_text = response.text
|
||||
except Exception:
|
||||
body_text = ""
|
||||
if body_text:
|
||||
try:
|
||||
parsed = json.loads(body_text)
|
||||
if isinstance(parsed, dict):
|
||||
body_json = parsed
|
||||
except (ValueError, TypeError):
|
||||
body_json = {}
|
||||
|
||||
# Dig into Google's error envelope. Shape is:
|
||||
# {"error": {"code": 429, "message": "...", "status": "RESOURCE_EXHAUSTED",
|
||||
# "details": [{"@type": ".../ErrorInfo", "reason": "MODEL_CAPACITY_EXHAUSTED",
|
||||
# "metadata": {...}},
|
||||
# {"@type": ".../RetryInfo", "retryDelay": "30s"}]}}
|
||||
err_obj = body_json.get("error") if isinstance(body_json, dict) else None
|
||||
if not isinstance(err_obj, dict):
|
||||
err_obj = {}
|
||||
err_status = str(err_obj.get("status") or "").strip()
|
||||
err_message = str(err_obj.get("message") or "").strip()
|
||||
_raw_details = err_obj.get("details")
|
||||
err_details_list = _raw_details if isinstance(_raw_details, list) else []
|
||||
|
||||
# Extract google.rpc.ErrorInfo reason + metadata. There may be more
|
||||
# than one ErrorInfo (rare), so we pick the first one with a reason.
|
||||
error_reason = ""
|
||||
error_metadata: Dict[str, Any] = {}
|
||||
retry_delay_seconds: Optional[float] = None
|
||||
for detail in err_details_list:
|
||||
if not isinstance(detail, dict):
|
||||
continue
|
||||
type_url = str(detail.get("@type") or "")
|
||||
if not error_reason and type_url.endswith("/google.rpc.ErrorInfo"):
|
||||
reason = detail.get("reason")
|
||||
if isinstance(reason, str) and reason:
|
||||
error_reason = reason
|
||||
md = detail.get("metadata")
|
||||
if isinstance(md, dict):
|
||||
error_metadata = md
|
||||
elif retry_delay_seconds is None and type_url.endswith("/google.rpc.RetryInfo"):
|
||||
# retryDelay is a google.protobuf.Duration string like "30s" or "1.5s".
|
||||
delay_raw = detail.get("retryDelay")
|
||||
if isinstance(delay_raw, str) and delay_raw.endswith("s"):
|
||||
try:
|
||||
retry_delay_seconds = float(delay_raw[:-1])
|
||||
except ValueError:
|
||||
pass
|
||||
elif isinstance(delay_raw, (int, float)):
|
||||
retry_delay_seconds = float(delay_raw)
|
||||
|
||||
# Fall back to the Retry-After header if the body didn't include RetryInfo.
|
||||
if retry_delay_seconds is None:
|
||||
try:
|
||||
header_val = response.headers.get("Retry-After") or response.headers.get("retry-after")
|
||||
except Exception:
|
||||
header_val = None
|
||||
if header_val:
|
||||
try:
|
||||
retry_delay_seconds = float(header_val)
|
||||
except (TypeError, ValueError):
|
||||
retry_delay_seconds = None
|
||||
|
||||
# Classify the error code. ``code_assist_rate_limited`` stays the default
|
||||
# for 429s; a more specific reason tag helps downstream callers (e.g. tests,
|
||||
# logs) without changing the rate_limit classification path.
|
||||
code = f"code_assist_http_{status}"
|
||||
if status == 401:
|
||||
code = "code_assist_unauthorized"
|
||||
elif status == 429:
|
||||
code = "code_assist_rate_limited"
|
||||
if error_reason == "MODEL_CAPACITY_EXHAUSTED":
|
||||
code = "code_assist_capacity_exhausted"
|
||||
|
||||
# Build a human-readable message. Keep the status + a raw-body tail for
|
||||
# debugging, but lead with a friendlier summary when we recognize the
|
||||
# Google signal.
|
||||
model_hint = ""
|
||||
if isinstance(error_metadata, dict):
|
||||
model_hint = str(error_metadata.get("model") or error_metadata.get("modelId") or "").strip()
|
||||
|
||||
if status == 429 and error_reason == "MODEL_CAPACITY_EXHAUSTED":
|
||||
target = model_hint or "this Gemini model"
|
||||
message = (
|
||||
f"Gemini capacity exhausted for {target} (Google-side throttle, "
|
||||
f"not a Hermes issue). Try a different Gemini model or set a "
|
||||
f"fallback_providers entry to a non-Gemini provider."
|
||||
)
|
||||
if retry_delay_seconds is not None:
|
||||
message += f" Google suggests retrying in {retry_delay_seconds:g}s."
|
||||
elif status == 429 and err_status == "RESOURCE_EXHAUSTED":
|
||||
message = (
|
||||
f"Gemini quota exhausted ({err_message or 'RESOURCE_EXHAUSTED'}). "
|
||||
f"Check /gquota for remaining daily requests."
|
||||
)
|
||||
if retry_delay_seconds is not None:
|
||||
message += f" Retry suggested in {retry_delay_seconds:g}s."
|
||||
elif status == 404:
|
||||
# Google returns 404 when a model has been retired or renamed.
|
||||
target = model_hint or (err_message or "model")
|
||||
message = (
|
||||
f"Code Assist 404: {target} is not available at "
|
||||
f"cloudcode-pa.googleapis.com. It may have been renamed or "
|
||||
f"retired. Check hermes_cli/models.py for the current list."
|
||||
)
|
||||
elif err_message:
|
||||
# Generic fallback with the parsed message.
|
||||
message = f"Code Assist HTTP {status} ({err_status or 'error'}): {err_message}"
|
||||
else:
|
||||
# Last-ditch fallback — raw body snippet.
|
||||
message = f"Code Assist returned HTTP {status}: {body_text[:500]}"
|
||||
|
||||
return CodeAssistError(
|
||||
message,
|
||||
code=code,
|
||||
status_code=status,
|
||||
response=response,
|
||||
retry_after=retry_delay_seconds,
|
||||
details={
|
||||
"status": err_status,
|
||||
"reason": error_reason,
|
||||
"metadata": error_metadata,
|
||||
"message": err_message,
|
||||
},
|
||||
)
|
||||
@@ -41,16 +41,6 @@ DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
|
||||
|
||||
|
||||
def bare_gemini_model_id(model: str) -> str:
|
||||
"""Strip Gemini's own provider prefix from an aggregator-style model id."""
|
||||
name = (model or "").strip()
|
||||
lowered = name.lower()
|
||||
for prefix in ("google/", "gemini/"):
|
||||
if lowered.startswith(prefix):
|
||||
return name[len(prefix):].strip() or name
|
||||
return name
|
||||
|
||||
|
||||
def is_native_gemini_base_url(base_url: str) -> bool:
|
||||
"""Return True when the endpoint speaks Gemini's native REST API."""
|
||||
normalized = str(base_url or "").strip().rstrip("/").lower()
|
||||
@@ -337,26 +327,10 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
|
||||
if parts:
|
||||
contents.append({"role": gemini_role, "parts": parts})
|
||||
|
||||
# Gemini's generateContent requires strict user/model alternation;
|
||||
# consecutive same-role contents are rejected with HTTP 400 "Please ensure
|
||||
# that multiturn requests alternate between user and model". The loop above
|
||||
# emits one content per source message, so parallel tool calls (N tool
|
||||
# results become N user functionResponse contents), back-to-back user turns,
|
||||
# or merged assistant turns would each violate that. Merge adjacent
|
||||
# same-role contents by concatenating their parts. For parallel calls this
|
||||
# also produces the grouped multi-functionResponse turn Gemini expects.
|
||||
merged_contents: List[Dict[str, Any]] = []
|
||||
for content in contents:
|
||||
if merged_contents and merged_contents[-1]["role"] == content["role"]:
|
||||
merged_contents[-1]["parts"].extend(content["parts"])
|
||||
else:
|
||||
merged_contents.append(content)
|
||||
contents = merged_contents
|
||||
|
||||
system_instruction = None
|
||||
joined_system = "\n".join(part for part in system_text_parts if part).strip()
|
||||
if joined_system:
|
||||
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
|
||||
system_instruction = {"parts": [{"text": joined_system}]}
|
||||
return contents, system_instruction
|
||||
|
||||
|
||||
@@ -940,7 +914,6 @@ class GeminiNativeClient:
|
||||
thinking_config=thinking_config,
|
||||
)
|
||||
|
||||
model = bare_gemini_model_id(model)
|
||||
if stream:
|
||||
return self._stream_completion(model=model, request=request, timeout=timeout)
|
||||
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""Google Code Assist API client — project discovery, onboarding, quota.
|
||||
|
||||
The Code Assist API powers Google's official gemini-cli. It sits at
|
||||
``cloudcode-pa.googleapis.com`` and provides:
|
||||
|
||||
- Free tier access (generous daily quota) for personal Google accounts
|
||||
- Paid tier access via GCP projects with billing / Workspace / Standard / Enterprise
|
||||
|
||||
This module handles the control-plane dance needed before inference:
|
||||
|
||||
1. ``load_code_assist()`` — probe the user's account to learn what tier they're on
|
||||
and whether a ``cloudaicompanionProject`` is already assigned.
|
||||
2. ``onboard_user()`` — if the user hasn't been onboarded yet (new account, fresh
|
||||
free tier, etc.), call this with the chosen tier + project id. Supports LRO
|
||||
polling for slow provisioning.
|
||||
3. ``retrieve_user_quota()`` — fetch the ``buckets[]`` array showing remaining
|
||||
quota per model, used by the ``/gquota`` slash command.
|
||||
|
||||
VPC-SC handling: enterprise accounts under a VPC Service Controls perimeter
|
||||
will get ``SECURITY_POLICY_VIOLATED`` on ``load_code_assist``. We catch this
|
||||
and force the account to ``standard-tier`` so the call chain still succeeds.
|
||||
|
||||
Derived from opencode-gemini-auth (MIT) and clawdbot/extensions/google. The
|
||||
request/response shapes are specific to Google's internal Code Assist API,
|
||||
documented nowhere public — we copy them from the reference implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Constants
|
||||
# =============================================================================
|
||||
|
||||
CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"
|
||||
|
||||
# Fallback endpoints tried when prod returns an error during project discovery
|
||||
FALLBACK_ENDPOINTS = [
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
|
||||
]
|
||||
|
||||
# Tier identifiers that Google's API uses
|
||||
FREE_TIER_ID = "free-tier"
|
||||
LEGACY_TIER_ID = "legacy-tier"
|
||||
STANDARD_TIER_ID = "standard-tier"
|
||||
|
||||
# Default HTTP headers matching gemini-cli's fingerprint.
|
||||
# Google may reject unrecognized User-Agents on these internal endpoints.
|
||||
_GEMINI_CLI_USER_AGENT = "google-api-nodejs-client/9.15.1 (gzip)"
|
||||
_X_GOOG_API_CLIENT = "gl-node/24.0.0"
|
||||
_DEFAULT_REQUEST_TIMEOUT = 30.0
|
||||
_ONBOARDING_POLL_ATTEMPTS = 12
|
||||
_ONBOARDING_POLL_INTERVAL_SECONDS = 5.0
|
||||
|
||||
|
||||
class CodeAssistError(RuntimeError):
|
||||
"""Exception raised by the Code Assist (``cloudcode-pa``) integration.
|
||||
|
||||
Carries HTTP status / response / retry-after metadata so the agent's
|
||||
``error_classifier._extract_status_code`` and the main loop's Retry-After
|
||||
handling (which walks ``error.response.headers``) pick up the right
|
||||
signals. Without these, 429s from the OAuth path look like opaque
|
||||
``RuntimeError`` and skip the rate-limit path.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str = "code_assist_error",
|
||||
status_code: Optional[int] = None,
|
||||
response: Any = None,
|
||||
retry_after: Optional[float] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
# ``status_code`` is picked up by ``agent.error_classifier._extract_status_code``
|
||||
# so a 429 from Code Assist classifies as FailoverReason.rate_limit and
|
||||
# triggers the main loop's fallback_providers chain the same way SDK
|
||||
# errors do.
|
||||
self.status_code = status_code
|
||||
# ``response`` is the underlying ``httpx.Response`` (or a shim with a
|
||||
# ``.headers`` mapping and ``.json()`` method). The main loop reads
|
||||
# ``error.response.headers["Retry-After"]`` to honor Google's retry
|
||||
# hints when the backend throttles us.
|
||||
self.response = response
|
||||
# Parsed ``Retry-After`` seconds (kept separately for convenience —
|
||||
# Google returns retry hints in both the header and the error body's
|
||||
# ``google.rpc.RetryInfo`` details, and we pick whichever we found).
|
||||
self.retry_after = retry_after
|
||||
# Parsed structured error details from the Google error envelope
|
||||
# (e.g. ``{"reason": "MODEL_CAPACITY_EXHAUSTED", "status": "RESOURCE_EXHAUSTED"}``).
|
||||
# Useful for logging and for tests that want to assert on specifics.
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
class ProjectIdRequiredError(CodeAssistError):
|
||||
def __init__(self, message: str = "GCP project id required for this tier") -> None:
|
||||
super().__init__(message, code="code_assist_project_id_required")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP primitive (auth via Bearer token passed per-call)
|
||||
# =============================================================================
|
||||
|
||||
def _build_headers(access_token: str, *, user_agent_model: str = "") -> Dict[str, str]:
|
||||
ua = _GEMINI_CLI_USER_AGENT
|
||||
if user_agent_model:
|
||||
ua = f"{ua} model/{user_agent_model}"
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"User-Agent": ua,
|
||||
"X-Goog-Api-Client": _X_GOOG_API_CLIENT,
|
||||
"x-activity-request-id": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
|
||||
def _client_metadata() -> Dict[str, str]:
|
||||
"""Match Google's gemini-cli exactly — unrecognized metadata may be rejected."""
|
||||
return {
|
||||
"ideType": "IDE_UNSPECIFIED",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
}
|
||||
|
||||
|
||||
def _post_json(
|
||||
url: str,
|
||||
body: Dict[str, Any],
|
||||
access_token: str,
|
||||
*,
|
||||
timeout: float = _DEFAULT_REQUEST_TIMEOUT,
|
||||
user_agent_model: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url, data=data, method="POST",
|
||||
headers=_build_headers(access_token, user_agent_model=user_agent_model),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read().decode("utf-8", errors="replace")
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = ""
|
||||
try:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
# Special case: VPC-SC violation should be distinguishable
|
||||
if _is_vpc_sc_violation(detail):
|
||||
raise CodeAssistError(
|
||||
f"VPC-SC policy violation: {detail}",
|
||||
code="code_assist_vpc_sc",
|
||||
) from exc
|
||||
raise CodeAssistError(
|
||||
f"Code Assist HTTP {exc.code}: {detail or exc.reason}",
|
||||
code=f"code_assist_http_{exc.code}",
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise CodeAssistError(
|
||||
f"Code Assist request failed: {exc}",
|
||||
code="code_assist_network_error",
|
||||
) from exc
|
||||
|
||||
|
||||
def _is_vpc_sc_violation(body: str) -> bool:
|
||||
"""Detect a VPC Service Controls violation from a response body."""
|
||||
if not body:
|
||||
return False
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return "SECURITY_POLICY_VIOLATED" in body
|
||||
# Walk the nested error structure Google uses
|
||||
error = parsed.get("error") if isinstance(parsed, dict) else None
|
||||
if not isinstance(error, dict):
|
||||
return False
|
||||
details = error.get("details") or []
|
||||
if isinstance(details, list):
|
||||
for item in details:
|
||||
if isinstance(item, dict):
|
||||
reason = item.get("reason") or ""
|
||||
if reason == "SECURITY_POLICY_VIOLATED":
|
||||
return True
|
||||
msg = str(error.get("message", ""))
|
||||
return "SECURITY_POLICY_VIOLATED" in msg
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# load_code_assist — discovers current tier + assigned project
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class CodeAssistProjectInfo:
|
||||
"""Result from ``load_code_assist``."""
|
||||
current_tier_id: str = ""
|
||||
cloudaicompanion_project: str = "" # Google-managed project (free tier)
|
||||
allowed_tiers: List[str] = field(default_factory=list)
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def load_code_assist(
|
||||
access_token: str,
|
||||
*,
|
||||
project_id: str = "",
|
||||
user_agent_model: str = "",
|
||||
) -> CodeAssistProjectInfo:
|
||||
"""Call ``POST /v1internal:loadCodeAssist`` with prod → sandbox fallback.
|
||||
|
||||
Returns whatever tier + project info Google reports. On VPC-SC violations,
|
||||
returns a synthetic ``standard-tier`` result so the chain can continue.
|
||||
"""
|
||||
body: Dict[str, Any] = {
|
||||
"metadata": {
|
||||
"duetProject": project_id,
|
||||
**_client_metadata(),
|
||||
},
|
||||
}
|
||||
if project_id:
|
||||
body["cloudaicompanionProject"] = project_id
|
||||
|
||||
endpoints = [CODE_ASSIST_ENDPOINT] + FALLBACK_ENDPOINTS
|
||||
last_err: Optional[Exception] = None
|
||||
for endpoint in endpoints:
|
||||
url = f"{endpoint}/v1internal:loadCodeAssist"
|
||||
try:
|
||||
resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
|
||||
return _parse_load_response(resp)
|
||||
except CodeAssistError as exc:
|
||||
if exc.code == "code_assist_vpc_sc":
|
||||
logger.info("VPC-SC violation on %s — defaulting to standard-tier", endpoint)
|
||||
return CodeAssistProjectInfo(
|
||||
current_tier_id=STANDARD_TIER_ID,
|
||||
cloudaicompanion_project=project_id,
|
||||
)
|
||||
last_err = exc
|
||||
logger.warning("loadCodeAssist failed on %s: %s", endpoint, exc)
|
||||
continue
|
||||
if last_err:
|
||||
raise last_err
|
||||
return CodeAssistProjectInfo()
|
||||
|
||||
|
||||
def _parse_load_response(resp: Dict[str, Any]) -> CodeAssistProjectInfo:
|
||||
current_tier = resp.get("currentTier") or {}
|
||||
tier_id = str(current_tier.get("id") or "") if isinstance(current_tier, dict) else ""
|
||||
project = str(resp.get("cloudaicompanionProject") or "")
|
||||
allowed = resp.get("allowedTiers") or []
|
||||
allowed_ids: List[str] = []
|
||||
if isinstance(allowed, list):
|
||||
for t in allowed:
|
||||
if isinstance(t, dict):
|
||||
tid = str(t.get("id") or "")
|
||||
if tid:
|
||||
allowed_ids.append(tid)
|
||||
return CodeAssistProjectInfo(
|
||||
current_tier_id=tier_id,
|
||||
cloudaicompanion_project=project,
|
||||
allowed_tiers=allowed_ids,
|
||||
raw=resp,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# onboard_user — provisions a new user on a tier (with LRO polling)
|
||||
# =============================================================================
|
||||
|
||||
def onboard_user(
|
||||
access_token: str,
|
||||
*,
|
||||
tier_id: str,
|
||||
project_id: str = "",
|
||||
user_agent_model: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Call ``POST /v1internal:onboardUser`` to provision the user.
|
||||
|
||||
For paid tiers, ``project_id`` is REQUIRED (raises ProjectIdRequiredError).
|
||||
For free tiers, ``project_id`` is optional — Google will assign one.
|
||||
|
||||
Returns the final operation response. Polls ``/v1internal/<name>`` for up
|
||||
to ``_ONBOARDING_POLL_ATTEMPTS`` × ``_ONBOARDING_POLL_INTERVAL_SECONDS``
|
||||
(default: 12 × 5s = 1 min).
|
||||
"""
|
||||
if tier_id != FREE_TIER_ID and tier_id != LEGACY_TIER_ID and not project_id:
|
||||
raise ProjectIdRequiredError(
|
||||
f"Tier {tier_id!r} requires a GCP project id. "
|
||||
"Set HERMES_GEMINI_PROJECT_ID or GOOGLE_CLOUD_PROJECT."
|
||||
)
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"tierId": tier_id,
|
||||
"metadata": _client_metadata(),
|
||||
}
|
||||
if project_id:
|
||||
body["cloudaicompanionProject"] = project_id
|
||||
|
||||
endpoint = CODE_ASSIST_ENDPOINT
|
||||
url = f"{endpoint}/v1internal:onboardUser"
|
||||
resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
|
||||
|
||||
# Poll if LRO (long-running operation)
|
||||
if not resp.get("done"):
|
||||
op_name = resp.get("name", "")
|
||||
if not op_name:
|
||||
return resp
|
||||
for attempt in range(_ONBOARDING_POLL_ATTEMPTS):
|
||||
time.sleep(_ONBOARDING_POLL_INTERVAL_SECONDS)
|
||||
poll_url = f"{endpoint}/v1internal/{op_name}"
|
||||
try:
|
||||
poll_resp = _post_json(poll_url, {}, access_token, user_agent_model=user_agent_model)
|
||||
except CodeAssistError as exc:
|
||||
logger.warning("Onboarding poll attempt %d failed: %s", attempt + 1, exc)
|
||||
continue
|
||||
if poll_resp.get("done"):
|
||||
return poll_resp
|
||||
logger.warning("Onboarding did not complete within %d attempts", _ONBOARDING_POLL_ATTEMPTS)
|
||||
return resp
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# retrieve_user_quota — for /gquota
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class QuotaBucket:
|
||||
model_id: str
|
||||
token_type: str = ""
|
||||
remaining_fraction: float = 0.0
|
||||
reset_time_iso: str = ""
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def retrieve_user_quota(
|
||||
access_token: str,
|
||||
*,
|
||||
project_id: str = "",
|
||||
user_agent_model: str = "",
|
||||
) -> List[QuotaBucket]:
|
||||
"""Call ``POST /v1internal:retrieveUserQuota`` and parse ``buckets[]``."""
|
||||
body: Dict[str, Any] = {}
|
||||
if project_id:
|
||||
body["project"] = project_id
|
||||
url = f"{CODE_ASSIST_ENDPOINT}/v1internal:retrieveUserQuota"
|
||||
resp = _post_json(url, body, access_token, user_agent_model=user_agent_model)
|
||||
raw_buckets = resp.get("buckets") or []
|
||||
buckets: List[QuotaBucket] = []
|
||||
if not isinstance(raw_buckets, list):
|
||||
return buckets
|
||||
for b in raw_buckets:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
buckets.append(QuotaBucket(
|
||||
model_id=str(b.get("modelId") or ""),
|
||||
token_type=str(b.get("tokenType") or ""),
|
||||
remaining_fraction=float(b.get("remainingFraction") or 0.0),
|
||||
reset_time_iso=str(b.get("resetTime") or ""),
|
||||
raw=b,
|
||||
))
|
||||
return buckets
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Project context resolution
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class ProjectContext:
|
||||
"""Resolved state for a given OAuth session."""
|
||||
project_id: str = "" # effective project id sent on requests
|
||||
managed_project_id: str = "" # Google-assigned project (free tier)
|
||||
tier_id: str = ""
|
||||
source: str = "" # "env", "config", "discovered", "onboarded"
|
||||
|
||||
|
||||
def resolve_project_context(
|
||||
access_token: str,
|
||||
*,
|
||||
configured_project_id: str = "",
|
||||
env_project_id: str = "",
|
||||
user_agent_model: str = "",
|
||||
) -> ProjectContext:
|
||||
"""Figure out what project id + tier to use for requests.
|
||||
|
||||
Priority:
|
||||
1. If configured_project_id or env_project_id is set, use that directly
|
||||
and short-circuit (no discovery needed).
|
||||
2. Otherwise call loadCodeAssist to see what Google says.
|
||||
3. If no tier assigned yet, onboard the user (free tier default).
|
||||
"""
|
||||
# Short-circuit: caller provided a project id
|
||||
if configured_project_id:
|
||||
return ProjectContext(
|
||||
project_id=configured_project_id,
|
||||
tier_id=STANDARD_TIER_ID, # assume paid since they specified one
|
||||
source="config",
|
||||
)
|
||||
if env_project_id:
|
||||
return ProjectContext(
|
||||
project_id=env_project_id,
|
||||
tier_id=STANDARD_TIER_ID,
|
||||
source="env",
|
||||
)
|
||||
|
||||
# Discover via loadCodeAssist
|
||||
info = load_code_assist(access_token, user_agent_model=user_agent_model)
|
||||
|
||||
effective_project = info.cloudaicompanion_project
|
||||
tier = info.current_tier_id
|
||||
|
||||
if not tier:
|
||||
# User hasn't been onboarded — provision them on free tier
|
||||
onboard_resp = onboard_user(
|
||||
access_token,
|
||||
tier_id=FREE_TIER_ID,
|
||||
project_id="",
|
||||
user_agent_model=user_agent_model,
|
||||
)
|
||||
# Re-parse from the onboard response
|
||||
response_body = onboard_resp.get("response") or {}
|
||||
if isinstance(response_body, dict):
|
||||
effective_project = (
|
||||
effective_project
|
||||
or str(response_body.get("cloudaicompanionProject") or "")
|
||||
)
|
||||
tier = FREE_TIER_ID
|
||||
source = "onboarded"
|
||||
else:
|
||||
source = "discovered"
|
||||
|
||||
return ProjectContext(
|
||||
project_id=effective_project,
|
||||
managed_project_id=effective_project if tier == FREE_TIER_ID else "",
|
||||
tier_id=tier,
|
||||
source=source,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,18 +11,6 @@ Providers live in ``<repo>/plugins/image_gen/<name>/`` (built-in, auto-loaded
|
||||
as ``kind: backend``) or ``~/.hermes/plugins/image_gen/<name>/`` (user, opt-in
|
||||
via ``plugins.enabled``).
|
||||
|
||||
Unified surface
|
||||
---------------
|
||||
One tool — ``image_generate`` — covers **text-to-image** and
|
||||
**image-to-image / image editing**. The router is the presence of
|
||||
``image_url`` (and/or ``reference_image_urls``): if any source image is
|
||||
provided, the provider routes to its image-to-image / edit endpoint; if
|
||||
omitted, the provider routes to text-to-image. Users pick one **model**
|
||||
(e.g. nano-banana-pro, gpt-image-2, grok-imagine-image); the provider
|
||||
handles which underlying endpoint to hit. This mirrors the ``video_gen``
|
||||
provider design (``agent/video_gen_provider.py``) so the two surfaces
|
||||
stay learnable together.
|
||||
|
||||
Response shape
|
||||
--------------
|
||||
All providers return a dict that :func:`success_response` / :func:`error_response`
|
||||
@@ -33,7 +21,6 @@ produce. The tool wrapper JSON-serializes it. Keys:
|
||||
model str provider-specific model identifier
|
||||
prompt str echoed prompt
|
||||
aspect_ratio str "landscape" | "square" | "portrait"
|
||||
modality str "text" | "image" (which mode was used)
|
||||
provider str provider name (for diagnostics)
|
||||
error str only when success=False
|
||||
error_type str only when success=False
|
||||
@@ -140,51 +127,19 @@ class ImageGenProvider(abc.ABC):
|
||||
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
|
||||
"max_reference_images": 9, # cap for reference_image_urls
|
||||
}
|
||||
|
||||
``modalities`` declares whether the active backend/model supports
|
||||
text-to-image (``"text"``), image-to-image / editing (``"image"``),
|
||||
or both. The tool layer surfaces this in the dynamic schema so the
|
||||
model knows when ``image_url`` is honored. Used by ``hermes tools``
|
||||
for the picker too. Default: text-only (backward compatible — a
|
||||
provider that doesn't override this advertises text-to-image only).
|
||||
"""
|
||||
return {
|
||||
"modalities": ["text"],
|
||||
"max_reference_images": 0,
|
||||
}
|
||||
|
||||
@abc.abstractmethod
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
*,
|
||||
image_url: Optional[str] = None,
|
||||
reference_image_urls: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate an image from a text prompt, or edit/transform a source image.
|
||||
|
||||
Routing: if ``image_url`` (or any ``reference_image_urls``) is
|
||||
provided, the provider should route to its image-to-image / edit
|
||||
endpoint; otherwise text-to-image. ``image_url`` is the primary
|
||||
source image to edit; ``reference_image_urls`` are additional
|
||||
style/composition references (provider clamps to its declared
|
||||
``max_reference_images``).
|
||||
"""Generate an image.
|
||||
|
||||
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).
|
||||
parameters future versions of the schema will expose — implementations
|
||||
should ignore unknown keys.
|
||||
"""
|
||||
|
||||
|
||||
@@ -207,26 +162,6 @@ def resolve_aspect_ratio(value: Optional[str]) -> str:
|
||||
return DEFAULT_ASPECT_RATIO
|
||||
|
||||
|
||||
def normalize_reference_images(value: Any) -> Optional[List[str]]:
|
||||
"""Coerce a reference-image argument into a clean list of URL/path strings.
|
||||
|
||||
Accepts a single string or a list; strips blanks and whitespace. Returns
|
||||
``None`` when nothing usable remains so providers can treat "no refs" as a
|
||||
single sentinel.
|
||||
"""
|
||||
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 _images_cache_dir() -> Path:
|
||||
"""Return ``$HERMES_HOME/cache/images/``, creating parents as needed."""
|
||||
from hermes_constants import get_hermes_home
|
||||
@@ -345,16 +280,13 @@ def success_response(
|
||||
prompt: str,
|
||||
aspect_ratio: str,
|
||||
provider: str,
|
||||
modality: str = "text",
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a uniform success response dict.
|
||||
|
||||
``image`` may be an HTTP URL or an absolute filesystem path (for b64
|
||||
providers like OpenAI). ``modality`` is ``"text"`` (text-to-image) or
|
||||
``"image"`` (image-to-image / editing) — indicates which endpoint was
|
||||
actually hit, useful for diagnostics. Callers that need to pass through
|
||||
additional backend-specific fields can supply ``extra``.
|
||||
providers like OpenAI). Callers that need to pass through additional
|
||||
backend-specific fields can supply ``extra``.
|
||||
"""
|
||||
payload: Dict[str, Any] = {
|
||||
"success": True,
|
||||
@@ -362,7 +294,6 @@ def success_response(
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"modality": modality,
|
||||
"provider": provider,
|
||||
}
|
||||
if extra:
|
||||
|
||||
+11
-211
@@ -251,78 +251,6 @@ def _supports_vision_override(
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_inference_base_url(
|
||||
cfg: Optional[Dict[str, Any]],
|
||||
provider: str,
|
||||
) -> str:
|
||||
"""Best-effort base URL for the active inference provider."""
|
||||
try:
|
||||
from agent.auxiliary_client import _RUNTIME_MAIN_BASE_URL
|
||||
|
||||
runtime = str(_RUNTIME_MAIN_BASE_URL or "").strip()
|
||||
if runtime:
|
||||
return runtime
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not isinstance(cfg, dict):
|
||||
return ""
|
||||
|
||||
model_cfg_raw = cfg.get("model")
|
||||
model_cfg: Dict[str, Any] = model_cfg_raw if isinstance(model_cfg_raw, dict) else {}
|
||||
base_url = str(model_cfg.get("base_url") or "").strip()
|
||||
if base_url:
|
||||
return base_url
|
||||
|
||||
config_provider = str(model_cfg.get("provider") or "").strip()
|
||||
candidate_names: set[str] = set()
|
||||
for p in filter(None, (provider, config_provider)):
|
||||
candidate_names.add(p)
|
||||
if p.lower().startswith("custom:"):
|
||||
candidate_names.add(p.split(":", 1)[1])
|
||||
else:
|
||||
candidate_names.add(f"custom:{p}")
|
||||
|
||||
providers_cfg = cfg.get("providers")
|
||||
if isinstance(providers_cfg, dict):
|
||||
for name in candidate_names:
|
||||
entry = providers_cfg.get(name)
|
||||
if isinstance(entry, dict):
|
||||
bu = str(entry.get("base_url") or "").strip()
|
||||
if bu:
|
||||
return bu
|
||||
|
||||
custom_providers = cfg.get("custom_providers")
|
||||
if isinstance(custom_providers, list):
|
||||
lowered = {n.lower() for n in candidate_names}
|
||||
for entry_raw in custom_providers:
|
||||
if not isinstance(entry_raw, dict):
|
||||
continue
|
||||
entry_name = str(entry_raw.get("name") or "").strip()
|
||||
if entry_name not in candidate_names and entry_name.lower() not in lowered:
|
||||
continue
|
||||
bu = str(entry_raw.get("base_url") or "").strip()
|
||||
if bu:
|
||||
return bu
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _should_probe_ollama_vision(provider: str, base_url: str) -> bool:
|
||||
"""True when the active provider likely fronts a local Ollama server."""
|
||||
p = (provider or "").strip().lower()
|
||||
if p == "ollama":
|
||||
return True
|
||||
if not base_url:
|
||||
return False
|
||||
try:
|
||||
from agent.model_metadata import detect_local_server_type
|
||||
|
||||
return detect_local_server_type(base_url) == "ollama"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_mode(raw: Any) -> str:
|
||||
"""Normalize a config value into one of the valid modes."""
|
||||
if not isinstance(raw, str):
|
||||
@@ -374,33 +302,15 @@ def _lookup_supports_vision(
|
||||
return override
|
||||
if not provider or not model:
|
||||
return None
|
||||
caps = None
|
||||
try:
|
||||
from agent.models_dev import get_model_capabilities
|
||||
caps = get_model_capabilities(provider, model)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("image_routing: caps lookup failed for %s:%s — %s", provider, model, exc)
|
||||
if caps is not None:
|
||||
return bool(caps.supports_vision)
|
||||
|
||||
base_url = _resolve_inference_base_url(cfg, provider)
|
||||
if not base_url and (provider or "").strip().lower() == "ollama":
|
||||
base_url = "http://localhost:11434/v1"
|
||||
if _should_probe_ollama_vision(provider, base_url):
|
||||
try:
|
||||
from agent.model_metadata import query_ollama_supports_vision
|
||||
|
||||
ollama_vision = query_ollama_supports_vision(model, base_url)
|
||||
if ollama_vision is not None:
|
||||
return ollama_vision
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug(
|
||||
"image_routing: ollama vision probe failed for %s:%s — %s",
|
||||
provider,
|
||||
model,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
return None
|
||||
if caps is None:
|
||||
return None
|
||||
return bool(caps.supports_vision)
|
||||
|
||||
|
||||
def decide_image_input_mode(
|
||||
@@ -478,98 +388,14 @@ def _sniff_mime_from_bytes(raw: bytes) -> Optional[str]:
|
||||
# BMP: "BM"
|
||||
if raw.startswith(b"BM"):
|
||||
return "image/bmp"
|
||||
# ISO-BMFF family (HEIC/HEIF/AVIF): bytes 4..8 == 'ftyp', major brand at 8..12
|
||||
if len(raw) >= 12 and raw[4:8] == b"ftyp":
|
||||
brand = raw[8:12]
|
||||
if brand in {b"avif", b"avis"}:
|
||||
return "image/avif"
|
||||
if brand in {
|
||||
b"heic", b"heix", b"hevc", b"hevx",
|
||||
b"mif1", b"msf1", b"heim", b"heis",
|
||||
}:
|
||||
return "image/heic"
|
||||
# TIFF: II*\0 (little-endian) or MM\0* (big-endian)
|
||||
if raw[:4] in {b"II*\x00", b"MM\x00*"}:
|
||||
return "image/tiff"
|
||||
# ICO: 00 00 01 00 (reserved=0, type=1=icon)
|
||||
if raw[:4] == b"\x00\x00\x01\x00":
|
||||
return "image/x-icon"
|
||||
# SVG: text-based, look for an <svg tag near the start (skip BOM/whitespace)
|
||||
head = raw[:512].lstrip().lower()
|
||||
if head.startswith(b"<?xml") or head.startswith(b"<svg"):
|
||||
if b"<svg" in head:
|
||||
return "image/svg+xml"
|
||||
# HEIC/HEIF: ftypheic / ftypheix / ftypmif1 / ftypmsf1 etc.
|
||||
if len(raw) >= 12 and raw[4:8] == b"ftyp" and raw[8:12] in {
|
||||
b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim", b"heis",
|
||||
}:
|
||||
return "image/heic"
|
||||
return None
|
||||
|
||||
|
||||
# Formats every major vision provider (Anthropic, OpenAI, Gemini, Bedrock)
|
||||
# accepts natively. Anything outside this set has to be transcoded to PNG
|
||||
# before we declare media_type, otherwise the provider returns HTTP 400
|
||||
# ("Could not process image" / "Unsupported image media type") and the
|
||||
# whole turn fails with no salvage path.
|
||||
#
|
||||
# Discord (and a few other chat platforms) freely accept attachments in
|
||||
# formats outside this set -- AVIF screenshots from Chromium, HEIC from
|
||||
# iPhones, TIFF from scanners, BMP from old Windows tools, ICO -- so users
|
||||
# do hit this in practice. SVG is vector and Pillow cannot rasterize it;
|
||||
# it is skipped (logged) rather than transcoded.
|
||||
_UNIVERSALLY_SUPPORTED_MIMES = frozenset({
|
||||
"image/png", "image/jpeg", "image/gif", "image/webp",
|
||||
})
|
||||
|
||||
|
||||
def _transcode_to_png(raw: bytes) -> Optional[bytes]:
|
||||
"""Decode arbitrary image bytes with Pillow and re-encode as PNG.
|
||||
|
||||
Returns None if Pillow isn't installed or can't decode the input
|
||||
(rare formats, corrupted bytes, missing optional decoder plugin for
|
||||
HEIC/AVIF, or vector formats like SVG). Caller falls back to skipping
|
||||
the image so the rest of the turn still works.
|
||||
|
||||
HEIC/HEIF and AVIF need optional Pillow plugins; we try to register
|
||||
them on demand and swallow ImportError so a missing plugin just
|
||||
looks like 'Pillow can't decode this' rather than crashing.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
logger.info(
|
||||
"image_routing: Pillow not installed; cannot transcode "
|
||||
"non-standard image format to PNG. Install with `pip install Pillow` "
|
||||
"(and `pillow-heif` / `pillow-avif-plugin` for those formats)."
|
||||
)
|
||||
return None
|
||||
# Optional plugin registration. Silent on failure: an unsupported
|
||||
# format will just fall through to Image.open raising below.
|
||||
try:
|
||||
import pillow_heif # type: ignore
|
||||
|
||||
pillow_heif.register_heif_opener()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import pillow_avif # type: ignore # noqa: F401 -- registers AVIF on import
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from io import BytesIO
|
||||
|
||||
with Image.open(BytesIO(raw)) as im:
|
||||
# Pick an output mode PNG can serialise. Anything other than
|
||||
# the standard set gets normalised to RGBA so transparency is
|
||||
# preserved where the source had it.
|
||||
if im.mode not in {"RGB", "RGBA", "L", "LA", "P"}:
|
||||
im = im.convert("RGBA")
|
||||
buf = BytesIO()
|
||||
im.save(buf, format="PNG", optimize=False)
|
||||
return buf.getvalue()
|
||||
except Exception as exc:
|
||||
logger.info(
|
||||
"image_routing: Pillow could not transcode image to PNG -- %s", exc
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _guess_mime(path: Path, raw: Optional[bytes] = None) -> str:
|
||||
"""Return image MIME type for *path*.
|
||||
|
||||
@@ -605,18 +431,8 @@ def _file_to_data_url(path: Path) -> Optional[str]:
|
||||
accept large images (OpenAI 49 MB+, Gemini 100 MB) don't pay a silent
|
||||
quality tax just because one other provider is stricter.
|
||||
|
||||
Format compatibility IS handled here: if the sniffed MIME isn't one
|
||||
of ``_UNIVERSALLY_SUPPORTED_MIMES`` (i.e. it's something like AVIF,
|
||||
HEIC, BMP, TIFF, or ICO that some providers reject outright), we
|
||||
transcode to PNG with Pillow before declaring media_type. This fixes
|
||||
the user-visible "Could not process image" HTTP 400 from Anthropic on
|
||||
Discord-attached AVIF/HEIC/BMP files.
|
||||
|
||||
Returns None if the file can't be read OR if the format isn't
|
||||
universally supported AND Pillow can't transcode it (Pillow missing,
|
||||
HEIC/AVIF plugin missing, vector format like SVG, corrupt bytes). The
|
||||
caller reports those paths in ``skipped`` and the rest of the turn
|
||||
proceeds.
|
||||
Returns None only if the file can't be read (missing, permission
|
||||
denied, etc.); the caller reports those paths in ``skipped``.
|
||||
"""
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
@@ -624,22 +440,6 @@ def _file_to_data_url(path: Path) -> Optional[str]:
|
||||
logger.warning("image_routing: failed to read %s — %s", path, exc)
|
||||
return None
|
||||
mime = _guess_mime(path, raw=raw)
|
||||
if mime not in _UNIVERSALLY_SUPPORTED_MIMES:
|
||||
transcoded = _transcode_to_png(raw)
|
||||
if transcoded is None:
|
||||
logger.warning(
|
||||
"image_routing: %s is %s which is not accepted by all major "
|
||||
"vision providers and could not be transcoded to PNG; "
|
||||
"skipping this attachment.",
|
||||
path, mime,
|
||||
)
|
||||
return None
|
||||
logger.info(
|
||||
"image_routing: transcoded %s (%s) -> image/png for provider compatibility",
|
||||
path.name, mime,
|
||||
)
|
||||
raw = transcoded
|
||||
mime = "image/png"
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
return f"data:{mime};base64,{b64}"
|
||||
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""``/learn`` — build the standards-guided prompt that turns whatever the user
|
||||
described into a reusable skill.
|
||||
|
||||
``/learn`` is open-ended. The user can point it at anything they can describe:
|
||||
a directory of code, an API doc URL, a workflow they just walked the agent
|
||||
through in this conversation, or pasted notes. This module builds ONE prompt
|
||||
that instructs the live agent to:
|
||||
|
||||
1. Gather the sources the user named, using the tools it already has
|
||||
(``read_file`` / ``search_files`` for dirs, ``web_extract`` for URLs, the
|
||||
current conversation for "what I just did", the user's text for pasted
|
||||
material).
|
||||
2. Author a single ``SKILL.md`` via ``skill_manage`` that follows the Hermes
|
||||
skill-authoring standards (description <=60 chars, the modern section
|
||||
order, Hermes-tool framing, no invented commands).
|
||||
|
||||
There is no separate distillation engine and no model-tool footprint: the
|
||||
agent does the work with its existing toolset, so this works identically on
|
||||
local, Docker, and remote terminal backends. Every surface (CLI ``/learn``,
|
||||
gateway ``/learn``, the dashboard "Learn a skill" panel) calls
|
||||
:func:`build_learn_prompt` and feeds the result to the agent as a normal turn.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# The house-style rules, distilled from AGENTS.md "Skill authoring standards
|
||||
# (HARDLINE)" and the hermes-agent-dev new-skill salvage reference. Embedded in
|
||||
# the prompt so the agent authors skills the way a maintainer would by hand.
|
||||
_AUTHORING_STANDARDS = """\
|
||||
Follow the Hermes skill-authoring standards exactly. These are the same
|
||||
HARDLINE rules a maintainer enforces in review:
|
||||
|
||||
Frontmatter:
|
||||
- name: lowercase-hyphenated, <=64 chars, no spaces.
|
||||
- description: ONE sentence, **<=60 characters**, ends with a period. State the
|
||||
capability, not the implementation. No marketing words (powerful,
|
||||
comprehensive, seamless, advanced, robust). Do NOT repeat the skill name. If
|
||||
the description contains a colon, wrap the whole value in double quotes.
|
||||
This is the most-violated rule and it is NOT cosmetic: the system-prompt
|
||||
skill index truncates the description to 60 chars and loads it every
|
||||
session, so anything past char 60 is silently cut and never routes. After
|
||||
you write the description, COUNT the characters; if it is over 60, cut it
|
||||
down before saving — do not ship a sentence and hope.
|
||||
Good (<=60): `Search arXiv papers by keyword, author, or ID.`
|
||||
Bad (123): `A comprehensive skill that lets the agent search arXiv for
|
||||
academic papers using keywords, authors, and categories.`
|
||||
- version: 0.1.0
|
||||
- author: always the literal value `Hermes`. NEVER fill it from the host
|
||||
environment — the OS/login username (e.g. the `user=` line in your
|
||||
environment hints), git config, or any identity you can probe must not be
|
||||
written. Skills get shared and published, so an environment-derived name is
|
||||
a privacy leak the user never opted into; the skill names itself as Hermes.
|
||||
- platforms: declare `[macos]`, `[linux]`, and/or `[windows]` IF the skill
|
||||
uses OS-bound primitives (osascript/apt/systemctl => the matching OS; /proc,
|
||||
os.setsid, signal.SIGKILL => linux; fcntl/termios => POSIX). Prefer fixing it
|
||||
cross-platform first (tempfile.gettempdir(), pathlib.Path, psutil); gate only
|
||||
when the dependency is genuinely platform-bound. Omit the field for portable
|
||||
skills.
|
||||
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
|
||||
|
||||
Body section order (omit a section only if it genuinely has no content):
|
||||
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT
|
||||
do, and the key dependency stance (e.g. "stdlib only").
|
||||
2. "## When to Use" — bullet list of concrete trigger phrases.
|
||||
3. "## Prerequisites" — exact env vars, install steps, credentials.
|
||||
4. "## How to Run" — the canonical invocation, framed through Hermes tools.
|
||||
5. "## Quick Reference" — a flat command/endpoint list, no narration.
|
||||
6. "## Procedure" — numbered steps with copy-paste-exact commands.
|
||||
7. "## Pitfalls" — known limits, rate limits, things that look broken but aren't.
|
||||
8. "## Verification" — a single command/check that proves the skill worked.
|
||||
|
||||
Hermes-tool framing (this is what makes it a skill, not shell docs):
|
||||
- Frame running scripts as "invoke through the `terminal` tool".
|
||||
- Reference Hermes tools by name in backticks: `terminal`, `read_file`,
|
||||
`write_file`, `search_files`, `patch`, `web_extract`, `web_search`,
|
||||
`vision_analyze`, `browser_navigate`, `delegate_task`, `image_generate`,
|
||||
`text_to_speech`, `cronjob`, `memory`, `skill_view`, `execute_code`.
|
||||
- Do NOT name shell utilities the agent already has wrapped: say `read_file`
|
||||
not cat/head/tail, `search_files` not grep/rg/find/ls, `patch` not sed/awk,
|
||||
`web_extract` not curl-to-scrape, `write_file` not echo>file or heredocs.
|
||||
- Third-party CLIs (ffmpeg, gh, an SDK) are fine inside a script file, but the
|
||||
prose still frames them as "invoke through the `terminal` tool". If the
|
||||
skill needs an MCP server, name it and document its setup in Prerequisites.
|
||||
|
||||
Quality bar:
|
||||
- Prefer exact commands, endpoint URLs, function signatures, and config keys
|
||||
that appear VERBATIM in the source. NEVER invent flags, paths, or APIs — if
|
||||
you didn't see it in the source, don't write it.
|
||||
- Keep it tight and scannable: ~100 lines for a simple skill, ~200 for a
|
||||
complex one. Don't re-paste the source docs.
|
||||
- Don't write a router/index/hub skill that only points at other skills.
|
||||
- Larger scripts/parsers belong in a `scripts/` file (add via
|
||||
`skill_manage` write_file), referenced from SKILL.md by relative path — not
|
||||
inlined for the agent to re-type every run. References go in `references/`,
|
||||
templates in `templates/`."""
|
||||
|
||||
|
||||
def build_learn_prompt(user_request: str) -> str:
|
||||
"""Build the agent prompt for an open-ended ``/learn`` request.
|
||||
|
||||
Args:
|
||||
user_request: the free-text the user gave after ``/learn`` — a
|
||||
description of the workflow, paths, URLs, or "what I just did".
|
||||
|
||||
Returns:
|
||||
A complete instruction the agent runs as a normal turn. The agent
|
||||
gathers the described sources with its existing tools and authors the
|
||||
skill via ``skill_manage``.
|
||||
"""
|
||||
req = (user_request or "").strip()
|
||||
if not req:
|
||||
req = (
|
||||
"the workflow we just went through in this conversation — review "
|
||||
"the steps taken and distill them into a reusable skill"
|
||||
)
|
||||
|
||||
return (
|
||||
"[/learn] The user wants you to learn a reusable skill from the "
|
||||
"source(s) they described below, and save it.\n\n"
|
||||
f"WHAT TO LEARN FROM:\n{req}\n\n"
|
||||
"Do this:\n"
|
||||
"1. Gather the material. Resolve whatever the user named using the "
|
||||
"tools you already have — `read_file`/`search_files` for local files "
|
||||
"or directories, `web_extract` for URLs, the current conversation "
|
||||
"history if they referred to something you just did, and the text "
|
||||
"they pasted as-is. If the request is ambiguous about scope, make a "
|
||||
"reasonable choice and note it; do not stall.\n"
|
||||
"2. Author ONE SKILL.md and save it with the `skill_manage` tool "
|
||||
"(action=\"create\"). Pick a sensible category. If the procedure needs "
|
||||
"a non-trivial script, add it under the skill's `scripts/` with "
|
||||
"`skill_manage` write_file and reference it by relative path.\n\n"
|
||||
f"{_AUTHORING_STANDARDS}\n\n"
|
||||
"When done, tell the user the skill name, its category, and a "
|
||||
"one-line summary of what it captured."
|
||||
)
|
||||
@@ -1,320 +0,0 @@
|
||||
"""Assemble the "learning made visible" graph for desktop.
|
||||
|
||||
This graph is intentionally scoped to what a user actually learns over time:
|
||||
- non-base, learned/profile skills (agent-created or used),
|
||||
- memory chunks from ``MEMORY.md`` / ``USER.md`` as first-class nodes.
|
||||
|
||||
Skill links come from declared ``related_skills``. Memory-to-skill links are
|
||||
derived from lexical overlap so the graph can answer "which learned skills are
|
||||
connected to the things I remember?".
|
||||
|
||||
Run as a module to print edge-density stats against real data:
|
||||
|
||||
python -m agent.learning_graph
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillNode:
|
||||
name: str
|
||||
category: str
|
||||
source: str = "profile"
|
||||
timestamp: Optional[int] = None
|
||||
use_count: int = 0
|
||||
state: str = "active"
|
||||
created_by: Optional[str] = None
|
||||
pinned: bool = False
|
||||
related: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _frontmatter(text: str) -> dict[str, Any]:
|
||||
try:
|
||||
from agent.skill_utils import parse_frontmatter
|
||||
|
||||
fm, _ = parse_frontmatter(text)
|
||||
return fm or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _related(fm: dict[str, Any]) -> list[str]:
|
||||
raw = fm.get("related_skills") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("related_skills")
|
||||
if isinstance(raw, list):
|
||||
return [str(r).strip() for r in raw if str(r).strip()]
|
||||
if isinstance(raw, str):
|
||||
return [r.strip() for r in raw.strip("[]").split(",") if r.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def _category(fm: dict[str, Any], skill_md: Path) -> str:
|
||||
cat = fm.get("category") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("category")
|
||||
if cat:
|
||||
return str(cat)
|
||||
# …/skills/<category>/<skill>/SKILL.md
|
||||
parts = skill_md.parts
|
||||
return parts[-3] if len(parts) >= 3 else "general"
|
||||
|
||||
|
||||
def _iter_skill_files(roots: list[tuple[str, Path]]):
|
||||
for source, root in roots:
|
||||
if root.exists():
|
||||
for path in root.rglob("SKILL.md"):
|
||||
yield source, path
|
||||
|
||||
|
||||
def _load_usage() -> dict[str, dict[str, Any]]:
|
||||
try:
|
||||
from tools.skill_usage import load_usage
|
||||
|
||||
return load_usage()
|
||||
except Exception:
|
||||
path = get_hermes_home() / "skills" / ".usage.json"
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _to_int_ts(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return int(float(s))
|
||||
except ValueError:
|
||||
parsed = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return int(parsed.timestamp())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _usage_timestamp(rec: dict[str, Any]) -> Optional[int]:
|
||||
for key in ("last_activity_at", "last_used_at", "last_viewed_at", "last_patched_at", "created_at"):
|
||||
ts = _to_int_ts(rec.get(key))
|
||||
if ts is not None:
|
||||
return ts
|
||||
return None
|
||||
|
||||
|
||||
def build_skill_nodes(skill_roots: list[tuple[str, Path]]) -> dict[str, SkillNode]:
|
||||
usage = _load_usage()
|
||||
nodes: dict[str, SkillNode] = {}
|
||||
|
||||
for source, skill_md in _iter_skill_files(skill_roots):
|
||||
if any(p in {".archive", ".hub", "node_modules", ".git"} for p in skill_md.parts):
|
||||
continue
|
||||
try:
|
||||
fm = _frontmatter(skill_md.read_text(encoding="utf-8")[:4000])
|
||||
except OSError:
|
||||
continue
|
||||
name = str(fm.get("name") or skill_md.parent.name).strip()
|
||||
if not name or name in nodes:
|
||||
continue
|
||||
rec = usage.get(name, {})
|
||||
last_activity = _usage_timestamp(rec)
|
||||
file_ts = _to_int_ts(skill_md.stat().st_mtime)
|
||||
nodes[name] = SkillNode(
|
||||
name=name,
|
||||
category=_category(fm, skill_md),
|
||||
source=source,
|
||||
timestamp=last_activity or file_ts,
|
||||
use_count=int(rec.get("use_count", 0) or 0),
|
||||
state=str(rec.get("state", "active") or "active"),
|
||||
created_by=rec.get("created_by"),
|
||||
pinned=bool(rec.get("pinned", False)),
|
||||
related=_related(fm),
|
||||
)
|
||||
return nodes
|
||||
|
||||
|
||||
def build_edges(nodes: dict[str, SkillNode]) -> list[tuple[str, str]]:
|
||||
"""Undirected related_skills edges where BOTH endpoints exist (deduped)."""
|
||||
seen: set[tuple[str, str]] = set()
|
||||
edges: list[tuple[str, str]] = []
|
||||
for node in nodes.values():
|
||||
for target in node.related:
|
||||
if target in nodes and target != node.name:
|
||||
a, b = sorted((node.name, target))
|
||||
key = (a, b)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
edges.append(key)
|
||||
return edges
|
||||
|
||||
|
||||
def density_stats(nodes: dict[str, SkillNode], edges: list[tuple[str, str]]) -> dict[str, Any]:
|
||||
linked: set[str] = set()
|
||||
for a, b in edges:
|
||||
linked.add(a)
|
||||
linked.add(b)
|
||||
cats: dict[str, int] = {}
|
||||
for n in nodes.values():
|
||||
cats[n.category] = cats.get(n.category, 0) + 1
|
||||
n = len(nodes) or 1
|
||||
return {
|
||||
"nodes": len(nodes),
|
||||
"related_edges": len(edges),
|
||||
"edges_per_node": round(len(edges) / n, 3),
|
||||
"linked_nodes": len(linked),
|
||||
"isolated_pct": round(100 * (n - len(linked)) / n, 1),
|
||||
"categories": len(cats),
|
||||
"agent_created": sum(1 for x in nodes.values() if x.created_by == "agent"),
|
||||
"used": sum(1 for x in nodes.values() if x.use_count > 0),
|
||||
"top_categories": sorted(cats.items(), key=lambda kv: -kv[1])[:8],
|
||||
}
|
||||
|
||||
|
||||
def _memory_cards() -> list[dict[str, Any]]:
|
||||
"""Freeform memory as readable cards.
|
||||
|
||||
``MEMORY.md`` / ``USER.md`` are prose split on bare ``§`` separators; each
|
||||
chunk becomes one card. Every chunk is surfaced — the graph shows everything.
|
||||
"""
|
||||
base = get_hermes_home() / "memories"
|
||||
cards: list[dict[str, Any]] = []
|
||||
for fname, source in (("MEMORY.md", "memory"), ("USER.md", "profile")):
|
||||
path = base / fname
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
file_ts = _to_int_ts(path.stat().st_mtime)
|
||||
except OSError:
|
||||
continue
|
||||
for chunk_idx, chunk in enumerate(c.strip() for c in text.split("\n§\n")):
|
||||
if not chunk:
|
||||
continue
|
||||
first = chunk.splitlines()[0].strip().lstrip("# ").strip()
|
||||
cards.append(
|
||||
{
|
||||
"source": source,
|
||||
"timestamp": file_ts + chunk_idx if file_ts is not None else None,
|
||||
"title": (first[:80] + "…") if len(first) > 80 else first,
|
||||
"body": chunk[:1200],
|
||||
}
|
||||
)
|
||||
return cards
|
||||
|
||||
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
return {t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) >= 3}
|
||||
|
||||
|
||||
def _memory_skill_edges(memory_cards: list[dict[str, Any]], skills: list[SkillNode]) -> list[tuple[str, str]]:
|
||||
edges: list[tuple[str, str]] = []
|
||||
skill_meta = [(s, _tokenize(s.name), s.name.lower()) for s in skills]
|
||||
for idx, card in enumerate(memory_cards):
|
||||
mem_id = f"memory:{card['source']}:{idx}"
|
||||
text = f"{card.get('title', '')}\n{card.get('body', '')}".lower()
|
||||
text_tokens = _tokenize(text)
|
||||
scored: list[tuple[int, str]] = []
|
||||
for skill, tokens, skill_name_lower in skill_meta:
|
||||
score = 0
|
||||
if skill_name_lower in text:
|
||||
score += 6
|
||||
score += len(tokens & text_tokens)
|
||||
if score > 0:
|
||||
scored.append((score, skill.name))
|
||||
scored.sort(key=lambda x: (-x[0], x[1]))
|
||||
for _, skill_name in scored[:4]:
|
||||
edges.append((mem_id, skill_name))
|
||||
return edges
|
||||
|
||||
|
||||
def _skill_roots() -> list[tuple[str, Path]]:
|
||||
repo = Path(__file__).resolve().parent.parent
|
||||
home_skills = get_hermes_home() / "skills"
|
||||
return [("base", repo / "skills"), ("profile", home_skills)]
|
||||
|
||||
|
||||
def build_learning_graph() -> dict[str, Any]:
|
||||
"""Full payload for the desktop learning panel.
|
||||
|
||||
Focus on what is profile-learned and actionable:
|
||||
- skills that are NOT base-installed and show real learning signal
|
||||
(agent-created or used),
|
||||
- memory chunks as first-class graph nodes connected to those learned skills.
|
||||
"""
|
||||
all_skills = build_skill_nodes(_skill_roots())
|
||||
learned_skills = {
|
||||
name: node
|
||||
for name, node in all_skills.items()
|
||||
if node.source != "base" and (node.created_by == "agent" or node.use_count > 0)
|
||||
}
|
||||
skill_edges = build_edges(learned_skills)
|
||||
memory_cards = _memory_cards()
|
||||
memory_edges = _memory_skill_edges(memory_cards, list(learned_skills.values()))
|
||||
|
||||
edges = skill_edges + memory_edges
|
||||
clusters: dict[str, int] = {}
|
||||
for node in learned_skills.values():
|
||||
clusters[node.category] = clusters.get(node.category, 0) + 1
|
||||
if memory_cards:
|
||||
clusters["memory"] = len(memory_cards)
|
||||
|
||||
graph_nodes = [
|
||||
{
|
||||
"id": n.name,
|
||||
"label": n.name,
|
||||
"kind": "skill",
|
||||
"timestamp": n.timestamp,
|
||||
"category": n.category,
|
||||
"useCount": n.use_count,
|
||||
"state": n.state,
|
||||
"createdBy": n.created_by,
|
||||
"pinned": n.pinned,
|
||||
}
|
||||
for n in learned_skills.values()
|
||||
]
|
||||
for i, card in enumerate(memory_cards):
|
||||
graph_nodes.append(
|
||||
{
|
||||
"id": f"memory:{card['source']}:{i}",
|
||||
"label": card["title"],
|
||||
"kind": "memory",
|
||||
"memorySource": card["source"],
|
||||
"timestamp": card.get("timestamp"),
|
||||
"category": "memory",
|
||||
"useCount": 0,
|
||||
"state": "active",
|
||||
"createdBy": "memory",
|
||||
"pinned": False,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"nodes": graph_nodes,
|
||||
"edges": [{"source": a, "target": b} for a, b in edges],
|
||||
"clusters": [
|
||||
{"category": c, "count": n}
|
||||
for c, n in sorted(clusters.items(), key=lambda kv: -kv[1])
|
||||
],
|
||||
"memory": memory_cards,
|
||||
"stats": {
|
||||
**density_stats(learned_skills, skill_edges),
|
||||
"memory_nodes": len(memory_cards),
|
||||
"memory_skill_edges": len(memory_edges),
|
||||
"learned_skills": len(learned_skills),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
nodes = build_skill_nodes(_skill_roots())
|
||||
print(json.dumps(density_stats(nodes, build_edges(nodes)), indent=2))
|
||||
@@ -262,7 +262,6 @@ def _install_npm(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
@@ -311,7 +310,6 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
|
||||
text=True,
|
||||
timeout=600,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
@@ -349,7 +347,6 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
|
||||
+6
-58
@@ -8,7 +8,6 @@ OpenCode's ``lsp/diagnostic.ts`` and Claude Code's
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Severity-1 only by default — warnings/info/hints would flood the
|
||||
@@ -19,65 +18,18 @@ DEFAULT_SEVERITIES = frozenset({1}) # ERROR only
|
||||
MAX_PER_FILE = 20
|
||||
MAX_TOTAL_CHARS = 4000
|
||||
|
||||
# Per-field caps for diagnostic content sourced from the language server.
|
||||
# These bound the length of any single attacker-controlled identifier that
|
||||
# can ride into the model's tool output via an LSP diagnostic message.
|
||||
MAX_MESSAGE_CHARS = 300
|
||||
MAX_CODE_CHARS = 80
|
||||
MAX_SOURCE_CHARS = 80
|
||||
|
||||
|
||||
def _sanitize_field(value: Any, *, limit: int) -> str:
|
||||
"""Make a language-server field safe to embed in a tool-result block.
|
||||
|
||||
Diagnostic ``message``, ``code``, and ``source`` originate from a
|
||||
language server that has just parsed user-controlled source code, so
|
||||
they're untrusted from the agent's point of view. A hostile repo can
|
||||
place instruction-shaped text inside identifier names, type aliases,
|
||||
or import paths so the resulting diagnostic echoes that text back
|
||||
into the ``<diagnostics>`` block the model reads.
|
||||
|
||||
This helper:
|
||||
|
||||
* Collapses CR/LF so a raw newline can't synthesize a new line in the
|
||||
formatted block.
|
||||
* Drops non-printable ASCII control characters that have no business
|
||||
in a single-line summary.
|
||||
* Caps length per-field so a long identifier can't push past the
|
||||
block boundary.
|
||||
* HTML-escapes ``< > &`` so the result can't close ``<diagnostics>``
|
||||
early or open a new tag.
|
||||
|
||||
Returns ``""`` for ``None`` / empty so the surrounding format string
|
||||
naturally omits the part (mirrors the prior ``if code not in {None,
|
||||
""}`` check at call sites).
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
raw = str(value)
|
||||
# Collapse newlines so identifier text with raw \n can't fake new lines.
|
||||
raw = raw.replace("\r", " ").replace("\n", " ")
|
||||
# Drop ASCII control chars; keep regular spaces.
|
||||
raw = "".join(ch for ch in raw if ch == " " or ch.isprintable())
|
||||
raw = raw.strip()[:limit]
|
||||
return html.escape(raw, quote=False)
|
||||
|
||||
|
||||
def format_diagnostic(d: Dict[str, Any]) -> str:
|
||||
"""One-line representation of a single diagnostic.
|
||||
|
||||
``message``, ``code``, and ``source`` are sanitized before
|
||||
interpolation — see ``_sanitize_field``.
|
||||
"""
|
||||
"""One-line representation of a single diagnostic."""
|
||||
sev = SEVERITY_NAMES.get(d.get("severity") or 1, "ERROR")
|
||||
rng = d.get("range") or {}
|
||||
start = rng.get("start") or {}
|
||||
line = int(start.get("line", 0)) + 1
|
||||
col = int(start.get("character", 0)) + 1
|
||||
msg = _sanitize_field(d.get("message"), limit=MAX_MESSAGE_CHARS)
|
||||
code = _sanitize_field(d.get("code"), limit=MAX_CODE_CHARS)
|
||||
code_part = f" [{code}]" if code else ""
|
||||
source = _sanitize_field(d.get("source"), limit=MAX_SOURCE_CHARS)
|
||||
msg = str(d.get("message") or "").rstrip()
|
||||
code = d.get("code")
|
||||
code_part = f" [{code}]" if code not in {None, ""} else ""
|
||||
source = d.get("source")
|
||||
source_part = f" ({source})" if source else ""
|
||||
return f"{sev} [{line}:{col}] {msg}{code_part}{source_part}"
|
||||
|
||||
@@ -105,11 +57,7 @@ def report_for_file(
|
||||
body = "\n".join(lines)
|
||||
if extra > 0:
|
||||
body += f"\n... and {extra} more"
|
||||
# quote=True escapes both ``"`` and ``&`` so a crafted file name like
|
||||
# ``foo"><script`` can't break out of the ``file="..."`` attribute and
|
||||
# synthesize new tags inside the tool output.
|
||||
safe_path = html.escape(file_path, quote=True)
|
||||
return f"<diagnostics file=\"{safe_path}\">\n{body}\n</diagnostics>"
|
||||
return f"<diagnostics file=\"{file_path}\">\n{body}\n</diagnostics>"
|
||||
|
||||
|
||||
def truncate(s: str, *, limit: int = MAX_TOTAL_CHARS) -> str:
|
||||
|
||||
+9
-233
@@ -25,16 +25,14 @@ Usage in run_agent.py:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import inspect
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from agent.skill_commands import extract_user_instruction_from_skill_message
|
||||
from tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,105 +44,6 @@ logger = logging.getLogger(__name__)
|
||||
_SYNC_DRAIN_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Return a function-tool dict with a resolvable top-level ``name``.
|
||||
|
||||
Context engines and memory providers expose tool schemas via
|
||||
``get_tool_schemas()``. The expected shape is a bare function schema
|
||||
(``{"name": ..., "description": ..., "parameters": ...}``) which callers
|
||||
wrap as ``{"type": "function", "function": schema}``.
|
||||
|
||||
Some providers instead return an entry that is *already* in OpenAI tool
|
||||
form (``{"type": "function", "function": {"name": ...}}``). Wrapping that
|
||||
a second time produces ``{"type": "function", "function": {"type":
|
||||
"function", "function": {...}}}`` whose ``function`` has no top-level
|
||||
``name``. Strict providers (e.g. DeepSeek) reject the *entire* request
|
||||
with ``tools[N].function: missing field name`` (HTTP 400), so one bad
|
||||
schema disables the whole toolset and breaks every turn (#47707).
|
||||
|
||||
This helper normalizes both shapes to the bare function schema and
|
||||
returns ``None`` for anything without a resolvable name, so callers can
|
||||
skip-with-warning rather than appending a nameless tool.
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return None
|
||||
# Unwrap an already-wrapped OpenAI tool entry.
|
||||
if schema.get("type") == "function" and isinstance(schema.get("function"), dict):
|
||||
schema = schema["function"]
|
||||
if not isinstance(schema, dict):
|
||||
return None
|
||||
name = schema.get("name", "")
|
||||
if not name or not isinstance(name, str):
|
||||
return None
|
||||
return schema
|
||||
|
||||
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
|
||||
"""Return whether external memory-provider tools should be exposed."""
|
||||
if enabled_toolsets is None:
|
||||
return True
|
||||
if not enabled_toolsets:
|
||||
return False
|
||||
if "memory" in enabled_toolsets:
|
||||
return True
|
||||
|
||||
try:
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def inject_memory_provider_tools(agent: Any) -> int:
|
||||
"""Append external memory-provider tool schemas to an agent tool surface."""
|
||||
memory_manager = getattr(agent, "_memory_manager", None)
|
||||
tools = getattr(agent, "tools", None)
|
||||
if not memory_manager or tools is None:
|
||||
return 0
|
||||
|
||||
existing_tool_names = {
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools
|
||||
if isinstance(tool, dict)
|
||||
}
|
||||
if (
|
||||
"memory" not in existing_tool_names
|
||||
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
|
||||
):
|
||||
return 0
|
||||
|
||||
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
|
||||
if not callable(get_schemas):
|
||||
return 0
|
||||
|
||||
valid_tool_names = getattr(agent, "valid_tool_names", None)
|
||||
if valid_tool_names is None:
|
||||
valid_tool_names = set()
|
||||
agent.valid_tool_names = valid_tool_names
|
||||
|
||||
added = 0
|
||||
for raw_schema in get_schemas():
|
||||
schema = normalize_tool_schema(raw_schema)
|
||||
if schema is None:
|
||||
logger.warning(
|
||||
"Memory provider returned a tool schema with no resolvable "
|
||||
"name; skipping to avoid poisoning the request (%r)",
|
||||
raw_schema,
|
||||
)
|
||||
continue
|
||||
tool_name = schema["name"]
|
||||
if tool_name in existing_tool_names:
|
||||
continue
|
||||
tools.append({"type": "function", "function": schema})
|
||||
valid_tool_names.add(tool_name)
|
||||
existing_tool_names.add(tool_name)
|
||||
added += 1
|
||||
|
||||
return added
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context fencing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -409,11 +308,8 @@ class MemoryManager:
|
||||
_core_tool_names = set(_HERMES_CORE_TOOLS)
|
||||
|
||||
# Index tool names → provider for routing
|
||||
for raw_schema in provider.get_tool_schemas():
|
||||
schema = normalize_tool_schema(raw_schema)
|
||||
if schema is None:
|
||||
continue
|
||||
tool_name = schema["name"]
|
||||
for schema in provider.get_tool_schemas():
|
||||
tool_name = schema.get("name", "")
|
||||
if tool_name in _core_tool_names:
|
||||
logger.warning(
|
||||
"Memory provider '%s' tool '%s' shadows a reserved core "
|
||||
@@ -474,37 +370,16 @@ class MemoryManager:
|
||||
|
||||
# -- Prefetch / recall ---------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _strip_skill_scaffolding(text: str) -> Optional[str]:
|
||||
"""Return memory-worthy user text, or None to skip the turn.
|
||||
|
||||
When a user invokes a /skill or /bundle, Hermes expands the turn into
|
||||
a model-facing message that embeds the entire skill body. Feeding that
|
||||
verbatim to memory providers pollutes their stores/embeddings with
|
||||
prompt scaffolding instead of what the user actually asked. We recover
|
||||
just the user's instruction here, once, for every provider — so this
|
||||
is fixed for the whole provider fan-out, not per backend.
|
||||
|
||||
- Non-skill messages pass through unchanged.
|
||||
- Skill turns with a user instruction return that instruction.
|
||||
- Bare skill invocations (no instruction) return None → callers skip
|
||||
the turn, since there is no user content worth remembering.
|
||||
"""
|
||||
return extract_user_instruction_from_skill_message(text)
|
||||
|
||||
def prefetch_all(self, query: str, *, session_id: str = "") -> str:
|
||||
"""Collect prefetch context from all providers.
|
||||
|
||||
Returns merged context text labeled by provider. Empty providers
|
||||
are skipped. Failures in one provider don't block others.
|
||||
"""
|
||||
clean_query = self._strip_skill_scaffolding(query)
|
||||
if not clean_query:
|
||||
return ""
|
||||
parts = []
|
||||
for provider in self._providers:
|
||||
try:
|
||||
result = provider.prefetch(clean_query, session_id=session_id)
|
||||
result = provider.prefetch(query, session_id=session_id)
|
||||
if result and result.strip():
|
||||
parts.append(result)
|
||||
except Exception as e:
|
||||
@@ -525,14 +400,10 @@ class MemoryManager:
|
||||
if not providers:
|
||||
return
|
||||
|
||||
clean_query = self._strip_skill_scaffolding(query)
|
||||
if not clean_query:
|
||||
return
|
||||
|
||||
def _run() -> None:
|
||||
for provider in providers:
|
||||
try:
|
||||
provider.queue_prefetch(clean_query, session_id=session_id)
|
||||
provider.queue_prefetch(query, session_id=session_id)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
|
||||
@@ -584,11 +455,6 @@ class MemoryManager:
|
||||
if not providers:
|
||||
return
|
||||
|
||||
clean_user_content = self._strip_skill_scaffolding(user_content)
|
||||
if not clean_user_content:
|
||||
return
|
||||
user_content = clean_user_content
|
||||
|
||||
def _run() -> None:
|
||||
for provider in providers:
|
||||
try:
|
||||
@@ -700,19 +566,11 @@ class MemoryManager:
|
||||
seen = set()
|
||||
for provider in self._providers:
|
||||
try:
|
||||
for raw_schema in provider.get_tool_schemas():
|
||||
schema = normalize_tool_schema(raw_schema)
|
||||
if schema is None:
|
||||
logger.warning(
|
||||
"Memory provider '%s' returned a tool schema with "
|
||||
"no resolvable name; skipping (%r)",
|
||||
provider.name, raw_schema,
|
||||
)
|
||||
continue
|
||||
name = schema["name"]
|
||||
for schema in provider.get_tool_schemas():
|
||||
name = schema.get("name", "")
|
||||
if name in _core_tool_names:
|
||||
continue
|
||||
if name not in seen:
|
||||
if name and name not in seen:
|
||||
schemas.append(schema)
|
||||
seen.add(name)
|
||||
except Exception as e:
|
||||
@@ -772,10 +630,9 @@ class MemoryManager:
|
||||
try:
|
||||
provider.on_session_end(messages)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
logger.debug(
|
||||
"Memory provider '%s' on_session_end failed: %s",
|
||||
provider.name, e,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def on_session_switch(
|
||||
@@ -901,87 +758,6 @@ class MemoryManager:
|
||||
provider.name, e,
|
||||
)
|
||||
|
||||
# Actions the bridge mirrors to external providers. The built-in memory
|
||||
# tool can also return non-mutating shapes (errors, staged-for-approval
|
||||
# records); those are filtered out by ``notify_memory_tool_write`` before
|
||||
# we ever reach a provider.
|
||||
_MIRRORED_MEMORY_ACTIONS = {"add", "replace", "remove"}
|
||||
|
||||
@staticmethod
|
||||
def _memory_tool_result_succeeded(result: Any) -> bool:
|
||||
"""True only when the built-in memory tool actually committed a write.
|
||||
|
||||
Fails closed: a string that isn't JSON, a non-dict result, a missing
|
||||
``success``, or a write staged for approval (``staged is True``) all
|
||||
return False so external providers are never told about a write that
|
||||
did not land.
|
||||
"""
|
||||
if isinstance(result, str):
|
||||
try:
|
||||
result = json.loads(result)
|
||||
except Exception:
|
||||
return False
|
||||
if not isinstance(result, dict):
|
||||
return False
|
||||
return result.get("success") is True and result.get("staged") is not True
|
||||
|
||||
def notify_memory_tool_write(
|
||||
self,
|
||||
tool_result: Any,
|
||||
tool_args: Dict[str, Any],
|
||||
*,
|
||||
build_metadata: Optional[Callable[[], Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""Mirror a built-in memory tool call to external providers.
|
||||
|
||||
This is the single entry point the agent loop calls after running the
|
||||
built-in ``memory`` tool. All the decisions about *whether* and *what*
|
||||
to mirror live here, behind the manager interface — the loop only hands
|
||||
over the raw tool result and args:
|
||||
|
||||
* gate on a committed (non-staged, successful) write,
|
||||
* expand the single-op and batched (``operations``) shapes,
|
||||
* keep only mutating actions (add/replace/remove),
|
||||
* build per-op provenance metadata and forward ``old_text``.
|
||||
|
||||
``build_metadata`` is an optional agent-side callable (the loop knows
|
||||
session/task/tool-call provenance the manager does not) invoked once per
|
||||
mirrored op.
|
||||
"""
|
||||
if not self._memory_tool_result_succeeded(tool_result):
|
||||
return
|
||||
|
||||
target = str(tool_args.get("target") or "memory")
|
||||
operations = tool_args.get("operations")
|
||||
if isinstance(operations, list) and operations:
|
||||
raw_operations = operations
|
||||
else:
|
||||
raw_operations = [{
|
||||
"action": tool_args.get("action"),
|
||||
"content": tool_args.get("content"),
|
||||
"old_text": tool_args.get("old_text"),
|
||||
}]
|
||||
|
||||
for op in raw_operations:
|
||||
if not isinstance(op, dict):
|
||||
continue
|
||||
action = str(op.get("action") or "")
|
||||
if action not in self._MIRRORED_MEMORY_ACTIONS:
|
||||
continue
|
||||
try:
|
||||
metadata = dict(build_metadata() if build_metadata else {})
|
||||
old_text = op.get("old_text")
|
||||
if old_text:
|
||||
metadata["old_text"] = str(old_text)
|
||||
self.on_memory_write(
|
||||
action,
|
||||
target,
|
||||
str(op.get("content") or ""),
|
||||
metadata=metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("notify_memory_tool_write failed for op %s: %s", action, e)
|
||||
|
||||
def on_delegation(self, task: str, result: str, *,
|
||||
child_session_id: str = "", **kwargs) -> None:
|
||||
"""Notify all providers that a subagent completed."""
|
||||
|
||||
@@ -28,7 +28,6 @@ Optional hooks (override to opt in):
|
||||
on_pre_compress(messages) -> str — extract before context compression
|
||||
on_memory_write(action, target, content, metadata=None) — mirror built-in memory writes
|
||||
on_delegation(task, result, **kwargs) — parent-side observation of subagent work
|
||||
backup_paths() -> list[str] — extra on-disk paths to include in `hermes backup`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -295,21 +294,3 @@ class MemoryProvider(ABC):
|
||||
|
||||
Use to mirror built-in memory writes to your backend.
|
||||
"""
|
||||
|
||||
def backup_paths(self) -> List[str]:
|
||||
"""Return extra on-disk paths this provider stores OUTSIDE HERMES_HOME.
|
||||
|
||||
``hermes backup`` only walks HERMES_HOME, so any provider state kept
|
||||
under ``~/.honcho``, ``~/.hindsight``, ``~/.openviking``, etc. is lost
|
||||
across a backup/import cycle unless it's declared here.
|
||||
|
||||
Return a list of absolute path strings (files or directories). The
|
||||
backup command resolves each, captures the ones that exist and live
|
||||
under the user's home directory into a reserved ``_external/`` subtree
|
||||
of the archive, and ``hermes import`` restores them to their original
|
||||
locations. Paths outside the home directory are skipped for safety.
|
||||
|
||||
MUST be callable without ``initialize()`` and without network — resolve
|
||||
from config/env only. Default returns an empty list (nothing external).
|
||||
"""
|
||||
return []
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
_NON_TEXT_PART_TYPES = {"image", "image_url", "input_image", "audio", "input_audio"}
|
||||
_TEXT_KEYS = ("text", "content", "input_text", "output_text", "summary_text")
|
||||
|
||||
|
||||
def _field(value: Any, key: str) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return value.get(key)
|
||||
return getattr(value, key, None)
|
||||
|
||||
|
||||
def _text_from_part(part: Any) -> str:
|
||||
if part is None:
|
||||
return ""
|
||||
if isinstance(part, str):
|
||||
return part
|
||||
|
||||
part_type = str(_field(part, "type") or "").strip().lower()
|
||||
if part_type in _NON_TEXT_PART_TYPES:
|
||||
return ""
|
||||
|
||||
for key in _TEXT_KEYS:
|
||||
text = _field(part, key)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def flatten_message_text(content: Any, *, sep: str = "\n") -> str:
|
||||
"""Return the visible text from common chat/Responses message content shapes."""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
chunks = [_text_from_part(part) for part in content]
|
||||
return sep.join(chunk for chunk in chunks if chunk)
|
||||
|
||||
text = _text_from_part(content)
|
||||
if text:
|
||||
return text
|
||||
try:
|
||||
return str(content)
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -279,38 +279,6 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
|
||||
return "{}"
|
||||
|
||||
|
||||
def close_interrupted_tool_sequence(messages: list, final_response: Any = None) -> bool:
|
||||
"""Append a synthetic assistant turn when an interrupted tail is a tool result.
|
||||
|
||||
A turn cut short by ``/stop`` can leave the transcript ending on a raw
|
||||
``tool`` message (a tool finished, or its execution was cancelled, but the
|
||||
model never streamed a closing assistant turn). Persisting that tail means
|
||||
the next user message lands as ``… tool → user`` — a role-alternation
|
||||
violation that strict providers (Gemini, Claude) react to by hallucinating
|
||||
a continuation of the user's message and ignoring prior context, which
|
||||
reads to the user as "lost context" (#48879).
|
||||
|
||||
``finalize_turn`` closes this on the happy interrupt path, but the
|
||||
retry/backoff/error interrupt aborts in ``conversation_loop`` ``return``
|
||||
early and never reach it — this shared helper closes the sequence on all of
|
||||
them. ``final_response`` is usually empty on an interrupt, so an explicit
|
||||
placeholder is used rather than an empty-content assistant turn.
|
||||
|
||||
Mutates ``messages`` in place. Returns True if a closing turn was appended.
|
||||
"""
|
||||
if not messages:
|
||||
return False
|
||||
last = messages[-1]
|
||||
if not isinstance(last, dict) or last.get("role") != "tool":
|
||||
return False
|
||||
text = final_response if isinstance(final_response, str) else ""
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": text.strip() or "Operation interrupted.",
|
||||
})
|
||||
return True
|
||||
|
||||
|
||||
def _strip_non_ascii(text: str) -> str:
|
||||
"""Remove non-ASCII characters, replacing with closest ASCII equivalent or removing.
|
||||
|
||||
@@ -463,7 +431,6 @@ def _sanitize_structure_non_ascii(payload: Any) -> bool:
|
||||
|
||||
__all__ = [
|
||||
"_SURROGATE_RE",
|
||||
"close_interrupted_tool_sequence",
|
||||
"_sanitize_surrogates",
|
||||
"_sanitize_structure_surrogates",
|
||||
"_sanitize_messages_surrogates",
|
||||
|
||||
@@ -1,631 +0,0 @@
|
||||
"""Mixture-of-Agents runtime helpers for /moa turns.
|
||||
|
||||
The slash command is deliberately not a model tool. It marks one user turn as
|
||||
MoA-enabled; the normal Hermes agent loop still owns tool calling and turn
|
||||
termination, while this module gathers reference-model context before each model
|
||||
iteration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from agent.auxiliary_client import call_llm
|
||||
from agent.transports import get_transport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Upper bound on concurrent reference-model calls. References are independent
|
||||
# advisory calls (no tools, no inter-dependence), so we fan them out the same
|
||||
# way delegate_task runs a batch: all in flight at once, results collected when
|
||||
# every reference finishes. Presets rarely list more than a handful of
|
||||
# references; this cap just protects against a pathologically large preset
|
||||
# opening dozens of sockets at once.
|
||||
_MAX_REFERENCE_WORKERS = 8
|
||||
|
||||
# Per-tool-result character budget for the advisory reference view. Tool
|
||||
# results can be huge (a full diff, a 5000-line file dump); replaying them
|
||||
# verbatim per reference per tool-loop step would blow the reference model's
|
||||
# context window and cost. We keep the agent's *actions* (tool calls) in full —
|
||||
# they are cheap, high-signal, and tell the reference what the agent did — but
|
||||
# preview each tool *result* head+tail so the reference still sees what came
|
||||
# back without replaying megabytes. The acting aggregator always gets the full,
|
||||
# untrimmed transcript; this budget only shapes the advisory copy.
|
||||
_REFERENCE_TOOL_RESULT_BUDGET = 4000
|
||||
|
||||
# System prompt prepended to every reference-model call. References are
|
||||
# advisory — they do NOT act, call tools, or own the task. Without this
|
||||
# framing a reference receives the bare trimmed conversation and assumes it is
|
||||
# the acting agent: it then refuses ("I can't access repositories / URLs from
|
||||
# here") or tries to call tools it doesn't have. The prompt reframes the model
|
||||
# as an analyst whose job is to reason about the presented state and hand its
|
||||
# best thinking to the aggregator/orchestrator that will actually act.
|
||||
_REFERENCE_SYSTEM_PROMPT = (
|
||||
"You are a reference advisor in a Mixture of Agents (MoA) process. You are "
|
||||
"NOT the acting agent and you do NOT execute anything: you cannot call "
|
||||
"tools, run commands, browse, or access files, repositories, or URLs, and "
|
||||
"you should not try to or apologize for being unable to. A separate "
|
||||
"aggregator/orchestrator model holds those capabilities and will take the "
|
||||
"actual actions.\n\n"
|
||||
"The conversation below is the current state of a task handled by that "
|
||||
"acting agent. Your job is to give your most intelligent analysis of that "
|
||||
"state: understand the goal, reason about the problem, and advise on what "
|
||||
"to do next. Surface the best approach, concrete next steps and tool-use "
|
||||
"strategy, likely pitfalls and risks, and anything the acting agent may "
|
||||
"have missed or gotten wrong. Assume any referenced files, URLs, or "
|
||||
"systems exist and reason about them from the context given rather than "
|
||||
"asking for access.\n\n"
|
||||
"Respond with your advice directly — no preamble, no disclaimers about "
|
||||
"tools or access. Your response is private guidance handed to the "
|
||||
"aggregator, not an answer shown to the user."
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _slot_label(slot: dict[str, str]) -> str:
|
||||
return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}"
|
||||
|
||||
|
||||
def _reference_system_prompt(slot: dict[str, str]) -> str:
|
||||
role_prompt = str(slot.get("role_prompt") or "").strip()
|
||||
if not role_prompt:
|
||||
return _REFERENCE_SYSTEM_PROMPT
|
||||
return (
|
||||
f"{_REFERENCE_SYSTEM_PROMPT}\n\n"
|
||||
"Reference-specific role instruction for this model:\n"
|
||||
f"{role_prompt}"
|
||||
)
|
||||
|
||||
|
||||
def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:
|
||||
"""Resolve a reference/aggregator slot to real runtime call kwargs.
|
||||
|
||||
A MoA slot is just a model selection — it must be called the same way any
|
||||
model is called elsewhere, not through a bare ``call_llm(provider=...,
|
||||
model=...)`` that leaves base_url/api_key/api_mode unresolved and lets the
|
||||
auxiliary auto-detector guess. We route the slot's provider through
|
||||
``resolve_runtime_provider`` (the canonical provider→api_mode/base_url/
|
||||
api_key resolver the CLI, gateway, and delegate_task all use), so the slot
|
||||
gets its provider's real API surface — e.g. MiniMax → anthropic_messages,
|
||||
GPT-5/o-series → max_completion_tokens, custom endpoints → their base_url.
|
||||
|
||||
Returns the kwargs to pass through to ``call_llm`` (provider/model plus the
|
||||
resolved base_url/api_key when available). Falls back to the bare
|
||||
provider/model on any resolution error so a misconfigured slot still
|
||||
attempts the call rather than aborting the whole MoA turn.
|
||||
"""
|
||||
provider = str(slot.get("provider") or "").strip()
|
||||
model = str(slot.get("model") or "").strip()
|
||||
out: dict[str, Any] = {"provider": provider, "model": model}
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
rt = resolve_runtime_provider(requested=provider, target_model=model)
|
||||
resolved_provider = str(rt.get("provider") or provider).strip().lower()
|
||||
# call_llm treats an explicit base_url as a custom endpoint. That is
|
||||
# correct for ordinary OpenAI-compatible targets, but wrong for OAuth /
|
||||
# provider-backed targets whose provider branch adds auth refresh,
|
||||
# request metadata, or request-shape adapters. Keep those providers
|
||||
# identified by name.
|
||||
if resolved_provider in {"nous", "openai-codex", "xai-oauth"}:
|
||||
return out
|
||||
# Pass the resolved endpoint through so call_llm builds the request for
|
||||
# the provider's actual API surface instead of auto-detecting. base_url
|
||||
# routes call_llm to the right adapter (incl. anthropic_messages mode);
|
||||
# api_key is the resolved credential for that provider.
|
||||
if rt.get("base_url"):
|
||||
out["base_url"] = rt["base_url"]
|
||||
if rt.get("api_key"):
|
||||
out["api_key"] = rt["api_key"]
|
||||
if rt.get("api_mode"):
|
||||
out["api_mode"] = rt["api_mode"]
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("MoA slot runtime resolution failed for %s: %s", _slot_label(slot), exc)
|
||||
return out
|
||||
|
||||
|
||||
def _run_reference(
|
||||
slot: dict[str, str],
|
||||
ref_messages: list[dict[str, Any]],
|
||||
*,
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Call one reference model and return ``(label, text)``.
|
||||
|
||||
The slot is resolved to its provider's real runtime (via ``_slot_runtime``)
|
||||
and called through the same ``call_llm`` request-building path any model
|
||||
uses, so per-model wire-format handling (anthropic_messages,
|
||||
max_completion_tokens, fixed/forbidden temperature) applies identically to
|
||||
a reference as it would if that model were the acting model. MoA imposes no
|
||||
cap of its own (``max_tokens`` defaults to ``None`` → omitted → the model's
|
||||
real maximum); ``temperature`` is only the user's configured preset value,
|
||||
which call_llm may still override per model.
|
||||
|
||||
Never raises: a failed reference becomes a labelled note so the aggregator
|
||||
can still act with partial context. Designed to run inside a thread pool —
|
||||
``call_llm`` is synchronous/blocking, so threads (not asyncio) are the right
|
||||
concurrency primitive, mirroring ``delegate_task``'s batch fan-out.
|
||||
"""
|
||||
label = _slot_label(slot)
|
||||
try:
|
||||
# Prepend the advisory-role system prompt so the reference understands
|
||||
# it is analyzing state for an aggregator, not acting on the task. The
|
||||
# trimmed view (_reference_messages) already strips the agent's own
|
||||
# system prompt, so this is the only system message the reference sees.
|
||||
messages = [{"role": "system", "content": _reference_system_prompt(slot)}, *ref_messages]
|
||||
response = call_llm(
|
||||
task="moa_reference",
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
**_slot_runtime(slot),
|
||||
)
|
||||
return label, _extract_text(response) or "(empty response)"
|
||||
except Exception as exc:
|
||||
logger.warning("MoA reference model %s failed: %s", label, exc)
|
||||
return label, f"[failed: {exc}]"
|
||||
|
||||
|
||||
def _run_references_parallel(
|
||||
reference_models: list[dict[str, str]],
|
||||
ref_messages: list[dict[str, Any]],
|
||||
*,
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Fan out all reference models in parallel, returning outputs in order.
|
||||
|
||||
Like ``delegate_task``'s batch mode, every reference is dispatched at once
|
||||
and we block until all of them finish before handing the joined results to
|
||||
the aggregator. Output order matches ``reference_models`` so the
|
||||
``Reference {idx}`` labelling stays stable. MoA presets that reference
|
||||
another MoA preset are skipped here (recursion guard) with a labelled note.
|
||||
"""
|
||||
if not reference_models:
|
||||
return []
|
||||
|
||||
results: list[tuple[str, str] | None] = [None] * len(reference_models)
|
||||
futures = {}
|
||||
workers = min(_MAX_REFERENCE_WORKERS, len(reference_models))
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
for idx, slot in enumerate(reference_models):
|
||||
if slot.get("provider") == "moa":
|
||||
results[idx] = (
|
||||
_slot_label(slot),
|
||||
"[skipped: MoA presets cannot recursively reference MoA]",
|
||||
)
|
||||
continue
|
||||
futures[
|
||||
executor.submit(
|
||||
_run_reference,
|
||||
slot,
|
||||
ref_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
] = idx
|
||||
# Collect every reference before returning — the aggregator needs the
|
||||
# complete set, so there is no early-exit / first-completed path here.
|
||||
for future, idx in futures.items():
|
||||
results[idx] = future.result()
|
||||
|
||||
return [r for r in results if r is not None]
|
||||
|
||||
|
||||
def _truncate_tool_result(text: str, budget: int = _REFERENCE_TOOL_RESULT_BUDGET) -> str:
|
||||
"""Head+tail preview of a tool result for the advisory view.
|
||||
|
||||
Keeps the first and last halves of the budget with a ``[... N chars
|
||||
omitted ...]`` marker between them, so a reference sees both how the result
|
||||
started and how it ended without replaying the whole payload.
|
||||
"""
|
||||
if not text or len(text) <= budget:
|
||||
return text
|
||||
half = budget // 2
|
||||
omitted = len(text) - 2 * half
|
||||
return f"{text[:half]}\n[... {omitted} chars omitted ...]\n{text[-half:]}"
|
||||
|
||||
|
||||
def _render_tool_calls(tool_calls: Any) -> str:
|
||||
"""Render an assistant turn's tool_calls as readable text lines.
|
||||
|
||||
The advisory view cannot carry real ``tool_calls`` payloads (strict
|
||||
providers reject tool_calls the reference never produced), so the agent's
|
||||
actions are flattened to text the reference can read and reason about.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for tc in tool_calls or []:
|
||||
fn = (tc.get("function") or {}) if isinstance(tc, dict) else {}
|
||||
name = fn.get("name") or (tc.get("name") if isinstance(tc, dict) else "") or "tool"
|
||||
args = fn.get("arguments")
|
||||
if isinstance(args, str):
|
||||
args_text = args
|
||||
elif args is not None:
|
||||
try:
|
||||
import json
|
||||
|
||||
args_text = json.dumps(args, ensure_ascii=False)
|
||||
except Exception:
|
||||
args_text = str(args)
|
||||
else:
|
||||
args_text = ""
|
||||
lines.append(f"[called tool: {name}({args_text})]" if args_text else f"[called tool: {name}]")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Build an advisory view of the conversation for reference models.
|
||||
|
||||
A reference gives an INFORMED judgement on the current state, so it must
|
||||
see what the agent actually did — its tool calls AND the tool results that
|
||||
came back — not just the agent's narration. We therefore preserve the whole
|
||||
conversation flow, but flatten it into clean user/assistant *text* turns:
|
||||
|
||||
- system prompt: dropped (8K of Hermes boilerplate, not advisory signal).
|
||||
- assistant turns: kept; any ``tool_calls`` are rendered inline as
|
||||
``[called tool: name(args)]`` text lines appended to the turn's text.
|
||||
- ``tool``-role results: NOT dropped. Each is folded (head+tail preview,
|
||||
see ``_truncate_tool_result``) into the *preceding* assistant turn as a
|
||||
``[tool result: ...]`` block, so the reference sees what came back.
|
||||
|
||||
This emits ZERO ``tool``-role messages and ZERO ``tool_calls`` arrays — only
|
||||
plain user/assistant text — so strict providers (Mistral, Fireworks) that
|
||||
reject orphan tool messages / unproduced tool_calls don't 400, while the
|
||||
reference still has the full picture.
|
||||
|
||||
The view MUST end with a ``user`` turn. Anthropic (and OpenRouter→Anthropic)
|
||||
interpret a trailing assistant turn as an assistant *prefill* to continue,
|
||||
and no-prefill models (e.g. Claude Opus 4.8) reject it with
|
||||
``400 ... must end with a user message``. Rather than DELETE the agent's
|
||||
latest context to satisfy that (which would blind the reference to the
|
||||
current state), we APPEND a synthetic user turn asking the reference to
|
||||
judge the state above. End-on-user is satisfied and no context is lost.
|
||||
|
||||
The acting aggregator always receives the full, untrimmed transcript; this
|
||||
function only shapes the disposable advisory copy.
|
||||
"""
|
||||
advisory_instruction = (
|
||||
"[The conversation above is the current state of the task. Give your "
|
||||
"most intelligent judgement: what is going on, what should happen next, "
|
||||
"what risks or mistakes you see, and how the acting agent should "
|
||||
"proceed.]"
|
||||
)
|
||||
|
||||
rendered: list[dict[str, Any]] = []
|
||||
last_user_content: str | None = None
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
text = content if isinstance(content, str) else ""
|
||||
|
||||
if role == "system":
|
||||
continue
|
||||
if role == "user":
|
||||
if text.strip():
|
||||
last_user_content = text
|
||||
rendered.append({"role": "user", "content": text})
|
||||
elif role == "assistant":
|
||||
parts: list[str] = []
|
||||
if text.strip():
|
||||
parts.append(text.strip())
|
||||
calls_text = _render_tool_calls(msg.get("tool_calls"))
|
||||
if calls_text:
|
||||
parts.append(calls_text)
|
||||
# Empty assistant turns (no text, no calls) carry nothing advisory.
|
||||
if parts:
|
||||
rendered.append({"role": "assistant", "content": "\n".join(parts)})
|
||||
elif role == "tool":
|
||||
# Fold the tool result into the preceding assistant turn as text so
|
||||
# the reference sees what came back, without emitting a tool-role
|
||||
# message a reference never produced.
|
||||
result_text = _truncate_tool_result(text)
|
||||
block = f"[tool result: {result_text}]"
|
||||
if rendered and rendered[-1].get("role") == "assistant":
|
||||
rendered[-1]["content"] = rendered[-1]["content"] + "\n" + block
|
||||
else:
|
||||
# No assistant turn to attach to (e.g. a leading tool result);
|
||||
# keep it as advisory context on its own assistant-role line.
|
||||
rendered.append({"role": "assistant", "content": block})
|
||||
# Any other role is ignored.
|
||||
|
||||
# End on a user turn: append a synthetic advisory request rather than
|
||||
# deleting the agent's latest assistant context. This satisfies Anthropic's
|
||||
# no-trailing-assistant-prefill rule while preserving full state.
|
||||
if rendered and rendered[-1].get("role") == "assistant":
|
||||
rendered.append({"role": "user", "content": advisory_instruction})
|
||||
elif rendered and rendered[-1].get("role") == "user":
|
||||
# Already ends on a user turn (fresh user prompt, no agent action yet).
|
||||
# Leave it — the reference answers that prompt directly.
|
||||
pass
|
||||
|
||||
if not rendered:
|
||||
# Degenerate case: nothing rendered. Fall back to the latest user turn.
|
||||
if last_user_content is not None:
|
||||
return [{"role": "user", "content": last_user_content}]
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
|
||||
return [{"role": "user", "content": msg["content"]}]
|
||||
return rendered
|
||||
|
||||
|
||||
|
||||
def _extract_text(response: Any) -> str:
|
||||
try:
|
||||
transport = get_transport("chat_completions")
|
||||
if transport is None:
|
||||
raise RuntimeError("chat_completions transport unavailable")
|
||||
normalized = transport.normalize_response(response)
|
||||
text = (normalized.content or "").strip()
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
message = response.choices[0].message
|
||||
if isinstance(message, dict):
|
||||
content = message.get("content")
|
||||
else:
|
||||
content = getattr(message, "content", message)
|
||||
if not isinstance(content, str):
|
||||
content = str(content) if content else ""
|
||||
return content.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def aggregate_moa_context(
|
||||
*,
|
||||
user_prompt: str,
|
||||
api_messages: list[dict[str, Any]],
|
||||
reference_models: list[dict[str, str]],
|
||||
aggregator: dict[str, str],
|
||||
temperature: float = 0.6,
|
||||
aggregator_temperature: float = 0.4,
|
||||
max_tokens: int | None = None,
|
||||
) -> str:
|
||||
"""Run configured reference models and synthesize their advice.
|
||||
|
||||
Failures are returned as model-specific notes instead of aborting the normal
|
||||
agent loop; the main model can still act with partial context.
|
||||
|
||||
``max_tokens`` is ``None`` by default: MoA does not cap reference or
|
||||
aggregator output, so each model uses its own maximum. ``call_llm`` omits
|
||||
the parameter entirely when it is ``None`` (see its docstring), which also
|
||||
sidesteps providers that reject ``max_tokens`` outright. A hardcoded cap
|
||||
here previously truncated long aggregator syntheses.
|
||||
"""
|
||||
reference_outputs: list[tuple[str, str]] = []
|
||||
ref_messages = _reference_messages(api_messages)
|
||||
reference_outputs = _run_references_parallel(
|
||||
reference_models,
|
||||
ref_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
joined = "\n\n".join(
|
||||
f"Reference {idx} — {label}:\n{text}"
|
||||
for idx, (label, text) in enumerate(reference_outputs, start=1)
|
||||
)
|
||||
synth_prompt = (
|
||||
"You are the aggregator in a Mixture of Agents process. Synthesize the "
|
||||
"reference responses into concise, actionable guidance for the main "
|
||||
"Hermes agent. Focus on next steps, tool-use strategy, risks, and any "
|
||||
"disagreements. Do not answer the user directly unless that is all that "
|
||||
"is needed; produce context the main agent should use in its normal loop.\n\n"
|
||||
f"Original user prompt:\n{user_prompt}\n\n"
|
||||
f"Reference responses:\n{joined}"
|
||||
)
|
||||
|
||||
agg_label = _slot_label(aggregator)
|
||||
try:
|
||||
response = call_llm(
|
||||
task="moa_aggregator",
|
||||
messages=[{"role": "user", "content": synth_prompt}],
|
||||
temperature=aggregator_temperature,
|
||||
max_tokens=max_tokens,
|
||||
**_slot_runtime(aggregator),
|
||||
)
|
||||
synthesis = _extract_text(response)
|
||||
except Exception as exc:
|
||||
logger.warning("MoA aggregator model %s failed: %s", agg_label, exc)
|
||||
synthesis = ""
|
||||
|
||||
if not synthesis:
|
||||
synthesis = joined
|
||||
|
||||
return (
|
||||
"[Mixture of Agents context — use this as private guidance for the "
|
||||
"normal Hermes agent loop. You may call tools, continue reasoning, or "
|
||||
"finish normally.]\n"
|
||||
f"Aggregator: {agg_label}\n"
|
||||
f"References: {', '.join(_slot_label(slot) for slot in reference_models)}\n\n"
|
||||
f"{synthesis.strip()}"
|
||||
)
|
||||
|
||||
|
||||
class MoAChatCompletions:
|
||||
"""OpenAI-chat-compatible facade where the aggregator is the acting model."""
|
||||
|
||||
def __init__(self, preset_name: str, reference_callback: Any = None):
|
||||
self.preset_name = preset_name or "default"
|
||||
# Optional display hook. Called as reference outputs become available so
|
||||
# frontends can show each reference model's answer as a labelled block
|
||||
# before the aggregator acts. Signature:
|
||||
# reference_callback(event, **kwargs)
|
||||
# where event is one of:
|
||||
# "moa.reference" kwargs: index, count, label, text
|
||||
# "moa.aggregating" kwargs: aggregator (label), ref_count
|
||||
# Never raises into the model call — display is best-effort.
|
||||
self.reference_callback = reference_callback
|
||||
# State-scoped reference cache. The agent loop calls create() once per
|
||||
# tool-loop iteration; references should re-run whenever the task STATE
|
||||
# advances — i.e. on every new user message AND every new tool result —
|
||||
# so each reference judges the latest state. The advisory view
|
||||
# (_reference_messages) now renders tool calls + results as text, so its
|
||||
# signature changes on every new tool response; the cache key is that
|
||||
# signature, so a new tool result is a cache MISS (references re-run)
|
||||
# while a redundant create() call with identical state is a HIT (no
|
||||
# re-run, no re-emit). This gives "fire on every user/tool response"
|
||||
# for free, without re-firing on a pure no-op re-call.
|
||||
self._ref_cache_key: tuple | None = None
|
||||
self._ref_cache_outputs: list[tuple[str, str]] = []
|
||||
|
||||
def _emit(self, event: str, **kwargs: Any) -> None:
|
||||
cb = self.reference_callback
|
||||
if cb is None:
|
||||
return
|
||||
try:
|
||||
cb(event, **kwargs)
|
||||
except Exception as exc: # pragma: no cover - display must never break the turn
|
||||
logger.debug("MoA reference_callback failed for %s: %s", event, exc)
|
||||
|
||||
def create(self, **api_kwargs: Any) -> Any:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.moa_config import resolve_moa_preset
|
||||
|
||||
preset = resolve_moa_preset(load_config().get("moa") or {}, self.preset_name)
|
||||
messages = list(api_kwargs.get("messages") or [])
|
||||
reference_models = preset.get("reference_models") or []
|
||||
aggregator = preset.get("aggregator") or {}
|
||||
# MoA does not cap reference or aggregator output: each model uses its
|
||||
# own maximum. Passing max_tokens=None makes call_llm omit the parameter
|
||||
# (it never caps by default), so a long aggregator synthesis is never
|
||||
# truncated and providers that reject max_tokens don't 400.
|
||||
temperature = float(preset.get("reference_temperature", 0.6) or 0.6)
|
||||
aggregator_temperature = float(preset.get("aggregator_temperature", api_kwargs.get("temperature") or 0.4) or 0.4)
|
||||
|
||||
# When the preset is disabled, skip the reference fan-out and let the
|
||||
# configured aggregator act alone — it is the preset's acting model, so
|
||||
# a disabled MoA preset is simply "use the aggregator directly."
|
||||
if not preset.get("enabled", True):
|
||||
reference_models = []
|
||||
|
||||
reference_outputs: list[tuple[str, str]] = []
|
||||
ref_messages = _reference_messages(messages)
|
||||
|
||||
# Turn-scoped cache: only run + display references when the advisory
|
||||
# view changed (i.e. a new user turn). Within one turn the agent loop
|
||||
# calls create() once per tool iteration with the same advisory view;
|
||||
# reuse the cached outputs and skip both the re-run and the re-emit.
|
||||
_sig = hashlib.sha256(
|
||||
"\u0000".join(
|
||||
f"{m.get('role')}:{m.get('content')}" for m in ref_messages
|
||||
).encode("utf-8", "replace")
|
||||
).hexdigest()
|
||||
_cache_key = (
|
||||
self.preset_name,
|
||||
_sig,
|
||||
tuple(
|
||||
(_slot_label(s), str(s.get("role_prompt") or "").strip())
|
||||
for s in reference_models
|
||||
),
|
||||
)
|
||||
_refs_from_cache = _cache_key == self._ref_cache_key and bool(self._ref_cache_outputs)
|
||||
|
||||
if _refs_from_cache:
|
||||
reference_outputs = list(self._ref_cache_outputs)
|
||||
else:
|
||||
reference_outputs = _run_references_parallel(
|
||||
reference_models,
|
||||
ref_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=None,
|
||||
)
|
||||
self._ref_cache_key = _cache_key
|
||||
self._ref_cache_outputs = list(reference_outputs)
|
||||
|
||||
# Surface each reference model's answer to the display BEFORE the
|
||||
# aggregator acts — once per turn (only on the iteration that
|
||||
# actually ran them). The user sees one labelled block per
|
||||
# reference (rendered like a thinking block) so the MoA process is
|
||||
# visible rather than a silent pause. Best-effort: never blocks the
|
||||
# turn.
|
||||
_ref_count = len(reference_outputs)
|
||||
for _idx, (_label, _text) in enumerate(reference_outputs, start=1):
|
||||
self._emit(
|
||||
"moa.reference",
|
||||
index=_idx,
|
||||
count=_ref_count,
|
||||
label=_label,
|
||||
text=_text,
|
||||
)
|
||||
if _ref_count:
|
||||
self._emit(
|
||||
"moa.aggregating",
|
||||
aggregator=_slot_label(aggregator),
|
||||
ref_count=_ref_count,
|
||||
)
|
||||
|
||||
agg_messages = [dict(m) for m in messages]
|
||||
if reference_outputs:
|
||||
joined = "\n\n".join(
|
||||
f"Reference {idx} — {label}:\n{text}"
|
||||
for idx, (label, text) in enumerate(reference_outputs, start=1)
|
||||
)
|
||||
guidance = (
|
||||
"[Mixture of Agents reference context]\n"
|
||||
f"Preset: {self.preset_name}\n"
|
||||
f"Aggregator/acting model: {_slot_label(aggregator)}\n"
|
||||
f"References: {', '.join(label for label, _ in reference_outputs)}\n\n"
|
||||
"Use the reference responses below as private context. You are the aggregator and acting model: "
|
||||
"answer the user directly or call tools as needed.\n\n"
|
||||
f"{joined}"
|
||||
)
|
||||
for msg in reversed(agg_messages):
|
||||
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
|
||||
msg["content"] = msg["content"] + "\n\n" + guidance
|
||||
break
|
||||
else:
|
||||
agg_messages.append({"role": "user", "content": guidance})
|
||||
|
||||
if aggregator.get("provider") == "moa":
|
||||
raise RuntimeError("MoA aggregator cannot be another MoA preset")
|
||||
agg_kwargs = dict(api_kwargs)
|
||||
agg_kwargs["messages"] = agg_messages
|
||||
# The aggregator is the acting model. Resolve its slot to the provider's
|
||||
# real runtime (base_url/api_key/api_mode) and call it through the same
|
||||
# request-building path any model uses — so per-model wire-format
|
||||
# handling (anthropic_messages, max_completion_tokens, fixed/forbidden
|
||||
# temperature) applies identically to it. MoA imposes no output cap:
|
||||
# max_tokens is passed through from the caller (normally None → omitted
|
||||
# → the model's real maximum). The preset's old hardcoded 4096 default
|
||||
# is gone — it truncated long syntheses.
|
||||
# When the agent's streaming consumer calls us with stream=True, run the
|
||||
# references first (above) and then return the aggregator's RAW token
|
||||
# stream so the acting model's output reaches the user live. The consumer
|
||||
# reassembles chunks + tool_calls, runs stale-stream detection, and falls
|
||||
# back to a non-streaming retry on error. The non-streaming path
|
||||
# (stream=False) is unchanged — no stream/stream_options/timeout are
|
||||
# forwarded, so its behavior is byte-for-byte identical to before.
|
||||
stream = bool(api_kwargs.get("stream"))
|
||||
stream_kwargs: dict[str, Any] = {}
|
||||
if stream:
|
||||
stream_kwargs["stream"] = True
|
||||
stream_kwargs["stream_options"] = (
|
||||
api_kwargs.get("stream_options") or {"include_usage": True}
|
||||
)
|
||||
# Forward the consumer's per-request (stream read) timeout so it
|
||||
# actually governs the aggregator stream, not just call_llm's default.
|
||||
if api_kwargs.get("timeout") is not None:
|
||||
stream_kwargs["timeout"] = api_kwargs["timeout"]
|
||||
return call_llm(
|
||||
task="moa_aggregator",
|
||||
messages=agg_messages,
|
||||
temperature=aggregator_temperature,
|
||||
max_tokens=agg_kwargs.get("max_tokens"),
|
||||
tools=agg_kwargs.get("tools"),
|
||||
extra_body=agg_kwargs.get("extra_body"),
|
||||
**stream_kwargs,
|
||||
**_slot_runtime(aggregator),
|
||||
)
|
||||
|
||||
|
||||
class MoAClient:
|
||||
def __init__(self, preset_name: str, reference_callback: Any = None):
|
||||
self.chat = type("_MoAChat", (), {})()
|
||||
self.chat.completions = MoAChatCompletions(preset_name, reference_callback=reference_callback)
|
||||
+6
-328
@@ -5,7 +5,6 @@ and run_agent.py for pre-flight context checks.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -17,7 +16,7 @@ from urllib.parse import urlparse
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
from utils import atomic_json_write, base_url_host_matches, base_url_hostname
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
@@ -112,57 +111,6 @@ _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||||
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
|
||||
_ENDPOINT_MODEL_CACHE_TTL = 300
|
||||
|
||||
|
||||
def _get_model_metadata_cache_path() -> Path:
|
||||
"""Return path to the OpenRouter model metadata disk cache."""
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "cache" / "openrouter_model_metadata.json"
|
||||
|
||||
|
||||
def _model_metadata_disk_cache_age_seconds() -> Optional[float]:
|
||||
"""Return disk-cache age in seconds, or None if freshness is unknown."""
|
||||
try:
|
||||
cache_path = _get_model_metadata_cache_path()
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
age = time.time() - cache_path.stat().st_mtime
|
||||
if age < 0:
|
||||
return None
|
||||
return age
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _load_model_metadata_disk_cache() -> Dict[str, Dict[str, Any]]:
|
||||
"""Load processed OpenRouter metadata cache from disk."""
|
||||
try:
|
||||
cache_path = _get_model_metadata_cache_path()
|
||||
with cache_path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {
|
||||
str(key): value
|
||||
for key, value in data.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load OpenRouter model metadata disk cache: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _save_model_metadata_disk_cache(data: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Save processed OpenRouter metadata cache to disk atomically."""
|
||||
try:
|
||||
atomic_json_write(
|
||||
_get_model_metadata_cache_path(),
|
||||
data,
|
||||
indent=0,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to save OpenRouter model metadata disk cache: %s", e)
|
||||
|
||||
# Descending tiers for context length probing when the model is unknown.
|
||||
# We start at 256K (covers GPT-5.x, many current large-context models) and
|
||||
# step down on context-length errors until one works. Tier[0] is also the
|
||||
@@ -193,8 +141,6 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
|
||||
# substring of "anthropic/claude-sonnet-4.6").
|
||||
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
|
||||
"claude-fable-5": 1000000,
|
||||
"claude-fable": 1000000,
|
||||
"claude-opus-4-8": 1000000,
|
||||
"claude-opus-4.8": 1000000,
|
||||
"claude-opus-4-7": 1000000,
|
||||
@@ -261,13 +207,7 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# https://platform.minimax.io/docs/api-reference/text-chat-openai
|
||||
"minimax-m3": 1000000,
|
||||
"minimax": 204800,
|
||||
# GLM — GLM-5.2 ships with a 1M context window (verified empirically:
|
||||
# needle-in-a-haystack retrieval at 789K prompt tokens succeeded with
|
||||
# zero errors on api.z.ai/api/coding/paas/v4). Older GLM models
|
||||
# (5, 5.1, 5-turbo) are ~202K. Longest-key-first substring matching
|
||||
# ensures "glm-5.2" resolves to 1M while older variants still hit the
|
||||
# generic 202K fallback.
|
||||
"glm-5.2": 1_048_576,
|
||||
# GLM
|
||||
"glm": 202752,
|
||||
# xAI Grok — xAI /v1/models does not return context_length metadata,
|
||||
# so these hardcoded fallbacks prevent Hermes from probing-down to
|
||||
@@ -275,11 +215,6 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# via a custom provider. Values sourced from models.dev (2026-04).
|
||||
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
|
||||
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
|
||||
# OAuth-only slug; absent from GET /v1/models. xAI publishes a 200k
|
||||
# usable context window for Composer 2.5 on Grok Build (SuperGrok /
|
||||
# Premium+); /v1/responses additionally enforces a ~262144 input+output
|
||||
# budget, but the usable context (what we track here) is 200k.
|
||||
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
|
||||
"grok-build": 256000, # grok-build-0.1
|
||||
"grok-code-fast": 256000, # grok-code-fast-1
|
||||
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
|
||||
@@ -429,10 +364,6 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
||||
"inference-api.nousresearch.com": "nous",
|
||||
"api.deepseek.com": "deepseek",
|
||||
"api.githubcopilot.com": "copilot",
|
||||
# Enterprise Copilot endpoints look like api.enterprise.githubcopilot.com,
|
||||
# api.business.githubcopilot.com, etc. Match the suffix so context-window
|
||||
# resolution works for enterprise accounts too.
|
||||
".githubcopilot.com": "copilot",
|
||||
"models.github.ai": "copilot",
|
||||
# GitHub Models free tier (Azure-hosted prototyping endpoint) — same
|
||||
# canonical provider as the Copilot API. Hard per-request token cap
|
||||
@@ -482,16 +413,6 @@ def _infer_provider_from_url(base_url: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _lmstudio_server_root(base_url: str) -> str:
|
||||
"""Return the LM Studio server root for native ``/api/v1`` endpoints."""
|
||||
root = _normalize_base_url(base_url).rstrip("/")
|
||||
for suffix in ("/api/v1", "/api", "/v1"):
|
||||
if root.endswith(suffix):
|
||||
root = root[: -len(suffix)].rstrip("/")
|
||||
break
|
||||
return root
|
||||
|
||||
|
||||
def _is_known_provider_base_url(base_url: str) -> bool:
|
||||
return _infer_provider_from_url(base_url) is not None
|
||||
|
||||
@@ -563,7 +484,6 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
|
||||
server_url = normalized
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
lmstudio_url = _lmstudio_server_root(base_url)
|
||||
|
||||
headers = _auth_headers(api_key)
|
||||
|
||||
@@ -571,7 +491,7 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
|
||||
with httpx.Client(timeout=2.0, headers=headers) as client:
|
||||
# LM Studio exposes /api/v1/models — check first (most specific)
|
||||
try:
|
||||
r = client.get(f"{lmstudio_url}/api/v1/models")
|
||||
r = client.get(f"{server_url}/api/v1/models")
|
||||
if r.status_code == 200:
|
||||
return "lm-studio"
|
||||
except Exception:
|
||||
@@ -705,15 +625,6 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
|
||||
if not force_refresh and _model_metadata_cache and (time.time() - _model_metadata_cache_time) < _MODEL_CACHE_TTL:
|
||||
return _model_metadata_cache
|
||||
|
||||
if not force_refresh:
|
||||
disk_age = _model_metadata_disk_cache_age_seconds()
|
||||
if disk_age is not None and disk_age < _MODEL_CACHE_TTL:
|
||||
disk_cache = _load_model_metadata_disk_cache()
|
||||
if disk_cache:
|
||||
_model_metadata_cache = disk_cache
|
||||
_model_metadata_cache_time = time.time() - disk_age
|
||||
return _model_metadata_cache
|
||||
|
||||
try:
|
||||
response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify())
|
||||
response.raise_for_status()
|
||||
@@ -735,24 +646,12 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
|
||||
|
||||
_model_metadata_cache = cache
|
||||
_model_metadata_cache_time = time.time()
|
||||
_save_model_metadata_disk_cache(cache)
|
||||
logger.debug("Fetched metadata for %s models from OpenRouter", len(cache))
|
||||
return cache
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch model metadata from OpenRouter: {e}")
|
||||
if _model_metadata_cache:
|
||||
return _model_metadata_cache
|
||||
disk_cache = _load_model_metadata_disk_cache()
|
||||
if disk_cache:
|
||||
_model_metadata_cache = disk_cache
|
||||
disk_age = _model_metadata_disk_cache_age_seconds()
|
||||
if disk_age is not None:
|
||||
_model_metadata_cache_time = time.time() - min(disk_age, _MODEL_CACHE_TTL)
|
||||
else:
|
||||
_model_metadata_cache_time = time.time() - _MODEL_CACHE_TTL + 1
|
||||
return _model_metadata_cache
|
||||
return {}
|
||||
return _model_metadata_cache or {}
|
||||
|
||||
|
||||
def fetch_endpoint_model_metadata(
|
||||
@@ -789,7 +688,7 @@ def fetch_endpoint_model_metadata(
|
||||
if is_local_endpoint(normalized):
|
||||
try:
|
||||
if detect_local_server_type(normalized, api_key=api_key) == "lm-studio":
|
||||
server_url = _lmstudio_server_root(normalized)
|
||||
server_url = normalized[:-3].rstrip("/") if normalized.endswith("/v1") else normalized
|
||||
response = requests.get(
|
||||
server_url.rstrip("/") + "/api/v1/models",
|
||||
headers=headers,
|
||||
@@ -1069,39 +968,10 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
||||
# OpenRouter/Nous phrasing of the same condition.
|
||||
"in the output" in error_lower
|
||||
and "maximum context length" in error_lower
|
||||
) or (
|
||||
# LM Studio / llama.cpp / some OpenAI-compatible servers:
|
||||
# "This model's maximum context length is 65536 tokens. However, you
|
||||
# requested 65536 output tokens and your prompt contains 77409
|
||||
# characters ..."
|
||||
# The "requested N output tokens" phrasing means the OUTPUT cap is the
|
||||
# problem (the input itself fits) — reduce max_tokens, don't compress.
|
||||
"maximum context length" in error_lower
|
||||
and "requested" in error_lower
|
||||
and "output tokens" in error_lower
|
||||
) or (
|
||||
# DashScope / Alibaba Cloud (Qwen) phrasing. The provider rejects an
|
||||
# over-cap output request with a bounded range whose upper bound IS the
|
||||
# real max-output cap, e.g.
|
||||
# "Range of max_tokens should be [1, 65536]"
|
||||
# The input itself fits — this is purely an output-cap error, so reduce
|
||||
# max_tokens and retry; do NOT compress.
|
||||
"range of max_tokens should be" in error_lower
|
||||
)
|
||||
if not is_output_cap_error:
|
||||
return None
|
||||
|
||||
# DashScope / Alibaba range form: "Range of max_tokens should be [1, 65536]".
|
||||
# The upper bound is the available output cap.
|
||||
_m_range = re.search(
|
||||
r'range of max_tokens should be\s*\[\s*\d+\s*,\s*(\d+)\s*\]',
|
||||
error_lower,
|
||||
)
|
||||
if _m_range:
|
||||
_cap = int(_m_range.group(1))
|
||||
if _cap >= 1:
|
||||
return _cap
|
||||
|
||||
# Extract the available_tokens figure.
|
||||
# Anthropic format: "… = available_tokens: 10000"
|
||||
patterns = [
|
||||
@@ -1129,89 +999,9 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
|
||||
if _available >= 1:
|
||||
return _available
|
||||
|
||||
# LM Studio / llama.cpp style: context window is reported in tokens but the
|
||||
# prompt size is reported in CHARACTERS, e.g.
|
||||
# "maximum context length is 65536 tokens ... your prompt contains 77409
|
||||
# characters ...".
|
||||
# Estimate the input tokens conservatively (~3 chars/token, which
|
||||
# over-reserves the input so the retried output cap stays safely inside the
|
||||
# window) and leave the remainder of the window for output.
|
||||
_m_ctx_tok = re.search(r'maximum context length is (\d+)\s*token', error_lower)
|
||||
_m_chars = re.search(r'prompt contains (\d+)\s*character', error_lower)
|
||||
if _m_ctx_tok and _m_chars:
|
||||
_ctx = int(_m_ctx_tok.group(1))
|
||||
_est_input = (int(_m_chars.group(1)) + 2) // 3
|
||||
_available = _ctx - _est_input
|
||||
if _available >= 1:
|
||||
return _available
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_output_cap_error(error_msg: str) -> bool:
|
||||
"""Return True if a 400 is about the OUTPUT cap (max_tokens) being too large.
|
||||
|
||||
This is the broader sibling of :func:`parse_available_output_tokens_from_error`:
|
||||
that function only returns a number when it can extract the available output
|
||||
budget from a *known* provider phrasing. This one answers the cheaper
|
||||
yes/no question — "is this an output-cap error at all?" — across providers
|
||||
whose exact wording we may not yet parse a number from.
|
||||
|
||||
Why this matters: an output-cap 400 is deterministic (every retry with the
|
||||
same ``max_tokens`` gets the identical rejection). If such an error is
|
||||
misclassified as a context-overflow it gets routed into the compression
|
||||
loop, the compressor re-issues the call with the same oversized
|
||||
``max_tokens``, the provider rejects it identically, and the session
|
||||
death-loops until "cannot compress further" (issue #55546, DashScope/Qwen:
|
||||
"Range of max_tokens should be [1, 65536]"). Compression cannot help an
|
||||
output-cap error — the input already fits.
|
||||
|
||||
The signal: the error talks about ``max_tokens`` (or its aliases) as a
|
||||
cap/range/limit, and does NOT talk about the INPUT/prompt/context window
|
||||
being too long. When both are present we defer to the context-overflow
|
||||
path (a real input overflow can also mention max_tokens).
|
||||
"""
|
||||
error_lower = error_msg.lower()
|
||||
|
||||
mentions_output_param = (
|
||||
"max_tokens" in error_lower
|
||||
or "max_output_tokens" in error_lower
|
||||
or "max_completion_tokens" in error_lower
|
||||
)
|
||||
if not mentions_output_param:
|
||||
return False
|
||||
|
||||
# Phrasing that signals the OUTPUT cap specifically is the problem.
|
||||
output_cap_signal = (
|
||||
"range of max_tokens should be" in error_lower # DashScope / Alibaba
|
||||
or "available_tokens" in error_lower # Anthropic
|
||||
or "available tokens" in error_lower
|
||||
or ("in the output" in error_lower # OpenRouter / Nous
|
||||
and "maximum context length" in error_lower)
|
||||
or ("requested" in error_lower # LM Studio / llama.cpp
|
||||
and "output tokens" in error_lower)
|
||||
or "should be" in error_lower # generic "max_tokens should be <= N"
|
||||
or "less than or equal" in error_lower
|
||||
or "must be" in error_lower
|
||||
)
|
||||
if not output_cap_signal:
|
||||
return False
|
||||
|
||||
# If the error ALSO clearly describes an oversized INPUT, it is a genuine
|
||||
# context overflow that happens to mention max_tokens — let the
|
||||
# context-overflow path handle it (it can compress the input).
|
||||
input_overflow_signal = (
|
||||
"prompt is too long" in error_lower
|
||||
or "prompt too long" in error_lower
|
||||
or "input is too long" in error_lower
|
||||
or "input token" in error_lower
|
||||
or "prompt length" in error_lower
|
||||
or "prompt contains" in error_lower
|
||||
or "reduce the length" in error_lower
|
||||
)
|
||||
return not input_overflow_signal
|
||||
|
||||
|
||||
def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:
|
||||
"""Return True if *candidate_id* (from server) matches *lookup_model* (configured).
|
||||
|
||||
@@ -1286,56 +1076,6 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option
|
||||
return None
|
||||
|
||||
|
||||
def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") -> Optional[bool]:
|
||||
"""Return True/False when Ollama ``/api/show`` reports vision support.
|
||||
|
||||
Uses the ``capabilities`` field on Ollama 0.6.0+ and falls back to
|
||||
``model_info.*.vision.block_count`` on older servers. Returns None when
|
||||
the server is unreachable, not Ollama, or the model is unknown.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
bare_model = _strip_provider_prefix(model)
|
||||
if not bare_model or not base_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
if detect_local_server_type(base_url, api_key=api_key) != "ollama":
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
server_url = base_url.rstrip("/")
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
|
||||
headers = _auth_headers(api_key)
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=3.0, headers=headers) as client:
|
||||
resp = client.post(f"{server_url}/api/show", json={"name": bare_model})
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
caps = data.get("capabilities")
|
||||
if isinstance(caps, list):
|
||||
if any(str(cap).lower() == "vision" for cap in caps):
|
||||
return True
|
||||
if caps:
|
||||
return False
|
||||
|
||||
model_info = data.get("model_info")
|
||||
if isinstance(model_info, dict):
|
||||
for key in model_info:
|
||||
if "vision.block_count" in str(key).lower():
|
||||
return True
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Optional[int]:
|
||||
"""Query an Ollama server's native ``/api/show`` for context length.
|
||||
|
||||
@@ -1445,7 +1185,6 @@ def _query_local_context_length(model: str, base_url: str, api_key: str = "") ->
|
||||
server_url = base_url.rstrip("/")
|
||||
if server_url.endswith("/v1"):
|
||||
server_url = server_url[:-3]
|
||||
lmstudio_url = _lmstudio_server_root(base_url)
|
||||
|
||||
headers = _auth_headers(api_key)
|
||||
|
||||
@@ -1489,7 +1228,7 @@ def _query_local_context_length(model: str, base_url: str, api_key: str = "") ->
|
||||
# Use _model_id_matches for fuzzy matching: LM Studio stores models as
|
||||
# "publisher/slug" but users configure only "slug" after "local:" prefix.
|
||||
if server_type == "lm-studio":
|
||||
resp = client.get(f"{lmstudio_url}/api/v1/models")
|
||||
resp = client.get(f"{server_url}/api/v1/models")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
for m in data.get("models", []):
|
||||
@@ -1795,34 +1534,6 @@ def get_model_context_length(
|
||||
if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:
|
||||
return config_context_length
|
||||
|
||||
# 0a. MoA virtual provider — ``model`` is a preset name, not a real model,
|
||||
# and ``base_url`` is the local virtual endpoint, so every probe below would
|
||||
# miss and fall through to the 256K default. The aggregator is the acting
|
||||
# model, so resolve the context window from the aggregator slot's real
|
||||
# provider+model instead. References are advisory-only and never bound the
|
||||
# acting context, so they're ignored here.
|
||||
if (provider or "").strip().lower() == "moa":
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.moa_config import resolve_moa_preset
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
preset = resolve_moa_preset(load_config().get("moa") or {}, model)
|
||||
agg = preset.get("aggregator") or {}
|
||||
agg_provider = str(agg.get("provider") or "").strip()
|
||||
agg_model = str(agg.get("model") or "").strip()
|
||||
if agg_model and agg_provider and agg_provider.lower() != "moa":
|
||||
rt = resolve_runtime_provider(requested=agg_provider, target_model=agg_model)
|
||||
return get_model_context_length(
|
||||
agg_model,
|
||||
base_url=rt.get("base_url", "") or "",
|
||||
api_key=rt.get("api_key", "") or "",
|
||||
provider=agg_provider,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("MoA aggregator context-length resolution failed", exc_info=True)
|
||||
# Fall through to the generic default if aggregator resolution failed.
|
||||
|
||||
# 0b. custom_providers per-model override — check before any probe.
|
||||
# This closes the gap where /model switch and display paths used to fall
|
||||
# back to 128K despite the user having a per-model context_length set.
|
||||
@@ -2073,43 +1784,10 @@ def get_model_context_length(
|
||||
if ctx is not None:
|
||||
save_context_length(model, base_url, ctx)
|
||||
return ctx
|
||||
# 5f. OpenRouter live /models metadata — authoritative for OpenRouter-routed
|
||||
# models. OpenRouter's catalog carries per-model context_length (e.g.
|
||||
# anthropic/claude-fable-5 -> 1M) and refreshes as new slugs ship, so it
|
||||
# must win over both models.dev (step 5g) and the hardcoded family catch-all
|
||||
# (step 8). Before this branch, an OpenRouter selection set
|
||||
# effective_provider="openrouter", which (a) made the models.dev lookup miss
|
||||
# brand-new slugs and (b) skipped the step-6 OR fallback (gated on `not
|
||||
# effective_provider`), so a fresh slug like claude-fable-5 fell through to
|
||||
# the generic "claude": 200K entry and under-reported a 1M window. Mirrors
|
||||
# the dedicated Nous/Copilot/GMI branches above.
|
||||
if effective_provider == "openrouter":
|
||||
metadata = fetch_model_metadata()
|
||||
entry = metadata.get(model)
|
||||
if entry:
|
||||
or_ctx = entry.get("context_length")
|
||||
# Guard against the known OpenRouter Kimi-family 32k underreport
|
||||
# (same class the hardcoded overrides exist to mitigate).
|
||||
if isinstance(or_ctx, int) and or_ctx > 0 and not (
|
||||
or_ctx == 32768 and _model_name_suggests_kimi(model)
|
||||
):
|
||||
return or_ctx
|
||||
|
||||
if effective_provider:
|
||||
from agent.models_dev import lookup_models_dev_context
|
||||
ctx = lookup_models_dev_context(effective_provider, model)
|
||||
if ctx:
|
||||
# MiniMax M3: models.dev reports 512K but actual context is 1M.
|
||||
# Prefer hardcoded catalog over stale probe value.
|
||||
if _model_name_suggests_minimax_m3(model):
|
||||
catalog = DEFAULT_CONTEXT_LENGTHS.get("minimax-m3")
|
||||
if catalog and ctx < catalog:
|
||||
logger.info(
|
||||
"Rejecting models.dev context=%s for %r "
|
||||
"(MiniMax-M3 underreport); using hardcoded default %s",
|
||||
ctx, model, f"{catalog:,}",
|
||||
)
|
||||
ctx = catalog
|
||||
return ctx
|
||||
|
||||
# 6. OpenRouter live API metadata — provider-unaware fallback.
|
||||
|
||||
@@ -135,14 +135,7 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
|
||||
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Infer a reasonable ``type`` if this schema node has none."""
|
||||
node_type = node.get("type")
|
||||
if isinstance(node_type, list):
|
||||
concrete = next(
|
||||
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
|
||||
"string",
|
||||
)
|
||||
return {**node, "type": concrete}
|
||||
if "type" in node and node_type not in {None, ""}:
|
||||
if "type" in node and node["type"] not in {None, ""}:
|
||||
return node
|
||||
|
||||
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Shared one-off LLM requests for non-conversational helpers.
|
||||
|
||||
A "one-shot" is a single, stateless model call that runs *outside* any
|
||||
conversation: it never touches a session's history, never breaks prompt
|
||||
caching, and returns plain text. UI surfaces use it for small generative
|
||||
chores — a commit message from a diff, a rename suggestion, a summary —
|
||||
where spinning up an agent turn would be wrong (it would pollute the thread)
|
||||
and hand-rolling an LLM call at every call site would be worse.
|
||||
|
||||
Two ways to call it:
|
||||
|
||||
* ``run_oneshot(instructions=..., user_input=...)`` — caller supplies the
|
||||
full prompt.
|
||||
* ``run_oneshot(template="commit_message", variables={...})`` — caller
|
||||
names a registered template and passes its variables; the template owns
|
||||
the prompt engineering so it stays consistent across CLI/TUI/desktop.
|
||||
|
||||
Model selection rides the same auxiliary plumbing as title generation
|
||||
(:func:`agent.auxiliary_client.call_llm`): pass ``main_runtime`` to inherit
|
||||
the live session's provider/model, otherwise the configured ``task`` (default
|
||||
``title_generation``) resolves a cheap/fast backend.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
from agent.auxiliary_client import call_llm, extract_content_or_reasoning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A template turns a variables dict into a (instructions, user_input) pair.
|
||||
# Templates are plain callables (not str.format) so diff/code payloads with
|
||||
# literal "{" / "}" pass through untouched.
|
||||
PromptTemplate = Callable[[Dict[str, Any]], Tuple[str, str]]
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
text = text or ""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + "\n…(truncated)"
|
||||
|
||||
|
||||
_COMMIT_INSTRUCTIONS = (
|
||||
"You write git commit messages. Given a diff of staged changes, write ONE "
|
||||
"concise Conventional Commits message describing what the change does and why.\n"
|
||||
"Rules:\n"
|
||||
"- Subject line: type(scope): summary — imperative mood, lower-case, no "
|
||||
"trailing period, ≤ 72 characters. Types: feat, fix, refactor, perf, docs, "
|
||||
"test, build, chore, style, ci.\n"
|
||||
"- Omit the scope if it isn't obvious.\n"
|
||||
"- Add a short body (wrapped at ~72 cols) ONLY when the change needs "
|
||||
"explanation; skip it for small/obvious changes.\n"
|
||||
"- Describe the actual change, never restate the diff line-by-line.\n"
|
||||
"- Return ONLY the commit message text — no quotes, no markdown fences, no "
|
||||
"preamble."
|
||||
)
|
||||
|
||||
|
||||
def _commit_message_template(variables: Dict[str, Any]) -> Tuple[str, str]:
|
||||
diff = _truncate(str(variables.get("diff") or ""), 12000)
|
||||
recent = _truncate(str(variables.get("recent_commits") or ""), 1500)
|
||||
|
||||
parts = []
|
||||
if recent.strip():
|
||||
parts.append(
|
||||
"Recent commit subjects from this repo (match their style/conventions):\n"
|
||||
f"{recent}"
|
||||
)
|
||||
parts.append("Diff to describe:\n" + (diff or "(no textual diff available)"))
|
||||
|
||||
# "Regenerate" must yield something new even on models that decode greedily
|
||||
# / pin temperature server-side. A trailing nonce isn't enough, so we hand
|
||||
# back the previous message and require a genuinely different one.
|
||||
avoid = _truncate(str(variables.get("avoid") or "").strip(), 1000)
|
||||
if avoid:
|
||||
parts.append(
|
||||
"You already proposed the message below and the user wants a "
|
||||
"different one. Write a NEW message with different wording (and, if "
|
||||
"reasonable, a different emphasis or scope framing) — do not repeat "
|
||||
f"it:\n{avoid}"
|
||||
)
|
||||
|
||||
return _COMMIT_INSTRUCTIONS, "\n\n".join(parts)
|
||||
|
||||
|
||||
# Registry of named templates. Add an entry here to give a new surface a
|
||||
# consistent, reusable prompt without teaching every caller the prompt text.
|
||||
PROMPT_TEMPLATES: Dict[str, PromptTemplate] = {
|
||||
"commit_message": _commit_message_template,
|
||||
}
|
||||
|
||||
|
||||
def render_template(name: str, variables: Optional[Dict[str, Any]] = None) -> Tuple[str, str]:
|
||||
"""Resolve a registered template into (instructions, user_input).
|
||||
|
||||
Raises KeyError if the template name is unknown so callers fail loudly
|
||||
instead of silently sending an empty prompt.
|
||||
"""
|
||||
template = PROMPT_TEMPLATES.get(name)
|
||||
if template is None:
|
||||
raise KeyError(f"unknown one-shot template: {name}")
|
||||
return template(variables or {})
|
||||
|
||||
|
||||
def run_oneshot(
|
||||
*,
|
||||
instructions: str = "",
|
||||
user_input: str = "",
|
||||
template: Optional[str] = None,
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
task: str = "title_generation",
|
||||
max_tokens: int = 1024,
|
||||
temperature: Optional[float] = 0.3,
|
||||
timeout: float = 60.0,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Run a single stateless LLM request and return its text.
|
||||
|
||||
Provide either a registered ``template`` (+ ``variables``) or an explicit
|
||||
``instructions`` / ``user_input`` pair. Returns the model's text answer,
|
||||
stripped of surrounding whitespace and any wrapping code fence.
|
||||
|
||||
Raises RuntimeError when no LLM provider is configured (surfaced from
|
||||
:func:`call_llm`) and KeyError for an unknown template name.
|
||||
"""
|
||||
if template:
|
||||
instructions, user_input = render_template(template, variables)
|
||||
|
||||
if not (instructions or "").strip() and not (user_input or "").strip():
|
||||
raise ValueError("run_oneshot requires a template or instructions/user_input")
|
||||
|
||||
messages = []
|
||||
if (instructions or "").strip():
|
||||
messages.append({"role": "system", "content": instructions})
|
||||
messages.append({"role": "user", "content": user_input or ""})
|
||||
|
||||
response = call_llm(
|
||||
task=task,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
timeout=timeout,
|
||||
main_runtime=main_runtime,
|
||||
)
|
||||
|
||||
text = (extract_content_or_reasoning(response) or "").strip()
|
||||
return _strip_code_fence(text)
|
||||
|
||||
|
||||
def _strip_code_fence(text: str) -> str:
|
||||
"""Drop a single wrapping ``` fence the model may have added."""
|
||||
if not text.startswith("```"):
|
||||
return text
|
||||
lines = text.splitlines()
|
||||
if len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```":
|
||||
return "\n".join(lines[1:-1]).strip()
|
||||
return text
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Petdex pet engine — shared core for the CLI, TUI, and desktop surfaces.
|
||||
|
||||
Petdex (https://github.com/crafter-station/petdex) is a public gallery of
|
||||
animated sprite "pets" for coding agents. Each pet is a ``pet.json`` plus a
|
||||
``spritesheet.{webp,png}`` of 192×208 px cells. Current Codex/petdex sheets use
|
||||
an 8-column × 9-row atlas; older Hermes/petdex sheets used an 8-row atlas.
|
||||
Hermes infers the row taxonomy from the sheet and maps agent activity onto
|
||||
idle/run/review/failed/wave/jump.
|
||||
|
||||
This package is the **single source of truth** for the feature so the base
|
||||
CLI (Python) and TUI (Ink, via ``tui_gateway``) never duplicate the hard
|
||||
parts:
|
||||
|
||||
- :mod:`agent.pet.constants` — frame geometry + the :class:`PetState` enum.
|
||||
- :mod:`agent.pet.state` — map agent activity → a :class:`PetState`.
|
||||
- :mod:`agent.pet.manifest` — fetch the public petdex manifest.
|
||||
- :mod:`agent.pet.store` — install / list / resolve pets on disk
|
||||
(profile-aware via ``get_hermes_home()``).
|
||||
- :mod:`agent.pet.render` — decode a spritesheet and encode frames for a
|
||||
terminal (kitty / iTerm2 / sixel graphics
|
||||
protocols, with a Unicode half-block
|
||||
fallback).
|
||||
|
||||
Rendering in the Electron desktop is necessarily TypeScript (canvas), but it
|
||||
reuses the same on-disk store and the same state semantics.
|
||||
|
||||
The whole feature is a *display* concern: it adds no model tool, mutates no
|
||||
system prompt or toolset, and therefore has zero effect on prompt caching.
|
||||
"""
|
||||
|
||||
from agent.pet.constants import (
|
||||
DEFAULT_SCALE,
|
||||
FRAME_H,
|
||||
FRAME_W,
|
||||
FRAMES_PER_STATE,
|
||||
LOOP_MS,
|
||||
STATE_ROWS,
|
||||
PetState,
|
||||
)
|
||||
from agent.pet.state import derive_pet_state
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SCALE",
|
||||
"FRAME_H",
|
||||
"FRAME_W",
|
||||
"FRAMES_PER_STATE",
|
||||
"LOOP_MS",
|
||||
"STATE_ROWS",
|
||||
"PetState",
|
||||
"derive_pet_state",
|
||||
]
|
||||
@@ -1,167 +0,0 @@
|
||||
"""Pet sprite geometry + animation-state taxonomy.
|
||||
|
||||
These values are the common petdex/Codex pet geometry. The real ``pet.json``
|
||||
usually only carries ``id``/``displayName``/``description``/``spritesheetPath``;
|
||||
row taxonomy is inferred from the atlas shape so Hermes can render both legacy
|
||||
8-row sheets and current 9-row Codex sheets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# Frame geometry (pixels). Current Codex/petdex spritesheets are 8 columns x 9
|
||||
# rows (1536x1872), while older Hermes/petdex sheets used 9 columns x 8 rows
|
||||
# (1728x1664). Renderers derive both row taxonomy and real column count from the
|
||||
# concrete sheet, so either shape works.
|
||||
FRAME_W = 192
|
||||
FRAME_H = 208
|
||||
|
||||
# Frames consumed per animation state (the petdex web app uses CSS
|
||||
# ``steps(6)``). A sheet may physically contain more columns; we only step
|
||||
# through the first ``FRAMES_PER_STATE``.
|
||||
FRAMES_PER_STATE = 6
|
||||
|
||||
# Full-loop duration for one state, milliseconds (petdex default).
|
||||
LOOP_MS = 1100
|
||||
|
||||
# Default on-screen scale relative to native frame size. ``display.pet.scale``
|
||||
# is the single master scalar: the desktop canvas multiplies its native pixels
|
||||
# by it and every terminal surface derives its half-block/kitty column width
|
||||
# from it (see :func:`cols_for_scale`), so one number shrinks all three
|
||||
# interfaces together. (petdex's own clients render at 0.7; we default smaller
|
||||
# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block
|
||||
# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its
|
||||
# legibility floor instead.)
|
||||
DEFAULT_SCALE = 0.33
|
||||
|
||||
# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the
|
||||
# pet clickable/visible; ceiling stops a fat-fingered value from filling the
|
||||
# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``.
|
||||
MIN_SCALE = 0.1
|
||||
MAX_SCALE = 3.0
|
||||
|
||||
|
||||
def clamp_scale(scale: float) -> float:
|
||||
"""Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point)."""
|
||||
return max(MIN_SCALE, min(MAX_SCALE, scale))
|
||||
|
||||
# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px
|
||||
# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty
|
||||
# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees.
|
||||
BASE_UNICODE_COLS = FRAME_W // 8
|
||||
|
||||
# Legibility floor for the half-block fallback. A half-block cell samples the
|
||||
# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208
|
||||
# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw
|
||||
# true pixels and have no such floor — that's why the same ``scale: 0.33`` is
|
||||
# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down
|
||||
# TO this floor (and grows it above), instead of past it into noise.
|
||||
UNICODE_MIN_COLS = 16
|
||||
|
||||
|
||||
def cols_for_scale(scale: float) -> int:
|
||||
"""Half-block width implied by *scale*, clamped to the legibility floor.
|
||||
|
||||
Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two
|
||||
renderers converge at larger sizes; below it the floor keeps the sprite
|
||||
readable rather than letting it devolve into a blob.
|
||||
"""
|
||||
return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE)))
|
||||
|
||||
|
||||
def resolve_cols(scale: float, unicode_cols: int = 0) -> int:
|
||||
"""Resolve terminal width: explicit *unicode_cols* override, else from *scale*."""
|
||||
return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale)
|
||||
|
||||
|
||||
class PetState(str, Enum):
|
||||
"""Animation state a pet can be shown in.
|
||||
|
||||
These are Hermes' activity state names. They are not always identical to the
|
||||
source atlas row names: Codex-format pets use rows like ``jumping`` /
|
||||
``running`` while the UI keeps the shorter ``jump`` / ``run`` names.
|
||||
"""
|
||||
|
||||
IDLE = "idle"
|
||||
WAVE = "wave"
|
||||
RUN = "run"
|
||||
FAILED = "failed"
|
||||
REVIEW = "review"
|
||||
JUMP = "jump"
|
||||
WAITING = "waiting"
|
||||
|
||||
|
||||
# Legacy Hermes/petdex row order (top -> bottom) used by the older 8-row,
|
||||
# 9-column atlas shape.
|
||||
LEGACY_STATE_ROWS: list[str] = [
|
||||
PetState.IDLE.value,
|
||||
PetState.WAVE.value,
|
||||
PetState.RUN.value,
|
||||
PetState.FAILED.value,
|
||||
PetState.REVIEW.value,
|
||||
PetState.JUMP.value,
|
||||
"extra1",
|
||||
"extra2",
|
||||
]
|
||||
|
||||
# Current Petdex row order (top -> bottom) used by 1536x1872 atlases:
|
||||
# 8 columns x 9 rows of 192x208 cells.
|
||||
CODEX_STATE_ROWS: list[str] = [
|
||||
PetState.IDLE.value,
|
||||
"running-right",
|
||||
"running-left",
|
||||
"waving",
|
||||
"jumping",
|
||||
PetState.FAILED.value,
|
||||
PetState.WAITING.value,
|
||||
"running",
|
||||
PetState.REVIEW.value,
|
||||
]
|
||||
|
||||
# Default/fallback for callers without a sheet. Prefer the current 9-row Codex
|
||||
# format because generated pets and the public Codex pet contract use it.
|
||||
STATE_ROWS: list[str] = CODEX_STATE_ROWS
|
||||
|
||||
# Canonical Hermes activity names -> accepted row-name aliases in descending
|
||||
# preference. This keeps our internal state names stable (`wave`/`jump`/`run`)
|
||||
# while matching Petdex's current `waving`/`jumping`/`running` taxonomy.
|
||||
STATE_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
PetState.IDLE.value: (PetState.IDLE.value,),
|
||||
PetState.WAVE.value: (PetState.WAVE.value, "waving"),
|
||||
PetState.JUMP.value: (PetState.JUMP.value, "jumping"),
|
||||
PetState.RUN.value: (PetState.RUN.value, "running"),
|
||||
PetState.FAILED.value: (PetState.FAILED.value,),
|
||||
PetState.REVIEW.value: (PetState.REVIEW.value,),
|
||||
PetState.WAITING.value: (PetState.WAITING.value,),
|
||||
}
|
||||
|
||||
|
||||
def state_aliases_for(state: "PetState | str") -> tuple[str, ...]:
|
||||
"""Return accepted row-name aliases for *state* (always non-empty)."""
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
aliases = STATE_ALIASES.get(value)
|
||||
return aliases if aliases else (value,)
|
||||
|
||||
|
||||
def state_rows_for_grid(row_count: int | None) -> list[str]:
|
||||
"""Return the row taxonomy for a spritesheet with *row_count* rows."""
|
||||
try:
|
||||
rows = int(row_count or 0)
|
||||
except (TypeError, ValueError):
|
||||
rows = 0
|
||||
|
||||
if rows >= len(CODEX_STATE_ROWS):
|
||||
return CODEX_STATE_ROWS
|
||||
return LEGACY_STATE_ROWS
|
||||
|
||||
|
||||
def state_row_index(state: "PetState | str", row_count: int | None = None) -> int:
|
||||
"""Return the spritesheet row index for *state* (clamped, never raises)."""
|
||||
rows = state_rows_for_grid(row_count)
|
||||
for name in state_aliases_for(state):
|
||||
try:
|
||||
return rows.index(name)
|
||||
except ValueError:
|
||||
continue
|
||||
return 0 # fall back to the idle row
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Pet generation — base-draft → hatch pipeline.
|
||||
|
||||
Public surface used by the gateway RPCs, the CLI ``hermes pets generate``
|
||||
command, and tests:
|
||||
|
||||
- :func:`generate_base_drafts` / :func:`hatch_pet` — the two-step flow.
|
||||
- :class:`HatchResult`, :class:`GenerationError`.
|
||||
- :mod:`atlas` — deterministic frame extraction + atlas composition/validation.
|
||||
|
||||
Image generation is delegated to the active reference-capable
|
||||
:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea);
|
||||
atlas assembly is fully deterministic so it's testable without any API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
from agent.pet.generate.orchestrate import (
|
||||
HatchResult,
|
||||
generate_base_drafts,
|
||||
hatch_pet,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GenerationError",
|
||||
"HatchResult",
|
||||
"generate_base_drafts",
|
||||
"hatch_pet",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,251 +0,0 @@
|
||||
"""Thin image-generation layer for pet sprites.
|
||||
|
||||
Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the
|
||||
two things sprite generation needs that the agent-facing ``image_generate`` tool
|
||||
doesn't expose: **N variants** (loop) and **reference-image grounding** (so each
|
||||
animation row stays the same character as the chosen base).
|
||||
|
||||
Reference grounding only works on providers that support it — currently OpenAI
|
||||
``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of
|
||||
those and surface a clear, actionable error otherwise rather than silently
|
||||
producing an ungrounded, drifting pet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Providers that can ground generation on a reference image, in preference order
|
||||
# (Nous Portal → OpenAI → OpenRouter → …). OpenRouter/Nous run a quality-first
|
||||
# model chain and may fall back depending on account access and endpoint behavior,
|
||||
# so fidelity can vary by configured backend + model availability.
|
||||
_REF_CAPABLE = ("nous", "openai", "openai-codex", "openrouter", "krea")
|
||||
|
||||
# Friendly display label per reference-capable provider, surfaced in the desktop
|
||||
# pet-gen picker.
|
||||
_PROVIDER_LABELS: dict[str, str] = {
|
||||
"nous": "Nous Portal",
|
||||
"openrouter": "OpenRouter",
|
||||
"openai": "OpenAI",
|
||||
"openai-codex": "OpenAI (Codex)",
|
||||
"krea": "Krea",
|
||||
}
|
||||
|
||||
|
||||
def _forced_provider_from_env() -> str | None:
|
||||
"""Optional QA override to force a pet-gen backend.
|
||||
|
||||
`HERMES_PET_IMAGE_PROVIDER=<name>` (e.g. `openrouter`) bypasses the normal
|
||||
active/default provider resolution for pet generation only. Unknown values are
|
||||
ignored so existing users are unaffected.
|
||||
"""
|
||||
forced = os.environ.get("HERMES_PET_IMAGE_PROVIDER", "").strip().lower()
|
||||
return forced if forced in _REF_CAPABLE else None
|
||||
|
||||
|
||||
class GenerationError(RuntimeError):
|
||||
"""Raised on any image-generation failure (no provider, API error, IO)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpriteProvider:
|
||||
"""Resolved provider plus whether it can take reference images."""
|
||||
|
||||
name: str
|
||||
provider: object
|
||||
supports_references: bool
|
||||
|
||||
|
||||
def _discover() -> None:
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
except Exception as exc: # noqa: BLE001 - discovery is best-effort
|
||||
logger.debug("image-gen plugin discovery failed: %s", exc)
|
||||
|
||||
|
||||
def resolve_provider(*, require_references: bool = True, prefer: str | None = None) -> SpriteProvider:
|
||||
"""Pick the image provider to use for sprite work.
|
||||
|
||||
Preference: an explicit *prefer* choice (the desktop pet-gen picker) when it's
|
||||
reference-capable and configured, then the configured/active provider when
|
||||
it's reference-capable, else the first available reference-capable provider.
|
||||
With *require_references* off we fall back to any available provider (used for
|
||||
prompt-only base drafts).
|
||||
"""
|
||||
_discover()
|
||||
from agent.image_gen_registry import get_active_provider, get_provider
|
||||
|
||||
# QA override: force one provider for pet-gen iteration regardless of the
|
||||
# globally active image_gen backend.
|
||||
forced = _forced_provider_from_env()
|
||||
if forced:
|
||||
chosen = get_provider(forced)
|
||||
if chosen is not None and chosen.is_available():
|
||||
return SpriteProvider(name=forced, provider=chosen, supports_references=True)
|
||||
|
||||
# An explicit user pick wins when it's reference-capable and has credentials;
|
||||
# otherwise we ignore it and fall through to the normal resolution.
|
||||
if prefer:
|
||||
chosen = get_provider(prefer)
|
||||
if prefer in _REF_CAPABLE and chosen is not None and chosen.is_available():
|
||||
return SpriteProvider(name=prefer, provider=chosen, supports_references=True)
|
||||
|
||||
# Configured / active provider first.
|
||||
active = None
|
||||
try:
|
||||
active = get_active_provider()
|
||||
except Exception: # noqa: BLE001
|
||||
active = None
|
||||
if active is not None:
|
||||
name = getattr(active, "name", "")
|
||||
if name in _REF_CAPABLE and active.is_available():
|
||||
return SpriteProvider(name=name, provider=active, supports_references=True)
|
||||
|
||||
# Any available reference-capable provider.
|
||||
for name in _REF_CAPABLE:
|
||||
provider = get_provider(name)
|
||||
if provider is not None and provider.is_available():
|
||||
return SpriteProvider(name=name, provider=provider, supports_references=True)
|
||||
|
||||
if not require_references and active is not None and active.is_available():
|
||||
return SpriteProvider(
|
||||
name=getattr(active, "name", "unknown"), provider=active, supports_references=False
|
||||
)
|
||||
|
||||
raise GenerationError(
|
||||
"Pet generation needs an image backend that supports reference images. "
|
||||
"Open `hermes tools` → Image Generation and configure Nous Portal, "
|
||||
"OpenRouter, or OpenAI (gpt-image-2) with an API key."
|
||||
)
|
||||
|
||||
|
||||
def list_sprite_providers() -> list[dict]:
|
||||
"""The reference-capable providers available to pick for pet generation.
|
||||
|
||||
Returns ``[{name, label, default}]`` for every ref-capable provider the user
|
||||
actually has credentials for, in preference order, marking the one
|
||||
:func:`resolve_provider` would choose with no explicit preference. Empty when
|
||||
none is configured (the picker hides itself). Best-effort: discovery hiccups
|
||||
yield an empty list.
|
||||
"""
|
||||
_discover()
|
||||
from agent.image_gen_registry import get_provider
|
||||
|
||||
try:
|
||||
default_name = resolve_provider(require_references=True).name
|
||||
except GenerationError:
|
||||
default_name = ""
|
||||
|
||||
out: list[dict] = []
|
||||
for name in _REF_CAPABLE:
|
||||
provider = get_provider(name)
|
||||
if provider is None or not provider.is_available():
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": _PROVIDER_LABELS.get(name, name),
|
||||
"default": name == default_name,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _save_local(image_ref: str, *, prefix: str) -> Path:
|
||||
"""Return a local path for *image_ref*, downloading it if it's a URL."""
|
||||
if image_ref.startswith(("http://", "https://")):
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
return Path(save_url_image(image_ref, prefix=prefix))
|
||||
return Path(image_ref)
|
||||
|
||||
|
||||
def _rejected_background(error: str) -> bool:
|
||||
"""True when a provider error is specifically about the ``background`` param.
|
||||
|
||||
Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers
|
||||
reject ``background=transparent`` outright). We detect that one rejection so
|
||||
we can retry without the flag rather than failing the whole pet — our chroma
|
||||
key pass makes the result transparent regardless.
|
||||
"""
|
||||
lowered = (error or "").lower()
|
||||
return "background" in lowered and ("not supported" in lowered or "transparent" in lowered)
|
||||
|
||||
|
||||
def generate(
|
||||
prompt: str,
|
||||
*,
|
||||
n: int = 1,
|
||||
reference_images: list[Path] | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
prefix: str = "pet_gen",
|
||||
aspect_ratio: str = "square",
|
||||
) -> list[Path]:
|
||||
"""Generate *n* sprite images and return their local paths.
|
||||
|
||||
*reference_images* grounds the output on a base image (required for rows).
|
||||
*aspect_ratio* picks the canvas: ``"square"`` for single-character base
|
||||
drafts, ``"landscape"`` for multi-frame row strips (the wider 1536px canvas
|
||||
gives every frame real horizontal room so winged poses don't have to be
|
||||
shrunk to avoid touching their neighbors).
|
||||
We *ask* for a transparent background, but fall back to an opaque generation
|
||||
(cleaned up downstream by the chroma-key pass) on models that reject the
|
||||
flag. Raises :class:`GenerationError` if nothing usable comes back.
|
||||
"""
|
||||
sprite = provider or resolve_provider(require_references=bool(reference_images))
|
||||
if reference_images and not sprite.supports_references:
|
||||
raise GenerationError(
|
||||
f"image backend '{sprite.name}' cannot use reference images; "
|
||||
"configure OpenAI gpt-image-2 or Krea for pet generation"
|
||||
)
|
||||
|
||||
refs = [str(p) for p in (reference_images or [])]
|
||||
|
||||
def _run(extra: dict) -> tuple[Path | None, str]:
|
||||
kwargs: dict = {"aspect_ratio": aspect_ratio, **extra}
|
||||
if refs:
|
||||
# Providers disagree on the ref kwarg name: our OpenRouter/Nous
|
||||
# backends read ``reference_images``, OpenAI's gpt-image-2 reads
|
||||
# ``reference_image_urls``. Send both; each ignores the other.
|
||||
kwargs["reference_images"] = refs
|
||||
kwargs["reference_image_urls"] = refs
|
||||
try:
|
||||
result = sprite.provider.generate(prompt, **kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - normalize provider crashes
|
||||
logger.debug("provider.generate crashed: %s", exc)
|
||||
return None, str(exc)
|
||||
if not isinstance(result, dict) or not result.get("success"):
|
||||
return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result"
|
||||
image_ref = result.get("image")
|
||||
if not image_ref:
|
||||
return None, "provider returned no image"
|
||||
try:
|
||||
return _save_local(str(image_ref), prefix=prefix), ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, f"could not save generated image: {exc}"
|
||||
|
||||
out: list[Path] = []
|
||||
last_error = ""
|
||||
allow_transparent = True
|
||||
for _ in range(max(1, n)):
|
||||
path, err = _run({"background": "transparent"} if allow_transparent else {})
|
||||
# Model doesn't support the transparent flag → drop it for this and every
|
||||
# remaining variant (no point re-probing a capability we just disproved).
|
||||
if path is None and allow_transparent and _rejected_background(err):
|
||||
allow_transparent = False
|
||||
path, err = _run({})
|
||||
if path is not None:
|
||||
out.append(path)
|
||||
else:
|
||||
last_error = err
|
||||
|
||||
if not out:
|
||||
raise GenerationError(last_error or "image generation produced no output")
|
||||
return out
|
||||
@@ -1,358 +0,0 @@
|
||||
"""Pet generation orchestration — the base-draft → hatch flow.
|
||||
|
||||
Two steps, mirroring the UX across every surface:
|
||||
|
||||
1. :func:`generate_base_drafts` — a handful of prompt-only "what should this pet
|
||||
look like" variants. Cheap; the user picks one (or retries for a fresh set).
|
||||
2. :func:`hatch_pet` — takes the chosen base and generates one grounded row
|
||||
strip per Hermes state, slices each into frames, composes the atlas, validates
|
||||
it, and writes the pet into the store.
|
||||
|
||||
Splitting it this way bounds cost (4 cheap base calls per round; the ~6 row
|
||||
calls happen once, on the pet you actually keep) and gives each UI a natural
|
||||
preview/loading point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from agent.pet.generate import atlas, imagegen, prompts
|
||||
from agent.pet.generate.imagegen import GenerationError, SpriteProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", "<slug>").
|
||||
ProgressFn = Callable[[str, str], None]
|
||||
|
||||
# Image generations are independent network calls, so we fan them out instead of
|
||||
# blocking on each in turn — a hatch is ~8 row calls that would otherwise run
|
||||
# back-to-back and routinely blow past the client's RPC timeout. Capped so we
|
||||
# don't hammer the provider's rate limit (one cold call can still be slow).
|
||||
_MAX_PARALLEL_GENERATIONS = 4
|
||||
# How many times to (re)generate a single row before accepting a best-effort
|
||||
# slice. Early attempts demand clean per-pose gutters; the last is lenient so a
|
||||
# stubborn row still yields frames instead of dropping out entirely.
|
||||
_ROW_GEN_ATTEMPTS = 3
|
||||
_MIN_FILLED_STATES = 6
|
||||
_REQUIRED_STATES = frozenset({"idle", "running-right", "waving"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HatchResult:
|
||||
"""Outcome of a successful :func:`hatch_pet`."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
spritesheet: Path
|
||||
states: list[str]
|
||||
validation: dict
|
||||
|
||||
|
||||
def _harden_transparency(path: Path) -> Path:
|
||||
"""Key out any solid backdrop the provider painted; save as an RGBA PNG.
|
||||
|
||||
``background=transparent`` is requested on every call, but image models honor
|
||||
it inconsistently — some still paint a flat (often near-white) backdrop. We
|
||||
run the same chroma-key pass the row extractor uses so every base draft the
|
||||
user picks between (and the reference the rows are grounded on) is a clean
|
||||
cutout. Best-effort: a decode failure leaves the original untouched.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(path) as opened:
|
||||
keyed = atlas.remove_background(opened.convert("RGBA"))
|
||||
# Zero the RGB of any leftover semi-transparent edge pixels so a keyed
|
||||
# draft has no colored halo when composited on the dark UI.
|
||||
keyed = atlas._clear_transparent_rgb(keyed)
|
||||
out = path.with_suffix(".png")
|
||||
keyed.save(out, format="PNG")
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image
|
||||
logger.debug("base draft transparency hardening failed for %s: %s", path, exc)
|
||||
return path
|
||||
|
||||
|
||||
def generate_base_drafts(
|
||||
concept: str,
|
||||
*,
|
||||
n: int = 4,
|
||||
style: str = "auto",
|
||||
reference_images: list[Path] | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
on_draft: Callable[[int, Path], None] | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> list[Path]:
|
||||
"""Generate *n* candidate base looks for *concept*; returns image paths.
|
||||
|
||||
Each draft is hardened to a transparent cutout (see :func:`_harden_transparency`).
|
||||
Drafts are generated concurrently and *on_draft(index, path)* fires as each
|
||||
one finishes (not at the end) so callers can stream previews to the UI
|
||||
instead of leaving it blank until the whole batch is done.
|
||||
|
||||
*is_cancelled*, when supplied, is polled cooperatively: a draft that hasn't
|
||||
started yet is skipped, and once it trips we stop staging/streaming further
|
||||
drafts and cancel any queued work (already-in-flight provider calls can't be
|
||||
hard-killed, but their results are dropped).
|
||||
"""
|
||||
# A user reference image (e.g. their own pet) grounds every draft, so it
|
||||
# needs a reference-capable provider — same requirement as the row passes.
|
||||
refs = reference_images or None
|
||||
sprite = provider or imagegen.resolve_provider(require_references=bool(refs))
|
||||
cancelled = is_cancelled or (lambda: False)
|
||||
|
||||
# Each draft is its own one-shot generation, run concurrently so the user
|
||||
# waits for one image, not N. A single draft failing must not sink the set.
|
||||
# Each gets a distinct variation nudge so the options aren't near-duplicates.
|
||||
logger.info("pet generate: drafting %d base looks for %r (style=%s)", n, concept, style)
|
||||
|
||||
def _one(index: int) -> tuple[int, Path | None, str | None]:
|
||||
if cancelled():
|
||||
return index, None, None
|
||||
t0 = time.monotonic()
|
||||
variation = prompts.BASE_VARIATIONS[index % len(prompts.BASE_VARIATIONS)]
|
||||
prompt = prompts.build_base_prompt(concept, style=style, variation=variation)
|
||||
try:
|
||||
out = imagegen.generate(prompt, n=1, reference_images=refs, provider=sprite, prefix="pet_base")
|
||||
except Exception as exc: # noqa: BLE001 - tolerate a single failed draft
|
||||
logger.warning("pet generate: draft %d failed after %.1fs: %s", index, time.monotonic() - t0, exc)
|
||||
return index, None, str(exc)
|
||||
if not out:
|
||||
logger.warning("pet generate: draft %d produced no image", index)
|
||||
return index, None, "the image provider returned no image"
|
||||
logger.info("pet generate: draft %d ready in %.1fs", index, time.monotonic() - t0)
|
||||
return index, _harden_transparency(out[0]), None
|
||||
|
||||
workers = max(1, min(n, _MAX_PARALLEL_GENERATIONS))
|
||||
results: dict[int, Path] = {}
|
||||
errors: list[str] = []
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [pool.submit(_one, i) for i in range(n)]
|
||||
# as_completed runs in *this* (the caller's) thread, so on_draft — and any
|
||||
# gateway event it emits — inherits the request's bound transport, unlike
|
||||
# the worker threads above.
|
||||
for fut in as_completed(futures):
|
||||
if cancelled():
|
||||
logger.info("pet generate: cancelled — dropping remaining drafts")
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
break
|
||||
index, path, err = fut.result()
|
||||
if path is None:
|
||||
if err:
|
||||
errors.append(err)
|
||||
continue
|
||||
results[index] = path
|
||||
if on_draft is not None:
|
||||
try:
|
||||
on_draft(index, path)
|
||||
except Exception as exc: # noqa: BLE001 - progress is best-effort
|
||||
logger.debug("on_draft callback failed: %s", exc)
|
||||
|
||||
drafts = [results[i] for i in sorted(results)]
|
||||
if not drafts and not cancelled():
|
||||
# Surface *why* — every draft failed for a reason (a content-policy refusal
|
||||
# on a name like "minion", a provider/auth error, …); the most common one
|
||||
# is the representative cause. Far more useful than "no usable drafts".
|
||||
raise GenerationError(_drafts_failed_reason(errors))
|
||||
return drafts
|
||||
|
||||
|
||||
def _drafts_failed_reason(errors: list[str]) -> str:
|
||||
"""The representative reason a draft round produced nothing, humanized."""
|
||||
if not errors:
|
||||
return "image generation produced no usable drafts"
|
||||
from collections import Counter
|
||||
|
||||
return _humanize_image_error(Counter(errors).most_common(1)[0][0])
|
||||
|
||||
|
||||
def _humanize_image_error(error: str) -> str:
|
||||
"""Turn a raw provider error into a friendly, actionable sentence.
|
||||
|
||||
The big one is moderation: image models refuse trademarked characters and
|
||||
real people (e.g. "minion"), which reads as an opaque 400 otherwise.
|
||||
"""
|
||||
low = error.lower()
|
||||
if any(s in low for s in ("moderation_blocked", "safety system", "content policy", "content_policy")):
|
||||
return (
|
||||
"The image provider blocked this prompt — its safety filter rejects "
|
||||
"trademarked characters and real people. Try an original description."
|
||||
)
|
||||
if any(s in low for s in ("api key", "unauthorized", "401", "auth")):
|
||||
return "The image provider rejected the request — check your API key in Settings → Providers."
|
||||
if "rate limit" in low or "429" in low:
|
||||
return "The image provider is rate-limiting — wait a moment and try again."
|
||||
# Otherwise the first line, trimmed of the noisy provider envelope.
|
||||
return error.splitlines()[0].strip()[:200]
|
||||
|
||||
|
||||
def hatch_pet(
|
||||
*,
|
||||
base_image: str | Path,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
concept: str = "",
|
||||
style: str = "auto",
|
||||
on_progress: ProgressFn | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> HatchResult:
|
||||
"""Turn an approved base image into a full, installed Hermes pet.
|
||||
|
||||
Generates a grounded row strip per state, extracts frames, composes +
|
||||
validates the atlas, and registers it. The idle row falls back to the base
|
||||
look so the pet always renders. Raises :class:`GenerationError` on failure.
|
||||
|
||||
*is_cancelled*, when supplied, is polled cooperatively: rows that haven't
|
||||
started are skipped, queued rows are cancelled, and once every row is done we
|
||||
abort (raising :class:`GenerationError`) before composing/saving so a stopped
|
||||
hatch never writes a half-built pet.
|
||||
"""
|
||||
base = Path(base_image)
|
||||
if not base.is_file():
|
||||
raise GenerationError(f"base image not found: {base}")
|
||||
|
||||
sprite = provider or imagegen.resolve_provider(require_references=True)
|
||||
progress = on_progress or (lambda *_: None)
|
||||
cancelled = is_cancelled or (lambda: False)
|
||||
label = concept or display_name or slug
|
||||
|
||||
frames_by_state: dict[str, list] = {}
|
||||
total_rows = len(atlas.ROW_SPECS)
|
||||
logger.info("pet hatch %r: generating %d animation rows", slug, total_rows)
|
||||
|
||||
# Generate every state's row strip concurrently — they're independent
|
||||
# grounded calls, so the hatch waits for the slowest row, not their sum. A
|
||||
# single row failing is tolerated (idle is guaranteed below).
|
||||
def _gen_row(spec: tuple[str, int, int]) -> tuple[str, list | None]:
|
||||
state, _row, count = spec
|
||||
if cancelled():
|
||||
return state, None
|
||||
t0 = time.monotonic()
|
||||
last_exc: Exception | None = None
|
||||
# Self-healing: a model occasionally returns a row whose poses are touching
|
||||
# (no clean gutters), which slices badly. We retry such rolls; only the
|
||||
# final attempt falls back to lenient ``auto`` slicing so a stubborn row
|
||||
# still yields *something* rather than dropping the whole row.
|
||||
for attempt in range(_ROW_GEN_ATTEMPTS):
|
||||
if cancelled():
|
||||
return state, None
|
||||
strict = attempt < _ROW_GEN_ATTEMPTS - 1
|
||||
try:
|
||||
strips = imagegen.generate(
|
||||
prompts.build_row_prompt(state, count, label, style=style),
|
||||
n=1,
|
||||
reference_images=[base],
|
||||
provider=sprite,
|
||||
prefix=f"pet_row_{state}",
|
||||
# Wider canvas → each frame gets real horizontal room, so winged
|
||||
# poses keep a full, healthy size and still leave clean gutters.
|
||||
aspect_ratio="landscape",
|
||||
)
|
||||
# ``components`` requires clean per-pose gutters (raises otherwise),
|
||||
# so a touching roll is rejected and regenerated; the last attempt
|
||||
# uses ``auto`` (equal-slot fallback, never raises). Raw (fit=False)
|
||||
# so normalize_cells registers the whole pet at once.
|
||||
method = "components" if strict else "auto"
|
||||
frames = atlas.extract_strip_frames(strips[0], count, method=method, fit=False)
|
||||
logger.info(
|
||||
"pet hatch %r: row %r ready in %.1fs (attempt %d)",
|
||||
slug, state, time.monotonic() - t0, attempt + 1,
|
||||
)
|
||||
return state, frames
|
||||
except Exception as exc: # noqa: BLE001 - retried; one bad row is tolerated
|
||||
last_exc = exc
|
||||
logger.warning(
|
||||
"pet hatch %r: row %r attempt %d/%d failed: %s",
|
||||
slug, state, attempt + 1, _ROW_GEN_ATTEMPTS, exc,
|
||||
)
|
||||
logger.warning(
|
||||
"pet hatch %r: row %r gave up after %.1fs: %s",
|
||||
slug, state, time.monotonic() - t0, last_exc,
|
||||
)
|
||||
return state, None
|
||||
|
||||
# running-left is derived by mirroring running-right (guaranteed-consistent
|
||||
# and one fewer generation), so we don't generate it directly.
|
||||
generated_specs = [spec for spec in atlas.ROW_SPECS if spec[0] != "running-left"]
|
||||
|
||||
workers = max(1, min(len(generated_specs), _MAX_PARALLEL_GENERATIONS))
|
||||
done = 0
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [pool.submit(_gen_row, spec) for spec in generated_specs]
|
||||
# as_completed runs on the caller (request) thread, so progress events
|
||||
# emitted here inherit the request transport — unlike the worker threads.
|
||||
for fut in as_completed(futures):
|
||||
if cancelled():
|
||||
logger.info("pet hatch %r: cancelled — dropping remaining rows", slug)
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
break
|
||||
state, frames = fut.result()
|
||||
done += 1
|
||||
progress("row", f"{state}:{done}:{total_rows}")
|
||||
if frames:
|
||||
frames_by_state[state] = frames
|
||||
|
||||
if cancelled():
|
||||
raise GenerationError("hatch cancelled")
|
||||
|
||||
# Derive running-left from the approved running-right row (per-frame mirror,
|
||||
# preserving order/timing). Missing running-right is rejected below; a pet
|
||||
# without its canonical walk cycle is a failed hatch, not a shippable mascot.
|
||||
right = frames_by_state.get("running-right")
|
||||
if right:
|
||||
done += 1
|
||||
progress("row", f"running-left:{done}:{total_rows}")
|
||||
frames_by_state["running-left"] = atlas.mirror_frames(right)
|
||||
logger.info("pet hatch %r: row 'running-left' mirrored from running-right", slug)
|
||||
else:
|
||||
logger.warning("pet hatch %r: no running-right to mirror; left walk left empty", slug)
|
||||
|
||||
# Idle is the resting state the renderer falls back to — guarantee it.
|
||||
if not frames_by_state.get("idle"):
|
||||
progress("row", "idle-fallback")
|
||||
frames_by_state["idle"] = [atlas.single_frame(base, fit=False)]
|
||||
|
||||
progress("compose", "")
|
||||
logger.info("pet hatch %r: composing atlas from %d states", slug, len(frames_by_state))
|
||||
# One shared scale + baseline across every state so the pet never slides or
|
||||
# pulses size between frames; compose just packs the normalized cells.
|
||||
sheet = atlas.compose_atlas(atlas.normalize_cells(frames_by_state))
|
||||
validation = atlas.validate_atlas(sheet)
|
||||
if not validation["ok"]:
|
||||
raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed")
|
||||
filled_states = set(validation["filled_states"])
|
||||
missing_required = sorted(_REQUIRED_STATES - filled_states)
|
||||
if missing_required:
|
||||
raise GenerationError(f"missing required animation row(s): {', '.join(missing_required)}")
|
||||
if len(filled_states) < _MIN_FILLED_STATES:
|
||||
raise GenerationError(
|
||||
f"only {len(filled_states)}/{len(atlas.ROW_SPECS)} animation rows were usable; regenerate"
|
||||
)
|
||||
|
||||
from agent.pet import store
|
||||
|
||||
progress("save", slug)
|
||||
logger.info("pet hatch %r: saving pet", slug)
|
||||
pet = store.register_local_pet(
|
||||
sheet,
|
||||
slug=slug,
|
||||
display_name=display_name or slug,
|
||||
description=description,
|
||||
)
|
||||
return HatchResult(
|
||||
slug=pet.slug,
|
||||
display_name=pet.display_name,
|
||||
spritesheet=pet.spritesheet,
|
||||
states=validation["filled_states"],
|
||||
validation=validation,
|
||||
)
|
||||
@@ -1,183 +0,0 @@
|
||||
"""Prompt builders for pet generation.
|
||||
|
||||
Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the
|
||||
user picks between) and per-*state* *row* prompts (grounded on the chosen base,
|
||||
produce one horizontal strip of N poses). Prompts stay concise and
|
||||
sprite-production oriented; the identity lock and "one transparent row" framing
|
||||
matter more than flowery description.
|
||||
|
||||
We generate the full petdex/Codex nine-state set (see
|
||||
:data:`agent.pet.generate.atlas.ROW_SPECS`) so a hatched pet is a valid
|
||||
``petdex submit`` spritesheet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# What each petdex/Codex state should depict (kept short — these go straight into
|
||||
# the row prompt). Phrased to avoid the common sprite-gen failure modes (detached
|
||||
# effects, motion lines, shadows). Critical distinction: ``running`` is the
|
||||
# *working* state (in place), while ``running-right`` / ``running-left`` are the
|
||||
# actual directional walk/run cycles.
|
||||
STATE_ACTIONS: dict[str, str] = {
|
||||
"idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures",
|
||||
"running-right": (
|
||||
"a sideways walk/run locomotion cycle moving to the RIGHT: the character "
|
||||
"faces and travels right with clear directional steps, a smooth gait loop"
|
||||
),
|
||||
"running-left": (
|
||||
"a sideways walk/run locomotion cycle moving to the LEFT: the character "
|
||||
"faces and travels left with clear directional steps (the mirror of the "
|
||||
"right-facing run)"
|
||||
),
|
||||
"waving": "a friendly greeting: raising a paw/hand/limb to wave, clear up-and-down gesture",
|
||||
"jumping": "a happy celebration jump: anticipation, lift off the ground, peak, and land",
|
||||
"failed": "a sad or deflated reaction: slumped, dejected, small frown — readable but not noisy",
|
||||
"waiting": (
|
||||
"an expectant 'waiting on you' pose: looking up/out as if asking for input "
|
||||
"or approval — distinct from idle and review"
|
||||
),
|
||||
"running": (
|
||||
"focused active work, staying IN PLACE (NOT walking or foot-running): "
|
||||
"leaning in, concentrating, busy 'thinking / processing / typing' energy"
|
||||
),
|
||||
"review": "careful inspection: a focused lean, head tilt, studying something intently",
|
||||
}
|
||||
|
||||
_STYLE_HINTS: dict[str, str] = {
|
||||
# Default to the popular petdex look: crisp 16-bit PIXEL ART, not the smooth
|
||||
# 2D illustration (let alone 3D render) gpt-image reaches for by default.
|
||||
"auto": (
|
||||
" Style: crisp 16-bit PIXEL-ART game sprite — visible square pixels, a small "
|
||||
"limited palette, clean dark outline, flat cel shading, chunky chibi "
|
||||
"proportions, like a classic SNES/JRPG party member or a petdex.dev mascot. "
|
||||
"Absolutely NOT 3D-rendered, NOT a smooth painted or vector illustration, "
|
||||
"NOT photorealistic — no soft gradients, no realistic lighting, no figurine look."
|
||||
),
|
||||
"pixel": " Render in clean 16-bit pixel-art style with visible square pixels and a limited palette.",
|
||||
"plush": " Render as a soft plush toy.",
|
||||
"clay": " Render as a claymation / soft 3D clay figure.",
|
||||
"sticker": " Render as a glossy die-cut sticker.",
|
||||
"flat-vector": " Render in flat vector mascot style.",
|
||||
"3d-toy": " Render as a glossy 3D toy.",
|
||||
"painterly": " Render in a soft painterly style.",
|
||||
}
|
||||
|
||||
_BACKGROUND = (
|
||||
"Center the character on a SINGLE flat, uniform, high-contrast chroma-key "
|
||||
"background — pure hot magenta #FF00FF (only if magenta appears on the "
|
||||
"character, use pure green #00FF00 instead). The background is ONE continuous "
|
||||
"even color that completely surrounds the character with NO gradient, "
|
||||
"vignette, texture, pattern, scenery, shadow, ground line, frame, border, "
|
||||
"panel, comic cell, gutter line, grid, or divider of any kind, so it keys out "
|
||||
"cleanly. The background color must not appear anywhere on the character. "
|
||||
"No text, no labels, no speech bubbles, no UI."
|
||||
)
|
||||
|
||||
|
||||
def style_hint(style: str | None) -> str:
|
||||
return _STYLE_HINTS.get((style or "auto").strip().lower(), "")
|
||||
|
||||
|
||||
# Row strips are generated on the wider landscape canvas (see imagegen.generate /
|
||||
# orchestrate). The extra width is what lets each pose stay a healthy size AND
|
||||
# leave a real gutter — used here only to cite concrete pixel numbers.
|
||||
_ASSUMED_STRIP_WIDTH = 1536
|
||||
|
||||
|
||||
def _spacing_spec(frame_count: int) -> tuple[int, int]:
|
||||
"""(per-pose width px, gap px) for a row of *frame_count* poses.
|
||||
|
||||
Pixel counts alone don't hold — the model fills each slot edge-to-edge with
|
||||
the full wingspan, so neighbors touch even when bodies are spaced. The lever
|
||||
that works is proportional containment on a wide canvas: give each pose its
|
||||
own equal cell and keep the ENTIRE silhouette (wings/tail/halo included)
|
||||
inside it. On the 1536px landscape strip ~70% occupancy still leaves a
|
||||
generous gutter, so the pet stays a normal, good-looking size — no shrinking.
|
||||
"""
|
||||
slots = max(1, frame_count)
|
||||
slot_w = _ASSUMED_STRIP_WIDTH / slots
|
||||
pose_px = round(slot_w * 0.7)
|
||||
gap_px = max(48, round(slot_w * 0.3))
|
||||
return pose_px, gap_px
|
||||
|
||||
|
||||
# Per-draft nudges so the 4 base options are actually distinct — gpt-image returns
|
||||
# near-duplicates for a single prompt. We vary the *look* (palette, build,
|
||||
# expression, accents), NOT the pose, so the chosen base still grounds clean,
|
||||
# consistent animation rows.
|
||||
BASE_VARIATIONS: tuple[str, ...] = (
|
||||
"",
|
||||
"a distinctly different colour palette and markings",
|
||||
"a heavier, broader silhouette with sturdier proportions",
|
||||
"a different facial structure and expression matching the concept tone, with unique accent/accessory details",
|
||||
"a leaner, taller build and an alternate colour scheme",
|
||||
"bolder, more saturated colours and a stronger expression matching the concept tone",
|
||||
)
|
||||
|
||||
|
||||
def build_base_prompt(concept: str, *, style: str | None = "auto", variation: str = "") -> str:
|
||||
"""The base look: a single, clean, centered full-body mascot.
|
||||
|
||||
*variation* differentiates one draft from the next (see :data:`BASE_VARIATIONS`).
|
||||
"""
|
||||
concept = (concept or "a distinctive mascot creature").strip()
|
||||
nudge = f" Make this design distinct: {variation}." if variation else ""
|
||||
return (
|
||||
f"A stylized mascot pet character: {concept}. "
|
||||
"Honor the requested tone and mood exactly (cute, eerie, scary, menacing, whimsical, etc.) "
|
||||
"while staying non-graphic. "
|
||||
"Compact, whole-body silhouette that reads clearly at small size, "
|
||||
"clear readable facial features, simple consistent palette. "
|
||||
# A neutral, symmetric, at-rest stance makes the cleanest identity anchor
|
||||
"Neutral front-facing standing pose, upright and symmetric, arms/limbs "
|
||||
"relaxed at the sides, feet together on the ground, any cape/accessories "
|
||||
"hanging straight and still."
|
||||
f"{nudge} "
|
||||
f"{_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
|
||||
|
||||
def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str:
|
||||
"""A row strip: *frame_count* poses of the SAME character, left→right.
|
||||
|
||||
The attached base image is the identity source of truth; the prompt locks
|
||||
species, palette, face, and props to it.
|
||||
"""
|
||||
action = STATE_ACTIONS.get(state, "a simple idle pose")
|
||||
concept = (concept or "the mascot").strip()
|
||||
pose_px, gap_px = _spacing_spec(frame_count)
|
||||
return (
|
||||
f"Using the attached reference image as the exact same character "
|
||||
f"(same species, face, colors, markings, proportions, and props), "
|
||||
"preserving the same emotional tone/mood (e.g., scary stays scary, cute stays cute), "
|
||||
f"draw a single WIDE horizontal strip of {frame_count} animation frames showing {action}. "
|
||||
f"LAYOUT: arrange {frame_count} poses in ONE horizontal row at equal spacing, "
|
||||
"each pose centered in its own imaginary equal region. Draw NO panel borders, "
|
||||
"NO comic cells, NO boxes, NO vertical divider/gutter lines, NO grid, NO frame "
|
||||
"outlines between poses — the backdrop is one unbroken flat field behind all of them. "
|
||||
"Fill the WHOLE strip with the SAME single flat chroma-key color as the attached "
|
||||
"reference image's background (identical hue in every frame, no per-pose color shifts). "
|
||||
f"SPACING (critical): draw each pose at a consistent, healthy, clearly "
|
||||
f"visible size (roughly {pose_px}px wide on a {_ASSUMED_STRIP_WIDTH}px "
|
||||
f"strip) — do NOT shrink it tiny — but keep its ENTIRE silhouette "
|
||||
f"(wings, tail, halo, horns, cape, every appendage) fully INSIDE its own "
|
||||
f"cell. Leave at least {gap_px}px of empty chroma-key background between "
|
||||
f"neighboring silhouettes at their closest point (wingtip to wingtip), and "
|
||||
f"the same empty margin before the first pose and after the last. If a wing, "
|
||||
f"cape, or tail would reach into a neighbor, FOLD or angle it inward rather "
|
||||
f"than letting it cross the gap. Silhouettes must NEVER touch, overlap, "
|
||||
f"share a shadow, share a ground line, share motion trails, or merge into "
|
||||
f"one connected shape. "
|
||||
# Registration: a clean sprite sheet keeps the character locked in place
|
||||
# so only the action moves — this is what stops the loop sliding/pulsing.
|
||||
"REGISTRATION (critical): the character is the SAME height and SAME width "
|
||||
"in every frame, drawn at the SAME scale, centered over the SAME point, "
|
||||
"with all feet aligned to the SAME invisible horizontal baseline across the "
|
||||
"whole strip — this baseline is conceptual ONLY: draw NO ground line, floor, "
|
||||
"platform, horizon, or contact shadow beneath the feet. Keep the body's center, size, and stance fixed frame to "
|
||||
"frame — ONLY the limbs/features the action needs may move. Capes, cloaks, "
|
||||
"bags, and scarves stay in the SAME place and shape every frame (no "
|
||||
"swinging, flowing, or drifting) unless the action itself requires it. No "
|
||||
"pose is cropped at the strip edges. "
|
||||
f"{_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
@@ -1,165 +0,0 @@
|
||||
"""Fetch the public petdex manifest.
|
||||
|
||||
``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2:
|
||||
|
||||
{
|
||||
"generatedAt": "...",
|
||||
"total": 2926,
|
||||
"pets": [
|
||||
{"slug": "boba", "displayName": "Boba", "kind": "creature",
|
||||
"submittedBy": "railly",
|
||||
"spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp",
|
||||
"petJsonUrl": "https://assets.petdex.dev/.../pet.json",
|
||||
"zipUrl": "https://assets.petdex.dev/.../boba.zip"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Read-only and unauthenticated; no credentials involved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MANIFEST_URL = "https://petdex.dev/api/manifest"
|
||||
|
||||
_DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
# In-process cache for the (large, slow, identical-per-call) manifest. The list
|
||||
# is a static CDN object that barely changes, yet a single session can ask for
|
||||
# it many times — every gallery open, plus a full re-fetch per install/select
|
||||
# (``find_entry``). A short TTL collapses those into one network hit without
|
||||
# going stale for long. Cleared by :func:`clear_cache` (tests).
|
||||
_MANIFEST_TTL = 300.0
|
||||
_cache: tuple[float, list[ManifestEntry]] | None = None
|
||||
|
||||
_prefetch_lock = threading.Lock()
|
||||
_prefetching = False
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop the cached manifest (forces the next fetch to hit the network)."""
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def _cache_is_warm() -> bool:
|
||||
return _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL
|
||||
|
||||
|
||||
def prefetch(*, timeout: float = _DEFAULT_TIMEOUT) -> None:
|
||||
"""Warm the manifest cache in a daemon thread — idempotent, never blocks.
|
||||
|
||||
The desktop picker calls this when it loads the (instant) local-only gallery
|
||||
so the full petdex catalog is usually cached by the time it's requested,
|
||||
without ever holding up the user's own pets on a network round-trip.
|
||||
"""
|
||||
global _prefetching
|
||||
|
||||
if _cache_is_warm():
|
||||
return
|
||||
|
||||
with _prefetch_lock:
|
||||
if _prefetching:
|
||||
return
|
||||
_prefetching = True
|
||||
|
||||
def _run() -> None:
|
||||
global _prefetching
|
||||
try:
|
||||
fetch_manifest(timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort warm
|
||||
logger.debug("petdex manifest prefetch failed: %s", exc)
|
||||
finally:
|
||||
_prefetching = False
|
||||
|
||||
threading.Thread(target=_run, name="petdex-prefetch", daemon=True).start()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestEntry:
|
||||
"""A single pet's row in the manifest."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
kind: str
|
||||
submitted_by: str
|
||||
spritesheet_url: str
|
||||
pet_json_url: str
|
||||
zip_url: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ManifestEntry":
|
||||
return cls(
|
||||
slug=str(data.get("slug", "")).strip(),
|
||||
display_name=str(data.get("displayName", "") or data.get("slug", "")),
|
||||
kind=str(data.get("kind", "") or "pet"),
|
||||
submitted_by=str(data.get("submittedBy", "") or ""),
|
||||
spritesheet_url=str(data.get("spritesheetUrl", "") or ""),
|
||||
pet_json_url=str(data.get("petJsonUrl", "") or ""),
|
||||
zip_url=str(data.get("zipUrl", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
class ManifestError(RuntimeError):
|
||||
"""Raised when the manifest can't be fetched or parsed."""
|
||||
|
||||
|
||||
def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]:
|
||||
"""Return every approved pet from the public manifest.
|
||||
|
||||
Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to
|
||||
bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on
|
||||
any network/parse failure so callers can surface a clean message.
|
||||
"""
|
||||
global _cache
|
||||
|
||||
if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL:
|
||||
return _cache[1]
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError as exc: # pragma: no cover - httpx is a core dep
|
||||
raise ManifestError("httpx is required to fetch the petdex manifest") from exc
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
MANIFEST_URL,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
||||
raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc
|
||||
|
||||
pets = payload.get("pets") if isinstance(payload, dict) else None
|
||||
if not isinstance(pets, list):
|
||||
raise ManifestError("petdex manifest had no 'pets' array")
|
||||
|
||||
entries: list[ManifestEntry] = []
|
||||
for raw in pets:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
entry = ManifestEntry.from_dict(raw)
|
||||
if entry.slug and entry.spritesheet_url:
|
||||
entries.append(entry)
|
||||
|
||||
_cache = (time.monotonic(), entries)
|
||||
return entries
|
||||
|
||||
|
||||
def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None:
|
||||
"""Return the manifest entry for *slug*, or ``None`` if not listed."""
|
||||
slug = slug.strip().lower()
|
||||
for entry in fetch_manifest(timeout=timeout):
|
||||
if entry.slug.lower() == slug:
|
||||
return entry
|
||||
return None
|
||||
@@ -1,618 +0,0 @@
|
||||
"""Decode a pet spritesheet and encode frames for a terminal.
|
||||
|
||||
Shared by the base CLI (writes the escape bytes to its own stdout) and the
|
||||
TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the
|
||||
decode + capability-detection + protocol-encoding logic exists exactly once.
|
||||
|
||||
Supported output modes, in fidelity order:
|
||||
|
||||
- ``kitty`` — the kitty graphics protocol (kitty, Ghostty, WezTerm).
|
||||
- ``iterm`` — iTerm2 inline images (iTerm2, WezTerm).
|
||||
- ``sixel`` — DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, …).
|
||||
- ``unicode`` — 24-bit half-block downscale; works in any truecolor terminal.
|
||||
|
||||
Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the
|
||||
spritesheet is unavailable the renderer degrades to ``unicode`` text or an
|
||||
empty string rather than raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from agent.pet.constants import (
|
||||
DEFAULT_SCALE,
|
||||
FRAME_H,
|
||||
FRAME_W,
|
||||
FRAMES_PER_STATE,
|
||||
PetState,
|
||||
state_row_index,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Public render-mode names accepted by ``display.pet.render_mode``.
|
||||
RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Terminal capability detection
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def detect_terminal_graphics() -> str:
|
||||
"""Best-effort detection of the richest graphics protocol available.
|
||||
|
||||
Env-based (non-blocking — we never issue a DA1/terminal query that could
|
||||
hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` /
|
||||
``unicode``. Conservative: unknown terminals get ``unicode``, which works
|
||||
anywhere with truecolor.
|
||||
"""
|
||||
term = os.environ.get("TERM", "").lower()
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
||||
|
||||
# The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode
|
||||
# authoritatively but does NOT scrub the terminal env vars it inherits when
|
||||
# launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …).
|
||||
# Trusting those leaks emits an image protocol the embedded xterm.js can't
|
||||
# display — you get a blank frame. Inline images there are opt-in
|
||||
# (terminal.integrated.enableImages), so default to half-blocks, which
|
||||
# always render in its truecolor grid. Users who enabled images can pin
|
||||
# display.pet.render_mode explicitly.
|
||||
if term_program == "vscode":
|
||||
return "unicode"
|
||||
|
||||
# kitty graphics protocol
|
||||
if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term:
|
||||
return "kitty"
|
||||
if term_program in {"ghostty"}:
|
||||
return "kitty"
|
||||
|
||||
# WezTerm speaks both kitty and iterm; prefer kitty (richer placement).
|
||||
if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"):
|
||||
return "kitty"
|
||||
|
||||
# iTerm2 inline images
|
||||
if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"):
|
||||
return "iterm"
|
||||
|
||||
# sixel-capable terminals (env heuristics only)
|
||||
if term_program in {"mintty"} or "foot" in term or "mlterm" in term:
|
||||
return "sixel"
|
||||
if "sixel" in term:
|
||||
return "sixel"
|
||||
|
||||
return "unicode"
|
||||
|
||||
|
||||
def resolve_mode(configured: str | None, *, stream=None) -> str:
|
||||
"""Resolve the effective render mode from config + the environment.
|
||||
|
||||
``configured`` is ``display.pet.render_mode`` (``auto`` → detect). Returns
|
||||
``off`` when not attached to a TTY (no point emitting graphics into a pipe
|
||||
or logfile).
|
||||
"""
|
||||
mode = (configured or "auto").strip().lower()
|
||||
if mode not in RENDER_MODES:
|
||||
mode = "auto"
|
||||
if mode == "off":
|
||||
return "off"
|
||||
|
||||
stream = stream or sys.stdout
|
||||
try:
|
||||
if not (hasattr(stream, "isatty") and stream.isatty()):
|
||||
return "off"
|
||||
except (ValueError, OSError):
|
||||
return "off"
|
||||
|
||||
if mode == "auto":
|
||||
return detect_terminal_graphics()
|
||||
return mode
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Frame decoding
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _open_sheet(path: Path):
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(path)
|
||||
return img.convert("RGBA")
|
||||
|
||||
|
||||
# Max alpha at/below which a frame counts as blank padding. petdex sheets are
|
||||
# left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills
|
||||
# the trailing columns with fully transparent cells. Animating into one flashes
|
||||
# the pet blank, so we stop the row at the first such gap.
|
||||
_BLANK_ALPHA = 8
|
||||
|
||||
|
||||
def _frame_is_blank(frame) -> bool:
|
||||
"""True if *frame* has no meaningfully opaque pixel (transparent padding)."""
|
||||
return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _raw_frames(
|
||||
sheet_path: str,
|
||||
state_value: str,
|
||||
frame_w: int,
|
||||
frame_h: int,
|
||||
frames_per_state: int,
|
||||
) -> tuple:
|
||||
"""Cropped, padding-trimmed RGBA frames for one state row (unscaled).
|
||||
|
||||
Steps across the row until the first blank column so pets with ragged
|
||||
per-state frame counts never animate into empty padding. Cached; returns
|
||||
``()`` on any decode failure.
|
||||
"""
|
||||
try:
|
||||
sheet = _open_sheet(Path(sheet_path))
|
||||
cols = max(1, sheet.width // frame_w)
|
||||
rows = max(1, sheet.height // frame_h)
|
||||
row = state_row_index(state_value, rows)
|
||||
top = row * frame_h
|
||||
# Clamp the row to the sheet (some pets ship fewer rows than the 8 the
|
||||
# taxonomy reserves).
|
||||
if top + frame_h > sheet.height:
|
||||
top = max(0, sheet.height - frame_h)
|
||||
|
||||
frames = []
|
||||
for i in range(min(frames_per_state, cols)):
|
||||
left = i * frame_w
|
||||
frame = sheet.crop((left, top, left + frame_w, top + frame_h))
|
||||
if _frame_is_blank(frame):
|
||||
break # trailing transparent padding — real frames end here
|
||||
frames.append(frame)
|
||||
return tuple(frames)
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal
|
||||
logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc)
|
||||
return ()
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _frames_for(
|
||||
sheet_path: str,
|
||||
state_value: str,
|
||||
frame_w: int,
|
||||
frame_h: int,
|
||||
frames_per_state: int,
|
||||
scale_w: int,
|
||||
scale_h: int,
|
||||
):
|
||||
"""Return padding-trimmed RGBA frames for one state row, scaled.
|
||||
|
||||
Thin scaling layer over :func:`_raw_frames`; both are cached so repeated
|
||||
frame requests during animation are free.
|
||||
"""
|
||||
raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state)
|
||||
if not raw or (scale_w, scale_h) == (frame_w, frame_h):
|
||||
return list(raw)
|
||||
from PIL import Image
|
||||
|
||||
return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw]
|
||||
|
||||
|
||||
def state_frame_counts(
|
||||
sheet_path: str | Path,
|
||||
*,
|
||||
frame_w: int = FRAME_W,
|
||||
frame_h: int = FRAME_H,
|
||||
frames_per_state: int = FRAMES_PER_STATE,
|
||||
) -> dict[str, int]:
|
||||
"""Map each driven :class:`PetState` → its real (padding-trimmed) frame count.
|
||||
|
||||
The single source of truth for "how many frames does this state actually
|
||||
have?". The CLI/TUI consume the trimmed frame lists directly; the gateway
|
||||
ships this map to the desktop canvas, which steps its own loop.
|
||||
"""
|
||||
return {
|
||||
state.value: len(
|
||||
_raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state)
|
||||
)
|
||||
for state in PetState
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Encoders
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _png_bytes(frame) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
frame.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _kitty_apc(ctrl: str, data: str) -> str:
|
||||
"""Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces."""
|
||||
chunk = 4096
|
||||
if len(data) <= chunk:
|
||||
return f"\x1b_G{ctrl},m=0;{data}\x1b\\"
|
||||
out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"]
|
||||
rest = data[chunk:]
|
||||
while rest:
|
||||
piece, rest = rest[:chunk], rest[chunk:]
|
||||
out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
|
||||
"""Encode one frame via the kitty graphics protocol (transmit + display).
|
||||
|
||||
``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display
|
||||
box in terminal cells so successive frames overwrite the same area.
|
||||
"""
|
||||
ctrl = "f=100,a=T,q=2"
|
||||
if cell_cols:
|
||||
ctrl += f",c={cell_cols}"
|
||||
if cell_rows:
|
||||
ctrl += f",r={cell_rows}"
|
||||
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# kitty Unicode placeholders
|
||||
#
|
||||
# Ink (the TUI's React-for-terminal layer) owns the screen and measures every
|
||||
# cell's width, so it can't host raw kitty image escapes (no width to count,
|
||||
# clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the
|
||||
# grid-safe path: transmit the image once (q=2, virtual placement U=1), then the
|
||||
# host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose
|
||||
# foreground color encodes the image id. Ink counts those as width-1 text, so
|
||||
# layout stays correct and the terminal paints the image underneath.
|
||||
# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_KITTY_PLACEHOLDER = "\U0010eeee"
|
||||
|
||||
# Row/column diacritics, in order (index → diacritic). Verbatim from kitty's
|
||||
# gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is
|
||||
# the diacritic that encodes the number i; we only ever need the row index.
|
||||
_ROWCOL_DIACRITICS: tuple[int, ...] = (
|
||||
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A,
|
||||
0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365,
|
||||
0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F,
|
||||
0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
|
||||
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9,
|
||||
0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
|
||||
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6,
|
||||
0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
|
||||
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736,
|
||||
0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A,
|
||||
0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817,
|
||||
0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
|
||||
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951,
|
||||
0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD,
|
||||
0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C,
|
||||
0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
|
||||
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6,
|
||||
0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
|
||||
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF,
|
||||
0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
|
||||
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0,
|
||||
0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6,
|
||||
0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0,
|
||||
0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
|
||||
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1,
|
||||
0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9,
|
||||
0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2,
|
||||
0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
|
||||
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188,
|
||||
0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
|
||||
)
|
||||
|
||||
|
||||
def kitty_image_id(slug: str) -> int:
|
||||
"""Stable per-pet image id in ``[1, 0x7FFF]``.
|
||||
|
||||
The id is encoded in the placeholder's 24-bit foreground color, so it must
|
||||
be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it
|
||||
deterministic per slug (so re-renders reuse the same terminal-side image)
|
||||
while making collisions between two different pets unlikely.
|
||||
"""
|
||||
import zlib
|
||||
|
||||
return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1
|
||||
|
||||
|
||||
def kitty_color_hex(image_id: int) -> str:
|
||||
"""Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty."""
|
||||
return "#%06x" % (image_id & 0xFFFFFF)
|
||||
|
||||
|
||||
def kitty_placeholder_rows(cols: int, rows: int) -> list[str]:
|
||||
"""Build the placeholder text grid for an *rows*×*cols* image.
|
||||
|
||||
Each line is one row of the grid: the first cell carries the row diacritic
|
||||
(column defaults to 0), and the remaining ``cols-1`` bare placeholders let
|
||||
the terminal auto-increment the column. The foreground color (the image id)
|
||||
is applied by the caller / Ink, not embedded here.
|
||||
"""
|
||||
cols = max(1, cols)
|
||||
out: list[str] = []
|
||||
for r in range(max(1, rows)):
|
||||
idx = min(r, len(_ROWCOL_DIACRITICS) - 1)
|
||||
first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx])
|
||||
out.append(first + _KITTY_PLACEHOLDER * (cols - 1))
|
||||
return out
|
||||
|
||||
|
||||
def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str:
|
||||
"""Transmit a frame as a kitty *virtual* placement for Unicode placeholders.
|
||||
|
||||
``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it
|
||||
virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the
|
||||
terminal's OK/error replies that would otherwise corrupt the host app's
|
||||
output. Re-sending with the same ``i`` replaces the image, so the static
|
||||
placeholder cells animate underneath.
|
||||
"""
|
||||
ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2"
|
||||
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
|
||||
|
||||
|
||||
def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
|
||||
"""Encode one frame as an iTerm2 inline image (OSC 1337 File)."""
|
||||
payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii")
|
||||
size = len(payload)
|
||||
args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"]
|
||||
if cell_cols:
|
||||
args.append(f"width={cell_cols}")
|
||||
if cell_rows:
|
||||
args.append(f"height={cell_rows}")
|
||||
return f"\x1b]1337;File={';'.join(args)}:{payload}\x07"
|
||||
|
||||
|
||||
def _encode_sixel(frame) -> str:
|
||||
"""Encode one frame as DEC sixel.
|
||||
|
||||
Quantizes to an adaptive palette (≤255 colors) and emits the sixel band
|
||||
stream. Pillow has no sixel writer, so this is a compact hand-rolled
|
||||
encoder. Transparent pixels render as background (color register skipped).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
rgba = frame
|
||||
# Composite onto transparent-as-skip: track alpha to decide background.
|
||||
pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT)
|
||||
palette = pal.getpalette() or []
|
||||
px = pal.load()
|
||||
alpha = rgba.getchannel("A").load()
|
||||
w, h = pal.size
|
||||
|
||||
out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)]
|
||||
# Color register definitions (sixel uses 0..100 scale).
|
||||
used = sorted({px[x, y] for y in range(h) for x in range(w)})
|
||||
for idx in used:
|
||||
r = palette[idx * 3] if idx * 3 < len(palette) else 0
|
||||
g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0
|
||||
b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0
|
||||
out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255))
|
||||
|
||||
# Emit in 6-row bands.
|
||||
for band in range(0, h, 6):
|
||||
for color_idx in used:
|
||||
line = ["#%d" % color_idx]
|
||||
run_char = None
|
||||
run_len = 0
|
||||
|
||||
def flush():
|
||||
nonlocal run_char, run_len
|
||||
if run_char is None:
|
||||
return
|
||||
if run_len > 3:
|
||||
line.append("!%d%s" % (run_len, run_char))
|
||||
else:
|
||||
line.append(run_char * run_len)
|
||||
run_char, run_len = None, 0
|
||||
|
||||
for x in range(w):
|
||||
bits = 0
|
||||
for bit in range(6):
|
||||
y = band + bit
|
||||
if y < h and alpha[x, y] > 32 and px[x, y] == color_idx:
|
||||
bits |= 1 << bit
|
||||
ch = chr(63 + bits)
|
||||
if ch == run_char:
|
||||
run_len += 1
|
||||
else:
|
||||
flush()
|
||||
run_char, run_len = ch, 1
|
||||
flush()
|
||||
out.append("".join(line) + "$") # carriage return within band
|
||||
out.append("-") # next band
|
||||
out.append("\x1b\\")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
_HALF_BLOCK = "▀"
|
||||
|
||||
# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples.
|
||||
Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]]
|
||||
|
||||
|
||||
def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]:
|
||||
"""Downscale a frame to a grid of half-block cells.
|
||||
|
||||
Each cell pairs a top and bottom pixel so one terminal row encodes two
|
||||
pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` — the
|
||||
framework-neutral representation shared by the ANSI encoder (CLI) and the
|
||||
structured ``cells`` API (Ink).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
target_cols = max(4, target_cols)
|
||||
aspect = frame.height / max(1, frame.width)
|
||||
target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2)
|
||||
small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA")
|
||||
px = small.load()
|
||||
|
||||
grid: list[list[Cell]] = []
|
||||
for y in range(0, target_rows, 2):
|
||||
row: list[Cell] = []
|
||||
for x in range(target_cols):
|
||||
top = px[x, y]
|
||||
bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0)
|
||||
row.append((top, bottom))
|
||||
grid.append(row)
|
||||
return grid
|
||||
|
||||
|
||||
def _encode_unicode(frame, *, target_cols: int) -> str:
|
||||
"""Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels)."""
|
||||
lines: list[str] = []
|
||||
for row in _downscale_cells(frame, target_cols=target_cols):
|
||||
cells: list[str] = []
|
||||
for (tr, tg, tb, ta), (br, bg, bb, ba) in row:
|
||||
if ta < 32 and ba < 32:
|
||||
cells.append("\x1b[0m ") # fully transparent → blank
|
||||
continue
|
||||
cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}")
|
||||
lines.append("".join(cells) + "\x1b[0m")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Public renderer
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PetRenderer:
|
||||
"""Holds a pet's spritesheet and yields encoded frames per (state, index).
|
||||
|
||||
Construct once per pet, then call :meth:`frame` on an animation timer.
|
||||
Cheap to call repeatedly — decoded frames are cached.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spritesheet: str | Path,
|
||||
*,
|
||||
mode: str = "unicode",
|
||||
scale: float = DEFAULT_SCALE,
|
||||
unicode_cols: int = 20,
|
||||
frame_w: int = FRAME_W,
|
||||
frame_h: int = FRAME_H,
|
||||
frames_per_state: int = FRAMES_PER_STATE,
|
||||
) -> None:
|
||||
self.spritesheet = str(spritesheet)
|
||||
self.mode = mode if mode in RENDER_MODES else "unicode"
|
||||
self.scale = scale
|
||||
self.unicode_cols = unicode_cols
|
||||
self.frame_w = frame_w
|
||||
self.frame_h = frame_h
|
||||
self.frames_per_state = frames_per_state
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.mode != "off" and Path(self.spritesheet).is_file()
|
||||
|
||||
def frame_count(self, state: PetState | str) -> int:
|
||||
return len(self._frames(state))
|
||||
|
||||
def _frames(self, state: PetState | str):
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
scale_w = max(1, int(self.frame_w * self.scale))
|
||||
scale_h = max(1, int(self.frame_h * self.scale))
|
||||
return _frames_for(
|
||||
self.spritesheet,
|
||||
value,
|
||||
self.frame_w,
|
||||
self.frame_h,
|
||||
self.frames_per_state,
|
||||
scale_w,
|
||||
scale_h,
|
||||
)
|
||||
|
||||
def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]:
|
||||
"""Return one frame as a half-block cell grid (framework-neutral).
|
||||
|
||||
Used by the TUI, which renders the grid with native Ink color props
|
||||
instead of raw ANSI. Returns ``[]`` when no frame is available.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return []
|
||||
frame = frames[index % len(frames)]
|
||||
return _downscale_cells(frame, target_cols=cols or self.unicode_cols)
|
||||
|
||||
def _cell_box(self, frame) -> tuple[int, int]:
|
||||
"""Terminal cell box for a scaled frame (~8×16 px per cell).
|
||||
|
||||
Must match :meth:`frame` graphics sizing — kitty stretches the image to
|
||||
fill ``c``×``r`` cells, so these must reflect the scaled pixel
|
||||
dimensions, not a native-aspect column count (that upscales small pets).
|
||||
"""
|
||||
return max(1, frame.width // 8), max(1, frame.height // 16)
|
||||
|
||||
def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None:
|
||||
"""Build the kitty Unicode-placeholder payload for one state.
|
||||
|
||||
Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a
|
||||
list of transmit escapes (one per animation frame, all reusing
|
||||
``image_id``) and ``placeholder`` is the static text grid Ink paints.
|
||||
Placement geometry is derived from the scaled frame pixels (via
|
||||
:meth:`_cell_box`), not ``unicode_cols`` — kitty upscales to fill
|
||||
``c``×``r`` cells. ``None`` when no frame is available.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return None
|
||||
cols, rows = self._cell_box(frames[0])
|
||||
return {
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"placeholder": kitty_placeholder_rows(cols, rows),
|
||||
"frames": [
|
||||
_encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames
|
||||
],
|
||||
}
|
||||
|
||||
def frame(self, state: PetState | str, index: int) -> str:
|
||||
"""Return the encoded escape string for one frame, or ``""``.
|
||||
|
||||
``index`` is taken modulo the available frame count so callers can pass
|
||||
a free-running counter.
|
||||
"""
|
||||
if self.mode == "off":
|
||||
return ""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return ""
|
||||
frame = frames[index % len(frames)]
|
||||
cell_cols, cell_rows = self._cell_box(frame)
|
||||
|
||||
try:
|
||||
if self.mode == "kitty":
|
||||
return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows)
|
||||
if self.mode == "iterm":
|
||||
return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows)
|
||||
if self.mode == "sixel":
|
||||
return _encode_sixel(frame)
|
||||
return _encode_unicode(frame, target_cols=self.unicode_cols)
|
||||
except Exception as exc: # noqa: BLE001 - degrade silently
|
||||
logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc)
|
||||
return ""
|
||||
|
||||
|
||||
def build_renderer(
|
||||
spritesheet: str | Path,
|
||||
*,
|
||||
configured_mode: str | None = None,
|
||||
scale: float = DEFAULT_SCALE,
|
||||
unicode_cols: int = 20,
|
||||
stream=None,
|
||||
) -> PetRenderer:
|
||||
"""Convenience factory: resolve the mode from config+env, then construct."""
|
||||
mode = resolve_mode(configured_mode, stream=stream)
|
||||
return PetRenderer(
|
||||
spritesheet,
|
||||
mode=mode,
|
||||
scale=scale,
|
||||
unicode_cols=unicode_cols,
|
||||
)
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Map agent activity → a :class:`PetState`.
|
||||
|
||||
This is the one place the "what is the agent doing right now?" → "which
|
||||
animation row?" decision lives. Each surface feeds it the signals it already
|
||||
tracks:
|
||||
|
||||
- CLI — ``KawaiiSpinner`` waiting/thinking state + tool outcomes.
|
||||
- TUI — gateway ``tool.start/complete`` + ``message.delta/complete`` events.
|
||||
- Desktop — the ``$busy``/``$awaitingResponse``/tool-event nanostores
|
||||
(re-implemented in TS, but mirroring this priority order).
|
||||
|
||||
Keeping the priority order here (and documenting it) lets the TypeScript
|
||||
mirror stay faithful without a second design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from agent.pet.constants import PetState
|
||||
|
||||
|
||||
def todos_all_done(todos: Iterable[Any] | None) -> bool:
|
||||
"""True iff there's ≥1 todo and every one is completed/cancelled.
|
||||
|
||||
The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors
|
||||
the TUI's ``isTodoDone`` so the trigger is defined once across surfaces.
|
||||
Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr.
|
||||
"""
|
||||
items = list(todos or [])
|
||||
if not items:
|
||||
return False
|
||||
|
||||
def _status(t: Any) -> Any:
|
||||
return t.get("status") if isinstance(t, dict) else getattr(t, "status", None)
|
||||
|
||||
return all(_status(t) in ("completed", "cancelled") for t in items)
|
||||
|
||||
|
||||
def derive_pet_state(
|
||||
*,
|
||||
busy: bool = False,
|
||||
awaiting_input: bool = False,
|
||||
error: bool = False,
|
||||
celebrate: bool = False,
|
||||
just_completed: bool = False,
|
||||
tool_running: bool = False,
|
||||
reasoning: bool = False,
|
||||
) -> PetState:
|
||||
"""Resolve the animation state from coarse activity signals.
|
||||
|
||||
Priority (highest first) — only one row can show at a time, so the most
|
||||
salient signal wins:
|
||||
|
||||
1. ``error`` → ``FAILED`` (a tool/turn just failed)
|
||||
2. ``celebrate`` → ``JUMP`` (explicit success beat, e.g. todos done)
|
||||
3. ``just_completed`` → ``WAVE`` (turn finished cleanly / greeting)
|
||||
4. ``awaiting_input`` → ``WAITING`` (blocked on the user — a clarify/approval
|
||||
prompt is open; this outranks the in-flight signals below because the turn
|
||||
is paused on *you*, even though a tool is technically mid-call)
|
||||
5. ``tool_running`` → ``RUN`` (a tool is executing)
|
||||
6. ``reasoning`` → ``REVIEW`` (model is thinking / reading)
|
||||
7. ``busy`` → ``RUN`` (turn in flight, unspecified work)
|
||||
8. otherwise → ``IDLE``
|
||||
"""
|
||||
if error:
|
||||
return PetState.FAILED
|
||||
if celebrate:
|
||||
return PetState.JUMP
|
||||
if just_completed:
|
||||
return PetState.WAVE
|
||||
if awaiting_input:
|
||||
return PetState.WAITING
|
||||
if tool_running:
|
||||
return PetState.RUN
|
||||
if reasoning:
|
||||
return PetState.REVIEW
|
||||
if busy:
|
||||
return PetState.RUN
|
||||
return PetState.IDLE
|
||||
@@ -1,503 +0,0 @@
|
||||
"""On-disk pet store — install / list / resolve pets.
|
||||
|
||||
Pets live under ``get_hermes_home()/pets/<slug>/`` so every profile gets its
|
||||
own set (we deliberately do **not** reuse petdex's ``~/.codex/pets`` default —
|
||||
that's owned by the petdex npm CLI and isn't profile-aware). Each installed
|
||||
pet directory holds:
|
||||
|
||||
pets/<slug>/
|
||||
pet.json # {id, displayName, description, spritesheetPath}
|
||||
spritesheet.webp # (or .png)
|
||||
|
||||
The active pet is resolved from the caller-supplied ``display.pet.slug`` config
|
||||
value (falling back to the first installed pet), so this module stays free of
|
||||
the config loader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DOWNLOAD_TIMEOUT = 60.0
|
||||
|
||||
|
||||
class PetStoreError(RuntimeError):
|
||||
"""Raised on install/IO failures."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstalledPet:
|
||||
"""A pet present on disk."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
description: str
|
||||
directory: Path
|
||||
spritesheet: Path
|
||||
created_by: str = "" # "generator" for pets hatched locally; "" for petdex installs
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
return self.spritesheet.is_file()
|
||||
|
||||
@property
|
||||
def generated(self) -> bool:
|
||||
return self.created_by == "generator"
|
||||
|
||||
|
||||
def pets_dir() -> Path:
|
||||
"""Return the profile-scoped pets directory (created on demand)."""
|
||||
path = get_hermes_home() / "pets"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _read_pet_json(directory: Path) -> dict:
|
||||
pet_json = directory / "pet.json"
|
||||
if not pet_json.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(pet_json.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.debug("unreadable pet.json in %s: %s", directory, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_spritesheet(directory: Path, meta: dict) -> Path:
|
||||
"""Find the spritesheet for a pet dir.
|
||||
|
||||
Honors ``spritesheetPath`` from pet.json, else probes the conventional
|
||||
filenames (``spritesheet.{webp,png}`` and petdex R2's ``sprite.webp``).
|
||||
"""
|
||||
declared = str(meta.get("spritesheetPath", "") or "").strip()
|
||||
if declared:
|
||||
candidate = directory / declared
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
for name in ("spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"):
|
||||
candidate = directory / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
# Default expectation even if missing, so callers get a stable path.
|
||||
return directory / "spritesheet.webp"
|
||||
|
||||
|
||||
def _safe_slug(slug: str) -> str:
|
||||
"""Normalize a slug to a single bare path segment.
|
||||
|
||||
Pet slugs index into ``pets_dir()/<slug>/`` for load/remove, so a value
|
||||
carrying path separators (``../``, absolute paths) could escape the pets
|
||||
directory. Strip every separator and reject ``.``/``..`` so callers can
|
||||
only ever name a direct child of the pets directory.
|
||||
"""
|
||||
segment = Path(str(slug).strip()).name
|
||||
if segment in ("", ".", ".."):
|
||||
return ""
|
||||
return segment
|
||||
|
||||
|
||||
def load_pet(slug: str) -> InstalledPet | None:
|
||||
"""Return the :class:`InstalledPet` for *slug*, or ``None`` if absent."""
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
return None
|
||||
directory = pets_dir() / slug
|
||||
if not directory.is_dir():
|
||||
return None
|
||||
meta = _read_pet_json(directory)
|
||||
return InstalledPet(
|
||||
slug=slug,
|
||||
display_name=str(meta.get("displayName", "") or slug),
|
||||
description=str(meta.get("description", "") or ""),
|
||||
directory=directory,
|
||||
spritesheet=_resolve_spritesheet(directory, meta),
|
||||
created_by=str(meta.get("createdBy", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
def installed_pets() -> list[InstalledPet]:
|
||||
"""Return every installed pet (dirs containing a usable spritesheet)."""
|
||||
out: list[InstalledPet] = []
|
||||
for child in sorted(pets_dir().iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
pet = load_pet(child.name)
|
||||
if pet and pet.exists:
|
||||
out.append(pet)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None:
|
||||
"""Resolve which pet to display.
|
||||
|
||||
Precedence: the configured slug (``display.pet.slug``) if it's installed,
|
||||
otherwise the first installed pet alphabetically, otherwise ``None``.
|
||||
"""
|
||||
if configured_slug:
|
||||
pet = load_pet(configured_slug.strip())
|
||||
if pet and pet.exists:
|
||||
return pet
|
||||
pets = installed_pets()
|
||||
return pets[0] if pets else None
|
||||
|
||||
|
||||
def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet:
|
||||
"""Download *slug* from the manifest into the pets directory.
|
||||
|
||||
Idempotent: a fully-installed pet is returned as-is unless *force*. Raises
|
||||
:class:`PetStoreError` / :class:`~agent.pet.manifest.ManifestError` on
|
||||
failure.
|
||||
"""
|
||||
from agent.pet.manifest import find_entry
|
||||
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
raise PetStoreError("invalid pet slug")
|
||||
existing = load_pet(slug)
|
||||
if existing and existing.exists and not force:
|
||||
return existing
|
||||
|
||||
entry = find_entry(slug, timeout=timeout)
|
||||
if entry is None:
|
||||
raise PetStoreError(f"pet '{slug}' is not in the petdex manifest")
|
||||
|
||||
# Host-pin every asset URL to petdex. The manifest is trusted (HTTPS from
|
||||
# petdex.dev), but pin the asset hosts too so a compromised/spoofed manifest
|
||||
# can't redirect the download at an arbitrary host. Matches thumbnail_png.
|
||||
if not _is_petdex_host(entry.spritesheet_url):
|
||||
raise PetStoreError(f"refusing non-petdex spritesheet host for '{slug}'")
|
||||
|
||||
directory = pets_dir() / slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp"
|
||||
sprite_path = directory / f"spritesheet{sprite_ext}"
|
||||
|
||||
_download(entry.spritesheet_url, sprite_path, timeout=timeout)
|
||||
|
||||
# Fetch the upstream pet.json if present; otherwise synthesize a minimal
|
||||
# one so the local layout is self-describing.
|
||||
meta: dict = {}
|
||||
if entry.pet_json_url and _is_petdex_host(entry.pet_json_url):
|
||||
try:
|
||||
meta = _download_json(entry.pet_json_url, timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - non-fatal, fall back below
|
||||
logger.debug("pet.json fetch failed for %s: %s", slug, exc)
|
||||
if not isinstance(meta, dict) or not meta:
|
||||
meta = {"id": slug, "displayName": entry.display_name, "description": ""}
|
||||
meta["spritesheetPath"] = sprite_path.name
|
||||
meta.setdefault("id", slug)
|
||||
meta.setdefault("displayName", entry.display_name)
|
||||
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
pet = load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
raise PetStoreError(f"install of '{slug}' did not produce a spritesheet")
|
||||
return pet
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Lowercase, hyphenate, and strip a display name into a filesystem slug."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
|
||||
return slug or "pet"
|
||||
|
||||
|
||||
def unique_slug(name: str) -> str:
|
||||
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
|
||||
base = slugify(name)
|
||||
slug = base
|
||||
counter = 2
|
||||
while (pets_dir() / slug).exists():
|
||||
slug = f"{base}-{counter}"
|
||||
counter += 1
|
||||
return slug
|
||||
|
||||
|
||||
def _write_spritesheet(source, dest: Path) -> None:
|
||||
"""Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*."""
|
||||
if isinstance(source, (bytes, bytearray)):
|
||||
dest.write_bytes(bytes(source))
|
||||
return
|
||||
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(source, (str, Path)):
|
||||
with Image.open(source) as opened:
|
||||
image = opened.convert("RGBA")
|
||||
else:
|
||||
image = source.convert("RGBA")
|
||||
image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
|
||||
|
||||
def register_local_pet(
|
||||
spritesheet,
|
||||
*,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
) -> InstalledPet:
|
||||
"""Write a locally-generated pet into the store and return it.
|
||||
|
||||
*spritesheet* may be a PIL image, raw WebP/PNG bytes, or a path. The pet
|
||||
appears in :func:`installed_pets` immediately, and because :func:`install_pet`
|
||||
returns an already-on-disk pet before consulting the manifest, it can be
|
||||
adopted (``pet.select`` / ``/pet <slug>``) without a manifest entry.
|
||||
"""
|
||||
slug = slugify(slug)
|
||||
directory = pets_dir() / slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
sprite_path = directory / "spritesheet.webp"
|
||||
try:
|
||||
_write_spritesheet(spritesheet, sprite_path)
|
||||
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
||||
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
|
||||
|
||||
meta = {
|
||||
"id": slug,
|
||||
"displayName": display_name or slug,
|
||||
"description": description or "",
|
||||
"spritesheetPath": sprite_path.name,
|
||||
"createdBy": "generator",
|
||||
}
|
||||
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
pet = load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
raise PetStoreError(f"register of generated pet '{slug}' did not produce a spritesheet")
|
||||
return pet
|
||||
|
||||
|
||||
def export_pet(slug: str) -> tuple[str, bytes]:
|
||||
"""Zip an installed pet's folder (pet.json + spritesheet) → (filename, bytes).
|
||||
|
||||
Dotfiles (cached thumbs, backups) are skipped so the archive is a clean,
|
||||
re-importable pet package. Raises :class:`PetStoreError` if not installed.
|
||||
"""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
root = pets_dir()
|
||||
directory = root / slug.strip()
|
||||
# Guard against traversal: the target must be a direct child of pets_dir.
|
||||
if directory.resolve().parent != root.resolve() or not directory.is_dir():
|
||||
raise PetStoreError(f"pet '{slug}' is not installed")
|
||||
|
||||
name = directory.name
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_file() and not path.name.startswith("."):
|
||||
archive.write(path, f"{name}/{path.name}")
|
||||
return f"{name}.zip", buf.getvalue()
|
||||
|
||||
|
||||
_THUMB_FRAME_W = 192
|
||||
_THUMB_FRAME_H = 208
|
||||
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
|
||||
|
||||
|
||||
def _thumbs_dir() -> Path:
|
||||
path = pets_dir() / ".thumbs"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _is_petdex_host(url: str) -> bool:
|
||||
"""True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
except ValueError:
|
||||
return False
|
||||
return host == "petdex.dev" or host.endswith(".petdex.dev")
|
||||
|
||||
|
||||
def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None:
|
||||
"""Return a small idle-frame PNG for *slug*, cached on disk.
|
||||
|
||||
Crops the top-left (idle, frame 0) cell of the spritesheet and downsamples
|
||||
it to a thumbnail. Source preference: an installed spritesheet on disk, else
|
||||
*source_url* — but only when it points at petdex (so the gateway never
|
||||
fetches an arbitrary client-supplied URL). Returns ``None`` when there's no
|
||||
usable source or Pillow/network fails; callers render a placeholder.
|
||||
|
||||
Doing this server-side sidesteps the renderer's CSP / R2 hotlink limits that
|
||||
break a direct ``<img src=cdn>`` and lets the result ride the authenticated
|
||||
gateway as a same-origin data URL.
|
||||
"""
|
||||
slug = slug.strip()
|
||||
if not slug:
|
||||
return None
|
||||
|
||||
cache = _thumbs_dir() / f"{slug}.png"
|
||||
if cache.is_file():
|
||||
try:
|
||||
return cache.read_bytes()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
sheet_bytes: bytes | None = None
|
||||
pet = load_pet(slug)
|
||||
if pet and pet.exists:
|
||||
try:
|
||||
sheet_bytes = pet.spritesheet.read_bytes()
|
||||
except OSError:
|
||||
sheet_bytes = None
|
||||
|
||||
if sheet_bytes is None and source_url and _is_petdex_host(source_url):
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(
|
||||
source_url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
sheet_bytes = resp.content
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder
|
||||
logger.debug("thumb fetch failed for %s: %s", slug, exc)
|
||||
|
||||
if not sheet_bytes:
|
||||
return None
|
||||
|
||||
try:
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(sheet_bytes)) as im:
|
||||
frame = im.convert("RGBA").crop(
|
||||
(0, 0, min(_THUMB_FRAME_W, im.width), min(_THUMB_FRAME_H, im.height))
|
||||
)
|
||||
height = round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W)
|
||||
frame = frame.resize((_THUMB_W, height), Image.NEAREST)
|
||||
buf = io.BytesIO()
|
||||
frame.save(buf, format="PNG")
|
||||
data = buf.getvalue()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("thumb crop failed for %s: %s", slug, exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
cache.write_bytes(data)
|
||||
except OSError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def remove_pet(slug: str) -> bool:
|
||||
"""Delete an installed pet directory. Returns True if anything was removed."""
|
||||
import shutil
|
||||
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
return False
|
||||
|
||||
# The cached thumbnail lives in pets/.thumbs/<slug>.png — OUTSIDE the pet
|
||||
# dir, so rmtree won't catch it. Drop it too, or a later pet that reuses this
|
||||
# slug renders this one's stale thumbnail.
|
||||
try:
|
||||
(_thumbs_dir() / f"{slug}.png").unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
directory = pets_dir() / slug
|
||||
if not directory.is_dir():
|
||||
return False
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
return not directory.exists()
|
||||
|
||||
|
||||
def rename_pet(slug: str, display_name: str) -> str | None:
|
||||
"""Rename a pet's ``displayName`` AND realign its slug/dir to match.
|
||||
|
||||
Generated pets are hatched under a provisional, prompt-derived slug; when
|
||||
the user names the pet on the reveal screen we make that name the real
|
||||
identity so lists/subtitles show what they typed, not the prompt. The dir is
|
||||
renamed to ``slugify(name)`` (and the cached thumbnail moved alongside it)
|
||||
whenever that yields a free, different slug — otherwise the slug is left as
|
||||
is. Returns the resulting slug on success, or ``None`` on failure.
|
||||
"""
|
||||
slug = _safe_slug(slug)
|
||||
display_name = (display_name or "").strip()
|
||||
if not slug or not display_name:
|
||||
return None
|
||||
directory = pets_dir() / slug
|
||||
pet_json = directory / "pet.json"
|
||||
if not pet_json.is_file():
|
||||
return None
|
||||
try:
|
||||
meta = json.loads(pet_json.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
meta["displayName"] = display_name
|
||||
|
||||
new_slug = slug
|
||||
desired = slugify(display_name)
|
||||
if desired and desired != slug and not (pets_dir() / desired).exists():
|
||||
try:
|
||||
directory.rename(pets_dir() / desired)
|
||||
try:
|
||||
(_thumbs_dir() / f"{slug}.png").rename(_thumbs_dir() / f"{desired}.png")
|
||||
except OSError:
|
||||
pass
|
||||
directory = pets_dir() / desired
|
||||
pet_json = directory / "pet.json"
|
||||
new_slug = desired
|
||||
meta["id"] = new_slug
|
||||
except OSError:
|
||||
new_slug = slug # keep the provisional slug if the move fails
|
||||
|
||||
try:
|
||||
pet_json.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
return new_slug
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, *, timeout: float) -> None:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
with httpx.stream(
|
||||
"GET",
|
||||
url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
with tmp.open("wb") as fh:
|
||||
for chunk in resp.iter_bytes():
|
||||
fh.write(chunk)
|
||||
tmp.replace(dest)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise PetStoreError(f"download failed for {url}: {exc}") from exc
|
||||
|
||||
|
||||
def _download_json(url: str, *, timeout: float) -> dict:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(
|
||||
url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
@@ -26,7 +26,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
from utils import base_url_hostname, normalize_proxy_url
|
||||
|
||||
@@ -142,46 +142,6 @@ def _get_proxy_for_base_url(base_url: Optional[str]) -> Optional[str]:
|
||||
return proxy
|
||||
|
||||
|
||||
def build_keepalive_http_client(
|
||||
base_url: str = "",
|
||||
*,
|
||||
async_mode: bool = False,
|
||||
) -> Optional[Any]:
|
||||
"""Build an httpx client for OpenAI SDK calls with env-only proxy policy.
|
||||
|
||||
Uses explicit ``HTTPS_PROXY`` / ``NO_PROXY`` env vars via
|
||||
``_get_proxy_for_base_url``. A custom transport disables httpx's default
|
||||
``trust_env`` path, so macOS system proxy settings from
|
||||
``urllib.request.getproxies()`` (which omit the ExceptionsList) are not
|
||||
applied. Mirrors ``AIAgent._build_keepalive_http_client``.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
import socket
|
||||
|
||||
if "api.githubcopilot.com" in str(base_url or "").lower():
|
||||
client_cls = httpx.AsyncClient if async_mode else httpx.Client
|
||||
return client_cls()
|
||||
|
||||
sock_opts = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
|
||||
if hasattr(socket, "TCP_KEEPIDLE"):
|
||||
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30))
|
||||
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10))
|
||||
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3))
|
||||
elif hasattr(socket, "TCP_KEEPALIVE"):
|
||||
sock_opts.append((socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 30))
|
||||
|
||||
proxy = _get_proxy_for_base_url(base_url)
|
||||
transport_cls = httpx.AsyncHTTPTransport if async_mode else httpx.HTTPTransport
|
||||
client_cls = httpx.AsyncClient if async_mode else httpx.Client
|
||||
return client_cls(
|
||||
transport=transport_cls(socket_options=sock_opts),
|
||||
proxy=proxy,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _install_safe_stdio() -> None:
|
||||
"""Wrap stdout/stderr so best-effort console output cannot crash the agent."""
|
||||
for stream_name in ("stdout", "stderr"):
|
||||
@@ -204,5 +164,4 @@ __all__ = [
|
||||
"_install_safe_stdio",
|
||||
"_get_proxy_from_env",
|
||||
"_get_proxy_for_base_url",
|
||||
"build_keepalive_http_client",
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user