diff --git a/.dockerignore b/.dockerignore index a5b50068f020..ec3d52f81413 100644 --- a/.dockerignore +++ b/.dockerignore @@ -66,8 +66,12 @@ runtime/ # ---------- Not needed inside the Docker image ---------- -# Desktop app source (Tauri/Electron); never installed in the container +# Desktop app source (Tauri/Electron); never installed in the container. +# apps/shared is the dashboard↔desktop websocket helper and is linked from +# web/package.json as a file: workspace dep — keep it in the build context. apps/ +!apps/shared/ +!apps/shared/** # Test suite — not shipped in production images tests/ diff --git a/.envrc b/.envrc index f746973cae60..01232045f166 100644 --- a/.envrc +++ b/.envrc @@ -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 +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 use flake diff --git a/.github/actions/hermes-smoke-test/action.yml b/.github/actions/hermes-smoke-test/action.yml deleted file mode 100644 index 8b79c4bf34d3..000000000000 --- a/.github/actions/hermes-smoke-test/action.yml +++ /dev/null @@ -1,50 +0,0 @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f205717a632a..595569a82fa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ 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 }} @@ -32,6 +33,7 @@ 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 }} @@ -53,11 +55,15 @@ 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 @@ -65,35 +71,49 @@ 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 @@ -104,7 +124,7 @@ jobs: mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }} osv-scanner: - needs: detect + name: OSV scan uses: ./.github/workflows/osv-scanner.yml # ───────────────────────────────────────────────────────────────────── @@ -127,6 +147,8 @@ 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: @@ -143,3 +165,67 @@ 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 }} diff --git a/.github/workflows/docker-lint.yml b/.github/workflows/docker-lint.yml index c01bf31f5c42..89b80fa10e09 100644 --- a/.github/workflows/docker-lint.yml +++ b/.github/workflows/docker-lint.yml @@ -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-publish smoke test can't — unquoted variable +# the behavioral docker smoke test can't — unquoted variable # expansions, silently-failing RUN commands, etc. # # Rules and ignores are documented in .hadolint.yaml at the repo root. diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 48b1f94a6f04..000000000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,352 +0,0 @@ -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 :. - # --------------------------------------------------------------------------- - 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 }} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000000..8030b889e246 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,209 @@ +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 :. + # --------------------------------------------------------------------------- + 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 }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 95627e7fdeb1..fcee2c1b8e86 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 0 # need full history for merge-base + worktree - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 - name: Install ruff + ty uses: ./.github/actions/retry @@ -109,46 +109,6 @@ 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 = ''; - 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()`` / @@ -164,7 +124,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 - name: Install ruff uses: ./.github/actions/retry diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml index c6caf098133a..1997dedf5c75 100644 --- a/.github/workflows/skills-index.yml +++ b/.github/workflows/skills-index.yml @@ -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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: skills-index path: website/static/api/skills-index.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3c97608aa026..f74d574fa27d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,6 +2,11 @@ name: Tests on: workflow_call: + inputs: + slice_count: + description: Number of parallel test slices + type: number + default: 8 permissions: contents: read @@ -12,13 +17,11 @@ concurrency: cancel-in-progress: true jobs: - test: + generate: + name: "Generate slices" runs-on: ubuntu-latest - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - slice: [1, 2, 3, 4, 5, 6] + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -27,13 +30,26 @@ 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 @@ -49,7 +65,7 @@ jobs: rg --version - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 with: # Persist uv's download/wheel cache (~/.cache/uv) across runs. # Keyed on the dependency manifests, so the cache is reused until @@ -78,33 +94,19 @@ jobs: # re-download, keeping the persisted cache small and fast to restore. run: uv cache prune --ci - - 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 ` in a freshly-spawned subprocess + - 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 ` subprocess # with bounded parallelism. No xdist, no shared workers, no # module-level state leakage between files. # - # 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. + # 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. run: | source .venv/bin/activate - python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6 + scripts/run_tests.sh --files '${{ matrix.slice.files }}' env: # Ensure tests don't accidentally call real APIs OPENROUTER_API_KEY: "" @@ -114,7 +116,7 @@ jobs: - name: Upload per-slice durations uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-durations-slice-${{ matrix.slice }} + name: test-durations-slice-${{ matrix.slice.index }} path: test_durations.json retention-days: 1 @@ -173,7 +175,7 @@ jobs: rg --version - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 with: # Persist uv's download/wheel cache (~/.cache/uv) across runs. # Keyed on the dependency manifests, so the cache is reused until diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 1c28bd04cd1c..dd2906629b01 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -6,6 +6,7 @@ on: jobs: typecheck: + name: Check TypeScript runs-on: ubuntu-latest strategy: matrix: @@ -22,8 +23,7 @@ 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,6 +35,7 @@ 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 @@ -44,8 +45,7 @@ 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 diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index 9d1806d6f72a..03fad4eba0ce 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -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@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 - 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 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 diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml index 93c3686daa98..8a7f52e899a4 100644 --- a/.github/workflows/uv-lockfile-check.yml +++ b/.github/workflows/uv-lockfile-check.yml @@ -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-publish workflow on main. +# docker 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@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 # `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-publish run + — catching it here avoids a ~15 min failed docker 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." diff --git a/.gitignore b/.gitignore index 489453d79bfe..c820e0a55106 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,9 @@ 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/ diff --git a/AGENTS.md b/AGENTS.md index 30deedf5bf19..e89c819844e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,17 @@ 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) @@ -480,7 +491,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)`). 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)`). 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`. **Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline: @@ -783,6 +794,24 @@ 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//`) Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …) @@ -1260,65 +1289,22 @@ 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 isolation - -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. - -Implementation notes: - -- 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) +### Subprocess-per-test-file isolation -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 -``` +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. -If you need to bypass isolation for fast feedback while debugging: +### Why the wrapper -```bash -python -m pytest tests/agent/test_foo.py -q --no-isolate -``` +| | 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 | -Always run the full suite before pushing changes. ### Don't write change-detector tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 045d8097f885..bad33481c745 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,23 @@ 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 @@ -132,13 +149,20 @@ 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 -uv venv venv --python 3.11 -export VIRTUAL_ENV="$(pwd)/venv" +# 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" # Install with all extras (messaging, cron, CLI menus, dev tools) uv pip install -e ".[all,dev]" diff --git a/Dockerfile b/Dockerfile index c01de9857bbf..6f957f779678 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,6 +119,9 @@ 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 @@ -184,12 +187,19 @@ 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. -COPY . . +# --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 . . # ---------- Permissions ---------- # Link hermes-agent itself (editable). Deps are already installed in the @@ -197,19 +207,15 @@ COPY . . # resolution or downloads. RUN uv pip install --no-cache-dir --no-deps -e "." -# 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. +# 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). + 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 && \ - chown -R root:root /opt/hermes && \ - chmod -R a+rX /opt/hermes && \ - chmod -R a-w /opt/hermes + printf 'docker\n' > /opt/hermes/.install_method # The ``.install_method`` stamp is baked next to the running code (the install # tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data # volume that is commonly bind-mounted from the host and even shared with a @@ -236,13 +242,11 @@ 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-publish.yml) passes ${{ github.sha }} so +# (.github/workflows/docker.yml) passes ${{ github.sha }} so # every published image has it. ARG HERMES_GIT_SHA= RUN if [ -n "${HERMES_GIT_SHA}" ]; then \ - 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; \ + printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \ fi # ---------- s6-overlay service wiring ---------- diff --git a/README.md b/README.md index 0d5a638e227b..ba1322a38920 100644 --- a/README.md +++ b/README.md @@ -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](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. +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. @@ -232,10 +232,14 @@ 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 .venv --python 3.11 -source .venv/bin/activate +uv venv ~/.hermes/venvs/hermes-dev --python 3.11 +source ~/.hermes/venvs/hermes-dev/bin/activate uv pip install -e ".[all,dev]" scripts/run_tests.sh ``` diff --git a/acp_adapter/server.py b/acp_adapter/server.py index a51db91d4e82..df773297346a 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -74,6 +74,10 @@ from acp_adapter.provenance import session_provenance_meta from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets from acp_adapter.tools import build_tool_complete, build_tool_start +from tools.approval import ( + reset_hermes_interactive_context, + set_hermes_interactive_context, +) logger = logging.getLogger(__name__) @@ -1446,20 +1450,23 @@ def stream_delta_cb(text: str) -> None: # Approval callback is per-thread (thread-local, GHSA-qg5c-hvr5-hjgr). # Set it INSIDE _run_agent so the TLS write happens in the executor # thread — setting it here would write to the event-loop thread's TLS, - # not the executor's. Also set HERMES_INTERACTIVE so approval.py - # takes the CLI-interactive path (which calls the registered - # callback via prompt_dangerous_approval) instead of the - # non-interactive auto-approve branch (GHSA-96vc-wcxf-jjff). + # not the executor's. Interactive routing uses a contextvar in + # tools.approval (set_hermes_interactive_context) rather than + # os.environ["HERMES_INTERACTIVE"], so concurrent executor workers can't + # race on a process-global flag — one session's restore can't drop + # another onto the non-interactive auto-approve path mid-run + # (GHSA-96vc-wcxf-jjff). The contextvar write is isolated by the + # contextvars.copy_context() wrapper around the executor call below. # ACP's conn.request_permission maps cleanly to the interactive # callback shape — not the gateway-queue HERMES_EXEC_ASK path, # which requires a notify_cb registered in _gateway_notify_cbs. previous_approval_cb = None - previous_interactive = None + interactive_token = None edit_approval_token = None previous_session_id = None def _run_agent() -> dict: - nonlocal previous_approval_cb, previous_interactive, edit_approval_token, previous_session_id + nonlocal previous_approval_cb, interactive_token, edit_approval_token, previous_session_id # Bind HERMES_SESSION_KEY for this session so per-session caches # (e.g. the interactive sudo password cache in tools.terminal_tool) # scope to the ACP session rather than leaking across sessions @@ -1491,9 +1498,10 @@ def _run_agent() -> dict: except Exception: logger.debug("Could not set ACP edit approval requester", exc_info=True) # Signal to tools.approval that we have an interactive callback - # and the non-interactive auto-approve path must not fire. - previous_interactive = os.environ.get("HERMES_INTERACTIVE") - os.environ["HERMES_INTERACTIVE"] = "1" + # and the non-interactive auto-approve path must not fire. Uses a + # contextvar (not os.environ) so concurrent executor workers don't + # race on the flag (GHSA-96vc-wcxf-jjff). + interactive_token = set_hermes_interactive_context(True) # Propagate the originating ACP session id to tools that want to # tag side-effects with it (e.g. ``kanban_create`` stamps it on # the new task so clients can render a per-session board). Save @@ -1513,11 +1521,9 @@ def _run_agent() -> dict: logger.exception("Agent error in session %s", session_id) return {"final_response": f"Error: {e}", "messages": state.history} finally: - # Restore HERMES_INTERACTIVE. - if previous_interactive is None: - os.environ.pop("HERMES_INTERACTIVE", None) - else: - os.environ["HERMES_INTERACTIVE"] = previous_interactive + # Restore the interactive contextvar for this context. + if interactive_token is not None: + reset_hermes_interactive_context(interactive_token) # Restore HERMES_SESSION_ID symmetrically. if previous_session_id is None: os.environ.pop("HERMES_SESSION_ID", None) diff --git a/agent/agent_init.py b/agent/agent_init.py index 180e1d971b0d..dcfb1082d4c5 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -722,10 +722,50 @@ def init_agent( elif agent.provider == "moa": from agent.moa_loop import MoAClient agent.api_mode = "chat_completions" - agent.client = MoAClient(agent.model or "default") + + # Route reference-model outputs to the agent's tool_progress_callback so + # every surface that already consumes it (CLI spinner/scrollback, TUI, + # desktop, gateway) can show each reference's answer as a labelled block + # before the aggregator acts. The facade emits "moa.reference" and + # "moa.aggregating" events; we forward them through the same callback + # the tool lifecycle uses. Best-effort and cache-safe — these are + # display-only events, they never touch the message history. + def _moa_reference_relay(event: str, **kwargs: Any) -> None: + cb = getattr(agent, "tool_progress_callback", None) + if cb is None: + return + try: + if event == "moa.reference": + label = str(kwargs.get("label") or "") + text = str(kwargs.get("text") or "") + idx = kwargs.get("index") + count = kwargs.get("count") + cb( + "moa.reference", + label, + text, + None, + moa_index=idx, + moa_count=count, + ) + elif event == "moa.aggregating": + cb( + "moa.aggregating", + str(kwargs.get("aggregator") or ""), + None, + None, + moa_ref_count=kwargs.get("ref_count"), + ) + except Exception: + pass + + agent.client = MoAClient( + agent.model or "default", + reference_callback=_moa_reference_relay, + ) agent._client_kwargs = {} agent.api_key = api_key or "moa-virtual-provider" - agent.base_url = base_url or "moa://local" + agent.base_url = "moa://local" if not agent.quiet_mode: print(f"🤖 AI Agent initialized with MoA preset: {agent.model}") elif agent.api_mode == "bedrock_converse": @@ -788,7 +828,7 @@ def init_agent( client_kwargs["default_headers"] = build_nvidia_nim_headers(effective_base) elif base_url_host_matches(effective_base, "api.routermint.com"): client_kwargs["default_headers"] = _ra()._routermint_headers() - elif base_url_host_matches(effective_base, "api.githubcopilot.com"): + elif base_url_host_matches(effective_base, "githubcopilot.com"): from hermes_cli.models import copilot_default_headers client_kwargs["default_headers"] = copilot_default_headers() @@ -1267,6 +1307,12 @@ 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 @@ -1619,6 +1665,12 @@ def init_agent( abort_on_summary_failure=compression_abort_on_summary_failure, max_tokens=agent.max_tokens, ) + _bind_session_state = getattr(agent.context_compressor, "bind_session_state", None) + if callable(_bind_session_state): + try: + _bind_session_state(session_db=session_db, session_id=agent.session_id) + except Exception: + pass agent.compression_enabled = compression_enabled agent.compression_in_place = compression_in_place @@ -1630,8 +1682,10 @@ 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, or set " - f"model.context_length in config.yaml to override." + 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)." ) # Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand). diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 3473eb0b54c8..af64541a8285 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -42,6 +42,14 @@ 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 @@ -360,6 +368,18 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: host code) can feed in already-broken histories. Repairs applied: + 0. Consecutive ``assistant`` messages with no intervening + ``tool``/``user`` turn — merged into a single assistant turn + (union of ``tool_calls``, concatenated ``content``). Strict + OpenAI-compatible providers (DeepSeek v4, Moonshot/Kimi) reject + a history where an ``assistant`` message carrying ``tool_calls`` + is immediately followed by another ``assistant`` message instead + of its ``tool`` results — HTTP 400 "An assistant message with + 'tool_calls' must be followed by tool messages…". The split + shape is produced by recovery/continuation paths that append an + interim assistant turn (thinking-prefill, codex + incomplete-continuation) or by host-fed / legacy-persisted / + resumed histories. Refs #29148, #49147. 1. Stray ``tool`` messages whose ``tool_call_id`` doesn't match any preceding assistant tool_call — dropped. 2. Consecutive ``user`` messages — merged with newline separator @@ -379,12 +399,74 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int: repairs = 0 + # Pass 0: merge consecutive assistant messages. Runs BEFORE Pass 1 so + # the merged turn's union of tool_call ids is known when Pass 1 + # validates which tool-result messages are orphans. Two assistant + # messages are only adjacent here when nothing (no tool result, no + # user turn) separates them — an intervening ``tool`` message means + # two distinct, valid tool-call rounds that must NOT be merged. + # + # Codex Responses interim turns are exempt: the codex_responses + # api_mode legitimately keeps multiple consecutive incomplete + # assistant turns in history, each carrying its own encrypted + # continuation state (codex_reasoning_items / codex_message_items) + # that must be replayed verbatim. Collapsing them corrupts the + # Responses replay chain (the duplicate-detection logic at + # conversation_loop.py already de-dups identical codex interims). + def _is_codex_interim(m: Dict) -> bool: + return bool( + m.get("codex_reasoning_items") + or m.get("codex_message_items") + or m.get("finish_reason") == "incomplete" + ) + + collapsed: List[Dict] = [] + for msg in messages: + if ( + collapsed + and isinstance(msg, dict) + and msg.get("role") == "assistant" + and isinstance(collapsed[-1], dict) + and collapsed[-1].get("role") == "assistant" + and not _is_codex_interim(msg) + and not _is_codex_interim(collapsed[-1]) + ): + prev = collapsed[-1] + # Union tool_calls (preserve order, both may carry them). + prev_calls = list(prev.get("tool_calls") or []) + new_calls = list(msg.get("tool_calls") or []) + if new_calls: + prev["tool_calls"] = prev_calls + new_calls + elif prev_calls: + prev["tool_calls"] = prev_calls + # Concatenate plain-text content; leave multimodal (list) + # content on either side alone to avoid mangling attachment + # blocks — fall back to keeping the existing content. + prev_content = prev.get("content") + new_content = msg.get("content") + if isinstance(prev_content, str) and isinstance(new_content, str): + joined = "\n".join( + p for p in (prev_content.strip(), new_content.strip()) if p + ) + prev["content"] = joined + elif not prev_content and new_content is not None: + prev["content"] = new_content + # Carry reasoning_content from the later turn only if the + # earlier turn lacks it (strict thinking providers require a + # reasoning_content on the merged tool-call turn; the first + # non-empty one suffices). + if not prev.get("reasoning_content") and msg.get("reasoning_content"): + prev["reasoning_content"] = msg["reasoning_content"] + repairs += 1 + continue + collapsed.append(msg) + # Pass 1: drop stray tool messages that don't follow a known # assistant tool_call_id. Uses a rolling set of known ids refreshed # on each assistant message. known_tool_ids: set = set() filtered: List[Dict] = [] - for msg in messages: + for msg in collapsed: if not isinstance(msg, dict): filtered.append(msg) continue @@ -655,6 +737,25 @@ def recover_with_credential_pool( elif status_code in {401, 403}: effective_reason = FailoverReason.auth + if effective_reason == FailoverReason.upstream_rate_limit: + # An upstream provider (e.g. DeepSeek behind OpenRouter) is + # rate-limiting the aggregator's traffic — the user's credential is + # healthy. Do NOT rotate or mark exhausted; let the caller's fallback + # path switch to a different model entirely. + upstream = (error_context or {}).get("upstream_provider") if error_context else None + if upstream: + _ra().logger.info( + "Upstream provider %s rate-limited via aggregator — skipping " + "credential rotation, deferring to fallback chain", + upstream, + ) + else: + _ra().logger.info( + "Upstream aggregator 429 (provider unknown) — skipping " + "credential rotation, deferring to fallback chain" + ) + return False, has_retried_429 + if effective_reason == FailoverReason.billing: rotate_status = status_code if status_code is not None else 402 next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context) @@ -775,6 +876,30 @@ 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 @@ -1046,6 +1171,34 @@ 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 @@ -1221,7 +1374,11 @@ def dump_api_request_debug( dump_payload["error"] = error_info timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") - dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json" + # Sanitize the session ID into a traversal-free path segment — it can + # originate from untrusted input (X-Hermes-Session-Id header), and an + # unsanitized "../"-shaped ID would write the dump outside logs_dir. + safe_sid = _ra()._safe_session_filename_component(agent.session_id) + dump_file = agent.logs_dir / f"request_dump_{safe_sid}_{timestamp}.json" # Redact secrets before persisting/printing. This dump captures the # full request body (system prompt, tool defs, context-embedded @@ -1420,6 +1577,15 @@ 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) @@ -1499,6 +1665,10 @@ 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 @@ -1523,8 +1693,48 @@ 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 api_mode == "anthropic_messages": + if (new_provider or "").strip().lower() == "moa": + from agent.moa_loop import MoAClient + + # The MoA virtual provider speaks only chat.completions via the + # MoAClient facade — the aggregator's real transport + # (codex_responses / anthropic_messages) is resolved and applied + # *inside* the reference/aggregator fan-out, never on the outer + # primary call. determine_api_mode("moa", ...) above may have left + # api_mode set to the aggregator's transport; if the conversation + # loop sees that, it dispatches client.responses.create (which the + # facade has no .responses for) and the call falls through to the + # moa://local placeholder → HTTP 404 → fallback to a reference + # model. Pin chat_completions here so the primary call always goes + # through MoAClient.chat.completions, matching agent_init.py. + agent.api_mode = "chat_completions" + agent.api_key = api_key or "moa-virtual-provider" + agent.base_url = "moa://local" + agent._client_kwargs = {} + agent.client = MoAClient(agent.model or "default") + elif api_mode == "anthropic_messages": from agent.anthropic_adapter import ( build_anthropic_client, resolve_anthropic_token, @@ -2058,7 +2268,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] result_call_ids: set = set() for msg in messages: if msg.get("role") == "tool": - cid = msg.get("tool_call_id") + cid = (msg.get("tool_call_id") or "").strip() if cid: result_call_ids.add(cid) @@ -2067,7 +2277,7 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] if orphaned_results: messages = [ m for m in messages - if not (m.get("role") == "tool" and m.get("tool_call_id") in orphaned_results) + if not (m.get("role") == "tool" and (m.get("tool_call_id") or "").strip() in orphaned_results) ] _ra().logger.debug( "Pre-call sanitizer: removed %d orphaned tool result(s)", @@ -2101,11 +2311,24 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any] def looks_like_codex_intermediate_ack( agent, - user_message: str, + user_message: Any, 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.""" + """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. + """ if any(isinstance(msg, dict) and msg.get("role") == "tool" for msg in messages): return False @@ -2158,17 +2381,74 @@ def looks_like_codex_intermediate_ack( "path", ) - user_text = (user_message or "").strip().lower() + assistant_mentions_action = any(marker in assistant_text for marker in action_markers) + if not assistant_mentions_action: + return False + + # Opted-in (all-api_mode) path: a future-ack + action verb + no prior tool + # call is enough — the user asked us to keep going when the model only + # announces intent, regardless of whether a filesystem is involved. + if not require_workspace: + return True + + # ``user_message`` is typed ``str`` but can arrive as an OpenAI-style + # multi-part content list (``[{type:"text",...}, {type:"image_url",...}]``) + # for vision requests routed through the OpenAI-compat API server. A + # truthy list survives ``(user_message or "")`` and then ``.strip()`` + # raises ``AttributeError`` — flatten to text first. + from agent.codex_responses_adapter import _summarize_user_message_for_log + + user_text = _summarize_user_message_for_log(user_message).strip().lower() user_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) and assistant_mentions_action + 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" diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 4f7595c94d5a..e4d1d5ac125e 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -673,6 +673,9 @@ 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 @@ -757,6 +760,12 @@ 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. @@ -852,6 +861,9 @@ 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])}, ) @@ -914,44 +926,72 @@ 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. - Checks two sources in order: + Reads from two possible sources and reconciles them: 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?} or None. + Returns dict with {accessToken, refreshToken?, expiresAt?, source} or None. """ - # Try macOS Keychain first (covers Claude Code >=2.1.114) kc_creds = _read_claude_code_credentials_from_keychain() - if kc_creds: - return kc_creds + file_creds = _read_claude_code_credentials_from_file() - # 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) + 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 - return None + return kc_creds or file_creds def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool: @@ -1034,8 +1074,40 @@ 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.""" - refresh_token = creds.get("refreshToken", "") + """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", "") if not refresh_token: logger.debug("No refresh token available — cannot refresh") return None diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e46780d23372..39ae9a759c31 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -102,6 +102,7 @@ def __repr__(self): 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 @@ -109,6 +110,32 @@ def __repr__(self): 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} + # Hermes owns auxiliary retry + provider/model fallback policy (the + # same-provider transient retry in call_llm plus the except-chain + # fallback). The OpenAI SDK's own default (max_retries=2 → up to 3 + # attempts) silently multiplies the effective wall time of every aux call + # by 3× on a slow/hung endpoint, so a 120s timeout can stall ~360s before + # Hermes sees a single failure (issue #54465). Disable SDK-internal retries + # by default and let Hermes control the budget; explicit callers can still + # override via kwargs. + kwargs.setdefault("max_retries", 0) + 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 @@ -666,6 +693,28 @@ 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"))) @@ -1575,7 +1624,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: extra = {} if base_url_host_matches(base_url, "api.kimi.com"): extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} - elif base_url_host_matches(base_url, "api.githubcopilot.com"): + elif base_url_host_matches(base_url, "githubcopilot.com"): from hermes_cli.models import copilot_default_headers extra["default_headers"] = copilot_default_headers() @@ -1592,7 +1641,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 = OpenAI(api_key=api_key, base_url=base_url, **extra) + _client = _create_openai_client(api_key=api_key, base_url=base_url, **extra) _client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url) return _client, model @@ -1615,7 +1664,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: extra = {} if base_url_host_matches(base_url, "api.kimi.com"): extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} - elif base_url_host_matches(base_url, "api.githubcopilot.com"): + elif base_url_host_matches(base_url, "githubcopilot.com"): from hermes_cli.models import copilot_default_headers extra["default_headers"] = copilot_default_headers() @@ -1632,7 +1681,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 = OpenAI(api_key=api_key, base_url=base_url, **extra) + _client = _create_openai_client(api_key=api_key, base_url=base_url, **extra) _client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url) return _client, model @@ -1647,20 +1696,21 @@ 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 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 + 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") or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY") if not or_key: _mark_provider_unhealthy("openrouter", ttl=60) return None, None logger.debug("Auxiliary client: OpenRouter") - return OpenAI(api_key=or_key, base_url=OPENROUTER_BASE_URL, + return _create_openai_client(api_key=or_key, base_url=OPENROUTER_BASE_URL, default_headers=build_or_headers()), model or _OPENROUTER_MODEL @@ -1753,7 +1803,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 ( - OpenAI( + _create_openai_client( api_key=api_key, base_url=base_url, ), @@ -2030,7 +2080,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 = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra) + real_client = _create_openai_client(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, @@ -2044,14 +2094,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 OpenAI(api_key=custom_key, base_url=_clean_base, **_extra), model + return _create_openai_client(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 = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra) + _fallback_client = _create_openai_client(api_key=custom_key, base_url=_clean_base, **_extra) _fallback_client = _maybe_wrap_anthropic( _fallback_client, model, custom_key, custom_base, custom_mode, ) @@ -2080,7 +2130,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 = OpenAI(api_key=api_key, base_url=base_url) + real_client = _create_openai_client(api_key=api_key, base_url=base_url) return CodexAuxiliaryClient(real_client, model), model @@ -2117,7 +2167,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 = OpenAI( + real_client = _create_openai_client( api_key=codex_token, base_url=base_url, default_headers=_codex_cloudflare_headers(codex_token), @@ -2217,7 +2267,7 @@ def _try_azure_foundry( if _dq: extra["default_query"] = _dq - client = OpenAI(api_key=api_key, base_url=_clean_base, **extra) + client = _create_openai_client(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 @@ -2256,9 +2306,16 @@ 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 the configured provider is anthropic — otherwise a non-Anthropic - # base_url (e.g. Codex endpoint) would leak into Anthropic requests. + # 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. 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 @@ -2268,7 +2325,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: + if cfg_base_url and _is_anthropic_compatible_host(cfg_base_url): base_url = cfg_base_url except Exception: pass @@ -2542,6 +2599,27 @@ def _is_rate_limit_error(exc: Exception) -> bool: return False +def _is_timeout_error(exc: Exception) -> bool: + """Detect a request timeout — the full-budget stall, distinct from a fast + connection drop. + + A timeout burns the entire configured ``timeout`` before surfacing, so a + same-provider retry on the critical compression path doubles the + user-visible wall time (issue #54465). A streaming-close / dropped + connection, by contrast, fails fast and is cheap to retry — those stay on + the retry path even for compression. + """ + try: + from openai import APITimeoutError + if isinstance(exc, APITimeoutError): + return True + except ImportError: + pass + if "Timeout" in type(exc).__name__: + return True + return "timed out" in str(exc).lower() + + def _is_connection_error(exc: Exception) -> bool: """Detect connection/network errors that warrant provider fallback. @@ -2754,6 +2832,25 @@ 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) @@ -2857,7 +2954,7 @@ def _recoverable_pool_provider( return "nous" if base_url_host_matches(base, "api.anthropic.com"): return "anthropic" - if base_url_host_matches(base, "api.githubcopilot.com"): + if base_url_host_matches(base, "githubcopilot.com"): return "copilot" if base_url_host_matches(base, "api.kimi.com"): return "kimi-coding" @@ -3576,6 +3673,37 @@ def _resolve_auto( # config.yaml (auxiliary..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= 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 @@ -3695,7 +3823,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): sync_base_url = str(sync_client.base_url) if base_url_host_matches(sync_base_url, "openrouter.ai"): async_kwargs["default_headers"] = build_or_headers() - elif base_url_host_matches(sync_base_url, "api.githubcopilot.com"): + elif base_url_host_matches(sync_base_url, "githubcopilot.com"): from hermes_cli.copilot_auth import copilot_request_headers async_kwargs["default_headers"] = copilot_request_headers( @@ -3722,6 +3850,13 @@ 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, + } + # See _create_openai_client: disable SDK-internal retries so Hermes owns + # the auxiliary retry/timeout budget (issue #54465). + async_kwargs.setdefault("max_retries", 0) return AsyncOpenAI(**async_kwargs), model @@ -3932,7 +4067,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", "but no Codex OAuth token found (run: hermes model)") return None, None final_model = _normalize_resolved_model(model, provider) - raw_client = OpenAI( + raw_client = _create_openai_client( api_key=codex_token, base_url=_CODEX_AUX_BASE_URL, default_headers=_codex_cloudflare_headers(codex_token), @@ -3993,7 +4128,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", extra["default_query"] = _dq if base_url_host_matches(custom_base, "api.kimi.com"): extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"} - elif base_url_host_matches(custom_base, "api.githubcopilot.com"): + elif base_url_host_matches(custom_base, "githubcopilot.com"): from hermes_cli.copilot_auth import copilot_request_headers extra["default_headers"] = copilot_request_headers( is_agent_turn=True, is_vision=is_vision @@ -4013,7 +4148,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", _merged_custom = _apply_user_default_headers(extra.get("default_headers")) if _merged_custom: extra["default_headers"] = _merged_custom - client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra) + client = _create_openai_client(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)) @@ -4117,7 +4252,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", _fb_headers = _apply_user_default_headers(_fb_extra.get("default_headers")) if _fb_headers: _fb_extra["default_headers"] = _fb_headers - client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra) + client = _create_openai_client(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( @@ -4126,7 +4261,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", if async_mode: return AsyncAnthropicAuxiliaryClient(sync_anthropic), final_model return sync_anthropic, final_model - client = OpenAI(api_key=custom_key, base_url=_clean_base2, **_extra2) + client = _create_openai_client(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 @@ -4246,7 +4381,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", headers = {} if base_url_host_matches(base_url, "api.kimi.com"): headers["User-Agent"] = "claude-code/0.1.0" - elif base_url_host_matches(base_url, "api.githubcopilot.com"): + elif base_url_host_matches(base_url, "githubcopilot.com"): from hermes_cli.copilot_auth import copilot_request_headers headers.update(copilot_request_headers( @@ -4268,7 +4403,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", _merged_main = _apply_user_default_headers(headers) if _merged_main: headers = _merged_main - client = OpenAI(api_key=api_key, base_url=base_url, + client = _create_openai_client(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 @@ -4719,9 +4854,14 @@ def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> di or_key = os.getenv("OPENROUTER_API_KEY") # Use max_completion_tokens for direct OpenAI-compatible providers that reject # max_tokens on newer GPT-4o/o-series/GPT-5-style models. + _custom_host = base_url_hostname(custom_base) or "" if (not or_key and _read_nous_auth() is None - and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}): + and ( + _custom_host == "api.openai.com" + or _custom_host == "api.githubcopilot.com" + or _custom_host.endswith(".githubcopilot.com") + )): return {"max_completion_tokens": value} # ...and for any caller serving a newer OpenAI-family model by name. if model_forces_max_completion_tokens(model): @@ -4804,7 +4944,7 @@ def _refresh_nous_auxiliary_client( return None, model fresh_key, fresh_base_url = runtime - sync_client = OpenAI(api_key=fresh_key, base_url=fresh_base_url) + sync_client = _create_openai_client(api_key=fresh_key, base_url=fresh_base_url) final_model = model current_loop = None @@ -5098,9 +5238,10 @@ def _resolve_task_provider_model( 3. "auto" (full auto-detection chain) Returns (provider, model, base_url, api_key, api_mode) where model may - be None (use provider default). When base_url is set, provider is forced - to "custom" and the task uses that direct endpoint. api_mode is one of - "chat_completions", "codex_responses", or None (auto-detect). + be None (use provider default). A bare base_url is treated as custom, but + a first-class provider plus base_url keeps the provider identity so its + auth, transport, and request-shaping behavior still apply. api_mode is one + of "chat_completions", "codex_responses", or None (auto-detect). """ cfg_provider = None cfg_model = None @@ -5133,11 +5274,35 @@ def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str]) return prov, existing_base return "custom", existing_base or target_base + def _preserve_provider_with_base_url(prov: Optional[str]) -> bool: + normalized = str(prov or "").strip().lower() + if normalized in {"", "auto", "custom"} or normalized.startswith("custom:"): + return False + try: + from hermes_cli.providers import get_provider + + return get_provider(normalized) is not None + except Exception: + # Keep the high-risk provider-backed routes safe even if provider + # catalog loading is unavailable during early import/test paths. + return normalized in { + "anthropic", + "copilot", + "copilot-acp", + "minimax-oauth", + "nous", + "openai-codex", + "qwen-oauth", + "xai-oauth", + } + if provider: provider, base_url = _expand_direct_api_alias(provider, base_url) if cfg_provider: cfg_provider, cfg_base_url = _expand_direct_api_alias(cfg_provider, cfg_base_url) + if base_url and _preserve_provider_with_base_url(provider): + return provider, resolved_model, base_url, api_key, resolved_api_mode if base_url: return "custom", resolved_model, base_url, api_key, resolved_api_mode if provider: @@ -5387,10 +5552,24 @@ 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 "" ) - if _is_anthropic_compat_endpoint(provider, _effective_base): + _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 + ): kwargs["max_tokens"] = max_tokens if tools: @@ -5445,6 +5624,9 @@ 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( @@ -5456,6 +5638,64 @@ 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, *, @@ -5470,6 +5710,7 @@ def call_llm( tools: list = None, timeout: float = None, extra_body: dict = None, + api_mode: str = None, ) -> Any: """Centralized synchronous LLM call. @@ -5482,6 +5723,8 @@ def call_llm( Reads provider:model from config/env. Ignored if provider is set. provider: Explicit provider override. model: Explicit model override. + api_mode: Explicit API mode override (e.g. "codex_responses", + "anthropic_messages"). Takes precedence over task config. messages: Chat messages list. temperature: Sampling temperature (None = provider default). max_tokens: Max output tokens (handles max_tokens vs max_completion_tokens). @@ -5497,6 +5740,8 @@ def call_llm( """ resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model( task, provider, model, base_url, api_key) + if api_mode: + resolved_api_mode = api_mode effective_extra_body = _get_task_extra_body(task) effective_extra_body.update(extra_body or {}) @@ -5608,6 +5853,21 @@ def call_llm( except Exception as transient_err: if not _is_transient_transport_error(transient_err): raise + # Compression is on the critical preflight path: a user cannot + # continue or resume an oversized session until it compacts. A + # same-provider retry on a timeout means another full ``timeout``- + # long wall-clock block before the except-chain below can fall + # back — doubling the user-visible stall (issue #54465). Skip the + # same-provider retry for compression on a full-budget timeout and + # fall straight through to provider/model fallback; fast blips (a + # streaming-close or a 5xx) still retry, since those are cheap. + if task == "compression" and _is_timeout_error(transient_err): + logger.info( + "Auxiliary compression: timeout on the critical path; " + "skipping same-provider retry and falling back: %s", + transient_err, + ) + raise logger.info( "Auxiliary %s: transient transport error; retrying once on " "the same provider before fallback: %s", @@ -5853,11 +6113,21 @@ 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_payment_error(first_err) + _is_auth_error(first_err) + or _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 @@ -5880,9 +6150,12 @@ 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_payment_error(first_err): + if _is_auth_error(first_err): + reason = "auth error" + elif _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 @@ -5895,6 +6168,8 @@ 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", @@ -6118,6 +6393,16 @@ async def async_call_llm( except Exception as transient_err: if not _is_transient_transport_error(transient_err): raise + # See call_llm(): compression is on the critical preflight path, + # so skip the same-provider retry on a full-budget timeout and + # fall straight through to fallback (issue #54465). + if task == "compression" and _is_timeout_error(transient_err): + logger.info( + "Auxiliary compression (async): timeout on the critical " + "path; skipping same-provider retry and falling back: %s", + transient_err, + ) + raise logger.info( "Auxiliary %s (async): transient transport error; retrying " "once on the same provider before fallback: %s", @@ -6329,11 +6614,17 @@ 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_payment_error(first_err) + _is_auth_error(first_err) + or _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 @@ -6348,9 +6639,12 @@ 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_payment_error(first_err): + if _is_auth_error(first_err): + reason = "auth error" + elif _is_payment_error(first_err): reason = "payment error" _mark_provider_unhealthy( _recoverable_pool_provider(resolved_provider, client) or resolved_provider @@ -6359,6 +6653,8 @@ 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", diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 9a5bcba0924f..aada15f51ed5 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -28,6 +28,7 @@ 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, @@ -37,6 +38,18 @@ 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(): @@ -115,6 +128,23 @@ 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))) @@ -229,6 +259,11 @@ def _call(): 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( @@ -597,7 +632,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: _ct = agent._get_transport() is_github_responses = ( base_url_host_matches(agent.base_url, "models.github.ai") - or base_url_host_matches(agent.base_url, "api.githubcopilot.com") + or base_url_host_matches(agent.base_url, "githubcopilot.com") ) is_codex_backend = ( agent.provider == "openai-codex" @@ -667,7 +702,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: _is_or = agent._is_openrouter_url() _is_gh = ( base_url_host_matches(agent._base_url_lower, "models.github.ai") - or base_url_host_matches(agent._base_url_lower, "api.githubcopilot.com") + or base_url_host_matches(agent._base_url_lower, "githubcopilot.com") ) _is_nous = "nousresearch" in agent._base_url_lower _is_nvidia = "integrate.api.nvidia.com" in agent._base_url_lower @@ -698,8 +733,9 @@ def build_api_kwargs(agent, api_messages: list) -> dict: _prefs["ignore"] = agent.providers_ignored if agent.providers_order: _prefs["order"] = agent.providers_order - if agent.provider_sort: - _prefs["sort"] = agent.provider_sort + _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) + if _provider_sort: + _prefs["sort"] = _provider_sort if agent.provider_require_parameters: _prefs["require_parameters"] = True if agent.provider_data_collection: @@ -1015,18 +1051,23 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic "arguments": tool_call.function.arguments }, } - # 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"] - ) + # 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.) # Preserve extra_content (e.g. Gemini thought_signature) so it # is sent back on subsequent API calls. Without this, Gemini 3 # thinking models reject the request with a 400 error. @@ -1083,7 +1124,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool auth resolution and client construction — no duplicated provider→key mappings. """ - if reason in {FailoverReason.rate_limit, FailoverReason.billing}: + if reason in {FailoverReason.rate_limit, FailoverReason.billing, FailoverReason.upstream_rate_limit}: # Only start cooldown when leaving the primary provider. If we're # already on a fallback and chain-switching, the primary wasn't the # source of the 429 so the cooldown should not be reset/extended. @@ -1093,8 +1134,22 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool if (not fallback_already_active) or (primary_provider and current_provider == primary_provider): agent._rate_limited_until = time.monotonic() + 60 if agent._fallback_index >= len(agent._fallback_chain): + # Chain exhausted. If we actually walked a non-empty chain and the + # failure was NOT a rate-limit/billing event (those already armed + # their own 60s cooldown above), arm a short cooldown so the next + # turn's restore_primary_runtime stays gated instead of resetting + # _fallback_index=0 and re-marshaling the whole context across every + # provider again. Guards the cross-turn replay storm in #24996. + if ( + len(agent._fallback_chain) > 0 + and reason not in {FailoverReason.rate_limit, FailoverReason.billing, FailoverReason.upstream_rate_limit} + ): + _existing_cooldown = getattr(agent, "_rate_limited_until", 0) or 0 + agent._rate_limited_until = max( + _existing_cooldown, + time.monotonic() + _FALLBACK_EXHAUSTED_COOLDOWN_S, + ) return False - fb = agent._fallback_chain[agent._fallback_index] agent._fallback_index += 1 fb_provider = (fb.get("provider") or "").strip().lower() @@ -1210,14 +1265,16 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool agent._transport_cache.clear() agent._fallback_activated = True - # 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. + # 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. + # # When the fallback shares the pool's provider (e.g. both openrouter - # entries with different routing) the pool is preserved. + # 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. _existing_pool = getattr(agent, "_credential_pool", None) if _existing_pool is not None: _pool_provider = (getattr(_existing_pool, "provider", "") or "").strip().lower() @@ -1228,6 +1285,22 @@ 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 @@ -1458,8 +1531,9 @@ 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 - if agent.provider_sort: - provider_preferences["sort"] = agent.provider_sort + _provider_sort = _validated_openrouter_provider_sort(agent.provider_sort) + if _provider_sort: + provider_preferences["sort"] = _provider_sort if provider_preferences and ( (agent.provider or "").strip().lower() == "openrouter" or agent._is_openrouter_url() @@ -1838,7 +1912,6 @@ def _call_chat_completions(): stream_kwargs = { **api_kwargs, "stream": True, - "stream_options": {"include_usage": True}, "timeout": _httpx.Timeout( connect=_conn_cap, read=_stream_read_timeout, @@ -1846,6 +1919,14 @@ def _call_chat_completions(): 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", @@ -2005,7 +2086,7 @@ def _call_chat_completions(): entry["function"]["arguments"] += tc_delta.function.arguments extra = getattr(tc_delta, "extra_content", None) if extra is None and hasattr(tc_delta, "model_extra"): - extra = (tc_delta.model_extra or {}).get("extra_content") + extra = (tc_delta.model_extra if isinstance(tc_delta.model_extra, dict) else {}).get("extra_content") if extra is not None: if hasattr(extra, "model_dump"): extra = extra.model_dump() @@ -2246,7 +2327,15 @@ def _call_anthropic(): _fire_first_delta() agent._fire_reasoning_delta(thinking_text) - # Return the native Anthropic Message for downstream processing + # 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 stream.get_final_message() def _call(): @@ -2391,12 +2480,19 @@ def _call(): diag=request_client_holder.get("diag"), ) _close_request_client_once("stream_mid_tool_retry_cleanup") - try: - agent._replace_primary_openai_client( - reason="stream_mid_tool_retry_pool_cleanup" - ) - except Exception: - pass + 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 continue # SSE error events from proxies (e.g. OpenRouter sends @@ -2444,12 +2540,19 @@ def _call(): _close_request_client_once("stream_retry_cleanup") # Also rebuild the primary client to purge # any dead connections from the pool. - try: - agent._replace_primary_openai_client( - reason="stream_retry_pool_cleanup" - ) - except Exception: - pass + 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 continue # Retries exhausted. Log the final failure with # full diagnostic detail (chain, headers, @@ -2620,10 +2723,17 @@ def _call(): pass # Rebuild the primary client too — its connection pool # may hold dead sockets from the same provider outage. - try: - agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup") - except Exception: - pass + 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 # Reset the timer so we don't kill repeatedly while # the inner thread processes the closure. last_chunk_time["t"] = time.time() @@ -2699,7 +2809,30 @@ def _call(): role="assistant", content=_partial_text, tool_calls=None, reasoning_content=None, ) - return SimpleNamespace( + # 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( id=PARTIAL_STREAM_STUB_ID, model=getattr(agent, "model", "unknown"), choices=[SimpleNamespace( @@ -2708,6 +2841,9 @@ def _call(): 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"] diff --git a/agent/coding_context.py b/agent/coding_context.py index 78229bc4f554..00f6d996d478 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -60,6 +60,8 @@ 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" @@ -351,6 +353,29 @@ 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() @@ -457,6 +482,9 @@ 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: @@ -503,6 +531,10 @@ def system_blocks(self) -> list[str]: 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]: @@ -555,6 +587,7 @@ def resolve_runtime_mode( cwd=resolved_cwd, config_mode=mode, model=model, + instructions=_coding_instructions(config), ) @@ -647,12 +680,14 @@ 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 "" diff --git a/agent/context_breakdown.py b/agent/context_breakdown.py new file mode 100644 index 000000000000..0e2eb772f2ff --- /dev/null +++ b/agent/context_breakdown.py @@ -0,0 +1,156 @@ +"""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".*?", 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 "", + } diff --git a/agent/context_compressor.py b/agent/context_compressor.py index fbde99bda5f9..4bccda13808b 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -19,6 +19,7 @@ import hashlib import json import logging +import sqlite3 import re import time from typing import Any, Dict, List, Optional @@ -638,6 +639,7 @@ def on_session_reset(self) -> None: self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session + self._last_summary_error = None self.last_real_prompt_tokens = 0 self.last_compression_rough_tokens = 0 self.last_rough_tokens_when_real_prompt_fit = 0 @@ -659,6 +661,104 @@ def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> Non """ self._previous_summary = None + def bind_session_state(self, session_db: Any = None, session_id: str = "") -> None: + """Bind the current session row so durable cooldowns can round-trip.""" + self._session_db = session_db + self._session_id = session_id or "" + self._summary_failure_cooldown_until = 0.0 + self._last_summary_error = None + self.get_active_compression_failure_cooldown() + + def on_session_start(self, session_id: str, **kwargs) -> None: + """Bind session-scoped compression state for a new or resumed session.""" + super().on_session_start(session_id, **kwargs) + self.bind_session_state(kwargs.get("session_db", getattr(self, "_session_db", None)), session_id) + + def get_active_compression_failure_cooldown(self) -> Optional[Dict[str, Any]]: + """Return the live compression-failure cooldown for the bound session.""" + now_mono = time.monotonic() + if self._summary_failure_cooldown_until > now_mono: + return { + "cooldown_until": time.time() + ( + self._summary_failure_cooldown_until - now_mono + ), + "remaining_seconds": self._summary_failure_cooldown_until - now_mono, + "error": self._last_summary_error, + } + + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "_session_id", "") + if not session_db or not session_id: + return None + + getter = getattr(session_db, "get_compression_failure_cooldown", None) + if getter is None: + return None + try: + state = getter(session_id) + except sqlite3.Error as exc: + logger.debug("compression failure cooldown lookup failed: %s", exc) + return None + except Exception: + return None + if not state: + return None + + remaining_seconds = float(state.get("remaining_seconds") or 0.0) + if remaining_seconds <= 0: + return None + + self._summary_failure_cooldown_until = now_mono + remaining_seconds + self._last_summary_error = state.get("error") + return { + "cooldown_until": float(state.get("cooldown_until") or 0.0), + "remaining_seconds": remaining_seconds, + "error": self._last_summary_error, + } + + def _record_compression_failure_cooldown( + self, + cooldown_seconds: float, + error: Optional[str], + ) -> None: + cooldown_until = time.time() + cooldown_seconds + self._summary_failure_cooldown_until = time.monotonic() + cooldown_seconds + self._last_summary_error = error + + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "_session_id", "") + if not session_db or not session_id: + return + + recorder = getattr(session_db, "record_compression_failure_cooldown", None) + if recorder is None: + return + try: + recorder(session_id, cooldown_until, error) + except sqlite3.Error as exc: + logger.debug("compression failure cooldown persist failed: %s", exc) + except Exception as exc: + logger.debug("compression failure cooldown persist failed (non-sqlite): %s", exc) + + def _clear_compression_failure_cooldown(self) -> None: + self._summary_failure_cooldown_until = 0.0 + self._last_summary_error = None + + session_db = getattr(self, "_session_db", None) + session_id = getattr(self, "_session_id", "") + if not session_db or not session_id: + return + + clearer = getattr(session_db, "clear_compression_failure_cooldown", None) + if clearer is None: + return + try: + clearer(session_id) + except sqlite3.Error as exc: + logger.debug("compression failure cooldown clear failed: %s", exc) + except Exception as exc: + logger.debug("compression failure cooldown clear failed (non-sqlite): %s", exc) + def update_model( self, model: str, @@ -863,6 +963,8 @@ def __init__( self.awaiting_real_usage_after_compression = False self.summary_model = summary_model_override or "" + self._session_db: Any = None + self._session_id: str = "" # Stores the previous compaction summary for iterative updates self._previous_summary: Optional[str] = None @@ -1448,7 +1550,7 @@ def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None: self._last_aux_model_failure_error = _err_text self._last_aux_model_failure_model = self.summary_model self.summary_model = "" # empty = use main model - self._summary_failure_cooldown_until = 0.0 # no cooldown — retry immediately + self._clear_compression_failure_cooldown() # no cooldown — retry immediately def _generate_summary( self, @@ -1666,7 +1768,15 @@ def _generate_summary( # retry (_generate_summary recursion) re-enters harmlessly. with aux_interrupt_protection(): response = call_llm(**call_kwargs) - content = response.choices[0].message.content + # ``_validate_llm_response`` only guarantees ``choices[0].message`` + # exists, not that it's an object with ``.content``. Some + # OpenAI-compatible proxies / local backends return a dict- or + # str-shaped message; coerce defensively instead of crashing. + message = response.choices[0].message + if isinstance(message, dict): + content = message.get("content") + else: + content = getattr(message, "content", message) # Handle cases where content is not a string (e.g., dict from llama.cpp) if not isinstance(content, str): content = str(content) if content else "" @@ -1691,7 +1801,7 @@ def _generate_summary( summary = redact_sensitive_text(content.strip()) # Store for iterative updates on next compaction self._previous_summary = summary - self._summary_failure_cooldown_until = 0.0 + self._clear_compression_failure_cooldown() self._summary_model_fallen_back = False self._last_summary_error = None self._last_summary_auth_failure = False @@ -1711,7 +1821,10 @@ def _generate_summary( # a main-model retry before any cooldown. (#11978, #11914) if isinstance(e, RuntimeError) and "no llm provider configured" in str(e).lower(): # No provider configured — long cooldown, unlikely to self-resolve - self._summary_failure_cooldown_until = time.monotonic() + _SUMMARY_FAILURE_COOLDOWN_SECONDS + self._record_compression_failure_cooldown( + _SUMMARY_FAILURE_COOLDOWN_SECONDS, + "no auxiliary LLM provider configured", + ) self._last_summary_error = "no auxiliary LLM provider configured" logger.warning("Context compression: no provider available for " "summary. Middle turns will be dropped without summary " @@ -1823,10 +1936,10 @@ def _generate_summary( # streaming premature-close) — shorter cooldown for JSON decode and # streaming-closed since those conditions can self-resolve quickly. _transient_cooldown = 30 if (_is_json_decode or _is_streaming_closed) else 60 - self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown err_text = str(e).strip() or e.__class__.__name__ if len(err_text) > 220: err_text = err_text[:217].rstrip() + "..." + self._record_compression_failure_cooldown(_transient_cooldown, err_text) self._last_summary_error = err_text # A terminal connection/network failure (we reach this branch only # after any main-model fallback has already been tried or is @@ -2405,8 +2518,8 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f # Manual /compress (force=True) bypasses the failure cooldown so the # user can retry immediately after an auto-compress abort. Without # this, /compress would silently no-op for 30-60s after a failure. - if force and self._summary_failure_cooldown_until > 0.0: - self._summary_failure_cooldown_until = 0.0 + if force: + self._clear_compression_failure_cooldown() n_messages = len(messages) # Only need head + 3 tail messages minimum (token budget decides the real tail size) _min_for_compress = self._protect_head_size(messages) + 3 + 1 diff --git a/agent/context_references.py b/agent/context_references.py index 6307033d2706..fe63190e2c0d 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -12,6 +12,7 @@ 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( @@ -151,13 +152,24 @@ async def preprocess_context_references_async( blocks: list[str] = [] injected_tokens = 0 - for ref in refs: - warning, block = await _expand_reference( - ref, - cwd_path, - url_fetcher=url_fetcher, - allowed_root=allowed_root_path, + # Expand all references concurrently. Each _expand_reference is independent + # (no shared state during expansion) — a message with several @url: refs + # would otherwise pay one full web_extract round-trip per ref in series. + # gather preserves positional order, so we reassemble warnings/blocks in the + # original ref order exactly as the prior serial loop did; the token-budget + # check below is unchanged (it runs once, after all refs are expanded). + expanded = await asyncio.gather( + *( + _expand_reference( + ref, + cwd_path, + url_fetcher=url_fetcher, + allowed_root=allowed_root_path, + ) + for ref in refs ) + ) + for warning, block in expanded: if warning: warnings.append(warning) if block: @@ -290,6 +302,7 @@ 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], @@ -298,6 +311,7 @@ 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 @@ -325,9 +339,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", use_llm_processing=True) + raw = await web_extract_tool([url], format="markdown") payload = json.loads(raw) - docs = payload.get("data", {}).get("documents", []) + docs = payload.get("results", []) if not docs: return "" doc = docs[0] @@ -483,6 +497,7 @@ 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))], @@ -491,6 +506,7 @@ 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 diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 70d997631bc1..74e9feda2e38 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -32,6 +32,7 @@ import os import tempfile import uuid +import threading from datetime import datetime from pathlib import Path from typing import Any, Optional, Tuple @@ -71,6 +72,85 @@ def _compression_lock_holder(agent: Any) -> str: ) +class _CompressionLockLeaseRefresher: + def __init__( + self, + db: Any, + session_id: str, + holder: str, + ttl_seconds: float, + refresh_interval_seconds: float | None = None, + ) -> None: + self._db = db + self._session_id = session_id + self._holder = holder + self._ttl_seconds = ttl_seconds + if refresh_interval_seconds is None: + refresh_interval_seconds = max(1.0, min(60.0, ttl_seconds / 2.0)) + self._refresh_interval_seconds = max(0.1, float(refresh_interval_seconds)) + # Tolerate transient refresh failures for at most one lease's worth of + # time, so the give-up window is genuinely bounded by the TTL the + # acquirer set (a single blip recovers on the next tick; a persistent + # failure stops before the lease could outlive its TTL). Floor of 1 so a + # degenerate interval >= ttl still tolerates one blip. + self._max_consecutive_failures = max( + 1, int(self._ttl_seconds / self._refresh_interval_seconds) + ) + self._stop = threading.Event() + self._thread = threading.Thread( + target=self._run, + name="compression-lock-refresh", + daemon=True, + ) + + def start(self) -> "_CompressionLockLeaseRefresher": + self._thread.start() + return self + + def stop(self) -> None: + self._stop.set() + # join() may time out while the refresher is mid-UPDATE; that's safe — + # it's a daemon thread, and a late refresh on an already-released lock + # matches rowcount 0 (a no-op). stop() returning does not guarantee the + # thread has fully quiesced, only that we've signalled it and waited + # briefly. + if self._thread.is_alive() and threading.current_thread() is not self._thread: + self._thread.join(timeout=1.0) + + def _run(self) -> None: + # A single falsy refresh must NOT permanently kill the lease: a + # transient DB blip (write contention escaping _execute_write's retry + # budget, a momentary "database is locked") returns False just like a + # genuine lost-ownership, but only the latter should stop the loop. + # Tolerate consecutive failures for at most one lease's worth of time + # (_max_consecutive_failures = ttl / interval), so a one-off blip + # recovers on the next tick while the total give-up window stays bounded + # by the TTL the acquirer set — the lock can never be held past its TTL + # by a stuck refresher. + consecutive_failures = 0 + while not self._stop.wait(self._refresh_interval_seconds): + try: + refreshed = self._db.refresh_compression_lock( + self._session_id, + self._holder, + ttl_seconds=self._ttl_seconds, + ) + except Exception as exc: + logger.debug("compression lock refresh raised: %s", exc) + refreshed = False + if refreshed: + consecutive_failures = 0 + continue + consecutive_failures += 1 + if consecutive_failures >= self._max_consecutive_failures: + logger.debug( + "compression lock refresh failed %d times in a row; " + "stopping lease refresher for session %s", + consecutive_failures, self._session_id, + ) + break + + def check_compression_model_feasibility(agent: Any) -> None: """Warn at session start if the auxiliary compression model's context window is smaller than the main model's compression threshold. @@ -288,6 +368,29 @@ 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, @@ -397,11 +500,17 @@ def compress_context( # and proceed with compression. Skipping the lock risks a rare # concurrent-compression session fork; an infinite no-progress loop # that never compresses at all is strictly worse. + try: + _lock_ttl = float(getattr(agent, "_compression_lock_ttl_seconds", 300.0) or 300.0) + except (TypeError, ValueError): + _lock_ttl = 300.0 + _lock_refresh_interval = getattr(agent, "_compression_lock_refresh_interval", None) + _lock_refresher: Optional[_CompressionLockLeaseRefresher] = None if _lock_db is not None and _lock_sid: _lock_holder = _compression_lock_holder(agent) try: _lock_acquired = _lock_db.try_acquire_compression_lock( - _lock_sid, _lock_holder + _lock_sid, _lock_holder, ttl_seconds=_lock_ttl ) except Exception as _lock_err: # Broken/absent lock subsystem (version skew, etc.). Log once @@ -444,9 +553,19 @@ def compress_context( if not _existing_sp: _existing_sp = agent._build_system_prompt(system_message) return messages, _existing_sp + if _lock_holder is not None: + _lock_refresher = _CompressionLockLeaseRefresher( + _lock_db, + _lock_sid, + _lock_holder, + _lock_ttl, + _lock_refresh_interval, + ).start() def _release_lock() -> None: """Release the lock keyed on the OLD session_id (before rotation).""" + if _lock_refresher is not None: + _lock_refresher.stop() if _lock_db is not None and _lock_sid and _lock_holder: try: _lock_db.release_compression_lock(_lock_sid, _lock_holder) @@ -465,7 +584,11 @@ def _release_lock() -> None: except TypeError: # Plugin context engine with strict signature that doesn't accept # focus_topic / force — fall back to calling without them. - compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens) + try: + compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens) + except BaseException: + _release_lock() + raise except BaseException: # ANY exception during compress() must release the lock so the # session isn't permanently blocked from future compression. @@ -478,328 +601,332 @@ def _release_lock() -> None: # session has logically ended), and let auto-compress callers detect # the no-op via len(returned) == len(input). if getattr(agent.context_compressor, "_last_compress_aborted", False): - _err = getattr(agent.context_compressor, "_last_summary_error", None) or "unknown error" - if getattr(agent, "_last_compression_summary_warning", None) != _err: - agent._last_compression_summary_warning = _err - agent._emit_warning( - f"⚠ Compression aborted: {_err}. " - "No messages were dropped — conversation continues unchanged. " - "Run /compress to retry, or /new to start a fresh session." - ) - _existing_sp = getattr(agent, "_cached_system_prompt", None) - if not _existing_sp: - _existing_sp = agent._build_system_prompt(system_message) - _release_lock() # compression aborted — no rotation will happen - return messages, _existing_sp - - summary_error = getattr(agent.context_compressor, "_last_summary_error", None) - if summary_error: - if getattr(agent, "_last_compression_summary_warning", None) != summary_error: - agent._last_compression_summary_warning = summary_error - agent._emit_warning( - f"⚠ Compression summary failed: {summary_error}. " - "Inserted a fallback context marker." - ) - else: - # No hard failure — but did the configured aux model error out - # and get recovered by retrying on main? Surface that so users - # know their auxiliary.compression.model setting is broken even - # though compression succeeded. - _aux_fail_model = getattr(agent.context_compressor, "_last_aux_model_failure_model", None) - _aux_fail_err = getattr(agent.context_compressor, "_last_aux_model_failure_error", None) - if _aux_fail_model: - # Dedup on (model, error) so we don't spam on every compaction - _aux_key = (_aux_fail_model, _aux_fail_err) - if getattr(agent, "_last_aux_fallback_warning_key", None) != _aux_key: - agent._last_aux_fallback_warning_key = _aux_key + try: + _err = getattr(agent.context_compressor, "_last_summary_error", None) or "unknown error" + if getattr(agent, "_last_compression_summary_warning", None) != _err: + agent._last_compression_summary_warning = _err agent._emit_warning( - f"ℹ Configured compression model '{_aux_fail_model}' failed " - f"({_aux_fail_err or 'unknown error'}). Recovered using main model — " - "check auxiliary.compression.model in config.yaml." + f"⚠ Compression aborted: {_err}. " + "No messages were dropped — conversation continues unchanged. " + "Run /compress to retry, or /new to start a fresh session." ) + _existing_sp = getattr(agent, "_cached_system_prompt", None) + if not _existing_sp: + _existing_sp = agent._build_system_prompt(system_message) + return messages, _existing_sp + finally: + _release_lock() - todo_snapshot = agent._todo_store.format_for_injection() - if todo_snapshot: - compressed.append({"role": "user", "content": todo_snapshot}) + try: + summary_error = getattr(agent.context_compressor, "_last_summary_error", None) + if summary_error: + if getattr(agent, "_last_compression_summary_warning", None) != summary_error: + agent._last_compression_summary_warning = summary_error + agent._emit_warning( + f"⚠ Compression summary failed: {summary_error}. " + "Inserted a fallback context marker." + ) + else: + # No hard failure — but did the configured aux model error out + # and get recovered by retrying on main? Surface that so users + # know their auxiliary.compression.model setting is broken even + # though compression succeeded. + _aux_fail_model = getattr(agent.context_compressor, "_last_aux_model_failure_model", None) + _aux_fail_err = getattr(agent.context_compressor, "_last_aux_model_failure_error", None) + if _aux_fail_model: + # Dedup on (model, error) so we don't spam on every compaction + _aux_key = (_aux_fail_model, _aux_fail_err) + if getattr(agent, "_last_aux_fallback_warning_key", None) != _aux_key: + agent._last_aux_fallback_warning_key = _aux_key + agent._emit_warning( + f"ℹ Configured compression model '{_aux_fail_model}' failed " + f"({_aux_fail_err or 'unknown error'}). Recovered using main model — " + "check auxiliary.compression.model in config.yaml." + ) - agent._invalidate_system_prompt() - new_system_prompt = agent._build_system_prompt(system_message) - agent._cached_system_prompt = new_system_prompt + todo_snapshot = agent._todo_store.format_for_injection() + if todo_snapshot: + compressed.append({"role": "user", "content": todo_snapshot}) - if agent._session_db: - try: - # Trigger memory extraction on the current session before the - # transcript is rewritten (runs in BOTH modes — the logical - # conversation's pre-compaction turns are about to be summarized - # away regardless of whether the id rotates). - agent.commit_memory_session(messages) - - if in_place: - # ── In-place compaction: keep the same session_id ────────── - # No end_session, no new row, no parent_session_id, no title - # renumber, no contextvar/env/logging re-sync. The session's - # id, title, cwd, /goal, and gateway routing all stay put. - # - # Durable, NON-DESTRUCTIVE replace: soft-archive the - # pre-compaction turns (active=0, kept on disk + FTS-searchable + - # recoverable) and insert `compressed` as the new live (active=1) - # set, atomically. `compressed` already carries the surviving - # tail (current-turn messages the compressor kept via - # protect_last_n), so we DON'T pre-flush here — a flush would - # INSERT current-turn rows that archive_and_compact would then - # archive alongside the rest (harmless but wasted writes). The - # live-context load filters active=1, so a resume reloads ONLY - # the compacted set; the original turns remain under the SAME id - # for search/recovery (Teknium review — keep one durable id - # WITHOUT destroying history, unlike a hard replace_messages). - # See #38763. - agent._session_db.archive_and_compact(agent.session_id, compressed) - # Reset the flush identity set so the next turn's appends are - # diffed against the COMPACTED transcript: the compacted dicts - # are passed as conversation_history next turn and skipped by - # identity, so only genuinely new turn messages get appended - # (no dup of the summary, no resurrection of dropped turns). - agent._flushed_db_message_ids = set() - # Rotation-independent signal: the conversation was compacted in - # place (id unchanged). The gateway reads this (NOT an id-change - # diff) to re-baseline transcript handling. - compacted_in_place = True - else: - # ── Rotation (legacy): end this session, fork a continuation ─ - # Flush any un-persisted current-turn messages to the OLD - # session before ending it, so they survive in the preserved - # parent transcript (#47202). (In-place skips this — see above.) - try: - agent._flush_messages_to_session_db(messages) - except Exception: - pass # best-effort — don't block compression on a flush error - # Propagate title to the new session with auto-numbering - old_title = agent._session_db.get_session_title(agent.session_id) - agent._session_db.end_session(agent.session_id, "compression") - old_session_id = agent.session_id - agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" - # Ordering contract: the agent thread updates the contextvar here; - # the gateway propagates to SessionEntry after run_in_executor returns. - try: - from gateway.session_context import set_current_session_id + agent._invalidate_system_prompt() + new_system_prompt = agent._build_system_prompt(system_message) + agent._cached_system_prompt = new_system_prompt - set_current_session_id(agent.session_id) - except Exception: - os.environ["HERMES_SESSION_ID"] = agent.session_id - # The gateway/tools session context (ContextVar + env) and the - # logging session context are SEPARATE mechanisms. The call above - # moves the former; the ``[session_id]`` tag on log lines comes - # from ``hermes_logging._session_context`` (set once per turn in - # conversation_loop.py). Without this, post-rotation log lines in - # the same turn keep the STALE old id while the message/DB/gateway - # state carry the new one — breaking log correlation exactly at the - # compaction boundary (see #34089). Guarded separately so a logging - # failure can never regress the routing update above. - try: - from hermes_logging import set_session_context - - set_session_context(agent.session_id) - except Exception: - pass - agent._session_db_created = False - try: - agent._session_db.create_session( - session_id=agent.session_id, - source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), - model=agent.model, - model_config=agent._session_init_model_config, - parent_session_id=old_session_id, - ) - except Exception as _cs_err: - # The child row could not be created (e.g. FK constraint, - # contended write). Previously the outer handler simply - # warned and let the agent continue on the NEW id — which - # has no row in state.db, producing an orphan: the parent - # is ended, the child is never indexed, and every - # subsequent message is attributed to a session that - # doesn't exist (#33906/#33907). Roll the live id back to - # the parent so the conversation stays attached to a real, - # indexed session instead of a phantom. - logger.warning( - "Compression child session create failed (%s) — " - "rolling back to parent session %s to avoid an orphan.", - _cs_err, old_session_id, - ) - agent.session_id = old_session_id + if agent._session_db: + try: + # Trigger memory extraction on the current session before the + # transcript is rewritten (runs in BOTH modes — the logical + # conversation's pre-compaction turns are about to be summarized + # away regardless of whether the id rotates). + agent.commit_memory_session(messages) + + if in_place: + # ── In-place compaction: keep the same session_id ────────── + # No end_session, no new row, no parent_session_id, no title + # renumber, no contextvar/env/logging re-sync. The session's + # id, title, cwd, /goal, and gateway routing all stay put. + # + # Durable, NON-DESTRUCTIVE replace: soft-archive the + # pre-compaction turns (active=0, kept on disk + FTS-searchable + + # recoverable) and insert `compressed` as the new live (active=1) + # set, atomically. `compressed` already carries the surviving + # tail (current-turn messages the compressor kept via + # protect_last_n), so we DON'T pre-flush here — a flush would + # INSERT current-turn rows that archive_and_compact would then + # archive alongside the rest (harmless but wasted writes). The + # live-context load filters active=1, so a resume reloads ONLY + # the compacted set; the original turns remain under the SAME id + # for search/recovery (Teknium review — keep one durable id + # WITHOUT destroying history, unlike a hard replace_messages). + # See #38763. + agent._session_db.archive_and_compact(agent.session_id, compressed) + # Reset the flush identity set so the next turn's appends are + # diffed against the COMPACTED transcript: the compacted dicts + # are passed as conversation_history next turn and skipped by + # identity, so only genuinely new turn messages get appended + # (no dup of the summary, no resurrection of dropped turns). + agent._flushed_db_message_ids = set() + # Rotation-independent signal: the conversation was compacted in + # place (id unchanged). The gateway reads this (NOT an id-change + # diff) to re-baseline transcript handling. + compacted_in_place = True + else: + # ── Rotation (legacy): end this session, fork a continuation ─ + # Flush any un-persisted current-turn messages to the OLD + # session before ending it, so they survive in the preserved + # parent transcript (#47202). (In-place skips this — see above.) + try: + agent._flush_messages_to_session_db(messages) + except Exception: + pass # best-effort — don't block compression on a flush error + # Propagate title to the new session with auto-numbering + old_title = agent._session_db.get_session_title(agent.session_id) + agent._session_db.end_session(agent.session_id, "compression") + old_session_id = agent.session_id + agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + # Ordering contract: the agent thread updates the contextvar here; + # the gateway propagates to SessionEntry after run_in_executor returns. try: from gateway.session_context import set_current_session_id + set_current_session_id(agent.session_id) except Exception: os.environ["HERMES_SESSION_ID"] = agent.session_id + # The gateway/tools session context (ContextVar + env) and the + # logging session context are SEPARATE mechanisms. The call above + # moves the former; the ``[session_id]`` tag on log lines comes + # from ``hermes_logging._session_context`` (set once per turn in + # conversation_loop.py). Without this, post-rotation log lines in + # the same turn keep the STALE old id while the message/DB/gateway + # state carry the new one — breaking log correlation exactly at the + # compaction boundary (see #34089). Guarded separately so a logging + # failure can never regress the routing update above. try: from hermes_logging import set_session_context + set_session_context(agent.session_id) except Exception: pass - # Re-open the parent: it was ended above, but we're - # continuing on it, so it must not stay closed. + agent._session_db_created = False try: - agent._session_db.reopen_session(old_session_id) - except Exception: - pass - old_session_id = None # no rotation happened - # The parent row already exists in state.db, so mark the - # session as created — _ensure_db_session would otherwise - # retry a (harmless INSERT OR IGNORE) create next turn. + agent._session_db.create_session( + session_id=agent.session_id, + source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), + model=agent.model, + model_config=agent._session_init_model_config, + parent_session_id=old_session_id, + ) + except Exception as _cs_err: + # The child row could not be created (e.g. FK constraint, + # contended write). Previously the outer handler simply + # warned and let the agent continue on the NEW id — which + # has no row in state.db, producing an orphan: the parent + # is ended, the child is never indexed, and every + # subsequent message is attributed to a session that + # doesn't exist (#33906/#33907). Roll the live id back to + # the parent so the conversation stays attached to a real, + # indexed session instead of a phantom. + logger.warning( + "Compression child session create failed (%s) — " + "rolling back to parent session %s to avoid an orphan.", + _cs_err, old_session_id, + ) + agent.session_id = old_session_id + try: + from gateway.session_context import set_current_session_id + set_current_session_id(agent.session_id) + except Exception: + os.environ["HERMES_SESSION_ID"] = agent.session_id + try: + from hermes_logging import set_session_context + set_session_context(agent.session_id) + except Exception: + pass + # Re-open the parent: it was ended above, but we're + # continuing on it, so it must not stay closed. + try: + agent._session_db.reopen_session(old_session_id) + except Exception: + pass + old_session_id = None # no rotation happened + # The parent row already exists in state.db, so mark the + # session as created — _ensure_db_session would otherwise + # retry a (harmless INSERT OR IGNORE) create next turn. + agent._session_db_created = True + raise agent._session_db_created = True - raise - agent._session_db_created = True - # Carry a persistent /goal onto the continuation session. - # Compression mints a fresh child id; load_goal does a flat - # per-session lookup with no parent walk, so without this an - # active goal silently dies at the boundary (#33618). - try: - from hermes_cli.goals import migrate_goal_to_session - migrate_goal_to_session(old_session_id, agent.session_id, reason="compression") - except Exception as _goal_err: - logger.debug("Could not migrate goal on compression: %s", _goal_err) - # Auto-number the title for the continuation session - if old_title: + # Carry a persistent /goal onto the continuation session. + # Compression mints a fresh child id; load_goal does a flat + # per-session lookup with no parent walk, so without this an + # active goal silently dies at the boundary (#33618). try: - new_title = agent._session_db.get_next_title_in_lineage(old_title) - agent._session_db.set_session_title(agent.session_id, new_title) - except (ValueError, Exception) as e: - logger.debug("Could not propagate title on compression: %s", e) - - # Shared post-write steps (both modes target agent.session_id, which - # in-place keeps and rotation has already reassigned to the new id): - # refresh the stored system prompt and reset the flush cursor so the - # next turn re-bases its append diff. - agent._session_db.update_system_prompt(agent.session_id, new_system_prompt) - agent._last_flushed_db_idx = 0 - except Exception as e: - # If the rotation rolled back to the parent (orphan-avoidance - # above), agent.session_id is the still-indexed parent and - # old_session_id was cleared — so this is recovery, not an - # un-indexed orphan. Otherwise an earlier step failed before the - # child was created and the warning's original meaning holds. - if locals().get("old_session_id") is None and not in_place: - logger.warning( - "Compression rotation aborted and rolled back to the " - "parent session (%s): %s", agent.session_id or "?", e, + from hermes_cli.goals import migrate_goal_to_session + migrate_goal_to_session(old_session_id, agent.session_id, reason="compression") + except Exception as _goal_err: + logger.debug("Could not migrate goal on compression: %s", _goal_err) + # Auto-number the title for the continuation session + if old_title: + try: + new_title = agent._session_db.get_next_title_in_lineage(old_title) + agent._session_db.set_session_title(agent.session_id, new_title) + except (ValueError, Exception) as e: + logger.debug("Could not propagate title on compression: %s", e) + + # Shared post-write steps (both modes target agent.session_id, which + # in-place keeps and rotation has already reassigned to the new id): + # refresh the stored system prompt and reset the flush cursor so the + # next turn re-bases its append diff. + agent._session_db.update_system_prompt(agent.session_id, new_system_prompt) + agent._last_flushed_db_idx = 0 + except Exception as e: + # If the rotation rolled back to the parent (orphan-avoidance + # above), agent.session_id is the still-indexed parent and + # old_session_id was cleared — so this is recovery, not an + # un-indexed orphan. Otherwise an earlier step failed before the + # child was created and the warning's original meaning holds. + if locals().get("old_session_id") is None and not in_place: + logger.warning( + "Compression rotation aborted and rolled back to the " + "parent session (%s): %s", agent.session_id or "?", e, + ) + else: + logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e) + + # Compaction-boundary bookkeeping, computed once. `old_session_id` is only + # bound in the rotation branch; in-place leaves it unset. `_boundary_parent` + # is the id the boundary notifications attribute the prior state to: the old + # id on rotation, the (unchanged) current id in-place. + _old_sid = locals().get("old_session_id") + _is_boundary = bool(_old_sid) or in_place + _boundary_parent = _old_sid or agent.session_id or "" + + # Notify the context engine that a compaction boundary occurred. Plugin + # engines (e.g. hermes-lcm) use boundary_reason="compression" to preserve + # DAG lineage / checkpoint per-session state across the boundary instead of + # re-initializing fresh. See hermes-lcm#68. Built-in ContextCompressor + # ignores kwargs. Fires in BOTH modes: rotation passes old→new ids; in-place + # passes the SAME id (the boundary is real even though the id didn't move). + try: + if _is_boundary and hasattr(agent.context_compressor, "on_session_start"): + agent.context_compressor.on_session_start( + agent.session_id or "", + boundary_reason="compression", + old_session_id=_boundary_parent, + platform=getattr(agent, "platform", None) or "cli", + conversation_id=getattr(agent, "_gateway_session_key", None), ) - else: - logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e) - - # Compaction-boundary bookkeeping, computed once. `old_session_id` is only - # bound in the rotation branch; in-place leaves it unset. `_boundary_parent` - # is the id the boundary notifications attribute the prior state to: the old - # id on rotation, the (unchanged) current id in-place. - _old_sid = locals().get("old_session_id") - _is_boundary = bool(_old_sid) or in_place - _boundary_parent = _old_sid or agent.session_id or "" - - # Notify the context engine that a compaction boundary occurred. Plugin - # engines (e.g. hermes-lcm) use boundary_reason="compression" to preserve - # DAG lineage / checkpoint per-session state across the boundary instead of - # re-initializing fresh. See hermes-lcm#68. Built-in ContextCompressor - # ignores kwargs. Fires in BOTH modes: rotation passes old→new ids; in-place - # passes the SAME id (the boundary is real even though the id didn't move). - try: - if _is_boundary and hasattr(agent.context_compressor, "on_session_start"): - agent.context_compressor.on_session_start( - agent.session_id or "", - boundary_reason="compression", - old_session_id=_boundary_parent, - platform=getattr(agent, "platform", None) or "cli", - conversation_id=getattr(agent, "_gateway_session_key", None), - ) - except Exception as _ce_err: - logger.debug("context engine on_session_start (compression): %s", _ce_err) - - # Notify memory providers of the compaction boundary so provider-cached - # per-session state (Hindsight's _document_id, accumulated turn buffers, - # counters) refreshes. reset=False because the logical conversation - # continues. See #6672. Fires in BOTH modes: in-place uses the same id as - # parent (the conversation didn't fork, but the buffer must still be told - # the transcript was compacted so it doesn't double-count dropped turns). - try: - if _is_boundary and agent._memory_manager: - agent._memory_manager.on_session_switch( - agent.session_id or "", - parent_session_id=_boundary_parent, - reset=False, - reason="compression", + except Exception as _ce_err: + logger.debug("context engine on_session_start (compression): %s", _ce_err) + + # Notify memory providers of the compaction boundary so provider-cached + # per-session state (Hindsight's _document_id, accumulated turn buffers, + # counters) refreshes. reset=False because the logical conversation + # continues. See #6672. Fires in BOTH modes: in-place uses the same id as + # parent (the conversation didn't fork, but the buffer must still be told + # the transcript was compacted so it doesn't double-count dropped turns). + try: + if _is_boundary and agent._memory_manager: + agent._memory_manager.on_session_switch( + agent.session_id or "", + parent_session_id=_boundary_parent, + reset=False, + reason="compression", + ) + except Exception as _me_err: + logger.debug("memory manager on_session_switch (compression): %s", _me_err) + + # Warn on repeated compressions (quality degrades with each pass). + # Route through _emit_status (like the other compression warnings above) + # so the warning reaches the TUI / Telegram / Discord via status_callback, + # not just CLI stdout. _emit_status still _vprints for the CLI, and + # storing it on _compression_warning lets replay_compression_warning + # re-deliver it once a late-bound gateway status_callback is wired (#36908). + _cc = agent.context_compressor.compression_count + if _cc >= 2: + _cc_msg = ( + f"{agent.log_prefix}⚠️ Session compressed {_cc} times — " + f"accuracy may degrade. Consider /new to start fresh." ) - except Exception as _me_err: - logger.debug("memory manager on_session_switch (compression): %s", _me_err) - - # Warn on repeated compressions (quality degrades with each pass). - # Route through _emit_status (like the other compression warnings above) - # so the warning reaches the TUI / Telegram / Discord via status_callback, - # not just CLI stdout. _emit_status still _vprints for the CLI, and - # storing it on _compression_warning lets replay_compression_warning - # re-deliver it once a late-bound gateway status_callback is wired (#36908). - _cc = agent.context_compressor.compression_count - if _cc >= 2: - _cc_msg = ( - f"{agent.log_prefix}⚠️ Session compressed {_cc} times — " - f"accuracy may degrade. Consider /new to start fresh." + agent._compression_warning = _cc_msg + agent._emit_status(_cc_msg) + + # Emit session:compress event so hooks (e.g. MemPalace sync) can ingest + # the completed old session before its details are lost. In in-place mode + # there is no old id (same session); ``in_place=True`` tells hooks the + # transcript was compacted on the same id rather than rotated. + if getattr(agent, "event_callback", None): + try: + agent.event_callback("session:compress", { + "platform": agent.platform or "", + "session_id": agent.session_id, + "old_session_id": _old_sid or "", + "in_place": in_place, + "compression_count": agent.context_compressor.compression_count, + }) + except Exception as e: + logger.debug("event_callback error on session:compress: %s", e) + + # Surface the compaction mode to the caller (run_conversation / gateway) + # via a rotation-independent flag. The gateway uses this — NOT an + # id-change diff — to re-baseline transcript handling (history_offset=0 + + # rewrite on the same id) when compaction happened in place. See #38763. + agent._last_compaction_in_place = compacted_in_place + + # Keep the post-compression rough estimate for diagnostics, but do not + # treat it as provider-reported prompt usage. Schema-heavy rough estimates + # can remain above threshold even after the next real API request fits. + _compressed_est = estimate_request_tokens_rough( + compressed, + system_prompt=new_system_prompt or "", + tools=agent.tools or None, ) - agent._compression_warning = _cc_msg - agent._emit_status(_cc_msg) - - # Emit session:compress event so hooks (e.g. MemPalace sync) can ingest - # the completed old session before its details are lost. In in-place mode - # there is no old id (same session); ``in_place=True`` tells hooks the - # transcript was compacted on the same id rather than rotated. - if getattr(agent, "event_callback", None): + agent.context_compressor.last_compression_rough_tokens = _compressed_est + agent.context_compressor.last_prompt_tokens = -1 + agent.context_compressor.last_completion_tokens = 0 + agent.context_compressor.awaiting_real_usage_after_compression = True + + # Clear the file-read dedup cache. After compression the original + # read content is summarised away — if the model re-reads the same + # file it needs the full content, not a "file unchanged" stub. try: - agent.event_callback("session:compress", { - "platform": agent.platform or "", - "session_id": agent.session_id, - "old_session_id": _old_sid or "", - "in_place": in_place, - "compression_count": agent.context_compressor.compression_count, - }) - except Exception as e: - logger.debug("event_callback error on session:compress: %s", e) - - # Surface the compaction mode to the caller (run_conversation / gateway) - # via a rotation-independent flag. The gateway uses this — NOT an - # id-change diff — to re-baseline transcript handling (history_offset=0 + - # rewrite on the same id) when compaction happened in place. See #38763. - agent._last_compaction_in_place = compacted_in_place - - # Keep the post-compression rough estimate for diagnostics, but do not - # treat it as provider-reported prompt usage. Schema-heavy rough estimates - # can remain above threshold even after the next real API request fits. - _compressed_est = estimate_request_tokens_rough( - compressed, - system_prompt=new_system_prompt or "", - tools=agent.tools or None, - ) - agent.context_compressor.last_compression_rough_tokens = _compressed_est - agent.context_compressor.last_prompt_tokens = -1 - agent.context_compressor.last_completion_tokens = 0 - agent.context_compressor.awaiting_real_usage_after_compression = True - - # Clear the file-read dedup cache. After compression the original - # read content is summarised away — if the model re-reads the same - # file it needs the full content, not a "file unchanged" stub. - try: - from tools.file_tools import reset_file_dedup - reset_file_dedup(task_id) - except Exception: - pass + from tools.file_tools import reset_file_dedup + reset_file_dedup(task_id) + except Exception: + pass - logger.info( - "context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true", - agent.session_id or "none", _pre_msg_count, len(compressed), - f"{_compressed_est:,}", - ) - # Release the lock on the OLD session_id only AFTER rotation completed - # and all post-rotation bookkeeping (memory manager, context engine, - # file dedup) ran. A concurrent path that wakes up the moment we - # release will see the NEW session_id in state.db / SessionEntry and - # acquire on that — no race against our just-finished work. - _release_lock() - return compressed, new_system_prompt + logger.info( + "context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true", + agent.session_id or "none", _pre_msg_count, len(compressed), + f"{_compressed_est:,}", + ) + return compressed, new_system_prompt + finally: + # Release the lock on the OLD session_id only AFTER rotation completed + # and all post-rotation bookkeeping (memory manager, context engine, + # file dedup) ran. A concurrent path that wakes up the moment we + # release will see the NEW session_id in state.db / SessionEntry and + # acquire on that — no race against our just-finished work. + _release_lock() def try_shrink_image_parts_in_messages( diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index d68c652d2b8e..4ca09c6895da 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -28,6 +28,7 @@ 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 @@ -51,6 +52,7 @@ estimate_messages_tokens_rough, estimate_request_tokens_rough, get_context_length_from_provider_error, + is_output_cap_error, parse_available_output_tokens_from_error, save_context_length, ) @@ -587,6 +589,13 @@ 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. @@ -827,7 +836,6 @@ 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): @@ -1692,6 +1700,56 @@ def _perform_api_call(next_api_kwargs): 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) @@ -2259,6 +2317,15 @@ def _perform_api_call(next_api_kwargs): # "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( @@ -2830,10 +2897,9 @@ def _perform_api_call(next_api_kwargs): approx_tokens=approx_tokens, task_id=effective_task_id, ) - # 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 + conversation_history = conversation_history_after_compression( + agent, messages + ) if len(messages) < original_len or old_ctx > _reduced_ctx: agent._buffer_status( f"🗜️ Context reduced to {_reduced_ctx:,} tokens " @@ -2845,29 +2911,61 @@ def _perform_api_call(next_api_kwargs): # Fall through to normal error handling if compression # is exhausted or didn't help. - # 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. + # 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. is_rate_limited = classified.reason in { FailoverReason.rate_limit, FailoverReason.billing, + FailoverReason.upstream_rate_limit, + } + _is_transport_failure = classified.reason in { + FailoverReason.timeout, + FailoverReason.overloaded, } - if is_rate_limited and agent._fallback_index < len(agent._fallback_chain): + _should_fallback = ( + is_rate_limited + or (_is_transport_failure and retry_count >= 2) + ) + if _should_fallback 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 # exceptions. Fixes #11314 and #13636. - pool_may_recover = _ra()._pool_may_recover_from_rate_limit( - agent._credential_pool, - provider=agent.provider, - base_url=getattr(agent, "base_url", None), + # + # Exception: an upstream-aggregator 429 — the credential + # pool can't help when the *upstream* model (DeepSeek, + # etc.) is throttling OpenRouter, so always fall back to a + # different model regardless of pool state. + _is_upstream = classified.reason == FailoverReason.upstream_rate_limit + pool_may_recover = ( + False if _is_upstream + else _ra()._pool_may_recover_from_rate_limit( + agent._credential_pool, + provider=agent.provider, + base_url=getattr(agent, "base_url", None), + ) ) if not pool_may_recover: - if classified.reason == FailoverReason.billing: + if _is_upstream: + _upstream_name = (classified.error_context or {}).get( + "upstream_provider", "aggregator" + ) + agent._buffer_status( + f"⚠️ Upstream {_upstream_name} rate-limited — " + "switching to fallback model..." + ) + elif classified.reason == FailoverReason.billing: 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): @@ -3042,10 +3140,9 @@ def _perform_api_call(next_api_kwargs): messages, system_message, approx_tokens=approx_tokens, task_id=effective_task_id, ) - # 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 + conversation_history = conversation_history_after_compression( + agent, messages + ) # Re-estimate tokens after compression. Same-message-count # compression (tool-result pruning, in-place summarization) @@ -3135,6 +3232,45 @@ def _perform_api_call(next_api_kwargs): _retry.restart_with_compressed_messages = True break + # The error is output-cap-shaped (about max_tokens being + # too large) but the provider's wording didn't let us parse + # the available output budget. Compression CANNOT help here + # — the input already fits; the call fails deterministically + # on the oversized max_tokens. Routing it into compression + # re-sends the same max_tokens, gets the identical 400, and + # death-loops until "cannot compress further" (#55546). + # Fail fast with an actionable message instead of looping. + if is_output_cap_error(error_msg): + agent._flush_status_buffer() + agent._vprint( + f"{agent.log_prefix}❌ The provider rejected the request because " + f"max_tokens exceeds its output cap for this model.", + force=True, + ) + agent._vprint( + f"{agent.log_prefix} 💡 Lower model.max_tokens in your config.yaml to " + f"at or below the model's max-output limit. " + f"(This is an output-cap error, not a context overflow — " + f"compression cannot fix it.)", + force=True, + ) + logger.error( + f"{agent.log_prefix}Output-cap error not routed into compression " + f"(max_tokens over provider cap): {error_msg[:200]}" + ) + agent._persist_session(messages, conversation_history) + return { + "messages": messages, + "completed": False, + "api_calls": api_call_count, + "error": ( + "max_tokens exceeds the provider's output cap for this model. " + "Lower model.max_tokens in config.yaml." + ), + "partial": True, + "failed": True, + } + # Error is about the INPUT being too large. Only reduce # context_length when the provider explicitly reports the # real lower limit. If the provider only says "input @@ -3209,10 +3345,9 @@ def _perform_api_call(next_api_kwargs): messages, system_message, approx_tokens=approx_tokens, task_id=effective_task_id, ) - # 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 + conversation_history = conversation_history_after_compression( + agent, messages + ) # Re-estimate tokens after compression. Same-message-count # compression (tool-result pruning, in-place summarization) @@ -3474,6 +3609,13 @@ def _perform_api_call(next_api_kwargs): ): _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(): @@ -3661,7 +3803,12 @@ def _perform_api_call(next_api_kwargs): _ra_raw = _resp_headers.get("retry-after") or _resp_headers.get("Retry-After") if _ra_raw: try: - _retry_after = min(float(_ra_raw), 120) # Cap at 2 minutes + # 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) except (TypeError, ValueError): pass wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0) @@ -3742,6 +3889,17 @@ def _perform_api_call(next_api_kwargs): _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. @@ -4316,10 +4474,9 @@ def _perform_api_call(next_api_kwargs): approx_tokens=agent.context_compressor.last_prompt_tokens, task_id=effective_task_id, ) - # 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 + conversation_history = conversation_history_after_compression( + agent, messages + ) # Save session log incrementally (so progress is visible even if interrupted) agent._session_messages = messages @@ -4361,7 +4518,11 @@ def _perform_api_call(next_api_kwargs): "as final response" ) final_response = _recovered - agent._response_was_previewed = True + # 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 break # If the previous turn already delivered real content alongside @@ -4606,14 +4767,20 @@ def _perform_api_call(next_api_kwargs): # 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 ( - agent.api_mode == "codex_responses" + _ack_mode != "off" 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 @@ -4694,7 +4861,60 @@ def _perform_api_call(next_api_kwargs): "_verification_stop_synthetic": True, }) agent._session_messages = messages - agent._emit_status("↻ Verification required before finishing") + # 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) continue messages.append(final_msg) diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index e3c03938af40..ce3ec2c5c400 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -21,8 +21,14 @@ 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 @@ -94,7 +100,10 @@ def _resolve_home_dir() -> str: def _build_subprocess_env() -> dict[str, str]: - env = os.environ.copy() + # Copilot ACP is a model-driving CLI executor: it legitimately needs LLM + # provider credentials. Route through the central helper so Tier-1 secrets + # (gateway bot tokens, GitHub auth, infra) are still stripped (#29157). + env = hermes_subprocess_env(inherit_credentials=True) home = _resolve_home_dir() env["HOME"] = home from hermes_constants import apply_subprocess_home_env @@ -224,11 +233,73 @@ def _render_message_content(content: Any) -> str: return str(content).strip() -def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]: +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]: if not isinstance(text, str) or not text.strip(): return [], "" - extracted: list[SimpleNamespace] = [] + extracted: list[ChatCompletionMessageToolCall] = [] consumed_spans: list[tuple[int, int]] = [] def _try_add_tool_call(raw_json: str) -> None: @@ -252,12 +323,10 @@ def _try_add_tool_call(raw_json: str) -> None: call_id = f"acp_call_{len(extracted)+1}" extracted.append( - SimpleNamespace( - id=call_id, + _build_openai_tool_call( call_id=call_id, - response_item_id=None, - type="function", - function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args), + name=fn_name.strip(), + arguments=fn_args, ) ) @@ -376,6 +445,7 @@ def _create_chat_completion( 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( @@ -422,11 +492,14 @@ def _create_chat_completion( ) finish_reason = "tool_calls" if tool_calls else "stop" choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason) - return SimpleNamespace( + completion = 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: diff --git a/agent/credential_pool.py b/agent/credential_pool.py index e4aa575ab049..8d10bbb1cbf7 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -537,10 +537,11 @@ def _replace_entry(self, old: PooledCredential, new: PooledCredential) -> None: self._entries[idx] = new return - def _persist(self) -> None: + def _persist(self, *, removed_ids: Optional[List[str]] = None) -> None: write_credential_pool( self.provider, [entry.to_dict() for entry in self._entries], + removed_ids=removed_ids, ) def _is_terminal_auth_failure( @@ -615,17 +616,32 @@ def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) - file_refresh = creds.get("refreshToken", "") file_access = creds.get("accessToken", "") file_expires = creds.get("expiresAt", 0) - # If the credentials file has a different token pair, sync it - if file_refresh and file_refresh != entry.refresh_token: - logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id) + # Sync when either token changed. Access tokens can be re-issued + # without a new refresh token (silent re-issue path), so checking + # only refresh_token misses that case and leaves a stale + # access_token in the pool → 401 on every request until the pool + # entry's exhausted TTL expires. + entry_access = entry.access_token or "" + entry_refresh = entry.refresh_token or "" + if (file_access or file_refresh) and ( + (file_access and file_access != entry_access) + or (file_refresh and file_refresh != entry_refresh) + ): + logger.debug( + "Pool entry %s: syncing tokens from credentials file (tokens changed)", + entry.id, + ) updated = replace( entry, - access_token=file_access, - refresh_token=file_refresh, - expires_at_ms=file_expires, + access_token=file_access or entry.access_token, + refresh_token=file_refresh or entry.refresh_token, + expires_at_ms=file_expires or entry.expires_at_ms, last_status=None, last_status_at=None, last_error_code=None, + last_error_reason=None, + last_error_message=None, + last_error_reset_at=None, ) self._replace_entry(entry, updated) self._persist() @@ -1124,13 +1140,17 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po 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() + self._persist(removed_ids=removed_ids) return None # For openai-codex: same race as xAI/nous — another Hermes process # may have consumed the refresh token between our proactive sync @@ -1190,13 +1210,17 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po 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() + self._persist(removed_ids=removed_ids) return None # For nous: another process may have consumed the refresh token # between our proactive sync and the HTTP call. Re-sync from @@ -1253,13 +1277,17 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po 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() + self._persist(removed_ids=removed_ids) return None self._mark_exhausted(entry, None) return None @@ -1421,7 +1449,7 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal 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() + self._persist(removed_ids=entries_to_prune) return available def _select_unlocked(self) -> Optional[PooledCredential]: @@ -1595,7 +1623,11 @@ def remove_index(self, index: int) -> Optional[PooledCredential]: replace(entry, priority=new_priority) for new_priority, entry in enumerate(self._entries) ] - self._persist() + write_credential_pool( + self.provider, + [entry.to_dict() for entry in self._entries], + removed_ids=[removed.id], + ) if self._current_id == removed.id: self._current_id = None return removed @@ -1867,11 +1899,16 @@ def _env_val(key: str) -> str: from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token token, source = resolve_copilot_token() if token: - api_token = get_copilot_api_token(token) + api_token, enterprise_base_url = get_copilot_api_token(token) source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}" if not _is_suppressed(provider, source_name): active_sources.add(source_name) pconfig = PROVIDER_REGISTRY.get(provider) + # Use enterprise base URL from token exchange if available, + # otherwise fall back to the provider's default. + effective_base_url = enterprise_base_url or ( + pconfig.inference_base_url if pconfig else "" + ) changed |= _upsert_entry( entries, provider, @@ -1880,7 +1917,7 @@ def _env_val(key: str) -> str: "source": source_name, "auth_type": AUTH_TYPE_API_KEY, "access_token": api_token, - "base_url": pconfig.inference_base_url if pconfig else "", + "base_url": effective_base_url, "label": source, }, ) @@ -2257,6 +2294,11 @@ def _is_suppressed(_p, _s): # type: ignore[misc] 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 @@ -2285,8 +2327,10 @@ 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) diff --git a/agent/curator.py b/agent/curator.py index 6843205c684b..c13a36ecbbd3 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -273,6 +273,21 @@ 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. @@ -292,6 +307,8 @@ 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(): @@ -300,6 +317,15 @@ 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): @@ -316,6 +342,18 @@ 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: @@ -390,10 +428,19 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int "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.\n" + "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" "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 " @@ -1413,12 +1460,14 @@ 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)} " diff --git a/agent/display.py b/agent/display.py index 861d84bc4105..060ac1266fa0 100644 --- a/agent/display.py +++ b/agent/display.py @@ -537,6 +537,122 @@ 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 " into "Searching the web for " — 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 " reads better than "Searching the web ". +_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 # ========================================================================= diff --git a/agent/error_classifier.py b/agent/error_classifier.py index c3b356d4e45a..8111880a7ec6 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -31,6 +31,9 @@ class FailoverReason(enum.Enum): # Billing / quota billing = "billing" # 402 or confirmed credit exhaustion — rotate immediately rate_limit = "rate_limit" # 429 or quota-based throttling — backoff then rotate + # Upstream model rate-limited (aggregator 429) — fallback to a different + # model, NOT credential rotation. The user's key is healthy. + upstream_rate_limit = "upstream_rate_limit" # Server-side overloaded = "overloaded" # 503/529 — provider overloaded, backoff @@ -133,6 +136,31 @@ def is_auth(self) -> bool: "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", @@ -330,6 +358,14 @@ def is_auth(self) -> bool: # 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) @@ -863,7 +899,35 @@ def _classify_by_status( ) if status_code == 429: - # Already checked long_context_tier above; this is a normal rate limit + # Already checked long_context_tier above. Some providers (notably + # Z.AI / Zhipu) reuse HTTP 429 for server-wide overload — same status + # code as a true per-credential rate limit, but the credential is + # valid and the correct recovery is "back off and retry the same key", + # NOT "rotate the credential" (which exhausts the pool while the + # endpoint is still busy, and does nothing for a single-key user). + # Disambiguate on the error body so an overload 429 takes the + # transient-overload path instead of burning the pool. (#14038) + if any(p in error_msg for p in _OVERLOADED_PATTERNS): + return result_fn( + FailoverReason.overloaded, + retryable=True, + ) + # Distinguish an OpenRouter-aggregator upstream 429 (an upstream model + # like DeepSeek rate-limited OpenRouter's aggregate traffic) from an + # account-level 429 (the user's key is actually throttled). OpenRouter + # wraps upstream errors with the outer message "Provider returned + # error" — the user's key is healthy, so marking it exhausted / rotating + # is wrong and burns the key for ~24min. Fall back to a different model. + if _is_openrouter_upstream_error(body, provider): + upstream_provider = _extract_upstream_provider_name(body) + ctx = {"upstream_provider": upstream_provider} if upstream_provider else {} + return result_fn( + FailoverReason.upstream_rate_limit, + retryable=True, + should_rotate_credential=False, + should_fallback=True, + error_context=ctx, + ) return result_fn( FailoverReason.rate_limit, retryable=True, @@ -1214,6 +1278,17 @@ 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( @@ -1303,19 +1378,25 @@ def _extract_status_code(error: Exception) -> Optional[int]: def _extract_error_body(error: Exception) -> dict: - """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 + """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 return {} @@ -1383,3 +1464,49 @@ def _extract_message(error: Exception, body: dict) -> str: return msg.strip()[:500] # Fallback to str(error) return str(error)[:500] + + +def _is_openrouter_upstream_error(body: Any, provider: str) -> bool: + """Detect OpenRouter's aggregator-wrapped upstream provider errors. + + OpenRouter returns errors from upstream model providers (DeepSeek, + Anthropic, etc.) wrapped with the outer message "Provider returned error" + and the real error nested in ``metadata.raw``. This signal means the + user's OpenRouter key is healthy — the upstream provider is the one that + failed — so credential rotation is the wrong recovery. + """ + if not isinstance(body, dict): + return False + provider_lower = (provider or "").strip().lower() + err = body.get("error") + if not isinstance(err, dict): + return False + outer_msg = str(err.get("message") or "").strip().lower() + if outer_msg != "provider returned error": + return False + # Require either the explicit OpenRouter provider OR the metadata shape + # that only OpenRouter produces (metadata.raw / metadata.provider_name). + if provider_lower == "openrouter": + return True + metadata = err.get("metadata") + if isinstance(metadata, dict) and ( + "raw" in metadata or "provider_name" in metadata + ): + return True + return False + + +def _extract_upstream_provider_name(body: Any) -> Optional[str]: + """Pull the upstream provider name out of OpenRouter's error metadata.""" + if not isinstance(body, dict): + return None + err = body.get("error") + if not isinstance(err, dict): + return None + metadata = err.get("metadata") + if not isinstance(metadata, dict): + return None + name = metadata.get("provider_name") + if isinstance(name, str) and name.strip(): + return name.strip() + return None diff --git a/agent/file_safety.py b/agent/file_safety.py index 7a70f9641250..482c4217c85f 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -77,15 +77,22 @@ def build_write_denied_prefixes(home: str) -> list[str]: ] -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 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 is_write_denied(path: str) -> bool: @@ -124,9 +131,15 @@ def is_write_denied(path: str) -> bool: except Exception: pass - safe_root = get_safe_write_root() - if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)): - return True + 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 return False diff --git a/agent/image_routing.py b/agent/image_routing.py index c8b3f6640c6d..acd66fea8274 100644 --- a/agent/image_routing.py +++ b/agent/image_routing.py @@ -251,6 +251,78 @@ 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): @@ -302,15 +374,33 @@ 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) - return None - if caps is None: - return None - return bool(caps.supports_vision) + 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 def decide_image_input_mode( @@ -388,14 +478,98 @@ def _sniff_mime_from_bytes(raw: bytes) -> Optional[str]: # BMP: "BM" if raw.startswith(b"BM"): return "image/bmp" - # 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" + # 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 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*. @@ -431,8 +605,18 @@ 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. - Returns None only if the file can't be read (missing, permission - denied, etc.); the caller reports those paths in ``skipped``. + 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. """ try: raw = path.read_bytes() @@ -440,6 +624,22 @@ 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}" diff --git a/agent/learning_graph.py b/agent/learning_graph.py new file mode 100644 index 000000000000..6dc518b2abaf --- /dev/null +++ b/agent/learning_graph.py @@ -0,0 +1,320 @@ +"""Assemble the "learning made visible" graph for desktop. + +This graph is intentionally scoped to what a user actually learns over time: +- non-base, learned/profile skills (agent-created or used), +- memory chunks from ``MEMORY.md`` / ``USER.md`` as first-class nodes. + +Skill links come from declared ``related_skills``. Memory-to-skill links are +derived from lexical overlap so the graph can answer "which learned skills are +connected to the things I remember?". + +Run as a module to print edge-density stats against real data: + + python -m agent.learning_graph +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from hermes_constants import get_hermes_home + + +@dataclass +class SkillNode: + name: str + category: str + source: str = "profile" + timestamp: Optional[int] = None + use_count: int = 0 + state: str = "active" + created_by: Optional[str] = None + pinned: bool = False + related: list[str] = field(default_factory=list) + + +def _frontmatter(text: str) -> dict[str, Any]: + try: + from agent.skill_utils import parse_frontmatter + + fm, _ = parse_frontmatter(text) + return fm or {} + except Exception: + return {} + + +def _related(fm: dict[str, Any]) -> list[str]: + raw = fm.get("related_skills") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("related_skills") + if isinstance(raw, list): + return [str(r).strip() for r in raw if str(r).strip()] + if isinstance(raw, str): + return [r.strip() for r in raw.strip("[]").split(",") if r.strip()] + return [] + + +def _category(fm: dict[str, Any], skill_md: Path) -> str: + cat = fm.get("category") or (fm.get("metadata", {}).get("hermes", {}) or {}).get("category") + if cat: + return str(cat) + # …/skills///SKILL.md + parts = skill_md.parts + return parts[-3] if len(parts) >= 3 else "general" + + +def _iter_skill_files(roots: list[tuple[str, Path]]): + for source, root in roots: + if root.exists(): + for path in root.rglob("SKILL.md"): + yield source, path + + +def _load_usage() -> dict[str, dict[str, Any]]: + try: + from tools.skill_usage import load_usage + + return load_usage() + except Exception: + path = get_hermes_home() / "skills" / ".usage.json" + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _to_int_ts(value: Any) -> Optional[int]: + try: + if value is None: + return None + if isinstance(value, (int, float)): + return int(value) + s = str(value).strip() + if not s: + return None + try: + return int(float(s)) + except ValueError: + parsed = datetime.fromisoformat(s.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return int(parsed.timestamp()) + except Exception: + return None + + +def _usage_timestamp(rec: dict[str, Any]) -> Optional[int]: + for key in ("last_activity_at", "last_used_at", "last_viewed_at", "last_patched_at", "created_at"): + ts = _to_int_ts(rec.get(key)) + if ts is not None: + return ts + return None + + +def build_skill_nodes(skill_roots: list[tuple[str, Path]]) -> dict[str, SkillNode]: + usage = _load_usage() + nodes: dict[str, SkillNode] = {} + + for source, skill_md in _iter_skill_files(skill_roots): + if any(p in {".archive", ".hub", "node_modules", ".git"} for p in skill_md.parts): + continue + try: + fm = _frontmatter(skill_md.read_text(encoding="utf-8")[:4000]) + except OSError: + continue + name = str(fm.get("name") or skill_md.parent.name).strip() + if not name or name in nodes: + continue + rec = usage.get(name, {}) + last_activity = _usage_timestamp(rec) + file_ts = _to_int_ts(skill_md.stat().st_mtime) + nodes[name] = SkillNode( + name=name, + category=_category(fm, skill_md), + source=source, + timestamp=last_activity or file_ts, + use_count=int(rec.get("use_count", 0) or 0), + state=str(rec.get("state", "active") or "active"), + created_by=rec.get("created_by"), + pinned=bool(rec.get("pinned", False)), + related=_related(fm), + ) + return nodes + + +def build_edges(nodes: dict[str, SkillNode]) -> list[tuple[str, str]]: + """Undirected related_skills edges where BOTH endpoints exist (deduped).""" + seen: set[tuple[str, str]] = set() + edges: list[tuple[str, str]] = [] + for node in nodes.values(): + for target in node.related: + if target in nodes and target != node.name: + a, b = sorted((node.name, target)) + key = (a, b) + if key not in seen: + seen.add(key) + edges.append(key) + return edges + + +def density_stats(nodes: dict[str, SkillNode], edges: list[tuple[str, str]]) -> dict[str, Any]: + linked: set[str] = set() + for a, b in edges: + linked.add(a) + linked.add(b) + cats: dict[str, int] = {} + for n in nodes.values(): + cats[n.category] = cats.get(n.category, 0) + 1 + n = len(nodes) or 1 + return { + "nodes": len(nodes), + "related_edges": len(edges), + "edges_per_node": round(len(edges) / n, 3), + "linked_nodes": len(linked), + "isolated_pct": round(100 * (n - len(linked)) / n, 1), + "categories": len(cats), + "agent_created": sum(1 for x in nodes.values() if x.created_by == "agent"), + "used": sum(1 for x in nodes.values() if x.use_count > 0), + "top_categories": sorted(cats.items(), key=lambda kv: -kv[1])[:8], + } + + +def _memory_cards() -> list[dict[str, Any]]: + """Freeform memory as readable cards. + + ``MEMORY.md`` / ``USER.md`` are prose split on bare ``§`` separators; each + chunk becomes one card. Every chunk is surfaced — the graph shows everything. + """ + base = get_hermes_home() / "memories" + cards: list[dict[str, Any]] = [] + for fname, source in (("MEMORY.md", "memory"), ("USER.md", "profile")): + path = base / fname + try: + text = path.read_text(encoding="utf-8").strip() + file_ts = _to_int_ts(path.stat().st_mtime) + except OSError: + continue + for chunk_idx, chunk in enumerate(c.strip() for c in text.split("\n§\n")): + if not chunk: + continue + first = chunk.splitlines()[0].strip().lstrip("# ").strip() + cards.append( + { + "source": source, + "timestamp": file_ts + chunk_idx if file_ts is not None else None, + "title": (first[:80] + "…") if len(first) > 80 else first, + "body": chunk[:1200], + } + ) + return cards + + +def _tokenize(text: str) -> set[str]: + return {t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) >= 3} + + +def _memory_skill_edges(memory_cards: list[dict[str, Any]], skills: list[SkillNode]) -> list[tuple[str, str]]: + edges: list[tuple[str, str]] = [] + skill_meta = [(s, _tokenize(s.name), s.name.lower()) for s in skills] + for idx, card in enumerate(memory_cards): + mem_id = f"memory:{card['source']}:{idx}" + text = f"{card.get('title', '')}\n{card.get('body', '')}".lower() + text_tokens = _tokenize(text) + scored: list[tuple[int, str]] = [] + for skill, tokens, skill_name_lower in skill_meta: + score = 0 + if skill_name_lower in text: + score += 6 + score += len(tokens & text_tokens) + if score > 0: + scored.append((score, skill.name)) + scored.sort(key=lambda x: (-x[0], x[1])) + for _, skill_name in scored[:4]: + edges.append((mem_id, skill_name)) + return edges + + +def _skill_roots() -> list[tuple[str, Path]]: + repo = Path(__file__).resolve().parent.parent + home_skills = get_hermes_home() / "skills" + return [("base", repo / "skills"), ("profile", home_skills)] + + +def build_learning_graph() -> dict[str, Any]: + """Full payload for the desktop learning panel. + + Focus on what is profile-learned and actionable: + - skills that are NOT base-installed and show real learning signal + (agent-created or used), + - memory chunks as first-class graph nodes connected to those learned skills. + """ + all_skills = build_skill_nodes(_skill_roots()) + learned_skills = { + name: node + for name, node in all_skills.items() + if node.source != "base" and (node.created_by == "agent" or node.use_count > 0) + } + skill_edges = build_edges(learned_skills) + memory_cards = _memory_cards() + memory_edges = _memory_skill_edges(memory_cards, list(learned_skills.values())) + + edges = skill_edges + memory_edges + clusters: dict[str, int] = {} + for node in learned_skills.values(): + clusters[node.category] = clusters.get(node.category, 0) + 1 + if memory_cards: + clusters["memory"] = len(memory_cards) + + graph_nodes = [ + { + "id": n.name, + "label": n.name, + "kind": "skill", + "timestamp": n.timestamp, + "category": n.category, + "useCount": n.use_count, + "state": n.state, + "createdBy": n.created_by, + "pinned": n.pinned, + } + for n in learned_skills.values() + ] + for i, card in enumerate(memory_cards): + graph_nodes.append( + { + "id": f"memory:{card['source']}:{i}", + "label": card["title"], + "kind": "memory", + "memorySource": card["source"], + "timestamp": card.get("timestamp"), + "category": "memory", + "useCount": 0, + "state": "active", + "createdBy": "memory", + "pinned": False, + } + ) + + return { + "nodes": graph_nodes, + "edges": [{"source": a, "target": b} for a, b in edges], + "clusters": [ + {"category": c, "count": n} + for c, n in sorted(clusters.items(), key=lambda kv: -kv[1]) + ], + "memory": memory_cards, + "stats": { + **density_stats(learned_skills, skill_edges), + "memory_nodes": len(memory_cards), + "memory_skill_edges": len(memory_edges), + "learned_skills": len(learned_skills), + }, + } + + +if __name__ == "__main__": + nodes = build_skill_nodes(_skill_roots()) + print(json.dumps(density_stats(nodes, build_edges(nodes)), indent=2)) diff --git a/agent/lsp/reporter.py b/agent/lsp/reporter.py index 0eba96ba1ff9..2be1779ccedb 100644 --- a/agent/lsp/reporter.py +++ b/agent/lsp/reporter.py @@ -8,6 +8,7 @@ """ from __future__ import annotations +import html from typing import Any, Dict, List # Severity-1 only by default — warnings/info/hints would flood the @@ -18,18 +19,65 @@ MAX_PER_FILE = 20 MAX_TOTAL_CHARS = 4000 +# Per-field caps for diagnostic content sourced from the language server. +# These bound the length of any single attacker-controlled identifier that +# can ride into the model's tool output via an LSP diagnostic message. +MAX_MESSAGE_CHARS = 300 +MAX_CODE_CHARS = 80 +MAX_SOURCE_CHARS = 80 + + +def _sanitize_field(value: Any, *, limit: int) -> str: + """Make a language-server field safe to embed in a tool-result block. + + Diagnostic ``message``, ``code``, and ``source`` originate from a + language server that has just parsed user-controlled source code, so + they're untrusted from the agent's point of view. A hostile repo can + place instruction-shaped text inside identifier names, type aliases, + or import paths so the resulting diagnostic echoes that text back + into the ```` block the model reads. + + This helper: + + * Collapses CR/LF so a raw newline can't synthesize a new line in the + formatted block. + * Drops non-printable ASCII control characters that have no business + in a single-line summary. + * Caps length per-field so a long identifier can't push past the + block boundary. + * HTML-escapes ``< > &`` so the result can't close ```` + early or open a new tag. + + Returns ``""`` for ``None`` / empty so the surrounding format string + naturally omits the part (mirrors the prior ``if code not in {None, + ""}`` check at call sites). + """ + if value is None: + return "" + raw = str(value) + # Collapse newlines so identifier text with raw \n can't fake new lines. + raw = raw.replace("\r", " ").replace("\n", " ") + # Drop ASCII control chars; keep regular spaces. + raw = "".join(ch for ch in raw if ch == " " or ch.isprintable()) + raw = raw.strip()[:limit] + return html.escape(raw, quote=False) + def format_diagnostic(d: Dict[str, Any]) -> str: - """One-line representation of a single diagnostic.""" + """One-line representation of a single diagnostic. + + ``message``, ``code``, and ``source`` are sanitized before + interpolation — see ``_sanitize_field``. + """ sev = SEVERITY_NAMES.get(d.get("severity") or 1, "ERROR") rng = d.get("range") or {} start = rng.get("start") or {} line = int(start.get("line", 0)) + 1 col = int(start.get("character", 0)) + 1 - msg = str(d.get("message") or "").rstrip() - code = d.get("code") - code_part = f" [{code}]" if code not in {None, ""} else "" - source = d.get("source") + msg = _sanitize_field(d.get("message"), limit=MAX_MESSAGE_CHARS) + code = _sanitize_field(d.get("code"), limit=MAX_CODE_CHARS) + code_part = f" [{code}]" if code else "" + source = _sanitize_field(d.get("source"), limit=MAX_SOURCE_CHARS) source_part = f" ({source})" if source else "" return f"{sev} [{line}:{col}] {msg}{code_part}{source_part}" @@ -57,7 +105,11 @@ def report_for_file( body = "\n".join(lines) if extra > 0: body += f"\n... and {extra} more" - return f"\n{body}\n" + # quote=True escapes both ``"`` and ``&`` so a crafted file name like + # ``foo">\n{body}\n" def truncate(s: str, *, limit: int = MAX_TOTAL_CHARS) -> str: diff --git a/agent/moa_loop.py b/agent/moa_loop.py index f908c70a0a58..583a0d56ccb5 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -8,6 +8,7 @@ from __future__ import annotations +import hashlib import logging from concurrent.futures import ThreadPoolExecutor from typing import Any @@ -25,20 +26,114 @@ # 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"] + if rt.get("api_mode"): + out["api_mode"] = rt["api_mode"] + except Exception as exc: # pragma: no cover - defensive + logger.debug("MoA slot runtime resolution failed for %s: %s", _slot_label(slot), exc) + return out + + def _run_reference( slot: dict[str, str], ref_messages: list[dict[str, Any]], *, - temperature: float, - max_tokens: int, + temperature: float | None = None, + max_tokens: int | None = None, ) -> tuple[str, str]: """Call one reference model and return ``(label, text)``. + The slot is resolved to its provider's real runtime (via ``_slot_runtime``) + and called through the same ``call_llm`` request-building path any model + uses, so per-model wire-format handling (anthropic_messages, + max_completion_tokens, fixed/forbidden temperature) applies identically to + a reference as it would if that model were the acting model. MoA imposes no + cap of its own (``max_tokens`` defaults to ``None`` → omitted → the model's + real maximum); ``temperature`` is only the user's configured preset value, + which call_llm may still override per model. + Never raises: a failed reference becomes a labelled note so the aggregator can still act with partial context. Designed to run inside a thread pool — ``call_llm`` is synchronous/blocking, so threads (not asyncio) are the right @@ -46,13 +141,17 @@ 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", - provider=slot["provider"], - model=slot["model"], - messages=ref_messages, + messages=messages, temperature=temperature, max_tokens=max_tokens, + **_slot_runtime(slot), ) return label, _extract_text(response) or "(empty response)" except Exception as exc: @@ -64,8 +163,8 @@ def _run_references_parallel( reference_models: list[dict[str, str]], ref_messages: list[dict[str, Any]], *, - temperature: float, - max_tokens: int, + temperature: float | None = None, + max_tokens: int | None = None, ) -> list[tuple[str, str]]: """Fan out all reference models in parallel, returning outputs in order. @@ -106,40 +205,140 @@ 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-safe view of the conversation for reference models. - - 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. + """Build an advisory view of the conversation for reference models. + + A reference gives an INFORMED judgement on the current state, so it must + see what the agent actually did — its tool calls AND the tool results that + came back — not just the agent's narration. We therefore preserve the whole + conversation flow, but flatten it into clean user/assistant *text* turns: + + - system prompt: dropped (8K of Hermes boilerplate, not advisory signal). + - assistant turns: kept; any ``tool_calls`` are rendered inline as + ``[called tool: name(args)]`` text lines appended to the turn's text. + - ``tool``-role results: NOT dropped. Each is folded (head+tail preview, + see ``_truncate_tool_result``) into the *preceding* assistant turn as a + ``[tool result: ...]`` block, so the reference sees what came back. + + This emits ZERO ``tool``-role messages and ZERO ``tool_calls`` arrays — only + plain user/assistant text — so strict providers (Mistral, Fireworks) that + reject orphan tool messages / unproduced tool_calls don't 400, while the + reference still has the full picture. + + The view MUST end with a ``user`` turn. Anthropic (and OpenRouter→Anthropic) + interpret a trailing assistant turn as an assistant *prefill* to continue, + and no-prefill models (e.g. Claude Opus 4.8) reject it with + ``400 ... must end with a user message``. Rather than DELETE the agent's + latest context to satisfy that (which would blind the reference to the + current state), we APPEND a synthetic user turn asking the reference to + judge the state above. End-on-user is satisfied and no context is lost. + + The acting aggregator always receives the full, untrimmed transcript; this + function only shapes the disposable advisory copy. """ - trimmed: list[dict[str, Any]] = [] + advisory_instruction = ( + "[The conversation above is the current state of the task. Give your " + "most intelligent judgement: what is going on, what should happen next, " + "what risks or mistakes you see, and how the acting agent should " + "proceed.]" + ) + + rendered: list[dict[str, Any]] = [] + last_user_content: str | None = None for msg in messages: role = msg.get("role") - if role not in ("user", "assistant"): - # Drop system prompt and tool-result messages. - continue 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. + + if role == "system": 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. + if role == "user": + if text.strip(): + last_user_content = text + rendered.append({"role": "user", "content": text}) + elif role == "assistant": + parts: list[str] = [] + if text.strip(): + parts.append(text.strip()) + calls_text = _render_tool_calls(msg.get("tool_calls")) + if calls_text: + parts.append(calls_text) + # Empty assistant turns (no text, no calls) carry nothing advisory. + if parts: + rendered.append({"role": "assistant", "content": "\n".join(parts)}) + elif role == "tool": + # Fold the tool result into the preceding assistant turn as text so + # the reference sees what came back, without emitting a tool-role + # message a reference never produced. + result_text = _truncate_tool_result(text) + block = f"[tool result: {result_text}]" + if rendered and rendered[-1].get("role") == "assistant": + rendered[-1]["content"] = rendered[-1]["content"] + "\n" + block + else: + # No assistant turn to attach to (e.g. a leading tool result); + # keep it as advisory context on its own assistant-role line. + rendered.append({"role": "assistant", "content": block}) + # Any other role is ignored. + + # End on a user turn: append a synthetic advisory request rather than + # deleting the agent's latest assistant context. This satisfies Anthropic's + # no-trailing-assistant-prefill rule while preserving full state. + if rendered and rendered[-1].get("role") == "assistant": + rendered.append({"role": "user", "content": advisory_instruction}) + elif rendered and rendered[-1].get("role") == "user": + # Already ends on a user turn (fresh user prompt, no agent action yet). + # Leave it — the reference answers that prompt directly. + pass + + if not rendered: + # Degenerate case: nothing rendered. Fall back to the latest user turn. + if last_user_content is not None: + return [{"role": "user", "content": last_user_content}] for msg in reversed(messages): if msg.get("role") == "user" and isinstance(msg.get("content"), str): return [{"role": "user", "content": msg["content"]}] - return trimmed + return rendered @@ -155,8 +354,14 @@ def _extract_text(response: Any) -> str: except Exception: pass try: - content = response.choices[0].message.content - return (content or "").strip() + message = response.choices[0].message + if isinstance(message, dict): + content = message.get("content") + else: + content = getattr(message, "content", message) + if not isinstance(content, str): + content = str(content) if content else "" + return content.strip() except Exception: return "" @@ -169,12 +374,18 @@ def aggregate_moa_context( aggregator: dict[str, str], temperature: float = 0.6, aggregator_temperature: float = 0.4, - max_tokens: int = 4096, + max_tokens: int | None = None, ) -> str: """Run configured reference models and synthesize their advice. Failures are returned as model-specific notes instead of aborting the normal agent loop; the main model can still act with partial context. + + ``max_tokens`` is ``None`` by default: MoA does not cap reference or + aggregator output, so each model uses its own maximum. ``call_llm`` omits + the parameter entirely when it is ``None`` (see its docstring), which also + sidesteps providers that reject ``max_tokens`` outright. A hardcoded cap + here previously truncated long aggregator syntheses. """ reference_outputs: list[tuple[str, str]] = [] ref_messages = _reference_messages(api_messages) @@ -203,11 +414,10 @@ 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: @@ -230,8 +440,38 @@ def aggregate_moa_context( class MoAChatCompletions: """OpenAI-chat-compatible facade where the aggregator is the acting model.""" - def __init__(self, preset_name: str): + def __init__(self, preset_name: str, reference_callback: Any = None): self.preset_name = preset_name or "default" + # Optional display hook. Called as reference outputs become available so + # frontends can show each reference model's answer as a labelled block + # before the aggregator acts. Signature: + # reference_callback(event, **kwargs) + # where event is one of: + # "moa.reference" kwargs: index, count, label, text + # "moa.aggregating" kwargs: aggregator (label), ref_count + # Never raises into the model call — display is best-effort. + self.reference_callback = reference_callback + # State-scoped reference cache. The agent loop calls create() once per + # tool-loop iteration; references should re-run whenever the task STATE + # advances — i.e. on every new user message AND every new tool result — + # so each reference judges the latest state. The advisory view + # (_reference_messages) now renders tool calls + results as text, so its + # signature changes on every new tool response; the cache key is that + # signature, so a new tool result is a cache MISS (references re-run) + # while a redundant create() call with identical state is a HIT (no + # re-run, no re-emit). This gives "fire on every user/tool response" + # for free, without re-firing on a pure no-op re-call. + self._ref_cache_key: tuple | None = None + self._ref_cache_outputs: list[tuple[str, str]] = [] + + def _emit(self, event: str, **kwargs: Any) -> None: + cb = self.reference_callback + if cb is None: + return + try: + cb(event, **kwargs) + except Exception as exc: # pragma: no cover - display must never break the turn + logger.debug("MoA reference_callback failed for %s: %s", event, exc) def create(self, **api_kwargs: Any) -> Any: from hermes_cli.config import load_config @@ -241,7 +481,10 @@ def create(self, **api_kwargs: Any) -> Any: messages = list(api_kwargs.get("messages") or []) reference_models = preset.get("reference_models") or [] aggregator = preset.get("aggregator") or {} - max_tokens = int(preset.get("max_tokens", api_kwargs.get("max_tokens") or 4096) or 4096) + # MoA does not cap reference or aggregator output: each model uses its + # own maximum. Passing max_tokens=None makes call_llm omit the parameter + # (it never caps by default), so a long aggregator synthesis is never + # truncated and providers that reject max_tokens don't 400. temperature = float(preset.get("reference_temperature", 0.6) or 0.6) aggregator_temperature = float(preset.get("aggregator_temperature", api_kwargs.get("temperature") or 0.4) or 0.4) @@ -253,12 +496,52 @@ def create(self, **api_kwargs: Any) -> Any: reference_outputs: list[tuple[str, str]] = [] ref_messages = _reference_messages(messages) - reference_outputs = _run_references_parallel( - reference_models, - ref_messages, - temperature=temperature, - max_tokens=max_tokens, - ) + + # 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, + ) agg_messages = [dict(m) for m in messages] if reference_outputs: @@ -286,21 +569,26 @@ def create(self, **api_kwargs: Any) -> Any: raise RuntimeError("MoA aggregator cannot be another MoA preset") agg_kwargs = dict(api_kwargs) agg_kwargs["messages"] = agg_messages - agg_kwargs["model"] = aggregator.get("model") - agg_kwargs["temperature"] = aggregator_temperature + # 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. 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): + def __init__(self, preset_name: str, reference_callback: Any = None): self.chat = type("_MoAChat", (), {})() - self.chat.completions = MoAChatCompletions(preset_name) + self.chat.completions = MoAChatCompletions(preset_name, reference_callback=reference_callback) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 4493eae5f1f8..734febd3bf43 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -429,6 +429,10 @@ def _is_custom_endpoint(base_url: str) -> bool: "inference-api.nousresearch.com": "nous", "api.deepseek.com": "deepseek", "api.githubcopilot.com": "copilot", + # Enterprise Copilot endpoints look like api.enterprise.githubcopilot.com, + # api.business.githubcopilot.com, etc. Match the suffix so context-window + # resolution works for enterprise accounts too. + ".githubcopilot.com": "copilot", "models.github.ai": "copilot", # GitHub Models free tier (Azure-hosted prototyping endpoint) — same # canonical provider as the Copilot API. Hard per-request token cap @@ -478,6 +482,16 @@ 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 @@ -549,6 +563,7 @@ 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) @@ -556,7 +571,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"{server_url}/api/v1/models") + r = client.get(f"{lmstudio_url}/api/v1/models") if r.status_code == 200: return "lm-studio" except Exception: @@ -774,7 +789,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 = normalized[:-3].rstrip("/") if normalized.endswith("/v1") else normalized + server_url = _lmstudio_server_root(normalized) response = requests.get( server_url.rstrip("/") + "/api/v1/models", headers=headers, @@ -1064,10 +1079,29 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]: "maximum context length" in error_lower and "requested" in error_lower and "output tokens" in error_lower + ) or ( + # DashScope / Alibaba Cloud (Qwen) phrasing. The provider rejects an + # over-cap output request with a bounded range whose upper bound IS the + # real max-output cap, e.g. + # "Range of max_tokens should be [1, 65536]" + # The input itself fits — this is purely an output-cap error, so reduce + # max_tokens and retry; do NOT compress. + "range of max_tokens should be" in error_lower ) if not is_output_cap_error: return None + # DashScope / Alibaba range form: "Range of max_tokens should be [1, 65536]". + # The upper bound is the available output cap. + _m_range = re.search( + r'range of max_tokens should be\s*\[\s*\d+\s*,\s*(\d+)\s*\]', + error_lower, + ) + if _m_range: + _cap = int(_m_range.group(1)) + if _cap >= 1: + return _cap + # Extract the available_tokens figure. # Anthropic format: "… = available_tokens: 10000" patterns = [ @@ -1114,6 +1148,70 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]: return None +def is_output_cap_error(error_msg: str) -> bool: + """Return True if a 400 is about the OUTPUT cap (max_tokens) being too large. + + This is the broader sibling of :func:`parse_available_output_tokens_from_error`: + that function only returns a number when it can extract the available output + budget from a *known* provider phrasing. This one answers the cheaper + yes/no question — "is this an output-cap error at all?" — across providers + whose exact wording we may not yet parse a number from. + + Why this matters: an output-cap 400 is deterministic (every retry with the + same ``max_tokens`` gets the identical rejection). If such an error is + misclassified as a context-overflow it gets routed into the compression + loop, the compressor re-issues the call with the same oversized + ``max_tokens``, the provider rejects it identically, and the session + death-loops until "cannot compress further" (issue #55546, DashScope/Qwen: + "Range of max_tokens should be [1, 65536]"). Compression cannot help an + output-cap error — the input already fits. + + The signal: the error talks about ``max_tokens`` (or its aliases) as a + cap/range/limit, and does NOT talk about the INPUT/prompt/context window + being too long. When both are present we defer to the context-overflow + path (a real input overflow can also mention max_tokens). + """ + error_lower = error_msg.lower() + + mentions_output_param = ( + "max_tokens" in error_lower + or "max_output_tokens" in error_lower + or "max_completion_tokens" in error_lower + ) + if not mentions_output_param: + return False + + # Phrasing that signals the OUTPUT cap specifically is the problem. + output_cap_signal = ( + "range of max_tokens should be" in error_lower # DashScope / Alibaba + or "available_tokens" in error_lower # Anthropic + or "available tokens" in error_lower + or ("in the output" in error_lower # OpenRouter / Nous + and "maximum context length" in error_lower) + or ("requested" in error_lower # LM Studio / llama.cpp + and "output tokens" in error_lower) + or "should be" in error_lower # generic "max_tokens should be <= N" + or "less than or equal" in error_lower + or "must be" in error_lower + ) + if not output_cap_signal: + return False + + # If the error ALSO clearly describes an oversized INPUT, it is a genuine + # context overflow that happens to mention max_tokens — let the + # context-overflow path handle it (it can compress the input). + input_overflow_signal = ( + "prompt is too long" in error_lower + or "prompt too long" in error_lower + or "input is too long" in error_lower + or "input token" in error_lower + or "prompt length" in error_lower + or "prompt contains" in error_lower + or "reduce the length" in error_lower + ) + return not input_overflow_signal + + def _model_id_matches(candidate_id: str, lookup_model: str) -> bool: """Return True if *candidate_id* (from server) matches *lookup_model* (configured). @@ -1188,6 +1286,56 @@ 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. @@ -1297,6 +1445,7 @@ 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) @@ -1340,7 +1489,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"{server_url}/api/v1/models") + resp = client.get(f"{lmstudio_url}/api/v1/models") if resp.status_code == 200: data = resp.json() for m in data.get("models", []): @@ -1646,6 +1795,34 @@ 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. diff --git a/agent/process_bootstrap.py b/agent/process_bootstrap.py index fdd9053f5d8f..ce238a9d405e 100644 --- a/agent/process_bootstrap.py +++ b/agent/process_bootstrap.py @@ -26,7 +26,7 @@ import os import sys import urllib.request -from typing import Optional +from typing import Any, Optional from utils import base_url_hostname, normalize_proxy_url @@ -142,6 +142,46 @@ 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"): @@ -164,4 +204,5 @@ def _install_safe_stdio() -> None: "_install_safe_stdio", "_get_proxy_from_env", "_get_proxy_for_base_url", + "build_keepalive_http_client", ] diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 1a87e66cde4c..3ec4a40b3929 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -88,12 +88,15 @@ def _find_hermes_md(cwd: Path) -> Optional[Path]: stop_at = _find_git_root(cwd) current = cwd.resolve() - for directory in [current, *current.parents]: + # 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 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 @@ -617,7 +620,12 @@ def format_steer_marker(steer_text: str) -> str: PLATFORM_HINTS = { "whatsapp": ( "You are on a text messaging communication platform, WhatsApp. " - "Please do not use markdown as it does not render. " + "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. " "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, " @@ -682,7 +690,11 @@ def format_steer_marker(steer_text: str) -> str: ), "signal": ( "You are on a text messaging communication platform, Signal. " - "Please do not use markdown as it does not render. " + "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. " "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 " @@ -917,8 +929,7 @@ 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 _get_env_config # type: ignore - from tools.environments import get_environment # type: ignore + from tools.terminal_tool import _create_environment, _get_env_config # type: ignore except Exception as e: logger.debug("Backend probe unavailable (import failed): %s", e) _BACKEND_PROBE_CACHE[cache_key] = "" @@ -926,7 +937,59 @@ def _probe_remote_backend(env_type: str) -> str | None: try: config = _get_env_config() - env = get_environment(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"), + ) # 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 = ( diff --git a/agent/redact.py b/agent/redact.py index 06a7300a307d..307e5dc3adfe 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -10,6 +10,7 @@ import logging import os import re +import shlex logger = logging.getLogger(__name__) @@ -105,14 +106,63 @@ r"brv_[A-Za-z0-9]{10,}", # ByteRover API key r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token + r"fw_[A-Za-z0-9]{30,}", # Fireworks AI API key ] -# ENV assignment patterns: KEY=value where KEY contains a secret-like name +# 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. _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( @@ -125,8 +175,15 @@ # while the header name and scheme word are preserved for debuggability. The # previous rule only matched ``Bearer``, so ``Basic `` and # ``token `` 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, ) @@ -154,9 +211,37 @@ ) # Database connection strings: protocol://user:PASSWORD@host -# Catches postgres, mysql, mongodb, redis, amqp URLs and redacts the password +# 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. _DB_CONNSTR_RE = re.compile( - r"((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:]+:)([^@]+)(@)", + 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... re.IGNORECASE, ) @@ -340,7 +425,40 @@ def _redact_form_body(text: str) -> str: return _redact_query_string(text.strip()) -def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = False) -> str: +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: """Apply all redaction patterns to a block of text. Safe to call on any string -- non-matching text passes through unchanged. @@ -353,6 +471,17 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F 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 @@ -371,9 +500,15 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F 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): - text = _PREFIX_RE.sub(lambda m: _mask_token(m.group(1)), text) + _prefix_sub = _mask_token_nonreusable if file_read else _mask_token + text = _PREFIX_RE.sub(lambda m: _prefix_sub(m.group(1)), text) # ENV assignments: OPENAI_API_KEY=*** (skip for code files — false positives) if not code_file: @@ -382,6 +517,13 @@ def _redact_env(m): 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: @@ -390,6 +532,15 @@ def _redact_json(m): 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(). @@ -419,9 +570,32 @@ def _redact_telegram(m): if "BEGIN" in text and "-----" in text: text = _PRIVATE_KEY_RE.sub("[REDACTED PRIVATE KEY]", text) - # Database connection string passwords + # 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. if "://" in text: - text = _DB_CONNSTR_RE.sub(lambda m: f"{m.group(1)}***{m.group(3)}", 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, + ) # JWT tokens (eyJ... — base64-encoded JSON headers) if "eyJ" in text: @@ -434,7 +608,12 @@ def _redact_telegram(m): # 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. + # 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. # Form-urlencoded bodies (only triggers on clean k=v&k=v inputs). if "&" in text and "=" in text: @@ -452,6 +631,66 @@ def _redact_phone(m): 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 diff --git a/agent/replay_cleanup.py b/agent/replay_cleanup.py new file mode 100644 index 000000000000..12de7a5c7e9e --- /dev/null +++ b/agent/replay_cleanup.py @@ -0,0 +1,140 @@ +"""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)) diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 97ba3862120b..3f155f20465c 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -122,6 +122,8 @@ 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 @@ -441,6 +443,7 @@ 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, @@ -449,6 +452,7 @@ 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 @@ -584,6 +588,17 @@ 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} diff --git a/agent/skill_preprocessing.py b/agent/skill_preprocessing.py index a7f526b25e7c..bd0386d58058 100644 --- a/agent/skill_preprocessing.py +++ b/agent/skill_preprocessing.py @@ -5,6 +5,8 @@ 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. @@ -66,6 +68,7 @@ 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], @@ -75,6 +78,7 @@ 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}]" diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 6845f79195e0..167a60946b2c 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -24,6 +24,7 @@ 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, @@ -1224,7 +1225,7 @@ def _execute(next_args: dict) -> Any: 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_preview(function_name, display_args) or function_name + preview = _build_tool_label(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 @@ -1258,7 +1259,7 @@ def _execute(next_args: dict) -> Any: 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_preview(function_name, display_args) or function_name + preview = _build_tool_label(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 @@ -1290,7 +1291,7 @@ def _execute(next_args: dict) -> Any: 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_preview(function_name, display_args) or function_name + preview = _build_tool_label(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 diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 42e81dc30e7c..878045da66d4 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -619,7 +619,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: tc_provider_data: dict[str, Any] = {} extra = getattr(tc, "extra_content", None) if extra is None and hasattr(tc, "model_extra"): - extra = (tc.model_extra or {}).get("extra_content") + extra = (tc.model_extra if isinstance(tc.model_extra, dict) else {}).get("extra_content") if extra is not None: if hasattr(extra, "model_dump"): try: diff --git a/agent/turn_context.py b/agent/turn_context.py index 6efa22a68ca6..88980b4ad276 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -28,6 +28,7 @@ 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, @@ -222,6 +223,9 @@ def build_turn_context( agent._unicode_sanitization_passes = 0 agent._tool_guardrails.reset_for_turn() agent._tool_guardrail_halt_decision = None + _reset_consol = getattr(agent._memory_store, "reset_consolidation_failures", None) + if callable(_reset_consol): + _reset_consol() agent._vision_supported = True # Pre-turn connection health check: clean up dead TCP connections. @@ -359,6 +363,12 @@ def build_turn_context( if _last >= 0 and _preflight_tokens > _last: _compressor.last_prompt_tokens = _preflight_tokens + _compression_cooldown = getattr( + _compressor, + "get_active_compression_failure_cooldown", + lambda: None, + )() + if _preflight_deferred: logger.info( "Skipping preflight compression: rough estimate ~%s >= %s, " @@ -367,6 +377,13 @@ def build_turn_context( f"{_compressor.threshold_tokens:,}", f"{_compressor.last_real_prompt_tokens:,}", ) + elif _compression_cooldown: + logger.info( + "Skipping preflight compression: same-session cooldown active " + "(~%s seconds remaining, session %s)", + int(_compression_cooldown.get("remaining_seconds", 0.0)), + agent.session_id or "none", + ) elif _compressor.should_compress(_preflight_tokens): logger.info( "Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)", @@ -400,7 +417,9 @@ def build_turn_context( _orig_len, len(messages), _orig_tokens, _preflight_tokens ): break # Cannot compress further: neither rows nor tokens moved - conversation_history = None + conversation_history = conversation_history_after_compression( + agent, messages + ) agent._empty_content_retries = 0 agent._thinking_prefill_retries = 0 agent._last_content_with_tools = None @@ -440,6 +459,7 @@ 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. diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index c51c18f93cc2..f09cc26c07f8 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -289,7 +289,14 @@ def finalize_turn( and len(_stripped) <= 24 and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"} ) - if _is_empty_terminal or _is_partial_fragment: + _is_partial_stream_recovery = ( + str(_turn_exit_reason) == "partial_stream_recovery" + ) + if ( + _is_empty_terminal + or _is_partial_fragment + or _is_partial_stream_recovery + ): _explanation = agent._format_turn_completion_explanation( _turn_exit_reason ) diff --git a/agent/turn_retry_state.py b/agent/turn_retry_state.py index 34183bd06be5..2298e14c24c4 100644 --- a/agent/turn_retry_state.py +++ b/agent/turn_retry_state.py @@ -67,6 +67,11 @@ 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. diff --git a/agent/verification_stop.py b/agent/verification_stop.py index e19cb22bc4eb..605d58d3a7de 100644 --- a/agent/verification_stop.py +++ b/agent/verification_stop.py @@ -15,9 +15,135 @@ _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.""" + """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 + ``"auto"`` (see ``DEFAULT_CONFIG``) — surface-aware: ON for interactive + coding surfaces (CLI, TUI, desktop) and programmatic callers, OFF for + conversational messaging surfaces (Telegram, Discord, etc.) where the + verification narrative would reach a human as chat noise. An explicit + bool forces the behavior in either direction. A missing or unrecognized + value falls back to the surface-aware ``"auto"`` default. + """ env = os.environ.get("HERMES_VERIFY_ON_STOP") if env is not None: return env.strip().lower() not in {"0", "false", "no", "off"} @@ -29,9 +155,19 @@ 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 - if isinstance(agent_cfg, dict) and "verify_on_stop" in agent_cfg: - return bool(agent_cfg.get("verify_on_stop")) - return True + 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": + return not _session_is_messaging_surface() + # Missing or unrecognized value -> surface-aware "auto" default. + return not _session_is_messaging_surface() def _candidate_cwds(paths: Iterable[str]) -> list[Path]: @@ -114,7 +250,10 @@ def build_verify_on_stop_nudge( max_attempts: int = 2, ) -> str | None: """Return a synthetic follow-up when edited code lacks fresh verification.""" - paths = sorted({str(p) for p in changed_paths if p}) + # 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)}) if not paths or attempts >= max_attempts: return None @@ -133,6 +272,15 @@ 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 (" @@ -157,7 +305,8 @@ 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.]" + "concrete blocker instead of claiming the work is fully verified." + f"{addendum}]" ) diff --git a/agent/verify_hooks.py b/agent/verify_hooks.py new file mode 100644 index 000000000000..e051080202c8 --- /dev/null +++ b/agent/verify_hooks.py @@ -0,0 +1,69 @@ +"""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", +] diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 8a6d3efe9bf2..3182b3ac2389 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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 `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`. +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`. ### Verification diff --git a/apps/desktop/electron/backend-command.cjs b/apps/desktop/electron/backend-command.cjs new file mode 100644 index 000000000000..9ada2cdf034c --- /dev/null +++ b/apps/desktop/electron/backend-command.cjs @@ -0,0 +1,51 @@ +'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 `). 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, +} diff --git a/apps/desktop/electron/backend-command.test.cjs b/apps/desktop/electron/backend-command.test.cjs new file mode 100644 index 000000000000..d483ad2fa5c1 --- /dev/null +++ b/apps/desktop/electron/backend-command.test.cjs @@ -0,0 +1,83 @@ +'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) +}) diff --git a/apps/desktop/electron/backend-probes.cjs b/apps/desktop/electron/backend-probes.cjs index 0e0e5848481a..b69bd3f08585 100644 --- a/apps/desktop/electron/backend-probes.cjs +++ b/apps/desktop/electron/backend-probes.cjs @@ -37,7 +37,18 @@ const { execFileSync } = require('node:child_process') const PROBE_TIMEOUT_MS = 5000 /** - * Return true iff `python -c "import hermes_cli"` exits 0. + * 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. * * Used to gate the "fallback to system Python with hermes_cli installed" * rung of resolveHermesBackend. Without this, a system Python 3.11-3.13 @@ -46,13 +57,20 @@ const PROBE_TIMEOUT_MS = 5000 * 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) { +function canImportHermesCli(pythonPath, opts = {}) { if (!pythonPath) return false try { - execFileSync(pythonPath, ['-c', 'import hermes_cli'], { + execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], { + env: { ...process.env, ...(opts.env || {}) }, stdio: 'ignore', timeout: PROBE_TIMEOUT_MS, windowsHide: true @@ -101,6 +119,7 @@ function verifyHermesCli(hermesCommand, opts = {}) { module.exports = { canImportHermesCli, + hermesRuntimeImportProbe, verifyHermesCli, PROBE_TIMEOUT_MS } diff --git a/apps/desktop/electron/backend-probes.test.cjs b/apps/desktop/electron/backend-probes.test.cjs index 13d30a286c12..93158a32ce8f 100644 --- a/apps/desktop/electron/backend-probes.test.cjs +++ b/apps/desktop/electron/backend-probes.test.cjs @@ -11,7 +11,7 @@ const fs = require('node:fs') const os = require('node:os') const path = require('node:path') -const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs') +const { canImportHermesCli, hermesRuntimeImportProbe, 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,6 +40,12 @@ 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) diff --git a/apps/desktop/electron/embed-referer.cjs b/apps/desktop/electron/embed-referer.cjs new file mode 100644 index 000000000000..2825eda40e70 --- /dev/null +++ b/apps/desktop/electron/embed-referer.cjs @@ -0,0 +1,48 @@ +'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 } diff --git a/apps/desktop/electron/git-review-ops.cjs b/apps/desktop/electron/git-review-ops.cjs index 28f5fc7f955e..a7df19880ebf 100644 --- a/apps/desktop/electron/git-review-ops.cjs +++ b/apps/desktop/electron/git-review-ops.cjs @@ -10,7 +10,26 @@ const { execFile } = require('node:child_process') const fs = require('node:fs/promises') const path = require('node:path') -const simpleGit = require('simple-git') +// `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 { resolveRequestedPathForIpc } = require('./hardening.cjs') diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index d7184bc97435..a6b70872632d 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -24,6 +24,7 @@ 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 { @@ -38,6 +39,7 @@ 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') @@ -533,9 +535,10 @@ function getTitleBarOverlayOptions() { return { height: TITLEBAR_HEIGHT } } - // Windows + WSLg paint WCO natively; plain Linux disables it (frameless hidden - // titlebar still applies). - if (!IS_WINDOWS && !IS_WSL) { + // 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) { return false } @@ -789,7 +792,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 dashboard children in a tight loop while boot is broken. +// respawning hermes serve backend 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. @@ -1283,8 +1286,14 @@ 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) { @@ -1301,7 +1310,7 @@ function isCommandScript(command) { return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '') } -function unwrapWindowsVenvHermesCommand(command, dashboardArgs) { +function unwrapWindowsVenvHermesCommand(command, backendArgs) { if (!IS_WINDOWS || !command || isCommandScript(command)) return null const resolved = path.resolve(String(command)) @@ -1311,14 +1320,14 @@ function unwrapWindowsVenvHermesCommand(command, dashboardArgs) { if (path.basename(scriptsDir).toLowerCase() !== 'scripts') return null const venvRoot = path.dirname(scriptsDir) - const python = getNoConsoleVenvPython(venvRoot) + const python = getVenvPython(venvRoot) if (!fileExists(python)) return null const root = path.dirname(venvRoot) return { - label: `existing Hermes no-console Python at ${python}`, + label: `existing Hermes Python at ${python}`, command: python, - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, @@ -1326,11 +1335,72 @@ function unwrapWindowsVenvHermesCommand(command, dashboardArgs) { venvRoot }), kind: 'python', - readyFile: true, + // Surfaced so backendSupportsServe() can read this runtime's source for the + // `serve` capability check instead of falling back to a heavyweight probe. + root, 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 @@ -1551,64 +1621,26 @@ function getVenvPython(venvRoot) { return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python')) } -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 -} +// 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 getVenvSitePackagesEntries(venvRoot) { const entries = [] @@ -1963,6 +1995,16 @@ 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 @@ -2218,6 +2260,7 @@ 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) @@ -2241,7 +2284,18 @@ 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 updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] + 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] await releaseBackendLockForUpdate(updateRoot) @@ -2264,6 +2318,7 @@ 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) @@ -2343,14 +2398,14 @@ async function applyUpdatesPosixInApp() { PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) } - // `hermes update` reaps stale `hermes dashboard` backends (a code update + // `hermes update` reaps stale `hermes serve` 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 dashboards. (#37532) + // it while still reaping any genuinely-orphaned backends. (#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). @@ -2471,6 +2526,7 @@ 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) { @@ -2576,6 +2632,7 @@ 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 } } @@ -2606,6 +2663,24 @@ 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 @@ -2618,7 +2693,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 isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT)) + return isActiveRuntimeUsable() } function writeBootstrapMarker(payload) { @@ -2781,60 +2856,60 @@ function writeDefaultProjectDir(dir) { } } -function createPythonBackend(root, label, dashboardArgs, options = {}) { +function createPythonBackend(root, label, backendArgs, 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) ? getNoConsoleVenvPython(venvRoot) : toNoConsolePython(python) + const command = IS_WINDOWS && fileExists(venvPython) ? venvPython : python - return applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label, command, - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + args: ['-m', 'hermes_cli.main', ...backendArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, - pythonPathEntries: [root], + pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)], 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(dashboardArgs) { +function createActiveBackend(backendArgs) { const venvPython = getVenvPython(VENV_ROOT) - const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython()) + const command = fileExists(venvPython) ? venvPython : findSystemPython() - return applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label: `Hermes at ${ACTIVE_HERMES_ROOT}`, command, - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + args: ['-m', 'hermes_cli.main', ...backendArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, - pythonPathEntries: [ACTIVE_HERMES_ROOT], + pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)], venvRoot: VENV_ROOT }), root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false - }) + } } -function resolveHermesBackend(dashboardArgs) { +function resolveHermesBackend(backendArgs) { // 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}`, dashboardArgs) + const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, backendArgs) if (backend) return backend } @@ -2843,7 +2918,7 @@ function resolveHermesBackend(dashboardArgs) { // 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}`, dashboardArgs) + const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, backendArgs) if (backend) return backend } @@ -2854,7 +2929,7 @@ function resolveHermesBackend(dashboardArgs) { // to spawning hermes. Updates flow through the in-app update path // (applyUpdates -> git pull) or `hermes update` from the CLI. if (isBootstrapComplete()) { - return createActiveBackend(dashboardArgs) + return createActiveBackend(backendArgs) } // 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from @@ -2887,7 +2962,7 @@ function resolveHermesBackend(dashboardArgs) { } if (hermesCommand) { - const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) + const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) if (unwrapped) { return unwrapped } @@ -2902,10 +2977,10 @@ function resolveHermesBackend(dashboardArgs) { const shellForProbe = isCommandScript(hermesCommand) if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) { return ( - unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || { + unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || { label: `existing Hermes CLI at ${hermesCommand}`, command: hermesCommand, - args: dashboardArgs, + args: backendArgs, bootstrap: false, env: {}, kind: 'command', @@ -2933,15 +3008,15 @@ function resolveHermesBackend(dashboardArgs) { // 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 applyWindowsNoConsoleSpawnHints({ + return { kind: 'python', label: `installed hermes_cli module via ${python}`, - command: toNoConsolePython(python), - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + command: python, + args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, env: {}, shell: false - }) + } } rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } @@ -2960,7 +3035,7 @@ function resolveHermesBackend(dashboardArgs) { kind: 'bootstrap-needed', label: 'Hermes Agent not installed yet; bootstrap required', command: null, - args: dashboardArgs, + args: backendArgs, bootstrap: true, env: {}, shell: false, @@ -2975,7 +3050,7 @@ function resolveHermesBackend(dashboardArgs) { async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) - return applyWindowsNoConsoleSpawnHints(backend) + return backend } // backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't @@ -3117,7 +3192,7 @@ async function ensureRuntime(backend) { ) } - backend.command = getNoConsoleVenvPython(VENV_ROOT) + backend.command = getVenvPython(VENV_ROOT) backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})` updateBootProgress({ phase: 'runtime.ready', @@ -3126,7 +3201,7 @@ async function ensureRuntime(backend) { running: true, error: null }) - return applyWindowsNoConsoleSpawnHints(backend) + return backend } function fetchJson(url, token, options = {}) { @@ -3787,7 +3862,7 @@ function getWindowButtonPosition() { } function getNativeOverlayWidth() { - return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL }) + return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL, isMac: IS_MAC }) } function getWindowState() { @@ -5033,13 +5108,24 @@ function resetBootProgressForReconnect() { ) } +function stopBackendChild(child) { + if (!child || child.killed) return + try { + if (IS_WINDOWS && Number.isInteger(child.pid)) { + forceKillProcessTree(child.pid) + } else { + child.kill('SIGTERM') + } + } catch { + // Already gone. + } +} + function resetHermesConnection() { connectionPromise = null backendStartFailure = null - if (hermesProcess && !hermesProcess.killed) { - hermesProcess.kill('SIGTERM') - } + stopBackendChild(hermesProcess) hermesProcess = null resetBootProgressForReconnect() @@ -5193,8 +5279,10 @@ 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 dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0'] - const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) + 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 hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null @@ -5285,13 +5373,7 @@ function stopPoolBackend(profile) { const entry = backendPool.get(profile) if (!entry) return backendPool.delete(profile) - if (entry.process && !entry.process.killed) { - try { - entry.process.kill('SIGTERM') - } catch { - // Already gone. - } - } + stopBackendChild(entry.process) } async function teardownPoolBackendAndWait(profile) { @@ -5299,13 +5381,7 @@ async function teardownPoolBackendAndWait(profile) { if (!entry) return backendPool.delete(profile) - if (entry.process && !entry.process.killed) { - try { - entry.process.kill('SIGTERM') - } catch { - // Already gone. - } - } + stopBackendChild(entry.process) await waitForBackendExit(entry.process) } @@ -5410,7 +5486,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 dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0'] + const backendArgs = ['serve', '--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 ` does on the CLI. An @@ -5418,10 +5494,12 @@ async function startHermes() { // unaffected. const activeProfile = readActiveDesktopProfile() if (activeProfile) { - dashboardArgs.unshift('--profile', activeProfile) + backendArgs.unshift('--profile', activeProfile) } await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) - const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) + const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) + // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. + backend.args = getBackendArgsForRuntime(backend) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null @@ -7322,6 +7400,7 @@ 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 } } @@ -7447,6 +7526,7 @@ app.whenReady().then(() => { } installMediaPermissions() registerMediaProtocol() + installEmbedReferer() registerDeepLinkProtocol() ensureWslWindowsFonts() configureSpellChecker() @@ -7519,12 +7599,16 @@ app.on('before-quit', () => { disposeTerminalSession(id) } - if (hermesProcess && !hermesProcess.killed) { - hermesProcess.kill('SIGTERM') - } + stopBackendChild(hermesProcess) stopAllPoolBackends() }) app.on('window-all-closed', () => { - if (process.platform !== 'darwin') app.quit() + // 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() }) diff --git a/apps/desktop/electron/titlebar-overlay-width.cjs b/apps/desktop/electron/titlebar-overlay-width.cjs index 27a62f1a9f3d..7c1e4ca09f06 100644 --- a/apps/desktop/electron/titlebar-overlay-width.cjs +++ b/apps/desktop/electron/titlebar-overlay-width.cjs @@ -1,11 +1,24 @@ -// Pre-layout fallback for WCO right-edge reservation (--titlebar-tools-right). -// Live width comes from navigator.windowControlsOverlay in the renderer. +'use strict' const OVERLAY_FALLBACK_WIDTH = 144 -/** @param {{ isWindows?: boolean, isWsl?: boolean }} opts */ -function nativeOverlayWidth({ isWindows = false, isWsl = false } = {}) { - return isWindows || isWsl ? OVERLAY_FALLBACK_WIDTH : 0 +/** + * 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 } module.exports = { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth } diff --git a/apps/desktop/electron/titlebar-overlay-width.test.cjs b/apps/desktop/electron/titlebar-overlay-width.test.cjs index a7b8b6f52151..b4d58c44b50d 100644 --- a/apps/desktop/electron/titlebar-overlay-width.test.cjs +++ b/apps/desktop/electron/titlebar-overlay-width.test.cjs @@ -18,10 +18,17 @@ test('WSLg paints the same WCO, so it reserves the same fallback width', () => { assert.equal(nativeOverlayWidth({ isWsl: true }), OVERLAY_FALLBACK_WIDTH) }) -test('plain Linux and macOS reserve nothing', () => { - assert.equal(nativeOverlayWidth({ isWindows: false, isWsl: false }), 0) - assert.equal(nativeOverlayWidth(), 0) - assert.equal(nativeOverlayWidth({}), 0) +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('the fallback width is a sane positive pixel value', () => { diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs index 0194464d641d..c15dc3b7b505 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/apps/desktop/electron/windows-child-process.test.cjs @@ -38,19 +38,63 @@ 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, 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 unwrapWindowsVenvHermesCommand\(command, backendArgs\)/) assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/) assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/) - assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/) + 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/) +}) + +test('desktop backend teardown tree-kills Windows backend descendants', () => { + const source = readElectronFile('main.cjs') + + const helperIndex = source.indexOf('function stopBackendChild(child)') + assert.notEqual(helperIndex, -1, 'missing backend teardown helper') + const helperSnippet = source.slice(helperIndex, helperIndex + 500) + assert.match(helperSnippet, /IS_WINDOWS && Number\.isInteger\(child\.pid\)/) + assert.match(helperSnippet, /forceKillProcessTree\(child\.pid\)/) + assert.match(helperSnippet, /child\.kill\('SIGTERM'\)/) + + const resetIndex = source.indexOf('function resetHermesConnection()') + assert.notEqual(resetIndex, -1, 'missing resetHermesConnection') + const resetSnippet = source.slice(resetIndex, resetIndex + 300) + assert.match(resetSnippet, /stopBackendChild\(hermesProcess\)/) + assert.doesNotMatch(resetSnippet, /hermesProcess\.kill\('SIGTERM'\)/) + + const quitIndex = source.indexOf("app.on('before-quit'") + assert.notEqual(quitIndex, -1, 'missing before-quit handler') + const quitSnippet = source.slice(quitIndex, quitIndex + 900) + assert.match(quitSnippet, /stopBackendChild\(hermesProcess\)/) + assert.doesNotMatch(quitSnippet, /hermesProcess\.kill\('SIGTERM'\)/) }) test('intentional or interactive desktop child processes stay documented', () => { @@ -68,5 +112,5 @@ test('bootstrap PowerShell runner hides Windows console children', () => { const source = readElectronFile('bootstrap-runner.cjs') assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/) - requireHiddenChildOptions(source, 'spawn(ps, fullArgs') + requireHiddenChildOptions(source, /spawn\(\s*ps,\s*fullArgs/) }) diff --git a/apps/desktop/electron/windows-hermes-resolution.test.cjs b/apps/desktop/electron/windows-hermes-resolution.test.cjs new file mode 100644 index 000000000000..ada41ce2905a --- /dev/null +++ b/apps/desktop/electron/windows-hermes-resolution.test.cjs @@ -0,0 +1,67 @@ +'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' + ) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 56a7374eeef7..6fd35c0fbc47 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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 && node scripts/bundle-electron-main.mjs && 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 && 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-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", + "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 electron/windows-hermes-resolution.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", @@ -73,6 +73,7 @@ "@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", @@ -80,12 +81,16 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-force": "^3.0.0", "dnd-core": "^14.0.1", + "dompurify": "^3.4.11", + "fflate": "^0.8.3", "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", @@ -115,6 +120,7 @@ "@eslint/js": "^9.39.4", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", + "@types/d3-force": "^3.0.10", "@types/hast": "^3.0.4", "@types/node": "^24.13.2", "@types/react": "^19.2.14", diff --git a/apps/desktop/scripts/.gitignore b/apps/desktop/scripts/.gitignore new file mode 100644 index 000000000000..646f02ffc794 --- /dev/null +++ b/apps/desktop/scripts/.gitignore @@ -0,0 +1 @@ +share-codes.txt diff --git a/apps/desktop/scripts/bundle-electron-main.mjs b/apps/desktop/scripts/bundle-electron-main.mjs deleted file mode 100644 index bb5b0ad061b9..000000000000 --- a/apps/desktop/scripts/bundle-electron-main.mjs +++ /dev/null @@ -1,33 +0,0 @@ -#!/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}`) diff --git a/apps/desktop/scripts/gen-share-codes.ts b/apps/desktop/scripts/gen-share-codes.ts new file mode 100644 index 000000000000..8de54582461f --- /dev/null +++ b/apps/desktop/scripts/gen-share-codes.ts @@ -0,0 +1,171 @@ +// Throwaway generator: deterministic fake star-map graphs → real share codes +// (runs the actual encoder, so every string round-trips). Run with `npx tsx`. +import { writeFileSync } from 'node:fs' + +import type { StarmapEdge, StarmapGraph, StarmapMemoryCard, StarmapNode } from '../src/types/hermes' + +import { decodeShareCode, encodeShareCode } from '../src/app/starmap/share-code' + +const DAY = 86_400 +const END = Math.floor(Date.UTC(2026, 5, 29) / 1000) + +// mulberry32 — tiny seeded PRNG so the output is byte-stable across runs. +const rng = (seed: number) => () => { + seed |= 0 + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296 +} + +const pick = (arr: readonly T[], r: number): T => arr[Math.floor(r * arr.length)]! + +const CATEGORIES = ['devops', 'research', 'creative', 'security', 'mlops', 'blockchain', 'email', 'health', 'web-development', 'comms'] as const +const STATES = ['active', 'active', 'active', 'archived', 'draft', 'disabled'] as const +const CREATED = [null, 'agent', 'agent', 'user'] as const + +const skill = (id: string, label: string, ts: number, r: () => number): StarmapNode => ({ + category: pick(CATEGORIES, r()), + createdBy: pick(CREATED, r()), + id, + kind: 'skill', + label, + pinned: r() > 0.85, + state: pick(STATES, r()), + timestamp: ts, + useCount: Math.floor(r() ** 3 * 120) +}) + +const memNode = (i: number, source: 'memory' | 'profile', label: string, ts: null | number): StarmapNode => ({ + category: 'memory', + createdBy: 'memory', + id: `memory:${source}:${i}`, + kind: 'memory', + label, + memorySource: source, + pinned: false, + state: 'active', + timestamp: ts, + useCount: 0 +}) + +const card = (source: 'memory' | 'profile', title: string, body: string, ts: null | number): StarmapMemoryCard => ({ body, source, timestamp: ts, title }) + +// ── 1. Tiny + quirky ────────────────────────────────────────────────────────── +function tiny(): StarmapGraph { + const r = rng(7) + const nodes: StarmapNode[] = [ + skill('summon-coffee', 'Summon Coffee', END - 40 * DAY, r), + skill('rubber-duck', 'Rubber-Duck Debugging', END - 22 * DAY, r), + skill('git-blame-zen', 'Git Blame Without Rage', END - 9 * DAY, r), + memNode(0, 'profile', 'Prefers tabs, dies on this hill', END - 30 * DAY), + memNode(1, 'memory', 'The prod incident of last Tuesday', END - 3 * DAY) + ] + const edges: StarmapEdge[] = [ + { source: 'memory:memory:1', target: 'git-blame-zen' }, + { source: 'rubber-duck', target: 'git-blame-zen' } + ] + const memory = [ + card('profile', 'Prefers tabs, dies on this hill', 'Tabs over spaces. Non-negotiable.', END - 30 * DAY), + card('memory', 'The prod incident of last Tuesday', 'Never deploy on a Friday again.', END - 3 * DAY) + ] + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +// ── 2. Mid-size, mixed signal ──────────────────────────────────────────────── +function mid(): StarmapGraph { + const r = rng(42) + const names = ['Kubernetes Whispering', 'Prompt Surgery', 'Threat Modeling', 'Pixel Pushing', 'Vector Janitor', 'Smart-Contract Audit', 'Inbox Zero Ops', 'Sleep Debt Tracker', 'SSR Hydration', 'Standup Telepathy', 'Flaky-Test Exorcism', 'Cost Spelunking'] + const nodes: StarmapNode[] = names.map((label, i) => skill(`s${i}`, label, END - Math.floor(r() * 200) * DAY, r)) + const memTitles = ['Hates meetings before noon', 'Lives in us-east-1', 'Allergic to YAML', 'Caffeine half-life ~5h', 'Reviews in dark mode'] + + memTitles.forEach((title, i) => { + const ts = END - Math.floor(r() * 120) * DAY + nodes.push(memNode(i, i % 2 ? 'memory' : 'profile', title, ts)) + }) + + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 9; i += 1) { + edges.push({ source: `s${Math.floor(r() * names.length)}`, target: `s${Math.floor(r() * names.length)}` }) + } + + const memory = memTitles.map((title, i) => card(i % 2 ? 'memory' : 'profile', title, `${title}. Logged automatically.`, END - Math.floor(rng(99 + i)() * 120) * DAY)) + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +// ── 3. Dense web, partly undated (ordinal fallback) ────────────────────────── +function web(): StarmapGraph { + const r = rng(1337) + const nodes: StarmapNode[] = Array.from({ length: 22 }, (_, i) => + // Half the skills carry no timestamp → exercises the ordinal recency path. + skill(`w${i}`, `Neuron ${String.fromCharCode(65 + (i % 26))}${i}`, i % 2 ? END - Math.floor(r() * 300) * DAY : (null as unknown as number), r) + ) + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 44; i += 1) { + edges.push({ source: `w${Math.floor(r() * 22)}`, target: `w${Math.floor(r() * 22)}` }) + } + + return { clusters: [], edges, memory: [], nodes, stats: {} } +} + +// ── 4. The beast: ~2 years, hundreds of nodes, bursty timeline ─────────────── +function beast(): StarmapGraph { + const r = rng(2024) + const start = END - 730 * DAY + const span = END - start + const nodes: StarmapNode[] = [] + const memory: StarmapMemoryCard[] = [] + + // Bursts → an interesting waveform instead of a flat smear. + const burstAt = (q: number) => Math.floor(start + (q + (r() - 0.5) * 0.06) * span) + + for (let i = 0; i < 240; i += 1) { + const burst = Math.floor(r() ** 1.5 * 12) / 12 // cluster toward the recent end + nodes.push(skill(`b${i}`, `Skill ${i} · ${pick(CATEGORIES, r())}`, burstAt(burst), r)) + } + + for (let i = 0; i < 150; i += 1) { + const ts = burstAt(Math.floor(r() ** 1.5 * 12) / 12) + const source = r() > 0.5 ? 'memory' : 'profile' + nodes.push(memNode(i, source, `Memory ${i}: ${pick(['quirk', 'fact', 'preference', 'incident', 'lesson'], r())}`, ts)) + memory.push(card(source, `Memory ${i}`, `Auto-captured note #${i}.`, ts)) + } + + const edges: StarmapEdge[] = [] + + for (let i = 0; i < 380; i += 1) { + const a = Math.floor(r() * 240) + const b = Math.floor(r() * 240) + + if (a !== b) { + edges.push({ source: `b${a}`, target: `b${b}` }) + } + } + + return { clusters: [], edges, memory, nodes, stats: {} } +} + +const graphs: [string, StarmapGraph][] = [ + ['tiny + quirky', tiny()], + ['mid · mixed signal', mid()], + ['dense web · half undated', web()], + ['the beast · ~2 years', beast()] +] + +const lines: string[] = [] + +for (const [name, g] of graphs) { + const code = encodeShareCode(g) + const back = decodeShareCode(code) // round-trip assert — throws if invalid + // v2 is viz-only: nodes + edge topology survive; memory prose is dropped. + const ok = back.nodes.length === g.nodes.length && back.edges.length <= g.edges.length + console.log(`${ok ? 'ok ' : 'BAD'} ${name} — ${g.nodes.length} nodes / ${g.edges.length} edges / ${g.memory.length} cards (${code.length} chars)`) + lines.push(`# ${name} — ${g.nodes.length} nodes, ${g.edges.length} edges, ${g.memory.length} cards`, code, '') +} + +writeFileSync(new URL('share-codes.txt', import.meta.url), lines.join('\n')) diff --git a/apps/desktop/scripts/stage-native-deps.cjs b/apps/desktop/scripts/stage-native-deps.cjs index d84ae2cf51f1..ef68368dee76 100644 --- a/apps/desktop/scripts/stage-native-deps.cjs +++ b/apps/desktop/scripts/stage-native-deps.cjs @@ -66,6 +66,31 @@ 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/ 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 }) } @@ -148,12 +173,111 @@ 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() diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index 8f6c2349f836..fd13758599b9 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -19,7 +19,7 @@ import { 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. @@ -86,18 +86,16 @@ export function AgentsView({ onClose }: AgentsViewProps) { const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession]) return ( - -
-

{t.agents.title}

-

{t.agents.subtitle}

- - - + + {tree.length === 0 ? ( + + ) : ( + <> + + + + )} + ) } diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index d76cc2baee40..f7d9e3238e30 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -16,6 +16,7 @@ import { PaginationNext, PaginationPrevious } from '@/components/ui/pagination' +import { RowButton } from '@/components/ui/row-button' import { TextTab, TextTabMeta } from '@/components/ui/text-tab' import { Tip } from '@/components/ui/tooltip' import { getSessionMessages, listAllProfileSessions } from '@/hermes' @@ -761,13 +762,12 @@ function ArtifactCellAction({ } return ( - + ) } diff --git a/apps/desktop/src/app/chat/composer/composer-utils.test.ts b/apps/desktop/src/app/chat/composer/composer-utils.test.ts new file mode 100644 index 000000000000..9fc5f5b5730c --- /dev/null +++ b/apps/desktop/src/app/chat/composer/composer-utils.test.ts @@ -0,0 +1,40 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' +import { describe, expect, it } from 'vitest' + +import { pickPlaceholder, slashArgStage, slashChipKindForItem, slashCommandToken } from './composer-utils' + +const item = (group: string): Unstable_TriggerItem => + ({ id: 'x', type: 'slash', label: 'x', metadata: { group } }) as unknown as Unstable_TriggerItem + +describe('slashArgStage', () => { + it('is true only once the query is past the command name', () => { + expect(slashArgStage('personality')).toBe(false) + expect(slashArgStage('personality alice')).toBe(true) + }) +}) + +describe('slashCommandToken', () => { + it('extracts the lowercased /command token', () => { + expect(slashCommandToken('Personality alice')).toBe('/personality') + expect(slashCommandToken('model')).toBe('/model') + }) + + it('handles an empty query', () => { + expect(slashCommandToken('')).toBe('/') + }) +}) + +describe('slashChipKindForItem', () => { + it('maps completion groups to chip kinds', () => { + expect(slashChipKindForItem(item('Skills'))).toBe('skill') + expect(slashChipKindForItem(item('Themes'))).toBe('theme') + expect(slashChipKindForItem(item('Commands'))).toBe('command') + }) +}) + +describe('pickPlaceholder', () => { + it('returns a member of the pool', () => { + const pool = ['a', 'b', 'c'] as const + expect(pool).toContain(pickPlaceholder(pool)) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts new file mode 100644 index 000000000000..ad7b63787fd0 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -0,0 +1,60 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' + +import type { SlashChipKind } from '@/components/assistant-ui/directive-text' +import type { ComposerAttachment } from '@/store/composer' +import { setSessionPickerOpen } from '@/store/session' + +export const COMPOSER_STACK_BREAKPOINT_PX = 320 + +// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem +// vertical padding). Anything taller means the text wrapped to a second line, +// which is when the composer should expand to the stacked layout. +export const COMPOSER_SINGLE_LINE_MAX_PX = 36 + +export const COMPOSER_FADE_BACKGROUND = + 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' + +// Quiet period after the last keystroke before persisting the draft; +// unmount/pagehide flushes bypass it. +export const DRAFT_PERSIST_DEBOUNCE_MS = 400 + +export const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)] + +/** Completion items can carry an `action` (set in use-slash-completions) that + * runs a side effect on pick instead of inserting a chip — e.g. the session + * picker's "Browse all…" entry opens the overlay. Table-driven so new action + * items are a registry row, not a composer branch. */ +export const COMPLETION_ACTIONS: Record void> = { + 'session-picker': () => setSessionPickerOpen(true) +} + +/** Map a picked `/` completion to its pill accent. Driven by the completion + * group set in use-slash-completions (Skills / Themes / Commands|Options). */ +export function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind { + const group = (item.metadata as { group?: unknown } | undefined)?.group + + if (group === 'Skills') { + return 'skill' + } + + if (group === 'Themes') { + return 'theme' + } + + return 'command' +} + +/** A `/` query is at its arg stage once it's past the command name. */ +export const slashArgStage = (query: string) => query.includes(' ') + +/** The `/command` token of a slash query (`personality x` → `/personality`). */ +export const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}` + +export interface QueueEditState { + attachments: ComposerAttachment[] + draft: string + entryId: string + sessionKey: string +} + +export const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7bef1e827674..8bc7abc4acc1 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -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 } from '@/lib/icons' +import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' @@ -39,6 +39,7 @@ interface ConversationProps { } export function ComposerControls({ + autoSpeak, busy, busyAction, canSteer, @@ -50,8 +51,10 @@ export function ComposerControls({ state, voiceStatus, onDictate, - onSteer + onSteer, + onToggleAutoSpeak }: { + autoSpeak: boolean busy: boolean busyAction: 'queue' | 'stop' canSteer: boolean @@ -64,6 +67,7 @@ export function ComposerControls({ voiceStatus: VoiceStatus onDictate: () => void onSteer: () => void + onToggleAutoSpeak: () => void }) { const { t } = useI18n() const c = t.composer @@ -99,12 +103,13 @@ export function ComposerControls({ type="button" variant="ghost" > - + ) : ( )} + {showVoicePrimary ? ( ) : ( @@ -131,7 +136,7 @@ export function ComposerControls({ > {busy ? ( busyAction === 'queue' ? ( - + ) : ( ) @@ -202,7 +207,7 @@ function ConversationPill({ type="button" variant="ghost" > - + {c.stopShort} )} @@ -237,7 +242,7 @@ function ConversationIndicator({ speaking: boolean }) { if (speaking) { - return + return } const bars = [0.55, 0.85, 1, 0.85, 0.55] @@ -254,6 +259,39 @@ 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 ( + + + + ) +} + function DictationButton({ disabled, state, @@ -295,9 +333,9 @@ function DictationButton({ variant="ghost" > {status === 'recording' ? ( - + ) : status === 'transcribing' ? ( - + ) : ( )} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts new file mode 100644 index 000000000000..c3268bc9cbd3 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts @@ -0,0 +1,79 @@ +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]) +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts new file mode 100644 index 000000000000..5f8bcf8e2330 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -0,0 +1,344 @@ +import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react' +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' + +import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' +import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer' +import { isBrowsingHistory } from '@/store/composer-input-history' + +import { cloneAttachments, DRAFT_PERSIST_DEBOUNCE_MS, type QueueEditState } from '../composer-utils' +import { + type ComposerInsertMode, + focusComposerInput, + markActiveComposer, + onComposerFocusRequest, + onComposerInsertRefsRequest, + onComposerInsertRequest +} from '../focus' +import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs' +import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor' +import type { ChatBarProps } from '../types' + +interface UseComposerDraftArgs { + activeQueueSessionKey: string | null + focusKey: ChatBarProps['focusKey'] + inputDisabled: boolean + queueEditRef: RefObject + sessionId: string | null | undefined +} + +/** + * The composer's draft engine — the detached source-of-truth spine. The live + * text lives in the contentEditable DOM + `draftRef`; React only sees coarse + * edge selectors, so typing never re-renders the chrome. Owns the imperative + * composer-runtime subscription (draftRef mirror + external repaint + debounced + * per-session stash), the edit primitives (append/insert/inline-refs), focus, + * and per-session load/clear/stash/restore. The contentEditable *event* + * handlers stay in ChatBar (they bridge into the trigger engine) and drive the + * primitives exposed here. + */ +export function useComposerDraft({ + activeQueueSessionKey, + focusKey, + inputDisabled, + queueEditRef, + sessionId +}: UseComposerDraftArgs) { + const aui = useAui() + const composerRuntime = useComposerRuntime() + + // Coarse edges only — these flip rarely (empty↔non-empty, the `?` help sigil, + // steerable-vs-slash), so typing within a line costs no render. + const hasText = useAuiState(s => s.composer.text.trim().length > 0) + const isHelpHint = useAuiState(s => s.composer.text === '?') + + const isSteerableText = useAuiState(s => { + const trimmed = s.composer.text.trim() + + return trimmed.length > 0 && !SLASH_COMMAND_RE.test(trimmed) + }) + + // assistant-ui's composer mutators throw when the core isn't bound yet (a + // startup/thread-swap window); the DOM + draftRef hold the text and the + // subscription reconciles once it binds, so swallow the premature write. + const setComposerText = useCallback( + (value: string) => { + try { + aui.composer().setText(value) + } catch { + // Composer core not bound yet — DOM/draftRef carry the text. + } + }, + [aui] + ) + + const editorRef = useRef(null) + const draftRef = useRef('') + const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null) + const draftPersistTimerRef = useRef(undefined) + const activeQueueSessionKeyRef = useRef(activeQueueSessionKey) + activeQueueSessionKeyRef.current = activeQueueSessionKey + const sessionIdRef = useRef(sessionId) + sessionIdRef.current = sessionId + const queueEditStateRef = useRef(queueEditRef.current) + queueEditStateRef.current = queueEditRef.current + + const [focusRequestId, setFocusRequestId] = useState(0) + + const focusInput = useCallback(() => { + focusComposerInput(editorRef.current) + markActiveComposer('main') + }, []) + + const requestMainFocus = useCallback(() => { + setFocusRequestId(id => id + 1) + }, []) + + // The single write path for programmatic draft mutations: mirror → AUI state → + // repaint the editor (caret to end). Repaints even while focused — inserts / + // restores run mid-focus, and the runtime sync only repaints an unfocused + // editor — so the visible text never lags the store. + const paintDraft = useCallback( + (next: string, focus = true) => { + draftRef.current = next + setComposerText(next) + + const editor = editorRef.current + + if (editor) { + renderComposerContents(editor, next) + placeCaretEnd(editor) + } + + if (focus) { + requestMainFocus() + } + }, + [requestMainFocus, setComposerText] + ) + + const appendExternalText = useCallback( + (text: string, mode: ComposerInsertMode) => { + const value = text.trim() + + if (!value) { + return + } + + const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current + const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' + + paintDraft(`${base}${sep}${value}`) + }, + [paintDraft] + ) + + useEffect(() => { + if (!inputDisabled) { + focusInput() + } + }, [focusInput, focusKey, focusRequestId, inputDisabled]) + + useEffect(() => { + if (inputDisabled) { + return undefined + } + + const offFocus = onComposerFocusRequest(target => { + if (target === 'main') { + setFocusRequestId(id => id + 1) + } + }) + + const offInsert = onComposerInsertRequest(({ mode, target, text }) => { + if (target === 'main') { + appendExternalText(text, mode) + } + }) + + return () => { + offFocus() + offInsert() + } + }, [appendExternalText, inputDisabled]) + + const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) => + stashSessionDraft(scope, text, attachments) + + const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { + $composerAttachments.set(cloneAttachments(attachments)) + paintDraft(text, false) + } + + const clearDraft = useCallback(() => { + setComposerText('') + draftRef.current = '' + + if (editorRef.current) { + editorRef.current.replaceChildren() + } + }, [setComposerText]) + + // Read the editor's current plain text into draftRef + composer state. This + // closes the "queued rAF flush hasn't run yet" window so scope-swap/pagehide + // persistence captures the latest keystrokes. + const syncDraftFromEditor = useCallback(() => { + const editor = editorRef.current + + if (!editor) { + return draftRef.current + } + + const text = composerPlainText(editor) + + if (text !== draftRef.current) { + draftRef.current = text + setComposerText(text) + } + + return text + }, [setComposerText]) + + // Imperative draft sync — the spine of the "work only when work is to be + // performed" model. Subscribing to the composer runtime directly (not + // `useAuiState(text)` + a `[draft]` effect) keeps per-keystroke text out of + // React, so typing never re-renders the chrome. On each change we (1) mirror + // text into draftRef, (2) repaint the editor only when the change came from + // OUTSIDE it (programmatic clear/restore/insert; the focused editor is the + // source otherwise), and (3) schedule the debounced per-session stash. + // Browsing history / editing a queued prompt suppress the stash so recalled + // text never clobbers the draft. + useEffect(() => { + const sync = () => { + const text = composerRuntime.getState().text + draftRef.current = text + + const editor = editorRef.current + + if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) { + renderComposerContents(editor, text) + } + + if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) { + return + } + + const scope = activeQueueSessionKeyRef.current + pendingDraftPersistRef.current = { scope, text } + window.clearTimeout(draftPersistTimerRef.current) + draftPersistTimerRef.current = window.setTimeout(() => { + pendingDraftPersistRef.current = null + stashAt(scope, text) + }, DRAFT_PERSIST_DEBOUNCE_MS) + } + + const unsubscribe = composerRuntime.subscribe(sync) + + return () => { + unsubscribe() + window.clearTimeout(draftPersistTimerRef.current) + } + }, [composerRuntime, queueEditRef]) + + const insertText = (text: string) => { + const base = draftRef.current + const sep = base && !base.endsWith('\n') ? '\n' : '' + + paintDraft(`${base}${sep}${text}`) + } + + // insertInlineRefs mutates the editor in place (chips), so it can't go through + // paintDraft's re-render — it mirrors the resulting plain text and refocuses. + const insertInlineRefs = (refs: InlineRefInput[]) => { + const editor = editorRef.current + + if (!editor) { + return false + } + + const nextDraft = insertInlineRefsIntoEditor(editor, refs) + + if (nextDraft === null) { + return false + } + + draftRef.current = nextDraft + setComposerText(nextDraft) + requestMainFocus() + + return true + } + + // Latest-closure ref so the once-only subscription always calls the current + // insertInlineRefs without re-subscribing every render. + const insertInlineRefsRef = useRef(insertInlineRefs) + insertInlineRefsRef.current = insertInlineRefs + + useEffect(() => { + return onComposerInsertRefsRequest(({ refs, target }) => { + if (target === 'main') { + insertInlineRefsRef.current(refs) + } + }) + }, []) + + // Per-thread draft swap — the composer's only session coupling. Lifecycle + // never clears composer state; this effect alone stashes on leave, restores + // on enter. Keyed writes are idempotent, so no skip-sentinel. + useEffect(() => { + const { attachments, text } = takeSessionDraft(activeQueueSessionKey) + loadIntoComposer(text, attachments) + + return () => { + const latestText = syncDraftFromEditor() + const editing = queueEditStateRef.current + + if (editing?.sessionKey === activeQueueSessionKey) { + stashAt(activeQueueSessionKey, editing.draft, editing.attachments) + } else if (!isBrowsingHistory(sessionId)) { + stashAt(activeQueueSessionKey, latestText) + } + } + }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps + + // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R + // inside the debounce/rAF window would drop trailing keystrokes without this. + useEffect(() => { + const flushPendingDraftPersist = () => { + const scope = activeQueueSessionKeyRef.current + const editing = queueEditStateRef.current + + if (editing?.sessionKey === scope || isBrowsingHistory(sessionIdRef.current)) { + return + } + + const latestText = syncDraftFromEditor() + pendingDraftPersistRef.current = null + stashAt(scope, latestText) + } + + window.addEventListener('pagehide', flushPendingDraftPersist) + + return () => { + window.removeEventListener('pagehide', flushPendingDraftPersist) + flushPendingDraftPersist() + } + }, [syncDraftFromEditor]) + + return { + activeQueueSessionKeyRef, + clearDraft, + draftRef, + editorRef, + focusInput, + hasText, + insertInlineRefs, + insertText, + isHelpHint, + isSteerableText, + loadIntoComposer, + requestMainFocus, + sessionIdRef, + setComposerText, + stashAt + } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts new file mode 100644 index 000000000000..2c56061c80d5 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-drop.ts @@ -0,0 +1,164 @@ +import { type DragEvent as ReactDragEvent, useRef, useState } from 'react' + +import { triggerHaptic } from '@/lib/haptics' + +import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../../hooks/use-composer-actions' +import { dragHasAttachments, droppedFileInlineRefs, type InlineRefInput } from '../inline-refs' +import type { ChatBarProps } from '../types' + +interface UseComposerDropArgs { + cwd: ChatBarProps['cwd'] + insertInlineRefs: (refs: InlineRefInput[]) => boolean + onAttachDroppedItems: ChatBarProps['onAttachDroppedItems'] + requestMainFocus: () => void +} + +/** + * Drag-and-drop attachment engine. Splits drops by origin: in-app drags + * (project tree / gutter) stay inline `@file:`/`@line:` refs the gateway + * resolves directly; OS/Finder drops (absolute local paths a remote gateway + * can't read, image bytes vision needs) route through the upload pipeline. + * Off the keystroke path; consumes `insertInlineRefs` + the attach handler. + */ +export function useComposerDrop({ + cwd, + insertInlineRefs, + onAttachDroppedItems, + requestMainFocus +}: UseComposerDropArgs) { + const [dragActive, setDragActive] = useState(false) + const dragDepthRef = useRef(0) + + const resetDragState = () => { + dragDepthRef.current = 0 + setDragActive(false) + } + + const handleDragEnter = (event: ReactDragEvent) => { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + dragDepthRef.current += 1 + + if (!dragActive) { + setDragActive(true) + } + } + + const handleDragOver = (event: ReactDragEvent) => { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + + const handleDragLeave = (event: ReactDragEvent) => { + if (!onAttachDroppedItems) { + return + } + + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + + if (dragDepthRef.current === 0) { + setDragActive(false) + } + } + + const handleDrop = (event: ReactDragEvent) => { + if (!onAttachDroppedItems) { + return + } + + event.preventDefault() + resetDragState() + + const candidates = extractDroppedFiles(event.dataTransfer) + + if (candidates.length === 0) { + return + } + + // In-app drags (project tree / gutter) are workspace-relative paths the + // gateway resolves directly, so they stay inline @file:/@line: refs. OS + // drops are absolute local paths a remote gateway can't read (and images + // need byte upload for vision), so route them through the upload pipeline. + const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) + const refs = droppedFileInlineRefs(inAppRefs, cwd) + + if (refs.length && insertInlineRefs(refs)) { + triggerHaptic('selection') + } + + if (osDrops.length) { + void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => { + if (attached) { + triggerHaptic('selection') + requestMainFocus() + } + }) + } + } + + const handleInputDragOver = (event: ReactDragEvent) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = 'copy' + } + + const handleInputDrop = (event: ReactDragEvent) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + const candidates = extractDroppedFiles(event.dataTransfer) + + if (!candidates.length) { + return + } + + event.preventDefault() + event.stopPropagation() + resetDragState() + + // Dropping straight onto the text box used to inline-ref *every* file — + // including OS/Finder drops, whose absolute local path a remote gateway + // can't read and whose image bytes never reached vision. Split by origin: + // in-app drags stay inline refs; OS drops go through the upload pipeline. + // (When no upload handler is wired, fall back to inline refs for all.) + const attach = onAttachDroppedItems + const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) + const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd) + + if (refs.length && insertInlineRefs(refs)) { + triggerHaptic('selection') + } + + if (attach && osDrops.length) { + void Promise.resolve(attach(osDrops)).then(attached => { + if (attached) { + triggerHaptic('selection') + requestMainFocus() + } + }) + } + } + + return { + dragActive, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleInputDragOver, + handleInputDrop + } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts new file mode 100644 index 000000000000..da66ddd843aa --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-metrics.ts @@ -0,0 +1,160 @@ +import { useAuiState } from '@assistant-ui/react' +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' + +import { useMediaQuery } from '@/hooks/use-media-query' +import { useResizeObserver } from '@/hooks/use-resize-observer' +import { $composerPoppedOut } from '@/store/composer-popout' +import { isSecondaryWindow } from '@/store/windows' + +import { COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils' + +interface UseComposerMetricsArgs { + composerRef: RefObject + composerSurfaceRef: RefObject + editorRef: RefObject + poppedOut: boolean +} + +/** + * Owns the composer's *sizing* engine: the stacked-vs-inline layout decision + * and the measured-height CSS vars the thread reads for bottom clearance. All + * work is edge-gated — the ResizeObserver only fires on real size changes, the + * height vars are 8px-bucketed so per-keystroke growth never invalidates the + * tree's computed style, and `tight` only flips when it crosses the breakpoint. + * Returns `stacked` (the only value the render needs). + */ +export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }: UseComposerMetricsArgs): { + stacked: boolean +} { + const [expanded, setExpanded] = useState(false) + const [tight, setTight] = useState(false) + const narrow = useMediaQuery('(max-width: 30rem)') + + // Edge signals, not the live text: these only re-render when emptiness / the + // presence of a non-trailing newline actually flips, so typing within a line + // costs nothing here. + const isEmpty = useAuiState(s => s.composer.text.length === 0) + const hasHardNewline = useAuiState(s => s.composer.text.trimEnd().includes('\n')) + + // Expansion (input on its own full-width row, controls below) is driven by + // the editor's *actual* rendered height via the ResizeObserver in + // syncComposerMetrics — it only fires when the text genuinely wraps to a + // second line, so the layout flips exactly at the wrap point rather than at + // a guessed character count. We only handle the two cases the observer + // can't: an explicit newline (expand before layout settles) and an emptied + // draft (collapse back). We never read scrollHeight per keystroke. + useEffect(() => { + if (isEmpty) { + setExpanded(false) + + return + } + + if (expanded) { + return + } + + // Only a non-trailing newline forces an immediate expand. A trailing newline + // (or phantom \n from contenteditable junk) is left to the ResizeObserver, + // which expands only when the editor's real height actually grows. + if (hasHardNewline) { + setExpanded(true) + } + }, [expanded, hasHardNewline, isEmpty]) + + // Bucket measured heights so we only invalidate the global CSS var when + // the size crosses a meaningful threshold. Without bucketing, the editor + // grows ~1px per character → setProperty fires every keystroke → entire + // tree's computed style is invalidated → next paint forces a full + // recalculate-style pass. With an 8px bucket, the invalidation rate drops + // ~8× and small char-by-char typing produces no style invalidation at all + // until a wrap or row change actually happens. + const lastBucketedHeightRef = useRef(0) + const lastBucketedSurfaceHeightRef = useRef(0) + const lastTightRef = useRef(null) + + const syncComposerMetrics = useCallback(() => { + const composer = composerRef.current + + if (!composer) { + return + } + + // Floating composer is out of the thread's flow — it must not reserve any + // bottom clearance. Zero the measured vars so the thread reclaims the space. + // (Read globals here so the callback stays stable; mirror the popoutAllowed + // gate since secondary windows are forced docked.) + if ($composerPoppedOut.get() && !isSecondaryWindow()) { + const root = document.documentElement + lastBucketedHeightRef.current = 0 + lastBucketedSurfaceHeightRef.current = 0 + root.style.setProperty('--composer-measured-height', '0px') + root.style.setProperty('--composer-surface-measured-height', '0px') + + return + } + + const { height, width } = composer.getBoundingClientRect() + const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height + const root = document.documentElement + + if (width > 0) { + const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX + + if (nextTight !== lastTightRef.current) { + lastTightRef.current = nextTight + setTight(nextTight) + } + } + + // Expand once the input has actually wrapped past a single line. The + // observer only fires on real size changes, so this reads scrollHeight at + // most once per wrap (not per keystroke). One line ≈ 28px (1.625rem + // min-height + padding); a second line clears ~36px. We only ever expand + // here — collapse is handled by the emptied-draft effect to avoid + // oscillating across the wrap boundary as the input switches widths. + const editor = editorRef.current + + if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) { + setExpanded(true) + } + + if (height > 0) { + const bucket = Math.round(height / 8) * 8 + + if (bucket !== lastBucketedHeightRef.current) { + lastBucketedHeightRef.current = bucket + root.style.setProperty('--composer-measured-height', `${bucket}px`) + } + } + + if (surfaceHeight && surfaceHeight > 0) { + const bucket = Math.round(surfaceHeight / 8) * 8 + + if (bucket !== lastBucketedSurfaceHeightRef.current) { + lastBucketedSurfaceHeightRef.current = bucket + root.style.setProperty('--composer-surface-measured-height', `${bucket}px`) + } + } + }, [composerRef, composerSurfaceRef, editorRef]) + + useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef) + + // Toggling pop-out changes whether the composer reserves thread clearance. + // The ResizeObserver may not fire (the box can keep the same box size), so + // re-sync explicitly: docked republishes the measured height, floating zeroes + // it so the thread reclaims the bottom space. + useEffect(() => { + syncComposerMetrics() + }, [poppedOut, syncComposerMetrics]) + + useEffect(() => { + return () => { + const root = document.documentElement + root.style.removeProperty('--composer-measured-height') + root.style.removeProperty('--composer-surface-measured-height') + } + }, []) + + return { stacked: expanded || narrow || tight } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts new file mode 100644 index 000000000000..c40d56a4826b --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -0,0 +1,350 @@ +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' + +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { useSessionSlice } from '@/lib/use-session-slice' +import { clearComposerAttachments, type ComposerAttachment } from '@/store/composer' +import { resetBrowseState } from '@/store/composer-input-history' +import { + $queuedPromptsBySession, + enqueueQueuedPrompt, + MAX_AUTO_DRAIN_ATTEMPTS, + migrateQueuedPrompts, + promoteQueuedPrompt, + type QueuedPromptEntry, + removeQueuedPrompt, + shouldAutoDrain, + updateQueuedPrompt +} from '@/store/composer-queue' +import { notify } from '@/store/notifications' + +import { cloneAttachments, type QueueEditState } from '../composer-utils' +import type { ChatBarProps } from '../types' + +interface UseComposerQueueArgs { + activeQueueSessionKey: string | null + attachments: ComposerAttachment[] + busy: boolean + clearDraft: () => void + draftRef: RefObject + focusInput: () => void + loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void + onCancel: ChatBarProps['onCancel'] + onSubmit: ChatBarProps['onSubmit'] + queueEditRef: RefObject + queueSessionKey: ChatBarProps['queueSessionKey'] + sessionId: string | null | undefined +} + +/** + * The composer's queue engine — everything about queued turns: the per-session + * queue store binding, in-place queued-prompt editing (begin/step/exit), the + * shared drain lock + send-then-remove sequence, manual send-now, and the + * edge-independent auto-drain with bounded retries. It consumes the draft API + * (draftRef/clearDraft/loadIntoComposer/focusInput) and writes the + * coordinator-owned `queueEditRef` so the draft engine can read the edit state + * without a back-reference. Behaviour-identical to the inline original. + */ +export function useComposerQueue({ + activeQueueSessionKey, + attachments, + busy, + clearDraft, + draftRef, + focusInput, + loadIntoComposer, + onCancel, + onSubmit, + queueEditRef, + queueSessionKey, + sessionId +}: UseComposerQueueArgs) { + const { t } = useI18n() + + // Per-session slice (edge): re-renders only when THIS session's queue changes, + // not on cross-session queue churn (the plain atom's map ref changes on every + // write; the keyed array does not). + const queuedPrompts = useSessionSlice($queuedPromptsBySession, activeQueueSessionKey) + + const [queueEdit, setQueueEdit] = useState(null) + queueEditRef.current = queueEdit + + const setQueueEditSnapshot = useCallback( + (next: QueueEditState | null) => { + queueEditRef.current = next + setQueueEdit(next) + }, + [queueEditRef] + ) + + const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null + + const prevQueueKeyRef = useRef(activeQueueSessionKey) + const drainingQueueRef = useRef(false) + const drainFailuresRef = useRef(new Map()) + + const beginQueuedEdit = (entry: QueuedPromptEntry) => { + if (!activeQueueSessionKey || queueEdit) { + return + } + + setQueueEditSnapshot({ + attachments: cloneAttachments(attachments), + draft: draftRef.current, + entryId: entry.id, + sessionKey: activeQueueSessionKey + }) + loadIntoComposer(entry.text, entry.attachments) + triggerHaptic('selection') + focusInput() + } + + // Walk queued entries while editing (ArrowUp = older, ArrowDown = newer), + // saving the in-progress edit on each step. Stepping newer past the last + // entry exits edit mode and restores the pre-edit draft. + const stepQueuedEdit = (direction: -1 | 1) => { + if (!queueEdit) { + return false + } + + const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId) + const target = index + direction + + if (index < 0 || target < 0) { + return index >= 0 // at the oldest: swallow; missing entry: let it fall through + } + + const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { + attachments: cloneAttachments(attachments), + text: draftRef.current + }) + + const next = queuedPrompts[target] + + if (next) { + setQueueEditSnapshot({ ...queueEdit, entryId: next.id }) + loadIntoComposer(next.text, next.attachments) + } else { + setQueueEditSnapshot(null) + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + } + + triggerHaptic(saved ? 'success' : 'selection') + focusInput() + + return true + } + + const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => { + if (!queueEdit) { + return false + } + + if (action === 'save') { + const text = draftRef.current + const next = cloneAttachments(attachments) + + if (!text.trim() && next.length === 0) { + return false + } + + const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text }) + triggerHaptic(saved ? 'success' : 'selection') + } else { + triggerHaptic('cancel') + } + + setQueueEditSnapshot(null) + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + focusInput() + + return true + } + + const queueCurrentDraft = useCallback(() => { + const text = draftRef.current + + if (!activeQueueSessionKey || (!text.trim() && attachments.length === 0)) { + return false + } + + if (!enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments })) { + return false + } + + clearDraft() + clearComposerAttachments() + triggerHaptic('selection') + + return true + }, [activeQueueSessionKey, attachments, clearDraft, draftRef]) + + // All queue drain paths share one lock + send-then-remove sequence. + // `pickEntry` lets each caller choose head, by-id, or skip-edited. + const runDrain = useCallback( + async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise => { + if (drainingQueueRef.current || !activeQueueSessionKey) { + return false + } + + const entry = pickEntry(queuedPrompts) + + if (!entry) { + return false + } + + drainingQueueRef.current = true + + try { + const accepted = await Promise.resolve( + onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }) + ) + + if (accepted === false) { + return false + } + + drainFailuresRef.current.delete(entry.id) + removeQueuedPrompt(activeQueueSessionKey, entry.id) + resetBrowseState(sessionId) + + return true + } finally { + drainingQueueRef.current = false + } + }, + [activeQueueSessionKey, onSubmit, queuedPrompts, sessionId] + ) + + const pickDrainHead = useCallback( + (entries: QueuedPromptEntry[]) => { + const skip = queueEditRef.current?.entryId + + return skip ? entries.find(e => e.id !== skip) : entries[0] + }, + [queueEditRef] // reads the edit id off a ref so the lock-holder always sees the latest + ) + + const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain]) + + const sendQueuedNow = useCallback( + (id: string) => { + if (!activeQueueSessionKey || id === queueEdit?.entryId) { + return false + } + + if (busy) { + // Promote to the head, then interrupt. The gateway always emits a + // settle (message.complete + session.info running:false) when the + // turn unwinds, and the busy→false auto-drain below sends this entry. + promoteQueuedPrompt(activeQueueSessionKey, id) + triggerHaptic('selection') + void Promise.resolve(onCancel()) + + return true + } + + // A manual send clears the auto-drain backoff so a stuck entry the user + // taps gets a fresh attempt (and re-enables auto-retry on success). + drainFailuresRef.current.delete(id) + + return runDrain(entries => entries.find(e => e.id === id)) + }, + [activeQueueSessionKey, busy, onCancel, queueEdit, runDrain] + ) + + // Edge-independent auto-drain: send the head whenever the session is idle and + // the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g. + // a stale-session 404) can't strand the entry permanently nor spin-loop. The + // drain lock serializes sends; a remount/reconnect resets the failure counts. + const autoDrainNext = useCallback(() => { + if (busy || drainingQueueRef.current || !activeQueueSessionKey) { + return + } + + const entry = pickDrainHead(queuedPrompts) + + if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) { + return + } + + const onFail = () => { + const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1 + drainFailuresRef.current.set(entry.id, fails) + + if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) { + notify({ + id: 'composer-queue-stuck', + kind: 'error', + title: t.composer.queueStuckTitle, + message: t.composer.queueStuckBody + }) + } + } + + void runDrain(() => entry) + .then(sent => { + if (!sent) { + onFail() + } + }) + .catch(onFail) + }, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t]) + + // Re-key on a runtime session-id change. A stable stored id (queueSessionKey) + // never churns, so a change there is a real session switch and must NOT + // migrate; only the runtime-derived key (queueSessionKey falsy → key is + // sessionId) churns on a backend bounce/resume of the same conversation. + useEffect(() => { + const prev = prevQueueKeyRef.current + prevQueueKeyRef.current = activeQueueSessionKey + + if (queueSessionKey || !prev || !activeQueueSessionKey || prev === activeQueueSessionKey) { + return + } + + migrateQueuedPrompts(prev, activeQueueSessionKey) + }, [activeQueueSessionKey, queueSessionKey]) + + // Queued turns flow whenever the session is idle — on the busy→false settle + // edge, on mount/reconnect, and after a re-key — so a swallowed edge can't + // strand them. To cancel queued turns, the user deletes them from the panel. + useEffect(() => { + if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) { + autoDrainNext() + } + }, [autoDrainNext, busy, queuedPrompts.length]) + + // Queue-edit cleanup: on session swap the scope effect already stashed the + // edit snapshot; only restore into the composer when still on the same scope. + useEffect(() => { + if (!queueEdit) { + return + } + + if (queueEdit.sessionKey === activeQueueSessionKey) { + if (editingQueuedPrompt) { + return + } + + setQueueEditSnapshot(null) + loadIntoComposer(queueEdit.draft, queueEdit.attachments) + + return + } + + setQueueEditSnapshot(null) + }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit, setQueueEditSnapshot]) // eslint-disable-line react-hooks/exhaustive-deps + + return { + beginQueuedEdit, + drainNextQueued, + editingQueuedPrompt, + exitQueuedEdit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sendQueuedNow, + stepQueuedEdit + } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts new file mode 100644 index 000000000000..eab822d7cd89 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -0,0 +1,190 @@ +import { type RefObject, useEffect, useRef } from 'react' + +import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' +import { triggerHaptic } from '@/lib/haptics' +import { clearComposerAttachments, clearSessionDraft, type ComposerAttachment } from '@/store/composer' +import { resetBrowseState } from '@/store/composer-input-history' +import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue' + +import { cloneAttachments, type QueueEditState } from '../composer-utils' +import { onComposerSubmitRequest } from '../focus' +import { composerPlainText } from '../rich-editor' +import type { ChatBarProps } from '../types' + +interface UseComposerSubmitArgs { + activeQueueSessionKey: string | null + activeQueueSessionKeyRef: RefObject + attachments: ComposerAttachment[] + busy: boolean + canSteer: boolean + clearDraft: () => void + disabled: boolean + draftRef: RefObject + drainNextQueued: () => Promise + editorRef: RefObject + exitQueuedEdit: (action: 'cancel' | 'save') => boolean + focusInput: () => void + inputDisabled: boolean + loadIntoComposer: (text: string, attachments: ComposerAttachment[]) => void + onCancel: ChatBarProps['onCancel'] + onSteer: ChatBarProps['onSteer'] + onSubmit: ChatBarProps['onSubmit'] + queueCurrentDraft: () => boolean + queueEdit: QueueEditState | null + queuedPrompts: QueuedPromptEntry[] + sessionId: string | null | undefined + setComposerText: (value: string) => void + stashAt: (scope: string | null, text?: string, attachments?: ComposerAttachment[]) => void +} + +/** + * The composer's submit engine — the orchestration seam where the draft and + * queue meet. `submitDraft` is the one decision tree (queue-edit save · slash- + * now-while-busy · queue · drain · send · stop); `dispatchSubmit` is the shared + * send-with-restore primitive (re-loads + re-stashes the draft if the gateway + * rejects, so nothing is ever lost); `steerDraft` nudges the live turn. Reads + * the draft + queue APIs; owns no state of its own beyond the stable + * external-submit listener ref. + */ +export function useComposerSubmit({ + activeQueueSessionKey, + activeQueueSessionKeyRef, + attachments, + busy, + canSteer, + clearDraft, + disabled, + draftRef, + drainNextQueued, + editorRef, + exitQueuedEdit, + focusInput, + inputDisabled, + loadIntoComposer, + onCancel, + onSteer, + onSubmit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sessionId, + setComposerText, + stashAt +}: UseComposerSubmitArgs) { + // Shared send primitive: fire onSubmit, and if the gateway rejects (accepted + // === false) or throws, re-load + re-stash the draft so the words survive. + const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => { + const submittedScope = activeQueueSessionKeyRef.current + const submittedAttachments = attachments ?? [] + + const restore = () => { + loadIntoComposer(text, submittedAttachments) + stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments) + } + + void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) + .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope))) + .catch(restore) + } + + // External "submit this prompt" requests (e.g. the review pane's agent-ship + // button) route through the same send path. A ref keeps the listener stable + // while always calling the latest dispatchSubmit closure. + const dispatchSubmitRef = useRef(dispatchSubmit) + dispatchSubmitRef.current = dispatchSubmit + + useEffect( + () => + onComposerSubmitRequest(({ target, text }) => { + if (target === 'main' && !inputDisabled) { + dispatchSubmitRef.current(text) + } + }), + [inputDisabled] + ) + + const submitDraft = () => { + if (disabled) { + return + } + + // Source the text from the DOM editor, not React state. The AUI composer + // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a + // render, so on fast typing or IME composition the final keystroke(s) may + // not have synced yet — reading state here drops the message (Enter looks + // like it does nothing; typing a trailing space only "fixes" it because the + // extra input event forces a state sync). draftRef is updated on every + // input event; refresh it from the editor once more to also cover an + // in-flight keystroke that hasn't fired its input event yet. + const editor = editorRef.current + + if (editor) { + const domText = composerPlainText(editor) + + if (domText !== draftRef.current) { + draftRef.current = domText + setComposerText(domText) + } + } + + const text = draftRef.current + const payloadPresent = text.trim().length > 0 || attachments.length > 0 + + if (queueEdit) { + exitQueuedEdit('save') + } else if (busy) { + // Slash commands should execute immediately even while the agent is + // busy — they're client-side operations (/yolo, /skin, /new, /help, + // etc.) or self-contained gateway RPCs (/status, /compress). onSubmit + // routes them to executeSlashCommand, which has its own per-command + // busy guard for commands that genuinely need an idle session (skill + // /send directives). Queuing them would make every slash command wait + // for the current turn to finish, which is how the TUI never behaves. + if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) { + triggerHaptic('submit') + clearDraft() + dispatchSubmit(text) + } else if (payloadPresent) { + queueCurrentDraft() + } else { + // Stop button (the only way to reach here while busy with an empty + // composer — empty Enter is short-circuited in the keydown handler). + triggerHaptic('cancel') + void Promise.resolve(onCancel()) + } + } else if (!payloadPresent && queuedPrompts.length > 0) { + void drainNextQueued() + } else if (payloadPresent) { + const submittedAttachments = cloneAttachments(attachments) + triggerHaptic('submit') + resetBrowseState(sessionId) + clearDraft() + clearComposerAttachments() + dispatchSubmit(text, submittedAttachments) + } + + focusInput() + } + + // Steer the live turn (nudge without interrupting). Clears the draft up front + // for snappy feedback; if the gateway rejects (no live tool window) the words + // are re-queued so nothing is lost — same safety net as a plain queue. + const steerDraft = () => { + if (!onSteer || !canSteer) { + return + } + + const text = draftRef.current.trim() + + triggerHaptic('submit') + clearDraft() + + void Promise.resolve(onSteer(text)).then(accepted => { + if (!accepted && activeQueueSessionKey) { + enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] }) + } + }) + } + + return { dispatchSubmit, steerDraft, submitDraft } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts new file mode 100644 index 000000000000..2cff7a4084c7 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useI18n } from '@/i18n' +import { chatMessageText } from '@/lib/chat-messages' +import { triggerHaptic } from '@/lib/haptics' +import { resetBrowseState } from '@/store/composer-input-history' +import { notifyError } from '@/store/notifications' +import { $messages } from '@/store/session' +import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' + +import { onComposerVoiceToggleRequest } from '../focus' +import type { ChatBarProps } from '../types' + +import { useAutoSpeakReplies } from './use-auto-speak-replies' +import { useVoiceConversation } from './use-voice-conversation' +import { useVoiceRecorder } from './use-voice-recorder' + +interface UseComposerVoiceArgs { + busy: boolean + clearDraft: () => void + disabled: boolean + focusInput: () => void + insertText: (text: string) => void + maxRecordingSeconds: number + onSubmit: ChatBarProps['onSubmit'] + onTranscribeAudio: ChatBarProps['onTranscribeAudio'] + sessionId: string | null | undefined +} + +/** + * The composer's voice engine: push-to-talk dictation (transcript → draft), the + * full voice-conversation loop, and auto-speak of replies. Self-contained — it + * consumes the draft/submit primitives passed in but nothing depends back on it, + * so it lifts cleanly out of ChatBar. + */ +export function useComposerVoice({ + busy, + clearDraft, + disabled, + focusInput, + insertText, + maxRecordingSeconds, + onSubmit, + onTranscribeAudio, + sessionId +}: UseComposerVoiceArgs) { + const { t } = useI18n() + const [voiceConversationActive, setVoiceConversationActive] = useState(false) + const lastSpokenIdRef = useRef(null) + + const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ + focusInput, + maxRecordingSeconds, + onTranscript: insertText, + onTranscribeAudio + }) + + const pendingResponse = () => { + const messages = $messages.get() + const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) + + if (!last || last.id === lastSpokenIdRef.current) { + return null + } + + const text = chatMessageText(last).trim() + + if (!text) { + return null + } + + return { + id: last.id, + pending: Boolean(last.pending), + text + } + } + + const consumePendingResponse = () => { + const messages = $messages.get() + const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) + + if (last) { + lastSpokenIdRef.current = last.id + } + } + + const submitVoiceTurn = async (text: string) => { + if (busy) { + return + } + + triggerHaptic('submit') + resetBrowseState(sessionId) + clearDraft() + await onSubmit(text) + } + + const conversation = useVoiceConversation({ + busy, + consumePendingResponse, + enabled: voiceConversationActive, + onFatalError: () => setVoiceConversationActive(false), + onSubmit: submitVoiceTurn, + onTranscribeAudio, + pendingResponse + }) + + // The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting + // with STT unconfigured lets the conversation surface its own "configure + // speech-to-text" notice rather than silently no-opping. + const toggleVoiceConversation = useCallback(() => { + if (disabled) { + return + } + + if (voiceConversationActive) { + setVoiceConversationActive(false) + void conversation.end() + } else { + setVoiceConversationActive(true) + } + }, [conversation, disabled, voiceConversationActive]) + + useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation]) + + // Explicit start/end for the on-screen conversation controls (the hotkey uses + // the gated toggle above). + const startConversation = useCallback(() => setVoiceConversationActive(true), []) + + const endConversation = useCallback(() => { + setVoiceConversationActive(false) + void conversation.end() + }, [conversation]) + + 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 + }) + + return { + conversation, + dictate, + endConversation, + handleToggleAutoSpeak, + startConversation, + voiceActivityState, + voiceConversationActive, + voiceStatus + } +} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts b/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts new file mode 100644 index 000000000000..c6b9af53b737 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts @@ -0,0 +1,36 @@ +import { useSyncExternalStore } from 'react' + +import { $statusItemsBySession } from '@/store/composer-status' +import { $previewStatusBySession } from '@/store/preview-status' + +const subscribe = (onChange: () => void) => { + const offItems = $statusItemsBySession.listen(onChange) + const offPreviews = $previewStatusBySession.listen(onChange) + + return () => { + offItems() + offPreviews() + } +} + +/** + * Whether a session has any status items or previews, as a coarse *edge*: the + * boolean only flips when the stack appears/disappears. ChatBar uses it to + * toggle a styling data-attr — subscribing to the whole `$statusItemsBySession` + * (a `computed` that rebuilds the entire map) / `$previewStatusBySession` maps + * re-rendered the ~1.4k ChatBar on every per-item mutation (a subagent tick, a + * 5s background poll) and on churn in OTHER sessions. The boolean snapshot bails + * out of all of that, re-rendering only on the actual show/hide transition. + */ +export function useSessionStatusPresence(sessionId: string | null): boolean { + return useSyncExternalStore(subscribe, () => { + if (!sessionId) { + return false + } + + return ( + ($statusItemsBySession.get()[sessionId]?.length ?? 0) > 0 || + ($previewStatusBySession.get()[sessionId]?.length ?? 0) > 0 + ) + }) +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 890ba02840c4..d49c8382b7bd 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1,38 +1,26 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' -import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react' +import { ComposerPrimitive } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { type ClipboardEvent, type FormEvent, type KeyboardEvent, - type DragEvent as ReactDragEvent, useCallback, useEffect, - useMemo, useRef, useState } from 'react' -import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text' +import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock' import { Button } from '@/components/ui/button' -import { useMediaQuery } from '@/hooks/use-media-query' -import { useResizeObserver } from '@/hooks/use-resize-observer' import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' -import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' -import { - $composerAttachments, - clearComposerAttachments, - clearSessionDraft, - type ComposerAttachment, - stashSessionDraft, - takeSessionDraft -} from '@/store/composer' +import { $composerAttachments, clearComposerAttachments } from '@/store/composer' import { browseBackward, browseForward, @@ -48,55 +36,41 @@ import { setComposerPopoutPosition, setComposerPoppedOut } from '@/store/composer-popout' -import { - $queuedPromptsBySession, - enqueueQueuedPrompt, - MAX_AUTO_DRAIN_ATTEMPTS, - migrateQueuedPrompts, - promoteQueuedPrompt, - type QueuedPromptEntry, - removeQueuedPrompt, - shouldAutoDrain, - updateQueuedPrompt -} from '@/store/composer-queue' -import { $statusItemsBySession } from '@/store/composer-status' -import { notify } from '@/store/notifications' -import { $previewStatusBySession } from '@/store/preview-status' +import { removeQueuedPrompt } from '@/store/composer-queue' 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 { $gatewayState, $messages } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' +import { $autoSpeakReplies } from '@/store/voice-prefs' import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes' -import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions' - import { AttachmentList } from './attachments' +import { + COMPLETION_ACTIONS, + COMPOSER_FADE_BACKGROUND, + pickPlaceholder, + type QueueEditState, + slashArgStage, + slashChipKindForItem, + slashCommandToken +} from './composer-utils' import { ContextMenu } from './context-menu' import { ComposerControls } from './controls' import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' -import { - type ComposerInsertMode, - focusComposerInput, - markActiveComposer, - onComposerFocusRequest, - onComposerInsertRefsRequest, - onComposerInsertRequest, - onComposerSubmitRequest, - onComposerVoiceToggleRequest -} from './focus' +import { markActiveComposer } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' +import { useComposerDraft } from './hooks/use-composer-draft' +import { useComposerDrop } from './hooks/use-composer-drop' +import { useComposerMetrics } from './hooks/use-composer-metrics' +import { useComposerQueue } from './hooks/use-composer-queue' +import { useComposerSubmit } from './hooks/use-composer-submit' +import { useComposerVoice } from './hooks/use-composer-voice' import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' -import { useVoiceConversation } from './hooks/use-voice-conversation' -import { useVoiceRecorder } from './hooks/use-voice-recorder' -import { - dragHasAttachments, - droppedFileInlineRefs, - type InlineRefInput, - insertInlineRefsIntoEditor -} from './inline-refs' +import { useSessionStatusPresence } from './hooks/use-status-presence' import { QueuePanel } from './queue-panel' import { composerPlainText, @@ -118,61 +92,6 @@ import type { ChatBarProps } from './types' import { UrlDialog } from './url-dialog' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' -const COMPOSER_STACK_BREAKPOINT_PX = 320 - -// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem -// vertical padding). Anything taller means the text wrapped to a second line, -// which is when the composer should expand to the stacked layout. -const COMPOSER_SINGLE_LINE_MAX_PX = 36 - -const COMPOSER_FADE_BACKGROUND = - 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' - -const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)] - -/** Completion items can carry an `action` (set in use-slash-completions) that - * runs a side effect on pick instead of inserting a chip — e.g. the session - * picker's "Browse all…" entry opens the overlay. Table-driven so new action - * items are a registry row, not a composer branch. */ -const COMPLETION_ACTIONS: Record void> = { - 'session-picker': () => setSessionPickerOpen(true) -} - -/** Map a picked `/` completion to its pill accent. Driven by the completion - * group set in use-slash-completions (Skills / Themes / Commands|Options). */ -function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind { - const group = (item.metadata as { group?: unknown } | undefined)?.group - - if (group === 'Skills') { - return 'skill' - } - - if (group === 'Themes') { - return 'theme' - } - - return 'command' -} - -/** A `/` query is at its arg stage once it's past the command name. */ -const slashArgStage = (query: string) => query.includes(' ') - -/** The `/command` token of a slash query (`personality x` → `/personality`). */ -const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}` - -interface QueueEditState { - attachments: ComposerAttachment[] - draft: string - entryId: string - sessionKey: string -} - -const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a })) - -// Quiet period after the last keystroke before persisting the draft; -// unmount/pagehide flushes bypass it. -const DRAFT_PERSIST_DEBOUNCE_MS = 400 - export function ChatBar({ busy, cwd, @@ -196,39 +115,14 @@ export function ChatBar({ onSubmit, onTranscribeAudio }: ChatBarProps) { - const aui = useAui() - const draft = useAuiState(s => s.composer.text) - - // assistant-ui's composer *mutators* (setText/send/…) throw "Composer is not - // available" when the thread's composer core isn't bound yet — and unlike the - // read path (`s.composer.text`, which is null-safe), there's no graceful - // fallback. There's a startup/thread-swap window where this ChatBar's mount - // effects (draft restore, clearDraft, external inserts) run before the core - // binds; the popout refactor (#49488) widened it by moving the composer out - // of the contain wrapper into a sibling of the thread, so the throw began - // surfacing as an uncaught error that wedged the desktop input (#49903). - // - // Guard every mutation: if the core isn't ready, no-op the assistant-ui write. - // The contentEditable DOM + draftRef already hold the text, and the - // draft⇄editor sync reconciles composer state once the core attaches, so the - // draft is never lost — only the (premature) state push is skipped. - const setComposerText = useCallback( - (value: string) => { - try { - aui.composer().setText(value) - } catch { - // Composer core not bound yet — DOM/draftRef carry the text; the sync - // effect re-applies it after bind. Swallow so the input stays usable. - } - }, - [aui] - ) - const attachments = useStore($composerAttachments) - const queuedPromptsBySession = useStore($queuedPromptsBySession) - 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 @@ -238,29 +132,17 @@ export function ChatBar({ const popoutPosition = useStore($composerPopoutPosition) const activeQueueSessionKey = queueSessionKey || sessionId || null - const queuedPrompts = useMemo( - () => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []), - [activeQueueSessionKey, queuedPromptsBySession] - ) - // Status items (subagents, background processes) are keyed by the RUNTIME // session id — gateway events and process.list both speak that id. Only the // queue uses the stored-session fallback key (prompts can queue pre-resume). const statusSessionId = sessionId ?? null - const statusStackVisible = useMemo( - () => - queuedPrompts.length > 0 || - (statusSessionId - ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 || - (previewStatusBySession[statusSessionId]?.length ?? 0) > 0 - : false), - [previewStatusBySession, queuedPrompts.length, statusItemsBySession, statusSessionId] - ) + // Coarse edge: re-renders ChatBar only when the stack shows/hides, NOT on + // every per-item status mutation or other sessions' churn (see the hook). + const statusPresent = useSessionStatusPresence(statusSessionId) const composerRef = useRef(null) const composerSurfaceRef = useRef(null) - const editorRef = useRef(null) const handleComposerPopOut = useCallback(() => { triggerHaptic('open') @@ -290,57 +172,115 @@ export function ChatBar({ position: popoutPosition }) - const draftRef = useRef(draft) - const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null) - const activeQueueSessionKeyRef = useRef(activeQueueSessionKey) - activeQueueSessionKeyRef.current = activeQueueSessionKey - const prevQueueKeyRef = useRef(activeQueueSessionKey) - const drainingQueueRef = useRef(false) - // Per-entry auto-drain failure counts; bounds retries so a persistent 404 - // can't spin-loop. Cleared on success; reset naturally on remount/reconnect. - const drainFailuresRef = useRef(new Map()) const urlInputRef = useRef(null) const [urlOpen, setUrlOpen] = useState(false) const [urlValue, setUrlValue] = useState('') - const [expanded, setExpanded] = useState(false) - const [voiceConversationActive, setVoiceConversationActive] = useState(false) - const [tight, setTight] = useState(false) - const [dragActive, setDragActive] = useState(false) - const [queueEdit, setQueueEdit] = useState(null) - const [focusRequestId, setFocusRequestId] = useState(0) - const queueEditRef = useRef(queueEdit) - queueEditRef.current = queueEdit - const dragDepthRef = useRef(0) + // Coordinator-owned: the draft engine reads the live queue-edit snapshot off + // this ref (to suppress its stash while editing a queued prompt) and the queue + // engine writes it — an explicit shared handle, not a back-reference. + const queueEditRef = useRef(null) const composingRef = useRef(false) // true during IME composition (CJK input) - const lastSpokenIdRef = useRef(null) - - const narrow = useMediaQuery('(max-width: 30rem)') const { availableThemes, themeName } = useTheme() const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null }) const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes }) - const stacked = expanded || narrow || tight - const trimmedDraft = draft.trim() - const hasComposerPayload = trimmedDraft.length > 0 || attachments.length > 0 + const { t } = useI18n() + const gatewayState = useStore($gatewayState) + const newSessionPlaceholders = t.composer.newSessionPlaceholders + const followUpPlaceholders = t.composer.followUpPlaceholders + const reconnecting = gatewayState === 'closed' || gatewayState === 'error' + const inputDisabled = disabled && !reconnecting + + // The draft engine — detached source of truth (DOM + draftRef + edge + // selectors); typing never re-renders the chrome. ChatBar owns `queueEditRef` + // and threads it in so the draft↔queue coupling is an explicit dep, not a tangle. + const { + activeQueueSessionKeyRef, + clearDraft, + draftRef, + editorRef, + focusInput, + hasText, + insertInlineRefs, + insertText, + isHelpHint, + isSteerableText, + loadIntoComposer, + requestMainFocus, + sessionIdRef, + setComposerText, + stashAt + } = useComposerDraft({ activeQueueSessionKey, focusKey, inputDisabled, queueEditRef, sessionId }) + + // The queue engine — queued turns, in-place editing, the shared drain lock, + // and bounded auto-drain. Consumes the draft API and writes `queueEditRef`. + const { + beginQueuedEdit, + drainNextQueued, + editingQueuedPrompt, + exitQueuedEdit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sendQueuedNow, + stepQueuedEdit + } = useComposerQueue({ + activeQueueSessionKey, + attachments, + busy, + clearDraft, + draftRef, + focusInput, + loadIntoComposer, + onCancel, + onSubmit, + queueEditRef, + queueSessionKey, + sessionId + }) + + const statusStackVisible = queuedPrompts.length > 0 || statusPresent + + const { stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut }) + const hasComposerPayload = hasText || attachments.length > 0 const canSubmit = busy || hasComposerPayload - const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' // Steer only makes sense mid-turn, text-only (the gateway can't carry images // into a tool result) and never for a slash command (those execute inline). - const canSteer = - busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft) + const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText - const showHelpHint = draft === '?' + const showHelpHint = isHelpHint - const { t } = useI18n() - const gatewayState = useStore($gatewayState) - const newSessionPlaceholders = t.composer.newSessionPlaceholders - const followUpPlaceholders = t.composer.followUpPlaceholders - const reconnecting = gatewayState === 'closed' || gatewayState === 'error' - const inputDisabled = disabled && !reconnecting + // The submit engine — the orchestration seam where draft + queue meet. Owns + // the submit decision tree, the send-with-restore primitive, and steer. + const { steerDraft, submitDraft } = useComposerSubmit({ + activeQueueSessionKey, + activeQueueSessionKeyRef, + attachments, + busy, + canSteer, + clearDraft, + disabled, + draftRef, + drainNextQueued, + editorRef, + exitQueuedEdit, + focusInput, + inputDisabled, + loadIntoComposer, + onCancel, + onSteer, + onSubmit, + queueCurrentDraft, + queueEdit, + queuedPrompts, + sessionId, + setComposerText, + stashAt + }) // Resting placeholder: a starter for brand-new sessions, a continuation for // existing ones. Picked once and only re-rolled when we genuinely move to a @@ -382,207 +322,12 @@ export function ChatBar({ : t.composer.placeholderStarting : restingPlaceholder - const focusInput = useCallback(() => { - focusComposerInput(editorRef.current) - markActiveComposer('main') - }, []) - - const requestMainFocus = useCallback(() => { - setFocusRequestId(id => id + 1) - }, []) - - const appendExternalText = useCallback( - (text: string, mode: ComposerInsertMode) => { - const value = text.trim() - - if (!value) { - return - } - - const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current - const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' - const next = `${base}${sep}${value}` - - draftRef.current = next - setComposerText(next) - - const editor = editorRef.current - - if (editor) { - renderComposerContents(editor, next) - placeCaretEnd(editor) - } - - setFocusRequestId(id => id + 1) - }, - [setComposerText] - ) - - useEffect(() => { - if (!inputDisabled) { - focusInput() - } - }, [focusInput, focusKey, focusRequestId, inputDisabled]) - - useEffect(() => { - if (inputDisabled) { - return undefined - } - - const offFocus = onComposerFocusRequest(target => { - if (target === 'main') { - setFocusRequestId(id => id + 1) - } - }) - - const offInsert = onComposerInsertRequest(({ mode, target, text }) => { - if (target === 'main') { - appendExternalText(text, mode) - } - }) - - return () => { - offFocus() - offInsert() - } - }, [appendExternalText, inputDisabled]) - - // Keep draftRef in sync with the assistant-ui composer state for callers - // that read the latest text outside the React render cycle. We don't push - // to `$composerDraft` per keystroke any more — nobody outside the composer - // subscribes to it (verified by grep), and the round-trip - // `setText` ⇄ `subscribe` ⇄ `setText` was adding two useEffects to the per- - // keystroke critical path. `reconcileComposerTerminalSelections` only - // matters when the draft is submitted; we now call it from the submit - // path instead. - useEffect(() => { - draftRef.current = draft - - const editor = editorRef.current - - if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) { - renderComposerContents(editor, draft) - } - }, [draft]) - useEffect(() => { if (urlOpen) { window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true })) } }, [urlOpen]) - // Expansion (input on its own full-width row, controls below) is driven by - // the editor's *actual* rendered height via the ResizeObserver in - // syncComposerMetrics — it only fires when the text genuinely wraps to a - // second line, so the layout flips exactly at the wrap point rather than at - // a guessed character count. We only handle the two cases the observer - // can't: an explicit newline (expand before layout settles) and an emptied - // draft (collapse back). We never read scrollHeight per keystroke. - useEffect(() => { - if (!draft) { - setExpanded(false) - - return - } - - if (expanded) { - return - } - - // Only a non-trailing newline forces an immediate expand. A trailing newline - // (or phantom \n from contenteditable junk) is left to the ResizeObserver, - // which expands only when the editor's real height actually grows. - if (draft.trimEnd().includes('\n')) { - setExpanded(true) - } - }, [draft, expanded]) - - // Bucket measured heights so we only invalidate the global CSS var when - // the size crosses a meaningful threshold. Without bucketing, the editor - // grows ~1px per character → setProperty fires every keystroke → entire - // tree's computed style is invalidated → next paint forces a full - // recalculate-style pass. With an 8px bucket, the invalidation rate drops - // ~8× and small char-by-char typing produces no style invalidation at all - // until a wrap or row change actually happens. - const lastBucketedHeightRef = useRef(0) - const lastBucketedSurfaceHeightRef = useRef(0) - const lastTightRef = useRef(null) - - const syncComposerMetrics = useCallback(() => { - const composer = composerRef.current - - if (!composer) { - return - } - - // Floating composer is out of the thread's flow — it must not reserve any - // bottom clearance. Zero the measured vars so the thread reclaims the space. - // (Read globals here so the callback stays stable; mirror the popoutAllowed - // gate since secondary windows are forced docked.) - if ($composerPoppedOut.get() && !isSecondaryWindow()) { - const root = document.documentElement - lastBucketedHeightRef.current = 0 - lastBucketedSurfaceHeightRef.current = 0 - root.style.setProperty('--composer-measured-height', '0px') - root.style.setProperty('--composer-surface-measured-height', '0px') - - return - } - - const { height, width } = composer.getBoundingClientRect() - const surfaceHeight = composerSurfaceRef.current?.getBoundingClientRect().height - const root = document.documentElement - - if (width > 0) { - const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX - - if (nextTight !== lastTightRef.current) { - lastTightRef.current = nextTight - setTight(nextTight) - } - } - - // Expand once the input has actually wrapped past a single line. The - // observer only fires on real size changes, so this reads scrollHeight at - // most once per wrap (not per keystroke). One line ≈ 28px (1.625rem - // min-height + padding); a second line clears ~36px. We only ever expand - // here — collapse is handled by the emptied-draft effect to avoid - // oscillating across the wrap boundary as the input switches widths. - const editor = editorRef.current - - if (editor && editor.scrollHeight > COMPOSER_SINGLE_LINE_MAX_PX) { - setExpanded(true) - } - - if (height > 0) { - const bucket = Math.round(height / 8) * 8 - - if (bucket !== lastBucketedHeightRef.current) { - lastBucketedHeightRef.current = bucket - root.style.setProperty('--composer-measured-height', `${bucket}px`) - } - } - - if (surfaceHeight && surfaceHeight > 0) { - const bucket = Math.round(surfaceHeight / 8) * 8 - - if (bucket !== lastBucketedSurfaceHeightRef.current) { - lastBucketedSurfaceHeightRef.current = bucket - root.style.setProperty('--composer-surface-measured-height', `${bucket}px`) - } - } - }, []) - - useResizeObserver(syncComposerMetrics, composerRef, composerSurfaceRef, editorRef) - - // Toggling pop-out changes whether the composer reserves thread clearance. - // The ResizeObserver may not fire (the box can keep the same box size), so - // re-sync explicitly: docked republishes the measured height, floating zeroes - // it so the thread reclaims the bottom space. - useEffect(() => { - syncComposerMetrics() - }, [poppedOut, syncComposerMetrics]) - // Keep the floating box on-screen: re-clamp (with the real measured size + // thread bounds) when it pops out and on every window resize — so a position // persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar @@ -611,72 +356,6 @@ export function ChatBar({ } }, [poppedOut]) - useEffect(() => { - return () => { - const root = document.documentElement - root.style.removeProperty('--composer-measured-height') - root.style.removeProperty('--composer-surface-measured-height') - } - }, []) - - const insertText = (text: string) => { - const currentDraft = draftRef.current - const sep = currentDraft && !currentDraft.endsWith('\n') ? '\n' : '' - const nextDraft = `${currentDraft}${sep}${text}` - - draftRef.current = nextDraft - setComposerText(nextDraft) - - // Push the new text into the contentEditable editor directly. Setting the - // assistant-ui composer state alone is not enough: the draft→editor sync - // effect only re-renders the editor when it is NOT focused - // (document.activeElement !== editor), and the dictation/insert paths - // typically run while the editor has (or immediately regains) focus — so - // the store would hold the text but the visible editor would stay empty - // and there'd be nothing to send. Mirror appendExternalText here. - const editor = editorRef.current - - if (editor) { - renderComposerContents(editor, nextDraft) - placeCaretEnd(editor) - } - - requestMainFocus() - } - - const insertInlineRefs = (refs: InlineRefInput[]) => { - const editor = editorRef.current - - if (!editor) { - return false - } - - const nextDraft = insertInlineRefsIntoEditor(editor, refs) - - if (nextDraft === null) { - return false - } - - draftRef.current = nextDraft - setComposerText(nextDraft) - requestMainFocus() - - return true - } - - // Latest-closure ref so the (once-only) subscription always calls the current - // insertInlineRefs without re-subscribing every render. - const insertInlineRefsRef = useRef(insertInlineRefs) - insertInlineRefsRef.current = insertInlineRefs - - useEffect(() => { - return onComposerInsertRefsRequest(({ refs, target }) => { - if (target === 'main') { - insertInlineRefsRef.current(refs) - } - }) - }, []) - const [trigger, setTrigger] = useState(null) const [triggerActive, setTriggerActive] = useState(0) const [triggerItems, setTriggerItems] = useState([]) @@ -737,7 +416,20 @@ export function ChatBar({ // Pull the live contentEditable text into draftRef + the AUI composer state // (which drives `hasComposerPayload` → the send button). Shared by the input // and compositionend paths so committed IME text reaches state through either. + // A pending coalesced flush (rAF id). `composerPlainText` serializes the whole + // editor (O(n)), so running it on every event during a burst — holding a key, + // or holding Cmd+V into a growing editor — is O(n²) across the burst. The + // contentEditable DOM is the source of truth (submit + the compositionend / + // keydown paths re-read it synchronously), so collapsing the input/paste + // flushes to one per paint is lossless. + const flushRafRef = useRef(undefined) + const flushEditorToDraft = (editor: HTMLDivElement) => { + if (flushRafRef.current !== undefined) { + window.cancelAnimationFrame(flushRafRef.current) + flushRafRef.current = undefined + } + normalizeComposerEditorDom(editor) const nextDraft = composerPlainText(editor) @@ -750,6 +442,29 @@ export function ChatBar({ window.setTimeout(refreshTrigger, 0) } + // Coalesce the high-frequency input/paste flushes to one per frame. Immediate + // paths (compositionend, Enter/keydown, submit) keep calling + // flushEditorToDraft directly, which cancels any pending coalesced run first. + const scheduleFlushEditorToDraft = (editor: HTMLDivElement) => { + if (flushRafRef.current !== undefined) { + return + } + + flushRafRef.current = window.requestAnimationFrame(() => { + flushRafRef.current = undefined + flushEditorToDraft(editor) + }) + } + + useEffect( + () => () => { + if (flushRafRef.current !== undefined) { + window.cancelAnimationFrame(flushRafRef.current) + } + }, + [] + ) + const handleEditorInput = (event: FormEvent) => { // During IME composition the DOM contains uncommitted preedit text // mixed with real content. Skip state writes — compositionend flushes @@ -758,7 +473,7 @@ export function ChatBar({ return } - flushEditorToDraft(event.currentTarget) + scheduleFlushEditorToDraft(event.currentTarget) } const handlePaste = (event: ClipboardEvent) => { @@ -808,7 +523,7 @@ export function ChatBar({ event.preventDefault() insertPlainTextAtCaret(event.currentTarget, pastedText) - flushEditorToDraft(event.currentTarget) + scheduleFlushEditorToDraft(event.currentTarget) } const triggerAdapter: Unstable_TriggerAdapter | null = @@ -1214,8 +929,10 @@ export function ChatBar({ return } - // Otherwise Esc interrupts the running turn (Stop-button parity). - if (busy) { + // 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) { event.preventDefault() triggerHaptic('cancel') void Promise.resolve(onCancel()) @@ -1239,137 +956,15 @@ export function ChatBar({ window.setTimeout(refreshTrigger, 0) } - const resetDragState = () => { - dragDepthRef.current = 0 - setDragActive(false) - } - - const handleDragEnter = (event: ReactDragEvent) => { - if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - dragDepthRef.current += 1 - - if (!dragActive) { - setDragActive(true) - } - } - - const handleDragOver = (event: ReactDragEvent) => { - if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - event.dataTransfer.dropEffect = 'copy' - } - - const handleDragLeave = (event: ReactDragEvent) => { - if (!onAttachDroppedItems) { - return - } - - event.preventDefault() - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) - - if (dragDepthRef.current === 0) { - setDragActive(false) - } - } - - const handleDrop = (event: ReactDragEvent) => { - if (!onAttachDroppedItems) { - return - } - - event.preventDefault() - resetDragState() - - const candidates = extractDroppedFiles(event.dataTransfer) - - if (candidates.length === 0) { - return - } - - // In-app drags (project tree / gutter) are workspace-relative paths the - // gateway resolves directly, so they stay inline @file:/@line: refs. OS - // drops are absolute local paths a remote gateway can't read (and images - // need byte upload for vision), so route them through the upload pipeline. - const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) - const refs = droppedFileInlineRefs(inAppRefs, cwd) - - if (refs.length && insertInlineRefs(refs)) { - triggerHaptic('selection') - } - - if (osDrops.length) { - void Promise.resolve(onAttachDroppedItems(osDrops)).then(attached => { - if (attached) { - triggerHaptic('selection') - requestMainFocus() - } - }) - } - } - - const handleInputDragOver = (event: ReactDragEvent) => { - if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - event.preventDefault() - event.stopPropagation() - event.dataTransfer.dropEffect = 'copy' - } - - const handleInputDrop = (event: ReactDragEvent) => { - if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { - return - } - - const candidates = extractDroppedFiles(event.dataTransfer) - - if (!candidates.length) { - return - } - - event.preventDefault() - event.stopPropagation() - resetDragState() - - // Dropping straight onto the text box used to inline-ref *every* file — - // including OS/Finder drops, whose absolute local path a remote gateway - // can't read and whose image bytes never reached vision. Split by origin: - // in-app drags stay inline refs; OS drops go through the upload pipeline. - // (When no upload handler is wired, fall back to inline refs for all.) - const attach = onAttachDroppedItems - const { inAppRefs, osDrops } = partitionDroppedFiles(candidates) - const refs = droppedFileInlineRefs(attach ? inAppRefs : candidates, cwd) - - if (refs.length && insertInlineRefs(refs)) { - triggerHaptic('selection') - } - - if (attach && osDrops.length) { - void Promise.resolve(attach(osDrops)).then(attached => { - if (attached) { - triggerHaptic('selection') - requestMainFocus() - } - }) - } - } - - const clearDraft = useCallback(() => { - setComposerText('') - draftRef.current = '' - - if (editorRef.current) { - editorRef.current.replaceChildren() - } - }, [setComposerText]) + const { + dragActive, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleInputDragOver, + handleInputDrop + } = useComposerDrop({ cwd, insertInlineRefs, onAttachDroppedItems, requestMainFocus }) // Hand a worktree off to the controller: open a fresh session anchored there, // carrying the composer draft as its first turn. Clearing here means the draft @@ -1445,329 +1040,6 @@ export function ChatBar({ [cwd] ) - const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { - draftRef.current = text - setComposerText(text) - $composerAttachments.set(cloneAttachments(attachments)) - - const editor = editorRef.current - - if (editor) { - renderComposerContents(editor, text) - placeCaretEnd(editor) - } - } - - const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) => - stashSessionDraft(scope, text, attachments) - - // Per-thread draft swap — the composer's only session coupling. Lifecycle - // never clears composer state; this effect alone stashes on leave, restores - // on enter. Keyed writes are idempotent, so no skip-sentinel. - useEffect(() => { - const { attachments, text } = takeSessionDraft(activeQueueSessionKey) - loadIntoComposer(text, attachments) - - return () => { - const editing = queueEditRef.current - - if (editing?.sessionKey === activeQueueSessionKey) { - stashAt(activeQueueSessionKey, editing.draft, editing.attachments) - } else if (!isBrowsingHistory(sessionId)) { - stashAt(activeQueueSessionKey) - } - } - }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps - - // Debounced stash into the active scope. Skipped while browsing history or - // editing a queued prompt — recalled text must not clobber the real draft. - useEffect(() => { - if (isBrowsingHistory(sessionId) || queueEdit) { - return - } - - pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft } - - const handle = window.setTimeout(() => { - pendingDraftPersistRef.current = null - stashAt(activeQueueSessionKey, draft) - }, DRAFT_PERSIST_DEBOUNCE_MS) - - return () => window.clearTimeout(handle) - }, [activeQueueSessionKey, draft, queueEdit, sessionId]) - - // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R - // inside the debounce window would drop trailing keystrokes without this. - useEffect(() => { - const flushPendingDraftPersist = () => { - const pending = pendingDraftPersistRef.current - - if (!pending) { - return - } - - pendingDraftPersistRef.current = null - stashAt(pending.scope, pending.text) - } - - window.addEventListener('pagehide', flushPendingDraftPersist) - - return () => { - window.removeEventListener('pagehide', flushPendingDraftPersist) - flushPendingDraftPersist() - } - }, []) - - const beginQueuedEdit = (entry: QueuedPromptEntry) => { - if (!activeQueueSessionKey || queueEdit) { - return - } - - setQueueEdit({ - attachments: cloneAttachments($composerAttachments.get()), - draft: draftRef.current, - entryId: entry.id, - sessionKey: activeQueueSessionKey - }) - loadIntoComposer(entry.text, entry.attachments) - triggerHaptic('selection') - focusInput() - } - - // Walk queued entries while editing (ArrowUp = older, ArrowDown = newer), - // saving the in-progress edit on each step. Stepping newer past the last - // entry exits edit mode and restores the pre-edit draft. - const stepQueuedEdit = (direction: -1 | 1) => { - if (!queueEdit) { - return false - } - - const index = queuedPrompts.findIndex(e => e.id === queueEdit.entryId) - const target = index + direction - - if (index < 0 || target < 0) { - return index >= 0 // at the oldest: swallow; missing entry: let it fall through - } - - const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { - attachments: cloneAttachments($composerAttachments.get()), - text: draftRef.current - }) - - const next = queuedPrompts[target] - - if (next) { - setQueueEdit({ ...queueEdit, entryId: next.id }) - loadIntoComposer(next.text, next.attachments) - } else { - setQueueEdit(null) - loadIntoComposer(queueEdit.draft, queueEdit.attachments) - } - - triggerHaptic(saved ? 'success' : 'selection') - focusInput() - - return true - } - - const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => { - if (!queueEdit) { - return false - } - - if (action === 'save') { - const text = draftRef.current - const next = cloneAttachments($composerAttachments.get()) - - if (!text.trim() && next.length === 0) { - return false - } - - const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text }) - triggerHaptic(saved ? 'success' : 'selection') - } else { - triggerHaptic('cancel') - } - - loadIntoComposer(queueEdit.draft, queueEdit.attachments) - setQueueEdit(null) - focusInput() - - return true - } - - const queueCurrentDraft = useCallback(() => { - if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) { - return false - } - - if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) { - return false - } - - clearDraft() - clearComposerAttachments() - triggerHaptic('selection') - - return true - }, [activeQueueSessionKey, attachments, clearDraft, draft]) - - // Steer the live turn (nudge without interrupting). Clears the draft up front - // for snappy feedback; if the gateway rejects (no live tool window) the words - // are re-queued so nothing is lost — same safety net as a plain queue. - const steerDraft = useCallback(() => { - if (!onSteer || !canSteer) { - return - } - - const text = draftRef.current.trim() - - triggerHaptic('submit') - clearDraft() - - void Promise.resolve(onSteer(text)).then(accepted => { - if (!accepted && activeQueueSessionKey) { - enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments: [] }) - } - }) - }, [activeQueueSessionKey, canSteer, clearDraft, onSteer]) - - // All queue drain paths share one lock + send-then-remove sequence. - // `pickEntry` lets each caller choose head, by-id, or skip-edited. - const runDrain = useCallback( - async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise => { - if (drainingQueueRef.current || !activeQueueSessionKey) { - return false - } - - const entry = pickEntry(queuedPrompts) - - if (!entry) { - return false - } - - drainingQueueRef.current = true - - try { - const accepted = await Promise.resolve( - onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }) - ) - - if (accepted === false) { - return false - } - - drainFailuresRef.current.delete(entry.id) - removeQueuedPrompt(activeQueueSessionKey, entry.id) - resetBrowseState(sessionId) - - return true - } finally { - drainingQueueRef.current = false - } - }, - [activeQueueSessionKey, onSubmit, queuedPrompts, sessionId] - ) - - const pickDrainHead = useCallback( - (entries: QueuedPromptEntry[]) => { - const skip = queueEditRef.current?.entryId - - return skip ? entries.find(e => e.id !== skip) : entries[0] - }, - [] // reads the edit id off a ref so the lock-holder always sees the latest - ) - - const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain]) - - const sendQueuedNow = useCallback( - (id: string) => { - if (!activeQueueSessionKey || id === queueEdit?.entryId) { - return false - } - - if (busy) { - // Promote to the head, then interrupt. The gateway always emits a - // settle (message.complete + session.info running:false) when the - // turn unwinds, and the busy→false auto-drain below sends this entry. - promoteQueuedPrompt(activeQueueSessionKey, id) - triggerHaptic('selection') - void Promise.resolve(onCancel()) - - return true - } - - // A manual send clears the auto-drain backoff so a stuck entry the user - // taps gets a fresh attempt (and re-enables auto-retry on success). - drainFailuresRef.current.delete(id) - - return runDrain(entries => entries.find(e => e.id === id)) - }, - [activeQueueSessionKey, busy, onCancel, queueEdit, runDrain] - ) - - // Edge-independent auto-drain: send the head whenever the session is idle and - // the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g. - // a stale-session 404) can't strand the entry permanently nor spin-loop. The - // drain lock serializes sends; a remount/reconnect resets the failure counts. - const autoDrainNext = useCallback(() => { - if (busy || drainingQueueRef.current || !activeQueueSessionKey) { - return - } - - const entry = pickDrainHead(queuedPrompts) - - if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) { - return - } - - const onFail = () => { - const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1 - drainFailuresRef.current.set(entry.id, fails) - - if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) { - notify({ - id: 'composer-queue-stuck', - kind: 'error', - title: t.composer.queueStuckTitle, - message: t.composer.queueStuckBody - }) - } - } - - void runDrain(() => entry) - .then(sent => { - if (!sent) { - onFail() - } - }) - .catch(onFail) - }, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t]) - - // Re-key on a runtime session-id change. A stable stored id (queueSessionKey) - // never churns, so a change there is a real session switch and must NOT - // migrate; only the runtime-derived key (queueSessionKey falsy → key is - // sessionId) churns on a backend bounce/resume of the same conversation. - useEffect(() => { - const prev = prevQueueKeyRef.current - prevQueueKeyRef.current = activeQueueSessionKey - - if (queueSessionKey || !prev || !activeQueueSessionKey || prev === activeQueueSessionKey) { - return - } - - migrateQueuedPrompts(prev, activeQueueSessionKey) - }, [activeQueueSessionKey, queueSessionKey]) - - // Queued turns flow whenever the session is idle — on the busy→false settle - // edge, on mount/reconnect, and after a re-key — so a swallowed edge can't - // strand them. To cancel queued turns, the user deletes them from the panel. - useEffect(() => { - if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) { - autoDrainNext() - } - }, [autoDrainNext, busy, queuedPrompts.length]) - // Esc cancels the in-flight turn when the CHAT has focus — not just the // composer input (which has its own handler above). Clicking into the // transcript and hitting Esc now stops the run, matching the Stop button. @@ -1779,7 +1051,10 @@ export function ChatBar({ const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {}) escCancelRef.current = (event: globalThis.KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented || !busy) { + // `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) { return } @@ -1805,117 +1080,6 @@ export function ChatBar({ return () => window.removeEventListener('keydown', onKeyDown) }, []) - // Queue-edit cleanup: on session swap the scope effect already stashed the - // edit snapshot; only restore into the composer when still on the same scope. - useEffect(() => { - if (!queueEdit) { - return - } - - if (queueEdit.sessionKey === activeQueueSessionKey) { - if (editingQueuedPrompt) { - return - } - - loadIntoComposer(queueEdit.draft, queueEdit.attachments) - } - - setQueueEdit(null) - }, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps - - const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => { - const submittedScope = activeQueueSessionKeyRef.current - const submittedAttachments = attachments ?? [] - - const restore = () => { - loadIntoComposer(text, submittedAttachments) - stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments) - } - - void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text)) - .then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope))) - .catch(restore) - } - - // External "submit this prompt" requests (e.g. the review pane's agent-ship - // button) route through the same send path. A ref keeps the listener stable - // while always calling the latest dispatchSubmit closure. - const dispatchSubmitRef = useRef(dispatchSubmit) - dispatchSubmitRef.current = dispatchSubmit - - useEffect( - () => - onComposerSubmitRequest(({ target, text }) => { - if (target === 'main' && !inputDisabled) { - dispatchSubmitRef.current(text) - } - }), - [inputDisabled] - ) - - const submitDraft = () => { - if (disabled) { - return - } - - // Source the text from the DOM editor, not React state. The AUI composer - // state (`draft`) and the derived `hasComposerPayload` lag the DOM by a - // render, so on fast typing or IME composition the final keystroke(s) may - // not have synced yet — reading state here drops the message (Enter looks - // like it does nothing; typing a trailing space only "fixes" it because the - // extra input event forces a state sync). draftRef is updated on every - // input event; refresh it from the editor once more to also cover an - // in-flight keystroke that hasn't fired its input event yet. - const editor = editorRef.current - - if (editor) { - const domText = composerPlainText(editor) - - if (domText !== draftRef.current) { - draftRef.current = domText - setComposerText(domText) - } - } - - const text = draftRef.current - const payloadPresent = text.trim().length > 0 || attachments.length > 0 - - if (queueEdit) { - exitQueuedEdit('save') - } else if (busy) { - // Slash commands should execute immediately even while the agent is - // busy — they're client-side operations (/yolo, /skin, /new, /help, - // etc.) or self-contained gateway RPCs (/status, /compress). onSubmit - // routes them to executeSlashCommand, which has its own per-command - // busy guard for commands that genuinely need an idle session (skill - // /send directives). Queuing them would make every slash command wait - // for the current turn to finish, which is how the TUI never behaves. - if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) { - triggerHaptic('submit') - clearDraft() - dispatchSubmit(text) - } else if (payloadPresent) { - queueCurrentDraft() - } else { - // Stop button (the only way to reach here while busy with an empty - // composer — empty Enter is short-circuited in the keydown handler). - triggerHaptic('cancel') - void Promise.resolve(onCancel()) - } - } else if (!payloadPresent && queuedPrompts.length > 0) { - void drainNextQueued() - } else if (payloadPresent) { - const submittedAttachments = cloneAttachments(attachments) - triggerHaptic('submit') - resetBrowseState(sessionId) - clearDraft() - clearComposerAttachments() - dispatchSubmit(text, submittedAttachments) - } - - focusInput() - } - const submitUrl = () => { const url = urlValue.trim() @@ -1934,82 +1098,27 @@ export function ChatBar({ setUrlOpen(false) } - const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ + const { + conversation, + dictate, + endConversation, + handleToggleAutoSpeak, + startConversation, + voiceActivityState, + voiceConversationActive, + voiceStatus + } = useComposerVoice({ + busy, + clearDraft, + disabled, focusInput, + insertText, maxRecordingSeconds, - onTranscript: insertText, - onTranscribeAudio - }) - - const pendingResponse = () => { - const messages = $messages.get() - const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) - - if (!last || last.id === lastSpokenIdRef.current) { - return null - } - - const text = chatMessageText(last).trim() - - if (!text) { - return null - } - - return { - id: last.id, - pending: Boolean(last.pending), - text - } - } - - const consumePendingResponse = () => { - const messages = $messages.get() - const last = messages.findLast(m => m.role === 'assistant' && !m.hidden) - - if (last) { - lastSpokenIdRef.current = last.id - } - } - - const submitVoiceTurn = async (text: string) => { - if (busy) { - return - } - - triggerHaptic('submit') - resetBrowseState(sessionId) - clearDraft() - await onSubmit(text) - } - - const conversation = useVoiceConversation({ - busy, - consumePendingResponse, - enabled: voiceConversationActive, - onFatalError: () => setVoiceConversationActive(false), - onSubmit: submitVoiceTurn, + onSubmit, onTranscribeAudio, - pendingResponse + sessionId }) - // The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting - // with STT unconfigured lets the conversation surface its own "configure - // speech-to-text" notice rather than silently no-opping. - const toggleVoiceConversation = useCallback(() => { - if (disabled) { - return - } - - if (voiceConversationActive) { - setVoiceConversationActive(false) - void conversation.end() - } else { - setVoiceConversationActive(true) - } - }, [conversation, disabled, voiceConversationActive]) - - useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation]) - const contextMenu = ( { - setVoiceConversationActive(false) - void conversation.end() - }, - onStart: () => setVoiceConversationActive(true), + onEnd: endConversation, + onStart: startConversation, onStopTurn: conversation.stopTurn, onToggleMute: conversation.toggleMute, status: conversation.status @@ -2049,6 +1156,7 @@ export function ChatBar({ hasComposerPayload={hasComposerPayload} onDictate={dictate} onSteer={steerDraft} + onToggleAutoSpeak={handleToggleAutoSpeak} state={state} voiceStatus={voiceStatus} /> @@ -2134,7 +1242,12 @@ export function ChatBar({ {dragging && poppedOut && (
+ } + label={c.queued(entries.length)} + > {entries.map(entry => { const isEditing = editingId === entry.id const attachmentsCount = entry.attachments.length @@ -52,7 +56,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN type="button" variant="ghost" > - + @@ -65,7 +69,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN type="button" variant="ghost" > - + @@ -77,7 +81,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN type="button" variant="ghost" > - + diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index 93c8a2dc1af4..fd360764da45 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -35,11 +35,11 @@ const BACKGROUND_POLL_MS = 5_000 // letting dead URLs pile up. File previews (a real on-disk artifact) stand alone. const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target) -// Real codicons per group (no sparkles): a checklist for todos, a bot for -// subagents, a background process glyph for background tasks. +// Real codicons per group (no sparkles): a checklist for todos, the agent glyph +// for subagents, a background process glyph for background tasks. const GROUP_ICON: Record = { todo: 'checklist', - subagent: 'hubot', + subagent: 'agent', background: 'server-process' } @@ -118,48 +118,59 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const hasBackgroundGroup = groups.some(g => g.type === 'background') - const sections: { key: string; node: ReactNode }[] = groups.map(group => ({ - key: group.type, - node: ( - - {t.statusStack.agents} - - ) : undefined - } - defaultCollapsed={group.type !== 'todo'} - icon={} - label={groupLabel(group, t.statusStack)} - > - {group.items.map(item => ( - dismissBackgroundProcess(sessionId, id) : undefined} - onOpen={() => openSubagent(item)} - onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined} - /> - ))} - {group.type === 'background' && previewRows} - - ) - })) + const previewBlock =
{previewRows}
- // No background group to host them (e.g. a standalone on-disk file preview): - // keep the previews as their own row block so they don't disappear. - if (previewRows.length > 0 && !hasBackgroundGroup) { + const sections: { key: string; node: ReactNode }[] = [] + + for (const group of groups) { sections.push({ - key: 'preview', - node:
{previewRows}
+ key: group.type, + node: ( + + {t.statusStack.agents} + + ) : undefined + } + defaultCollapsed={group.type !== 'todo'} + icon={} + label={groupLabel(group, t.statusStack)} + > + {group.items.map(item => ( + dismissBackgroundProcess(sessionId, id) : undefined} + onOpen={() => openSubagent(item)} + onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined} + /> + ))} + + ) }) + + // Preview links belong to the background group (a localhost dev server and + // its preview are the same thing), but they must stay VISIBLE even when that + // group is collapsed — the whole point is a one-tap open. Render them as an + // always-visible block right after the background section, not as collapsible + // children that get swallowed the moment a background task appears. + if (group.type === 'background' && previewRows.length > 0) { + sections.push({ key: 'preview', node: previewBlock }) + } + } + + // No background group to host them (e.g. a standalone on-disk file preview): + // still render them as their own always-visible block. + if (previewRows.length > 0 && !hasBackgroundGroup) { + sections.push({ key: 'preview', node: previewBlock }) } if (queue) { diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx index bc54b92ffe94..68962cb7295f 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -1,10 +1,9 @@ -import { Fragment, memo, type ReactNode, useState } from 'react' +import { Fragment, memo, type ReactNode } 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' @@ -82,7 +81,6 @@ 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' @@ -94,8 +92,10 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp : null const canOpen = item.type === 'subagent' && !!onOpen - const hasOutput = item.type === 'background' && !!item.output - const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined + + // 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 return ( @@ -146,9 +146,7 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp {s.exit(item.exitCode)} )} - {hasOutput && } - {hasOutput && outputOpen && } ) }) diff --git a/apps/desktop/src/app/chat/composer/voice-activity.tsx b/apps/desktop/src/app/chat/composer/voice-activity.tsx index 535d1422e45a..bd1f33036218 100644 --- a/apps/desktop/src/app/chat/composer/voice-activity.tsx +++ b/apps/desktop/src/app/chat/composer/voice-activity.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react' import { Button } from '@/components/ui/button' import { useI18n } from '@/i18n' -import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons' +import { iconSize, Loader2, Mic, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' import { stopVoicePlayback } from '@/lib/voice-playback' import { $voicePlayback } from '@/store/voice-playback' @@ -188,7 +188,7 @@ export function VoiceActivity({ state }: { state: VoiceActivityState }) { recording ? 'bg-primary/15 text-primary' : 'bg-primary/10 text-primary' )} > - {recording ? : } + {recording ? : }
@@ -229,7 +229,7 @@ export function VoicePlaybackActivity() { role="status" >
- {preparing ? : } + {preparing ? : }
@@ -244,7 +244,7 @@ export function VoicePlaybackActivity() { type="button" variant="ghost" > - + Stop
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index ecc13808413a..a8afdd128306 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -5,6 +5,7 @@ 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, @@ -262,7 +263,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway const pickContextPaths = useCallback( async (kind: 'file' | 'folder') => { - const paths = await window.hermesDesktop?.selectPaths({ + const paths = await selectDesktopPaths({ title: kind === 'file' ? 'Add files as context' : 'Add folders as context', defaultPath: currentCwd || undefined, directories: kind === 'folder' @@ -347,7 +348,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway attachToMain(baseAttachment) try { - const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath) + const previewUrl = await readDesktopFileDataUrl(filePath) if (previewUrl) { addComposerAttachment({ ...baseAttachment, previewUrl }) @@ -395,7 +396,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway ) const pickImages = useCallback(async () => { - const paths = await window.hermesDesktop?.selectPaths({ + const paths = await selectDesktopPaths({ title: copy.attachImages, defaultPath: currentCwd || undefined, filters: [ diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index b61df2337b71..a5216210a799 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -45,7 +45,7 @@ import { $sessions, sessionPinId } from '@/store/session' -import { isSecondaryWindow } from '@/store/windows' +import { isSecondaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' @@ -342,8 +342,9 @@ 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. - const showChatBar = !loadingSession && !resumeExhausted + // 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() const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') const modelOptionsQuery = useQuery({ diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx index 3963aaf3dbd9..7815d1fafcf3 100644 --- a/apps/desktop/src/app/chat/sidebar/chrome.tsx +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -1,6 +1,7 @@ import type * as React from 'react' import { Codicon } from '@/components/ui/codicon' +import { RowButton } from '@/components/ui/row-button' import { cn } from '@/lib/utils' // Shared, content-agnostic sidebar chrome — used by both the flat session @@ -64,7 +65,7 @@ export function SidebarRowCluster({ className, ...props }: React.ComponentProps< /** Session row main tap target. */ export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) { - return + ) } diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 19665341f3d6..89e719f77600 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -1,19 +1,5 @@ -import { - closestCenter, - DndContext, - type DragEndEvent, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors -} from '@dnd-kit/core' -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy -} from '@dnd-kit/sortable' +import { KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' +import { sortableKeyboardCoordinates } from '@dnd-kit/sortable' import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -21,7 +7,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PlatformAvatar } from '@/app/messaging/platform-icon' 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 { KbdGroup } from '@/components/ui/kbd' import { SearchField } from '@/components/ui/search-field' @@ -34,13 +19,10 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' -import { Skeleton } from '@/components/ui/skeleton' -import type { HermesGitWorktree } from '@/global' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' import { comboTokens } from '@/lib/keybinds/combo' import { profileColor } from '@/lib/profile-color' -import { flattenSessionsWithBranches } from '@/lib/session-branch-tree' import { sessionMatchesSearch } from '@/lib/session-search' import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source' import { cn } from '@/lib/utils' @@ -114,37 +96,31 @@ import { } from '@/store/session' import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' -import { SidebarPanelLabel } from '../../shell/sidebar-label' import type { SidebarNavItem } from '../../types' -import { countLabel, SidebarCount } from './chrome' +import { countLabel } from './chrome' import { SidebarCronJobsSection } from './cron-jobs-section' import { SidebarLoadMoreRow } from './load-more-row' -import { reconcileFreshFirst, resolveManualSessionOrderIds } from './order' +import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order' import { ProfileRail } from './profile-switcher' import { ProjectDialog } from './project-dialog' import { - EnteredProjectContent, overlayLiveLanes, overlayLivePreviews, PROJECT_PREVIEW_COUNT, ProjectBackRow, ProjectMenu, - ProjectOverviewRow, projectTreeCwd, sessionRecency as sessionTime, type SidebarProjectTree, type SidebarSessionGroup, - SidebarWorkspaceGroup, type SidebarWorkspaceTree, sortProjectsForOverview, StartWorkButton, useRepoWorktreeMap } from './projects' -import { SidebarSessionRow } from './session-row' -import { VirtualSessionList } from './virtual-session-list' - -const VIRTUALIZE_THRESHOLD = 25 +import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states' +import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section' // Non-session groups (messaging platforms) stay compact: show a few rows up // front, reveal more in larger steps on demand. Keeps a busy platform from @@ -196,108 +172,6 @@ const HEADER_ACTION_BTN = const HEADER_NAV_BTN = 'text-(--ui-text-tertiary) opacity-70 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100' -// Sidebar reordering is a strictly vertical list. The dragged item's transform -// is rendered Y-only in useSortableBindings (no x, no scale); this just stops -// dnd-kit's auto-scroll from dragging the rail — or the window — sideways when -// the pointer nears an edge, killing the horizontal "drag to valhalla". -const reorderAutoScroll = { threshold: { x: 0, y: 0.2 } } - -// One self-contained, nesting-safe reorderable list. It owns its DndContext, so a -// drag only ever collides with THIS list's own items — drop it at any depth (repos, -// worktrees, sessions) and reordering "just works" without leaking into the lists -// around or inside it. Pair each item with useSortableBindings(id); the list reports -// the new id order and the caller persists it. This is the single generic primitive -// behind every reorderable surface in the sidebar. -function ReorderableList({ - children, - ids, - onReorder, - sensors -}: { - children: React.ReactNode - ids: string[] - onReorder: (ids: string[]) => void - sensors?: ReturnType -}) { - const handleDragEnd = ({ activatorEvent, active, over }: DragEndEvent) => { - // dnd-kit only restores focus for keyboard drags; after a pointer drop the - // browser leaves :focus on the grab handle, which keeps a focus-within - // grabber/affordance reveal stuck "on". Drop that focus so the row returns - // to its resting state once the pointer moves away. - if (!(activatorEvent instanceof KeyboardEvent)) { - ;(document.activeElement as HTMLElement | null)?.blur() - } - - if (!over || active.id === over.id) { - return - } - - const from = ids.indexOf(String(active.id)) - const to = ids.indexOf(String(over.id)) - - if (from >= 0 && to >= 0) { - onReorder(arrayMove(ids, from, to)) - } - } - - return ( - - - {children} - - - ) -} - -function orderByIds(items: T[], getId: (item: T) => string, orderIds: string[]): T[] { - if (!orderIds.length) { - return items - } - - const byId = new Map(items.map(item => [getId(item), item])) - const seen = new Set() - const ordered: T[] = [] - - for (const id of orderIds) { - const item = byId.get(id) - - if (item) { - ordered.push(item) - seen.add(id) - } - } - - // Items missing from the persisted order are new since it was last - // reconciled. Callers pass recency-sorted lists (newest first), so surface - // these at the TOP instead of burying them beneath the saved order — - // otherwise a brand-new session sinks to the bottom of the sidebar and reads - // as "my latest session never showed up". - const fresh = items.filter(item => !seen.has(getId(item))) - - return fresh.length ? [...fresh, ...ordered] : ordered -} - -function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { - if (!currentIds.length) { - return [] - } - - if (!orderIds.length) { - return currentIds - } - - return reconcileFreshFirst(currentIds, orderIds) -} - -function sameIds(left: string[], right: string[]) { - return left.length === right.length && left.every((item, index) => item === right[index]) -} - // FTS results cover sessions that aren't in the loaded page; synthesize a // minimal SessionInfo so they render in the same row component (resume works // by id; the snippet stands in for the preview). @@ -324,25 +198,6 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo { } } -function useSortableBindings(id: string) { - const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) - - return { - dragging: isDragging, - dragHandleProps: { ...attributes, ...listeners }, - ref: setNodeRef, - reorderable: true as const, - style: { - // Uniform vertical list: only ever translate on Y. Ignoring x and the - // scaleX/scaleY that CSS.Transform.toString would emit keeps a dragged - // group/row from drifting sideways or morphing its size mid-drag. - transform: transform ? `translate3d(0px, ${transform.y}px, 0)` : undefined, - transition: isDragging ? undefined : transition, - willChange: isDragging ? 'transform' : undefined - } - } -} - interface ChatSidebarProps extends React.ComponentProps { currentView: AppView onNavigate: (item: SidebarNavItem) => void @@ -1149,7 +1004,7 @@ export function ChatSidebar({ const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 - const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 + const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 || projectModel.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. @@ -1537,7 +1392,7 @@ export function ChatSidebar({
)} - {contentVisible && !showSessionSections &&
} + {contentVisible && !showSessionSections && } {contentVisible && (
@@ -1550,87 +1405,6 @@ export function ChatSidebar({ ) } -interface SidebarSectionHeaderProps { - label: string - open: boolean - onToggle: () => void - action?: React.ReactNode - meta?: React.ReactNode - icon?: React.ReactNode - // When false the section can't be collapsed: the label renders static (no - // toggle, no caret) and the section is always open. Used for the single- - // project view, where collapsing one project makes no sense. - collapsible?: boolean -} - -function SidebarSectionHeader({ - label, - open, - onToggle, - action, - meta, - icon, - collapsible = true -}: SidebarSectionHeaderProps) { - const labelBody = ( - <> - {icon} - {label} - {meta && {meta}} - - ) - - return ( -
- {collapsible ? ( - - ) : ( -
{labelBody}
- )} - {action} -
- ) -} - -function SidebarSessionSkeletons() { - return ( - - ) -} - -function SidebarPinnedEmptyState() { - const { t } = useI18n() - - return ( -
- - - - {t.sidebar.shiftClickHint} -
- ) -} - interface MessagingSection { sourceId: string label: string @@ -1638,302 +1412,3 @@ interface MessagingSection { total: number hasMore: boolean } - -interface SidebarSessionsSectionProps { - label: string - open: boolean - onToggle: () => void - sessions: SessionInfo[] - activeSessionId: null | string - workingSessionIdSet: Set - onResumeSession: (sessionId: string) => void - onDeleteSession: (sessionId: string) => void - onArchiveSession: (sessionId: string) => void - onBranchSession?: (sessionId: string, profile?: string) => void - onTogglePin: (sessionId: string) => void - onNewSessionInWorkspace?: (path: null | string) => void - pinned: boolean - rootClassName?: string - contentClassName?: string - emptyState: React.ReactNode - forceEmptyState?: boolean - headerAction?: React.ReactNode - footer?: React.ReactNode - groups?: SidebarSessionGroup[] - tree?: SidebarWorkspaceTree[] - // Project overview: when present, render a drill-in list of project rows - // instead of sessions. Clicking a row enters that project (onEnterProject), - // which then passes `projectContent` on the next render. Takes precedence - // over `tree` / `groups`. - projectOverview?: SidebarProjectTree[] - // Per-project preview rows (from the backend tree), keyed by project path. - projectOverviewPreviews?: Record - // True while the backend project tree is loading (overview skeleton). - projectsLoading?: boolean - onEnterProject?: (id: string) => void - // The entered project's flattened content: main-checkout sessions render - // directly (no redundant repo/branch header); only linked worktrees nest. - projectContent?: SidebarProjectTree - // Live git lanes (`git worktree list`) for repos in the entered project — - // a VISUAL enhancer only (empty lanes), never session membership. - projectRepoWorktrees?: Record - // Live session cache used for optimistic placement inside entered-project lanes. - liveSessions?: SessionInfo[] - // Client-side optimistic eviction layer (deleted/archived ids). - removedSessionIds?: ReadonlySet - activeProjectId?: null | string - labelMeta?: React.ReactNode - labelIcon?: React.ReactNode - // When false the section header is static (no caret/toggle) and always open. - collapsible?: boolean - sortable?: boolean - // The flat session list is the only hand-reorderable surface (grouped/project - // views sort deterministically), so it owns the one ReorderableList. - onReorderSessions?: (ids: string[]) => void - // Drag-to-reorder for the project overview list (top-level projects). - onReorderProjects?: (ids: string[]) => void - // Rendered atop the entered-project body (a "back to overview" row). - projectBackRow?: React.ReactNode - dndSensors?: ReturnType -} - -function SidebarSessionsSection({ - label, - open, - onToggle, - sessions, - activeSessionId, - workingSessionIdSet, - onResumeSession, - onDeleteSession, - onArchiveSession, - onBranchSession, - onTogglePin, - onNewSessionInWorkspace, - pinned, - rootClassName, - contentClassName, - emptyState, - forceEmptyState = false, - headerAction, - footer, - groups, - projectOverview, - projectOverviewPreviews, - projectsLoading = false, - onEnterProject, - projectContent, - projectRepoWorktrees, - liveSessions, - removedSessionIds, - activeProjectId, - labelMeta, - labelIcon, - collapsible = true, - sortable = false, - onReorderSessions, - onReorderProjects, - projectBackRow, - dndSensors -}: SidebarSessionsSectionProps) { - const sectionOpen = collapsible ? open : true - const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0)) - // A defined project list is itself content (even an empty project should - // render as a drill-in row so the user can see it exists). - const hasProjectOverview = Boolean(projectOverview?.length) - const hasProjectContent = Boolean(projectContent && projectContent.sessionCount > 0) - - const showEmptyState = - forceEmptyState || (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0) - - // The flat recents/pinned list is the only place sessions reorder by hand; - // grouped/tree views always sort by creation date and never drag. - const sessionsDraggable = sortable && !!onReorderSessions - const displayEntries = useMemo(() => flattenSessionsWithBranches(sessions), [sessions]) - - const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => { - const rowProps = { - branchStem, - isPinned: pinned, - isSelected: session.id === activeSessionId, - isWorking: workingSessionIdSet.has(session.id), - onArchive: () => onArchiveSession(session.id), - onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined, - onDelete: () => onDeleteSession(session.id), - onPin: () => onTogglePin(sessionPinId(session)), - onResume: () => onResumeSession(session.id), - reorderable: draggable && !branchStem, - session - } - - return draggable && !branchStem ? ( - - ) : ( - - ) - } - - // Sessions inside repos/worktrees are date-ordered and static. - const renderRows = (items: SessionInfo[]) => - flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem)) - - const flatVirtualized = - !showEmptyState && - !groups?.length && - !projectOverview?.length && - !projectContent && - sessions.length >= VIRTUALIZE_THRESHOLD - - // First paint into the grouped view (e.g. the app restoring the Projects tab) - // has flat recents in `sessions` but no tree yet. Show skeletons rather than - // flashing the flat session list until the overview/content/groups resolve. A - // background refresh keeps the prior tree, so this only fires when empty. - const showProjectsSkeleton = - projectsLoading && !hasProjectOverview && !hasProjectContent && !projectContent && !groups?.length - - let inner: React.ReactNode - - if (showProjectsSkeleton) { - inner = - } else if (projectContent) { - // Entered a project: the back row is always present, then either the - // (overlay-aware) content or a clean empty state — never a bare spinner or a - // blank pane while lanes hydrate. - inner = ( - <> - {projectBackRow} - {hasProjectContent ? ( - - ) : ( - emptyState - )} - - ) - } else if (showEmptyState) { - inner = emptyState - } else if (projectOverview?.length) { - // The model is already ordered (default sort groups explicit-before-auto; - // a manual drag-order, when present, wins). Render in that order and make - // rows drag-to-reorder when a handler is wired. - const projectsDraggable = projectOverview.length > 1 && !!onReorderProjects - const Row = projectsDraggable ? SortableProjectOverviewRow : ProjectOverviewRow - - const rows = projectOverview.map(project => ( - - )) - - inner = - projectsDraggable && onReorderProjects ? ( - project.id)} - onReorder={onReorderProjects} - sensors={dndSensors} - > - {rows} - - ) : ( - rows - ) - } else if (groups?.length) { - // Profile/source groups never reorder; render them flat with static rows. - inner = groups.map(group => ( - - )) - } else if (flatVirtualized) { - const virtual = ( - - ) - - inner = - sessionsDraggable && onReorderSessions ? ( - s.id)} onReorder={onReorderSessions} sensors={dndSensors}> - {virtual} - - ) : ( - virtual - ) - } else if (sessionsDraggable && onReorderSessions) { - inner = ( - s.id)} onReorder={onReorderSessions} sensors={dndSensors}> - {displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))} - - ) - } else { - inner = displayEntries.map(({ branchStem, session }) => renderRow(session, false, branchStem)) - } - - // The virtualizer owns its own scroller, so suppress the wrapper's overflow - // to avoid a double scroll container. - const resolvedContentClassName = cn(contentClassName, flatVirtualized && 'overflow-y-visible') - - return ( - - - {sectionOpen && ( - - {inner} - {footer} - - )} - - ) -} - -interface SortableSessionRowProps { - session: SessionInfo - isPinned: boolean - isSelected: boolean - isWorking: boolean - onArchive: () => void - onDelete: () => void - onPin: () => void - onResume: () => void -} - -function SortableSidebarSessionRow(props: SortableSessionRowProps) { - return -} - -function SortableProjectOverviewRow(props: React.ComponentProps) { - return -} diff --git a/apps/desktop/src/app/chat/sidebar/order.test.ts b/apps/desktop/src/app/chat/sidebar/order.test.ts index f65b08e260ca..e1a48bc5fbf5 100644 --- a/apps/desktop/src/app/chat/sidebar/order.test.ts +++ b/apps/desktop/src/app/chat/sidebar/order.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { resolveManualSessionOrderIds } from './order' +import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order' describe('resolveManualSessionOrderIds', () => { it('clears legacy auto-seeded order until the user manually reorders sessions', () => { @@ -19,3 +19,44 @@ describe('resolveManualSessionOrderIds', () => { expect(resolveManualSessionOrderIds(['newest'], ['gone'], true)).toEqual([]) }) }) + +describe('orderByIds', () => { + const id = (item: { id: string }) => item.id + + it('returns items untouched when no order is given', () => { + const items = [{ id: 'a' }, { id: 'b' }] + expect(orderByIds(items, id, [])).toBe(items) + }) + + it('reorders by the given ids and drops missing ones', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + expect(orderByIds(items, id, ['c', 'gone', 'a'])).toEqual([{ id: 'b' }, { id: 'c' }, { id: 'a' }]) + }) + + it('surfaces items absent from the order first', () => { + const items = [{ id: 'fresh' }, { id: 'a' }, { id: 'b' }] + expect(orderByIds(items, id, ['b', 'a'])).toEqual([{ id: 'fresh' }, { id: 'b' }, { id: 'a' }]) + }) +}) + +describe('reconcileOrderIds', () => { + it('returns empty for no current ids', () => { + expect(reconcileOrderIds([], ['a'])).toEqual([]) + }) + + it('returns current ids when there is no saved order', () => { + expect(reconcileOrderIds(['a', 'b'], [])).toEqual(['a', 'b']) + }) + + it('puts newly-seen ids ahead of the retained saved order', () => { + expect(reconcileOrderIds(['fresh', 'a', 'b'], ['b', 'a', 'gone'])).toEqual(['fresh', 'b', 'a']) + }) +}) + +describe('sameIds', () => { + it('is true only for identical ordered lists', () => { + expect(sameIds(['a', 'b'], ['a', 'b'])).toBe(true) + expect(sameIds(['a', 'b'], ['b', 'a'])).toBe(false) + expect(sameIds(['a'], ['a', 'b'])).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/order.ts b/apps/desktop/src/app/chat/sidebar/order.ts index 97225ac5a4c1..9cefea57d01b 100644 --- a/apps/desktop/src/app/chat/sidebar/order.ts +++ b/apps/desktop/src/app/chat/sidebar/order.ts @@ -21,3 +21,50 @@ export function resolveManualSessionOrderIds(currentIds: string[], orderIds: str return reconcileFreshFirst(currentIds, orderIds) } + +/** Reorder `items` by `orderIds`; items missing from the order surface first. */ +export function orderByIds(items: T[], getId: (item: T) => string, orderIds: string[]): T[] { + if (!orderIds.length) { + return items + } + + const byId = new Map(items.map(item => [getId(item), item])) + const seen = new Set() + const ordered: T[] = [] + + for (const id of orderIds) { + const item = byId.get(id) + + if (item) { + ordered.push(item) + seen.add(id) + } + } + + // Items missing from the persisted order are new since it was last + // reconciled. Callers pass recency-sorted lists (newest first), so surface + // these at the TOP instead of burying them beneath the saved order — + // otherwise a brand-new session sinks to the bottom of the sidebar and reads + // as "my latest session never showed up". + const fresh = items.filter(item => !seen.has(getId(item))) + + return fresh.length ? [...fresh, ...ordered] : ordered +} + +/** Reconcile a persisted order against the live id set (fresh-first). */ +export function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { + if (!currentIds.length) { + return [] + } + + if (!orderIds.length) { + return currentIds + } + + return reconcileFreshFirst(currentIds, orderIds) +} + +/** True when two id lists are element-for-element identical. */ +export function sameIds(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((item, index) => item === right[index]) +} diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 612305b1479b..100ad8001e44 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -200,7 +200,7 @@ export function ProfileRail() { }, [createRequest]) return ( -
+
{/* 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. */} diff --git a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx index 16cbb04983d6..dcd9f067f438 100644 --- a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx +++ b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx @@ -87,21 +87,25 @@ export function ProjectDialog() { } const pickFolder = async () => { - const dir = await pickProjectFolder() + try { + const dir = await pickProjectFolder() - if (!dir) { - return - } + if (!dir) { + return + } - const projectId = state?.projectId + const projectId = state?.projectId - if (mode === 'add-folder' && projectId) { - await runSubmit(() => addProjectFolder(projectId, dir)) + if (mode === 'add-folder' && projectId) { + await runSubmit(() => addProjectFolder(projectId, dir)) - return - } + return + } - setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir])) + setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir])) + } catch (err) { + notifyError(err, p.createFailed) + } } const submit = async () => { @@ -145,7 +149,10 @@ export function ProjectDialog() { return ( - + event.preventDefault()} + > {title} {mode === 'create' && {p.createDesc}} diff --git a/apps/desktop/src/app/chat/sidebar/projects/model.ts b/apps/desktop/src/app/chat/sidebar/projects/model.ts index e5931f0bcb87..1c17c676e0c6 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/model.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/model.ts @@ -3,6 +3,7 @@ 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' @@ -88,7 +89,7 @@ export function useRepoWorktreeMap( const refreshToken = useStore($worktreeRefreshToken) useEffect(() => { - const git = window.hermesDesktop?.git + const git = desktopGit() if (!enabled || !repoPaths.length || !git?.worktreeList) { setMap({}) diff --git a/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx b/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx new file mode 100644 index 000000000000..8be14fcb8ea0 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx @@ -0,0 +1,81 @@ +import type { useSensors } from '@dnd-kit/core'; +import { closestCenter, DndContext, type DragEndEvent } from '@dnd-kit/core' +import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' +import type * as React from 'react' + +// Sidebar reordering is a strictly vertical list. The dragged item's transform +// is rendered Y-only in useSortableBindings (no x, no scale); this just stops +// dnd-kit's auto-scroll from dragging the rail — or the window — sideways when +// the pointer nears an edge, killing the horizontal "drag to valhalla". +const reorderAutoScroll = { threshold: { x: 0, y: 0.2 } } + +// One self-contained, nesting-safe reorderable list. It owns its DndContext, so a +// drag only ever collides with THIS list's own items — drop it at any depth (repos, +// worktrees, sessions) and reordering "just works" without leaking into the lists +// around or inside it. Pair each item with useSortableBindings(id); the list reports +// the new id order and the caller persists it. This is the single generic primitive +// behind every reorderable surface in the sidebar. +export function ReorderableList({ + children, + ids, + onReorder, + sensors +}: { + children: React.ReactNode + ids: string[] + onReorder: (ids: string[]) => void + sensors?: ReturnType +}) { + const handleDragEnd = ({ activatorEvent, active, over }: DragEndEvent) => { + // dnd-kit only restores focus for keyboard drags; after a pointer drop the + // browser leaves :focus on the grab handle, which keeps a focus-within + // grabber/affordance reveal stuck "on". Drop that focus so the row returns + // to its resting state once the pointer moves away. + if (!(activatorEvent instanceof KeyboardEvent)) { + ;(document.activeElement as HTMLElement | null)?.blur() + } + + if (!over || active.id === over.id) { + return + } + + const from = ids.indexOf(String(active.id)) + const to = ids.indexOf(String(over.id)) + + if (from >= 0 && to >= 0) { + onReorder(arrayMove(ids, from, to)) + } + } + + return ( + + + {children} + + + ) +} + +export function useSortableBindings(id: string) { + const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) + + return { + dragging: isDragging, + dragHandleProps: { ...attributes, ...listeners }, + ref: setNodeRef, + reorderable: true as const, + style: { + // Uniform vertical list: only ever translate on Y. Ignoring x and the + // scaleX/scaleY that CSS.Transform.toString would emit keeps a dragged + // group/row from drifting sideways or morphing its size mid-drag. + transform: transform ? `translate3d(0px, ${transform.y}px, 0)` : undefined, + transition: isDragging ? undefined : transition, + willChange: isDragging ? 'transform' : undefined + } + } +} diff --git a/apps/desktop/src/app/chat/sidebar/section-states.tsx b/apps/desktop/src/app/chat/sidebar/section-states.tsx new file mode 100644 index 000000000000..d65eda981326 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/section-states.tsx @@ -0,0 +1,52 @@ +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Skeleton } from '@/components/ui/skeleton' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' + +export function SidebarSessionSkeletons() { + return ( + + ) +} + +export function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) { + const { t } = useI18n() + const s = t.sidebar + + return ( +
+
+ +

{s.noSessions}

+ +
+
+ ) +} + +export function SidebarPinnedEmptyState() { + const { t } = useI18n() + + return ( +
+ + + + {t.sidebar.shiftClickHint} +
+ ) +} diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx new file mode 100644 index 000000000000..ffe729eb51e4 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -0,0 +1,379 @@ +import type { useSensors } from '@dnd-kit/core' +import type * as React from 'react' +import { useMemo } from 'react' + +import { SidebarPanelLabel } from '@/app/shell/sidebar-label' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar' +import type { HermesGitWorktree } from '@/global' +import type { SessionInfo } from '@/hermes' +import { flattenSessionsWithBranches } from '@/lib/session-branch-tree' +import { cn } from '@/lib/utils' +import { sessionPinId } from '@/store/session' + +import { SidebarCount } from './chrome' +import { + EnteredProjectContent, + ProjectOverviewRow, + type SidebarProjectTree, + type SidebarSessionGroup, + SidebarWorkspaceGroup, + type SidebarWorkspaceTree +} from './projects' +import { ReorderableList, useSortableBindings } from './reorderable-list' +import { SidebarSessionSkeletons } from './section-states' +import { SidebarSessionRow } from './session-row' +import { VirtualSessionList } from './virtual-session-list' + +export const VIRTUALIZE_THRESHOLD = 25 + +interface SidebarSectionHeaderProps { + label: string + open: boolean + onToggle: () => void + action?: React.ReactNode + meta?: React.ReactNode + icon?: React.ReactNode + // When false the section can't be collapsed: the label renders static (no + // toggle, no caret) and the section is always open. Used for the single- + // project view, where collapsing one project makes no sense. + collapsible?: boolean +} + +function SidebarSectionHeader({ + label, + open, + onToggle, + action, + meta, + icon, + collapsible = true +}: SidebarSectionHeaderProps) { + const labelBody = ( + <> + {icon} + {label} + {meta && {meta}} + + ) + + return ( +
+ {collapsible ? ( + + ) : ( +
{labelBody}
+ )} + {action} +
+ ) +} + +interface SidebarSessionsSectionProps { + label: string + open: boolean + onToggle: () => void + sessions: SessionInfo[] + activeSessionId: null | string + workingSessionIdSet: Set + onResumeSession: (sessionId: string) => void + onDeleteSession: (sessionId: string) => void + onArchiveSession: (sessionId: string) => void + onBranchSession?: (sessionId: string, profile?: string) => void + onTogglePin: (sessionId: string) => void + onNewSessionInWorkspace?: (path: null | string) => void + pinned: boolean + rootClassName?: string + contentClassName?: string + emptyState: React.ReactNode + forceEmptyState?: boolean + headerAction?: React.ReactNode + footer?: React.ReactNode + groups?: SidebarSessionGroup[] + tree?: SidebarWorkspaceTree[] + // Project overview: when present, render a drill-in list of project rows + // instead of sessions. Clicking a row enters that project (onEnterProject), + // which then passes `projectContent` on the next render. Takes precedence + // over `tree` / `groups`. + projectOverview?: SidebarProjectTree[] + // Per-project preview rows (from the backend tree), keyed by project path. + projectOverviewPreviews?: Record + // True while the backend project tree is loading (overview skeleton). + projectsLoading?: boolean + onEnterProject?: (id: string) => void + // The entered project's flattened content: main-checkout sessions render + // directly (no redundant repo/branch header); only linked worktrees nest. + projectContent?: SidebarProjectTree + // Live git lanes (`git worktree list`) for repos in the entered project — + // a VISUAL enhancer only (empty lanes), never session membership. + projectRepoWorktrees?: Record + // Live session cache used for optimistic placement inside entered-project lanes. + liveSessions?: SessionInfo[] + // Client-side optimistic eviction layer (deleted/archived ids). + removedSessionIds?: ReadonlySet + activeProjectId?: null | string + labelMeta?: React.ReactNode + labelIcon?: React.ReactNode + // When false the section header is static (no caret/toggle) and always open. + collapsible?: boolean + sortable?: boolean + // The flat session list is the only hand-reorderable surface (grouped/project + // views sort deterministically), so it owns the one ReorderableList. + onReorderSessions?: (ids: string[]) => void + // Drag-to-reorder for the project overview list (top-level projects). + onReorderProjects?: (ids: string[]) => void + // Rendered atop the entered-project body (a "back to overview" row). + projectBackRow?: React.ReactNode + dndSensors?: ReturnType +} + +export function SidebarSessionsSection({ + label, + open, + onToggle, + sessions, + activeSessionId, + workingSessionIdSet, + onResumeSession, + onDeleteSession, + onArchiveSession, + onBranchSession, + onTogglePin, + onNewSessionInWorkspace, + pinned, + rootClassName, + contentClassName, + emptyState, + forceEmptyState = false, + headerAction, + footer, + groups, + projectOverview, + projectOverviewPreviews, + projectsLoading = false, + onEnterProject, + projectContent, + projectRepoWorktrees, + liveSessions, + removedSessionIds, + activeProjectId, + labelMeta, + labelIcon, + collapsible = true, + sortable = false, + onReorderSessions, + onReorderProjects, + projectBackRow, + dndSensors +}: SidebarSessionsSectionProps) { + const sectionOpen = collapsible ? open : true + const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0)) + // A defined project list is itself content (even an empty project should + // render as a drill-in row so the user can see it exists). + const hasProjectOverview = Boolean(projectOverview?.length) + const hasProjectContent = Boolean(projectContent && projectContent.sessionCount > 0) + + const showEmptyState = + forceEmptyState || (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0) + + // The flat recents/pinned list is the only place sessions reorder by hand; + // grouped/tree views always sort by creation date and never drag. + const sessionsDraggable = sortable && !!onReorderSessions + const displayEntries = useMemo(() => flattenSessionsWithBranches(sessions), [sessions]) + + const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => { + const rowProps = { + branchStem, + isPinned: pinned, + isSelected: session.id === activeSessionId, + isWorking: workingSessionIdSet.has(session.id), + onArchive: () => onArchiveSession(session.id), + onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined, + onDelete: () => onDeleteSession(session.id), + onPin: () => onTogglePin(sessionPinId(session)), + onResume: () => onResumeSession(session.id), + reorderable: draggable && !branchStem, + session + } + + return draggable && !branchStem ? ( + + ) : ( + + ) + } + + // Sessions inside repos/worktrees are date-ordered and static. + const renderRows = (items: SessionInfo[]) => + flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem)) + + const flatVirtualized = + !showEmptyState && + !groups?.length && + !projectOverview?.length && + !projectContent && + sessions.length >= VIRTUALIZE_THRESHOLD + + // First paint into the grouped view (e.g. the app restoring the Projects tab) + // has flat recents in `sessions` but no tree yet. Show skeletons rather than + // flashing the flat session list until the overview/content/groups resolve. A + // background refresh keeps the prior tree, so this only fires when empty. + const showProjectsSkeleton = + projectsLoading && !hasProjectOverview && !hasProjectContent && !projectContent && !groups?.length + + let inner: React.ReactNode + + if (showProjectsSkeleton) { + inner = + } else if (projectContent) { + // Entered a project: the back row is always present, then either the + // (overlay-aware) content or a clean empty state — never a bare spinner or a + // blank pane while lanes hydrate. + inner = ( + <> + {projectBackRow} + {hasProjectContent ? ( + + ) : ( + emptyState + )} + + ) + } else if (showEmptyState) { + inner = emptyState + } else if (projectOverview?.length) { + // The model is already ordered (default sort groups explicit-before-auto; + // a manual drag-order, when present, wins). Render in that order and make + // rows drag-to-reorder when a handler is wired. + const projectsDraggable = projectOverview.length > 1 && !!onReorderProjects + const Row = projectsDraggable ? SortableProjectOverviewRow : ProjectOverviewRow + + const rows = projectOverview.map(project => ( + + )) + + inner = + projectsDraggable && onReorderProjects ? ( + project.id)} + onReorder={onReorderProjects} + sensors={dndSensors} + > + {rows} + + ) : ( + rows + ) + } else if (groups?.length) { + // Profile/source groups never reorder; render them flat with static rows. + inner = groups.map(group => ( + + )) + } else if (flatVirtualized) { + const virtual = ( + + ) + + inner = + sessionsDraggable && onReorderSessions ? ( + s.id)} onReorder={onReorderSessions} sensors={dndSensors}> + {virtual} + + ) : ( + virtual + ) + } else if (sessionsDraggable && onReorderSessions) { + inner = ( + s.id)} onReorder={onReorderSessions} sensors={dndSensors}> + {displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))} + + ) + } else { + inner = displayEntries.map(({ branchStem, session }) => renderRow(session, false, branchStem)) + } + + // The virtualizer owns its own scroller, so suppress the wrapper's overflow + // to avoid a double scroll container. + const resolvedContentClassName = cn(contentClassName, flatVirtualized && 'overflow-y-visible') + + return ( + + + {sectionOpen && ( + + {inner} + {footer} + + )} + + ) +} + +interface SortableSessionRowProps { + session: SessionInfo + isPinned: boolean + isSelected: boolean + isWorking: boolean + onArchive: () => void + onDelete: () => void + onPin: () => void + onResume: () => void +} + +function SortableSidebarSessionRow(props: SortableSessionRowProps) { + return +} + +function SortableProjectOverviewRow(props: React.ComponentProps) { + return +} diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index 9eacc0f41ee7..f6f2ed0324a6 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -9,7 +9,16 @@ 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, Pin, Trash2 } from '@/lib/icons' +import { + Activity, + AlertCircle, + BarChart3, + Bookmark, + BookmarkFilled, + Download, + MessageCircle, + Trash2 +} from '@/lib/icons' import { exportSession } from '@/lib/session-export' import { cn } from '@/lib/utils' import { upsertDesktopActionTask } from '@/store/activity' @@ -263,7 +272,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on {SECTIONS.map(value => ( setSection(value)} @@ -361,7 +370,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on /> ) : (
-
+
{status ? (
@@ -406,7 +415,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on )}
-
+
{cc.recentLogs} @@ -503,7 +512,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp )} -
+
-
+
({ diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index bfda963204ce..ec1c79566dcb 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -36,6 +36,7 @@ import { RefreshCw, Settings, Settings2, + Starmap, Sun, Terminal, Users, @@ -68,7 +69,8 @@ import { PROFILES_ROUTE, sessionRoute, SETTINGS_ROUTE, - SKILLS_ROUTE + SKILLS_ROUTE, + STARMAP_ROUTE } from '../routes' import { FIELD_LABELS, SECTIONS } from '../settings/constants' import { fieldCopyForSchemaKey } from '../settings/field-copy' @@ -383,7 +385,14 @@ export function CommandPalette() { run: go(CRON_ROUTE) }, { action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) }, - { action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) } + { action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }, + { + icon: Starmap, + id: 'nav-starmap', + keywords: ['star map', 'memory', 'memories', 'skills', 'graph', 'learning', 'constellation'], + label: t.starmap.title, + run: go(STARMAP_ROUTE) + } ] }, ...branchGroup, diff --git a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx index eb175fdcb720..6766b2dae308 100644 --- a/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx +++ b/apps/desktop/src/app/command-palette/marketplace-theme-page.tsx @@ -8,6 +8,7 @@ * user can grab several. */ +import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { useEffect, useState } from 'react' @@ -18,6 +19,7 @@ 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 }) @@ -43,8 +45,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(null) - const [installed, setInstalled] = useState>({}) const [installError, setInstallError] = useState(null) const query = useQuery({ @@ -53,6 +55,20 @@ 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 @@ -65,7 +81,6 @@ 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) @@ -93,7 +108,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa {installError &&

{installError}

} {results.map(item => { const busy = installingId === item.extensionId - const done = installed[item.extensionId] + const done = installs.has(item.extensionId) return ( + } + description={c.emptyDescNew} + icon="watch" + title={c.emptyTitleNew} + /> ) : ( - - - setEditor({ mode: 'create' })} /> - {totalCount > 0 && ( - - )} - {visibleJobs.map(job => ( - setSelectedJobId(job.id)} - /> - ))} - {visibleJobs.length === 0 && ( -

- {totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch} -

- )} -
+ <> + + + + {visibleJobs.map(job => ( + setEditor({ mode: 'edit', job }) }, + { icon: 'trash', label: t.common.delete, onSelect: () => setPendingDelete(job), tone: 'danger' } + ]} + /> + } + onSelect={() => setSelectedJobId(job.id)} + /> + ))} + {visibleJobs.length === 0 && ( +

{c.emptyTitleSearch}

+ )} + setEditor({ mode: 'create' })} /> +
- {selectedJob ? ( setPendingDelete(selectedJob)} - onEdit={() => setEditor({ mode: 'edit', job: selectedJob })} onOpenSession={onOpenSession} onPauseResume={() => void handlePauseResume(selectedJob)} onTrigger={() => void handleTrigger(selectedJob)} /> ) : ( -
-
- -

{totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}

-
-
+ )} -
-
+ + )} setEditor({ mode: 'closed' })} onSave={handleEditorSave} /> @@ -488,42 +500,32 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
- + ) } function CronJobListRow({ active, - c, job, + menu, onSelect }: { active: boolean - c: Translations['cron'] job: CronJob + menu?: React.ReactNode onSelect: () => void }) { const state = jobState(job) return ( - + ) } @@ -531,8 +533,6 @@ function CronJobDetail({ busy, c, job, - onDelete, - onEdit, onOpenSession, onPauseResume, onTrigger @@ -540,8 +540,6 @@ function CronJobDetail({ busy: boolean c: Translations['cron'] job: CronJob - onDelete: () => void - onEdit: () => void onOpenSession?: (sessionId: string) => void onPauseResume: () => void onTrigger: () => void @@ -552,69 +550,49 @@ function CronJobDetail({ const prompt = jobPrompt(job) return ( -
-
-
-
-
-
-
-

{jobTitle(job)}

- {c.states[state] ?? state} - {deliver && deliver !== DEFAULT_DELIVER && ( - {c.deliveryLabels[deliver] ?? deliver} - )} -
-
- - - {jobScheduleDisplay(job)} - - - {c.last} {formatTime(job.last_run_at)} - - - {c.next} {formatTime(job.next_run_at)} - -
-
-
- - - - -
-
+ +
+
+
+

{jobTitle(job)}

+ {c.states[state] ?? state} +
+
+ + {isPaused ? c.resumeTitle : c.pauseTitle} + + + {c.triggerNow} + +
+
- {prompt &&

{prompt}

} - {job.last_error && ( -

- - {job.last_error} -

- )} -
+ - -
-
-
+ {job.last_error ? ( +
+ + {job.last_error} +
+ ) : null} + + + {prompt ? ( +
+ {c.promptLabel} + {prompt} +
+ ) : null} + + + ) } @@ -685,10 +663,10 @@ function CronJobRuns({ return (
-
+ {c.runHistory} {runs && runs.length > 0 ? ` · ${runs.length}` : ''} -
+ {runs === null ? (
@@ -699,13 +677,13 @@ function CronJobRuns({
{runs.map(run => ( @@ -716,16 +694,6 @@ function CronJobRuns({ ) } -function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) { - return ( - - {children} - - ) -} - function CronEditorDialog({ editor, onClose, diff --git a/apps/desktop/src/app/desktop-controller-utils.test.ts b/apps/desktop/src/app/desktop-controller-utils.test.ts new file mode 100644 index 000000000000..ac39395db09e --- /dev/null +++ b/apps/desktop/src/app/desktop-controller-utils.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/hermes' + +import { sameCronSignature } from './desktop-controller-utils' + +const session = (id: string, title: string | null): SessionInfo => ({ id, title }) as SessionInfo + +describe('sameCronSignature', () => { + it('is false when the lengths differ', () => { + expect(sameCronSignature([session('a', 't')], [])).toBe(false) + }) + + it('is true when ids and titles match in order', () => { + const a = [session('a', 'one'), session('b', 'two')] + const b = [session('a', 'one'), session('b', 'two')] + expect(sameCronSignature(a, b)).toBe(true) + }) + + it('is false when a title changed', () => { + const a = [session('a', 'one')] + const b = [session('a', 'renamed')] + expect(sameCronSignature(a, b)).toBe(false) + }) + + it('is false when order differs', () => { + const a = [session('a', 't'), session('b', 't')] + const b = [session('b', 't'), session('a', 't')] + expect(sameCronSignature(a, b)).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/desktop-controller-utils.ts b/apps/desktop/src/app/desktop-controller-utils.ts new file mode 100644 index 000000000000..cb7d618e6afa --- /dev/null +++ b/apps/desktop/src/app/desktop-controller-utils.ts @@ -0,0 +1,11 @@ +import type { SessionInfo } from '@/hermes' + +// Cheap signature compare so a poll only swaps the atom (and re-renders the +// sidebar) when the visible rows actually changed. +export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { + if (a.length !== b.length) { + return false + } + + return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title) +} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 9df44d628cec..07d57ac49ccf 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -10,27 +10,20 @@ 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' import { formatRefValue } from '../components/assistant-ui/directive-text' -import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' +import { getSessionMessages, triggerCronJob } from '../hermes' import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' import { storedSessionIdForNotification } from '../lib/session-ids' -import { - isMessagingSource, - LOCAL_SESSION_SOURCE_IDS, - MESSAGING_SESSION_SOURCE_IDS, - normalizeSessionSource -} from '../lib/session-source' import { latestSessionTodos } from '../lib/todos' -import { setCronFocusJobId, setCronJobs } from '../store/cron' +import { setCronFocusJobId } from '../store/cron' import { $fileBrowserOpen, $panesFlipped, $pinnedSessionIds, - $sessionsLimit, - bumpSessionsLimit, FILE_BROWSER_DEFAULT_WIDTH, FILE_BROWSER_MAX_WIDTH, FILE_BROWSER_MIN_WIDTH, @@ -40,7 +33,6 @@ import { setSidebarOverlayMounted, SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, - SIDEBAR_SESSIONS_PAGE_SIZE, unpinSession } from '../store/layout' import { respondToApprovalAction } from '../store/native-notifications' @@ -57,8 +49,6 @@ import { $activeGatewayProfile, $freshSessionRequest, $profileScope, - ALL_PROFILES, - normalizeProfileKey, refreshActiveProfile } from '../store/profile' import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects' @@ -70,34 +60,20 @@ import { $freshDraftReady, $gatewayState, $messages, - $messagingSessions, $resumeExhaustedSessionId, $resumeFailedSessionId, $selectedStoredSessionId, $sessions, - $workingSessionIds, - CRON_SECTION_LIMIT, - getRecentlySettledSessionIds, getRememberedSessionId, - mergeSessionPage, - MESSAGING_SECTION_LIMIT, sessionPinId, setAwaitingResponse, setBusy, - setCronSessions, setCurrentBranch, setCurrentCwd, setCurrentModel, setCurrentProvider, setMessages, - setMessagingPlatformTotals, - setMessagingSessions, - setMessagingTruncated, - setRememberedSessionId, - setSessionProfileTotals, - setSessions, - setSessionsLoading, - setSessionsTotal + setRememberedSessionId } from '../store/session' import { onSessionsChanged } from '../store/session-sync' import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos' @@ -124,9 +100,12 @@ 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 { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' +import { TerminalPaneChrome } from './right-sidebar/terminal/chrome' +import { PersistentTerminal } from './right-sidebar/terminal/persistent' +import { closeActiveTerminal } from './right-sidebar/terminal/terminals' import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' import { SessionPickerOverlay } from './session-picker-overlay' import { SessionSwitcher } from './session-switcher' @@ -139,6 +118,7 @@ import { usePreviewRouting } from './session/hooks/use-preview-routing' import { usePromptActions } from './session/hooks/use-prompt-actions' import { useRouteResume } from './session/hooks/use-route-resume' import { useSessionActions } from './session/hooks/use-session-actions' +import { useSessionListActions } from './session/hooks/use-session-list-actions' import { useSessionStateCache } from './session/hooks/use-session-state-cache' import { AppShell } from './shell/app-shell' import { useOverlayRouting } from './shell/hooks/use-overlay-routing' @@ -154,6 +134,7 @@ const AgentsView = lazy(async () => ({ default: (await import('./agents')).Agent const ArtifactsView = lazy(async () => ({ default: (await import('./artifacts')).ArtifactsView })) const CommandCenterView = lazy(async () => ({ default: (await import('./command-center')).CommandCenterView })) const CronView = lazy(async () => ({ default: (await import('./cron')).CronView })) +const StarmapView = lazy(async () => ({ default: (await import('./starmap')).StarmapView })) const MessagingView = lazy(async () => ({ default: (await import('./messaging')).MessagingView })) const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).ProfilesView })) const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView })) @@ -165,51 +146,6 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill // this cadence while the app is open + visible so new runs surface promptly // instead of waiting for the next user-triggered refreshSessions(). const CRON_POLL_INTERVAL_MS = 30_000 -// The recents list is local-only: cron rows have their own section, and each -// messaging platform (telegram, discord, …) is fetched separately into its own -// self-managed sidebar section (refreshMessagingSessions). Excluding both here -// keeps "Load more" paging through interactive local chats instead of -// interleaving gateway threads that bury them. -const SIDEBAR_EXCLUDED_SOURCES = ['cron', 'subagent', 'tool', ...MESSAGING_SESSION_SOURCE_IDS] -// The messaging slice is the inverse: drop cron + every local source so only -// external-platform conversations remain, then split per platform in the UI. -const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS] - -// Cheap signature compare so the poll only swaps the atom (and re-renders the -// sidebar) when the visible cron rows actually changed. -function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { - if (a.length !== b.length) { - return false - } - - return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title) -} - -// Rows a session refresh must preserve even if the aggregator omits them: -// in-flight first turns (message_count 0), pinned rows aged off the page, the -// actively-viewed chat (its "working" flag clears a beat before the aggregator -// sees the persisted row), and sessions whose turn just settled (same race, but -// for a chat the user has already navigated away from). Pass `scope` to only -// keep the active row when it belongs to the profile being paged. -function sessionsToKeep(scope?: string): Set { - const keep = new Set([ - ...$workingSessionIds.get(), - ...$pinnedSessionIds.get(), - ...getRecentlySettledSessionIds() - ]) - - const active = $selectedStoredSessionId.get() - - if (active) { - const session = scope ? $sessions.get().find(s => s.id === active) : null - - if (!scope || !session || normalizeProfileKey(session.profile) === scope) { - keep.add(active) - } - } - - return keep -} export function DesktopController() { const queryClient = useQueryClient() @@ -218,7 +154,6 @@ export function DesktopController() { const busyRef = useRef(false) const creatingSessionRef = useRef(false) - const refreshSessionsRequestRef = useRef(0) const gatewayState = useStore($gatewayState) const activeSessionId = useStore($activeSessionId) @@ -258,6 +193,7 @@ export function DesktopController() { openCommandCenterSection, profilesOpen, settingsOpen, + starmapOpen, toggleCommandCenter } = useOverlayRouting() @@ -387,11 +323,25 @@ export function DesktopController() { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (!$filePreviewTarget.get() && !$previewTarget.get()) { + if (event.altKey || event.shiftKey || event.key.toLowerCase() !== 'w' || (!event.metaKey && !event.ctrlKey)) { return } - if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') { + // 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()) { event.preventDefault() event.stopPropagation() closeActiveRightRailTab() @@ -408,126 +358,13 @@ export function DesktopController() { } }, []) - // Cron-job sessions as their own list (latest N). Independent of the recents - // page so the two never compete for slots. Cheap + bounded. Kept (even though - // the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run - // still resolves into the Pinned section via sessionByAnyId. - const refreshCronSessions = useCallback(async () => { - try { - const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { - source: 'cron' - }) - - setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions)) - } catch { - // Non-fatal: the cron section just stays empty/stale. - } - }, []) - - // Messaging-platform sessions as their own slice, fetched separately from - // local recents so each platform renders a self-managed section and never - // competes with local chats for the recents page budget. One combined fetch - // seeds every platform; the sidebar splits the rows per source. - const refreshMessagingSessions = useCallback(async () => { - try { - const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', { - excludeSources: MESSAGING_EXCLUDED_SOURCES - }) - - // Drop any non-messaging source the broad exclude didn't catch (custom - // sources) — those stay in local recents, not a platform section. - const rows = result.sessions.filter(s => isMessagingSource(s.source)) - - setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows)) - // Hit the cap → at least one platform may have more on disk than loaded, - // so platform sections offer their own per-platform "load more". - setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT) - } catch { - // Non-fatal: the messaging sections just stay empty/stale. - } - }, []) - - // Page a single platform's section independently (mirrors the per-profile - // pager): fetch that source's next window and merge it back in place, leaving - // every other platform's rows untouched. Resolves the platform's exact total. - const loadMoreMessagingForPlatform = useCallback(async (platform: string) => { - const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform - const loaded = $messagingSessions.get().filter(inPlatform).length - - const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', { - source: platform - }) - - const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform) - - setMessagingSessions(prev => [ - ...prev.filter(s => !inPlatform(s)), - ...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep()) - ]) - - const total = result.total ?? incoming.length - setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) })) - }, []) - - // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created - // synchronously (agent tool call or the cron UI), so refreshing here right - // after an agent turn surfaces a new job immediately; the interval poll keeps - // next-run/state fresh as the scheduler advances them. - const refreshCronJobs = useCallback(async () => { - try { - const jobs = await getCronJobs() - - setCronJobs(jobs) - } catch { - // Non-fatal: the cron section just keeps its last-known jobs. - } - }, []) - - const refreshSessions = useCallback(async () => { - const requestId = refreshSessionsRequestRef.current + 1 - refreshSessionsRequestRef.current = requestId - setSessionsLoading(true) - - try { - const limit = $sessionsLimit.get() - - // Require at least one message so abandoned/empty "Untitled" drafts (one - // was created per TUI/desktop launch before the lazy-create fix) don't - // clutter the sidebar. - // Unified cross-profile list (served read-only off each profile's - // state.db; no per-profile backend is spawned). Single-profile users get - // the same rows tagged profile="default". Cron sessions are excluded here - // and fetched separately (refreshCronSessions) so the scheduler's - // always-newest rows can't consume the recents page budget. - // Scope the fetch to the active profile (not always 'all') so a profile - // with few recent sessions isn't windowed out of the cross-profile - // recency page — the empty-history-on-profile-switch bug. - const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope - - const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, { - excludeSources: SIDEBAR_EXCLUDED_SOURCES - }) - - if (refreshSessionsRequestRef.current === requestId) { - setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep())) - setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length) - setSessionProfileTotals(result.profile_totals ?? {}) - } - } finally { - if (refreshSessionsRequestRef.current === requestId) { - setSessionsLoading(false) - } - } - - void refreshCronSessions() - void refreshCronJobs() - void refreshMessagingSessions() - }, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions]) - - const loadMoreSessions = useCallback(async () => { - bumpSessionsLimit() - await refreshSessions() - }, [refreshSessions]) + const { + loadMoreMessagingForPlatform, + loadMoreSessions, + loadMoreSessionsForProfile, + refreshCronJobs, + refreshSessions + } = useSessionListActions({ profileScope }) // Another window mutated the shared session list (e.g. a chat started in the // pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so @@ -540,28 +377,6 @@ export function DesktopController() { return onSessionsChanged(() => void refreshSessions().catch(() => undefined)) }, [refreshSessions]) - // ALL-profiles view pages one profile at a time: fetch that profile's next - // page and merge it in place, leaving every other profile's rows untouched. - const loadMoreSessionsForProfile = useCallback(async (profile: string) => { - const key = normalizeProfileKey(profile) - const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key - const loaded = $sessions.get().filter(inKey).length - - const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, { - excludeSources: SIDEBAR_EXCLUDED_SOURCES - }) - - const keep = sessionsToKeep(key) - - setSessions(prev => [ - ...prev.filter(s => !inKey(s)), - ...mergeSessionPage(prev.filter(inKey), result.sessions, keep) - ]) - - const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length - setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) })) - }, []) - const toggleSelectedPin = useCallback(() => { const sessionId = $selectedStoredSessionId.get() @@ -580,7 +395,7 @@ export function DesktopController() { } }, []) - const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) + const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway) const updateActiveSessionRuntimeInfo = useCallback( (info: { branch?: string; cwd?: string }) => { @@ -1060,7 +875,6 @@ export function DesktopController() { commandCenterOpen, extraLeftItems: statusbarItemGroups.flat.left, extraRightItems: statusbarItemGroups.flat.right, - gatewayLogLines, gatewayState, inferenceStatus, openAgents, @@ -1095,12 +909,12 @@ export function DesktopController() { /> ) - // One PTY-backed terminal mounted forever; 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 = ( - - ) + // The persistent xterm layer (one host per terminal tab), CSS-overlaid onto the + // pane's . 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. + const mainOverlays = const overlays = ( <> @@ -1127,6 +941,7 @@ export function DesktopController() { + {settingsOpen && ( @@ -1181,6 +996,12 @@ export function DesktopController() { )} + + {starmapOpen && ( + + + + )} ) @@ -1329,7 +1150,7 @@ export function DesktopController() { terminalAsRow ? 'border-l border-(--ui-stroke-secondary) pt-0' : 'pt-(--titlebar-height)' )} > - +
) diff --git a/apps/desktop/src/app/floating-hud.ts b/apps/desktop/src/app/floating-hud.ts index 1c499b4a08aa..ef501dcc6aeb 100644 --- a/apps/desktop/src/app/floating-hud.ts +++ b/apps/desktop/src/app/floating-hud.ts @@ -6,7 +6,11 @@ export const HUD_POSITION = 'fixed left-1/2 top-3 -translate-x-1/2' // Matches the app's borderless-overlay surface (dialog, keybind panel, …): // hairline `--stroke-nous` paired with the soft `--shadow-nous` float. -export const HUD_SURFACE = 'rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous' +// `no-drag`: these HUDs overlap the titlebar's `[-webkit-app-region:drag]` band +// (app-shell.tsx), which wins hit-testing over DOM regardless of z-index — so +// without it the top of the surface (the search input) swallows clicks. +export const HUD_SURFACE = + 'rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous [-webkit-app-region:no-drag]' // One row/text size for both HUDs (compact — two notches under `text-sm`). export const HUD_TEXT = 'text-xs' diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 1db1c2aaa0d9..26a4e2ce7c8e 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -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, diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index 29b6cbd80c8b..d6c9ab0e029b 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -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' diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 80370f2488b1..379c719ad036 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -2,6 +2,7 @@ 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' @@ -164,6 +165,17 @@ 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'), diff --git a/apps/desktop/src/app/messaging/index.test.tsx b/apps/desktop/src/app/messaging/index.test.tsx new file mode 100644 index 000000000000..a7d9273c0c90 --- /dev/null +++ b/apps/desktop/src/app/messaging/index.test.tsx @@ -0,0 +1,89 @@ +// @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 { + 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( + + + + ) +} + +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)) + }) +}) diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 659c655dccf6..b2d5837fef67 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -14,6 +14,7 @@ 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' @@ -404,14 +405,31 @@ function PlatformDetail({

{introCopy(platform, m)}

- + {platform.docs_url && ( + + )}
diff --git a/apps/desktop/src/app/overlays/overlay-chrome.tsx b/apps/desktop/src/app/overlays/overlay-chrome.tsx index 23a57da4eb51..5a28e4fb80ec 100644 --- a/apps/desktop/src/app/overlays/overlay-chrome.tsx +++ b/apps/desktop/src/app/overlays/overlay-chrome.tsx @@ -1,26 +1,11 @@ -import type { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react' +import type { ButtonHTMLAttributes, 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 { tone?: 'default' | 'danger' | 'subtle' } -export function OverlayCard({ children, className, ...props }: OverlayCardProps) { - return ( -
- {children} -
- ) -} - export function OverlayActionButton({ children, className, diff --git a/apps/desktop/src/app/overlays/overlay-search-input.tsx b/apps/desktop/src/app/overlays/overlay-search-input.tsx deleted file mode 100644 index 4f82b0918bcb..000000000000 --- a/apps/desktop/src/app/overlays/overlay-search-input.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { RefObject } from 'react' - -import { SearchField } from '@/components/ui/search-field' - -interface OverlaySearchInputProps { - containerClassName?: string - inputRef?: RefObject - loading?: boolean - onChange: (value: string) => void - placeholder: string - value: string -} - -// Borderless underline search — matches the tools/skills page (PageSearchShell). -export function OverlaySearchInput({ - containerClassName, - inputRef, - loading = false, - onChange, - placeholder, - value -}: OverlaySearchInputProps) { - return ( - - ) -} diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx index fd562b40e283..6b95e0e48306 100644 --- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from 'react' -import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' import type { IconComponent } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -50,9 +48,10 @@ export function OverlaySidebar({ children, className }: OverlaySidebarProps) { return (
A real terminal interfaceFull TUI with multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output.