Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17df5ea573 | ||
|
|
0db227a6d8 | ||
|
|
49f7a0b456 | ||
|
|
74ab798b49 |
+1
-5
@@ -66,12 +66,8 @@ 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.
|
||||
# Desktop app source (Tauri/Electron); never installed in the container
|
||||
apps/
|
||||
!apps/shared/
|
||||
!apps/shared/**
|
||||
|
||||
# Test suite — not shipped in production images
|
||||
tests/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -20,7 +20,6 @@ permissions:
|
||||
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 }}
|
||||
@@ -33,7 +32,6 @@ jobs:
|
||||
# (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 }}
|
||||
@@ -55,15 +53,11 @@ jobs:
|
||||
# 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
|
||||
@@ -71,49 +65,35 @@ jobs:
|
||||
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
|
||||
@@ -124,7 +104,7 @@ jobs:
|
||||
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
|
||||
|
||||
osv-scanner:
|
||||
name: OSV scan
|
||||
needs: detect
|
||||
uses: ./.github/workflows/osv-scanner.yml
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@@ -147,8 +127,6 @@ jobs:
|
||||
- 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:
|
||||
@@ -165,67 +143,3 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
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/**'
|
||||
|
||||
# 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).
|
||||
pull_request:
|
||||
|
||||
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
|
||||
|
||||
# The image build + smoke test + integration tests run ONLY on
|
||||
# push-to-main and release — never on PRs. They are the heaviest jobs
|
||||
# in CI (~15-45 min) and a broken build surfaces on the main push (and
|
||||
# is gated pre-merge by docker-lint + uv-lockfile-check). Every step
|
||||
# below is skipped on PRs, so the job still reports green and the
|
||||
# required check never hangs.
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name != 'pull_request'
|
||||
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)
|
||||
if: github.event_name != 'pull_request'
|
||||
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
|
||||
if: github.event_name != 'pull_request'
|
||||
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`` — 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)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Set up Python 3.11 (for docker tests)
|
||||
if: github.event_name != 'pull_request'
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install Python dependencies (for docker tests)
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
# ``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 pip install -e ".[dev]"
|
||||
|
||||
- name: Run docker integration tests
|
||||
if: github.event_name != 'pull_request'
|
||||
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
|
||||
|
||||
# arm64 build runs only on push-to-main and release (see build-amd64).
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name != 'pull_request'
|
||||
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)
|
||||
if: github.event_name != 'pull_request'
|
||||
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, then push
|
||||
# by digest below. Reads AND writes the registry-backed cache so the
|
||||
# push reuses layers from this build and the next build starts warm.
|
||||
#
|
||||
# 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, 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
|
||||
if: github.event_name != 'pull_request'
|
||||
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 }}
|
||||
@@ -37,7 +37,7 @@ 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
|
||||
@@ -109,6 +109,46 @@ jobs:
|
||||
--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: inputs.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,7 +164,7 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+36
-38
@@ -2,11 +2,6 @@ name: Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
slice_count:
|
||||
description: Number of parallel test slices
|
||||
type: number
|
||||
default: 8
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -17,11 +12,13 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
name: "Generate slices"
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
slice: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -30,26 +27,13 @@ jobs:
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
# main always writes a new suffix, but jobs pick the latest one with the same prefix
|
||||
# quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses
|
||||
# If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys.
|
||||
# If there are no exact matches, the action searches for partial matches of the restore keys.
|
||||
# When the action finds a partial match, the most recent cache is restored to the path directory.
|
||||
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) }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -65,7 +49,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
@@ -94,19 +78,33 @@ jobs:
|
||||
# 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: ""
|
||||
@@ -116,7 +114,7 @@ jobs:
|
||||
- name: Upload per-slice durations
|
||||
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
|
||||
|
||||
@@ -175,7 +173,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Check TypeScript
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -23,7 +22,8 @@ jobs:
|
||||
# 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
|
||||
-
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||
@@ -35,7 +35,6 @@ jobs:
|
||||
# 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
|
||||
@@ -45,7 +44,8 @@ jobs:
|
||||
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
|
||||
-
|
||||
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
|
||||
@@ -63,7 +63,7 @@ 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
|
||||
|
||||
# `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 +100,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."
|
||||
|
||||
@@ -137,9 +137,3 @@ 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/
|
||||
|
||||
@@ -123,17 +123,6 @@ conservative at the waist.
|
||||
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)
|
||||
|
||||
@@ -491,7 +480,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:
|
||||
|
||||
@@ -794,24 +783,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, …)
|
||||
@@ -1289,22 +1260,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
|
||||
|
||||
|
||||
+3
-27
@@ -85,23 +85,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
|
||||
@@ -149,20 +132,13 @@ 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.
|
||||
|
||||
```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]"
|
||||
|
||||
+14
-18
@@ -119,9 +119,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
|
||||
@@ -187,19 +184,12 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
|
||||
# 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
|
||||
|
||||
# ---------- 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 . .
|
||||
|
||||
# ---------- Permissions ----------
|
||||
# Link hermes-agent itself (editable). Deps are already installed in the
|
||||
@@ -207,15 +197,19 @@ COPY --link --chmod=a+rX,go-w . .
|
||||
# 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).
|
||||
|
||||
# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
|
||||
# instances must not be able to self-edit the installed source or venv; user
|
||||
# data, skills, plugins, config, logs, and dashboard uploads live under
|
||||
# /opt/data instead. Root can still repair the image during build/boot, but
|
||||
# supervised Hermes processes drop to the non-root hermes user.
|
||||
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
|
||||
printf 'docker\n' > /opt/hermes/.install_method && \
|
||||
chown -R root:root /opt/hermes && \
|
||||
chmod -R a+rX /opt/hermes && \
|
||||
chmod -R a-w /opt/hermes
|
||||
# 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
|
||||
@@ -242,11 +236,13 @@ 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; \
|
||||
chmod u+w /opt/hermes && \
|
||||
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
|
||||
chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
|
||||
fi
|
||||
|
||||
# ---------- s6-overlay service wiring ----------
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
|
||||
|
||||
Use any model you want — [Nous Portal](https://portal.nousresearch.com), OpenRouter, 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>
|
||||
@@ -232,14 +232,10 @@ scripts/run_tests.sh
|
||||
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.
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
+4
-52
@@ -722,50 +722,10 @@ def init_agent(
|
||||
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 = MoAClient(agent.model or "default")
|
||||
agent._client_kwargs = {}
|
||||
agent.api_key = api_key or "moa-virtual-provider"
|
||||
agent.base_url = "moa://local"
|
||||
agent.base_url = base_url or "moa://local"
|
||||
if not agent.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with MoA preset: {agent.model}")
|
||||
elif agent.api_mode == "bedrock_converse":
|
||||
@@ -1307,12 +1267,6 @@ 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
|
||||
@@ -1676,10 +1630,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).
|
||||
|
||||
@@ -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
|
||||
@@ -783,30 +775,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
|
||||
@@ -1078,34 +1046,6 @@ 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
|
||||
@@ -1281,11 +1221,7 @@ 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"
|
||||
dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
|
||||
|
||||
# Redact secrets before persisting/printing. This dump captures the
|
||||
# full request body (system prompt, tool defs, context-embedded
|
||||
@@ -1484,15 +1420,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)
|
||||
@@ -1572,10 +1499,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
|
||||
@@ -1600,36 +1523,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
|
||||
|
||||
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,
|
||||
@@ -2209,21 +2104,8 @@ def looks_like_codex_intermediate_ack(
|
||||
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
|
||||
|
||||
@@ -2276,67 +2158,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_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
|
||||
|
||||
|
||||
|
||||
|
||||
+25
-97
@@ -673,9 +673,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
|
||||
@@ -760,12 +757,6 @@ def build_anthropic_client(
|
||||
_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 +852,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])},
|
||||
)
|
||||
|
||||
@@ -926,72 +914,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 +1034,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
|
||||
|
||||
+32
-235
@@ -102,7 +102,6 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
|
||||
from agent.process_bootstrap import build_keepalive_http_client
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars
|
||||
@@ -110,23 +109,6 @@ from utils import base_url_host_matches, base_url_hostname, env_float, model_for
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _openai_http_client_kwargs(
|
||||
base_url: Optional[str],
|
||||
*,
|
||||
async_mode: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Inject keepalive httpx client with env-only proxy (not macOS system proxy)."""
|
||||
client = build_keepalive_http_client(str(base_url or ""), async_mode=async_mode)
|
||||
if client is None:
|
||||
return {}
|
||||
return {"http_client": client}
|
||||
|
||||
|
||||
def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any:
|
||||
kwargs = {**_openai_http_client_kwargs(base_url), **kwargs}
|
||||
return OpenAI(api_key=api_key, base_url=base_url, **kwargs)
|
||||
|
||||
|
||||
# ── Interrupt protection for atomic auxiliary tasks ──────────────────────
|
||||
# Some auxiliary tasks must NOT be aborted mid-flight by a gateway interrupt
|
||||
# (e.g. an incoming user message while the agent is busy). Context
|
||||
@@ -684,28 +666,6 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
|
||||
return str(url or "").strip().rstrip("/")
|
||||
|
||||
|
||||
# Hostnames (lowercase, exact) that the auxiliary Anthropic path is allowed to
|
||||
# be pointed at via config.yaml model.base_url. Anything else falls back to the
|
||||
# Anthropic default — operators routing main-session traffic through a
|
||||
# non-Anthropic host (e.g. OpenRouter, OpenAI) with provider=anthropic in config
|
||||
# must NOT have that foreign host leak into the auxiliary client. See #52608.
|
||||
_ANTHROPIC_COMPATIBLE_HOSTS = frozenset({
|
||||
"api.anthropic.com",
|
||||
})
|
||||
|
||||
|
||||
def _is_anthropic_compatible_host(url: str) -> bool:
|
||||
"""Return True if ``url``'s hostname is an Anthropic endpoint we trust for aux calls."""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
host = (urlparse(url).hostname or "").strip().lower().rstrip(".")
|
||||
return host in _ANTHROPIC_COMPATIBLE_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _nous_min_key_ttl_seconds() -> int:
|
||||
try:
|
||||
return max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")))
|
||||
@@ -1632,7 +1592,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
_merged_aux = _apply_user_default_headers(extra.get("default_headers"))
|
||||
if _merged_aux:
|
||||
extra["default_headers"] = _merged_aux
|
||||
_client = _create_openai_client(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
|
||||
@@ -1672,7 +1632,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
_merged_aux2 = _apply_user_default_headers(extra.get("default_headers"))
|
||||
if _merged_aux2:
|
||||
extra["default_headers"] = _merged_aux2
|
||||
_client = _create_openai_client(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
|
||||
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
|
||||
return _client, model
|
||||
|
||||
@@ -1687,21 +1647,20 @@ def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Op
|
||||
pool_present, entry = _select_pool_entry("openrouter")
|
||||
if pool_present:
|
||||
or_key = explicit_api_key or _pool_runtime_api_key(entry)
|
||||
if or_key:
|
||||
base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL
|
||||
logger.debug("Auxiliary client: OpenRouter via pool")
|
||||
return _create_openai_client(api_key=or_key, base_url=base_url,
|
||||
default_headers=build_or_headers()), model or _OPENROUTER_MODEL
|
||||
# Pool exists but is exhausted (no usable runtime key) — fall through to
|
||||
# the OPENROUTER_API_KEY env-var path rather than failing outright.
|
||||
logger.debug("Auxiliary client: OpenRouter pool exhausted, trying OPENROUTER_API_KEY")
|
||||
if not or_key:
|
||||
_mark_provider_unhealthy("openrouter", ttl=60)
|
||||
return None, None
|
||||
base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL
|
||||
logger.debug("Auxiliary client: OpenRouter via pool")
|
||||
return OpenAI(api_key=or_key, base_url=base_url,
|
||||
default_headers=build_or_headers()), model or _OPENROUTER_MODEL
|
||||
|
||||
or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY")
|
||||
if not or_key:
|
||||
_mark_provider_unhealthy("openrouter", ttl=60)
|
||||
return None, None
|
||||
logger.debug("Auxiliary client: OpenRouter")
|
||||
return _create_openai_client(api_key=or_key, base_url=OPENROUTER_BASE_URL,
|
||||
return OpenAI(api_key=or_key, base_url=OPENROUTER_BASE_URL,
|
||||
default_headers=build_or_headers()), model or _OPENROUTER_MODEL
|
||||
|
||||
|
||||
@@ -1794,7 +1753,7 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
return None, None
|
||||
base_url = str((nous or {}).get("inference_base_url") or _nous_base_url()).rstrip("/")
|
||||
return (
|
||||
_create_openai_client(
|
||||
OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
),
|
||||
@@ -2071,7 +2030,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
if _custom_headers:
|
||||
_extra["default_headers"] = _custom_headers
|
||||
if custom_mode == "codex_responses":
|
||||
real_client = _create_openai_client(api_key=custom_key, base_url=_clean_base, **_extra)
|
||||
real_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
|
||||
return CodexAuxiliaryClient(real_client, model), model
|
||||
if custom_mode == "anthropic_messages":
|
||||
# Third-party Anthropic-compatible gateway (MiniMax, Zhipu GLM,
|
||||
@@ -2085,14 +2044,14 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
|
||||
"Custom endpoint declares api_mode=anthropic_messages but the "
|
||||
"anthropic SDK is not installed — falling back to OpenAI-wire."
|
||||
)
|
||||
return _create_openai_client(api_key=custom_key, base_url=_clean_base, **_extra), model
|
||||
return OpenAI(api_key=custom_key, base_url=_clean_base, **_extra), model
|
||||
return (
|
||||
AnthropicAuxiliaryClient(real_client, model, custom_key, custom_base, is_oauth=False),
|
||||
model,
|
||||
)
|
||||
# URL-based anthropic detection for custom endpoints that didn't set
|
||||
# api_mode explicitly (e.g. kimi.com/coding reached via custom config).
|
||||
_fallback_client = _create_openai_client(api_key=custom_key, base_url=_clean_base, **_extra)
|
||||
_fallback_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
|
||||
_fallback_client = _maybe_wrap_anthropic(
|
||||
_fallback_client, model, custom_key, custom_base, custom_mode,
|
||||
)
|
||||
@@ -2121,7 +2080,7 @@ def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str
|
||||
return None, None
|
||||
api_key, base_url = resolved
|
||||
logger.debug("Auxiliary client: xAI OAuth (%s via Responses API)", model)
|
||||
real_client = _create_openai_client(api_key=api_key, base_url=base_url)
|
||||
real_client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
return CodexAuxiliaryClient(real_client, model), model
|
||||
|
||||
|
||||
@@ -2158,7 +2117,7 @@ def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]:
|
||||
return None, None
|
||||
base_url = _CODEX_AUX_BASE_URL
|
||||
logger.debug("Auxiliary client: Codex OAuth (%s via Responses API)", model)
|
||||
real_client = _create_openai_client(
|
||||
real_client = OpenAI(
|
||||
api_key=codex_token,
|
||||
base_url=base_url,
|
||||
default_headers=_codex_cloudflare_headers(codex_token),
|
||||
@@ -2258,7 +2217,7 @@ def _try_azure_foundry(
|
||||
if _dq:
|
||||
extra["default_query"] = _dq
|
||||
|
||||
client = _create_openai_client(api_key=api_key, base_url=_clean_base, **extra)
|
||||
client = OpenAI(api_key=api_key, base_url=_clean_base, **extra)
|
||||
|
||||
if runtime_api_mode == "codex_responses":
|
||||
# GPT-5.x / o-series / codex models on Azure Foundry are
|
||||
@@ -2297,16 +2256,9 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
|
||||
if not token:
|
||||
return None, None
|
||||
|
||||
# Allow base URL override from config.yaml model.base_url, but only when:
|
||||
# 1. the configured provider is anthropic (otherwise a non-Anthropic
|
||||
# base_url, e.g. Codex endpoint, would leak into Anthropic requests), AND
|
||||
# 2. the override URL actually points at an Anthropic-compatible endpoint.
|
||||
# Without gate (2), operators who route main-session traffic through a
|
||||
# non-Anthropic provider that accepts Anthropic-format requests (e.g.
|
||||
# OpenRouter at openrouter.ai/api/v1, with provider=anthropic in config.yaml)
|
||||
# would have every auxiliary side-channel call (memory extractors,
|
||||
# reflection, vision, title generation) 401 from the foreign host —
|
||||
# see issue #52608.
|
||||
# Allow base URL override from config.yaml model.base_url, but only
|
||||
# when the configured provider is anthropic — otherwise a non-Anthropic
|
||||
# base_url (e.g. Codex endpoint) would leak into Anthropic requests.
|
||||
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
@@ -2316,7 +2268,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if cfg_provider == "anthropic":
|
||||
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
|
||||
if cfg_base_url and _is_anthropic_compatible_host(cfg_base_url):
|
||||
if cfg_base_url:
|
||||
base_url = cfg_base_url
|
||||
except Exception:
|
||||
pass
|
||||
@@ -2802,25 +2754,6 @@ def _is_model_incompatible_error(exc: Exception) -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _is_invalid_aux_response_error(exc: Exception) -> bool:
|
||||
"""Detect provider responses that authenticated but cannot serve aux shape.
|
||||
|
||||
Some OpenAI-compatible routes return HTTP 200 with an empty/malformed
|
||||
ChatCompletion instead of a normal provider error. That is still a
|
||||
provider/model capability failure for auxiliary tasks: downstream callers
|
||||
need ``choices[0].message`` and should be able to continue through the
|
||||
same fallback path as explicit model-incompatibility errors.
|
||||
"""
|
||||
if not isinstance(exc, RuntimeError):
|
||||
return False
|
||||
msg = str(exc).lower()
|
||||
return (
|
||||
"auxiliary " in msg
|
||||
and "llm returned invalid response" in msg
|
||||
and "choices[0].message" in msg
|
||||
)
|
||||
|
||||
|
||||
def _evict_cached_clients(provider: str) -> None:
|
||||
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
@@ -3643,37 +3576,6 @@ def _resolve_auto(
|
||||
# config.yaml (auxiliary.<task>.provider) still win over this.
|
||||
main_provider = str(runtime_provider or _read_main_provider() or "")
|
||||
main_model = str(runtime_model or _read_main_model() or "")
|
||||
|
||||
# MoA virtual provider: the "model" is a preset name (e.g. "opus-gpt") and
|
||||
# there is no real "moa" HTTP endpoint, so resolving an aux client against
|
||||
# provider="moa"/model=<preset> sends the preset name as the model id and
|
||||
# the provider 400s ("opus-gpt is not a valid model ID"). Auxiliary tasks
|
||||
# (title generation, compression, vision, …) don't need the reference
|
||||
# fan-out — they should run on the aggregator, which is the preset's acting
|
||||
# model. Resolve the MoA preset to its aggregator slot and continue Step 1
|
||||
# with that real provider+model. Mirrors the MoA context-length resolution.
|
||||
if main_provider == "moa":
|
||||
try:
|
||||
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 {}, main_model)
|
||||
_agg = _preset.get("aggregator") or {}
|
||||
_agg_provider = str(_agg.get("provider") or "").strip()
|
||||
_agg_model = str(_agg.get("model") or "").strip()
|
||||
if _agg_provider and _agg_model and _agg_provider.lower() != "moa":
|
||||
main_provider = _agg_provider
|
||||
main_model = _agg_model
|
||||
# The MoA virtual runtime carries a non-HTTP base_url
|
||||
# ("moa://local") and a placeholder api_key; they belong to the
|
||||
# facade, not the aggregator's real provider. Drop them so the
|
||||
# aggregator resolves through its own provider credentials.
|
||||
runtime_base_url = ""
|
||||
runtime_api_key = ""
|
||||
runtime_api_mode = ""
|
||||
except Exception:
|
||||
logger.debug("MoA aux resolution to aggregator failed", exc_info=True)
|
||||
|
||||
if (main_provider and main_model
|
||||
and main_provider not in {"auto", ""}):
|
||||
resolved_provider = main_provider
|
||||
@@ -3820,10 +3722,6 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
||||
_merged_async = _apply_user_default_headers(async_kwargs.get("default_headers"))
|
||||
if _merged_async:
|
||||
async_kwargs["default_headers"] = _merged_async
|
||||
async_kwargs = {
|
||||
**_openai_http_client_kwargs(sync_base_url, async_mode=True),
|
||||
**async_kwargs,
|
||||
}
|
||||
return AsyncOpenAI(**async_kwargs), model
|
||||
|
||||
|
||||
@@ -4034,7 +3932,7 @@ def resolve_provider_client(
|
||||
"but no Codex OAuth token found (run: hermes model)")
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model, provider)
|
||||
raw_client = _create_openai_client(
|
||||
raw_client = OpenAI(
|
||||
api_key=codex_token,
|
||||
base_url=_CODEX_AUX_BASE_URL,
|
||||
default_headers=_codex_cloudflare_headers(codex_token),
|
||||
@@ -4115,7 +4013,7 @@ def resolve_provider_client(
|
||||
_merged_custom = _apply_user_default_headers(extra.get("default_headers"))
|
||||
if _merged_custom:
|
||||
extra["default_headers"] = _merged_custom
|
||||
client = _create_openai_client(api_key=custom_key, base_url=_clean_base, **extra)
|
||||
client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra)
|
||||
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
@@ -4219,7 +4117,7 @@ def resolve_provider_client(
|
||||
_fb_headers = _apply_user_default_headers(_fb_extra.get("default_headers"))
|
||||
if _fb_headers:
|
||||
_fb_extra["default_headers"] = _fb_headers
|
||||
client = _create_openai_client(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
|
||||
client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
else (client, final_model))
|
||||
sync_anthropic = AnthropicAuxiliaryClient(
|
||||
@@ -4228,7 +4126,7 @@ def resolve_provider_client(
|
||||
if async_mode:
|
||||
return AsyncAnthropicAuxiliaryClient(sync_anthropic), final_model
|
||||
return sync_anthropic, final_model
|
||||
client = _create_openai_client(api_key=custom_key, base_url=_clean_base2, **_extra2)
|
||||
client = OpenAI(api_key=custom_key, base_url=_clean_base2, **_extra2)
|
||||
# codex_responses or inherited auto-detect (via _wrap_if_needed).
|
||||
# _wrap_if_needed reads the closed-over `api_mode` (the task-level
|
||||
# override). Named-provider entry api_mode=codex_responses also
|
||||
@@ -4370,7 +4268,7 @@ def resolve_provider_client(
|
||||
_merged_main = _apply_user_default_headers(headers)
|
||||
if _merged_main:
|
||||
headers = _merged_main
|
||||
client = _create_openai_client(api_key=api_key, base_url=base_url,
|
||||
client = OpenAI(api_key=api_key, base_url=base_url,
|
||||
**({"default_headers": headers} if headers else {}))
|
||||
|
||||
# Copilot GPT-5+ models (except gpt-5-mini) require the Responses
|
||||
@@ -4906,7 +4804,7 @@ def _refresh_nous_auxiliary_client(
|
||||
return None, model
|
||||
|
||||
fresh_key, fresh_base_url = runtime
|
||||
sync_client = _create_openai_client(api_key=fresh_key, base_url=fresh_base_url)
|
||||
sync_client = OpenAI(api_key=fresh_key, base_url=fresh_base_url)
|
||||
final_model = model
|
||||
|
||||
current_loop = None
|
||||
@@ -5489,24 +5387,10 @@ def _build_call_kwargs(
|
||||
# ``/anthropic`` endpoint reached through the OpenAI SDK wrapper), where
|
||||
# max_tokens is a MANDATORY field — omitting it is a hard 400. Keep it only
|
||||
# there.
|
||||
#
|
||||
# NVIDIA NIM (integrate.api.nvidia.com and local NIM endpoints) is a
|
||||
# second exception: some models—notably minimaxai/minimax-m3—return HTTP
|
||||
# 200 with an empty choices[] payload when max_tokens is omitted. The main
|
||||
# NVIDIA chat path already sends an output cap via the provider profile;
|
||||
# preserve it on the auxiliary path too.
|
||||
_effective_base = base_url or (
|
||||
_current_custom_base_url() if provider == "custom" else ""
|
||||
)
|
||||
_provider_norm = str(provider or "").strip().lower()
|
||||
_is_nvidia_nim = (
|
||||
_provider_norm in {"nvidia", "nvidia-nim", "nim", "build-nvidia", "nemotron"}
|
||||
or base_url_host_matches(_effective_base, "integrate.api.nvidia.com")
|
||||
)
|
||||
if (
|
||||
_is_anthropic_compat_endpoint(provider, _effective_base)
|
||||
or _is_nvidia_nim
|
||||
):
|
||||
if _is_anthropic_compat_endpoint(provider, _effective_base):
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
if tools:
|
||||
@@ -5561,9 +5445,6 @@ def _validate_llm_response(response: Any, task: str = None) -> Any:
|
||||
if not choices or not hasattr(choices[0], "message"):
|
||||
raise AttributeError("missing choices[0].message")
|
||||
except (AttributeError, TypeError, IndexError) as exc:
|
||||
recovered = _recover_aux_response_message(response)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
response_type = type(response).__name__
|
||||
response_preview = str(response)[:120]
|
||||
raise RuntimeError(
|
||||
@@ -5575,64 +5456,6 @@ def _validate_llm_response(response: Any, task: str = None) -> Any:
|
||||
return response
|
||||
|
||||
|
||||
def _recover_aux_response_message(response: Any) -> Optional[Any]:
|
||||
"""Synthesize chat-completions shape from Responses-style text fields.
|
||||
|
||||
Auxiliary callers consume ``choices[0].message``. Some compatible
|
||||
endpoints return text outside ``choices`` (for example ``output_text`` or
|
||||
``output`` items). Preserve that response before declaring it malformed.
|
||||
"""
|
||||
text = _extract_aux_response_text(response)
|
||||
if not text:
|
||||
return None
|
||||
|
||||
choice = SimpleNamespace(
|
||||
message=SimpleNamespace(content=text),
|
||||
finish_reason=getattr(response, "finish_reason", None) or "stop",
|
||||
)
|
||||
try:
|
||||
response.choices = [choice]
|
||||
return response
|
||||
except Exception:
|
||||
return SimpleNamespace(
|
||||
id=getattr(response, "id", ""),
|
||||
model=getattr(response, "model", ""),
|
||||
object=getattr(response, "object", "chat.completion"),
|
||||
choices=[choice],
|
||||
usage=getattr(response, "usage", None),
|
||||
)
|
||||
|
||||
|
||||
def _extract_aux_response_text(response: Any) -> str:
|
||||
output_text = _obj_get(response, "output_text")
|
||||
if isinstance(output_text, str) and output_text.strip():
|
||||
return output_text.strip()
|
||||
|
||||
output = _obj_get(response, "output")
|
||||
if not isinstance(output, list):
|
||||
return ""
|
||||
|
||||
parts: List[str] = []
|
||||
for item in output:
|
||||
item_type = _obj_get(item, "type")
|
||||
if item_type and item_type != "message":
|
||||
continue
|
||||
for part in (_obj_get(item, "content") or []):
|
||||
part_type = _obj_get(part, "type")
|
||||
if part_type in {"output_text", "text", None}:
|
||||
text = _obj_get(part, "text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
parts.append(text.strip())
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
def _obj_get(obj: Any, key: str, default: Any = None) -> Any:
|
||||
value = getattr(obj, key, default)
|
||||
if value is default and isinstance(obj, dict):
|
||||
value = obj.get(key, default)
|
||||
return value
|
||||
|
||||
|
||||
def call_llm(
|
||||
task: str = None,
|
||||
*,
|
||||
@@ -6030,21 +5853,11 @@ def call_llm(
|
||||
# When the provider returns a 429 rate-limit (not billing), fall
|
||||
# back to an alternative provider instead of exhausting retries
|
||||
# against the same rate-limited endpoint.
|
||||
#
|
||||
# ── Auth error fallback (#21165) ─────────────────────────────
|
||||
# When the resolved provider returns 401 and neither the Nous
|
||||
# refresh path nor explicit provider credential refresh applies,
|
||||
# fall back to an alternative provider instead of dropping the
|
||||
# auxiliary task on the floor (silent compression failure /
|
||||
# message loss). Auth is NOT a capacity error: it only bypasses
|
||||
# the explicit-provider gate when the user is in auto mode.
|
||||
should_fallback = (
|
||||
_is_auth_error(first_err)
|
||||
or _is_payment_error(first_err)
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
or _is_invalid_aux_response_error(first_err)
|
||||
)
|
||||
# Respect explicit provider choice for transient errors (auth, request
|
||||
# validation, etc.) but allow fallback when the provider clearly cannot
|
||||
@@ -6067,12 +5880,9 @@ def call_llm(
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
or _is_invalid_aux_response_error(first_err)
|
||||
)
|
||||
if should_fallback and (is_auto or is_capacity_error):
|
||||
if _is_auth_error(first_err):
|
||||
reason = "auth error"
|
||||
elif _is_payment_error(first_err):
|
||||
if _is_payment_error(first_err):
|
||||
reason = "payment error"
|
||||
# Resolve the actual provider label (resolved_provider may be
|
||||
# "auto"; the client's base_url tells us which backend got the
|
||||
@@ -6085,8 +5895,6 @@ def call_llm(
|
||||
reason = "rate limit"
|
||||
elif _is_model_incompatible_error(first_err):
|
||||
reason = "model incompatible with route"
|
||||
elif _is_invalid_aux_response_error(first_err):
|
||||
reason = "invalid provider response"
|
||||
else:
|
||||
reason = "connection error"
|
||||
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
|
||||
@@ -6521,17 +6329,11 @@ async def async_call_llm(
|
||||
raise
|
||||
|
||||
# ── Payment / connection / rate-limit fallback (mirrors sync call_llm) ──
|
||||
# Auth error fallback (#21165): a 401 that survived the refresh path
|
||||
# falls back in auto mode just like the sync call_llm() path. Auth is
|
||||
# NOT a capacity error, so on an explicit provider it still respects
|
||||
# the user's choice (handled by the is_auto/is_capacity_error gate).
|
||||
should_fallback = (
|
||||
_is_auth_error(first_err)
|
||||
or _is_payment_error(first_err)
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
or _is_invalid_aux_response_error(first_err)
|
||||
)
|
||||
# Capacity errors (payment/quota/connection/rate-limit) bypass the
|
||||
# explicit-provider gate — the provider cannot serve the request
|
||||
@@ -6546,12 +6348,9 @@ async def async_call_llm(
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
or _is_invalid_aux_response_error(first_err)
|
||||
)
|
||||
if should_fallback and (is_auto or is_capacity_error):
|
||||
if _is_auth_error(first_err):
|
||||
reason = "auth error"
|
||||
elif _is_payment_error(first_err):
|
||||
if _is_payment_error(first_err):
|
||||
reason = "payment error"
|
||||
_mark_provider_unhealthy(
|
||||
_recoverable_pool_provider(resolved_provider, client) or resolved_provider
|
||||
@@ -6560,8 +6359,6 @@ async def async_call_llm(
|
||||
reason = "rate limit"
|
||||
elif _is_model_incompatible_error(first_err):
|
||||
reason = "model incompatible with route"
|
||||
elif _is_invalid_aux_response_error(first_err):
|
||||
reason = "invalid provider response"
|
||||
else:
|
||||
reason = "connection error"
|
||||
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
|
||||
|
||||
@@ -28,7 +28,6 @@ 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,
|
||||
@@ -38,18 +37,6 @@ from tools.terminal_tool import is_persistent_env
|
||||
from utils import base_url_host_matches, base_url_hostname, env_float, 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(
|
||||
@@ -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:
|
||||
@@ -1051,23 +1015,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.
|
||||
@@ -1134,22 +1093,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}
|
||||
):
|
||||
_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 +1210,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 +1228,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
|
||||
@@ -1531,9 +1458,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()
|
||||
@@ -1912,6 +1838,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
stream_kwargs = {
|
||||
**api_kwargs,
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
"timeout": _httpx.Timeout(
|
||||
connect=_conn_cap,
|
||||
read=_stream_read_timeout,
|
||||
@@ -1919,14 +1846,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",
|
||||
@@ -2327,15 +2246,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 +2391,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 +2444,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,
|
||||
@@ -2723,17 +2620,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 +2699,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 +2708,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"]
|
||||
|
||||
|
||||
@@ -60,8 +60,6 @@ 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"
|
||||
@@ -353,29 +351,6 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str:
|
||||
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()
|
||||
@@ -482,9 +457,6 @@ class RuntimeMode:
|
||||
# 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:
|
||||
@@ -531,10 +503,6 @@ class RuntimeMode:
|
||||
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]:
|
||||
@@ -587,7 +555,6 @@ def resolve_runtime_mode(
|
||||
cwd=resolved_cwd,
|
||||
config_mode=mode,
|
||||
model=model,
|
||||
instructions=_coding_instructions(config),
|
||||
)
|
||||
|
||||
|
||||
@@ -680,14 +647,12 @@ def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
@@ -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 "",
|
||||
}
|
||||
@@ -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(
|
||||
@@ -291,7 +290,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],
|
||||
@@ -300,7 +298,6 @@ def _expand_git_reference(
|
||||
text=True,
|
||||
timeout=30,
|
||||
stdin=subprocess.DEVNULL,
|
||||
**_popen_kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"{ref.raw}: git command timed out (30s)", None
|
||||
@@ -328,9 +325,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]
|
||||
@@ -486,7 +483,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))],
|
||||
@@ -495,7 +491,6 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
||||
text=True,
|
||||
timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
**_popen_kwargs,
|
||||
)
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
@@ -288,29 +288,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,
|
||||
|
||||
+26
-188
@@ -28,7 +28,6 @@ import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.codex_responses_adapter import _summarize_user_message_for_log
|
||||
from agent.conversation_compression import conversation_history_after_compression
|
||||
from agent.display import KawaiiSpinner
|
||||
from agent.error_classifier import FailoverReason, classify_api_error
|
||||
from agent.iteration_budget import IterationBudget
|
||||
@@ -588,13 +587,6 @@ def run_conversation(
|
||||
compression_attempts = 0
|
||||
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
|
||||
|
||||
# Per-turn tally of consecutive successful credential-pool token refreshes,
|
||||
# keyed by (provider, pool-entry-id). A persistent upstream 401 lets
|
||||
# ``try_refresh_current()`` "succeed" forever on a single-entry OAuth pool,
|
||||
# so this tally caps same-entry refreshes and lets the fallback chain take
|
||||
# over instead of spinning. Reset here so each turn starts fresh. See #26080.
|
||||
agent._auth_pool_refresh_counts = {}
|
||||
|
||||
# Optional opt-in runtime: if api_mode == codex_app_server, hand the
|
||||
# turn to the codex app-server subprocess (terminal/file ops/patching
|
||||
# all run inside Codex). Default Hermes path is bypassed entirely.
|
||||
@@ -835,6 +827,7 @@ def run_conversation(
|
||||
aggregator=moa_config.get("aggregator") or {},
|
||||
temperature=float(moa_config.get("reference_temperature", 0.6) or 0.6),
|
||||
aggregator_temperature=float(moa_config.get("aggregator_temperature", 0.4) or 0.4),
|
||||
max_tokens=int(moa_config.get("max_tokens", 4096) or 4096),
|
||||
)
|
||||
if _moa_context:
|
||||
for _msg in reversed(api_messages):
|
||||
@@ -1699,56 +1692,6 @@ def run_conversation(
|
||||
|
||||
if agent.api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"}:
|
||||
assistant_message = _trunc_msg
|
||||
# ── Content-filter stream stall → fallback (#32421) ──
|
||||
# When the provider's output-layer safety filter (e.g.
|
||||
# MiniMax "output new_sensitive (1027)", Azure
|
||||
# content_filter) kills the stream mid-delivery, the
|
||||
# raw error was classified at the swallow point and the
|
||||
# stub tagged ``_content_filter_terminated``. This
|
||||
# filter is content-deterministic — continuation
|
||||
# retries against the SAME primary just re-hit it and
|
||||
# burn paid attempts (the loop used to give up with
|
||||
# "Response remained truncated after 3 continuation
|
||||
# attempts" and never consult the fallback chain).
|
||||
# Escalate to the configured fallback BEFORE retrying.
|
||||
_cf_terminated = getattr(
|
||||
response, "_content_filter_terminated", False
|
||||
)
|
||||
if (
|
||||
_cf_terminated
|
||||
and agent._fallback_index < len(agent._fallback_chain)
|
||||
):
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}🛡️ Content filter terminated "
|
||||
f"stream — activating fallback provider...",
|
||||
force=True,
|
||||
)
|
||||
agent._emit_status(
|
||||
"Content filter terminated stream; switching to fallback..."
|
||||
)
|
||||
if agent._try_activate_fallback():
|
||||
# Roll the partial content (if any was already
|
||||
# appended in a prior continuation pass) back to
|
||||
# the last clean turn so the fallback provider
|
||||
# gets a coherent continuation point.
|
||||
if truncated_response_parts:
|
||||
messages = agent._get_messages_up_to_last_assistant(messages)
|
||||
agent._session_messages = messages
|
||||
length_continue_retries = 0
|
||||
truncated_response_parts = []
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
_retry.primary_recovery_attempted = False
|
||||
_retry.restart_with_rebuilt_messages = True
|
||||
break
|
||||
# No fallback available — fall through to normal
|
||||
# continuation (best-effort, may loop).
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ No fallback provider "
|
||||
f"configured — retrying with same provider "
|
||||
f"(may re-hit filter)...",
|
||||
force=True,
|
||||
)
|
||||
if assistant_message is not None and not _trunc_has_tool_calls:
|
||||
length_continue_retries += 1
|
||||
interim_msg = agent._build_assistant_message(assistant_message, finish_reason)
|
||||
@@ -2316,15 +2259,6 @@ def run_conversation(
|
||||
# "unknown variant `image_url`, expected `text`".
|
||||
"unknown variant `image_url`, expected `text`",
|
||||
"unknown variant image_url, expected text",
|
||||
# OpenRouter routes a request to upstream endpoints and,
|
||||
# when none of the candidate endpoints for the model accept
|
||||
# image input, returns HTTP 404 "No endpoints found that
|
||||
# support image input". Without this phrase the agent never
|
||||
# strips the images, the retry loop re-sends the same
|
||||
# rejected request until exhaustion, and the gateway leaves
|
||||
# every subsequent message queued behind the stuck turn —
|
||||
# the P1 in issue #21160. The 404 passes the 4xx gate below.
|
||||
"no endpoints found that support image input",
|
||||
)
|
||||
_err_lower = _err_body.lower()
|
||||
_looks_like_image_rejection = any(
|
||||
@@ -2896,9 +2830,10 @@ def run_conversation(
|
||||
approx_tokens=approx_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
)
|
||||
# Compression created a new session — clear history
|
||||
# so _flush_messages_to_session_db writes compressed
|
||||
# messages to the new session, not skipping them.
|
||||
conversation_history = None
|
||||
if len(messages) < original_len or old_ctx > _reduced_ctx:
|
||||
agent._buffer_status(
|
||||
f"🗜️ Context reduced to {_reduced_ctx:,} tokens "
|
||||
@@ -2910,25 +2845,15 @@ def run_conversation(
|
||||
# Fall through to normal error handling if compression
|
||||
# is exhausted or didn't help.
|
||||
|
||||
# Eager fallback for rate-limit errors (429 or quota exhaustion)
|
||||
# and transport errors (connection failure / timeout / provider
|
||||
# overloaded). Rate limits and billing: switch immediately —
|
||||
# the primary provider won't recover within the retry window.
|
||||
# Transport errors: allow 1 retry first (transient hiccups
|
||||
# recover), then fall back if the provider is truly unreachable.
|
||||
# Eager fallback for rate-limit errors (429 or quota exhaustion).
|
||||
# When a fallback model is configured, switch immediately instead
|
||||
# of burning through retries with exponential backoff -- the
|
||||
# primary provider won't recover within the retry window.
|
||||
is_rate_limited = classified.reason in {
|
||||
FailoverReason.rate_limit,
|
||||
FailoverReason.billing,
|
||||
}
|
||||
_is_transport_failure = classified.reason in {
|
||||
FailoverReason.timeout,
|
||||
FailoverReason.overloaded,
|
||||
}
|
||||
_should_fallback = (
|
||||
is_rate_limited
|
||||
or (_is_transport_failure and retry_count >= 2)
|
||||
)
|
||||
if _should_fallback and agent._fallback_index < len(agent._fallback_chain):
|
||||
if is_rate_limited and agent._fallback_index < len(agent._fallback_chain):
|
||||
# Don't eagerly fallback if credential pool rotation may
|
||||
# still recover. See _pool_may_recover_from_rate_limit
|
||||
# for the single-credential-pool and CloudCode-quota
|
||||
@@ -2943,10 +2868,6 @@ def run_conversation(
|
||||
agent._buffer_status(
|
||||
"⚠️ Billing or credits exhausted — switching to fallback provider..."
|
||||
)
|
||||
elif _is_transport_failure:
|
||||
agent._buffer_status(
|
||||
"⚠️ Provider unreachable — switching to fallback provider..."
|
||||
)
|
||||
else:
|
||||
agent._buffer_status("⚠️ Rate limited — switching to fallback provider...")
|
||||
if agent._try_activate_fallback(reason=classified.reason):
|
||||
@@ -3121,9 +3042,10 @@ def run_conversation(
|
||||
messages, system_message, approx_tokens=approx_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
)
|
||||
# Compression created a new session — clear history
|
||||
# so _flush_messages_to_session_db writes compressed
|
||||
# messages to the new session, not skipping them.
|
||||
conversation_history = None
|
||||
|
||||
# Re-estimate tokens after compression. Same-message-count
|
||||
# compression (tool-result pruning, in-place summarization)
|
||||
@@ -3287,9 +3209,10 @@ def run_conversation(
|
||||
messages, system_message, approx_tokens=approx_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
)
|
||||
# Compression created a new session — clear history
|
||||
# so _flush_messages_to_session_db writes compressed
|
||||
# messages to the new session, not skipping them.
|
||||
conversation_history = None
|
||||
|
||||
# Re-estimate tokens after compression. Same-message-count
|
||||
# compression (tool-result pruning, in-place summarization)
|
||||
@@ -3551,13 +3474,6 @@ def run_conversation(
|
||||
):
|
||||
_retry.primary_recovery_attempted = True
|
||||
retry_count = 0
|
||||
# Primary transport recovery starts a fresh attempt
|
||||
# cycle. Re-open fallback state so a follow-on 429 can
|
||||
# still activate fallback_providers after stale
|
||||
# pre-recovery fallback/credential-pool bookkeeping.
|
||||
_retry.has_retried_429 = False
|
||||
agent._fallback_index = 0
|
||||
agent._fallback_activated = False
|
||||
continue
|
||||
# Try fallback before giving up entirely
|
||||
if agent._has_pending_fallback():
|
||||
@@ -3745,12 +3661,7 @@ def run_conversation(
|
||||
_ra_raw = _resp_headers.get("retry-after") or _resp_headers.get("Retry-After")
|
||||
if _ra_raw:
|
||||
try:
|
||||
# Cap at 10 minutes. Anthropic Tier 1 input-token
|
||||
# buckets reset in ~171s, so a 120s cap caused us to
|
||||
# retry before the actual reset window and re-trip the
|
||||
# limit. 600s covers all realistic provider reset
|
||||
# windows while still rejecting pathological values. (#26293)
|
||||
_retry_after = min(float(_ra_raw), 600)
|
||||
_retry_after = min(float(_ra_raw), 120) # Cap at 2 minutes
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
|
||||
@@ -3831,17 +3742,6 @@ def run_conversation(
|
||||
_retry.restart_with_compressed_messages = False
|
||||
continue
|
||||
|
||||
if _retry.restart_with_rebuilt_messages:
|
||||
# A content-filter stream stall (#32421) was escalated to the
|
||||
# fallback chain and the partial content rolled back. Re-issue
|
||||
# the API call against the now-active fallback provider. Refund
|
||||
# the budget/count for the stalled attempt so the fallback gets a
|
||||
# fair turn.
|
||||
api_call_count -= 1
|
||||
agent.iteration_budget.refund()
|
||||
_retry.restart_with_rebuilt_messages = False
|
||||
continue
|
||||
|
||||
if _retry.restart_with_length_continuation:
|
||||
# Progressively boost the output token budget on each retry.
|
||||
# Retry 1 → 2× base, retry 2 → 3× base, capped at 32 768.
|
||||
@@ -4416,9 +4316,10 @@ def run_conversation(
|
||||
approx_tokens=agent.context_compressor.last_prompt_tokens,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
)
|
||||
# Compression created a new session — clear history so
|
||||
# _flush_messages_to_session_db writes compressed messages
|
||||
# to the new session (see preflight compression comment).
|
||||
conversation_history = None
|
||||
|
||||
# Save session log incrementally (so progress is visible even if interrupted)
|
||||
agent._session_messages = messages
|
||||
@@ -4460,11 +4361,7 @@ def run_conversation(
|
||||
"as final response"
|
||||
)
|
||||
final_response = _recovered
|
||||
# Streaming delivered a fragment, not a confirmed
|
||||
# final preview. Leave response_previewed false so
|
||||
# gateway fallback delivery can send the recovered
|
||||
# text plus the abnormal-turn explanation.
|
||||
agent._response_was_previewed = False
|
||||
agent._response_was_previewed = True
|
||||
break
|
||||
|
||||
# If the previous turn already delivered real content alongside
|
||||
@@ -4709,20 +4606,14 @@ def run_conversation(
|
||||
# status from earlier failed attempts in this turn.
|
||||
agent._clear_status_buffer()
|
||||
|
||||
from agent.agent_runtime_helpers import (
|
||||
intent_ack_continuation_mode,
|
||||
)
|
||||
|
||||
_ack_mode = intent_ack_continuation_mode(agent)
|
||||
if (
|
||||
_ack_mode != "off"
|
||||
agent.api_mode == "codex_responses"
|
||||
and agent.valid_tool_names
|
||||
and codex_ack_continuations < 2
|
||||
and agent._looks_like_codex_intermediate_ack(
|
||||
user_message=user_message,
|
||||
assistant_content=final_response,
|
||||
messages=messages,
|
||||
require_workspace=(_ack_mode == "codex_only"),
|
||||
)
|
||||
):
|
||||
codex_ack_continuations += 1
|
||||
@@ -4803,60 +4694,7 @@ def run_conversation(
|
||||
"_verification_stop_synthetic": True,
|
||||
})
|
||||
agent._session_messages = messages
|
||||
# Run the verification-stop loop silently — the nudge is an
|
||||
# internal turn that should not add noise to the user's
|
||||
# terminal. Keep a debug breadcrumb in agent.log for tracing.
|
||||
logger.debug("verification stop-loop nudge issued (attempt %d)",
|
||||
agent._verification_stop_nudges)
|
||||
continue
|
||||
|
||||
# User verification-loop gate: when the agent edited code this
|
||||
# turn, let a registered `pre_verify` hook (plugin/shell) keep it
|
||||
# going one more turn. The shipped guidance is folded into the
|
||||
# evidence-based verify-on-stop nudge above, so this path has no
|
||||
# default continuation cost.
|
||||
_verify_nudge2 = None
|
||||
_edited = sorted(getattr(agent, "_turn_file_mutation_paths", set()) or [])
|
||||
_attempt = getattr(agent, "_pre_verify_nudges", 0)
|
||||
try:
|
||||
from agent.verify_hooks import max_verify_nudges
|
||||
from hermes_cli.plugins import get_pre_verify_continue_message, has_hook
|
||||
|
||||
if _edited and has_hook("pre_verify") and _attempt < max_verify_nudges():
|
||||
# Posture is fixed for the session — resolve once + cache.
|
||||
coding = getattr(agent, "_resolved_is_coding", None)
|
||||
if coding is None:
|
||||
from agent.coding_context import is_coding_context
|
||||
coding = bool(is_coding_context(platform=getattr(agent, "platform", "") or ""))
|
||||
agent._resolved_is_coding = coding
|
||||
_verify_nudge2 = get_pre_verify_continue_message(
|
||||
session_id=getattr(agent, "session_id", None) or "",
|
||||
platform=getattr(agent, "platform", "") or "",
|
||||
model=getattr(agent, "model", "") or "",
|
||||
coding=coding,
|
||||
attempt=_attempt,
|
||||
final_response=final_response,
|
||||
changed_paths=_edited,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("pre_verify hook check failed", exc_info=True)
|
||||
_verify_nudge2 = None
|
||||
|
||||
if _verify_nudge2:
|
||||
agent._pre_verify_nudges = _attempt + 1
|
||||
final_msg["finish_reason"] = "verify_hook_continue"
|
||||
# Same alternation contract as verify-on-stop: keep the
|
||||
# attempted answer in history, follow it with a synthetic
|
||||
# user nudge, and don't surface the premature answer.
|
||||
messages.append(final_msg)
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": _verify_nudge2,
|
||||
"_pre_verify_synthetic": True,
|
||||
})
|
||||
agent._session_messages = messages
|
||||
logger.debug("pre_verify nudge issued (attempt %d)",
|
||||
agent._pre_verify_nudges)
|
||||
agent._emit_status("↻ Verification required before finishing")
|
||||
continue
|
||||
|
||||
messages.append(final_msg)
|
||||
|
||||
@@ -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
|
||||
@@ -100,10 +94,7 @@ 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)
|
||||
env = os.environ.copy()
|
||||
home = _resolve_home_dir()
|
||||
env["HOME"] = home
|
||||
from hermes_constants import apply_subprocess_home_env
|
||||
@@ -233,73 +224,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 +252,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 +376,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 +422,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:
|
||||
|
||||
@@ -537,11 +537,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(
|
||||
@@ -1125,17 +1124,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
|
||||
@@ -1195,17 +1190,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
|
||||
@@ -1262,17 +1253,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
|
||||
@@ -1434,7 +1421,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]:
|
||||
@@ -1608,11 +1595,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
|
||||
@@ -2274,11 +2257,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
|
||||
@@ -2307,10 +2285,8 @@ def load_pool(provider: str) -> CredentialPool:
|
||||
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)
|
||||
|
||||
+1
-50
@@ -273,21 +273,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 +292,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 +300,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 +316,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:
|
||||
@@ -428,19 +390,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 "
|
||||
@@ -1460,14 +1413,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)} "
|
||||
|
||||
@@ -537,122 +537,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
|
||||
# =========================================================================
|
||||
|
||||
+14
-76
@@ -133,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",
|
||||
@@ -355,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)
|
||||
@@ -896,19 +863,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,
|
||||
)
|
||||
# Already checked long_context_tier above; this is a normal rate limit
|
||||
return result_fn(
|
||||
FailoverReason.rate_limit,
|
||||
retryable=True,
|
||||
@@ -1259,17 +1214,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(
|
||||
@@ -1359,25 +1303,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 {}
|
||||
|
||||
|
||||
|
||||
+12
-25
@@ -77,22 +77,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:
|
||||
@@ -131,15 +124,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
|
||||
|
||||
|
||||
+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}"
|
||||
|
||||
|
||||
+49
-329
@@ -8,7 +8,6 @@ iteration.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
@@ -26,112 +25,20 @@ logger = logging.getLogger(__name__)
|
||||
# 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 _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"]
|
||||
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,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
) -> 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
|
||||
@@ -139,17 +46,13 @@ def _run_reference(
|
||||
"""
|
||||
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}, *ref_messages]
|
||||
response = call_llm(
|
||||
task="moa_reference",
|
||||
messages=messages,
|
||||
provider=slot["provider"],
|
||||
model=slot["model"],
|
||||
messages=ref_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
**_slot_runtime(slot),
|
||||
)
|
||||
return label, _extract_text(response) or "(empty response)"
|
||||
except Exception as exc:
|
||||
@@ -161,8 +64,8 @@ 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,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Fan out all reference models in parallel, returning outputs in order.
|
||||
|
||||
@@ -203,140 +106,40 @@ def _run_references_parallel(
|
||||
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.
|
||||
"""Build an advisory-safe 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.
|
||||
Reference calls are advisory: they never call tools and never emit the
|
||||
``tool_calls`` the main model did. Replaying the full transcript verbatim
|
||||
(a) re-bills the ~8K-token Hermes system prompt per reference per
|
||||
iteration and (b) risks 400s from strict providers (Mistral, Fireworks)
|
||||
that reject orphan ``tool`` messages or ``tool_calls`` the reference never
|
||||
produced. We keep only the user/assistant *text* turns, dropping the
|
||||
system prompt, any ``tool``-role messages, and any ``tool_calls`` payloads.
|
||||
"""
|
||||
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
|
||||
trimmed: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
text = content if isinstance(content, str) else ""
|
||||
|
||||
if role == "system":
|
||||
if role not in ("user", "assistant"):
|
||||
# Drop system prompt and tool-result messages.
|
||||
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}]
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str):
|
||||
# Skip non-text (multimodal/tool-call-only) assistant turns.
|
||||
if not content:
|
||||
continue
|
||||
text = content if isinstance(content, str) else ""
|
||||
if role == "assistant" and not text.strip():
|
||||
# Assistant turn that was purely tool calls — nothing advisory.
|
||||
continue
|
||||
trimmed.append({"role": role, "content": text})
|
||||
if not trimmed:
|
||||
# Degenerate case (e.g. first turn was stripped): fall back to a
|
||||
# minimal user turn so the reference still has something to answer.
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
|
||||
return [{"role": "user", "content": msg["content"]}]
|
||||
return rendered
|
||||
return trimmed
|
||||
|
||||
|
||||
|
||||
@@ -366,18 +169,12 @@ def aggregate_moa_context(
|
||||
aggregator: dict[str, str],
|
||||
temperature: float = 0.6,
|
||||
aggregator_temperature: float = 0.4,
|
||||
max_tokens: int | None = None,
|
||||
max_tokens: int = 4096,
|
||||
) -> 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)
|
||||
@@ -406,10 +203,11 @@ def aggregate_moa_context(
|
||||
try:
|
||||
response = call_llm(
|
||||
task="moa_aggregator",
|
||||
provider=aggregator["provider"],
|
||||
model=aggregator["model"],
|
||||
messages=[{"role": "user", "content": synth_prompt}],
|
||||
temperature=aggregator_temperature,
|
||||
max_tokens=max_tokens,
|
||||
**_slot_runtime(aggregator),
|
||||
)
|
||||
synthesis = _extract_text(response)
|
||||
except Exception as exc:
|
||||
@@ -432,38 +230,8 @@ def aggregate_moa_context(
|
||||
class MoAChatCompletions:
|
||||
"""OpenAI-chat-compatible facade where the aggregator is the acting model."""
|
||||
|
||||
def __init__(self, preset_name: str, reference_callback: Any = None):
|
||||
def __init__(self, preset_name: str):
|
||||
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
|
||||
@@ -473,10 +241,7 @@ class MoAChatCompletions:
|
||||
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.
|
||||
max_tokens = int(preset.get("max_tokens", api_kwargs.get("max_tokens") or 4096) or 4096)
|
||||
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)
|
||||
|
||||
@@ -488,52 +253,12 @@ class MoAChatCompletions:
|
||||
|
||||
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) 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,
|
||||
)
|
||||
reference_outputs = _run_references_parallel(
|
||||
reference_models,
|
||||
ref_messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
agg_messages = [dict(m) for m in messages]
|
||||
if reference_outputs:
|
||||
@@ -561,26 +286,21 @@ class MoAChatCompletions:
|
||||
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.
|
||||
agg_kwargs["model"] = aggregator.get("model")
|
||||
agg_kwargs["temperature"] = aggregator_temperature
|
||||
return call_llm(
|
||||
task="moa_aggregator",
|
||||
provider=aggregator.get("provider"),
|
||||
model=aggregator.get("model"),
|
||||
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"),
|
||||
**_slot_runtime(aggregator),
|
||||
)
|
||||
|
||||
|
||||
class MoAClient:
|
||||
def __init__(self, preset_name: str, reference_callback: Any = None):
|
||||
def __init__(self, preset_name: str):
|
||||
self.chat = type("_MoAChat", (), {})()
|
||||
self.chat.completions = MoAChatCompletions(preset_name, reference_callback=reference_callback)
|
||||
self.chat.completions = MoAChatCompletions(preset_name)
|
||||
|
||||
+3
-93
@@ -478,16 +478,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
|
||||
|
||||
@@ -559,7 +549,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)
|
||||
|
||||
@@ -567,7 +556,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:
|
||||
@@ -785,7 +774,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,
|
||||
@@ -1199,56 +1188,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.
|
||||
|
||||
@@ -1358,7 +1297,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)
|
||||
|
||||
@@ -1402,7 +1340,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", []):
|
||||
@@ -1708,34 +1646,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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
+7
-70
@@ -88,15 +88,12 @@ def _find_hermes_md(cwd: Path) -> Optional[Path]:
|
||||
stop_at = _find_git_root(cwd)
|
||||
current = cwd.resolve()
|
||||
|
||||
# When there is no git root, only check cwd itself – walking parents
|
||||
# could pick up a .hermes.md planted in /tmp, /home, etc.
|
||||
search_dirs = [current, *current.parents] if stop_at else [current]
|
||||
|
||||
for directory in search_dirs:
|
||||
for directory in [current, *current.parents]:
|
||||
for name in _HERMES_MD_NAMES:
|
||||
candidate = directory / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
# Stop walking at the git root (or filesystem root).
|
||||
if stop_at and directory == stop_at:
|
||||
break
|
||||
return None
|
||||
@@ -620,12 +617,7 @@ DEVELOPER_ROLE_MODELS = ("gpt-5", "codex")
|
||||
PLATFORM_HINTS = {
|
||||
"whatsapp": (
|
||||
"You are on a text messaging communication platform, WhatsApp. "
|
||||
"Standard markdown (**bold**, *italic*, ~~strike~~, # headers, "
|
||||
"`code`, ```code blocks```, [links](url)) is auto-converted to "
|
||||
"WhatsApp's native syntax (*bold*, _italic_, ~strike~, monospace) — "
|
||||
"feel free to write in markdown, and use bullet lists ('- item') "
|
||||
"freely. Tables are NOT supported — prefer bullet lists or labeled "
|
||||
"key:value pairs. "
|
||||
"Please do not use markdown as it does not render. "
|
||||
"You can send media files natively: to deliver a file to the user, "
|
||||
"include MEDIA:/absolute/path/to/file in your response. The file "
|
||||
"will be sent as a native WhatsApp attachment — images (.jpg, .png, "
|
||||
@@ -690,11 +682,7 @@ PLATFORM_HINTS = {
|
||||
),
|
||||
"signal": (
|
||||
"You are on a text messaging communication platform, Signal. "
|
||||
"Standard markdown (**bold**, *italic*, ~~strike~~, # headers, "
|
||||
"`code`, ```code blocks```) is auto-converted to Signal's native "
|
||||
"rich formatting — feel free to write in markdown, and use bullet "
|
||||
"lists ('- item') freely (they render as • bullets). Tables are NOT "
|
||||
"supported — prefer bullet lists or labeled key:value pairs. "
|
||||
"Please do not use markdown as it does not render. "
|
||||
"You can send media files natively: to deliver a file to the user, "
|
||||
"include MEDIA:/absolute/path/to/file in your response. Images "
|
||||
"(.png, .jpg, .webp) appear as photos, audio as attachments, and other "
|
||||
@@ -929,7 +917,8 @@ def _probe_remote_backend(env_type: str) -> str | None:
|
||||
try:
|
||||
# Import locally: tools/ imports are heavy and only relevant when a
|
||||
# non-local backend is actually configured.
|
||||
from tools.terminal_tool import _create_environment, _get_env_config # type: ignore
|
||||
from tools.terminal_tool import _get_env_config # type: ignore
|
||||
from tools.environments import get_environment # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug("Backend probe unavailable (import failed): %s", e)
|
||||
_BACKEND_PROBE_CACHE[cache_key] = ""
|
||||
@@ -937,59 +926,7 @@ def _probe_remote_backend(env_type: str) -> str | None:
|
||||
|
||||
try:
|
||||
config = _get_env_config()
|
||||
# Build the environment the same way tools/terminal_tool.py does for a
|
||||
# live command: select the backend image, then assemble ssh/container
|
||||
# config from the env-derived dict. (There is no `get_environment`
|
||||
# factory — the real entry point is `_create_environment`.)
|
||||
if env_type == "docker":
|
||||
image = config.get("docker_image", "")
|
||||
elif env_type == "singularity":
|
||||
image = config.get("singularity_image", "")
|
||||
elif env_type == "modal":
|
||||
image = config.get("modal_image", "")
|
||||
elif env_type == "daytona":
|
||||
image = config.get("daytona_image", "")
|
||||
else:
|
||||
image = ""
|
||||
|
||||
ssh_config = None
|
||||
if env_type == "ssh":
|
||||
ssh_config = {
|
||||
"host": config.get("ssh_host", ""),
|
||||
"user": config.get("ssh_user", ""),
|
||||
"port": config.get("ssh_port", 22),
|
||||
"key": config.get("ssh_key", ""),
|
||||
"persistent": config.get("ssh_persistent", False),
|
||||
}
|
||||
|
||||
container_config = None
|
||||
if env_type in {"docker", "singularity", "modal", "daytona"}:
|
||||
container_config = {
|
||||
"container_cpu": config.get("container_cpu", 1),
|
||||
"container_memory": config.get("container_memory", 5120),
|
||||
"container_disk": config.get("container_disk", 51200),
|
||||
"container_persistent": config.get("container_persistent", True),
|
||||
"modal_mode": config.get("modal_mode", "auto"),
|
||||
"docker_volumes": config.get("docker_volumes", []),
|
||||
"docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
|
||||
"docker_forward_env": config.get("docker_forward_env", []),
|
||||
"docker_env": config.get("docker_env", {}),
|
||||
"docker_run_as_host_user": config.get("docker_run_as_host_user", False),
|
||||
"docker_extra_args": config.get("docker_extra_args", []),
|
||||
"docker_persist_across_processes": config.get("docker_persist_across_processes", True),
|
||||
"docker_orphan_reaper": config.get("docker_orphan_reaper", True),
|
||||
}
|
||||
|
||||
env = _create_environment(
|
||||
env_type=env_type,
|
||||
image=image,
|
||||
cwd=config.get("cwd", ""),
|
||||
timeout=config.get("timeout", 180),
|
||||
ssh_config=ssh_config,
|
||||
container_config=container_config,
|
||||
task_id="prompt-backend-probe",
|
||||
host_cwd=config.get("host_cwd"),
|
||||
)
|
||||
env = get_environment(config)
|
||||
# Single-line POSIX probe — works on any Unixy backend. Wrapped in
|
||||
# `2>/dev/null` so a missing binary doesn't pollute the output.
|
||||
probe_cmd = (
|
||||
|
||||
+9
-247
@@ -10,7 +10,6 @@ the first 6 and last 4 characters for debuggability.
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -108,60 +107,12 @@ _PREFIX_PATTERNS = [
|
||||
r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token
|
||||
]
|
||||
|
||||
# ENV assignment patterns: KEY=value where KEY contains a secret-like name.
|
||||
# Uppercase keys tolerate spaces around "=" (e.g. ``FOO_SECRET = bar``) because
|
||||
# an all-caps key is almost never prose/code.
|
||||
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
|
||||
_SECRET_ENV_NAMES = r"(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)"
|
||||
_ENV_ASSIGN_RE = re.compile(
|
||||
rf"([A-Z0-9_]{{0,50}}{_SECRET_ENV_NAMES}[A-Z0-9_]{{0,50}})\s*=\s*(['\"]?)(\S+)\2",
|
||||
)
|
||||
|
||||
# Lowercase / dotted / hyphenated config keys from config files
|
||||
# (application.properties, .env, YAML-ish dumps): ``spring.datasource.password=secret``,
|
||||
# ``app.api.key=xyz``, ``password=secret``. The uppercase _ENV_ASSIGN_RE above
|
||||
# never matched these, so config-file passwords leaked verbatim (issue #16413).
|
||||
#
|
||||
# These run only in a config-file context, NOT in prose, code, or URLs — three
|
||||
# carve-outs preserved from the original design (#4367 + the documented
|
||||
# web-URL passthrough below):
|
||||
# 1. The value is bounded by ``[^\s&]`` (stops at whitespace AND ``&``) so
|
||||
# form-urlencoded bodies are handled pair-by-pair (by _redact_form_body),
|
||||
# not greedily swallowed.
|
||||
# 2. _CFG_DOTTED_RE only matches when the key is NAMESPACED (contains a dot),
|
||||
# which is unambiguously a config key — never a prose word.
|
||||
# 3. _CFG_ANCHORED_RE matches a bare secret-word key only at line start
|
||||
# (optionally after ``export``), so conversational ``I have password=foo``
|
||||
# mid-sentence is left alone.
|
||||
# The colon-form URL guard (skip when ``://`` present) lives at the call site.
|
||||
_SECRET_CFG_NAMES = r"(?:api[ _.\-]?key|token|secret|passwd|password|credential|auth)"
|
||||
_CFG_VALUE = r"(['\"]?)([^\s&]+?)\2(?=[\s&]|$)"
|
||||
# Namespaced (dotted) key: the secret word may sit anywhere in a dotted path.
|
||||
_CFG_DOTTED_RE = re.compile(
|
||||
rf"((?:[A-Za-z0-9_\-]+\.)+[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*"
|
||||
rf"|[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*\.[A-Za-z0-9_.\-]+)"
|
||||
rf"={_CFG_VALUE}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Line-anchored bare key: ``password=…`` / ``export api_key=…`` at start of line.
|
||||
_CFG_ANCHORED_RE = re.compile(
|
||||
rf"(^[ \t]*(?:export[ \t]+)?[A-Za-z0-9_\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_\-]*)={_CFG_VALUE}",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# Unquoted YAML / colon config (e.g. ``password: secret``,
|
||||
# ``spring.datasource.password: hunter2``). The secret keyword must be part of
|
||||
# the KEY (anchored to the start of the line/indent), and the value is a single
|
||||
# whitespace-free token — so prose like ``note: secret meeting`` (keyword in the
|
||||
# value) and ``error: token expired`` are left alone. Bare ``auth`` is excluded
|
||||
# from the key set so ``Authorization:`` / ``author:`` don't match (the former
|
||||
# is masked by _AUTH_HEADER_RE); ``auth_token``/``auth-token`` still match via
|
||||
# the ``token`` keyword. Quoted values defer to _JSON_FIELD_RE via the lookahead.
|
||||
_YAML_CFG_NAMES = r"(?:api[ _.\-]?key|token|secret|passwd|password|credential)"
|
||||
_YAML_ASSIGN_RE = re.compile(
|
||||
rf"(^[ \t]*[A-Za-z0-9_.\-]*{_YAML_CFG_NAMES}[A-Za-z0-9_.\-]*)(:[ \t]*)(?!['\"])([^\s&]+)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# JSON field patterns: "apiKey": "value", "token": "value", etc.
|
||||
_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)"
|
||||
_JSON_FIELD_RE = re.compile(
|
||||
@@ -174,15 +125,8 @@ _JSON_FIELD_RE = re.compile(
|
||||
# while the header name and scheme word are preserved for debuggability. The
|
||||
# previous rule only matched ``Bearer``, so ``Basic <base64 user:pass>`` and
|
||||
# ``token <pat>`` leaked verbatim into logs/transcripts.
|
||||
#
|
||||
# The credential class excludes quote characters (``"`` / ``'``): a token sitting
|
||||
# flush against a closing quote (``"Authorization: Bearer sk-..."``) must not pull
|
||||
# that quote into the match, or masking turns value corruption into *syntax*
|
||||
# corruption — the closing quote vanishes and the command/string no longer parses
|
||||
# (unterminated quote → shell EOF / Python SyntaxError). Real credentials never
|
||||
# contain ``"`` or ``'``, so excluding them is safe. See #43083.
|
||||
_AUTH_HEADER_RE = re.compile(
|
||||
r"((?:Proxy-)?Authorization:\s*)([A-Za-z][\w.+-]*\s+)?([^\s\"']+)",
|
||||
r"((?:Proxy-)?Authorization:\s*)([A-Za-z][\w.+-]*\s+)?(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -210,37 +154,9 @@ _PRIVATE_KEY_RE = re.compile(
|
||||
)
|
||||
|
||||
# Database connection strings: protocol://user:PASSWORD@host
|
||||
# Catches postgres, mysql, mongodb, redis, amqp URLs and redacts the password.
|
||||
# The userinfo and password groups forbid whitespace ([^:\s]+ / [^@\s]+) so the
|
||||
# match can never span a line break. A real DSN password never contains
|
||||
# whitespace; without this bound the greedy [^@]+ would scan past the end of a
|
||||
# code line to the next stray "@" (e.g. a Python decorator), swallowing
|
||||
# intervening lines and corrupting tool OUTPUT for any source containing a
|
||||
# postgresql:// f-string template. See issue #33801.
|
||||
# Catches postgres, mysql, mongodb, redis, amqp URLs and redacts the password
|
||||
_DB_CONNSTR_RE = re.compile(
|
||||
r"((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:\s]+:)([^@\s]+)(@)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Bare-token credential in a web/transport URL: ``scheme://TOKEN@host``.
|
||||
# This is the ``git remote set-url origin https://PASSWORD@github.com/...``
|
||||
# shape from issue #6396 — a single opaque credential in the userinfo position
|
||||
# with NO ``user:pass`` colon. It is unambiguously a secret: legitimate
|
||||
# round-trip URLs (OAuth callbacks, magic links, pre-signed shares — see the
|
||||
# "Web-URL redaction is intentionally OFF" note in redact_sensitive_text) carry
|
||||
# their tokens in the QUERY STRING, never in bare userinfo. The colon form
|
||||
# ``user:pass@`` is deliberately left to pass through (commit "pass web URLs
|
||||
# through unchanged", #34029) and is NOT matched here — the token class forbids
|
||||
# ``:``. DB schemes are handled by _DB_CONNSTR_RE above and excluded here.
|
||||
#
|
||||
# Guards against false positives:
|
||||
# - 8+ char floor skips short usernames (git, admin, root, deploy, ubuntu).
|
||||
# - The token class ``[^\s:@/]`` cannot cross ``/``, so an ``@`` sitting in a
|
||||
# path or query (e.g. ``?q=user@example.com``) is never treated as userinfo.
|
||||
_URL_BARE_TOKEN_RE = re.compile(
|
||||
r"((?:https?|wss?|git|ssh|ftp|ftps|sftp)://)" # scheme
|
||||
r"([^\s:@/]{8,})" # bare token (no colon/slash/@), 8+ chars
|
||||
r"(@[^\s]+)", # @host...
|
||||
r"((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:]+:)([^@]+)(@)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -424,40 +340,7 @@ def _redact_form_body(text: str) -> str:
|
||||
return _redact_query_string(text.strip())
|
||||
|
||||
|
||||
def _mask_token_nonreusable(token: str) -> str:
|
||||
"""Redact a prefix-matched credential to a NON-REUSABLE sentinel.
|
||||
|
||||
Unlike :func:`_mask_token` (which keeps head/tail chars — fine for logs
|
||||
that are never fed back into a config), this emits a marker that:
|
||||
|
||||
* cannot be mistaken for a usable-but-truncated key, so an agent that
|
||||
reads it from a config file and writes it back does NOT corrupt the
|
||||
stored credential into a dead 13-char string (issue #35519); and
|
||||
* still does not leak the secret material (no head/tail chars).
|
||||
|
||||
The vendor prefix label is preserved for debuggability so the agent can
|
||||
still tell *which* credential is present (e.g. a GitHub PAT vs an OpenAI
|
||||
key) without seeing any of its bytes.
|
||||
"""
|
||||
if not token:
|
||||
return "«redacted-secret»"
|
||||
# Preserve only the recognizable vendor prefix label (e.g. "ghp_", "sk-"),
|
||||
# never any of the random secret body.
|
||||
label = ""
|
||||
for sub in _PREFIX_SUBSTRINGS:
|
||||
if token.startswith(sub):
|
||||
label = sub
|
||||
break
|
||||
return f"«redacted:{label}…»" if label else "«redacted-secret»"
|
||||
|
||||
|
||||
def redact_sensitive_text(
|
||||
text: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
code_file: bool = False,
|
||||
file_read: bool = False,
|
||||
) -> str:
|
||||
def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = False) -> str:
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
@@ -470,17 +353,6 @@ def redact_sensitive_text(
|
||||
constants, "apiKey": "test" fixtures). Prefix patterns, auth headers,
|
||||
private keys, DB connstrings, JWTs, and URL secrets are still redacted.
|
||||
|
||||
Set file_read=True for file *content* returned to the agent (read_file /
|
||||
search_files / cat). Secrets are STILL redacted — they are never exposed —
|
||||
but prefix-matched credentials are replaced with a non-reusable sentinel
|
||||
(``«redacted:ghp_…»``) instead of a head/tail-preserving mask
|
||||
(``ghp_S1...Pn2T``). The old mask looked like a real-but-truncated key, so
|
||||
an agent reading it from config.yaml and writing it back silently corrupted
|
||||
the stored credential into a dead 13-char value → 401 (issue #35519). The
|
||||
sentinel is syntactically invalid as a token, so it can't be mistaken for a
|
||||
usable key or written back as one. Implies code_file=True (config/data
|
||||
files shouldn't trigger the source-code ENV/JSON false-positive paths).
|
||||
|
||||
Performance: each regex pattern is gated behind a cheap substring
|
||||
pre-check (e.g. ``"=" in text`` for ENV assignments, ``"://" in text``
|
||||
for URLs, ``"eyJ" in text`` for JWTs). On a typical hermes log line
|
||||
@@ -499,15 +371,9 @@ def redact_sensitive_text(
|
||||
if not (force or _REDACT_ENABLED):
|
||||
return text
|
||||
|
||||
# file_read content shouldn't hit the source-code ENV/JSON false-positive
|
||||
# paths either (it's config/data, not log lines).
|
||||
if file_read:
|
||||
code_file = True
|
||||
|
||||
# Known prefixes (sk-, ghp_, etc.) — gate on substring presence
|
||||
if _has_known_prefix_substring(text):
|
||||
_prefix_sub = _mask_token_nonreusable if file_read else _mask_token
|
||||
text = _PREFIX_RE.sub(lambda m: _prefix_sub(m.group(1)), text)
|
||||
text = _PREFIX_RE.sub(lambda m: _mask_token(m.group(1)), text)
|
||||
|
||||
# ENV assignments: OPENAI_API_KEY=*** (skip for code files — false positives)
|
||||
if not code_file:
|
||||
@@ -516,13 +382,6 @@ def redact_sensitive_text(
|
||||
name, quote, value = m.group(1), m.group(2), m.group(3)
|
||||
return f"{name}={quote}{_mask_token(value)}{quote}"
|
||||
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
|
||||
# Lowercase/dotted config keys (issue #16413). Skip URLs entirely —
|
||||
# web-URL query params are intentionally passed through (see note
|
||||
# near the bottom of this function); _DB_CONNSTR_RE still guards
|
||||
# connection-string passwords.
|
||||
if "://" not in text:
|
||||
text = _CFG_DOTTED_RE.sub(_redact_env, text)
|
||||
text = _CFG_ANCHORED_RE.sub(_redact_env, text)
|
||||
|
||||
# JSON fields: "apiKey": "***" (skip for code files — false positives)
|
||||
if ":" in text and '"' in text:
|
||||
@@ -531,15 +390,6 @@ def redact_sensitive_text(
|
||||
return f'{key}: "{_mask_token(value)}"'
|
||||
text = _JSON_FIELD_RE.sub(_redact_json, text)
|
||||
|
||||
# Unquoted YAML / colon config: password: *** (after JSON so quoted
|
||||
# values are handled there; the lookahead in _YAML_ASSIGN_RE skips
|
||||
# quotes). Skip URLs — web-URL query params pass through by design.
|
||||
if ":" in text and "://" not in text:
|
||||
def _redact_yaml(m):
|
||||
key, sep, value = m.group(1), m.group(2), m.group(3)
|
||||
return f"{key}{sep}{_mask_token(value)}"
|
||||
text = _YAML_ASSIGN_RE.sub(_redact_yaml, text)
|
||||
|
||||
# Authorization headers — _AUTH_HEADER_RE matches any scheme after
|
||||
# "[Proxy-]Authorization:" case-insensitively, so "uthorization" is the
|
||||
# cheapest substring gate that covers every casing without a casefold().
|
||||
@@ -569,32 +419,9 @@ def redact_sensitive_text(
|
||||
if "BEGIN" in text and "-----" in text:
|
||||
text = _PRIVATE_KEY_RE.sub("[REDACTED PRIVATE KEY]", text)
|
||||
|
||||
# Database connection string passwords. With code_file=True, a password
|
||||
# group that is a pure ``{...}`` brace expression is an f-string template
|
||||
# reference (e.g. f"postgresql://{user}:{pass}@{host}"), not a literal
|
||||
# credential — preserve it. Literal passwords are still redacted. The regex
|
||||
# forbids whitespace in the password group, so a single-line template's
|
||||
# group(2) is exactly the brace expression. See issue #33801.
|
||||
# Database connection string passwords
|
||||
if "://" in text:
|
||||
if code_file:
|
||||
def _redact_db(m):
|
||||
pw = m.group(2)
|
||||
if pw.startswith("{") and pw.endswith("}"):
|
||||
return m.group(0)
|
||||
return f"{m.group(1)}***{m.group(3)}"
|
||||
text = _DB_CONNSTR_RE.sub(_redact_db, text)
|
||||
else:
|
||||
text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text)
|
||||
|
||||
# Bare-token userinfo in web/transport URLs: ``scheme://TOKEN@host``.
|
||||
# The git-remote-with-embedded-password shape from #6396. Only the
|
||||
# colon-less bare-token form is redacted — ``user:pass@`` and
|
||||
# query-string tokens are left to pass through (see the web-URL note
|
||||
# below). See _URL_BARE_TOKEN_RE for the false-positive guards.
|
||||
text = _URL_BARE_TOKEN_RE.sub(
|
||||
lambda m: f"{m.group(1)}{_mask_token(m.group(2))}{m.group(3)}",
|
||||
text,
|
||||
)
|
||||
text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", text)
|
||||
|
||||
# JWT tokens (eyJ... — base64-encoded JSON headers)
|
||||
if "eyJ" in text:
|
||||
@@ -607,12 +434,7 @@ def redact_sensitive_text(
|
||||
# blanket-redacting param values by name breaks those skills mid-flow.
|
||||
# Known credential shapes (sk-, ghp_, JWTs, etc.) inside URLs are still
|
||||
# caught by _PREFIX_RE and _JWT_RE above. DB connection-string passwords
|
||||
# are still caught by _DB_CONNSTR_RE. The ONE userinfo case still redacted
|
||||
# is the colon-less bare-token form ``scheme://TOKEN@host`` (#6396, handled
|
||||
# by _URL_BARE_TOKEN_RE in the ``://`` block above): a bare credential in
|
||||
# userinfo is never a round-trip workflow token (those live in the query
|
||||
# string), so masking it can't break a skill. The ``user:pass@`` form is
|
||||
# left to pass through per #34029.
|
||||
# are still caught by _DB_CONNSTR_RE.
|
||||
|
||||
# Form-urlencoded bodies (only triggers on clean k=v&k=v inputs).
|
||||
if "&" in text and "=" in text:
|
||||
@@ -630,66 +452,6 @@ def redact_sensitive_text(
|
||||
return text
|
||||
|
||||
|
||||
# Commands whose stdout is an environment-variable dump (KEY=value lines),
|
||||
# NOT source code. For these, terminal-output redaction must run the
|
||||
# ENV-assignment pass (code_file=False) so opaque tokens with no recognized
|
||||
# vendor prefix (e.g. ``MY_SERVICE_TOKEN=abc123randomstring``) are still
|
||||
# masked. For all other commands, code_file=True is used to avoid mangling
|
||||
# legitimate source/config dumps (``MAX_TOKENS=100``, ``"apiKey": "x"``
|
||||
# fixtures, ``postgresql://{user}`` f-string templates). See issue #43025.
|
||||
_ENV_DUMP_COMMANDS = frozenset({"env", "printenv", "set", "export", "declare"})
|
||||
|
||||
|
||||
def is_env_dump_command(command: str | None) -> bool:
|
||||
"""Return True if ``command`` dumps environment variables to stdout.
|
||||
|
||||
Detects ``env`` / ``printenv`` / ``set`` / ``export`` / ``declare`` as the
|
||||
first token of any segment in a pipeline or sequence (``;`` / ``&&`` /
|
||||
``||`` / ``|``). Conservative: a parse failure or anything unrecognized
|
||||
returns False (callers then fall back to the safer code_file=True path,
|
||||
which still masks prefix-shaped keys).
|
||||
"""
|
||||
if not command or not isinstance(command, str):
|
||||
return False
|
||||
# Split on shell separators, then inspect the first token of each segment.
|
||||
segments = re.split(r"[|;&]+", command)
|
||||
for seg in segments:
|
||||
seg = seg.strip()
|
||||
if not seg:
|
||||
continue
|
||||
try:
|
||||
tokens = shlex.split(seg)
|
||||
except ValueError:
|
||||
tokens = seg.split()
|
||||
if tokens and tokens[0] in _ENV_DUMP_COMMANDS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def redact_terminal_output(
|
||||
output: str, command: str | None = None, *, force: bool = False
|
||||
) -> str:
|
||||
"""Redact secrets from terminal/process stdout.
|
||||
|
||||
Single redaction policy for ALL terminal-output surfaces — foreground
|
||||
``terminal`` results AND background ``process(action=poll/log/wait)``
|
||||
output — so they can't diverge. Picks ``code_file`` based on whether
|
||||
``command`` is an environment dump:
|
||||
|
||||
- env-dump command (``env``/``printenv``/``set``/``export``/``declare``)
|
||||
→ ``code_file=False`` so the ENV-assignment pass masks opaque tokens.
|
||||
- anything else (or unknown command) → ``code_file=True`` to avoid
|
||||
false positives on source/config dumps.
|
||||
|
||||
``force=True`` bypasses the global ``security.redact_secrets`` preference
|
||||
for safety boundaries that must never emit raw credentials.
|
||||
"""
|
||||
if not output:
|
||||
return output
|
||||
code_file = not is_env_dump_command(command or "")
|
||||
return redact_sensitive_text(output, force=force, code_file=code_file)
|
||||
|
||||
|
||||
# Substrings used to gate ``_PREFIX_RE`` execution. If none of these appear in
|
||||
# the input string, the prefix regex cannot match anything, so we skip it.
|
||||
# False positives are fine (they just run the regex, which then matches
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Replay-history sanitization shared across resume code paths.
|
||||
|
||||
When a session's last turn dies mid-tool-loop — the process is killed by a
|
||||
restart/shutdown command, a stale-timeout fires, or an interrupt lands before
|
||||
the tool result is written — the persisted transcript can end with a dangling
|
||||
``assistant(tool_calls)`` (no matching ``tool`` answer) or an interrupted
|
||||
``assistant→tool`` block. On resume the model sees that broken tail and
|
||||
re-issues the unanswered call, producing an endless "thinking"/reboot loop
|
||||
(#49201, #29086).
|
||||
|
||||
These pure helpers strip those tails before the history is replayed to the
|
||||
model. They were originally local to ``gateway/run.py`` (which fixed the
|
||||
messaging-gateway path) and are extracted here so every resume surface — the
|
||||
messaging gateway AND the TUI/WebUI gateway — shares the same cleanup instead
|
||||
of the WebUI path silently skipping it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_interrupted_tool_result(content: Any) -> bool:
|
||||
"""Return True if a tool result indicates the tool was interrupted."""
|
||||
if not isinstance(content, str):
|
||||
return False
|
||||
lowered = content.lower()
|
||||
if "[command interrupted]" in lowered:
|
||||
return True
|
||||
if "exit_code" in lowered and ("130" in lowered or "-1" in lowered):
|
||||
return "interrupt" in lowered
|
||||
return False
|
||||
|
||||
|
||||
def strip_interrupted_tool_tails(
|
||||
agent_history: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Strip interrupted assistant→tool sequences from replay history.
|
||||
|
||||
Older interrupted gateway turns can be followed by a queued real user
|
||||
message, so the interrupted assistant/tool block is not necessarily the
|
||||
final tail by the time we rebuild replay history. Remove any contiguous
|
||||
assistant(tool_calls) + tool-result block that contains an interrupted tool
|
||||
result, while preserving successful tool-call sequences intact.
|
||||
"""
|
||||
if not agent_history:
|
||||
return agent_history
|
||||
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
i = 0
|
||||
n = len(agent_history)
|
||||
while i < n:
|
||||
msg = agent_history[i]
|
||||
if msg.get("role") == "assistant" and "tool_calls" in msg:
|
||||
j = i + 1
|
||||
tool_results: List[Dict[str, Any]] = []
|
||||
while j < n and agent_history[j].get("role") == "tool":
|
||||
tool_results.append(agent_history[j])
|
||||
j += 1
|
||||
if tool_results and any(
|
||||
is_interrupted_tool_result(m.get("content", ""))
|
||||
for m in tool_results
|
||||
):
|
||||
logger.debug(
|
||||
"Stripping interrupted assistant→tool replay block "
|
||||
"(indices %d–%d, tool_results=%d)",
|
||||
i, j - 1, len(tool_results),
|
||||
)
|
||||
i = j
|
||||
continue
|
||||
if msg.get("role") == "tool" and is_interrupted_tool_result(msg.get("content", "")):
|
||||
logger.debug("Stripping orphan interrupted tool result from replay history")
|
||||
i += 1
|
||||
continue
|
||||
cleaned.append(msg)
|
||||
i += 1
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def strip_dangling_tool_call_tail(
|
||||
agent_history: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Strip a trailing ``assistant(tool_calls)`` block left with NO answers.
|
||||
|
||||
When a tool call itself kills the gateway process (``docker restart``,
|
||||
``systemctl restart``, ``kill``, ``hermes gateway restart``), the process
|
||||
is terminated by SIGKILL *mid-call* — before the tool result is ever
|
||||
written and before the orderly shutdown rewind
|
||||
(``_drop_trailing_empty_response_scaffolding``) can run. The last thing
|
||||
persisted is the ``assistant`` message that issued the ``tool_calls``,
|
||||
with zero matching ``tool`` rows.
|
||||
|
||||
On resume the model sees an unanswered tool call at the tail and naturally
|
||||
re-issues it — which restarts the gateway again, producing the infinite
|
||||
reboot loop in #49201. ``strip_interrupted_tool_tails`` does not catch
|
||||
this because there is no tool result to inspect for an interrupt marker.
|
||||
|
||||
This strips that dangling tail at the source so there is nothing for the
|
||||
model to re-execute. It only acts when the tail is an
|
||||
``assistant(tool_calls)`` whose calls have NO corresponding ``tool``
|
||||
results — a completed assistant→tool pair (any tool answers present) is
|
||||
left untouched so genuine mid-progress tool loops still resume.
|
||||
"""
|
||||
if not agent_history:
|
||||
return agent_history
|
||||
|
||||
last = agent_history[-1]
|
||||
if not (
|
||||
isinstance(last, dict)
|
||||
and last.get("role") == "assistant"
|
||||
and last.get("tool_calls")
|
||||
):
|
||||
return agent_history
|
||||
|
||||
logger.debug(
|
||||
"Stripping dangling unanswered assistant(tool_calls) tail "
|
||||
"(%d call(s)) — process likely killed mid-tool-call by a "
|
||||
"restart/shutdown command (#49201)",
|
||||
len(last.get("tool_calls") or []),
|
||||
)
|
||||
return agent_history[:-1]
|
||||
|
||||
|
||||
def sanitize_replay_history(
|
||||
agent_history: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Apply both replay-tail strippers in the canonical order.
|
||||
|
||||
Convenience entry point for resume code paths: removes interrupted
|
||||
assistant→tool blocks anywhere in the history, then removes a dangling
|
||||
unanswered ``assistant(tool_calls)`` tail. Returns the same list object
|
||||
when there is nothing to strip.
|
||||
"""
|
||||
if not agent_history:
|
||||
return agent_history
|
||||
return strip_dangling_tool_call_tail(strip_interrupted_tool_tails(agent_history))
|
||||
@@ -122,8 +122,6 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||
|
||||
try:
|
||||
import fcntl # POSIX only; Windows falls back to best-effort without flock.
|
||||
except ImportError: # pragma: no cover
|
||||
@@ -443,7 +441,6 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
|
||||
return result
|
||||
|
||||
t0 = time.monotonic()
|
||||
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
argv,
|
||||
@@ -452,7 +449,6 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
|
||||
timeout=spec.timeout,
|
||||
text=True,
|
||||
shell=False,
|
||||
**_popen_kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
result["timed_out"] = True
|
||||
@@ -588,17 +584,6 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
|
||||
return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))}
|
||||
return None
|
||||
|
||||
if event == "pre_verify":
|
||||
# "continue" (Hermes) / "block" (Claude-Code Stop: block the stop) both
|
||||
# mean keep going; the message/reason is the follow-up for the model. A
|
||||
# continue with no message is a no-op — let the turn finish.
|
||||
action = str(data.get("action") or data.get("decision") or "").strip().lower()
|
||||
if action in {"continue", "block"}:
|
||||
message = data.get("message") or data.get("reason")
|
||||
if isinstance(message, str) and message.strip():
|
||||
return {"action": "continue", "message": message.strip()}
|
||||
return None
|
||||
|
||||
context = data.get("context")
|
||||
if isinstance(context, str) and context.strip():
|
||||
return {"context": context}
|
||||
|
||||
@@ -5,8 +5,6 @@ import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens in SKILL.md.
|
||||
@@ -68,7 +66,6 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
|
||||
Failures return a short ``[inline-shell error: ...]`` marker instead of
|
||||
raising, so one bad snippet can't wreck the whole skill message.
|
||||
"""
|
||||
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["bash", "-c", command],
|
||||
@@ -78,7 +75,6 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
|
||||
timeout=max(1, int(timeout)),
|
||||
check=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
**_popen_kwargs,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"[inline-shell timeout after {timeout}s: {command}]"
|
||||
|
||||
@@ -24,7 +24,6 @@ from typing import Any, Optional
|
||||
from agent.display import (
|
||||
KawaiiSpinner,
|
||||
build_tool_preview as _build_tool_preview,
|
||||
build_tool_label as _build_tool_label,
|
||||
get_cute_tool_message as _get_cute_tool_message_impl,
|
||||
get_tool_emoji as _get_tool_emoji,
|
||||
redact_tool_args_for_display as _redact_tool_args_for_display,
|
||||
@@ -1225,7 +1224,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
preview = _build_tool_label(function_name, display_args) or function_name
|
||||
preview = _build_tool_preview(function_name, display_args) or function_name
|
||||
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
|
||||
spinner.start()
|
||||
_ce_result = None
|
||||
@@ -1259,7 +1258,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
preview = _build_tool_label(function_name, display_args) or function_name
|
||||
preview = _build_tool_preview(function_name, display_args) or function_name
|
||||
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
|
||||
spinner.start()
|
||||
_mem_result = None
|
||||
@@ -1291,7 +1290,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
preview = _build_tool_label(function_name, display_args) or function_name
|
||||
preview = _build_tool_preview(function_name, display_args) or function_name
|
||||
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
|
||||
spinner.start()
|
||||
_spinner_result = None
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
"""Derive OpenTelemetry-style traces from the Hermes session store.
|
||||
|
||||
Hermes already persists everything a trace needs: ``sessions`` rows carry
|
||||
server-side ``started_at`` / ``ended_at`` and full token accounting, and
|
||||
``messages`` rows carry a server-side ``timestamp`` plus ``tool_calls`` (the
|
||||
OpenAI tool-call JSON) and ``tool_call_id`` so a tool call can be paired with
|
||||
its result. Every subagent is itself a session linked by
|
||||
``parent_session_id``. That means a complete, accurately-timed span tree can be
|
||||
reconstructed for *any* session — historical or live — with zero extra
|
||||
instrumentation.
|
||||
|
||||
This module is the read-side "derive-on-read" trace builder. It turns a session
|
||||
(and its subagent descendants) into a provider-neutral :class:`Trace` of
|
||||
:class:`Span` objects. ``agent/trace_export.py`` renders that into OTLP/JSON
|
||||
(OpenInference conventions, ingestible by Arize Phoenix / any OTel backend) or
|
||||
the Chrome Trace Event format (viewable in https://ui.perfetto.dev).
|
||||
|
||||
Accuracy note: the Hermes agent loop runs tool calls sequentially, so inferring
|
||||
span durations from consecutive message timestamps matches real execution. The
|
||||
only inferred link is a ``delegate_task`` tool call → its child session, matched
|
||||
by start-time proximity; a future precision pass can persist the spawning
|
||||
``tool_call_id`` to make that exact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Protocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OpenInference span kinds (what Phoenix and other OTel-GenAI viewers expect).
|
||||
KIND_AGENT = "AGENT"
|
||||
KIND_LLM = "LLM"
|
||||
KIND_TOOL = "TOOL"
|
||||
KIND_CHAIN = "CHAIN"
|
||||
|
||||
# Status codes, mirroring OTLP (1 = OK, 2 = ERROR).
|
||||
STATUS_OK = "ok"
|
||||
STATUS_ERROR = "error"
|
||||
STATUS_UNSET = "unset"
|
||||
|
||||
# Tool names that spawn subagent sessions. A span with one of these names gets
|
||||
# its matched child session's subtree nested underneath it.
|
||||
_DELEGATE_TOOL_NAMES = frozenset({"delegate_task"})
|
||||
|
||||
# How long after the last message a session's ``ended_at`` may sit and still be
|
||||
# trusted as real activity (vs a cleanup/orphan reaper firing much later).
|
||||
_END_GRACE_SECONDS = 300.0
|
||||
|
||||
|
||||
class _SessionStore(Protocol):
|
||||
"""The slice of ``SessionDB`` the builder depends on (keeps it testable)."""
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: ...
|
||||
|
||||
def get_messages(
|
||||
self, session_id: str, include_inactive: bool = False
|
||||
) -> List[Dict[str, Any]]: ...
|
||||
|
||||
def get_child_session_ids(self, parent_session_id: str) -> List[str]: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class Span:
|
||||
"""A single unit of work on the trace timeline.
|
||||
|
||||
Times are epoch seconds (float) to match ``messages.timestamp``. Exporters
|
||||
convert to their own units (OTLP nanoseconds, Chrome microseconds).
|
||||
"""
|
||||
|
||||
span_id: str
|
||||
parent_id: Optional[str]
|
||||
name: str
|
||||
kind: str
|
||||
start: float
|
||||
end: float
|
||||
status: str = STATUS_UNSET
|
||||
session_id: Optional[str] = None
|
||||
attributes: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return max(0.0, self.end - self.start)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"span_id": self.span_id,
|
||||
"parent_id": self.parent_id,
|
||||
"name": self.name,
|
||||
"kind": self.kind,
|
||||
"start": self.start,
|
||||
"end": self.end,
|
||||
"duration": self.duration,
|
||||
"status": self.status,
|
||||
"session_id": self.session_id,
|
||||
"attributes": {k: v for k, v in self.attributes.items() if v is not None},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trace:
|
||||
"""A full span tree rooted at one session (plus its subagent descendants)."""
|
||||
|
||||
trace_id: str
|
||||
root_session_id: str
|
||||
spans: List[Span] = field(default_factory=list)
|
||||
root_span_id: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def start(self) -> float:
|
||||
return min((s.start for s in self.spans), default=0.0)
|
||||
|
||||
@property
|
||||
def end(self) -> float:
|
||||
return max((s.end for s in self.spans), default=0.0)
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return max(0.0, self.end - self.start)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"trace_id": self.trace_id,
|
||||
"root_session_id": self.root_session_id,
|
||||
"root_span_id": self.root_span_id,
|
||||
"start": self.start,
|
||||
"end": self.end,
|
||||
"duration": self.duration,
|
||||
"metadata": {k: v for k, v in self.metadata.items() if v is not None},
|
||||
"spans": [s.to_dict() for s in self.spans],
|
||||
}
|
||||
|
||||
|
||||
# ── tool-call shape helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _tool_call_id(call: Dict[str, Any]) -> str:
|
||||
return str(call.get("id") or call.get("tool_call_id") or "")
|
||||
|
||||
|
||||
def _tool_call_name(call: Dict[str, Any]) -> str:
|
||||
fn = call.get("function")
|
||||
if isinstance(fn, dict) and fn.get("name"):
|
||||
return str(fn["name"])
|
||||
return str(call.get("name") or "tool")
|
||||
|
||||
|
||||
def _tool_call_args(call: Dict[str, Any]) -> Any:
|
||||
fn = call.get("function")
|
||||
raw = fn.get("arguments") if isinstance(fn, dict) else call.get("arguments")
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return raw
|
||||
return raw
|
||||
|
||||
|
||||
def _as_text(content: Any) -> str:
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
try:
|
||||
return json.dumps(content, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
return str(content)
|
||||
|
||||
|
||||
def _looks_like_error(message: Dict[str, Any]) -> bool:
|
||||
"""Best-effort error detection on a tool-result message."""
|
||||
text = _as_text(message.get("content")).lstrip()
|
||||
if not text:
|
||||
return False
|
||||
head = text[:400].lower()
|
||||
if text.startswith("{"):
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
if isinstance(obj, dict):
|
||||
if obj.get("error") or obj.get("success") is False:
|
||||
return True
|
||||
status = str(obj.get("status", "")).lower()
|
||||
if status in {"error", "failed", "failure"}:
|
||||
return True
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return any(
|
||||
marker in head
|
||||
for marker in ("traceback (most recent call last)", "error:", "exception:")
|
||||
)
|
||||
|
||||
|
||||
# ── builder ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _short(value: str, limit: int = 120) -> str:
|
||||
flat = " ".join(value.split())
|
||||
return flat if len(flat) <= limit else flat[: limit - 1] + "…"
|
||||
|
||||
|
||||
def _llm_span_name() -> str:
|
||||
"""Label for an LLM (assistant-turn) span. A plain structural "llm" (matching
|
||||
the OTel/Langfuse convention of short, uniform LLM labels) — the model and
|
||||
the response text live in the span's attributes / detail panel, not the row.
|
||||
"""
|
||||
return "llm"
|
||||
|
||||
|
||||
def _mk_span_id(prefix: str, session_id: str, key: Any) -> str:
|
||||
return f"{prefix}:{session_id}:{key}"
|
||||
|
||||
|
||||
def _trace_id(session_id: str) -> str:
|
||||
return f"trace:{session_id}"
|
||||
|
||||
|
||||
def build_trace(
|
||||
store: _SessionStore,
|
||||
session_id: str,
|
||||
*,
|
||||
include_subagents: bool = True,
|
||||
_depth: int = 0,
|
||||
_max_depth: int = 8,
|
||||
) -> Optional[Trace]:
|
||||
"""Reconstruct a :class:`Trace` for ``session_id`` from the session store.
|
||||
|
||||
Returns ``None`` when the session does not exist. Walks delegate subagent
|
||||
descendants (``parent_session_id``) and nests each under the
|
||||
``delegate_task`` tool span that spawned it.
|
||||
"""
|
||||
session = store.get_session(session_id)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
trace = Trace(
|
||||
trace_id=_trace_id(session_id),
|
||||
root_session_id=session_id,
|
||||
metadata={
|
||||
"source": session.get("source"),
|
||||
"model": session.get("model"),
|
||||
"cwd": session.get("cwd"),
|
||||
"git_branch": session.get("git_branch"),
|
||||
},
|
||||
)
|
||||
|
||||
root_span = _build_session_spans(store, session, parent_span_id=None, trace=trace)
|
||||
trace.root_span_id = root_span.span_id if root_span else None
|
||||
|
||||
if include_subagents and root_span and _depth < _max_depth:
|
||||
_attach_subagents(store, session_id, trace, _depth=_depth, _max_depth=_max_depth)
|
||||
|
||||
trace.spans.sort(key=lambda s: (s.start, s.span_id))
|
||||
return trace
|
||||
|
||||
|
||||
# Synthetic re-injections that re-enter the conversation as ``user`` messages
|
||||
# but are CONTINUATIONS of earlier work, not a fresh prompt: async-delegation
|
||||
# completions (`[ASYNC DELEGATION …]`) and background-process notifications
|
||||
# (`[IMPORTANT: …]`). They must not open a new turn — otherwise a background
|
||||
# subagent dispatched in turn N shows up as its own orphan "[ASYNC DELEGATION]"
|
||||
# turn when it finishes, instead of folding into the group that spawned it. This
|
||||
# mirrors the desktop live view, which only resets the live turn on a real
|
||||
# ``prompt.submit``.
|
||||
_CONTINUATION_PREFIXES = (
|
||||
"[ASYNC DELEGATION",
|
||||
"[IMPORTANT:",
|
||||
)
|
||||
|
||||
|
||||
def _is_continuation(message: Dict[str, Any]) -> bool:
|
||||
"""True for a synthetic re-injection that should merge into the current turn."""
|
||||
if message.get("role") != "user":
|
||||
return False
|
||||
return _as_text(message.get("content")).lstrip().startswith(_CONTINUATION_PREFIXES)
|
||||
|
||||
|
||||
def _split_turns(messages: List[Dict[str, Any]]) -> List[tuple]:
|
||||
"""Split messages into turns. A turn begins at each *real* ``user`` message and
|
||||
runs until the next one. Synthetic continuations (async-delegation /
|
||||
background-process re-injections) do NOT start a turn — they merge into the
|
||||
one that spawned the work. Leading non-user messages (e.g. system) join the
|
||||
first turn. Returns ``[(start_idx, end_idx), ...]`` index ranges.
|
||||
"""
|
||||
bounds: List[tuple] = []
|
||||
start = 0
|
||||
for i, m in enumerate(messages):
|
||||
if i > 0 and m.get("role") == "user" and not _is_continuation(m):
|
||||
bounds.append((start, i))
|
||||
start = i
|
||||
bounds.append((start, len(messages)))
|
||||
return bounds
|
||||
|
||||
|
||||
def build_session_turns(
|
||||
store: _SessionStore,
|
||||
session_id: str,
|
||||
*,
|
||||
include_subagents: bool = True,
|
||||
) -> List[Trace]:
|
||||
"""Build one :class:`Trace` per turn for a session.
|
||||
|
||||
A turn (one user prompt → the agent's full response, subagents included) is
|
||||
the natural trace unit — it has no inter-turn idle gaps, so each renders as a
|
||||
tight waterfall. Returns traces in chronological order.
|
||||
"""
|
||||
session = store.get_session(session_id)
|
||||
if not session:
|
||||
return []
|
||||
messages = store.get_messages(session_id)
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
meta = {
|
||||
"source": session.get("source"),
|
||||
"model": session.get("model"),
|
||||
"cwd": session.get("cwd"),
|
||||
"git_branch": session.get("git_branch"),
|
||||
}
|
||||
out: List[Trace] = []
|
||||
for ti, (a, b) in enumerate(_split_turns(messages)):
|
||||
slice_msgs = messages[a:b]
|
||||
if not slice_msgs:
|
||||
continue
|
||||
trace = Trace(
|
||||
trace_id=f"{_trace_id(session_id)}:t{ti}",
|
||||
root_session_id=session_id,
|
||||
metadata={**meta, "turn": ti},
|
||||
)
|
||||
root = _build_session_spans(
|
||||
store,
|
||||
session,
|
||||
parent_span_id=None,
|
||||
trace=trace,
|
||||
messages=slice_msgs,
|
||||
agent_key=f"turn{ti}",
|
||||
)
|
||||
if not root:
|
||||
continue
|
||||
trace.root_span_id = root.span_id
|
||||
if include_subagents:
|
||||
_attach_subagents(
|
||||
store,
|
||||
session_id,
|
||||
trace,
|
||||
agent_key=f"turn{ti}",
|
||||
window=(root.start, root.end),
|
||||
_depth=0,
|
||||
_max_depth=8,
|
||||
)
|
||||
trace.spans.sort(key=lambda s: (s.start, s.span_id))
|
||||
out.append(trace)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _build_session_spans(
|
||||
store: _SessionStore,
|
||||
session: Dict[str, Any],
|
||||
*,
|
||||
parent_span_id: Optional[str],
|
||||
trace: Trace,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
agent_key: str = "root",
|
||||
) -> Optional[Span]:
|
||||
"""Append the AGENT span for one session (or a turn slice of it) plus its
|
||||
LLM/TOOL child spans.
|
||||
|
||||
``messages`` lets a caller pass a turn-scoped slice; ``agent_key`` keeps the
|
||||
AGENT span id unique per turn. Returns the session's root (AGENT) span, or
|
||||
``None`` for an empty session.
|
||||
"""
|
||||
session_id = session["id"]
|
||||
if messages is None:
|
||||
messages = store.get_messages(session_id)
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
msg_start = min(float(m["timestamp"]) for m in messages)
|
||||
msg_end = max(float(m["timestamp"]) for m in messages)
|
||||
started_at = float(session.get("started_at") or msg_start)
|
||||
|
||||
# Clamp the AGENT span to real message activity. Session ``started_at`` /
|
||||
# ``ended_at`` are unreliable for trace timing: ``ended_at`` can be a
|
||||
# cleanup/orphan reaper firing hours later (e.g. ``ws_orphan_reap``), and on
|
||||
# a turn slice ``started_at`` is the whole-session start, far before this
|
||||
# turn. Snap to them only when they sit right at the slice's edges, so a
|
||||
# span never balloons into inter-turn idle.
|
||||
activity_start = msg_start
|
||||
if msg_start - _END_GRACE_SECONDS <= started_at <= msg_start:
|
||||
activity_start = started_at
|
||||
activity_end = msg_end
|
||||
raw_ended = session.get("ended_at")
|
||||
if raw_ended is not None:
|
||||
ended_at = float(raw_ended)
|
||||
if msg_end <= ended_at <= msg_end + _END_GRACE_SECONDS:
|
||||
activity_end = ended_at
|
||||
|
||||
goal = _session_goal(messages, session)
|
||||
agent_span = Span(
|
||||
span_id=_mk_span_id("agent", session_id, agent_key),
|
||||
parent_id=parent_span_id,
|
||||
name=goal,
|
||||
kind=KIND_AGENT,
|
||||
start=activity_start,
|
||||
end=activity_end,
|
||||
status=STATUS_ERROR if session.get("end_reason") in {"error", "failed"} else STATUS_OK,
|
||||
session_id=session_id,
|
||||
attributes={
|
||||
"session.id": session_id,
|
||||
"session.source": session.get("source"),
|
||||
"llm.model_name": session.get("model"),
|
||||
"llm.token_count.prompt": session.get("input_tokens"),
|
||||
"llm.token_count.completion": session.get("output_tokens"),
|
||||
"llm.token_count.reasoning": session.get("reasoning_tokens"),
|
||||
"session.message_count": session.get("message_count"),
|
||||
"session.tool_call_count": session.get("tool_call_count"),
|
||||
"session.end_reason": session.get("end_reason"),
|
||||
},
|
||||
)
|
||||
trace.spans.append(agent_span)
|
||||
|
||||
# Pre-index tool results by tool_call_id so calls pair with their output.
|
||||
results_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
for m in messages:
|
||||
if m.get("role") == "tool" and m.get("tool_call_id"):
|
||||
results_by_id[str(m["tool_call_id"])] = m
|
||||
|
||||
# Walk the turn timeline. An assistant message closes the LLM span that
|
||||
# began at the previous boundary; each tool_call it carries becomes a TOOL
|
||||
# span ending at its paired result.
|
||||
prev_boundary = activity_start
|
||||
for m in messages:
|
||||
role = m.get("role")
|
||||
ts = float(m["timestamp"])
|
||||
|
||||
if role == "assistant":
|
||||
llm_span = Span(
|
||||
span_id=_mk_span_id("llm", session_id, m["id"]),
|
||||
parent_id=agent_span.span_id,
|
||||
name=_llm_span_name(),
|
||||
kind=KIND_LLM,
|
||||
start=prev_boundary,
|
||||
end=ts,
|
||||
status=STATUS_OK,
|
||||
session_id=session_id,
|
||||
attributes={
|
||||
"llm.model_name": session.get("model"),
|
||||
"llm.token_count.completion": m.get("token_count"),
|
||||
"output.value": _short(_as_text(m.get("content")), 2000),
|
||||
"hermes.finish_reason": m.get("finish_reason"),
|
||||
"hermes.has_reasoning": bool(
|
||||
m.get("reasoning") or m.get("reasoning_content")
|
||||
),
|
||||
},
|
||||
)
|
||||
trace.spans.append(llm_span)
|
||||
|
||||
for call in m.get("tool_calls") or []:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
_append_tool_span(
|
||||
trace=trace,
|
||||
session=session,
|
||||
parent_span_id=agent_span.span_id,
|
||||
call=call,
|
||||
call_ts=ts,
|
||||
results_by_id=results_by_id,
|
||||
fallback_end=activity_end,
|
||||
)
|
||||
|
||||
prev_boundary = ts
|
||||
elif role in {"user", "tool"}:
|
||||
# User input and tool results define the next LLM span's start.
|
||||
prev_boundary = ts
|
||||
|
||||
return agent_span
|
||||
|
||||
|
||||
def _append_tool_span(
|
||||
*,
|
||||
trace: Trace,
|
||||
session: Dict[str, Any],
|
||||
parent_span_id: str,
|
||||
call: Dict[str, Any],
|
||||
call_ts: float,
|
||||
results_by_id: Dict[str, Dict[str, Any]],
|
||||
fallback_end: float,
|
||||
) -> None:
|
||||
session_id = session["id"]
|
||||
call_id = _tool_call_id(call)
|
||||
name = _tool_call_name(call)
|
||||
result = results_by_id.get(call_id) if call_id else None
|
||||
end = float(result["timestamp"]) if result else fallback_end
|
||||
status = STATUS_OK
|
||||
if result and _looks_like_error(result):
|
||||
status = STATUS_ERROR
|
||||
elif not result:
|
||||
status = STATUS_UNSET
|
||||
|
||||
args = _tool_call_args(call)
|
||||
span = Span(
|
||||
span_id=_mk_span_id("tool", session_id, call_id or f"{call_ts}:{name}"),
|
||||
parent_id=parent_span_id,
|
||||
name=name,
|
||||
kind=KIND_TOOL,
|
||||
start=call_ts,
|
||||
end=max(end, call_ts),
|
||||
status=status,
|
||||
session_id=session_id,
|
||||
attributes={
|
||||
"tool.name": name,
|
||||
"tool.call_id": call_id or None,
|
||||
"input.value": _short(_as_text(args), 2000),
|
||||
"output.value": _short(_as_text(result.get("content")), 2000) if result else None,
|
||||
"hermes.is_delegate": name in _DELEGATE_TOOL_NAMES,
|
||||
},
|
||||
)
|
||||
trace.spans.append(span)
|
||||
|
||||
|
||||
def _attach_subagents(
|
||||
store: _SessionStore,
|
||||
session_id: str,
|
||||
trace: Trace,
|
||||
*,
|
||||
agent_key: str = "root",
|
||||
window: Optional[tuple] = None,
|
||||
_depth: int,
|
||||
_max_depth: int,
|
||||
) -> None:
|
||||
"""Nest each delegate child session under the tool span that spawned it.
|
||||
|
||||
Children are matched to ``delegate_task`` tool spans by start-time proximity
|
||||
(each child consumed once). Unmatched children attach to the session's AGENT
|
||||
span so they are never dropped from the trace. ``window`` (start, end) limits
|
||||
attachment to children spawned during a turn slice.
|
||||
"""
|
||||
child_ids = store.get_child_session_ids(session_id)
|
||||
if not child_ids:
|
||||
return
|
||||
|
||||
delegate_spans = sorted(
|
||||
(
|
||||
s
|
||||
for s in trace.spans
|
||||
if s.session_id == session_id
|
||||
and s.kind == KIND_TOOL
|
||||
and s.attributes.get("hermes.is_delegate")
|
||||
),
|
||||
key=lambda s: s.start,
|
||||
)
|
||||
agent_span_id = _mk_span_id("agent", session_id, agent_key)
|
||||
|
||||
children = []
|
||||
for cid in child_ids:
|
||||
csess = store.get_session(cid)
|
||||
if not csess:
|
||||
continue
|
||||
if window is not None:
|
||||
cstart = float(csess.get("started_at") or 0.0)
|
||||
if not (window[0] - 1.0 <= cstart <= window[1] + 1.0):
|
||||
continue
|
||||
children.append(csess)
|
||||
children.sort(key=lambda c: float(c.get("started_at") or 0.0))
|
||||
|
||||
used: set = set()
|
||||
for csess in children:
|
||||
cstart = float(csess.get("started_at") or 0.0)
|
||||
parent_span_id = agent_span_id
|
||||
best = None
|
||||
best_gap = None
|
||||
for ds in delegate_spans:
|
||||
if ds.span_id in used:
|
||||
continue
|
||||
gap = abs(ds.start - cstart)
|
||||
if best_gap is None or gap < best_gap:
|
||||
best, best_gap = ds, gap
|
||||
if best is not None:
|
||||
used.add(best.span_id)
|
||||
parent_span_id = best.span_id
|
||||
|
||||
child_root = _build_session_spans(
|
||||
store, csess, parent_span_id=parent_span_id, trace=trace
|
||||
)
|
||||
if child_root and _depth + 1 < _max_depth:
|
||||
_attach_subagents(
|
||||
store, csess["id"], trace, _depth=_depth + 1, _max_depth=_max_depth
|
||||
)
|
||||
|
||||
|
||||
def _session_goal(messages: List[Dict[str, Any]], session: Dict[str, Any]) -> str:
|
||||
"""A human-readable label for an AGENT span.
|
||||
|
||||
Prefer the first user message in the given slice so per-turn spans get their
|
||||
own prompt as a label (the session title is identical across every turn).
|
||||
Fall back to the session title, then a short id.
|
||||
"""
|
||||
for m in messages:
|
||||
if m.get("role") == "user":
|
||||
text = _as_text(m.get("content")).strip()
|
||||
if text:
|
||||
return _short(text, 120)
|
||||
title = session.get("title")
|
||||
if title:
|
||||
return _short(str(title), 120)
|
||||
return f"session {str(session.get('id', ''))[:8]}"
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Render a :class:`~agent.trace_builder.Trace` into portable file formats.
|
||||
|
||||
Two formats, both hand-built (no OTel SDK dependency):
|
||||
|
||||
* **OTLP/JSON** with OpenInference semantic conventions — the industry standard
|
||||
for LLM/agent traces. Ingestible by Arize Phoenix and any OpenTelemetry
|
||||
backend, so we can confirm our spans are correct in a real viewer.
|
||||
* **Chrome Trace Event format** — each session becomes its own track, viewable
|
||||
by dropping the file into https://ui.perfetto.dev or ``chrome://tracing``.
|
||||
|
||||
Keeping these as plain dict/JSON builders means the trace layer has zero new
|
||||
third-party dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from agent.trace_builder import STATUS_ERROR, STATUS_OK, Trace
|
||||
|
||||
_SERVICE_NAME = "hermes-agent"
|
||||
_SCOPE_NAME = "hermes.tracing"
|
||||
|
||||
|
||||
def _hex_id(value: str, nbytes: int) -> str:
|
||||
"""Deterministic hex id of ``nbytes`` bytes from an arbitrary string."""
|
||||
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()
|
||||
return digest[: nbytes * 2]
|
||||
|
||||
|
||||
def _otlp_any_value(value: Any) -> Dict[str, Any]:
|
||||
if isinstance(value, bool):
|
||||
return {"boolValue": value}
|
||||
if isinstance(value, int):
|
||||
return {"intValue": str(value)}
|
||||
if isinstance(value, float):
|
||||
return {"doubleValue": value}
|
||||
return {"stringValue": str(value)}
|
||||
|
||||
|
||||
def _otlp_attributes(attrs: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for key, value in attrs.items():
|
||||
if value is None:
|
||||
continue
|
||||
out.append({"key": key, "value": _otlp_any_value(value)})
|
||||
return out
|
||||
|
||||
|
||||
def _otlp_status(status: str) -> Dict[str, Any]:
|
||||
if status == STATUS_OK:
|
||||
return {"code": 1}
|
||||
if status == STATUS_ERROR:
|
||||
return {"code": 2}
|
||||
return {"code": 0}
|
||||
|
||||
|
||||
def _to_nanos(seconds: float) -> str:
|
||||
return str(int(seconds * 1_000_000_000))
|
||||
|
||||
|
||||
def to_otlp_json(trace: Trace) -> Dict[str, Any]:
|
||||
"""Build an OTLP/JSON ``TracesData`` document with OpenInference attributes."""
|
||||
trace_hex = _hex_id(trace.trace_id, 16)
|
||||
otlp_spans: List[Dict[str, Any]] = []
|
||||
|
||||
for span in trace.spans:
|
||||
attributes = dict(span.attributes)
|
||||
# OpenInference: the GenAI span kind travels as an attribute; the OTLP
|
||||
# SpanKind stays INTERNAL (1).
|
||||
attributes["openinference.span.kind"] = span.kind
|
||||
if span.session_id:
|
||||
attributes.setdefault("session.id", span.session_id)
|
||||
|
||||
otlp_span: Dict[str, Any] = {
|
||||
"traceId": trace_hex,
|
||||
"spanId": _hex_id(span.span_id, 8),
|
||||
"name": span.name,
|
||||
"kind": 1,
|
||||
"startTimeUnixNano": _to_nanos(span.start),
|
||||
"endTimeUnixNano": _to_nanos(span.end),
|
||||
"attributes": _otlp_attributes(attributes),
|
||||
"status": _otlp_status(span.status),
|
||||
}
|
||||
if span.parent_id:
|
||||
otlp_span["parentSpanId"] = _hex_id(span.parent_id, 8)
|
||||
otlp_spans.append(otlp_span)
|
||||
|
||||
return {
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": _otlp_attributes(
|
||||
{
|
||||
"service.name": _SERVICE_NAME,
|
||||
"session.id": trace.root_session_id,
|
||||
"hermes.source": trace.metadata.get("source"),
|
||||
}
|
||||
)
|
||||
},
|
||||
"scopeSpans": [
|
||||
{
|
||||
"scope": {"name": _SCOPE_NAME},
|
||||
"spans": otlp_spans,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def to_chrome_trace(trace: Trace) -> Dict[str, Any]:
|
||||
"""Build a Chrome Trace Event document (one track per session)."""
|
||||
base = trace.start
|
||||
events: List[Dict[str, Any]] = []
|
||||
|
||||
# Stable, compact track ids per session — each session is its own lane.
|
||||
tids: Dict[str, int] = {}
|
||||
|
||||
def tid_for(session_id: str) -> int:
|
||||
if session_id not in tids:
|
||||
tids[session_id] = len(tids) + 1
|
||||
return tids[session_id]
|
||||
|
||||
for span in trace.spans:
|
||||
sid = span.session_id or trace.root_session_id
|
||||
events.append(
|
||||
{
|
||||
"name": span.name,
|
||||
"cat": span.kind,
|
||||
"ph": "X",
|
||||
"ts": (span.start - base) * 1_000_000,
|
||||
"dur": max(0.0, span.duration) * 1_000_000,
|
||||
"pid": 1,
|
||||
"tid": tid_for(sid),
|
||||
"args": {k: v for k, v in span.attributes.items() if v is not None},
|
||||
}
|
||||
)
|
||||
|
||||
# Name each track after its session (root first) for legible lanes.
|
||||
for session_id, tid in tids.items():
|
||||
label = "root" if session_id == trace.root_session_id else f"subagent {session_id[:8]}"
|
||||
events.append(
|
||||
{
|
||||
"name": "thread_name",
|
||||
"ph": "M",
|
||||
"pid": 1,
|
||||
"tid": tid,
|
||||
"args": {"name": label},
|
||||
}
|
||||
)
|
||||
|
||||
return {"traceEvents": events, "displayTimeUnit": "ms"}
|
||||
|
||||
|
||||
def dumps(trace: Trace, fmt: str = "otlp", *, indent: int = 2) -> str:
|
||||
"""Serialize ``trace`` to a JSON string in the requested format."""
|
||||
fmt = (fmt or "otlp").lower()
|
||||
if fmt in {"otlp", "otlp-json", "openinference"}:
|
||||
doc = to_otlp_json(trace)
|
||||
elif fmt in {"chrome", "perfetto", "trace-event"}:
|
||||
doc = to_chrome_trace(trace)
|
||||
else:
|
||||
raise ValueError(f"unknown trace format: {fmt!r} (use 'otlp' or 'chrome')")
|
||||
return json.dumps(doc, ensure_ascii=False, indent=indent, default=str)
|
||||
|
||||
|
||||
__all__ = ["dumps", "to_chrome_trace", "to_otlp_json"]
|
||||
@@ -28,7 +28,6 @@ import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.conversation_compression import conversation_history_after_compression
|
||||
from agent.iteration_budget import IterationBudget
|
||||
from agent.model_metadata import (
|
||||
estimate_messages_tokens_rough,
|
||||
@@ -401,9 +400,7 @@ def build_turn_context(
|
||||
_orig_len, len(messages), _orig_tokens, _preflight_tokens
|
||||
):
|
||||
break # Cannot compress further: neither rows nor tokens moved
|
||||
conversation_history = conversation_history_after_compression(
|
||||
agent, messages
|
||||
)
|
||||
conversation_history = None
|
||||
agent._empty_content_retries = 0
|
||||
agent._thinking_prefill_retries = 0
|
||||
agent._last_content_with_tools = None
|
||||
@@ -443,7 +440,6 @@ def build_turn_context(
|
||||
agent._turn_failed_file_mutations = {}
|
||||
agent._turn_file_mutation_paths = set()
|
||||
agent._verification_stop_nudges = 0
|
||||
agent._pre_verify_nudges = 0
|
||||
|
||||
# Record the execution thread so interrupt()/clear_interrupt() can scope
|
||||
# the tool-level interrupt signal to THIS agent's thread only.
|
||||
|
||||
@@ -289,14 +289,7 @@ def finalize_turn(
|
||||
and len(_stripped) <= 24
|
||||
and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"}
|
||||
)
|
||||
_is_partial_stream_recovery = (
|
||||
str(_turn_exit_reason) == "partial_stream_recovery"
|
||||
)
|
||||
if (
|
||||
_is_empty_terminal
|
||||
or _is_partial_fragment
|
||||
or _is_partial_stream_recovery
|
||||
):
|
||||
if _is_empty_terminal or _is_partial_fragment:
|
||||
_explanation = agent._format_turn_completion_explanation(
|
||||
_turn_exit_reason
|
||||
)
|
||||
|
||||
@@ -67,11 +67,6 @@ class TurnRetryState:
|
||||
# ── Restart signals (read by the outer loop after the attempt) ───────
|
||||
restart_with_compressed_messages: bool = False
|
||||
restart_with_length_continuation: bool = False
|
||||
# Set when a content-filter stream stall (e.g. MiniMax "new_sensitive")
|
||||
# has been escalated to the fallback chain: the partial-stream content
|
||||
# was rolled back off ``messages`` and the loop should re-issue the API
|
||||
# call against the newly-activated provider (#32421).
|
||||
restart_with_rebuilt_messages: bool = False
|
||||
|
||||
def __iter__(self):
|
||||
# Convenience for debugging / tests: iterate (name, value) pairs.
|
||||
|
||||
+6
-156
@@ -15,135 +15,9 @@ from typing import Any, Iterable
|
||||
|
||||
_MAX_CHANGED_PATHS_IN_NUDGE = 8
|
||||
|
||||
# Non-code file extensions whose edits carry no verifiable runtime behavior:
|
||||
# documentation, prose, and data/markup that no test/build exercises. When a
|
||||
# turn touches ONLY these, verify-on-stop has nothing to check, so the nudge is
|
||||
# suppressed (this is fix "C" for the doc/markdown/skill false-positive — a
|
||||
# SKILL.md or README edit must never demand a /tmp verification script). A turn
|
||||
# that edits any non-listed path (a real source/code/config file) still nudges.
|
||||
_NON_CODE_VERIFY_EXTENSIONS = frozenset(
|
||||
{
|
||||
".md",
|
||||
".markdown",
|
||||
".mdx",
|
||||
".rst",
|
||||
".txt",
|
||||
".text",
|
||||
".adoc",
|
||||
".asciidoc",
|
||||
".org",
|
||||
".log",
|
||||
".csv",
|
||||
".tsv",
|
||||
}
|
||||
)
|
||||
|
||||
# Filenames (case-insensitive, extension-less or otherwise) that are pure prose
|
||||
# even without a recognized doc extension.
|
||||
_NON_CODE_VERIFY_FILENAMES = frozenset(
|
||||
{
|
||||
"license",
|
||||
"licence",
|
||||
"notice",
|
||||
"authors",
|
||||
"contributors",
|
||||
"changelog",
|
||||
"codeowners",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_non_code_path(raw: str) -> bool:
|
||||
"""Return True when a changed path is documentation/prose with nothing to verify."""
|
||||
try:
|
||||
p = Path(str(raw))
|
||||
except Exception:
|
||||
return False
|
||||
suffix = p.suffix.lower()
|
||||
if suffix in _NON_CODE_VERIFY_EXTENSIONS:
|
||||
return True
|
||||
if not suffix and p.name.lower() in _NON_CODE_VERIFY_FILENAMES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _filter_verifiable_paths(paths: Iterable[str]) -> list[str]:
|
||||
"""Drop documentation/prose paths; keep paths that could have verifiable behavior."""
|
||||
return [p for p in paths if p and not _is_non_code_path(p)]
|
||||
|
||||
|
||||
# Session identities (platform or source) that are NOT human conversational
|
||||
# messaging surfaces: interactive coding surfaces (CLI, TUI, desktop, codex,
|
||||
# local, gateway) and programmatic callers (API server, webhooks, tools).
|
||||
# Verify-on-stop stays ON by default for these. Any other resolved gateway
|
||||
# platform is a conversational messaging surface (Telegram, Discord, WhatsApp,
|
||||
# Signal, Slack, etc.) where the verification narrative would reach a human as
|
||||
# chat noise, so it defaults OFF. Mirrors LOCAL_SESSION_SOURCE_IDS in
|
||||
# apps/desktop/src/lib/session-source.ts; keep roughly in sync when adding a
|
||||
# local or programmatic surface. Default-deny by design: an unrecognized
|
||||
# identity is treated as messaging (OFF) so a new chat platform never leaks the
|
||||
# verification receipt before this set is updated.
|
||||
_NON_MESSAGING_SESSION_SURFACES = frozenset(
|
||||
{
|
||||
"",
|
||||
"cli",
|
||||
"codex",
|
||||
"desktop",
|
||||
"gateway",
|
||||
"local",
|
||||
"tui",
|
||||
"tool",
|
||||
"api_server",
|
||||
"webhook",
|
||||
"msgraph_webhook",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _session_is_messaging_surface() -> bool:
|
||||
"""Return whether this turn is delivered over a human messaging channel.
|
||||
|
||||
The gateway binds the platform value (e.g. ``telegram``) to
|
||||
``HERMES_SESSION_PLATFORM``; the CLI and TUI set ``HERMES_SESSION_SOURCE``
|
||||
(e.g. ``cli``, ``tui``) instead. Both are consulted via the session-context
|
||||
helper (with an ``os.environ`` fallback), alongside the ``HERMES_PLATFORM``
|
||||
override, matching the sibling platform resolution in
|
||||
``agent/skill_commands.py`` and ``agent/prompt_builder.py``. A turn is a
|
||||
messaging surface when a resolved identity is present and is not a known
|
||||
non-messaging surface.
|
||||
"""
|
||||
try:
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
platform = (
|
||||
os.getenv("HERMES_PLATFORM")
|
||||
or get_session_env("HERMES_SESSION_PLATFORM", "")
|
||||
)
|
||||
source = get_session_env("HERMES_SESSION_SOURCE", "")
|
||||
except Exception:
|
||||
platform = os.getenv("HERMES_PLATFORM", "") or os.environ.get(
|
||||
"HERMES_SESSION_PLATFORM", ""
|
||||
)
|
||||
source = os.environ.get("HERMES_SESSION_SOURCE", "")
|
||||
for identity in (platform, source):
|
||||
identity = str(identity or "").strip().lower()
|
||||
if identity and identity not in _NON_MESSAGING_SESSION_SURFACES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
|
||||
"""Return whether edit -> verify-before-finish behavior is enabled.
|
||||
|
||||
Precedence: an explicit ``HERMES_VERIFY_ON_STOP`` env var wins, then an
|
||||
explicit ``agent.verify_on_stop`` config value. The config default is
|
||||
``False`` (see ``DEFAULT_CONFIG``) — verify-on-stop is OFF unless the user
|
||||
opts in. The legacy ``"auto"`` sentinel is still honored for anyone who
|
||||
sets it explicitly: it resolves to ON for interactive coding surfaces
|
||||
(CLI, TUI, desktop) and programmatic callers, and OFF for conversational
|
||||
messaging surfaces (Telegram, Discord, etc.). A missing/unknown value
|
||||
falls back to OFF.
|
||||
"""
|
||||
"""Return whether edit -> verify-before-finish behavior is enabled."""
|
||||
env = os.environ.get("HERMES_VERIFY_ON_STOP")
|
||||
if env is not None:
|
||||
return env.strip().lower() not in {"0", "false", "no", "off"}
|
||||
@@ -155,20 +29,9 @@ def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
|
||||
except Exception:
|
||||
config = {}
|
||||
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
|
||||
cfg_val = agent_cfg.get("verify_on_stop") if isinstance(agent_cfg, dict) else None
|
||||
if isinstance(cfg_val, bool):
|
||||
return cfg_val
|
||||
if isinstance(cfg_val, str):
|
||||
token = cfg_val.strip().lower()
|
||||
if token in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if token in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
if token == "auto":
|
||||
# Explicit opt-in to the legacy surface-aware behavior.
|
||||
return not _session_is_messaging_surface()
|
||||
# Missing or unknown value -> OFF (the new default).
|
||||
return False
|
||||
if isinstance(agent_cfg, dict) and "verify_on_stop" in agent_cfg:
|
||||
return bool(agent_cfg.get("verify_on_stop"))
|
||||
return True
|
||||
|
||||
|
||||
def _candidate_cwds(paths: Iterable[str]) -> list[Path]:
|
||||
@@ -251,10 +114,7 @@ def build_verify_on_stop_nudge(
|
||||
max_attempts: int = 2,
|
||||
) -> str | None:
|
||||
"""Return a synthetic follow-up when edited code lacks fresh verification."""
|
||||
# Drop documentation/prose paths (markdown, skills, README, LICENSE, ...) —
|
||||
# they carry no verifiable behavior, so a turn that touched only those has
|
||||
# nothing to verify and must not nudge.
|
||||
paths = sorted({str(p) for p in _filter_verifiable_paths(changed_paths)})
|
||||
paths = sorted({str(p) for p in changed_paths if p})
|
||||
if not paths or attempts >= max_attempts:
|
||||
return None
|
||||
|
||||
@@ -273,15 +133,6 @@ def build_verify_on_stop_nudge(
|
||||
if state == "passed":
|
||||
return None
|
||||
|
||||
# Optional shipped coding guidance, only paid when this evidence gate fires.
|
||||
try:
|
||||
from agent.verify_hooks import coding_verify_guidance
|
||||
|
||||
guidance = coding_verify_guidance()
|
||||
except Exception:
|
||||
guidance = None
|
||||
addendum = f"\n\n{guidance}" if guidance else ""
|
||||
|
||||
if verify_commands:
|
||||
command_instruction = (
|
||||
"Run the relevant verification command now ("
|
||||
@@ -306,8 +157,7 @@ def build_verify_on_stop_nudge(
|
||||
f"Verification status: {_status_detail(status)}\n\n"
|
||||
f"Changed paths:\n{_format_changed_paths(paths)}\n\n"
|
||||
f"{command_instruction} If verification is not possible, explain the "
|
||||
"concrete blocker instead of claiming the work is fully verified."
|
||||
f"{addendum}]"
|
||||
"concrete blocker instead of claiming the work is fully verified.]"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Verification-loop helpers for the ``pre_verify`` round-end gate.
|
||||
|
||||
When the agent has edited code and is about to verify/finish, the loop fires the
|
||||
``pre_verify`` hook (user directives resolved by
|
||||
:func:`hermes_cli.plugins.get_pre_verify_continue_message`). A directive keeps
|
||||
the agent going one more turn — run a check, defer it, tidy the diff — instead of
|
||||
stopping immediately.
|
||||
|
||||
The shipped coding guidance lives on the evidence-based verification-stop nudge
|
||||
(``agent/verification_stop.py``), not as a second default stop gate. That keeps
|
||||
the default token cost tied to the existing "missing verification evidence"
|
||||
decision while preserving ``pre_verify`` for user/plugin policy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from utils import is_truthy_value
|
||||
|
||||
DEFAULT_MAX_VERIFY_NUDGES = 3
|
||||
|
||||
# Shipped guidance appended to the verification-stop nudge when code lacks fresh
|
||||
# verification evidence. Wording mirrors the user-facing "clean your work"
|
||||
# workflow, but does not create its own extra model turn.
|
||||
CODING_VERIFY_GUIDANCE = (
|
||||
"[Coding] Before you run tests/linters or call this done: if this is "
|
||||
"creative UI/visual work, hold off on tests and linters until the user says "
|
||||
"they like the result or you're about to commit. And before every commit, "
|
||||
"clean your work: keep it KISS/DRY, match the surrounding code style, and be "
|
||||
"elitist, shorthand, clever, concise, efficient, and elegant."
|
||||
)
|
||||
|
||||
|
||||
def max_verify_nudges(config: Optional[dict[str, Any]] = None) -> int:
|
||||
"""Bound on consecutive ``pre_verify`` continue directives per turn (>= 0)."""
|
||||
agent_cfg = _agent_cfg(config)
|
||||
raw = agent_cfg.get("max_verify_nudges")
|
||||
try:
|
||||
return max(0, int(raw))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_MAX_VERIFY_NUDGES
|
||||
|
||||
|
||||
def coding_verify_guidance(config: Optional[dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Return the optional guidance appended to verification-stop nudges."""
|
||||
if not is_truthy_value(_agent_cfg(config).get("verify_guidance", True), default=True):
|
||||
return None
|
||||
return CODING_VERIFY_GUIDANCE
|
||||
|
||||
|
||||
def _agent_cfg(config: Optional[dict[str, Any]]) -> dict[str, Any]:
|
||||
if config is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
except Exception:
|
||||
config = {}
|
||||
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
|
||||
return agent_cfg if isinstance(agent_cfg, dict) else {}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CODING_VERIFY_GUIDANCE",
|
||||
"DEFAULT_MAX_VERIFY_NUDGES",
|
||||
"coding_verify_guidance",
|
||||
"max_verify_nudges",
|
||||
]
|
||||
@@ -85,7 +85,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
|
||||
|
||||
### How it works
|
||||
|
||||
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.cjs` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
|
||||
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a `hermes dashboard` backend over the `tui_gateway`/dashboard APIs and reuses the agent runtime rather than embedding `hermes --tui`. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
|
||||
|
||||
### Verification
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
// Backend subcommand routing for the desktop-managed Hermes process.
|
||||
//
|
||||
// The desktop app launches its own headless backend via `hermes serve` — it
|
||||
// must NEVER depend on or launch the browser `dashboard`. But `serve` is a
|
||||
// newer subcommand: a runtime that predates it (an older managed install the
|
||||
// app hasn't updated yet, or an older `hermes` resolved from PATH) only knows
|
||||
// `dashboard --no-open`. To avoid bricking those users mid-upgrade we detect
|
||||
// whether the resolved runtime understands `serve` and, only when it does not,
|
||||
// fall back to the legacy `dashboard --no-open` invocation. Both produce the
|
||||
// exact same headless gateway; `serve` is just the decoupled name.
|
||||
//
|
||||
// These helpers are pure so they can be unit-tested without Electron.
|
||||
|
||||
/**
|
||||
* Build the canonical headless backend argv (always `serve`).
|
||||
* @param {string} [profile] optional Hermes profile to pin via `--profile`.
|
||||
*/
|
||||
function serveBackendArgs(profile) {
|
||||
const head = profile ? ['--profile', profile] : []
|
||||
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a resolved backend argv from `serve` to the legacy
|
||||
* `dashboard --no-open` form, preserving every other argument (incl. a leading
|
||||
* `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is
|
||||
* no `serve` token the argv is returned unchanged.
|
||||
*/
|
||||
function dashboardFallbackArgs(args) {
|
||||
const i = args.indexOf('serve')
|
||||
if (i === -1) return args.slice()
|
||||
return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a runtime's `hermes_cli/subcommands/dashboard.py` source registers
|
||||
* the `serve` subcommand. Matches `add_parser("serve"` / `add_parser('serve'`
|
||||
* specifically so the substring "server" (e.g. "start_server", "web server")
|
||||
* never produces a false positive.
|
||||
*/
|
||||
function sourceDeclaresServe(dashboardPySource) {
|
||||
return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serveBackendArgs,
|
||||
dashboardFallbackArgs,
|
||||
sourceDeclaresServe,
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
serveBackendArgs,
|
||||
dashboardFallbackArgs,
|
||||
sourceDeclaresServe,
|
||||
} = require('./backend-command.cjs')
|
||||
|
||||
test('serveBackendArgs builds a headless serve invocation', () => {
|
||||
assert.deepEqual(serveBackendArgs(), [
|
||||
'serve',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
test('serveBackendArgs pins a profile when provided', () => {
|
||||
assert.deepEqual(serveBackendArgs('worker'), [
|
||||
'--profile',
|
||||
'worker',
|
||||
'serve',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -m prefix', () => {
|
||||
const serve = ['-m', 'hermes_cli.main', 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
assert.deepEqual(dashboardFallbackArgs(serve), [
|
||||
'-m',
|
||||
'hermes_cli.main',
|
||||
'dashboard',
|
||||
'--no-open',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs preserves a --profile flag ahead of serve', () => {
|
||||
const serve = ['-m', 'hermes_cli.main', '--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
assert.deepEqual(dashboardFallbackArgs(serve), [
|
||||
'-m',
|
||||
'hermes_cli.main',
|
||||
'--profile',
|
||||
'worker',
|
||||
'dashboard',
|
||||
'--no-open',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs is a no-op (copy) when there is no serve token', () => {
|
||||
const args = ['-m', 'hermes_cli.main', 'dashboard', '--no-open']
|
||||
const out = dashboardFallbackArgs(args)
|
||||
assert.deepEqual(out, args)
|
||||
assert.notEqual(out, args, 'should return a copy, not the same reference')
|
||||
})
|
||||
|
||||
test('sourceDeclaresServe detects the serve subparser registration', () => {
|
||||
assert.equal(sourceDeclaresServe('subparsers.add_parser("serve", help="...")'), true)
|
||||
assert.equal(sourceDeclaresServe("subparsers.add_parser('serve')"), true)
|
||||
assert.equal(sourceDeclaresServe('subparsers.add_parser(\n "serve",\n)'), true)
|
||||
})
|
||||
|
||||
test('sourceDeclaresServe does not false-positive on the substring "server"', () => {
|
||||
const oldSource = `
|
||||
dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard")
|
||||
from hermes_cli.web_server import start_server # web server
|
||||
`
|
||||
assert.equal(sourceDeclaresServe(oldSource), false)
|
||||
})
|
||||
@@ -37,18 +37,7 @@ const { execFileSync } = require('node:child_process')
|
||||
const PROBE_TIMEOUT_MS = 5000
|
||||
|
||||
/**
|
||||
* Return the Python snippet used to verify Hermes can import far enough to
|
||||
* launch the CLI. Kept exported for tests so dependency regressions are
|
||||
* caught without needing a real broken venv fixture.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
function hermesRuntimeImportProbe() {
|
||||
return 'import yaml; import hermes_cli.config'
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true iff the Hermes runtime import probe exits 0.
|
||||
* Return true iff `python -c "import hermes_cli"` exits 0.
|
||||
*
|
||||
* Used to gate the "fallback to system Python with hermes_cli installed"
|
||||
* rung of resolveHermesBackend. Without this, a system Python 3.11-3.13
|
||||
@@ -57,20 +46,13 @@ function hermesRuntimeImportProbe() {
|
||||
* site-packages -- and the resolver returns a backend that immediately
|
||||
* dies on spawn.
|
||||
*
|
||||
* The probe intentionally imports hermes_cli.config, not just the top-level
|
||||
* package: a broken/empty Windows launcher venv can still see the source tree
|
||||
* through PYTHONPATH but lack PyYAML, then die on the first real CLI import.
|
||||
*
|
||||
* @param {string} pythonPath - Absolute path to a python.exe / python.
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.env] - Additional environment for the probe.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function canImportHermesCli(pythonPath, opts = {}) {
|
||||
function canImportHermesCli(pythonPath) {
|
||||
if (!pythonPath) return false
|
||||
try {
|
||||
execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
|
||||
env: { ...process.env, ...(opts.env || {}) },
|
||||
execFileSync(pythonPath, ['-c', 'import hermes_cli'], {
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
@@ -119,7 +101,6 @@ function verifyHermesCli(hermesCommand, opts = {}) {
|
||||
|
||||
module.exports = {
|
||||
canImportHermesCli,
|
||||
hermesRuntimeImportProbe,
|
||||
verifyHermesCli,
|
||||
PROBE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
|
||||
// Resolve the host's own Node binary -- guaranteed to be on disk and
|
||||
// runnable. We use it as both a stand-in for "a python that doesn't
|
||||
@@ -40,12 +40,6 @@ test('canImportHermesCli returns false when binary does not exist', () => {
|
||||
assert.equal(canImportHermesCli(ghost), false)
|
||||
})
|
||||
|
||||
test('hermes runtime import probe checks config dependencies', () => {
|
||||
const probe = hermesRuntimeImportProbe()
|
||||
assert.match(probe, /\bimport yaml\b/)
|
||||
assert.match(probe, /\bimport hermes_cli\.config\b/)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns false when command is falsy', () => {
|
||||
assert.equal(verifyHermesCli(''), false)
|
||||
assert.equal(verifyHermesCli(null), false)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const { session } = require('electron')
|
||||
|
||||
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
|
||||
const EMBED_REFERER = 'https://www.youtube.com/'
|
||||
const YOUTUBE_REFERER_HOST_RE =
|
||||
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
|
||||
|
||||
function installEmbedRefererForSession(embedSession) {
|
||||
if (!embedSession) {
|
||||
return
|
||||
}
|
||||
|
||||
embedSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
let host = ''
|
||||
|
||||
try {
|
||||
host = new URL(details.url).hostname
|
||||
} catch {
|
||||
host = ''
|
||||
}
|
||||
|
||||
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
return
|
||||
}
|
||||
|
||||
const headers = { ...details.requestHeaders }
|
||||
|
||||
if (!headers.Referer && !headers.referer) {
|
||||
headers.Referer = EMBED_REFERER
|
||||
}
|
||||
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
||||
/** Stamp Referer on YouTube requests in the embed webview partition only. */
|
||||
function installEmbedReferer() {
|
||||
try {
|
||||
installEmbedRefererForSession(session.fromPartition(EMBED_SESSION_PARTITION))
|
||||
} catch {
|
||||
// Non-fatal: embeds still render; YouTube may show referer errors.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { installEmbedReferer }
|
||||
@@ -1,96 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const { scanGitRepos } = require('./git-repo-scan.cjs')
|
||||
const {
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
} = require('./git-review-ops.cjs')
|
||||
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
|
||||
|
||||
// Register the git/worktree/review IPC handlers. Thin delegators to the
|
||||
// git-*-ops sibling modules; the git/gh binary resolution lives in the main
|
||||
// process (Windows PATH discovery) and is injected so this module stays pure.
|
||||
function registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary }) {
|
||||
// Git-driven worktree management ("Start work" flow). Errors surface to the
|
||||
// renderer as rejected promises so it can toast a friendly message.
|
||||
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
|
||||
addWorktree(repoPath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
|
||||
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
switchBranch(repoPath, branch, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
|
||||
|
||||
// Codex-style review pane: list changed files for a scope, fetch one file's
|
||||
// unified diff, and stage / unstage / revert. Reads return empty on failure;
|
||||
// mutations reject so the renderer can toast.
|
||||
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
|
||||
reviewList(repoPath, scope, baseRef, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
|
||||
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
|
||||
)
|
||||
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
|
||||
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
|
||||
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
|
||||
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
|
||||
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
|
||||
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
|
||||
reviewRevParse(repoPath, ref, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
|
||||
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
|
||||
reviewCommitContext(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
|
||||
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
|
||||
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
|
||||
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
|
||||
)
|
||||
|
||||
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
|
||||
// no native addon). Never throws to the renderer — failures yield an empty list.
|
||||
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
|
||||
try {
|
||||
return await scanGitRepos(roots || [], options || {})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerGitIpc }
|
||||
@@ -1,61 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerGitIpc } = require('./git-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('registerGitIpc wires only hermes:git:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerGitIpc({ ipcMain, resolveGitBinary: () => 'git', resolveGhBinary: () => 'gh' })
|
||||
|
||||
assert.ok(ipcMain.handlers.size >= 19, `expected the full git surface, got ${ipcMain.handlers.size}`)
|
||||
|
||||
for (const [channel, handler] of ipcMain.handlers) {
|
||||
assert.match(channel, /^hermes:git:/, `${channel} is not a git channel`)
|
||||
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
|
||||
}
|
||||
|
||||
// Spot-check the load-bearing channels across the worktree / review / scan groups.
|
||||
for (const channel of ['hermes:git:worktreeList', 'hermes:git:review:commit', 'hermes:git:scanRepos']) {
|
||||
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('handlers thread the injected resolver into the ops layer', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
const calls = []
|
||||
|
||||
registerGitIpc({
|
||||
ipcMain,
|
||||
resolveGitBinary: () => {
|
||||
calls.push('git')
|
||||
|
||||
return 'git'
|
||||
},
|
||||
resolveGhBinary: () => 'gh'
|
||||
})
|
||||
|
||||
// The resolver is consulted synchronously to build the ops call; whatever the
|
||||
// ops layer does with a non-repo path is irrelevant to the wiring.
|
||||
try {
|
||||
await ipcMain.handlers.get('hermes:git:worktreeList')({}, '/definitely/not/a/repo')
|
||||
} catch {
|
||||
// ops layer may reject on a bad path — not what this test asserts.
|
||||
}
|
||||
|
||||
assert.deepEqual(calls, ['git'])
|
||||
})
|
||||
@@ -10,26 +10,7 @@ const { execFile } = require('node:child_process')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
|
||||
// `simple-git` is a pure-JS runtime dep that workspace dedup hoists into the
|
||||
// repo-root node_modules. Packaged builds set `files:` in package.json, which
|
||||
// excludes node_modules from the asar, so the normal require() fails at launch
|
||||
// (issue #52735: "Cannot find module 'simple-git'"). We ship the dep's
|
||||
// closure under resources/native-deps/vendor/node_modules/ via extraResources
|
||||
// + scripts/stage-native-deps.cjs, and resolve from there when the hoisted
|
||||
// require() isn't reachable. The `vendor/` nesting matters: electron-builder
|
||||
// drops a node_modules dir at the root of an extraResources copy but keeps a
|
||||
// nested one. Dev mode never hits the fallback -- Node's normal lookup finds
|
||||
// the hoisted copy.
|
||||
let simpleGit
|
||||
try {
|
||||
simpleGit = require('simple-git')
|
||||
} catch {
|
||||
const resourcesPath = process.resourcesPath
|
||||
if (!resourcesPath) {
|
||||
throw new Error("git-review IPC: 'simple-git' not found and no resourcesPath to fall back to")
|
||||
}
|
||||
simpleGit = require(path.join(resourcesPath, 'native-deps', 'vendor', 'node_modules', 'simple-git'))
|
||||
}
|
||||
const simpleGit = require('simple-git')
|
||||
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
|
||||
+192
-192
@@ -24,7 +24,6 @@ const https = require('node:https')
|
||||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { installEmbedReferer } = require('./embed-referer.cjs')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const {
|
||||
@@ -39,7 +38,6 @@ const { createLinkTitleWindow } = require('./link-title-window.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs')
|
||||
const { dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
@@ -58,7 +56,23 @@ const {
|
||||
buildRelaunchScript
|
||||
} = require('./update-relaunch.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { registerGitIpc } = require('./git-ipc.cjs')
|
||||
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
|
||||
const {
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
} = require('./git-review-ops.cjs')
|
||||
const { scanGitRepos } = require('./git-repo-scan.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
@@ -519,10 +533,9 @@ function getTitleBarOverlayOptions() {
|
||||
return { height: TITLEBAR_HEIGHT }
|
||||
}
|
||||
|
||||
// WSLg paints WCO via the RDP host's own min/max/close, so requesting
|
||||
// an Electron overlay there just leaves a dead gap. Plain Linux (KDE,
|
||||
// GNOME) can use the native overlay — let it through.
|
||||
if (!IS_WINDOWS && IS_WSL) {
|
||||
// Windows + WSLg paint WCO natively; plain Linux disables it (frameless hidden
|
||||
// titlebar still applies).
|
||||
if (!IS_WINDOWS && !IS_WSL) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -776,7 +789,7 @@ let rendererReloadTimes = []
|
||||
// the renderer's "Reload and retry" path or by quitting the app.
|
||||
let bootstrapFailure = null
|
||||
// Latched non-bootstrap backend spawn failure — stops getConnection() from
|
||||
// respawning hermes serve backend children in a tight loop while boot is broken.
|
||||
// respawning hermes dashboard children in a tight loop while boot is broken.
|
||||
let backendStartFailure = null
|
||||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
@@ -1270,14 +1283,8 @@ function findOnPath(command) {
|
||||
const pathEntries = String(process.env.PATH || '')
|
||||
.split(path.delimiter)
|
||||
.filter(Boolean)
|
||||
// On Windows, try PATHEXT extensions BEFORE the bare (empty-extension) name.
|
||||
// A real command must resolve via its .exe/.cmd (Windows command-resolution
|
||||
// semantics consult PATHEXT); an extensionless file — e.g. a Git-Bash
|
||||
// shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`.
|
||||
// The empty entry is kept LAST so callers that already include the extension
|
||||
// (py.exe, pwsh.exe, powershell.exe) still resolve.
|
||||
const extensions = IS_WINDOWS
|
||||
? [...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
|
||||
? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)]
|
||||
: ['']
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
@@ -1294,7 +1301,7 @@ function isCommandScript(command) {
|
||||
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '')
|
||||
}
|
||||
|
||||
function unwrapWindowsVenvHermesCommand(command, backendArgs) {
|
||||
function unwrapWindowsVenvHermesCommand(command, dashboardArgs) {
|
||||
if (!IS_WINDOWS || !command || isCommandScript(command)) return null
|
||||
|
||||
const resolved = path.resolve(String(command))
|
||||
@@ -1304,14 +1311,14 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) {
|
||||
if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null
|
||||
|
||||
const venvRoot = path.dirname(scriptsDir)
|
||||
const python = getVenvPython(venvRoot)
|
||||
const python = getNoConsoleVenvPython(venvRoot)
|
||||
if (!fileExists(python)) return null
|
||||
|
||||
const root = path.dirname(venvRoot)
|
||||
return {
|
||||
label: `existing Hermes Python at ${python}`,
|
||||
label: `existing Hermes no-console Python at ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
bootstrap: false,
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
@@ -1319,69 +1326,11 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) {
|
||||
venvRoot
|
||||
}),
|
||||
kind: 'python',
|
||||
// Surfaced so backendSupportsServe() can read this runtime's source for the
|
||||
// `serve` capability check instead of falling back to a heavyweight probe.
|
||||
root,
|
||||
readyFile: true,
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
|
||||
// Does the resolved runtime understand the `serve` subcommand? The desktop
|
||||
// spawns `hermes serve`; runtimes older than serve only have `dashboard`. We
|
||||
// detect support so getBackendArgsForRuntime() can route old runtimes through
|
||||
// the legacy `dashboard --no-open` form instead of crashing on an unknown
|
||||
// subcommand (would brick every user mid-upgrade — #54568 follow-up).
|
||||
//
|
||||
// Fast path: read the runtime's own dashboard.py (instant, covers managed
|
||||
// installs, dev checkouts, and the Windows venv). Fallback: probe the CLI once
|
||||
// (covers a bare `hermes` resolved from PATH with no known source root). Result
|
||||
// is cached per resolved runtime so we probe at most once per backend.
|
||||
const _serveSupportCache = new Map()
|
||||
function backendSupportsServe(backend) {
|
||||
if (!backend || !backend.command) return true
|
||||
const key = `${backend.command}::${backend.root || ''}`
|
||||
if (_serveSupportCache.has(key)) return _serveSupportCache.get(key)
|
||||
|
||||
let supported = null
|
||||
if (backend.root) {
|
||||
try {
|
||||
const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8')
|
||||
supported = sourceDeclaresServe(src)
|
||||
} catch {
|
||||
supported = null // source unreadable — fall through to the probe
|
||||
}
|
||||
}
|
||||
|
||||
if (supported === null) {
|
||||
try {
|
||||
const prefix = backend.args && backend.args[0] === '-m' ? backend.args.slice(0, 2) : []
|
||||
execFileSync(backend.command, [...prefix, 'serve', '--help'], {
|
||||
cwd: backend.root || undefined,
|
||||
env: { ...process.env, HERMES_HOME, ...(backend.env || {}) },
|
||||
timeout: 15000,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
supported = true
|
||||
} catch {
|
||||
supported = false
|
||||
}
|
||||
}
|
||||
|
||||
_serveSupportCache.set(key, supported)
|
||||
rememberLog(
|
||||
`[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}`
|
||||
)
|
||||
return supported
|
||||
}
|
||||
|
||||
// Given a resolved backend whose args target `serve`, return the args the
|
||||
// runtime actually understands: unchanged when `serve` is supported, or
|
||||
// rewritten to `dashboard --no-open` for older runtimes.
|
||||
function getBackendArgsForRuntime(backend) {
|
||||
return backendSupportsServe(backend) ? backend.args : dashboardFallbackArgs(backend.args)
|
||||
}
|
||||
|
||||
function normalizeExecutablePathForCompare(commandPath) {
|
||||
if (!commandPath) return null
|
||||
|
||||
@@ -1602,26 +1551,64 @@ function getVenvPython(venvRoot) {
|
||||
return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python'))
|
||||
}
|
||||
|
||||
// Windows console-window flashes are governed by the *parent's* console, not by
|
||||
// each child spawn. A GUI-subsystem parent (pythonw.exe) has no console, so every
|
||||
// console-subsystem child it spawns (git, gh, cmd, ...) must allocate its own —
|
||||
// which flashes a window. A console-subsystem parent (python.exe) instead owns a
|
||||
// single console that all of its children inherit, so none of them flash.
|
||||
//
|
||||
// Note this change adds no new creationflag: the backend spawn is ALREADY wrapped
|
||||
// in hiddenWindowsChildOptions() (windowsHide: true), but that setting is INERT
|
||||
// against pythonw.exe — a GUI-subsystem process has no console for it to act on.
|
||||
// Switching the backend to the venv's console python.exe is what makes the
|
||||
// existing wrapper load-bearing: with windowsHide the process comes up owning a
|
||||
// *windowless* console (verified at runtime — it has an attachable console whose
|
||||
// window handle is NULL), and its children inherit that one windowless console
|
||||
// instead of each allocating a visible one.
|
||||
//
|
||||
// This makes "no flashing windows" a property of the one backend launch rather
|
||||
// than a flag that has to be remembered at every descendant spawn site. Restoring
|
||||
// console python also restores stdout, so the backend announces its port on the
|
||||
// normal HERMES_DASHBOARD_READY stdout line and no ready-file side channel is
|
||||
// needed.
|
||||
function readVenvHome(venvRoot) {
|
||||
try {
|
||||
const cfg = fs.readFileSync(path.join(venvRoot, 'pyvenv.cfg'), 'utf8')
|
||||
const match = cfg.match(/^home\s*=\s*(.+?)\s*$/im)
|
||||
return match ? match[1].trim() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getNoConsoleVenvPython(venvRoot) {
|
||||
if (!IS_WINDOWS) return getVenvPython(venvRoot)
|
||||
|
||||
// Prefer the venv's own pythonw shim — it carries pyvenv.cfg / site-packages
|
||||
// wiring. Falling back to the base uv/python.org pythonw.exe skips the venv
|
||||
// and breaks imports (yaml, hermes_cli, …) even when PYTHONPATH is patched.
|
||||
const venvPythonw = path.join(venvRoot, 'Scripts', 'pythonw.exe')
|
||||
if (fileExists(venvPythonw)) return venvPythonw
|
||||
|
||||
const baseHome = readVenvHome(venvRoot)
|
||||
if (baseHome) {
|
||||
const basePythonw = path.join(baseHome, 'pythonw.exe')
|
||||
if (fileExists(basePythonw)) return basePythonw
|
||||
}
|
||||
|
||||
return venvPythonw
|
||||
}
|
||||
|
||||
function toNoConsolePython(pythonPath) {
|
||||
if (!IS_WINDOWS || !pythonPath) return pythonPath
|
||||
|
||||
const resolved = String(pythonPath)
|
||||
if (/pythonw\.exe$/i.test(resolved)) return resolved
|
||||
|
||||
if (/python\.exe$/i.test(resolved)) {
|
||||
const pythonw = path.join(path.dirname(resolved), 'pythonw.exe')
|
||||
if (fileExists(pythonw)) return pythonw
|
||||
}
|
||||
|
||||
return pythonPath
|
||||
}
|
||||
|
||||
function applyWindowsNoConsoleSpawnHints(backend) {
|
||||
if (!IS_WINDOWS || !backend?.command) return backend
|
||||
|
||||
const usesHermesModule =
|
||||
backend.kind === 'python' ||
|
||||
(Array.isArray(backend.args) && backend.args[0] === '-m' && backend.args[1] === 'hermes_cli.main')
|
||||
|
||||
if (!usesHermesModule) return backend
|
||||
|
||||
backend.command = toNoConsolePython(backend.command)
|
||||
if (/pythonw\.exe$/i.test(path.basename(String(backend.command || '')))) {
|
||||
backend.readyFile = true
|
||||
}
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
function getVenvSitePackagesEntries(venvRoot) {
|
||||
const entries = []
|
||||
@@ -1976,16 +1963,6 @@ async function readCommitLog(cwd, branch) {
|
||||
|
||||
let updateInFlight = false
|
||||
|
||||
// Set to true when the desktop is about to quit so a detached swap/install/
|
||||
// uninstall script can take over. On macOS, app.quit() closes windows but
|
||||
// window-all-closed deliberately keeps the process alive (standard Electron
|
||||
// macOS convention). Without this flag the process never exits — the detached
|
||||
// hand-off script spins its PID-wait for the full timeout, and the user sees a
|
||||
// blank app with no window (and an uninstall that appears to do nothing). When
|
||||
// set, window-all-closed calls app.quit() on every platform so the process
|
||||
// actually dies and the hand-off script can proceed immediately.
|
||||
let isQuittingForHandoff = false
|
||||
|
||||
// Resolve the staged updater binary. The Tauri installer copies itself to
|
||||
// HERMES_HOME/hermes-setup.exe on a successful install (see
|
||||
// apps/bootstrap-installer paths::copy_self_to_hermes_home). That binary owns
|
||||
@@ -2241,7 +2218,6 @@ async function applyUpdates(opts = {}) {
|
||||
// appears), THEN quit to release the venv shim. The updater rebuilds and
|
||||
// relaunches us when it's done. (#50419 — a 600ms quit looked like a crash
|
||||
// and lured users into the #50238 relaunch loop.)
|
||||
isQuittingForHandoff = true
|
||||
setTimeout(() => {
|
||||
app.quit()
|
||||
}, UPDATE_HANDOFF_DWELL_MS)
|
||||
@@ -2265,16 +2241,7 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
||||
: configuredBranch || DEFAULT_UPDATE_BRANCH
|
||||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||||
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
|
||||
const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python')
|
||||
// Choose the gentle in-place --update when ANY real-install signal is present,
|
||||
// not just the `hermes.exe` console-script shim. That shim is generated at the
|
||||
// END of venv setup and is absent in exactly the interrupted/quarantined states
|
||||
// this recovery exists to heal — gating on it alone forced the destructive
|
||||
// --repair (full venv recreate) and drove reinstall loops. The venv interpreter
|
||||
// and the bootstrap-complete marker are present earlier and are better signals.
|
||||
const haveRealInstall =
|
||||
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
@@ -2297,7 +2264,6 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
||||
// Same dwell as the in-app update hand-off (#50419): give the updater's
|
||||
// window time to appear before we vanish, so the recovery doesn't look like
|
||||
// a crash and provoke a mid-recovery relaunch.
|
||||
isQuittingForHandoff = true
|
||||
setTimeout(() => {
|
||||
app.quit()
|
||||
}, UPDATE_HANDOFF_DWELL_MS)
|
||||
@@ -2377,14 +2343,14 @@ async function applyUpdatesPosixInApp() {
|
||||
PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin'))
|
||||
}
|
||||
|
||||
// `hermes update` reaps stale `hermes serve` backends (a code update
|
||||
// `hermes update` reaps stale `hermes dashboard` backends (a code update
|
||||
// leaves the running process serving old Python against the freshly-updated
|
||||
// JS bundle). But OUR backend is one of those processes, and killing it
|
||||
// mid-update produces the boot→kill→crash loop in #37532 — the desktop
|
||||
// already restarts its own backend via the rebuild+relaunch below, so the
|
||||
// reap must spare it. Hand the live backend's PID to the update process;
|
||||
// _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes
|
||||
// it while still reaping any genuinely-orphaned backends. (#37532)
|
||||
// it while still reaping any genuinely-orphaned dashboards. (#37532)
|
||||
// Exclude every desktop-managed backend (primary + all pool profiles) from
|
||||
// the update reaper. _kill_stale_dashboard_processes accepts a comma-separated
|
||||
// list (a single int still parses for back-compat).
|
||||
@@ -2505,7 +2471,6 @@ async function applyUpdatesPosixInApp() {
|
||||
`[updates] launched linux relaunch: ${scriptPath} -> ${process.execPath} ` +
|
||||
`(args=${relaunchArgs.length}, env=${Object.keys(relaunchEnv).length})`
|
||||
)
|
||||
isQuittingForHandoff = true
|
||||
setTimeout(() => app.quit(), UPDATE_HANDOFF_DWELL_MS)
|
||||
return { ok: true, handedOff: true }
|
||||
} catch (err) {
|
||||
@@ -2611,7 +2576,6 @@ fi
|
||||
child.unref()
|
||||
rememberLog(`[updates] launched mac swap+relaunch: ${scriptPath} (${rebuiltApp} -> ${targetApp})`)
|
||||
|
||||
isQuittingForHandoff = true
|
||||
setTimeout(() => app.quit(), 600)
|
||||
return { ok: true, handedOff: true, rebuiltApp, targetApp }
|
||||
}
|
||||
@@ -2642,24 +2606,6 @@ function readBootstrapMarker() {
|
||||
return readJson(BOOTSTRAP_COMPLETE_MARKER)
|
||||
}
|
||||
|
||||
// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually
|
||||
// runnable right now? A complete CLI install (`install.sh --include-desktop`)
|
||||
// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop
|
||||
// ever having written the bootstrap marker -- so we must be able to recognise
|
||||
// "already installed" off the filesystem alone, not just the marker.
|
||||
function isActiveRuntimeUsable() {
|
||||
const venvPython = getVenvPython(VENV_ROOT)
|
||||
return (
|
||||
isHermesSourceRoot(ACTIVE_HERMES_ROOT) &&
|
||||
fileExists(venvPython) &&
|
||||
canImportHermesCli(venvPython, {
|
||||
env: {
|
||||
PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function isBootstrapComplete() {
|
||||
const marker = readBootstrapMarker()
|
||||
if (!marker || typeof marker !== 'object') return false
|
||||
@@ -2672,7 +2618,7 @@ function isBootstrapComplete() {
|
||||
// a runnable venv: an interrupted or split-home install can leave the marker
|
||||
// + checkout without a venv, and trusting that spawns a dead backend
|
||||
// ("gateway offline") instead of re-running bootstrap to repair it.
|
||||
return isActiveRuntimeUsable()
|
||||
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
|
||||
}
|
||||
|
||||
function writeBootstrapMarker(payload) {
|
||||
@@ -2835,60 +2781,60 @@ function writeDefaultProjectDir(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
function createPythonBackend(root, label, backendArgs, options = {}) {
|
||||
function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
const python = findPythonForRoot(root)
|
||||
if (!python) return null
|
||||
|
||||
const venvRoot = path.join(root, 'venv')
|
||||
const venvPython = getVenvPython(venvRoot)
|
||||
const command = IS_WINDOWS && fileExists(venvPython) ? venvPython : python
|
||||
const command = IS_WINDOWS && fileExists(venvPython) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python)
|
||||
|
||||
return {
|
||||
return applyWindowsNoConsoleSpawnHints({
|
||||
kind: 'python',
|
||||
label,
|
||||
command,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)],
|
||||
pythonPathEntries: [root],
|
||||
venvRoot
|
||||
}),
|
||||
root,
|
||||
bootstrap: Boolean(options.bootstrap),
|
||||
shell: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the
|
||||
// canonical install location shared with the CLI installer. The venv at
|
||||
// VENV_ROOT may not exist yet on first run; bootstrap=true tells
|
||||
// ensureRuntime() to create / refresh it before launch.
|
||||
function createActiveBackend(backendArgs) {
|
||||
function createActiveBackend(dashboardArgs) {
|
||||
const venvPython = getVenvPython(VENV_ROOT)
|
||||
const command = fileExists(venvPython) ? venvPython : findSystemPython()
|
||||
const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython())
|
||||
|
||||
return {
|
||||
return applyWindowsNoConsoleSpawnHints({
|
||||
kind: 'python',
|
||||
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
|
||||
command,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)],
|
||||
pythonPathEntries: [ACTIVE_HERMES_ROOT],
|
||||
venvRoot: VENV_ROOT
|
||||
}),
|
||||
root: ACTIVE_HERMES_ROOT,
|
||||
bootstrap: true,
|
||||
shell: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resolveHermesBackend(backendArgs) {
|
||||
function resolveHermesBackend(dashboardArgs) {
|
||||
// 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer
|
||||
// checkout. Honour it as-is (no bootstrap; the user is driving).
|
||||
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
|
||||
if (overrideRoot && isHermesSourceRoot(overrideRoot)) {
|
||||
const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, backendArgs)
|
||||
const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, dashboardArgs)
|
||||
if (backend) return backend
|
||||
}
|
||||
|
||||
@@ -2897,7 +2843,7 @@ function resolveHermesBackend(backendArgs) {
|
||||
// installed `hermes` on PATH so local Python edits are actually exercised.
|
||||
// (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
|
||||
if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
|
||||
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, backendArgs)
|
||||
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
|
||||
if (backend) return backend
|
||||
}
|
||||
|
||||
@@ -2908,7 +2854,7 @@ function resolveHermesBackend(backendArgs) {
|
||||
// to spawning hermes. Updates flow through the in-app update path
|
||||
// (applyUpdates -> git pull) or `hermes update` from the CLI.
|
||||
if (isBootstrapComplete()) {
|
||||
return createActiveBackend(backendArgs)
|
||||
return createActiveBackend(dashboardArgs)
|
||||
}
|
||||
|
||||
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
|
||||
@@ -2941,7 +2887,7 @@ function resolveHermesBackend(backendArgs) {
|
||||
}
|
||||
|
||||
if (hermesCommand) {
|
||||
const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs)
|
||||
const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs)
|
||||
if (unwrapped) {
|
||||
return unwrapped
|
||||
}
|
||||
@@ -2956,10 +2902,10 @@ function resolveHermesBackend(backendArgs) {
|
||||
const shellForProbe = isCommandScript(hermesCommand)
|
||||
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
|
||||
return (
|
||||
unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || {
|
||||
unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || {
|
||||
label: `existing Hermes CLI at ${hermesCommand}`,
|
||||
command: hermesCommand,
|
||||
args: backendArgs,
|
||||
args: dashboardArgs,
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
kind: 'command',
|
||||
@@ -2987,15 +2933,15 @@ function resolveHermesBackend(backendArgs) {
|
||||
// failure, fall through to step 6 so the bootstrap runner pulls
|
||||
// a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv.
|
||||
if (canImportHermesCli(python)) {
|
||||
return {
|
||||
return applyWindowsNoConsoleSpawnHints({
|
||||
kind: 'python',
|
||||
label: `installed hermes_cli module via ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
command: toNoConsolePython(python),
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
shell: false
|
||||
}
|
||||
})
|
||||
}
|
||||
rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`)
|
||||
}
|
||||
@@ -3014,7 +2960,7 @@ function resolveHermesBackend(backendArgs) {
|
||||
kind: 'bootstrap-needed',
|
||||
label: 'Hermes Agent not installed yet; bootstrap required',
|
||||
command: null,
|
||||
args: backendArgs,
|
||||
args: dashboardArgs,
|
||||
bootstrap: true,
|
||||
env: {},
|
||||
shell: false,
|
||||
@@ -3029,7 +2975,7 @@ function resolveHermesBackend(backendArgs) {
|
||||
async function ensureRuntime(backend) {
|
||||
if (!backend.bootstrap) {
|
||||
await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32)
|
||||
return backend
|
||||
return applyWindowsNoConsoleSpawnHints(backend)
|
||||
}
|
||||
|
||||
// backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't
|
||||
@@ -3171,7 +3117,7 @@ async function ensureRuntime(backend) {
|
||||
)
|
||||
}
|
||||
|
||||
backend.command = getVenvPython(VENV_ROOT)
|
||||
backend.command = getNoConsoleVenvPython(VENV_ROOT)
|
||||
backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})`
|
||||
updateBootProgress({
|
||||
phase: 'runtime.ready',
|
||||
@@ -3180,7 +3126,7 @@ async function ensureRuntime(backend) {
|
||||
running: true,
|
||||
error: null
|
||||
})
|
||||
return backend
|
||||
return applyWindowsNoConsoleSpawnHints(backend)
|
||||
}
|
||||
|
||||
function fetchJson(url, token, options = {}) {
|
||||
@@ -3841,7 +3787,7 @@ function getWindowButtonPosition() {
|
||||
}
|
||||
|
||||
function getNativeOverlayWidth() {
|
||||
return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL, isMac: IS_MAC })
|
||||
return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL })
|
||||
}
|
||||
|
||||
function getWindowState() {
|
||||
@@ -5247,10 +5193,8 @@ async function spawnPoolBackend(profile, entry) {
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const backendArgs = ['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
|
||||
// Route old runtimes (no `serve`) through the legacy `dashboard --no-open`.
|
||||
backend.args = getBackendArgsForRuntime(backend)
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
|
||||
@@ -5466,7 +5410,7 @@ async function startHermes() {
|
||||
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const backendArgs = ['serve', '--host', '127.0.0.1', '--port', '0']
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
|
||||
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
|
||||
@@ -5474,12 +5418,10 @@ async function startHermes() {
|
||||
// unaffected.
|
||||
const activeProfile = readActiveDesktopProfile()
|
||||
if (activeProfile) {
|
||||
backendArgs.unshift('--profile', activeProfile)
|
||||
dashboardArgs.unshift('--profile', activeProfile)
|
||||
}
|
||||
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
|
||||
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
|
||||
// Route old runtimes (no `serve`) through the legacy `dashboard --no-open`.
|
||||
backend.args = getBackendArgsForRuntime(backend)
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
|
||||
@@ -6987,9 +6929,75 @@ ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
|
||||
return true
|
||||
})
|
||||
|
||||
// Git/worktree/review IPC lives in git-ipc.cjs; the git + gh binary resolvers
|
||||
// stay here (Windows PATH discovery) and are injected into the registrar.
|
||||
registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary })
|
||||
// Git-driven worktree management ("Start work" flow). Errors surface to the
|
||||
// renderer as rejected promises so it can toast a friendly message.
|
||||
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
|
||||
addWorktree(repoPath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
|
||||
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
switchBranch(repoPath, branch, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
|
||||
|
||||
// Codex-style review pane: list changed files for a scope, fetch one file's
|
||||
// unified diff, and stage / unstage / revert. Reads return empty on failure;
|
||||
// mutations reject so the renderer can toast.
|
||||
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
|
||||
reviewList(repoPath, scope, baseRef, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
|
||||
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
|
||||
)
|
||||
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
|
||||
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
|
||||
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
|
||||
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
|
||||
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
|
||||
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
|
||||
reviewRevParse(repoPath, ref, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
|
||||
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
|
||||
reviewCommitContext(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
|
||||
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
|
||||
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
|
||||
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
|
||||
)
|
||||
|
||||
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
|
||||
// no native addon). Never throws to the renderer — failures yield an empty list.
|
||||
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
|
||||
try {
|
||||
return await scanGitRepos(roots || [], options || {})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
@@ -7314,7 +7322,6 @@ async function runDesktopUninstall(mode) {
|
||||
|
||||
// Give the renderer a beat to show its "uninstalling…" state, then quit so
|
||||
// the venv python shim + app bundle unlock and the cleanup script can run.
|
||||
isQuittingForHandoff = true
|
||||
setTimeout(() => app.quit(), 800)
|
||||
return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath }
|
||||
}
|
||||
@@ -7440,7 +7447,6 @@ app.whenReady().then(() => {
|
||||
}
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
installEmbedReferer()
|
||||
registerDeepLinkProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
configureSpellChecker()
|
||||
@@ -7520,11 +7526,5 @@ app.on('before-quit', () => {
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// macOS convention: keep the process alive in the Dock when the user closes
|
||||
// the last window. But when we're handing off to a detached updater / swap /
|
||||
// uninstall script, the process MUST exit so the script can replace or remove
|
||||
// the bundle and relaunch — without this the script's PID-wait spins to its
|
||||
// full timeout and the user is left with an invisible app (or an uninstall
|
||||
// that appears to do nothing).
|
||||
if (process.platform !== 'darwin' || isQuittingForHandoff) app.quit()
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
'use strict'
|
||||
// Pre-layout fallback for WCO right-edge reservation (--titlebar-tools-right).
|
||||
// Live width comes from navigator.windowControlsOverlay in the renderer.
|
||||
|
||||
const OVERLAY_FALLBACK_WIDTH = 144
|
||||
|
||||
/**
|
||||
* Static pre-layout reservation (px) for the right-side native window-controls
|
||||
* overlay (min/max/close). Only a FALLBACK — once laid out the renderer reads
|
||||
* the exact width from navigator.windowControlsOverlay
|
||||
* (use-window-controls-overlay-width.ts) and uses this value only when the WCO
|
||||
* API is unavailable.
|
||||
*
|
||||
* macOS uses traffic lights positioned via trafficLightPosition, not a WCO
|
||||
* overlay, so it reserves nothing here. Every other desktop platform now paints
|
||||
* the Electron overlay (Windows, WSLg, and plain Linux KDE/GNOME), so they all
|
||||
* reserve the fallback width.
|
||||
*
|
||||
* @param {{ isWindows?: boolean, isWsl?: boolean, isMac?: boolean }} opts
|
||||
*/
|
||||
function nativeOverlayWidth({ isWindows = false, isWsl = false, isMac = false } = {}) {
|
||||
if (isMac) return 0
|
||||
return OVERLAY_FALLBACK_WIDTH
|
||||
/** @param {{ isWindows?: boolean, isWsl?: boolean }} opts */
|
||||
function nativeOverlayWidth({ isWindows = false, isWsl = false } = {}) {
|
||||
return isWindows || isWsl ? OVERLAY_FALLBACK_WIDTH : 0
|
||||
}
|
||||
|
||||
module.exports = { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth }
|
||||
|
||||
@@ -18,17 +18,10 @@ test('WSLg paints the same WCO, so it reserves the same fallback width', () => {
|
||||
assert.equal(nativeOverlayWidth({ isWsl: true }), OVERLAY_FALLBACK_WIDTH)
|
||||
})
|
||||
|
||||
test('plain Linux paints the WCO too, so it reserves the fallback width', () => {
|
||||
// Regression #53185: re-enabling the overlay on plain Linux (KDE/GNOME)
|
||||
// without reserving its width left the native min/max/close buttons painting
|
||||
// on top of the app's right-edge titlebar tools.
|
||||
assert.equal(nativeOverlayWidth({ isWindows: false, isWsl: false }), OVERLAY_FALLBACK_WIDTH)
|
||||
assert.equal(nativeOverlayWidth(), OVERLAY_FALLBACK_WIDTH)
|
||||
assert.equal(nativeOverlayWidth({}), OVERLAY_FALLBACK_WIDTH)
|
||||
})
|
||||
|
||||
test('macOS uses traffic lights, not a WCO overlay, so it reserves nothing', () => {
|
||||
assert.equal(nativeOverlayWidth({ isMac: true }), 0)
|
||||
test('plain Linux and macOS reserve nothing', () => {
|
||||
assert.equal(nativeOverlayWidth({ isWindows: false, isWsl: false }), 0)
|
||||
assert.equal(nativeOverlayWidth(), 0)
|
||||
assert.equal(nativeOverlayWidth({}), 0)
|
||||
})
|
||||
|
||||
test('the fallback width is a sane positive pixel value', () => {
|
||||
|
||||
@@ -38,40 +38,19 @@ test('desktop background child processes opt into hidden Windows consoles', () =
|
||||
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
|
||||
|
||||
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/)
|
||||
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, dashboardArgs\)/)
|
||||
assert.match(source, /existing Hermes no-console Python at/)
|
||||
assert.match(source, /function getNoConsoleVenvPython\(venvRoot\)/)
|
||||
assert.match(source, /function toNoConsolePython\(pythonPath\)/)
|
||||
assert.match(source, /function applyWindowsNoConsoleSpawnHints\(backend\)/)
|
||||
assert.match(source, /function readVenvHome\(venvRoot\)/)
|
||||
assert.match(source, /path\.join\(venvRoot, 'Scripts', 'pythonw\.exe'\)/)
|
||||
assert.match(source, /backendStartFailure/)
|
||||
assert.match(source, /HERMES_DESKTOP_READY_FILE/)
|
||||
assert.match(source, /readyFile: true/)
|
||||
assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/)
|
||||
assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/)
|
||||
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/)
|
||||
})
|
||||
|
||||
test('desktop backend launches console python so child consoles are inherited, not pythonw', () => {
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
// The flash fix is structural: the backend runs as a console-subsystem
|
||||
// python.exe under hiddenWindowsChildOptions() (-> CREATE_NO_WINDOW), so it
|
||||
// owns ONE windowless console that every descendant spawn inherits. Launching
|
||||
// it as GUI-subsystem pythonw.exe is what made each child allocate (and flash)
|
||||
// its own console, so the backend command must never be pythonw.
|
||||
assert.doesNotMatch(source, /pythonw\.exe'\)/, 'backend must not be launched via pythonw.exe')
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/function getNoConsoleVenvPython\b/,
|
||||
'pythonw-conversion helper should be gone; console python is launched directly'
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/function applyWindowsNoConsoleSpawnHints\b/,
|
||||
'pythonw spawn-hint rewriter should be gone'
|
||||
)
|
||||
|
||||
// Console python restores stdout, so the port is announced on the normal
|
||||
// HERMES_DASHBOARD_READY stdout line — no ready-file side channel is set.
|
||||
assert.doesNotMatch(source, /readyFile: true/, 'no backend should opt into the pythonw ready-file path')
|
||||
|
||||
// Both desktop backend launches must still go through hiddenWindowsChildOptions
|
||||
// so the single backend console is created windowless.
|
||||
requireHiddenChildOptions(source, /spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/)
|
||||
})
|
||||
|
||||
test('intentional or interactive desktop child processes stay documented', () => {
|
||||
@@ -89,5 +68,5 @@ test('bootstrap PowerShell runner hides Windows console children', () => {
|
||||
const source = readElectronFile('bootstrap-runner.cjs')
|
||||
|
||||
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/)
|
||||
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
|
||||
})
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
// Regression guards for Windows `hermes` resolution in main.cjs.
|
||||
//
|
||||
// main.cjs has no module.exports, so these follow the repo's source-assertion
|
||||
// test pattern (see windows-child-process.test.cjs). They pin the two Windows
|
||||
// resolution bugs that caused desktop reinstall loops:
|
||||
// 1. findOnPath() tried the empty extension FIRST, so an extensionless
|
||||
// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the
|
||||
// shim then failed the --version probe and the desktop fell through to a
|
||||
// spurious bootstrap/repair.
|
||||
// 2. handOffWindowsBootstrapRecovery() chose --update vs the destructive
|
||||
// --repair by checking ONLY venv\Scripts\hermes.exe (the console-script
|
||||
// shim, written at the END of venv setup and absent in interrupted
|
||||
// states), so it escalated to a full venv recreate even on healthy
|
||||
// installs.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
function readMain() {
|
||||
return fs.readFileSync(path.join(__dirname, 'main.cjs'), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => {
|
||||
const source = readMain()
|
||||
// Fixed order: PATHEXT first, empty string LAST.
|
||||
assert.match(
|
||||
source,
|
||||
/\(process\.env\.PATHEXT \|\| '\.COM;\.EXE;\.BAT;\.CMD'\)\.split\(';'\)\.filter\(Boolean\), ''\]/,
|
||||
'extensions array must end with the empty string, not start with it'
|
||||
)
|
||||
// The buggy empty-first order must not return.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\['', \.\.\.\(process\.env\.PATHEXT/,
|
||||
'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe'
|
||||
)
|
||||
})
|
||||
|
||||
test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => {
|
||||
const source = readMain()
|
||||
assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall')
|
||||
assert.match(
|
||||
source,
|
||||
/fileExists\(venvPython\)/,
|
||||
'recovery must accept the venv interpreter as a real-install signal'
|
||||
)
|
||||
assert.match(
|
||||
source,
|
||||
/\.hermes-bootstrap-complete/,
|
||||
'recovery must accept the bootstrap-complete marker as a real-install signal'
|
||||
)
|
||||
assert.match(
|
||||
source,
|
||||
/updaterArgs = haveRealInstall \? \['--update'/,
|
||||
'updaterArgs must gate on haveRealInstall'
|
||||
)
|
||||
// The old too-narrow check (only venv\Scripts\hermes.exe) must not return.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/updaterArgs = fileExists\(venvHermes\) \?/,
|
||||
'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair'
|
||||
)
|
||||
})
|
||||
@@ -18,7 +18,7 @@
|
||||
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
|
||||
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/bundle-electron-main.mjs && npm run postbuild",
|
||||
"postbuild": "node scripts/assert-dist-built.cjs",
|
||||
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
|
||||
@@ -37,7 +37,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-ipc.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -73,7 +73,6 @@
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@vscode/codicons": "^0.0.45",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
@@ -81,14 +80,15 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"dnd-core": "^14.0.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
"katex": "^0.16.45",
|
||||
"leva": "^0.10.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"node-pty": "1.1.0",
|
||||
@@ -118,6 +118,9 @@
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
// bundle-electron-main.mjs — bundles electron/main.cjs into a single
|
||||
// self-contained file so the nix build doesn't need to ship node_modules/.
|
||||
//
|
||||
// `electron` is provided by the runtime; `node-pty` is staged separately
|
||||
// via stage-native-deps.cjs. `preload.cjs` is NOT require()'d by main —
|
||||
// Electron loads it via path.join(__dirname, 'preload.cjs') — so it stays
|
||||
// as a separate file and doesn't need bundling.
|
||||
import { build } from 'esbuild'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { renameSync } from 'node:fs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(here, '..')
|
||||
const entry = resolve(root, 'electron/main.cjs')
|
||||
const tmp = resolve(root, 'electron/main.bundled.cjs')
|
||||
|
||||
await build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node20',
|
||||
outfile: tmp,
|
||||
external: ['electron', 'node-pty'],
|
||||
logLevel: 'info'
|
||||
})
|
||||
|
||||
// Overwrite the original with the bundled version.
|
||||
renameSync(tmp, entry)
|
||||
|
||||
console.log(`bundled ${entry}`)
|
||||
@@ -66,31 +66,6 @@ const NATIVE_DEPS = [
|
||||
}
|
||||
]
|
||||
|
||||
// Pure-JS runtime dependencies that the packaged electron main require()s but
|
||||
// that workspace dedup hoists into the repo-root node_modules -- out of reach
|
||||
// of electron-builder's file collector, exactly like node-pty above. Unlike
|
||||
// node-pty there is no native binary to select; we stage each package's whole
|
||||
// directory into build/native-deps/vendor/node_modules/<name> so the dep's own
|
||||
// internal require()s resolve against a real node_modules tree, and the
|
||||
// requiring file (electron/git-review-ops.cjs) falls back to that path via
|
||||
// process.resourcesPath when the normal require() fails. See issue #52735
|
||||
// (packaged app crashed at launch on `Cannot find module 'simple-git'`).
|
||||
//
|
||||
// The closure is resolved at stage time by walking dependencies +
|
||||
// optionalDependencies, so a simple-git version bump that pulls in a new
|
||||
// transitive dep can't silently re-introduce the crash.
|
||||
//
|
||||
// Layout note: the closure lands in build/native-deps/vendor/node_modules/,
|
||||
// NOT build/native-deps/node_modules/. electron-builder's file collector
|
||||
// hard-drops a `node_modules` directory that sits at the ROOT of an
|
||||
// extraResources copy (app-builder-lib/out/util/filter.js: `if (relative ===
|
||||
// "node_modules") return false`), but keeps a NESTED one. Nesting under
|
||||
// `vendor/` makes node_modules a subdirectory so it survives packing; the
|
||||
// require() fallback in git-review-ops.cjs resolves the matching
|
||||
// vendor/node_modules path.
|
||||
const JS_DEP_ROOTS = ['simple-git']
|
||||
const JS_DEP_STAGE_ROOT = path.join(STAGE_ROOT, 'vendor', 'node_modules')
|
||||
|
||||
function rmrf(target) {
|
||||
fs.rmSync(target, { recursive: true, force: true })
|
||||
}
|
||||
@@ -173,111 +148,12 @@ function stageOne(spec) {
|
||||
console.log(`[stage-native-deps] ${path.relative(APP_ROOT, spec.to)}: ${copied} files`)
|
||||
}
|
||||
|
||||
// Resolve a package's directory by name, searching the repo-root node_modules
|
||||
// first (where workspace dedup hoists everything) and then the requiring
|
||||
// package's own node_modules for any non-hoisted nested copy.
|
||||
//
|
||||
// We deliberately do NOT use require.resolve(`${name}/package.json`): packages
|
||||
// with an "exports" map that doesn't list "./package.json" (e.g. simple-git
|
||||
// 3.x) make that subpath unresolvable under Node's exports enforcement
|
||||
// (ERR_PACKAGE_PATH_NOT_EXPORTED), which fails on CI even though it happened to
|
||||
// work locally. Instead resolve the package's main entry (exports-aware) and
|
||||
// walk up to the directory whose package.json's "name" matches.
|
||||
function resolvePkgDir(name, fromDir) {
|
||||
const searchPaths = [fromDir, REPO_ROOT, path.join(REPO_ROOT, 'node_modules')]
|
||||
let entry
|
||||
try {
|
||||
entry = require.resolve(name, { paths: searchPaths })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
// Walk up from the resolved entry file to the package root: the first
|
||||
// ancestor dir whose package.json declares this package's name.
|
||||
let dir = path.dirname(entry)
|
||||
while (true) {
|
||||
const pjPath = path.join(dir, 'package.json')
|
||||
try {
|
||||
const pj = JSON.parse(fs.readFileSync(pjPath, 'utf8'))
|
||||
if (pj.name === name) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
// no package.json here (or unreadable) — keep walking up
|
||||
}
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// Walk dependencies + optionalDependencies from each root package and return
|
||||
// the set of resolved package directories in the runtime closure. Keyed by
|
||||
// package name so a dep reached via two paths is staged once.
|
||||
function resolveJsClosure(roots) {
|
||||
const closure = new Map() // name -> absolute package dir
|
||||
const stack = roots.map(name => ({ name, fromDir: REPO_ROOT }))
|
||||
while (stack.length) {
|
||||
const { name, fromDir } = stack.pop()
|
||||
if (closure.has(name)) continue
|
||||
const dir = resolvePkgDir(name, fromDir)
|
||||
if (!dir) {
|
||||
throw new Error(
|
||||
`stage-native-deps: could not resolve '${name}' for the simple-git ` +
|
||||
`closure. Run \`npm install\` at the workspace root first.`
|
||||
)
|
||||
}
|
||||
closure.set(name, dir)
|
||||
let pj
|
||||
try {
|
||||
pj = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const deps = { ...(pj.dependencies || {}), ...(pj.optionalDependencies || {}) }
|
||||
for (const depName of Object.keys(deps)) {
|
||||
stack.push({ name: depName, fromDir: dir })
|
||||
}
|
||||
}
|
||||
return closure
|
||||
}
|
||||
|
||||
// Stage the resolved JS dependency closure into build/native-deps/vendor/node_modules/
|
||||
// so the packaged app (and the nix output) can require() it from
|
||||
// process.resourcesPath when the hoisted-root require() isn't reachable. Each
|
||||
// package is copied whole (minus node_modules/ — the closure is flattened so
|
||||
// every dep already has its own top-level entry) into a real node_modules
|
||||
// layout, which keeps the deps' own internal require()s working unchanged.
|
||||
function stageJsClosure(roots) {
|
||||
const closure = resolveJsClosure(roots)
|
||||
rmrf(JS_DEP_STAGE_ROOT)
|
||||
ensureDir(JS_DEP_STAGE_ROOT)
|
||||
let staged = 0
|
||||
for (const [name, fromDir] of closure) {
|
||||
const dest = path.join(JS_DEP_STAGE_ROOT, name)
|
||||
ensureDir(path.dirname(dest))
|
||||
// Copy the package directory but skip any nested node_modules/ — the
|
||||
// closure is flattened, so nested copies would just bloat the bundle.
|
||||
fs.cpSync(fromDir, dest, {
|
||||
recursive: true,
|
||||
filter: src => path.basename(src) !== 'node_modules'
|
||||
})
|
||||
staged += 1
|
||||
}
|
||||
console.log(
|
||||
`[stage-native-deps] vendor/node_modules/: ${staged} package(s) ` +
|
||||
`(${[...closure.keys()].sort().join(', ')})`
|
||||
)
|
||||
}
|
||||
|
||||
function main() {
|
||||
rmrf(STAGE_ROOT)
|
||||
ensureDir(STAGE_ROOT)
|
||||
for (const spec of NATIVE_DEPS) {
|
||||
stageOne(spec)
|
||||
}
|
||||
stageJsClosure(JS_DEP_ROOTS)
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { LiveTurn } from '@/store/live-turn'
|
||||
import type { SubagentProgress } from '@/store/subagents'
|
||||
import type { TraceDoc, TraceSpan, TraceSpanStatus } from '@/store/trace'
|
||||
|
||||
const TERMINAL_SUB = new Set(['completed', 'failed', 'interrupted'])
|
||||
|
||||
/**
|
||||
* Stitch the live turn + subagent stream into a TraceDoc (client time, epoch
|
||||
* seconds) in the SAME shape the DB trace produces, so the waterfall renders the
|
||||
* in-flight turn with no separate code path. LLM spans are the gaps between tool
|
||||
* calls; subagents nest under the `delegate_task` tool span that spawned them.
|
||||
*
|
||||
* `live=false` finalizes the snapshot (running → ok) so a settled turn stops
|
||||
* pulsing. Returns null when there's nothing in flight to draw.
|
||||
*/
|
||||
export function buildLiveTrace(
|
||||
turn: LiveTurn | undefined,
|
||||
subs: SubagentProgress[],
|
||||
nowMs: number,
|
||||
rootLabel?: string,
|
||||
live = true
|
||||
): null | TraceDoc {
|
||||
const tools = turn?.tools ?? []
|
||||
|
||||
// Robust to opening/reloading mid-turn (when message.start was never captured
|
||||
// in this renderer): build from whatever live data exists — the captured turn,
|
||||
// its tools, or streamed subagents.
|
||||
if (!turn?.busy && tools.length === 0 && subs.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nowSec = nowMs / 1000
|
||||
|
||||
const startCandidates = [turn?.turnStart, ...subs.map(s => s.startedAt)].filter(
|
||||
(n): n is number => typeof n === 'number'
|
||||
)
|
||||
|
||||
const startSec = (startCandidates.length ? Math.min(...startCandidates) : nowMs) / 1000
|
||||
const rootId = 'live:root'
|
||||
|
||||
const spans: TraceSpan[] = [
|
||||
{
|
||||
id: rootId,
|
||||
parentId: null,
|
||||
name: rootLabel || 'Current turn',
|
||||
kind: 'AGENT',
|
||||
start: startSec,
|
||||
end: nowSec,
|
||||
duration: Math.max(0, nowSec - startSec),
|
||||
status: live ? 'running' : 'ok',
|
||||
sessionId: null,
|
||||
attributes: {}
|
||||
}
|
||||
]
|
||||
|
||||
const toolSpanIds = new Set<string>()
|
||||
const sortedTools = [...tools].sort((a, b) => a.start - b.start)
|
||||
let prev = startSec
|
||||
let llmIdx = 0
|
||||
|
||||
const pushLlm = (start: number, end: number, running: boolean, output?: string) => {
|
||||
if (end - start <= 0.05) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = output?.trim()
|
||||
|
||||
spans.push({
|
||||
id: `live:llm:${llmIdx++}`,
|
||||
parentId: rootId,
|
||||
name: 'llm',
|
||||
kind: 'LLM',
|
||||
start,
|
||||
end,
|
||||
duration: end - start,
|
||||
status: running ? 'running' : 'ok',
|
||||
sessionId: null,
|
||||
attributes: text ? { 'output.value': text } : {}
|
||||
})
|
||||
}
|
||||
|
||||
for (const t of sortedTools) {
|
||||
const ts = t.start / 1000
|
||||
const te = (t.end ?? nowMs) / 1000
|
||||
pushLlm(prev, ts, false)
|
||||
const spanId = `live:tool:${t.id}`
|
||||
toolSpanIds.add(spanId)
|
||||
spans.push({
|
||||
id: spanId,
|
||||
parentId: rootId,
|
||||
name: t.name,
|
||||
kind: 'TOOL',
|
||||
start: ts,
|
||||
end: Math.max(te, ts),
|
||||
duration: Math.max(0, te - ts),
|
||||
status: t.status,
|
||||
sessionId: null,
|
||||
attributes: { 'tool.name': t.name }
|
||||
})
|
||||
prev = Math.max(prev, te)
|
||||
}
|
||||
|
||||
// Trailing llm = the model's response after the last tool. Show it ONLY when
|
||||
// there were tools (it's a distinct segment, and it's what streams/grows during
|
||||
// "reporting back"). A pure no-tool turn skips it — the root already is the
|
||||
// response, so a lone "llm" child would just duplicate it.
|
||||
if (sortedTools.length > 0) {
|
||||
pushLlm(prev, nowSec, live, turn?.replyText)
|
||||
} else if (turn?.replyText.trim()) {
|
||||
// No-tool turn: the root IS the response, so don't add a redundant llm row —
|
||||
// instead hang the streamed reply on the root so selecting it shows the text.
|
||||
spans[0].attributes = { ...spans[0].attributes, 'output.value': turn.replyText.trim() }
|
||||
}
|
||||
|
||||
// Nest each subagent under: another subagent (parentId), else the delegate_task
|
||||
// tool span that spawned it. Native subagents don't carry the tool id, so match
|
||||
// by the nearest delegate span that started at/before the subagent.
|
||||
const subIds = new Set(subs.map(s => s.id))
|
||||
|
||||
const delegateSpans = sortedTools
|
||||
.filter(t => t.name === 'delegate_task')
|
||||
.map(t => ({ id: `live:tool:${t.id}`, startMs: t.start }))
|
||||
|
||||
for (const s of subs) {
|
||||
const start = s.startedAt / 1000
|
||||
const terminal = TERMINAL_SUB.has(s.status)
|
||||
const end = terminal && s.durationSeconds ? start + s.durationSeconds : nowSec
|
||||
|
||||
const status: TraceSpanStatus =
|
||||
s.status === 'failed' || s.status === 'interrupted' ? 'error' : terminal ? 'ok' : 'running'
|
||||
|
||||
let parentId = rootId
|
||||
|
||||
if (s.parentId && subIds.has(s.parentId)) {
|
||||
parentId = `live:sub:${s.parentId}`
|
||||
} else {
|
||||
const idMatch = /^delegate-tool:(.+):\d+$/.exec(s.id)
|
||||
|
||||
if (idMatch && toolSpanIds.has(`live:tool:${idMatch[1]}`)) {
|
||||
parentId = `live:tool:${idMatch[1]}`
|
||||
} else {
|
||||
let best: null | { id: string; startMs: number } = null
|
||||
|
||||
for (const d of delegateSpans) {
|
||||
if (d.startMs <= s.startedAt + 1000 && (!best || d.startMs > best.startMs)) {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
parentId = best.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spans.push({
|
||||
id: `live:sub:${s.id}`,
|
||||
parentId,
|
||||
name: s.goal || 'subagent',
|
||||
kind: 'AGENT',
|
||||
start,
|
||||
end: Math.max(end, start),
|
||||
duration: Math.max(0, end - start),
|
||||
status,
|
||||
sessionId: s.sessionId ?? null,
|
||||
attributes: {
|
||||
'llm.model_name': s.model,
|
||||
'llm.token_count.completion': s.outputTokens,
|
||||
'llm.token_count.prompt': s.inputTokens
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Finalized snapshot (turn settled): no span should keep a 'running' status,
|
||||
// or its bar pulses forever.
|
||||
if (!live) {
|
||||
for (const sp of spans) {
|
||||
if (sp.status === 'running') {
|
||||
sp.status = 'ok'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
traceId: 'live',
|
||||
rootSessionId: 'live',
|
||||
rootSpanId: rootId,
|
||||
start: startSec,
|
||||
end: nowSec,
|
||||
duration: Math.max(0, nowSec - startSec),
|
||||
metadata: { live: true },
|
||||
spans
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
||||
export function EmptyState({ icon, text }: { icon: string; text: string }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
|
||||
<Codicon className="text-muted-foreground/50" name={icon} size="1.25rem" />
|
||||
<p className="text-xs text-muted-foreground/70">{text}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Span/trace duration (seconds) → "120ms" / "1.50s" / "2m 3s". */
|
||||
export function fmtDuration(s: number): string {
|
||||
if (s < 1) {
|
||||
return `${Math.round(s * 1000)}ms`
|
||||
}
|
||||
|
||||
if (s < 60) {
|
||||
return `${s.toFixed(s < 10 ? 2 : 1)}s`
|
||||
}
|
||||
|
||||
return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import {
|
||||
$trace,
|
||||
$traceError,
|
||||
$traceLoading,
|
||||
$traceTurns,
|
||||
setTrace,
|
||||
toTraceDoc,
|
||||
toTurnSummaries,
|
||||
type TraceDoc
|
||||
} from '@/store/trace'
|
||||
|
||||
/**
|
||||
* Fetch the execution trace for a session from the gateway (`trace.get`) and
|
||||
* publish it into the trace store. Re-fetches when the session or turn changes.
|
||||
*/
|
||||
export function useSessionTrace(sessionId: null | string, turn?: number) {
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
setTrace(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
$traceLoading.set(true)
|
||||
$traceError.set(null)
|
||||
|
||||
try {
|
||||
const params: Record<string, unknown> = { session_id: sessionId }
|
||||
|
||||
if (typeof turn === 'number') {
|
||||
params.turn = turn
|
||||
}
|
||||
|
||||
const wire = await requestGateway<Record<string, unknown>>('trace.get', params)
|
||||
setTrace(toTraceDoc(wire))
|
||||
} catch (error) {
|
||||
$traceError.set(error instanceof Error ? error.message : String(error))
|
||||
$trace.set(null)
|
||||
} finally {
|
||||
$traceLoading.set(false)
|
||||
}
|
||||
}, [requestGateway, sessionId, turn])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the per-turn summaries for a session (`trace.turns`) so the overlay can
|
||||
* offer a turn strip. Publishes into the trace store.
|
||||
*/
|
||||
export function useTraceTurns(sessionId: null | string) {
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const reloadTurns = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
$traceTurns.set([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const wire = await requestGateway<{ turns?: unknown[] }>('trace.turns', { session_id: sessionId })
|
||||
$traceTurns.set(toTurnSummaries(wire as { turns?: never[] }))
|
||||
} catch {
|
||||
// Keep the previous list on a transient failure — never blank it, or the
|
||||
// nav (and any latest-index math) flickers mid-turn.
|
||||
}
|
||||
}, [requestGateway, sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
void reloadTurns()
|
||||
}, [reloadTurns])
|
||||
|
||||
return { reloadTurns }
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative one-shot fetch of a single turn's trace, WITHOUT touching the
|
||||
* `$trace` store. The agents overlay uses this to grab a just-finished turn's
|
||||
* exact DB trace and fold it into the live view, instead of letting the
|
||||
* declarative `useSessionTrace` swap the store (which would race the live
|
||||
* stitch and churn the view).
|
||||
*/
|
||||
export function useTraceFetcher() {
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const fetchTurn = useCallback(
|
||||
async (sessionId: null | string, turn: number): Promise<null | TraceDoc> => {
|
||||
if (!sessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const wire = await requestGateway<Record<string, unknown>>('trace.get', { session_id: sessionId, turn })
|
||||
|
||||
return toTraceDoc(wire)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
return { fetchTurn }
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { $liveTurnBySession } from '@/store/live-turn'
|
||||
import { $activeSessionId, $messages } from '@/store/session'
|
||||
import { $subagentsBySession } from '@/store/subagents'
|
||||
import {
|
||||
$hoveredSpanId,
|
||||
$selectedSpanId,
|
||||
$trace,
|
||||
$traceError,
|
||||
$traceLoading,
|
||||
$traceSelection,
|
||||
$traceTurns,
|
||||
rebaseTrace,
|
||||
type TraceDoc
|
||||
} from '@/store/trace'
|
||||
|
||||
import { buildLiveTrace } from '../build-live-trace'
|
||||
|
||||
import { useSessionTrace, useTraceFetcher, useTraceTurns } from './use-session-trace'
|
||||
|
||||
export interface TraceView {
|
||||
activeIndex: null | number
|
||||
error: null | string
|
||||
liveIndex: null | number
|
||||
loading: boolean
|
||||
selectTurn: (index: number) => void
|
||||
selection: ReturnType<typeof $traceSelection.get>
|
||||
sessionId: null | string
|
||||
trace: null | TraceDoc
|
||||
}
|
||||
|
||||
/**
|
||||
* The agents overlay's view-model: resolves the one trace to render from three
|
||||
* sources — the live event stitch while a followed turn is in flight, the
|
||||
* server-exact DB trace it folds into once settled, and any pinned/historical
|
||||
* turn or whole-session view. Keeps the route root pure layout.
|
||||
*/
|
||||
export function useTraceView(): TraceView {
|
||||
const sessionId = useStore($activeSessionId)
|
||||
const turns = useStore($traceTurns)
|
||||
const dbTrace = useStore($trace)
|
||||
const loading = useStore($traceLoading)
|
||||
const error = useStore($traceError)
|
||||
const subsBySession = useStore($subagentsBySession)
|
||||
const liveTurnBySession = useStore($liveTurnBySession)
|
||||
const selection = useStore($traceSelection)
|
||||
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
// Frozen last live render — kept after a turn settles so the time mapping (and
|
||||
// thus the view) can't shift. Cleared on session switch.
|
||||
const liveTraceRef = useRef<null | TraceDoc>(null)
|
||||
const finalizedRef = useRef(false)
|
||||
// The just-finished followed turn re-fetched from the DB and rebased onto the
|
||||
// live start: settled exactness folded into the live view (the "B" in A→B).
|
||||
const [foldedTrace, setFoldedTrace] = useState<null | TraceDoc>(null)
|
||||
const sessionRef = useRef(sessionId)
|
||||
|
||||
if (sessionRef.current !== sessionId) {
|
||||
sessionRef.current = sessionId
|
||||
liveTraceRef.current = null
|
||||
finalizedRef.current = false
|
||||
}
|
||||
|
||||
const liveSubs = useMemo(() => (sessionId ? (subsBySession[sessionId] ?? []) : []), [sessionId, subsBySession])
|
||||
const liveTurn = sessionId ? liveTurnBySession[sessionId] : undefined
|
||||
// Live = turn busy OR subagents in flight (robust to opening mid-turn).
|
||||
const isLive = !!liveTurn?.busy || liveSubs.some(s => s.status === 'running' || s.status === 'queued')
|
||||
const hasLiveData = isLive || (liveTurn?.tools.length ?? 0) > 0 || liveSubs.length > 0
|
||||
|
||||
const following = selection === 'latest'
|
||||
const latestIndex = turns.length - 1
|
||||
// Latch onto the live stitch while following: once a turn has produced live
|
||||
// data we keep showing it (frozen, then folded with DB exactness after it
|
||||
// settles) and NEVER let the declarative DB fetch swap the store under us.
|
||||
// That swap + the turn-list reload races were the churn ("before subagents →
|
||||
// all → end"). DB-by-store is only for an explicitly pinned turn/all, or the
|
||||
// latest turn on a fresh idle open (no live data this session).
|
||||
const showLive = following && (hasLiveData || liveTraceRef.current !== null)
|
||||
|
||||
const activeIndex =
|
||||
selection === 'all' ? null : selection === 'latest' ? (latestIndex >= 0 ? latestIndex : null) : selection
|
||||
|
||||
const liveIndex = isLive && latestIndex >= 0 ? latestIndex : null
|
||||
|
||||
const { reloadTurns } = useTraceTurns(sessionId)
|
||||
const { fetchTurn } = useTraceFetcher()
|
||||
|
||||
// DB fetch target: a pinned turn number, else the latest settled turn. Skipped
|
||||
// entirely (undefined) while we render the live stitch or the whole session.
|
||||
const dbTurnArg =
|
||||
showLive || selection === 'all'
|
||||
? undefined
|
||||
: typeof selection === 'number'
|
||||
? selection
|
||||
: latestIndex >= 0
|
||||
? latestIndex
|
||||
: undefined
|
||||
|
||||
useSessionTrace(sessionId, dbTurnArg)
|
||||
|
||||
// Drop the ephemeral hover/selection when the panel closes so a stale span
|
||||
// can't auto-zoom the next time it opens.
|
||||
useEffect(
|
||||
() => () => {
|
||||
$selectedSpanId.set(null)
|
||||
$hoveredSpanId.set(null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Follow-latest on session switch.
|
||||
useEffect(() => {
|
||||
$traceSelection.set('latest')
|
||||
setFoldedTrace(null)
|
||||
}, [sessionId])
|
||||
|
||||
// A new live turn (or leaving 'latest') invalidates a folded snapshot.
|
||||
useEffect(() => {
|
||||
if (isLive || selection !== 'latest') {
|
||||
setFoldedTrace(null)
|
||||
}
|
||||
}, [isLive, selection])
|
||||
|
||||
// While the turn streams, tick so running bars grow toward "now".
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 400)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [isLive])
|
||||
|
||||
// Refresh the turn list on live edges so the nav reflects the in-flight /
|
||||
// finished turn. Does NOT touch the displayed (live) trace.
|
||||
const prevLive = useRef(isLive)
|
||||
useEffect(() => {
|
||||
if (isLive !== prevLive.current) {
|
||||
void reloadTurns()
|
||||
}
|
||||
|
||||
prevLive.current = isLive
|
||||
}, [isLive, reloadTurns])
|
||||
|
||||
// Fold: once a followed turn settles, pull its exact DB trace and rebase it
|
||||
// onto the frozen live start, then swap. rebaseTrace keeps it on the same
|
||||
// on-screen window, and the waterfall preserves the view across the tmap
|
||||
// change, so the swap is seamless — approximate live bars become server-exact
|
||||
// in place, with no reframe.
|
||||
useEffect(() => {
|
||||
if (!following || isLive || !sessionId || !liveTraceRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const liveStart = liveTraceRef.current.start
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
await reloadTurns()
|
||||
const idx = $traceTurns.get().length - 1
|
||||
|
||||
if (idx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const db = await fetchTurn(sessionId, idx)
|
||||
|
||||
if (!cancelled && db && db.spans.length > 0) {
|
||||
setFoldedTrace(rebaseTrace(db, liveStart))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [following, isLive, sessionId, reloadTurns, fetchTurn])
|
||||
|
||||
const trace = useMemo<null | TraceDoc>(() => {
|
||||
if (!showLive) {
|
||||
return dbTrace
|
||||
}
|
||||
|
||||
const lastUser = $messages.get().findLast(m => m.role === 'user' && !m.hidden)
|
||||
const rootLabel = lastUser ? chatMessageText(lastUser).trim().slice(0, 80) : undefined
|
||||
|
||||
if (isLive) {
|
||||
finalizedRef.current = false
|
||||
liveTraceRef.current = buildLiveTrace(liveTurn, liveSubs, nowMs, rootLabel, true)
|
||||
|
||||
return liveTraceRef.current
|
||||
}
|
||||
|
||||
// Settled & folded: server-exact spans rebased onto the live start.
|
||||
if (foldedTrace) {
|
||||
return foldedTrace
|
||||
}
|
||||
|
||||
// Settled, fold not in yet: build the finalized snapshot once (running → ok,
|
||||
// no pulse) and freeze it as the bridge until the DB fold lands.
|
||||
if (!finalizedRef.current) {
|
||||
liveTraceRef.current = buildLiveTrace(liveTurn, liveSubs, nowMs, rootLabel, false)
|
||||
finalizedRef.current = true
|
||||
}
|
||||
|
||||
return liveTraceRef.current
|
||||
}, [showLive, isLive, dbTrace, liveTurn, liveSubs, nowMs, foldedTrace])
|
||||
|
||||
const selectTurn = (index: number) => $traceSelection.set(index === latestIndex ? 'latest' : index)
|
||||
|
||||
return { activeIndex, error, liveIndex, loading, selectTurn, selection, sessionId, trace }
|
||||
}
|
||||
@@ -1,396 +1,60 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import { $traceSelection } from '@/store/trace'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$subagentsBySession,
|
||||
allSubagents,
|
||||
buildSubagentTree,
|
||||
type SubagentNode,
|
||||
type SubagentStatus,
|
||||
type SubagentStreamEntry
|
||||
} from '@/store/subagents'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
import { Panel, PanelEmpty, PanelHeader } from '../overlays/panel'
|
||||
|
||||
// Mirrors statusGlyph() in tool-fallback.tsx so subagent rows speak the
|
||||
// same visual vocabulary as the chat tool blocks.
|
||||
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
|
||||
if (status === 'running' || status === 'queued') {
|
||||
return (
|
||||
<GlyphSpinner
|
||||
ariaLabel={a.running}
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
|
||||
spinner="breathe"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'failed' || status === 'interrupted') {
|
||||
return <AlertCircle aria-label={a.failed} className="size-3.5 shrink-0 text-destructive" />
|
||||
}
|
||||
|
||||
return <CheckCircle2 aria-label={a.done} className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
||||
}
|
||||
|
||||
const STREAM_TONE: Record<SubagentStreamEntry['kind'], string> = {
|
||||
progress: 'text-muted-foreground/75',
|
||||
summary: 'text-foreground/85',
|
||||
thinking: 'text-muted-foreground/80',
|
||||
tool: 'text-foreground/85'
|
||||
}
|
||||
|
||||
function streamGlyph(entry: SubagentStreamEntry): ReactNode {
|
||||
if (entry.isError) {
|
||||
return <AlertCircle aria-hidden className="mt-0.5 size-3 shrink-0 text-destructive" />
|
||||
}
|
||||
|
||||
if (entry.kind === 'tool') {
|
||||
return <span aria-hidden className="mt-0.5 size-1.5 shrink-0 rounded-full bg-foreground/55" />
|
||||
}
|
||||
|
||||
if (entry.kind === 'summary') {
|
||||
return <CheckCircle2 aria-hidden className="mt-0.5 size-3 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
||||
}
|
||||
|
||||
if (entry.kind === 'thinking') {
|
||||
return (
|
||||
<span aria-hidden className="font-mono text-[0.7rem] leading-none text-muted-foreground/70">
|
||||
…
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <span aria-hidden className="mt-0.5 size-1 shrink-0 rounded-full bg-muted-foreground/55" />
|
||||
}
|
||||
import { EmptyState } from './empty-state'
|
||||
import { fmtDuration } from './format'
|
||||
import { useTraceView } from './hooks/use-trace-view'
|
||||
import { SpanInspector } from './span-inspector'
|
||||
import { ROW_HEIGHT, TraceWaterfall } from './trace-waterfall'
|
||||
import { TurnStrip } from './turn-strip'
|
||||
|
||||
interface AgentsViewProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AgentsView({ onClose }: AgentsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
|
||||
// Aggregate every session, matching the status-bar indicator — a subagent
|
||||
// running in a background session must still be visible here, or the two
|
||||
// desync ("Agents N running" vs an empty tree).
|
||||
const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession])
|
||||
const { activeIndex, error, liveIndex, loading, selectTurn, selection, sessionId, trace } = useTraceView()
|
||||
const hasTrace = !!trace && trace.spans.length > 0
|
||||
|
||||
return (
|
||||
<Panel closeLabel={t.agents.close} onClose={onClose}>
|
||||
{tree.length === 0 ? (
|
||||
<PanelEmpty description={t.agents.emptyDesc} icon="hubot" title={t.agents.emptyTitle} />
|
||||
) : (
|
||||
<>
|
||||
<PanelHeader subtitle={t.agents.subtitle} title={t.agents.title} />
|
||||
<SubagentTree tree={tree} />
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const fmtDuration = (seconds: number | undefined, a: Translations['agents']) => {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return a.durationSeconds(seconds.toFixed(1))
|
||||
}
|
||||
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
|
||||
return a.durationMinutes(m, s)
|
||||
}
|
||||
|
||||
const fmtTokens = (value: number | undefined, a: Translations['agents']) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return value >= 1000 ? a.tokensK((value / 1000).toFixed(1)) : a.tokens(value)
|
||||
}
|
||||
|
||||
const fmtAge = (updatedAt: number, nowMs: number, a: Translations['agents']) => {
|
||||
const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000))
|
||||
|
||||
if (s < 2) {
|
||||
return a.ageNow
|
||||
}
|
||||
|
||||
if (s < 60) {
|
||||
return a.ageSeconds(s)
|
||||
}
|
||||
|
||||
const m = Math.floor(s / 60)
|
||||
|
||||
if (m < 60) {
|
||||
return a.ageMinutes(m)
|
||||
}
|
||||
|
||||
return a.ageHours(Math.floor(m / 60))
|
||||
}
|
||||
|
||||
const flatten = (nodes: readonly SubagentNode[]): SubagentNode[] =>
|
||||
nodes.flatMap(node => [node, ...flatten(node.children)])
|
||||
|
||||
interface RootGroup {
|
||||
id: string
|
||||
delegationIndex: number
|
||||
nodes: SubagentNode[]
|
||||
taskCount: number
|
||||
}
|
||||
|
||||
function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] {
|
||||
const groups: RootGroup[] = []
|
||||
let n = 0
|
||||
|
||||
for (const node of roots) {
|
||||
const prev = groups.at(-1)
|
||||
const prevTail = prev?.nodes.at(-1)
|
||||
const closeInTime = prevTail ? Math.abs(node.startedAt - prevTail.startedAt) <= 5_000 : false
|
||||
const sameShape = prev && node.taskCount > 1 && prev.taskCount === node.taskCount
|
||||
const uniqueStep = prev ? !prev.nodes.some(item => item.taskIndex === node.taskIndex) : false
|
||||
|
||||
if (prev && sameShape && closeInTime && uniqueStep) {
|
||||
prev.nodes.push(node)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (node.taskCount > 1) {
|
||||
n += 1
|
||||
groups.push({ id: `delegation-${n}`, delegationIndex: n, nodes: [node], taskCount: node.taskCount })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
groups.push({ id: node.id, delegationIndex: 0, nodes: [node], taskCount: node.taskCount })
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
const { t } = useI18n()
|
||||
const flat = useMemo(() => flatten(tree), [tree])
|
||||
const groups = useMemo(() => groupDelegations(tree), [tree])
|
||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||
|
||||
const active = flat.filter(n => n.status === 'running' || n.status === 'queued').length
|
||||
const failed = flat.filter(n => n.status === 'failed' || n.status === 'interrupted').length
|
||||
const tools = flat.reduce((sum, n) => sum + (n.toolCount ?? 0), 0)
|
||||
const files = flat.reduce((sum, n) => sum + n.filesRead.length + n.filesWritten.length, 0)
|
||||
const tokens = flat.reduce((sum, n) => sum + (n.inputTokens ?? 0) + (n.outputTokens ?? 0), 0)
|
||||
const cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (active <= 0 || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 500)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [active])
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<div className="grid place-items-center gap-3 py-12 text-center">
|
||||
<Codicon className="text-muted-foreground/60" name="hubot" size="1.5rem" />
|
||||
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const summary = [
|
||||
t.agents.agentsCount(flat.length),
|
||||
active > 0 ? t.agents.activeCount(active) : '',
|
||||
failed > 0 ? t.agents.failedCount(failed) : '',
|
||||
tools > 0 ? t.agents.toolsCount(tools) : '',
|
||||
files > 0 ? t.agents.filesCount(files) : '',
|
||||
tokens > 0 ? fmtTokens(tokens, t.agents) : '',
|
||||
cost > 0 ? `$${cost.toFixed(2)}` : ''
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-hidden">
|
||||
<p className="shrink-0 text-[0.7rem] text-muted-foreground/70">{summary.join(' · ')}</p>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{groups.map(group => (
|
||||
<DelegationGroup group={group} key={group.id} nowMs={nowMs} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DelegationGroup({ group, nowMs }: { group: RootGroup; nowMs: number }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (group.nodes.length === 1 && group.taskCount <= 1) {
|
||||
return <SubagentRow node={group.nodes[0]!} nowMs={nowMs} />
|
||||
}
|
||||
|
||||
const activeWorkers = group.nodes.filter(n => n.status === 'running' || n.status === 'queued').length
|
||||
|
||||
return (
|
||||
<section className="grid min-w-0 gap-3">
|
||||
<p className="text-[0.66rem] font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
{group.delegationIndex > 0 ? t.agents.delegation(group.delegationIndex) : ''}{' '}
|
||||
<span className="text-muted-foreground/50">·</span> {t.agents.workers(group.nodes.length)}
|
||||
{activeWorkers > 0 ? <span className="text-primary/85"> · {t.agents.workersActive(activeWorkers)}</span> : null}
|
||||
</p>
|
||||
<div className="grid min-w-0 gap-4">
|
||||
{group.nodes.map(node => (
|
||||
<SubagentRow key={node.id} node={node} nowMs={nowMs} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StreamLine({
|
||||
active,
|
||||
entry,
|
||||
parentRunning,
|
||||
rowKey
|
||||
}: {
|
||||
active: boolean
|
||||
entry: SubagentStreamEntry
|
||||
parentRunning: boolean
|
||||
rowKey: string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const enterRef = useEnterAnimation(parentRunning, `subagent-stream:${rowKey}`)
|
||||
const isMono = entry.kind === 'tool'
|
||||
const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind]
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-baseline gap-2 text-[0.72rem] leading-relaxed" ref={enterRef}>
|
||||
<span className="flex h-[0.95rem] shrink-0 items-center">{streamGlyph(entry)}</span>
|
||||
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
|
||||
{entry.text}
|
||||
{active ? (
|
||||
<GlyphSpinner
|
||||
ariaLabel={t.agents.streaming}
|
||||
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
|
||||
spinner="breathe"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) {
|
||||
const { t } = useI18n()
|
||||
const running = node.status === 'running' || node.status === 'queued'
|
||||
const elapsed = useElapsedSeconds(running, `subagent:${node.id}`)
|
||||
|
||||
const durationSeconds =
|
||||
typeof node.durationSeconds === 'number' ? Math.max(0, Math.round(node.durationSeconds)) : elapsed
|
||||
|
||||
const [open, setOpen] = useState(() => running || depth < 2)
|
||||
const enterRef = useEnterAnimation(true, `subagent-row:${node.id}`)
|
||||
|
||||
useEffect(() => {
|
||||
if (running) {
|
||||
setOpen(true)
|
||||
}
|
||||
}, [running])
|
||||
|
||||
const visibleRows = open ? node.stream.slice(-10) : node.stream.slice(-2)
|
||||
const fileLines = [...node.filesWritten.map(p => `+ ${p}`), ...node.filesRead.map(p => `· ${p}`)]
|
||||
|
||||
const subtitle = [
|
||||
node.model,
|
||||
fmtDuration(durationSeconds, t.agents),
|
||||
node.toolCount ? t.agents.toolsCount(node.toolCount) : '',
|
||||
fmtTokens((node.inputTokens ?? 0) + (node.outputTokens ?? 0), t.agents),
|
||||
t.agents.updatedAgo(fmtAge(node.updatedAt, nowMs, t.agents))
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className={cn('grid min-w-0 max-w-full gap-2', depth > 0 && 'pl-4')} data-slot="tool-block" ref={enterRef}>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="group flex w-full min-w-0 items-start gap-2.5 text-left"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
<span className="mt-0.5 flex h-[1.1rem] shrink-0 items-center">{statusGlyph(node.status, t.agents)}</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
'wrap-anywhere text-[0.82rem] font-medium leading-[1.1rem] text-foreground/90 transition-colors group-hover:text-foreground',
|
||||
running && 'shimmer text-foreground/65'
|
||||
)}
|
||||
>
|
||||
{node.goal}
|
||||
</span>
|
||||
{subtitle.length > 0 ? (
|
||||
<FadeText className="text-[0.66rem] leading-[1.05rem] text-muted-foreground/65">
|
||||
{subtitle.join(' · ')}
|
||||
</FadeText>
|
||||
) : null}
|
||||
</span>
|
||||
{running ? <ActivityTimerText className="mt-1 shrink-0 text-[0.6rem]" seconds={durationSeconds} /> : null}
|
||||
</button>
|
||||
|
||||
{visibleRows.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-1 pl-6" data-selectable-text="true">
|
||||
{visibleRows.map((entry, i) => (
|
||||
<StreamLine
|
||||
active={running && i === visibleRows.length - 1}
|
||||
entry={entry}
|
||||
key={`${entry.kind}:${entry.at}:${i}`}
|
||||
parentRunning={running}
|
||||
rowKey={`${node.id}:${entry.kind}:${entry.at}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{open && fileLines.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-0.5 pl-6" data-selectable-text="true">
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">
|
||||
{t.agents.files}
|
||||
<OverlayView
|
||||
closeLabel="Close"
|
||||
contentClassName="flex h-full flex-col px-4 py-4 sm:px-5"
|
||||
onClose={onClose}
|
||||
rootClassName="mx-auto flex h-full w-full max-w-6xl flex-col"
|
||||
>
|
||||
<header className="mb-2 flex shrink-0 items-center justify-between gap-3 pl-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">Trace</h2>
|
||||
<p className="truncate text-xs text-muted-foreground/80">
|
||||
{sessionId ? `Execution waterfall · ${sessionId.slice(0, 16)}` : 'No active session'}
|
||||
{hasTrace ? ` · ${trace.spans.length} spans · ${fmtDuration(trace.duration)}` : ''}
|
||||
</p>
|
||||
{fileLines.slice(0, 8).map(line => (
|
||||
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
{fileLines.length > 8 ? (
|
||||
<p className="font-mono text-[0.67rem] leading-relaxed text-muted-foreground/65">
|
||||
{t.agents.moreFiles(fileLines.length - 8)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<TurnStrip
|
||||
activeIndex={activeIndex}
|
||||
allActive={selection === 'all'}
|
||||
liveIndex={liveIndex}
|
||||
onAll={() => $traceSelection.set('all')}
|
||||
onTurn={selectTurn}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{node.children.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-3 pl-6">
|
||||
{node.children.map(child => (
|
||||
<SubagentRow depth={depth + 1} key={child.id} node={child} nowMs={nowMs} />
|
||||
))}
|
||||
{hasTrace ? (
|
||||
<div className="flex min-h-0 flex-1 gap-3 overflow-hidden">
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<TraceWaterfall trace={trace} viewKey={`${sessionId ?? ''}:${selection}`} />
|
||||
</div>
|
||||
<div className="flex w-72 shrink-0 flex-col overflow-y-auto" style={{ paddingTop: ROW_HEIGHT }}>
|
||||
<SpanInspector trace={trace} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : error ? (
|
||||
<EmptyState icon="warning" text={error} />
|
||||
) : (
|
||||
<EmptyState icon={loading ? 'loading~spin' : 'hubot'} text={loading ? 'Loading trace…' : 'No trace yet'} />
|
||||
)}
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { $hoveredSpanId, $selectedSpanId, type TraceDoc } from '@/store/trace'
|
||||
|
||||
import { fmtDuration } from './format'
|
||||
import { ROW_HEIGHT } from './trace-waterfall'
|
||||
|
||||
const fmtInt = (n: number) => n.toLocaleString()
|
||||
|
||||
export function SpanInspector({ trace }: { trace: null | TraceDoc }) {
|
||||
const selectedId = useStore($selectedSpanId)
|
||||
const hoveredId = useStore($hoveredSpanId)
|
||||
// Hover previews; the clicked span stays pinned when nothing is hovered.
|
||||
const activeId = hoveredId ?? selectedId
|
||||
|
||||
const span = useMemo(() => trace?.spans.find(s => s.id === activeId) ?? null, [trace, activeId])
|
||||
|
||||
if (!span) {
|
||||
return (
|
||||
<div className="flex items-center text-[0.7rem] text-muted-foreground/55" style={{ height: ROW_HEIGHT }}>
|
||||
Select a span to inspect its details.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const attrs = span.attributes
|
||||
const num = (key: string) => (typeof attrs[key] === 'number' ? (attrs[key] as number) : undefined)
|
||||
|
||||
const meta: [string, string][] = [['kind', span.kind], ['status', span.status]]
|
||||
|
||||
// Where the span sits in the trace, then how long it ran.
|
||||
if (trace) {
|
||||
meta.push(['started', `+${fmtDuration(Math.max(0, span.start - trace.start))}`])
|
||||
}
|
||||
|
||||
meta.push(['duration', fmtDuration(span.duration)])
|
||||
|
||||
// Push an attribute row when present; numbers are thousands-formatted.
|
||||
const push = (label: string, key: string) => {
|
||||
const v = attrs[key]
|
||||
|
||||
if (v !== undefined && v !== null && v !== '') {
|
||||
meta.push([label, typeof v === 'number' ? fmtInt(v) : String(v)])
|
||||
}
|
||||
}
|
||||
|
||||
push('model', 'llm.model_name')
|
||||
push('tokens in', 'llm.token_count.prompt')
|
||||
push('tokens out', 'llm.token_count.completion')
|
||||
|
||||
const treason = num('llm.token_count.reasoning')
|
||||
|
||||
if (treason) {
|
||||
meta.push(['reasoning', fmtInt(treason)])
|
||||
}
|
||||
|
||||
const tin = num('llm.token_count.prompt')
|
||||
const tout = num('llm.token_count.completion')
|
||||
|
||||
if (tin !== undefined || tout !== undefined) {
|
||||
meta.push(['tokens total', fmtInt((tin ?? 0) + (tout ?? 0) + (treason ?? 0))])
|
||||
}
|
||||
|
||||
push('finish', 'hermes.finish_reason')
|
||||
push('tool', 'tool.name')
|
||||
// Container (AGENT) spans carry session shape; subagents expose their id.
|
||||
push('source', 'session.source')
|
||||
push('messages', 'session.message_count')
|
||||
push('tool calls', 'session.tool_call_count')
|
||||
|
||||
if (span.sessionId) {
|
||||
meta.push(['session', span.sessionId.slice(0, 12)])
|
||||
}
|
||||
|
||||
const input = attrs['input.value']
|
||||
const output = attrs['output.value']
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pb-3">
|
||||
<p
|
||||
className="flex items-center text-[0.82rem] font-medium break-words text-foreground/90"
|
||||
style={{ minHeight: ROW_HEIGHT }}
|
||||
>
|
||||
{span.name}
|
||||
</p>
|
||||
<dl className="grid grid-cols-[6rem_1fr] gap-x-3 gap-y-1 text-[0.7rem]">
|
||||
{meta.map(([k, v]) => (
|
||||
<div className="contents" key={k}>
|
||||
<dt className="truncate text-muted-foreground/55">{k}</dt>
|
||||
<dd className="min-w-0 break-words text-foreground/85">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{input ? <InspectorBlock label="input" value={String(input)} /> : null}
|
||||
{output ? <InspectorBlock label="output" value={String(output)} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorBlock({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-[0.6rem] font-medium tracking-wider text-muted-foreground/50 uppercase">{label}</span>
|
||||
<pre className="max-h-40 overflow-auto rounded bg-foreground/5 p-2 text-[0.66rem] break-words whitespace-pre-wrap text-foreground/80">
|
||||
{value}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { toolIconName } from '@/components/assistant-ui/tool-fallback-model'
|
||||
import type { TraceSpanKind, TraceSpanNode } from '@/store/trace'
|
||||
|
||||
// Category → bar classes. Tailwind -500 family reads at even weight on dark;
|
||||
// error tint overrides downstream.
|
||||
const KIND_BAR: Record<TraceSpanKind, string> = {
|
||||
AGENT: 'bg-violet-500/70',
|
||||
CHAIN: 'bg-slate-500/70',
|
||||
LLM: 'bg-sky-500/70',
|
||||
TOOL: 'bg-emerald-500/70'
|
||||
}
|
||||
|
||||
const TOOL_BAR: Record<string, string> = {
|
||||
delegate_task: 'bg-violet-500/70',
|
||||
patch: 'bg-amber-500/70',
|
||||
read_file: 'bg-cyan-500/70',
|
||||
search_files: 'bg-cyan-500/70',
|
||||
terminal: 'bg-zinc-400/70',
|
||||
web_extract: 'bg-teal-500/70',
|
||||
web_search: 'bg-teal-500/70',
|
||||
write_file: 'bg-amber-500/70'
|
||||
}
|
||||
|
||||
/** Bar color for a span: error tint wins, then per-tool, then per-kind. */
|
||||
export function barClass(node: TraceSpanNode): string {
|
||||
if (node.status === 'error') {
|
||||
return 'bg-red-500/80'
|
||||
}
|
||||
|
||||
const tool = String(node.attributes['tool.name'] ?? '')
|
||||
|
||||
if (node.kind === 'TOOL' && TOOL_BAR[tool]) {
|
||||
return TOOL_BAR[tool]
|
||||
}
|
||||
|
||||
return KIND_BAR[node.kind]
|
||||
}
|
||||
|
||||
/** Icon for a span, reusing the thread's tool icons so the trace matches what
|
||||
* the conversation shows mid-thread. */
|
||||
export function spanIconName(node: TraceSpanNode): string {
|
||||
if (node.kind === 'TOOL') {
|
||||
return toolIconName(String(node.attributes['tool.name'] ?? 'tools'))
|
||||
}
|
||||
|
||||
// The root span is the user's turn (its label is the prompt) — mark it human.
|
||||
if (node.kind === 'AGENT' && node.parentId === null) {
|
||||
return 'account'
|
||||
}
|
||||
|
||||
if (node.kind === 'AGENT' || node.kind === 'LLM') {
|
||||
return 'hubot'
|
||||
}
|
||||
|
||||
return 'list-tree'
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { TraceSpanNode } from '@/store/trace'
|
||||
|
||||
import { buildTimeMap, niceTicks } from './time-map'
|
||||
|
||||
function node(kind: TraceSpanNode['kind'], start: number, end: number): TraceSpanNode {
|
||||
return {
|
||||
id: `${kind}:${start}`,
|
||||
parentId: null,
|
||||
name: kind,
|
||||
kind,
|
||||
start,
|
||||
end,
|
||||
duration: end - start,
|
||||
status: 'ok',
|
||||
sessionId: null,
|
||||
attributes: {},
|
||||
depth: 0,
|
||||
children: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildTimeMap', () => {
|
||||
it('maps busy time 1:1 and compresses idle gaps', () => {
|
||||
// Two 1s busy spans separated by a 100s idle gap.
|
||||
const nodes = [node('TOOL', 0, 1), node('TOOL', 101, 102)]
|
||||
const map = buildTimeMap(nodes, 0, 102)
|
||||
|
||||
// Endpoints anchor.
|
||||
expect(map.toV(0)).toBe(0)
|
||||
expect(map.toReal(0)).toBe(0)
|
||||
expect(map.toReal(map.totalV)).toBeCloseTo(102)
|
||||
|
||||
// The 100s gap is collapsed: virtual width ≪ real width.
|
||||
expect(map.totalV).toBeLessThan(20)
|
||||
expect(map.gaps).toHaveLength(1)
|
||||
expect(map.gaps[0]!.r1 - map.gaps[0]!.r0).toBeCloseTo(100)
|
||||
})
|
||||
|
||||
it('round-trips real↔virtual within busy regions', () => {
|
||||
const map = buildTimeMap([node('LLM', 10, 20)], 10, 20)
|
||||
|
||||
expect(map.toReal(map.toV(15))).toBeCloseTo(15)
|
||||
})
|
||||
|
||||
it('survives degenerate input (only container spans)', () => {
|
||||
const map = buildTimeMap([node('AGENT', 0, 0)], 0, 0)
|
||||
|
||||
expect(map.totalV).toBeGreaterThan(0)
|
||||
expect(Number.isFinite(map.toReal(0))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('niceTicks', () => {
|
||||
it('returns ascending ticks covering the range', () => {
|
||||
const ticks = niceTicks(0, 10)
|
||||
|
||||
expect(ticks.length).toBeGreaterThan(1)
|
||||
expect(ticks).toEqual([...ticks].sort((a, b) => a - b))
|
||||
expect(ticks.at(-1)!).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('degenerates safely on a zero-width range', () => {
|
||||
expect(niceTicks(5, 5)).toEqual([5])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { TraceSpanNode } from '@/store/trace'
|
||||
|
||||
// Time compression. "All" spans a whole session, mostly idle between turns. We
|
||||
// build a compressed coordinate space (virtual units) where idle gaps collapse
|
||||
// to a fixed width, and route positioning / zoom / ticks through it — the
|
||||
// approach Sentry's compressed trace timeline uses. Real busy time maps 1:1;
|
||||
// long idle gaps shrink and get a marker.
|
||||
|
||||
export interface TimeSeg {
|
||||
gap: boolean
|
||||
r0: number
|
||||
r1: number
|
||||
v0: number
|
||||
v1: number
|
||||
}
|
||||
|
||||
export interface TimeMap {
|
||||
gaps: TimeSeg[]
|
||||
toReal: (v: number) => number
|
||||
toV: (t: number) => number
|
||||
totalV: number
|
||||
}
|
||||
|
||||
export function buildTimeMap(nodes: TraceSpanNode[], fullStart: number, fullEnd: number): TimeMap {
|
||||
// Busy = actual work (LLM/TOOL). AGENT/CHAIN containers span whole turns
|
||||
// including idle, so they'd hide every gap — exclude them from gap detection.
|
||||
const busy = nodes
|
||||
.filter(n => n.kind === 'LLM' || n.kind === 'TOOL')
|
||||
.map(n => [n.start, Math.max(n.end, n.start)] as [number, number])
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
|
||||
const merged: [number, number][] = []
|
||||
|
||||
for (const [s, e] of busy) {
|
||||
const last = merged.at(-1)
|
||||
|
||||
if (last && s <= last[1]) {
|
||||
last[1] = Math.max(last[1], e)
|
||||
} else {
|
||||
merged.push([s, e])
|
||||
}
|
||||
}
|
||||
|
||||
if (merged.length === 0) {
|
||||
merged.push([fullStart, fullEnd])
|
||||
}
|
||||
|
||||
const activeTotal = merged.reduce((sum, [s, e]) => sum + (e - s), 0) || 1
|
||||
const compressed = Math.min(Math.max(activeTotal * 0.04, 0.5), 4)
|
||||
|
||||
const segs: TimeSeg[] = []
|
||||
let v = 0
|
||||
|
||||
const add = (r0: number, r1: number, gap: boolean) => {
|
||||
if (r1 <= r0) {
|
||||
return
|
||||
}
|
||||
|
||||
const vlen = gap ? Math.min(r1 - r0, compressed) : r1 - r0
|
||||
segs.push({ gap, r0, r1, v0: v, v1: v + vlen })
|
||||
v += vlen
|
||||
}
|
||||
|
||||
let cursor = fullStart
|
||||
|
||||
if (merged[0]![0] > cursor) {
|
||||
add(cursor, merged[0]![0], true)
|
||||
cursor = merged[0]![0]
|
||||
}
|
||||
|
||||
for (let i = 0; i < merged.length; i++) {
|
||||
const [s, e] = merged[i]!
|
||||
add(Math.max(s, cursor), e, false)
|
||||
cursor = Math.max(cursor, e)
|
||||
const next = merged[i + 1]
|
||||
|
||||
if (next && next[0] > cursor) {
|
||||
add(cursor, next[0], true)
|
||||
cursor = next[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (fullEnd > cursor) {
|
||||
add(cursor, fullEnd, true)
|
||||
}
|
||||
|
||||
// Degenerate input (e.g. a live trace with only AGENT spans, or a zero-width
|
||||
// range at spawn) yields no segments — guarantee at least one so toV/toReal
|
||||
// never index into an empty array.
|
||||
if (segs.length === 0) {
|
||||
const r1 = Math.max(fullEnd, fullStart + 0.001)
|
||||
segs.push({ gap: false, r0: fullStart, r1, v0: 0, v1: r1 - fullStart })
|
||||
v = r1 - fullStart
|
||||
}
|
||||
|
||||
const totalV = v || 1
|
||||
|
||||
const toV = (t: number) => {
|
||||
if (t <= segs[0]!.r0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
for (const s of segs) {
|
||||
if (t <= s.r1) {
|
||||
return s.v0 + ((t - s.r0) / (s.r1 - s.r0 || 1)) * (s.v1 - s.v0)
|
||||
}
|
||||
}
|
||||
|
||||
return totalV
|
||||
}
|
||||
|
||||
const toReal = (vv: number) => {
|
||||
for (const s of segs) {
|
||||
if (vv <= s.v1) {
|
||||
return s.r0 + ((vv - s.v0) / (s.v1 - s.v0 || 1)) * (s.r1 - s.r0)
|
||||
}
|
||||
}
|
||||
|
||||
return fullEnd
|
||||
}
|
||||
|
||||
const gaps = segs.filter(s => s.gap && s.r1 - s.r0 > s.v1 - s.v0 + 1e-6)
|
||||
|
||||
return { gaps, toReal, toV, totalV }
|
||||
}
|
||||
|
||||
/** "Nice" axis ticks (1/2/5 × 10ⁿ) covering [start, end] in virtual units. */
|
||||
export function niceTicks(start: number, end: number, target = 6): number[] {
|
||||
const span = end - start
|
||||
|
||||
if (span <= 0) {
|
||||
return [start]
|
||||
}
|
||||
|
||||
const raw = span / target
|
||||
const mag = 10 ** Math.floor(Math.log10(raw))
|
||||
const norm = raw / mag
|
||||
const step = (norm >= 5 ? 5 : norm >= 2 ? 2 : 1) * mag
|
||||
const ticks: number[] = []
|
||||
|
||||
for (let t = Math.ceil(start / step) * step; t <= end; t += step) {
|
||||
ticks.push(t)
|
||||
}
|
||||
|
||||
return ticks
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { scaleLinear } from 'd3-scale'
|
||||
import { select } from 'd3-selection'
|
||||
import {
|
||||
zoom as d3Zoom,
|
||||
type D3ZoomEvent,
|
||||
type ZoomBehavior,
|
||||
zoomIdentity,
|
||||
type ZoomTransform,
|
||||
zoomTransform
|
||||
} from 'd3-zoom'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ToolIcon } from '@/components/ui/tool-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$hoveredSpanId,
|
||||
$selectedSpanId,
|
||||
$traceLabelsCollapsed,
|
||||
clearHoveredSpan,
|
||||
flattenSpanTree,
|
||||
type TraceDoc,
|
||||
type TraceSpanNode
|
||||
} from '@/store/trace'
|
||||
|
||||
import { fmtDuration } from './format'
|
||||
import { barClass, spanIconName } from './span-style'
|
||||
import { buildTimeMap, niceTicks } from './time-map'
|
||||
|
||||
export const ROW_HEIGHT = 26
|
||||
const LABEL_WIDTH = 280
|
||||
const LABEL_MAX_WIDTH = 240
|
||||
const MAX_ZOOM = 5000
|
||||
|
||||
export function TraceWaterfall({ trace, viewKey }: { trace: TraceDoc; viewKey: string }) {
|
||||
const nodes = useMemo(() => flattenSpanTree(trace), [trace])
|
||||
const selectedId = useStore($selectedSpanId)
|
||||
const hoveredId = useStore($hoveredSpanId)
|
||||
const collapsed = useStore($traceLabelsCollapsed)
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const zoomRef = useRef<ZoomBehavior<HTMLDivElement, unknown> | null>(null)
|
||||
const [size, setSize] = useState({ height: 0, width: 0 })
|
||||
const [availHeight, setAvailHeight] = useState(0)
|
||||
const [transform, setTransform] = useState<ZoomTransform>(zoomIdentity)
|
||||
|
||||
// Measure the track so the time scale + zoom extents track its real width.
|
||||
useLayoutEffect(() => {
|
||||
const el = trackRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
if (entry) {
|
||||
setSize({ height: entry.contentRect.height, width: entry.contentRect.width })
|
||||
}
|
||||
})
|
||||
|
||||
ro.observe(el)
|
||||
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// Available height of the scroll area, so the track can fill it when there are
|
||||
// few rows (and still grow + scroll when there are many).
|
||||
useLayoutEffect(() => {
|
||||
const el = bodyRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
if (entry) {
|
||||
setAvailHeight(entry.contentRect.height)
|
||||
}
|
||||
})
|
||||
|
||||
ro.observe(el)
|
||||
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const tmap = useMemo(
|
||||
() => buildTimeMap(nodes, trace.start, trace.end || trace.start + 1),
|
||||
[nodes, trace.start, trace.end]
|
||||
)
|
||||
|
||||
const xScale = useMemo(
|
||||
() => scaleLinear().domain([0, tmap.totalV]).range([0, Math.max(1, size.width)]),
|
||||
[tmap.totalV, size.width]
|
||||
)
|
||||
|
||||
// d3-zoom owns drag-pan + click/drag separation; we drive the wheel ourselves.
|
||||
// Attached ONCE on mount (not gated on measured size) so the gesture is live
|
||||
// immediately — gating on size.width was the regression that "lost" zoom when
|
||||
// the grid delayed the first measurement.
|
||||
useEffect(() => {
|
||||
const el = trackRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const behavior = d3Zoom<HTMLDivElement, unknown>()
|
||||
.scaleExtent([1, MAX_ZOOM])
|
||||
.clickDistance(4)
|
||||
.filter((event: Event) => event.type !== 'wheel' && !(event as MouseEvent).button)
|
||||
.on('zoom', (event: D3ZoomEvent<HTMLDivElement, unknown>) => setTransform(event.transform))
|
||||
|
||||
zoomRef.current = behavior
|
||||
const sel = select(el)
|
||||
sel.call(behavior)
|
||||
sel.on('dblclick.zoom', null)
|
||||
|
||||
// Wheel routing on the track itself:
|
||||
// ⌘/Ctrl + wheel → zoom toward the cursor · horizontal/shift wheel → pan
|
||||
// time · plain vertical wheel → fall through so the row list scrolls.
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const px = e.clientX - el.getBoundingClientRect().left
|
||||
behavior.scaleBy(select(el), Math.exp(-e.deltaY * 0.002), [px, 0])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY)
|
||||
const dx = horizontal ? e.deltaX : e.shiftKey ? e.deltaY : 0
|
||||
|
||||
if (!dx) {
|
||||
return // plain vertical → let the list scroll
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
behavior.translateBy(select(el), -dx / zoomTransform(el).k, 0)
|
||||
}
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
|
||||
return () => {
|
||||
sel.on('.zoom', null)
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Vertical drag scrubs the actual scroll container (the body owns row
|
||||
// position; labels + canvas stay in sync because they share this scroller).
|
||||
useEffect(() => {
|
||||
const scroller = bodyRef.current
|
||||
|
||||
if (!scroller) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastY: null | number = null
|
||||
|
||||
const stop = () => {
|
||||
lastY = null
|
||||
window.removeEventListener('mousemove', move, { capture: true })
|
||||
window.removeEventListener('mouseup', stop, { capture: true })
|
||||
}
|
||||
|
||||
const move = (e: MouseEvent) => {
|
||||
if (!(e.buttons & 1) || lastY === null) {
|
||||
stop()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
scroller.scrollTop -= e.clientY - lastY
|
||||
lastY = e.clientY
|
||||
}
|
||||
|
||||
const start = (e: MouseEvent) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
lastY = e.clientY
|
||||
window.addEventListener('mousemove', move, { capture: true })
|
||||
window.addEventListener('mouseup', stop, { capture: true })
|
||||
}
|
||||
|
||||
scroller.addEventListener('mousedown', start, { capture: true })
|
||||
|
||||
return () => {
|
||||
scroller.removeEventListener('mousedown', start, { capture: true })
|
||||
stop()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keep the pan/zoom extents in sync with the measured track size.
|
||||
useEffect(() => {
|
||||
const behavior = zoomRef.current
|
||||
|
||||
if (!behavior || size.width === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
behavior
|
||||
.extent([
|
||||
[0, 0],
|
||||
[size.width, size.height]
|
||||
])
|
||||
.translateExtent([
|
||||
[0, 0],
|
||||
[size.width, size.height]
|
||||
])
|
||||
}, [size.width, size.height])
|
||||
|
||||
// Reset the viewport only on an explicit navigation (session switch or pinning
|
||||
// a different turn) — NOT when the followed turn settles live→DB, and not on
|
||||
// live ticks. Keeps the user's zoom/pan; no auto-nav.
|
||||
useEffect(() => {
|
||||
const el = trackRef.current
|
||||
|
||||
if (el && zoomRef.current) {
|
||||
select(el).call(zoomRef.current.transform, zoomIdentity)
|
||||
} else {
|
||||
setTransform(zoomIdentity)
|
||||
}
|
||||
}, [viewKey])
|
||||
|
||||
// Preserve the visible *real-time* window across tmap changes that AREN'T a
|
||||
// nav (same viewKey): live ticks growing the trace, and the live→DB fold where
|
||||
// the compressed axis (totalV) shifts. Without this, a zoomed-in view would
|
||||
// drift each tick and the fold would reframe/jump. We translate the OLD view's
|
||||
// edges back to real time, re-project them through the NEW map, and re-apply
|
||||
// the transform — so the same span stays put while the bars get more exact.
|
||||
const prevTmapRef = useRef(tmap)
|
||||
const prevViewKeyRef = useRef(viewKey)
|
||||
useLayoutEffect(() => {
|
||||
const el = trackRef.current
|
||||
const behavior = zoomRef.current
|
||||
const width = size.width
|
||||
|
||||
// Nav reset owns viewKey changes; just resync our baselines and bail.
|
||||
if (viewKey !== prevViewKeyRef.current) {
|
||||
prevViewKeyRef.current = viewKey
|
||||
prevTmapRef.current = tmap
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const oldMap = prevTmapRef.current
|
||||
|
||||
if (oldMap === tmap || !el || !behavior || width === 0) {
|
||||
prevTmapRef.current = tmap
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const t = zoomTransform(el)
|
||||
prevTmapRef.current = tmap
|
||||
|
||||
// Full view (identity) → keep showing the whole trace as it grows; nothing
|
||||
// to preserve, and re-projecting would fight the natural "fit all".
|
||||
if (t.k <= 1.0001 && Math.abs(t.x) < 0.5) {
|
||||
return
|
||||
}
|
||||
|
||||
const clampV = (v: number, total: number) => Math.max(0, Math.min(total, v))
|
||||
const oldXs = scaleLinear().domain([0, oldMap.totalV]).range([0, width])
|
||||
const zxOld = t.rescaleX(oldXs)
|
||||
const realStart = oldMap.toReal(clampV(zxOld.invert(0), oldMap.totalV))
|
||||
const realEnd = oldMap.toReal(clampV(zxOld.invert(width), oldMap.totalV))
|
||||
|
||||
const newXs = scaleLinear().domain([0, tmap.totalV]).range([0, width])
|
||||
const bx0 = newXs(tmap.toV(realStart))
|
||||
const bx1 = newXs(tmap.toV(realEnd))
|
||||
const k = Math.max(1, Math.min(MAX_ZOOM, width / Math.max(1, bx1 - bx0)))
|
||||
const x = Math.min(0, Math.max(width * (1 - k), -k * bx0))
|
||||
|
||||
select(el).call(behavior.transform, zoomIdentity.scale(k).translate(x / k, 0))
|
||||
}, [tmap, viewKey, size.width])
|
||||
|
||||
// Latest nodes/tmap read via refs so the zoom-to-span effect can fire ONLY on
|
||||
// a selection change — never on live ticks (which would re-snap the view).
|
||||
const nodesRef = useRef(nodes)
|
||||
nodesRef.current = nodes
|
||||
const tmapRef = useRef(tmap)
|
||||
tmapRef.current = tmap
|
||||
|
||||
// Selecting a span (clicking a row) zooms the timeline to frame it (~70%) and
|
||||
// scrolls to it. Keyed on the selection alone so it never fights live updates.
|
||||
useEffect(() => {
|
||||
const el = trackRef.current
|
||||
const behavior = zoomRef.current
|
||||
|
||||
if (!el || !behavior || !selectedId) {
|
||||
return
|
||||
}
|
||||
|
||||
const span = nodesRef.current.find(n => n.id === selectedId)
|
||||
const width = el.clientWidth
|
||||
|
||||
if (!span || !width) {
|
||||
return
|
||||
}
|
||||
|
||||
const map = tmapRef.current
|
||||
const xs = scaleLinear().domain([0, map.totalV]).range([0, width])
|
||||
const bx0 = xs(map.toV(span.start))
|
||||
const bx1 = xs(map.toV(span.end))
|
||||
const bw = Math.max(2, bx1 - bx0)
|
||||
const k = Math.max(1, Math.min(MAX_ZOOM, (width * 0.7) / bw))
|
||||
const center = (bx0 + bx1) / 2
|
||||
// Clamp the translate so the view can't slide past the start (left gap) or
|
||||
// the end — matching d3's translateExtent, but on the target so there's no
|
||||
// "show gap then snap back".
|
||||
const x = Math.min(0, Math.max(width * (1 - k), width / 2 - k * center))
|
||||
const next = zoomIdentity.scale(k).translate(x / k, 0)
|
||||
select(el).transition().duration(250).call(behavior.transform, next)
|
||||
}, [selectedId])
|
||||
|
||||
const view = useMemo(() => {
|
||||
if (size.width === 0) {
|
||||
return { end: tmap.totalV, start: 0 }
|
||||
}
|
||||
|
||||
const zx = transform.rescaleX(xScale)
|
||||
|
||||
return { end: zx.invert(size.width), start: zx.invert(0) }
|
||||
}, [transform, xScale, size.width, tmap.totalV])
|
||||
|
||||
// view is in compressed (virtual) coordinates.
|
||||
const viewSpan = Math.max(1e-6, view.end - view.start)
|
||||
const ticks = useMemo(() => niceTicks(view.start, view.end), [view.start, view.end])
|
||||
// pctV: a virtual coord → %; pct: a real time → % (via the compression map).
|
||||
const pctV = (vv: number) => ((vv - view.start) / viewSpan) * 100
|
||||
const pct = (t: number) => pctV(tmap.toV(t))
|
||||
|
||||
const resetView = () => {
|
||||
const el = trackRef.current
|
||||
|
||||
if (el && zoomRef.current) {
|
||||
select(el).transition().duration(200).call(zoomRef.current.transform, zoomIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
// One column template shared by the ruler and the body so the time axis and
|
||||
// the bars can never drift out of alignment. Column 1 is the label tree (a
|
||||
// thin gutter when collapsed, just wide enough for the expand toggle); the
|
||||
// `gap-x-2` between the columns separates labels from the chart.
|
||||
const cols = `${collapsed ? '1.5rem' : `${LABEL_WIDTH}px`} minmax(0, 1fr)`
|
||||
// Fill the scroll area when rows are few; grow + scroll when they're many.
|
||||
const bodyHeight = Math.max(nodes.length * ROW_HEIGHT, availHeight)
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Ruler — shares the body's column template; column 1 holds the collapse
|
||||
toggle (in flow so it never overlaps labels), column 2 the time axis. */}
|
||||
<div
|
||||
className="grid shrink-0 items-center gap-x-2 text-[0.62rem] text-muted-foreground/70"
|
||||
style={{ gridTemplateColumns: cols, height: ROW_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground/55 hover:bg-foreground/10 hover:text-foreground"
|
||||
onClick={() => $traceLabelsCollapsed.set(!collapsed)}
|
||||
title={collapsed ? 'Show labels' : 'Hide labels'}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={collapsed ? 'chevron-right' : 'chevron-left'} size="0.9rem" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative h-full overflow-hidden">
|
||||
{ticks.map(t => {
|
||||
const left = pctV(t)
|
||||
|
||||
if (left < 0 || left > 100) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Edge ticks align inward so the first/last label isn't sliced by the
|
||||
// track's clip; interior ticks center on their gridline.
|
||||
const edge = left < 4 ? 'left' : left > 96 ? 'right' : 'center'
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-y-1/2 px-1 whitespace-nowrap tabular-nums',
|
||||
edge === 'left' ? 'translate-x-0' : edge === 'right' ? '-translate-x-full' : '-translate-x-1/2'
|
||||
)}
|
||||
key={t}
|
||||
style={{ left: `${left}%` }}
|
||||
>
|
||||
{fmtDuration(tmap.toReal(t) - trace.start)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body — same column template; vertically scrolls labels + track together.
|
||||
scrollbar-gutter stays stable so switching between a short turn (no
|
||||
scrollbar) and the full session (scrollbar) never reflows the track. */}
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]"
|
||||
ref={bodyRef}
|
||||
>
|
||||
<div className="grid min-h-full gap-x-2" style={{ gridTemplateColumns: cols }}>
|
||||
{/* Column 1: label index (empty cell when collapsed keeps the grid 2-wide) */}
|
||||
{collapsed ? (
|
||||
<div />
|
||||
) : (
|
||||
<div>
|
||||
{nodes.map(node => (
|
||||
<SpanLabel active={node.id === selectedId || node.id === hoveredId} key={node.id} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column 2: time track */}
|
||||
<div
|
||||
className="relative cursor-grab touch-none overflow-hidden select-none active:cursor-grabbing"
|
||||
onClick={() => $selectedSpanId.set(null)}
|
||||
onDoubleClick={resetView}
|
||||
ref={trackRef}
|
||||
style={{ height: bodyHeight }}
|
||||
>
|
||||
{ticks.map(t => {
|
||||
const left = pctV(t)
|
||||
|
||||
// Skip the flush-left gridline at t=0 — it reads as a border.
|
||||
if (left <= 0.1 || left > 100) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 bottom-0 w-px bg-border/30"
|
||||
key={t}
|
||||
style={{ left: `${left}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Collapsed-idle markers: a dashed seam where dead time was removed. */}
|
||||
{tmap.gaps.map(g => {
|
||||
const left = pctV(g.v0)
|
||||
const right = pctV(g.v1)
|
||||
|
||||
if (right < 0 || left > 100) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 bottom-0 flex items-start justify-center border-l border-dashed border-border/50 bg-foreground/[0.03]"
|
||||
key={`gap-${g.v0}`}
|
||||
style={{ left: `${left}%`, width: `${Math.max(0.4, right - left)}%` }}
|
||||
>
|
||||
<span className="mt-0.5 rounded bg-background/70 px-1 text-[0.55rem] whitespace-nowrap text-muted-foreground/55">
|
||||
{fmtDuration(g.r1 - g.r0)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{nodes.map((node, i) => {
|
||||
const left = pct(node.start)
|
||||
const width = (tmap.toV(node.end) - tmap.toV(node.start)) / viewSpan
|
||||
const active = node.id === selectedId || node.id === hoveredId
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 left-0 transition-colors duration-100 ease-out hover:bg-foreground/[0.035] hover:transition-none',
|
||||
active && 'bg-foreground/[0.035]'
|
||||
)}
|
||||
key={node.id}
|
||||
onMouseEnter={() => $hoveredSpanId.set(node.id)}
|
||||
onMouseLeave={() => clearHoveredSpan(node.id)}
|
||||
style={{ height: ROW_HEIGHT, top: i * ROW_HEIGHT }}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'absolute top-1/2 h-4 -translate-y-1/2 overflow-hidden rounded-[3px] transition-[filter] hover:brightness-125',
|
||||
barClass(node),
|
||||
node.status === 'running' && 'animate-pulse',
|
||||
active && 'ring-1 ring-foreground/70'
|
||||
)}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
$selectedSpanId.set(node.id)
|
||||
}}
|
||||
style={{ left: `${left}%`, minWidth: 2, width: `${width * 100}%` }}
|
||||
type="button"
|
||||
/>
|
||||
{/* On-lane label: name + duration, anchored at the bar start.
|
||||
Only when the start is in view AND the bar is wide enough to
|
||||
host it (or it's active) — otherwise sliver bars (e.g. a
|
||||
sub-second span pinned at t=0) leave a floating duration
|
||||
stuck at the left edge. The left tree always has the full
|
||||
label, so slivers lose nothing. */}
|
||||
{left >= 0 && left < 100 && (active || width * size.width >= 32) ? (
|
||||
<span
|
||||
className="pointer-events-none absolute inset-y-0 flex items-center gap-1.5 truncate pl-1.5 text-[0.6rem] text-white/85"
|
||||
style={{ left: `${left}%`, maxWidth: LABEL_MAX_WIDTH }}
|
||||
>
|
||||
<span className="truncate">{node.name}</span>
|
||||
<span className="shrink-0 text-white/55">{fmtDuration(node.duration)}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SpanLabel({ active, node }: { active: boolean; node: TraceSpanNode }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 truncate pr-2 pl-2 text-left text-[0.72rem] text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
|
||||
active && 'bg-(--ui-row-active-background) text-foreground'
|
||||
)}
|
||||
onClick={() => $selectedSpanId.set(node.id)}
|
||||
onMouseEnter={() => $hoveredSpanId.set(node.id)}
|
||||
onMouseLeave={() => clearHoveredSpan(node.id)}
|
||||
style={{ height: ROW_HEIGHT, paddingLeft: 8 + node.depth * 14 }}
|
||||
type="button"
|
||||
>
|
||||
<ToolIcon
|
||||
className={cn('shrink-0', node.status === 'error' ? 'text-red-500' : 'text-muted-foreground/60')}
|
||||
name={spanIconName(node)}
|
||||
size="0.8rem"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/85">{node.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-[0.62rem] text-muted-foreground/55">{fmtDuration(node.duration)}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $traceTurns } from '@/store/trace'
|
||||
|
||||
interface TurnStripProps {
|
||||
activeIndex: null | number
|
||||
allActive: boolean
|
||||
liveIndex: null | number
|
||||
onAll: () => void
|
||||
onTurn: (index: number) => void
|
||||
}
|
||||
|
||||
// Turn nav as a row of timeline bars (à la the thread timeline). Each button is
|
||||
// full header height (the hit target); the bar inside is short when inactive,
|
||||
// full height when active/live.
|
||||
export function TurnStrip({ activeIndex, allActive, liveIndex, onAll, onTurn }: TurnStripProps) {
|
||||
const turns = useStore($traceTurns)
|
||||
|
||||
if (turns.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 self-stretch items-center gap-2 overflow-x-auto pt-7">
|
||||
<button
|
||||
aria-label="All turns"
|
||||
className={cn(
|
||||
'flex h-full shrink-0 items-center gap-1 rounded px-1 text-[0.6rem] font-medium tracking-wide uppercase transition-colors',
|
||||
allActive ? 'text-foreground' : 'text-muted-foreground/45 hover:text-foreground/80'
|
||||
)}
|
||||
onClick={onAll}
|
||||
title="All turns"
|
||||
type="button"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<div className="flex h-full items-center gap-px">
|
||||
{turns.map(turn => {
|
||||
const active = turn.index === activeIndex
|
||||
const live = turn.index === liveIndex
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={`Turn ${turn.index + 1}`}
|
||||
className="group flex h-full items-center px-px"
|
||||
key={turn.index}
|
||||
onClick={() => onTurn(turn.index)}
|
||||
onMouseEnter={() => onTurn(turn.index)}
|
||||
title={`#${turn.index + 1} · ${turn.label}`}
|
||||
type="button"
|
||||
>
|
||||
{/* Fixed-height box so the strip never grows when a bar activates;
|
||||
only the inner fill changes height. */}
|
||||
<span className="flex h-4 w-[3px] items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
'w-full rounded-full transition-all duration-100 ease-out group-hover:transition-none',
|
||||
live
|
||||
? 'h-full animate-pulse bg-emerald-500'
|
||||
: active
|
||||
? 'h-full bg-foreground'
|
||||
: 'h-1/2 bg-foreground/25 group-hover:h-3/4 group-hover:bg-foreground/50'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -39,7 +39,6 @@ interface ConversationProps {
|
||||
}
|
||||
|
||||
export function ComposerControls({
|
||||
autoSpeak,
|
||||
busy,
|
||||
busyAction,
|
||||
canSteer,
|
||||
@@ -51,10 +50,8 @@ export function ComposerControls({
|
||||
state,
|
||||
voiceStatus,
|
||||
onDictate,
|
||||
onSteer,
|
||||
onToggleAutoSpeak
|
||||
onSteer
|
||||
}: {
|
||||
autoSpeak: boolean
|
||||
busy: boolean
|
||||
busyAction: 'queue' | 'stop'
|
||||
canSteer: boolean
|
||||
@@ -67,7 +64,6 @@ export function ComposerControls({
|
||||
voiceStatus: VoiceStatus
|
||||
onDictate: () => void
|
||||
onSteer: () => void
|
||||
onToggleAutoSpeak: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
@@ -109,7 +105,6 @@ export function ComposerControls({
|
||||
) : (
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
)}
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
{showVoicePrimary ? (
|
||||
<Tip label={c.startVoice}>
|
||||
<Button
|
||||
@@ -259,47 +254,6 @@ function ConversationIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
// Pure-TTS toggle: type normally, but have every assistant reply read aloud —
|
||||
// no dictation, no full conversation loop. Filled/accent when on, mirroring the
|
||||
// muted-mic pressed state above. Driven by (and persisted to) `voice.auto_tts`.
|
||||
function AutoSpeakButton({
|
||||
active,
|
||||
disabled,
|
||||
onToggle
|
||||
}: {
|
||||
active: boolean
|
||||
disabled: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const label = active ? c.stopSpeakingReplies : c.speakReplies
|
||||
|
||||
return (
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
active && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic(active ? 'close' : 'open')
|
||||
onToggle()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{active ? <Volume2 size={14} /> : <VolumeX size={14} />}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
function DictationButton({
|
||||
disabled,
|
||||
state,
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { playSpeechText } from '@/lib/voice-playback'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
import { $autoSpeakReplies } from '@/store/voice-prefs'
|
||||
|
||||
interface AutoSpeakReply {
|
||||
id: string
|
||||
pending: boolean
|
||||
text: string
|
||||
}
|
||||
|
||||
interface UseAutoSpeakReplies {
|
||||
conversationActive: boolean
|
||||
failureLabel: string
|
||||
/** Mark the current last reply spoken — shared dedupe with the conversation consumer. */
|
||||
markSpoken: () => void
|
||||
/** Latest completed assistant reply, or null; `pending` true while still streaming. */
|
||||
pendingReply: () => AutoSpeakReply | null
|
||||
/** Re-arm on session switch so opening a chat never reads its existing last reply. */
|
||||
sessionId: string | null | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure-TTS auto-speak: when `voice.auto_tts` is on, read each completed assistant
|
||||
* turn aloud — no dictation, no conversation loop. Stays off while a full voice
|
||||
* conversation runs (it speaks replies itself) and never overlaps clips: a reply
|
||||
* landing mid-playback is held and spoken on the playback-idle edge. Always reads
|
||||
* the latest reply, so a backlog collapses to the newest.
|
||||
*/
|
||||
export function useAutoSpeakReplies({
|
||||
conversationActive,
|
||||
failureLabel,
|
||||
markSpoken,
|
||||
pendingReply,
|
||||
sessionId
|
||||
}: UseAutoSpeakReplies) {
|
||||
const enabled = useStore($autoSpeakReplies)
|
||||
const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply })
|
||||
latest.current = { conversationActive, failureLabel, markSpoken, pendingReply }
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Don't read whatever reply already sits at the bottom when the toggle flips
|
||||
// on (or a chat opens) — consume it so only later replies are spoken.
|
||||
latest.current.markSpoken()
|
||||
|
||||
const speakLatest = () => {
|
||||
const { conversationActive, failureLabel, markSpoken, pendingReply } = latest.current
|
||||
|
||||
if (conversationActive || $voicePlayback.get().status !== 'idle') {
|
||||
return
|
||||
}
|
||||
|
||||
const reply = pendingReply()
|
||||
|
||||
if (!reply || reply.pending) {
|
||||
return
|
||||
}
|
||||
|
||||
markSpoken()
|
||||
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
|
||||
notifyError(error, failureLabel)
|
||||
)
|
||||
}
|
||||
|
||||
// Re-check on a reply completing ($messages) and on the prior clip ending
|
||||
// ($voicePlayback → idle), which frees us to read the next held reply.
|
||||
const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)]
|
||||
|
||||
return () => stops.forEach(f => f())
|
||||
}, [enabled, sessionId])
|
||||
}
|
||||
@@ -60,14 +60,12 @@ import {
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
import { $activeSessionAwaitingInput } from '@/store/prompts'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { useTheme } from '@/themes'
|
||||
|
||||
@@ -89,7 +87,6 @@ import {
|
||||
} from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
import { useAtCompletions } from './hooks/use-at-completions'
|
||||
import { useAutoSpeakReplies } from './hooks/use-auto-speak-replies'
|
||||
import { useComposerPopoutGestures } from './hooks/use-popout-drag'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
@@ -232,12 +229,6 @@ export function ChatBar({
|
||||
const statusItemsBySession = useStore($statusItemsBySession)
|
||||
const previewStatusBySession = useStore($previewStatusBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const autoSpeak = useStore($autoSpeakReplies)
|
||||
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
|
||||
// not interrupt it — there's nothing actively running to stop, and stopping
|
||||
// would discard a question the user may want to come back to. The blocking
|
||||
// prompt owns its own dismissal (Skip, Reject, dialog close).
|
||||
const awaitingInput = useStore($activeSessionAwaitingInput)
|
||||
// Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N
|
||||
// tiny window, subagent watch windows) always start docked and can't pop out:
|
||||
// a floating composer makes no sense in a single-session side window, and it
|
||||
@@ -1223,10 +1214,8 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise Esc interrupts the running turn (Stop-button parity) — unless
|
||||
// the turn is parked waiting on the user, where Esc must not discard the
|
||||
// pending prompt.
|
||||
if (busy && !awaitingInput) {
|
||||
// Otherwise Esc interrupts the running turn (Stop-button parity).
|
||||
if (busy) {
|
||||
event.preventDefault()
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
@@ -1790,10 +1779,7 @@ export function ChatBar({
|
||||
const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {})
|
||||
|
||||
escCancelRef.current = (event: globalThis.KeyboardEvent) => {
|
||||
// `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret
|
||||
// prompt, which owns Esc (or is meant to persist) — never cancel the stream
|
||||
// out from under it.
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !busy) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2024,20 +2010,6 @@ export function ChatBar({
|
||||
|
||||
useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
|
||||
|
||||
const handleToggleAutoSpeak = useCallback(() => {
|
||||
void setAutoSpeakReplies(!$autoSpeakReplies.get()).catch(error =>
|
||||
notifyError(error, t.settings.config.autosaveFailed)
|
||||
)
|
||||
}, [t])
|
||||
|
||||
useAutoSpeakReplies({
|
||||
conversationActive: voiceConversationActive,
|
||||
failureLabel: t.assistant.thread.readAloudFailed,
|
||||
markSpoken: consumePendingResponse,
|
||||
pendingReply: pendingResponse,
|
||||
sessionId
|
||||
})
|
||||
|
||||
const contextMenu = (
|
||||
<ContextMenu
|
||||
onInsertText={insertText}
|
||||
@@ -2055,7 +2027,6 @@ export function ChatBar({
|
||||
|
||||
const controls = (
|
||||
<ComposerControls
|
||||
autoSpeak={autoSpeak}
|
||||
busy={busy}
|
||||
busyAction={busyAction}
|
||||
canSteer={canSteer}
|
||||
@@ -2078,7 +2049,6 @@ export function ChatBar({
|
||||
hasComposerPayload={hasComposerPayload}
|
||||
onDictate={dictate}
|
||||
onSteer={steerDraft}
|
||||
onToggleAutoSpeak={handleToggleAutoSpeak}
|
||||
state={state}
|
||||
voiceStatus={voiceStatus}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Fragment, memo, type ReactNode } from 'react'
|
||||
import { Fragment, memo, type ReactNode, useState } from 'react'
|
||||
|
||||
import { openAgentTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { TerminalOutput } from '@/components/chat/terminal-output'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
@@ -81,6 +82,7 @@ interface StatusItemRowProps {
|
||||
export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack
|
||||
const [outputOpen, setOutputOpen] = useState(false)
|
||||
const failed = item.state === 'failed'
|
||||
const running = item.state === 'running'
|
||||
|
||||
@@ -92,10 +94,8 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
|
||||
: null
|
||||
|
||||
const canOpen = item.type === 'subagent' && !!onOpen
|
||||
|
||||
// Background rows link to their read-only terminal tab; subagents open their session.
|
||||
const onActivate =
|
||||
item.type === 'background' ? () => openAgentTerminal(item.id, item.title) : canOpen ? onOpen : undefined
|
||||
const hasOutput = item.type === 'background' && !!item.output
|
||||
const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -146,7 +146,9 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
|
||||
{s.exit(item.exitCode)}
|
||||
</span>
|
||||
)}
|
||||
{hasOutput && <DisclosureCaret className="shrink-0 text-muted-foreground/45" open={outputOpen} size="0.8em" />}
|
||||
</StatusRow>
|
||||
{hasOutput && outputOpen && <TerminalOutput className="mx-auto mb-1 max-w-[90%]" text={item.output!} />}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
import { readDesktopFileDataUrl, selectDesktopPaths } from '@/lib/desktop-fs'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
type ComposerAttachment,
|
||||
@@ -263,7 +262,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
const pickContextPaths = useCallback(
|
||||
async (kind: 'file' | 'folder') => {
|
||||
const paths = await selectDesktopPaths({
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
|
||||
defaultPath: currentCwd || undefined,
|
||||
directories: kind === 'folder'
|
||||
@@ -348,7 +347,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
attachToMain(baseAttachment)
|
||||
|
||||
try {
|
||||
const previewUrl = await readDesktopFileDataUrl(filePath)
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
@@ -396,7 +395,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
)
|
||||
|
||||
const pickImages = useCallback(async () => {
|
||||
const paths = await selectDesktopPaths({
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
title: copy.attachImages,
|
||||
defaultPath: currentCwd || undefined,
|
||||
filters: [
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
$sessions,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import { isSecondaryWindow, isWatchWindow } from '@/store/windows'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
@@ -342,9 +342,8 @@ export function ChatView({
|
||||
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
|
||||
// Hide the composer in the exhausted error state too: there's no live runtime
|
||||
// to send to until a retry rebinds one. Watch windows are pure spectators of a
|
||||
// subagent run driven elsewhere — no composer, transcript is read-only.
|
||||
const showChatBar = !loadingSession && !resumeExhausted && !isWatchWindow()
|
||||
// to send to until a retry rebinds one.
|
||||
const showChatBar = !loadingSession && !resumeExhausted
|
||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||
|
||||
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||
|
||||
@@ -1149,8 +1149,7 @@ export function ChatSidebar({
|
||||
|
||||
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
||||
|
||||
const showSessionSections =
|
||||
showSessionSkeletons || sortedSessions.length > 0 || projectModel.length > 0
|
||||
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
|
||||
|
||||
// Each reorderable list reports its OWN new id order; persisting is a direct,
|
||||
// typed write — no id-prefix sniffing to figure out which level moved.
|
||||
@@ -1538,7 +1537,7 @@ export function ChatSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentVisible && !showSessionSections && <SidebarBlankState onNewProject={openProjectCreate} />}
|
||||
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
|
||||
|
||||
{contentVisible && (
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
@@ -1619,29 +1618,6 @@ function SidebarSessionSkeletons() {
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 place-items-center px-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Codicon className="text-(--ui-text-quaternary)" name="root-folder" size="1.25rem" />
|
||||
<p className="text-xs text-(--ui-text-tertiary)">{s.noSessions}</p>
|
||||
<Button
|
||||
className="mt-0.5 text-(--ui-text-secondary)"
|
||||
onClick={onNewProject}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
{s.projects.newButton}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarPinnedEmptyState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ export function ProfileRail() {
|
||||
}, [createRequest])
|
||||
|
||||
return (
|
||||
<div aria-label="Profiles" className="flex items-center gap-0.5" data-slot="profile-rail" role="tablist">
|
||||
<div aria-label="Profiles" className="flex items-center gap-0.5" role="tablist">
|
||||
{/* One button toggles default ↔ all: home face when scoped to a profile,
|
||||
layers face when showing everything. Pinned left like Manage is right.
|
||||
Hidden until a second profile exists. */}
|
||||
|
||||
@@ -87,25 +87,21 @@ export function ProjectDialog() {
|
||||
}
|
||||
|
||||
const pickFolder = async () => {
|
||||
try {
|
||||
const dir = await pickProjectFolder()
|
||||
const dir = await pickProjectFolder()
|
||||
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
|
||||
const projectId = state?.projectId
|
||||
|
||||
if (mode === 'add-folder' && projectId) {
|
||||
await runSubmit(() => addProjectFolder(projectId, dir))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
|
||||
} catch (err) {
|
||||
notifyError(err, p.createFailed)
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
|
||||
const projectId = state?.projectId
|
||||
|
||||
if (mode === 'add-folder' && projectId) {
|
||||
await runSubmit(() => addProjectFolder(projectId, dir))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
@@ -149,10 +145,7 @@ export function ProjectDialog() {
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onInteractOutside={event => event.preventDefault()}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { HermesGitWorktree } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { desktopGit } from '@/lib/desktop-git'
|
||||
import { mapPool } from '@/lib/pool'
|
||||
import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout'
|
||||
import { $worktreeRefreshToken } from '@/store/projects'
|
||||
@@ -89,7 +88,7 @@ export function useRepoWorktreeMap(
|
||||
const refreshToken = useStore($worktreeRefreshToken)
|
||||
|
||||
useEffect(() => {
|
||||
const git = desktopGit()
|
||||
const git = window.hermesDesktop?.git
|
||||
|
||||
if (!enabled || !repoPaths.length || !git?.worktreeList) {
|
||||
setMap({})
|
||||
|
||||
@@ -9,16 +9,7 @@ import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway,
|
||||
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
Bookmark,
|
||||
BookmarkFilled,
|
||||
Download,
|
||||
MessageCircle,
|
||||
Trash2
|
||||
} from '@/lib/icons'
|
||||
import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, Pin, Trash2 } from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
@@ -272,7 +263,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
{SECTIONS.map(value => (
|
||||
<OverlayNavItem
|
||||
active={section === value}
|
||||
icon={value === 'sessions' ? MessageCircle : value === 'system' ? Activity : BarChart3}
|
||||
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : BarChart3}
|
||||
key={value}
|
||||
label={cc.sections[value]}
|
||||
onClick={() => setSection(value)}
|
||||
@@ -370,7 +361,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
/>
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-4">
|
||||
<div>
|
||||
<div className="border-b border-(--ui-stroke-tertiary) pb-4">
|
||||
{status ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -415,7 +406,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col pt-2">
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
|
||||
{cc.recentLogs}
|
||||
@@ -512,7 +503,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 py-2 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 border-b border-(--ui-stroke-tertiary) pb-5 sm:grid-cols-3">
|
||||
<UsageStat label={cc.statSessions} value={formatInteger(totals.total_sessions)} />
|
||||
<UsageStat label={cc.statApiCalls} value={formatInteger(totals.total_api_calls)} />
|
||||
<UsageStat
|
||||
@@ -572,7 +563,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid min-h-0 gap-x-8 gap-y-5 pt-1 sm:grid-cols-2">
|
||||
<div className="grid min-h-0 gap-x-8 gap-y-5 border-t border-(--ui-stroke-tertiary) pt-5 sm:grid-cols-2">
|
||||
<UsageList
|
||||
emptyLabel={cc.noModelUsage}
|
||||
rows={byModel.slice(0, 6).map(entry => ({
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* user can grab several.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
@@ -19,7 +18,6 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Download, Loader2, Palette } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { $marketplaceInstalls } from '@/themes/user-themes'
|
||||
|
||||
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
|
||||
|
||||
@@ -45,8 +43,8 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.installTheme
|
||||
const debouncedSearch = useDebounced(search.trim(), 300)
|
||||
const installs = useStore($marketplaceInstalls)
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [installed, setInstalled] = useState<Record<string, true>>({})
|
||||
const [installError, setInstallError] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
@@ -55,20 +53,6 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
||||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
// Already installed → just re-activate it; never re-download what we have.
|
||||
const select = (item: DesktopMarketplaceSearchItem) => {
|
||||
const owned = installs.get(item.extensionId)
|
||||
|
||||
if (owned) {
|
||||
triggerHaptic('crisp')
|
||||
onPickTheme(owned.name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void install(item)
|
||||
}
|
||||
|
||||
const install = async (item: DesktopMarketplaceSearchItem) => {
|
||||
if (installingId) {
|
||||
return
|
||||
@@ -81,6 +65,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
||||
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
|
||||
|
||||
triggerHaptic('crisp')
|
||||
setInstalled(prev => ({ ...prev, [item.extensionId]: true }))
|
||||
onPickTheme(theme.name)
|
||||
} catch (error) {
|
||||
setInstallError(error instanceof Error ? error.message : copy.error)
|
||||
@@ -108,7 +93,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
||||
{installError && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{installError}</p>}
|
||||
{results.map(item => {
|
||||
const busy = installingId === item.extensionId
|
||||
const done = installs.has(item.extensionId)
|
||||
const done = installed[item.extensionId]
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -119,7 +104,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
||||
)}
|
||||
disabled={Boolean(installingId) && !busy}
|
||||
key={item.extensionId}
|
||||
onClick={() => select(item)}
|
||||
onClick={() => void install(item)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
|
||||
+155
-123
@@ -14,6 +14,7 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
@@ -29,28 +30,14 @@ import {
|
||||
updateCronJob
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { AlertTriangle, Clock } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
|
||||
import {
|
||||
Panel,
|
||||
PanelAction,
|
||||
PanelAddButton,
|
||||
PanelBlock,
|
||||
PanelBody,
|
||||
PanelDetail,
|
||||
PanelEmpty,
|
||||
PanelHeader,
|
||||
PanelList,
|
||||
PanelListRow,
|
||||
PanelMeta,
|
||||
PanelPill,
|
||||
type PanelPillTone,
|
||||
PanelRowMenu,
|
||||
PanelSectionLabel
|
||||
} from '../overlays/panel'
|
||||
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
@@ -69,7 +56,7 @@ const SCHEDULE_OPTIONS: ReadonlyArray<ScheduleOption> = [
|
||||
{ value: 'custom' }
|
||||
]
|
||||
|
||||
const STATE_TONE: Record<string, PanelPillTone> = {
|
||||
const STATE_TONE: Record<string, 'good' | 'muted' | 'warn' | 'bad'> = {
|
||||
enabled: 'good',
|
||||
scheduled: 'good',
|
||||
running: 'good',
|
||||
@@ -79,6 +66,13 @@ const STATE_TONE: Record<string, PanelPillTone> = {
|
||||
completed: 'muted'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<'good' | 'muted' | 'warn' | 'bad', string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300',
|
||||
bad: 'bg-destructive/10 text-destructive'
|
||||
}
|
||||
|
||||
const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
|
||||
|
||||
const truncate = (value: string, max = 80): string => (value.length > max ? `${value.slice(0, max)}…` : value)
|
||||
@@ -327,7 +321,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
||||
|
||||
pendingScrollRef.current = null
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector(`[data-panel-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
document.querySelector(`[data-cron-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}, [selectedJob])
|
||||
|
||||
@@ -412,66 +406,60 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel closeLabel={c.close} onClose={onClose}>
|
||||
<OverlayView closeLabel={c.close} onClose={onClose}>
|
||||
{loading && jobs.length === 0 ? (
|
||||
<PageLoader label={c.loading} />
|
||||
) : totalCount === 0 ? (
|
||||
<PanelEmpty
|
||||
action={
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
{c.newCron}
|
||||
</Button>
|
||||
}
|
||||
description={c.emptyDescNew}
|
||||
icon="watch"
|
||||
title={c.emptyTitleNew}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<PanelHeader subtitle={c.count(totalCount)} title={c.title} />
|
||||
<PanelBody>
|
||||
<PanelList
|
||||
onSearchChange={setQuery}
|
||||
searchLabel={c.search}
|
||||
searchPlaceholder={c.search}
|
||||
searchValue={query}
|
||||
>
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobListRow
|
||||
active={selectedJob?.id === job.id}
|
||||
job={job}
|
||||
key={job.id}
|
||||
menu={
|
||||
<PanelRowMenu
|
||||
items={[
|
||||
{ icon: 'edit', label: c.edit, onSelect: () => setEditor({ mode: 'edit', job }) },
|
||||
{ icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
onSelect={() => setSelectedJobId(job.id)}
|
||||
/>
|
||||
))}
|
||||
{visibleJobs.length === 0 && (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{c.emptyTitleSearch}</p>
|
||||
)}
|
||||
<PanelAddButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
|
||||
</PanelList>
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
<OverlayNewButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
|
||||
{totalCount > 0 && (
|
||||
<SearchField
|
||||
aria-label={c.search}
|
||||
containerClassName="mb-1 w-full px-2"
|
||||
onChange={setQuery}
|
||||
placeholder={c.search}
|
||||
value={query}
|
||||
/>
|
||||
)}
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobListRow
|
||||
active={selectedJob?.id === job.id}
|
||||
c={c}
|
||||
job={job}
|
||||
key={job.id}
|
||||
onSelect={() => setSelectedJobId(job.id)}
|
||||
/>
|
||||
))}
|
||||
{visibleJobs.length === 0 && (
|
||||
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
{totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
|
||||
</p>
|
||||
)}
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain className="px-0">
|
||||
{selectedJob ? (
|
||||
<CronJobDetail
|
||||
busy={busyJobId === selectedJob.id}
|
||||
c={c}
|
||||
job={selectedJob}
|
||||
onDelete={() => setPendingDelete(selectedJob)}
|
||||
onEdit={() => setEditor({ mode: 'edit', job: selectedJob })}
|
||||
onOpenSession={onOpenSession}
|
||||
onPauseResume={() => void handlePauseResume(selectedJob)}
|
||||
onTrigger={() => void handleTrigger(selectedJob)}
|
||||
/>
|
||||
) : (
|
||||
<PanelEmpty description={c.emptyDescSearch} icon="search" />
|
||||
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Clock className="mx-auto size-6 text-muted-foreground/60" />
|
||||
<p className="mt-3">{totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PanelBody>
|
||||
</>
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
)}
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
@@ -500,32 +488,42 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Panel>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
function CronJobListRow({
|
||||
active,
|
||||
c,
|
||||
job,
|
||||
menu,
|
||||
onSelect
|
||||
}: {
|
||||
active: boolean
|
||||
c: Translations['cron']
|
||||
job: CronJob
|
||||
menu?: React.ReactNode
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const state = jobState(job)
|
||||
|
||||
return (
|
||||
<PanelListRow
|
||||
active={active}
|
||||
dotClassName={STATE_DOT[state] ?? 'bg-muted-foreground'}
|
||||
menu={menu}
|
||||
onSelect={onSelect}
|
||||
rowKey={job.id}
|
||||
title={jobTitle(job)}
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
|
||||
)}
|
||||
data-cron-row={job.id}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('size-1.5 shrink-0 rounded-full', STATE_DOT[state] ?? 'bg-muted-foreground')}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{jobTitle(job)}</span>
|
||||
</span>
|
||||
<span className="truncate pl-3.5 text-[0.66rem] text-muted-foreground">{jobScheduleDisplay(job)}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -533,6 +531,8 @@ function CronJobDetail({
|
||||
busy,
|
||||
c,
|
||||
job,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenSession,
|
||||
onPauseResume,
|
||||
onTrigger
|
||||
@@ -540,6 +540,8 @@ function CronJobDetail({
|
||||
busy: boolean
|
||||
c: Translations['cron']
|
||||
job: CronJob
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
onOpenSession?: (sessionId: string) => void
|
||||
onPauseResume: () => void
|
||||
onTrigger: () => void
|
||||
@@ -550,49 +552,69 @@ function CronJobDetail({
|
||||
const prompt = jobPrompt(job)
|
||||
|
||||
return (
|
||||
<PanelDetail>
|
||||
<header className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="text-[0.95rem] font-semibold tracking-tight text-foreground">{jobTitle(job)}</h3>
|
||||
<PanelPill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</PanelPill>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<PanelAction disabled={busy} icon={isPaused ? 'play' : 'debug-pause'} onClick={onPauseResume}>
|
||||
{isPaused ? c.resumeTitle : c.pauseTitle}
|
||||
</PanelAction>
|
||||
<PanelAction disabled={busy} icon="zap" onClick={onTrigger}>
|
||||
{c.triggerNow}
|
||||
</PanelAction>
|
||||
</div>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||
<header className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-xl font-semibold tracking-tight">{jobTitle(job)}</h3>
|
||||
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
|
||||
{deliver && deliver !== DEFAULT_DELIVER && (
|
||||
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.7rem] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{jobScheduleDisplay(job)}
|
||||
</span>
|
||||
<span>
|
||||
{c.last} {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{c.next} {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button disabled={busy} onClick={onPauseResume} size="sm" variant="outline">
|
||||
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
|
||||
{isPaused ? c.resumeTitle : c.pauseTitle}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={onTrigger} size="sm" variant="outline">
|
||||
<Codicon name="zap" size="0.875rem" />
|
||||
{c.triggerNow}
|
||||
</Button>
|
||||
<Button onClick={onEdit} size="sm" variant="outline">
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
{c.edit}
|
||||
</Button>
|
||||
<Button
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prompt && <p className="line-clamp-3 text-xs text-muted-foreground">{prompt}</p>}
|
||||
{job.last_error && (
|
||||
<p className="inline-flex items-start gap-1 text-[0.7rem] text-destructive">
|
||||
<AlertTriangle className="mt-px size-3 shrink-0" />
|
||||
<span className="line-clamp-2">{job.last_error}</span>
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<CronJobRuns c={c} jobId={job.id} onOpenSession={onOpenSession} />
|
||||
</div>
|
||||
|
||||
<PanelMeta
|
||||
rows={[
|
||||
{ label: c.frequencyLabel, value: jobScheduleDisplay(job) },
|
||||
{ label: c.last.replace(/:$/, ''), value: formatTime(job.last_run_at) },
|
||||
{ label: c.next.replace(/:$/, ''), value: formatTime(job.next_run_at) },
|
||||
{ label: c.deliverLabel, value: c.deliveryLabels[deliver] ?? deliver }
|
||||
]}
|
||||
/>
|
||||
|
||||
{job.last_error ? (
|
||||
<div className="flex items-start gap-1.5 rounded bg-destructive/10 p-2 text-[0.7rem] text-destructive">
|
||||
<AlertTriangle className="mt-px size-3 shrink-0" />
|
||||
<span className="min-w-0 break-words">{job.last_error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{prompt ? (
|
||||
<section className="space-y-1.5">
|
||||
<PanelSectionLabel>{c.promptLabel}</PanelSectionLabel>
|
||||
<PanelBlock>{prompt}</PanelBlock>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<CronJobRuns c={c} jobId={job.id} onOpenSession={onOpenSession} />
|
||||
</PanelDetail>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -663,10 +685,10 @@ function CronJobRuns({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PanelSectionLabel className="mb-1.5">
|
||||
<div className="mb-1.5 text-[0.62rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{c.runHistory}
|
||||
{runs && runs.length > 0 ? ` · ${runs.length}` : ''}
|
||||
</PanelSectionLabel>
|
||||
</div>
|
||||
{runs === null ? (
|
||||
<div className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground">
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
@@ -677,13 +699,13 @@ function CronJobRuns({
|
||||
<div className="flex flex-col gap-px">
|
||||
{runs.map(run => (
|
||||
<button
|
||||
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs transition-colors duration-100 hover:bg-(--ui-row-hover-background) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs hover:bg-(--chrome-action-hover) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
key={run.id}
|
||||
onClick={() => onOpenSession?.(run.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate text-foreground/85">{run.title?.trim() || run.preview?.trim() || run.id}</span>
|
||||
<span className="shrink-0 text-[0.62rem] text-muted-foreground/55 tabular-nums">
|
||||
<span className="truncate text-foreground">{run.title?.trim() || run.preview?.trim() || run.id}</span>
|
||||
<span className="shrink-0 text-[0.62rem] text-muted-foreground tabular-nums">
|
||||
{formatRunTime(run.last_active || run.started_at)}
|
||||
</span>
|
||||
</button>
|
||||
@@ -694,6 +716,16 @@ function CronJobRuns({
|
||||
)
|
||||
}
|
||||
|
||||
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem] capitalize', PILL_TONE[tone])}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CronEditorDialog({
|
||||
editor,
|
||||
onClose,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla
|
||||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { isFocusWithin } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
@@ -125,12 +124,9 @@ import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { FileActionDialogs } from './right-sidebar/file-actions'
|
||||
import { RemoteFolderPicker } from './right-sidebar/files/remote-picker'
|
||||
import { ReviewPane } from './right-sidebar/review'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { TerminalPaneChrome } from './right-sidebar/terminal/chrome'
|
||||
import { PersistentTerminal } from './right-sidebar/terminal/persistent'
|
||||
import { closeActiveTerminal } from './right-sidebar/terminal/terminals'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||
import { SessionPickerOverlay } from './session-picker-overlay'
|
||||
import { SessionSwitcher } from './session-switcher'
|
||||
@@ -391,25 +387,11 @@ export function DesktopController() {
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.altKey || event.shiftKey || event.key.toLowerCase() !== 'w' || (!event.metaKey && !event.ctrlKey)) {
|
||||
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Terminal focused: ⌘W closes the active terminal. Ctrl+W is left untouched
|
||||
// for the shell's werase, and nothing else may steal ⌘/Ctrl+W from a
|
||||
// focused terminal (so it never closes a preview tab out from under it).
|
||||
if (isFocusWithin('[data-terminal]')) {
|
||||
if (event.metaKey && !event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeActiveTerminal()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise ⌘/Ctrl+W closes the active preview tab when one is open.
|
||||
if ($filePreviewTarget.get() || $previewTarget.get()) {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeActiveRightRailTab()
|
||||
@@ -598,7 +580,7 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
|
||||
const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
|
||||
|
||||
const updateActiveSessionRuntimeInfo = useCallback(
|
||||
(info: { branch?: string; cwd?: string }) => {
|
||||
@@ -1078,6 +1060,7 @@ export function DesktopController() {
|
||||
commandCenterOpen,
|
||||
extraLeftItems: statusbarItemGroups.flat.left,
|
||||
extraRightItems: statusbarItemGroups.flat.right,
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
openAgents,
|
||||
@@ -1112,13 +1095,11 @@ export function DesktopController() {
|
||||
/>
|
||||
)
|
||||
|
||||
// The persistent xterm layer (one host per terminal tab), CSS-overlaid onto the
|
||||
// pane's <TerminalSlot />. Lives in main's stacking context (not the root overlay
|
||||
// layer) so pane resize handles still paint above it. Terminals own their state
|
||||
// (incl. a snapshotted cwd) independent of the session, so switching sessions
|
||||
// never rebuilds or closes them; toggling the pane never rebuilds the shells.
|
||||
// One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders decide
|
||||
// where it shows. Lives in main's stacking context (not the root overlay layer)
|
||||
// so pane resize handles still paint above it. Toggling never rebuilds the shell.
|
||||
const mainOverlays = (
|
||||
<PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
||||
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
||||
)
|
||||
|
||||
const overlays = (
|
||||
@@ -1146,7 +1127,6 @@ export function DesktopController() {
|
||||
<PetGenerateOverlay />
|
||||
<SessionSwitcher />
|
||||
<FileActionDialogs />
|
||||
<RemoteFolderPicker />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
@@ -1349,7 +1329,7 @@ export function DesktopController() {
|
||||
terminalAsRow ? 'border-l border-(--ui-stroke-secondary) pt-0' : 'pt-(--titlebar-height)'
|
||||
)}
|
||||
>
|
||||
<TerminalPaneChrome />
|
||||
<TerminalSlot />
|
||||
</div>
|
||||
</Pane>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { desktopDefaultCwd } from '@/lib/desktop-fs'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import {
|
||||
$desktopBoot,
|
||||
applyDesktopBootProgress,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $gatewayState, setConnection } from '@/store/session'
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||
import { matchesQuery } from '@/hooks/use-media-query'
|
||||
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
@@ -165,17 +164,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
'view.toggleReview': toggleReview,
|
||||
'view.showFiles': showFiles,
|
||||
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||
// Create first so the pane's open-effect ensure sees a non-empty set and
|
||||
// doesn't also spawn one — net effect is exactly one fresh terminal.
|
||||
'view.newTerminal': () => {
|
||||
createTerminal()
|
||||
setTerminalTakeover(true)
|
||||
},
|
||||
// Switch / close only act while the pane is open (no focus-scoping here, so
|
||||
// this stands in for "terminal is showing").
|
||||
'view.nextTerminal': () => $terminalTakeover.get() && cycleTerminal(1),
|
||||
'view.prevTerminal': () => $terminalTakeover.get() && cycleTerminal(-1),
|
||||
'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
|
||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MessagingPlatformInfo } from '@/types/hermes'
|
||||
|
||||
const getMessagingPlatforms = vi.fn()
|
||||
const updateMessagingPlatform = vi.fn()
|
||||
const openExternalLink = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getMessagingPlatforms: () => getMessagingPlatforms(),
|
||||
updateMessagingPlatform: (id: string, body: unknown) => updateMessagingPlatform(id, body)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/external-link', () => ({
|
||||
openExternalLink: (href: string) => openExternalLink(href)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/system-actions', () => ({
|
||||
runGatewayRestart: vi.fn()
|
||||
}))
|
||||
|
||||
function platform(patch: Partial<MessagingPlatformInfo> = {}): MessagingPlatformInfo {
|
||||
return {
|
||||
configured: false,
|
||||
description: 'A platform.',
|
||||
docs_url: '',
|
||||
enabled: false,
|
||||
env_vars: [],
|
||||
gateway_running: true,
|
||||
id: 'teams',
|
||||
name: 'Microsoft Teams',
|
||||
state: 'disabled',
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateMessagingPlatform.mockResolvedValue({ ok: true, platform: 'teams' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderMessaging() {
|
||||
const { MessagingView } = await import('./index')
|
||||
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MessagingView />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('MessagingView setup-guide link', () => {
|
||||
it('hides the setup-guide button for a plugin platform with no docs URL', async () => {
|
||||
// Teams (and other plugin platforms) ship an empty docs_url. Rendering an
|
||||
// anchor with href="" let Electron resolve it to the app's own packaged
|
||||
// index.html and fail with an OS "file not found" dialog. The button must
|
||||
// simply not appear when there is no guide to open.
|
||||
getMessagingPlatforms.mockResolvedValue({ platforms: [platform({ docs_url: '' })] })
|
||||
|
||||
await renderMessaging()
|
||||
|
||||
expect((await screen.findAllByText('Microsoft Teams')).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Open setup guide')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a real docs URL through the validated external opener', async () => {
|
||||
const docsUrl = 'https://hermes-agent.nousresearch.com/docs/user-guide/messaging/teams'
|
||||
getMessagingPlatforms.mockResolvedValue({ platforms: [platform({ docs_url: docsUrl })] })
|
||||
|
||||
await renderMessaging()
|
||||
|
||||
const link = await screen.findByText('Open setup guide')
|
||||
fireEvent.click(link)
|
||||
|
||||
await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl))
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
updateMessagingPlatform
|
||||
} from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { openExternalLink } from '@/lib/external-link'
|
||||
import { AlertTriangle, ExternalLink, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -405,31 +404,14 @@ function PlatformDetail({
|
||||
<p className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{introCopy(platform, m)}
|
||||
</p>
|
||||
{platform.docs_url && (
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="textStrong">
|
||||
<a
|
||||
href={platform.docs_url}
|
||||
onClick={event => {
|
||||
// Route through the validated external opener instead of
|
||||
// letting Electron resolve the anchor. A packaged build's
|
||||
// empty/relative href resolves to the app's own
|
||||
// index.html file path, which shell.openPath then fails to
|
||||
// open ("file not found"). Plugin platforms (Teams, etc.)
|
||||
// ship no docs_url, so this guard + handler keeps the
|
||||
// button from ever pointing at a local bundle path.
|
||||
event.preventDefault()
|
||||
openExternalLink(platform.docs_url)
|
||||
}}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{m.openSetupGuide}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Button asChild size="sm" variant="textStrong">
|
||||
<a href={platform.docs_url} rel="noreferrer" target="_blank">
|
||||
{m.openSetupGuide}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import type { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const overlayCardClass =
|
||||
'rounded-lg border border-[color-mix(in_srgb,var(--dt-border)_52%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_34%,transparent)]'
|
||||
|
||||
interface OverlayCardProps extends ComponentProps<'div'> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface OverlayActionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
tone?: 'default' | 'danger' | 'subtle'
|
||||
}
|
||||
|
||||
export function OverlayCard({ children, className, ...props }: OverlayCardProps) {
|
||||
return (
|
||||
<div className={cn(overlayCardClass, className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayActionButton({
|
||||
children,
|
||||
className,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user