Merge origin/main: keep unified declared-schema config surface

Conflict resolutions:
- web_server.py: keep the branch's unified provider-config handlers
  (declared schema served by default, profile-scoped) over main's
  surface=declared query param. Main's declared-surface helpers and the
  hermes_cli.memory_providers import are dropped — the branch deleted
  that module when provider schemas moved into their plugins, so main's
  code path could no longer import.
- hermes.ts: keep profileScoped() calls without ?surface=declared to
  match the unified backend.
- constants.ts: take main's new reasoning effort values (max, ultra),
  keep the branch's memory provider ordering.
- config-settings.tsx: take main's FallbackModelsField import, drop the
  now-unused SECTIONS import (branch replaced it with
  sectionFieldEntries).
- provider-config-panel.test.tsx: file moved to settings/memory/ on the
  branch; main's act() fixes targeted the old tests, and the rewritten
  suite produces no act() warnings, so the old path stays deleted.
- test_web_server.py: keep both sides' new tests (main's MoA endpoint
  tests plus the branch's Honcho provider tests).
This commit is contained in:
Erosika 2026-07-15 17:50:31 -04:00
commit 155a792013
1090 changed files with 89299 additions and 13320 deletions

View file

@ -1,6 +1,13 @@
# Hermes Agent Environment Configuration
# Copy this file to .env and fill in your API keys
# =============================================================================
# LLM PROVIDER (Fireworks AI)
# =============================================================================
# Get your key at: https://app.fireworks.ai/settings/users/api-keys
# Address models directly by catalog ID, e.g.
# accounts/fireworks/models/kimi-k2p6, accounts/fireworks/models/glm-5p2
# FIREWORKS_API_KEY=
# =============================================================================
# LLM PROVIDER (OpenRouter)
# =============================================================================
@ -108,6 +115,10 @@
# HF_BASE_URL=https://router.huggingface.co/v1 # Override default base URL
# OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL
# DeepInfra — 100+ top open models, pay-per-use.
# Get your key at: https://deepinfra.com/dash/api_keys
# DEEPINFRA_API_KEY=
# =============================================================================
# LLM PROVIDER (Qwen OAuth)
# =============================================================================
@ -125,6 +136,15 @@
# Optional base URL override:
# XIAOMI_BASE_URL=https://api.xiaomimimo.com/v1
# =============================================================================
# LLM PROVIDER (Upstage Solar)
# =============================================================================
# Upstage provides access to Upstage Solar models.
# Get your key at: https://console.upstage.ai/api-keys
# UPSTAGE_API_KEY=your_key_here
# Optional base URL override:
# UPSTAGE_BASE_URL=https://api.upstage.ai/v1
# =============================================================================
# TOOL API KEYS
# =============================================================================

View file

@ -10,7 +10,7 @@ outputs:
description: Run Python tests / ruff / ty / windows-footguns.
value: ${{ steps.classify.outputs.python }}
frontend:
description: Run the TypeScript typecheck matrix + desktop build.
description: Run the TypeScript testing matrix + desktop build.
value: ${{ steps.classify.outputs.frontend }}
docker_meta:
description: Docker setup and meta files have changed.
@ -27,6 +27,9 @@ outputs:
mcp_catalog:
description: Require MCP catalog security review label.
value: ${{ steps.classify.outputs.mcp_catalog }}
ci_review:
description: Require CI-sensitive file review label.
value: ${{ steps.classify.outputs.ci_review }}
runs:
using: composite

View file

@ -43,6 +43,7 @@ jobs:
deps: ${{ steps.classify.outputs.deps }}
docker_meta: ${{ steps.classify.outputs.docker_meta }}
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
ci_review: ${{ steps.classify.outputs.ci_review }}
event_name: ${{ github.event_name }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -65,16 +66,17 @@ jobs:
lint:
name: Python lints
needs: detect
if: needs.detect.outputs.python == 'true'
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true'
uses: ./.github/workflows/lint.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
typecheck:
name: TypeScript
js-tests:
name: JS & TS checks
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/typecheck.yml
uses: ./.github/workflows/js-tests.yml
docs-site:
name: Docs Site
@ -139,7 +141,7 @@ jobs:
needs:
- tests
- lint
- typecheck
- js-tests
- docs-site
- history-check
- contributor-check
@ -154,14 +156,18 @@ jobs:
steps:
- name: Evaluate job results
env:
RESULTS: ${{ toJSON(needs.*.result) }}
NEEDS: ${{ toJSON(needs) }}
run: |
echo "$RESULTS" | python3 -c "
echo "$NEEDS" | python3 -c "
import json, sys
results = json.load(sys.stdin)
failed = [r for r in results if r == 'failure']
needs = json.load(sys.stdin)
failed = [name for name, info in needs.items() if info['result'] == 'failure']
for name, info in sorted(needs.items()):
result = info['result']
icon = '✅' if result in ('success', 'skipped') else '❌'
print(f'{icon} {name}: {result}')
if failed:
print(f'::error::{len(failed)} job(s) failed')
print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}')
sys.exit(1)
print('All checks passed (or were skipped)')
"

235
.github/workflows/js-autofix.yml vendored Normal file
View file

@ -0,0 +1,235 @@
name: auto-fix lint issues & formatting
# On push to main (or manual trigger), run `npm run fix` on each workspace
# package and apply any changes via a PR.
#
# Fixable lint issues (import sorting, unused imports, curly braces, etc.) are
# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint
# check in typecheck.yml fails only when un-fixable errors remain.
#
# Pushes made by GITHUB_TOKEN don't trigger further workflow runs, so there's
# no infinite loop — the push to bot/js-autofix and the resulting squash merge
# to main are both made by GITHUB_TOKEN.
#
# ── Security model: two-job split ───────────────────────────────────────────
#
# The eslint process executes repo code (eslint.config.mjs, package.json
# scripts, installed plugins). To prevent a malicious PR from getting arbitrary
# code execution on a runner with push access, the work is split:
#
# 1. generate-patch (unprivileged, contents: read only)
# Checks out, installs deps, runs eslint --fix, produces a .patch artifact.
# Worst case: malicious code runs here on an ephemeral runner with zero
# push permissions.
#
# 2. apply-patch (privileged, contents: write + pull-requests: write)
# Checks out, downloads the patch artifact, applies it, pushes to the
# bot/js-autofix branch, creates/updates a PR, and enables auto-merge.
# This job never runs npm, never installs anything, never executes any
# repo code. The only input it trusts is the patch artifact.
# The PR auto-merges (squash) once CI passes. If CI fails or main moves,
# the PR is auto-closed and the branch deleted — the next run re-applies
# on the current state.
on:
push:
branches: [main]
paths:
- '**/*.js'
- '**/*.cjs'
- '**/*.mjs'
- '**/*.ts'
- '**/*.tsx'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read # default; apply-patch job overrides to write
concurrency:
group: ts-autofix-${{ github.ref }}
cancel-in-progress: true
jobs:
generate-patch:
name: Generate eslint --fix patch
runs-on: ubuntu-latest
timeout-minutes: 5
# No permissions override → inherits workflow-level contents: read.
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# --ignore-scripts: eslint only needs TS sources + eslint packages.
- uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- name: npm run fix in all workspaces
# continue-on-error: if un-fixable errors exist on main, we still want
# to commit whatever fixes were applied. The PR-time check in
# typecheck.yml is what blocks un-fixable errors from landing.
continue-on-error: true
run: npm run fix
- name: Produce patch
run: |
if git diff --quiet; then
echo "No fixes needed."
# Empty patch signals "nothing to do" to apply-patch.
: > js-fix.patch
else
git diff > js-fix.patch
echo "Patch size: $(wc -c < js-fix.patch) bytes"
# Reject patches that touch anything outside JS/TS/JSON sources.
# `npm run fix` should only ever modify those; anything else means
# eslint/prettier or a plugin went rogue and we refuse to ship it.
BAD=$(git diff --name-only | grep -vE '\.(js|cjs|mjs|ts|tsx|json)$' || true)
if [ -n "$BAD" ]; then
echo "::error::Refusing to upload patch — touches disallowed files:"
echo "$BAD"
exit 1
fi
fi
- name: Upload patch artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: js-fix-patch
path: js-fix.patch
retention-days: 1
include-hidden-files: true
apply-patch:
name: Apply patch
needs: generate-patch
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write # needed to push to bot/js-autofix
pull-requests: write # needed for PR creation + auto-merge
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download patch
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: js-fix-patch
# ${{ runner.temp }} expands in with: params (shell-style $VAR does not).
# download-artifact's path is a *directory* — the artifact's js-fix.patch
# file lands inside it, so $RUNNER_TEMP/js-fix.patch resolves correctly
# in the run step below.
path: ${{ runner.temp }}
- name: Apply patch and push to bot branch
env:
BOT_BRANCH: bot/js-autofix
run: |
set -euo pipefail
# Empty patch = nothing to do.
if [ ! -s "$RUNNER_TEMP/js-fix.patch" ]; then
echo "Patch is empty. No fixes to apply."
exit 0
fi
# Apply the patch produced by the unprivileged job.
git apply --check "$RUNNER_TEMP/js-fix.patch" || {
echo "::error::Patch does not apply cleanly. Branch may have moved."
exit 1
}
git apply "$RUNNER_TEMP/js-fix.patch"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "fmt(js): \`npm run fix\` on merge"
# Push to the dedicated bot branch. Force-push is safe here:
# bot/js-autofix is a bot-only branch that gets rewritten each run.
# If the branch was deleted after a previous PR merge, this
# recreates it.
git push --force origin HEAD:"$BOT_BRANCH"
- name: Create/update PR and enable auto-merge
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BOT_BRANCH: bot/js-autofix
run: |
set -euo pipefail
# Create PR if one doesn't exist. If it already exists, the
# force-push above already updated it with the latest fixes.
PR_NUM=$(gh pr list --head "$BOT_BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || true)
if [ -z "$PR_NUM" ]; then
# gh pr create prints the PR URL. Extract the number from it
# (https://github.com/<org>/<repo>/pull/<number>).
PR_URL=$(gh pr create \
--head "$BOT_BRANCH" --base main \
--title 'fmt(js): `npm run fix` auto-fix' \
--body 'Auto-generated by the `auto-fix lint issues & formatting` workflow. Auto-merges (squash) once CI passes. If CI fails or `main` moves, the PR is auto-closed and the branch deleted — the next run re-applies on the current state.')
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
fi
# Enable auto-merge (squash). If already enabled, this is a no-op.
gh pr merge "$PR_NUM" --auto --squash || true
- name: Wait for merge, auto-close on failure or stale
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
START_SHA: ${{ github.sha }}
run: |
set -euo pipefail
PR_NUM=$(gh pr list --head bot/js-autofix --state open --json number --jq '.[0].number' 2>/dev/null || true)
if [ -z "$PR_NUM" ]; then
echo "No open PR. Nothing to wait for."
exit 0
fi
echo "Waiting for PR #$PR_NUM to merge..."
# Poll every 15s for up to ~10 minutes. Auto-merge will handle the
# PR even if this job times out — the polling is for cleanup only
# (auto-close on CI failure, conflicts, or main moving).
for i in $(seq 1 40); do
sleep 15
STATE=$(gh pr view "$PR_NUM" --json state --jq '.state')
if [ "$STATE" = "MERGED" ] || [ "$STATE" = "CLOSED" ]; then
echo "PR #$PR_NUM is $STATE."
exit 0
fi
# If main moved, close the PR — the next workflow run re-applies
# on the current state.
CURRENT_SHA=$(gh api "repos/${{ github.repository }}/branches/main" --jq '.commit.sha')
if [ "$CURRENT_SHA" != "$START_SHA" ]; then
echo "Main moved ($START_SHA → $CURRENT_SHA). Closing stale PR."
gh pr close "$PR_NUM" --delete-branch
exit 0
fi
# If CI checks failed, close + delete the branch.
if gh pr checks "$PR_NUM" 2>/dev/null | grep -qi "fail"; then
echo "CI failed on PR #$PR_NUM. Closing + deleting branch."
gh pr close "$PR_NUM" --delete-branch
exit 0
fi
# If PR is conflicted, close + delete the branch.
MERGEABLE=$(gh pr view "$PR_NUM" --json mergeable --jq '.mergeable')
if [ "$MERGEABLE" = "CONFLICTING" ]; then
echo "PR #$PR_NUM is conflicted. Closing + deleting branch."
gh pr close "$PR_NUM" --delete-branch
exit 0
fi
done
echo "Timeout reached. Auto-merge will handle PR #$PR_NUM if CI passes."

49
.github/workflows/js-tests.yml vendored Normal file
View file

@ -0,0 +1,49 @@
# .github/workflows/js-tests.yml
name: JS Tests
on:
workflow_call:
jobs:
workspaces:
name: List npm workspaces
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.set-matrix.outputs.packages }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- id: set-matrix
run: |
PACKAGES=$(npm query .workspace | jq -c '[.[].location]')
if [ "$PACKAGES" = "[]" ] || [ -z "$PACKAGES" ]; then
echo "::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently)."
exit 1
fi
echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
check:
name: Typecheck & Test
needs: workspaces
runs-on: ubuntu-latest
strategy:
matrix:
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
fail-fast: false # report all failures, not just the first one
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- uses: ./.github/actions/retry
with:
command: npm ci
- run: npm run --prefix ${{ matrix.package }} check
- run: npm run --prefix ${{ matrix.package }} fix

View file

@ -15,6 +15,10 @@ on:
description: The event name from the calling orchestrator (pull_request or push).
type: string
required: true
ci_review:
description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label.
type: boolean
default: false
permissions:
contents: read
@ -158,3 +162,111 @@ jobs:
- name: Run footgun checker
run: python scripts/check-windows-footguns.py --all
ci-review:
# Require explicit maintainer review when CI-sensitive files change:
# eslint config, workflow YAMLs, or composite actions. These files
# influence what code the js-autofix job executes and pushes to
# main, so a malicious PR could inject arbitrary code via a custom eslint
# rule's `fix` function. The label gate ensures a human reviews before
# merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml.
name: CI-sensitive file review
if: inputs.event_name == 'pull_request' && inputs.ci_review
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Require ci-reviewed label
id: label-check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
echo "reviewed=true" >> "$GITHUB_OUTPUT"
echo "ci-reviewed label present."
exit 0
fi
echo "reviewed=false" >> "$GITHUB_OUTPUT"
# On failure: find the bot's previous comment and edit it, or create
# a new one if none exists. Using an HTML comment marker so we can
# locate it reliably across runs without parsing the body text.
# Skipped on fork PRs — GITHUB_TOKEN is read-only there, so the API
# call would fail. The label gate still holds via the step below.
- name: Post or update review warning
if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
MARKER="<!-- ci-review-bot -->"
BODY="${MARKER}
## ⚠️ CI-sensitive file review required
This PR changes CI-sensitive files (eslint config, workflow YAMLs,
or composite actions). These files influence what code the
js-autofix job executes and pushes to main.
A maintainer should verify:
- no new eslint rules with custom \`fix\` functions that write outside linted paths,
- no workflow changes that widen permissions or remove guards,
- no composite action changes that alter what gets executed.
After review, add the \`ci-reviewed\` label and re-run this check."
# Find an existing comment with our marker.
COMMENT_ID=$(gh api \
"repos/${{ github.repository }}/issues/${PR}/comments" \
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
| head -1 || true)
if [ -n "$COMMENT_ID" ]; then
gh api --method PATCH \
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
-f body="$BODY"
else
gh pr comment "$PR" --body "$BODY"
fi
# Fail the job when the label is missing — always runs (including
# fork PRs) so the security gate holds even when the comment step
# was skipped above.
- name: Fail on missing label
if: steps.label-check.outputs.reviewed != 'true'
run: |
echo "::error::CI-sensitive changes require the ci-reviewed label."
exit 1
# On success: if a previous warning comment exists, edit it to show
# the review passed so the PR doesn't have a stale ⚠️ sitting around.
# Skipped on fork PRs — no comment was ever posted to update.
- name: Update previous warning to passed
if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
MARKER="<!-- ci-review-bot -->"
# Find an existing comment with our marker.
COMMENT_ID=$(gh api \
"repos/${{ github.repository }}/issues/${PR}/comments" \
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
| head -1 || true)
if [ -n "$COMMENT_ID" ]; then
BODY="${MARKER}
## ✅ CI-sensitive file review passed
The \`ci-reviewed\` label is present on this PR."
gh api --method PATCH \
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
-f body="$BODY"
fi

View file

@ -1,51 +0,0 @@
# .github/workflows/typecheck.yml
name: Typecheck
on:
workflow_call:
jobs:
typecheck:
name: Check TypeScript
runs-on: ubuntu-latest
strategy:
matrix:
package:
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
fail-fast: false # report all failures, not just the first one
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# --ignore-scripts: typecheck only needs the TS sources + type defs, not
# native builds. Skipping install scripts drops node-pty's node-gyp
# header fetch — the transient flake that killed this job pre-`tsc` — and
# is faster. retry covers the remaining registry blips.
- uses: ./.github/actions/retry
with:
command: npm ci --ignore-scripts
- run: npm run --prefix ${{ matrix.package }} typecheck
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
# which does NOT exercise Vite/Rolldown module resolution — so an
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
# longer exports "./react-shim") slips past typecheck and only explodes when
# users build apps/desktop from source on install/update. Run the real
# `vite build` here so that class of break fails in CI instead.
desktop-build:
name: Build desktop app
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
# Keep install scripts here: the production build may need node-pty's
# native binary. retry handles the transient install-time fetch flakes.
- uses: ./.github/actions/retry
with:
command: npm ci
- run: npm run --prefix apps/desktop build

16
.gitignore vendored
View file

@ -68,8 +68,19 @@ environments/benchmarks/evals/
hermes_cli/web_dist/
apps/desktop/build/
apps/desktop/dist/
# tsc-emitted artifacts (a stray `tsc -b` compiles into src/, and vite then
# resolves the stale .js OVER the .tsx — never track these)
apps/desktop/src/**/*.js
apps/desktop/src/**/*.js.map
apps/desktop/src/**/*.d.ts
!apps/desktop/src/global.d.ts
!apps/desktop/src/vite-env.d.ts
apps/shared/src/**/*.js
apps/shared/src/**/*.js.map
apps/shared/src/**/*.d.ts
apps/desktop/release/
apps/desktop/*.tsbuildinfo
*.tsbuildinfo
# Web UI assets — synced from @nous-research/ui at build time via
# `npm run sync-assets` (see web/package.json).
@ -119,6 +130,9 @@ docs/superpowers/*
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Persistent dev sandbox dir (scripts/dev-sandbox.sh --persistent)
.hermes-sandbox/
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
# — ignore so `git status` stays clean and update's autostash skips them.

4
.prettierignore Normal file
View file

@ -0,0 +1,4 @@
# Lockfiles must never be reformatted — main has a repo rule requiring
# team approval when lockfiles change, so an autofix PR touching one
# would hang waiting for review.
package-lock.json

View file

@ -491,18 +491,18 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
### Electron Desktop Chat App (`apps/desktop/`)
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared``JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). 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 entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `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.ts` + `backendSupportsServe()` in `electron/main.ts`) 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. For scoped Desktop architecture, state, resolver, transport, and testing rules, read `apps/desktop/AGENTS.md`.
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
- **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills.
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMANDS` (the ~19 built-ins shown in the palette) plus block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMAND_SPECS` (the built-ins and their Desktop surfaces) plus `NO_DESKTOP_SURFACE` block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
- `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
- `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`.
- `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.)
- **Dispatch** lives in `app/session/hooks/use-prompt-actions.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
- **Dispatch** lives in `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: `apps/desktop/src/lib/desktop-slash-commands.test.ts` (run via the repo-root `vitest`, since `apps/desktop` resolves deps from the root workspace install).
**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: from `apps/desktop`, run `npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace dependencies are installed at the repo root).
---
@ -1278,6 +1278,7 @@ def profile_env(tmp_path, monkeypatch):
## Testing
### Python
**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
`-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest`
@ -1291,12 +1292,12 @@ scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
```
### Subprocess-per-test-file isolation
#### Subprocess-per-test-file isolation
Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
ContextVars from one test file cannot leak into the next.
### Why the wrapper
#### Why the wrapper
| | Without wrapper | With wrapper |
| ------------------- | ------------------------------------------- | ----------------------------------------- |
@ -1305,6 +1306,17 @@ ContextVars from one test file cannot leak into the next.
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
### Where to place what tests
The CI change classifier (`scripts/ci/classify_changes.py`) runs specific jobs based on what files changed. A Python test that asserts
about the contents of `package.json`, `package-lock.json`, `.ts`/`.tsx`
source, or any other JS-side artifact will not run on a PR that only touches
those files. This means a regression can go green on a PR and red on `main` (where the
classifier fails open and runs everything).
Any test that reads or asserts about `package.json`,
`package-lock.json`, `tsconfig.json`, `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs`
source files configuration belongs in the JS (vitest) test suite, not in `tests/*.py`.
### Don't write change-detector tests
@ -1354,3 +1366,58 @@ not the specific names.
Reviewers should reject new change-detector tests; authors should convert
them into invariants before re-requesting review.
### Never read source code in tests
A test that reads a source file's text is testing *the shape of the
source code*, not its behavior. This is a hard antipattern, banned outright.
Any test that reads a .py, .ts, .tsx, etc., file is suspect.
**Why it's actively harmful, not just weak:**
- It passes when the implementation is subtly broken (the regex matches a
call site that exists but is wired wrong) and fails when a correct
refactor changes formatting, variable names, or control flow with
identical runtime behavior. Both directions of failure are wrong.
- It can't be run against a built/bundled/minified artifact, so it silently
stops testing anything the moment code moves, gets renamed, or a
dependency reformats it.
- It actively blocks refactors: reviewers see "keeps a pattern intact" tests
fail during pure structural cleanup with no behavior change, and either
hand-wave the failure (dangerous) or waste time updating regexes that add
nothing (waste).
- It gives false confidence. a green suite full of source-regex tests
looks like coverage but has never once executed the code path it claims
to guard.
**Do not write:**
```ts
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
test('backend spawn hides the Windows console', () => {
assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
})
```
**Do write — extract the logic into a small pure/DI-testable function and
call it for real:**
```ts
// backend-spawn.ts
export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
if (!isWindows || 'windowsHide' in options) return options
return { ...options, windowsHide: true }
}
// backend-spawn.test.ts
test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
})
```
If the logic lives inline in a god-file (`main.ts`, `cli.py`,
`gateway/run.py`) and extracting it feels disruptive: that's the actual
signal to do the extraction, not to regex around it.

View file

@ -38,19 +38,22 @@ def _permission_option_supports_kind(kind: str) -> bool:
return True
def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]:
def _build_permission_options(
*, allow_permanent: bool, smart_denied: bool = False,
) -> list[PermissionOption]:
"""Return ACP options that match Hermes approval semantics."""
options = [
PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"),
PermissionOption(
options = [PermissionOption(
option_id="allow_once", kind="allow_once", name="Allow once",
)]
if not smart_denied:
options.append(PermissionOption(
option_id="allow_session",
# ACP has no session-scoped kind, so use the closest persistent
# hint while keeping Hermes semantics in the option id.
kind="allow_always",
name="Allow for session",
),
]
if allow_permanent:
))
if allow_permanent and not smart_denied:
options.append(
PermissionOption(
option_id="allow_always",
@ -59,7 +62,7 @@ def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption
),
)
options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny"))
if _permission_option_supports_kind("reject_always"):
if not smart_denied and _permission_option_supports_kind("reject_always"):
options.append(
PermissionOption(
option_id="deny_always",
@ -129,11 +132,15 @@ def make_approval_callback(
description: str,
*,
allow_permanent: bool = True,
smart_denied: bool = False,
**_: object,
) -> str:
from agent.async_utils import safe_schedule_threadsafe
options = _build_permission_options(allow_permanent=allow_permanent)
options = _build_permission_options(
allow_permanent=allow_permanent,
smart_denied=smart_denied,
)
tool_call = _build_permission_tool_call(command, description)
coro = request_permission_fn(

View file

@ -26,31 +26,18 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
def _win_path_to_wsl(path: str) -> str | None:
"""Convert a Windows drive path to its WSL /mnt/<drive>/... equivalent."""
match = re.match(r"^([A-Za-z]):[\\/](.*)$", path)
if not match:
return None
drive = match.group(1).lower()
tail = match.group(2).replace("\\", "/")
return f"/mnt/{drive}/{tail}"
def _translate_acp_cwd(cwd: str) -> str:
"""Translate Windows ACP cwd values when Hermes itself is running in WSL.
Windows ACP clients can launch ``hermes acp`` inside WSL while still sending
editor workspaces as Windows drive paths such as ``E:\\Projects``. Store
and execute against the WSL mount path so agents, tools, and persisted ACP
sessions all agree on the usable workspace. Native Linux/macOS keeps the
original cwd unchanged.
editor workspaces as Windows drive paths (``E:\\Projects``) or
``\\\\wsl.localhost\\`` UNC paths. Store and execute against the POSIX form so
agents, tools, and persisted ACP sessions all agree on the usable workspace.
Native Linux/macOS keeps the original cwd unchanged.
"""
from hermes_constants import is_wsl
from hermes_constants import translate_cwd_for_wsl_backend
if not is_wsl():
return cwd
translated = _win_path_to_wsl(str(cwd))
return translated if translated is not None else cwd
return translate_cwd_for_wsl_backend(str(cwd))
def _normalize_cwd_for_compare(cwd: str | None) -> str:
@ -61,7 +48,9 @@ def _normalize_cwd_for_compare(cwd: str | None) -> str:
# Normalize Windows drive paths into the equivalent WSL mount form so
# ACP history filters match the same workspace across Windows and WSL.
translated = _win_path_to_wsl(expanded)
from hermes_constants import windows_path_to_wsl
translated = windows_path_to_wsl(expanded)
if translated is not None:
expanded = translated
elif re.match(r"^/mnt/[A-Za-z]/", expanded):

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import json
import logging
import uuid
from typing import Any, Dict, List, Optional
@ -14,6 +15,8 @@ from acp.schema import (
ToolKind,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Map hermes tool names -> ACP ToolKind
# ---------------------------------------------------------------------------
@ -110,7 +113,12 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:
if tool_name == "web_extract":
urls = args.get("urls", [])
if urls:
return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
first = urls[0]
if isinstance(first, dict):
first = first.get("url") or first.get("href") or "?"
elif not isinstance(first, str):
first = "?"
return f"extract: {first}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
return "web extract"
if tool_name == "process":
action = str(args.get("action") or "").strip() or "manage"
@ -1021,7 +1029,37 @@ def build_tool_start(
*,
edit_diff: Any = None,
) -> ToolCallStart:
"""Create a ToolCallStart event for the given hermes tool invocation."""
"""Create a ToolCallStart event for the given hermes tool invocation.
A malformed tool argument (e.g. a non-string ``command``/``path`` from a
model that ignores the schema) must never abort the ACP tool-call render
``build_tool_start`` runs on the live tool-progress callback and during
session history replay. On any failure in the title/content/location
builders, fall back to a minimal, valid start event. Mirrors
``get_cute_tool_message`` in ``agent/display.py``, wrapped for the same
reason on the CLI side.
"""
try:
return _build_tool_start(
tool_call_id, tool_name, arguments, edit_diff=edit_diff
)
except Exception as exc: # noqa: BLE001 — a tool-call render must never abort the turn
logger.debug("ACP tool-start render failed for %r: %s", tool_name, exc)
safe_name = tool_name if isinstance(tool_name, str) and tool_name else "tool"
return acp.start_tool_call(
tool_call_id, safe_name, kind=get_tool_kind(safe_name),
content=None, locations=[], raw_input=None,
)
def _build_tool_start(
tool_call_id: str,
tool_name: str,
arguments: Dict[str, Any],
*,
edit_diff: Any = None,
) -> ToolCallStart:
"""Build the ToolCallStart event (unguarded; see ``build_tool_start``)."""
kind = get_tool_kind(tool_name)
title = build_tool_title(tool_name, arguments)
locations = extract_locations(arguments)

View file

@ -425,15 +425,28 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
)
def _resolve_codex_usage_url(base_url: str) -> str:
def _codex_backend_urls(base_url: str) -> tuple[str, str, str]:
"""Resolve the Codex backend endpoints (usage, reset-credits list, consume).
Mirrors the Codex CLI's PathStyle split (codex-rs backend-client): base URLs
containing ``/backend-api`` use the ChatGPT ``/wham/...`` paths; everything
else uses ``/api/codex/...``.
"""
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
normalized = "https://chatgpt.com/backend-api/codex"
if normalized.endswith("/codex"):
normalized = normalized[: -len("/codex")]
if "/backend-api" in normalized:
return normalized + "/wham/usage"
return normalized + "/api/codex/usage"
prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex")
return (
prefix + "/usage",
prefix + "/rate-limit-reset-credits",
prefix + "/rate-limit-reset-credits/consume",
)
def _resolve_codex_usage_url(base_url: str) -> str:
return _codex_backend_urls(base_url)[0]
def _resolve_codex_usage_credentials(
@ -525,6 +538,14 @@ def _fetch_codex_account_usage(
)
)
details: list[str] = []
reset_credits = payload.get("rate_limit_reset_credits") or {}
banked = reset_credits.get("available_count")
if isinstance(banked, (int, float)) and int(banked) > 0:
count = int(banked)
plural = "s" if count != 1 else ""
details.append(
f"You have {count} reset{plural} banked - use /usage reset to activate"
)
credits = payload.get("credits") or {}
if credits.get("has_credits"):
balance = credits.get("balance")
@ -542,6 +563,179 @@ def _fetch_codex_account_usage(
)
@dataclass(frozen=True)
class CodexResetRedeemResult:
"""Outcome of a `/usage reset` attempt against the Codex backend."""
status: str # reset | nothing_to_reset | no_credit | already_redeemed |
# not_exhausted | no_credits_banked | unavailable
message: str
available_count: int = 0
windows_reset: int = 0
@property
def redeemed(self) -> bool:
return self.status == "reset"
# Client-side guard threshold: a rate-limit window only counts as exhausted
# when it is fully used. Below this, redeeming a banked reset wastes most of
# its value, so we block and point at --force instead.
_CODEX_WINDOW_EXHAUSTED_PERCENT = 100.0
def redeem_codex_reset_credit(
*,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
force: bool = False,
) -> CodexResetRedeemResult:
"""Redeem one banked Codex rate-limit reset credit (`/usage reset`).
Flow (mirrors the Codex CLI's reset-credits picker, codex-rs
``backend-client``):
1. ``GET .../usage`` read the current windows + banked credit count.
2. Guard: zero banked credits refuse. No window fully used and not
``force`` refuse with a warning (a banked reset restores the WHOLE
5h + weekly allowance; burning it early wastes it). The backend has
the same protection (``nothing_to_reset`` doesn't consume the
credit), but failing fast client-side gives a clearer message.
3. ``POST .../rate-limit-reset-credits/consume`` with a fresh UUID
idempotency key (``redeem_request_id``). No ``credit_id`` the
backend picks the next available credit, exactly like the CLI's
default "Full reset" option.
Never raises: every failure mode returns a ``CodexResetRedeemResult``
with a user-renderable message.
"""
import uuid
try:
token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key)
except Exception:
return CodexResetRedeemResult(
status="unavailable",
message="No Codex credentials available. Run `hermes auth` to sign in with your ChatGPT account.",
)
usage_url, _credits_url, consume_url = _codex_backend_urls(resolved_base_url)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"User-Agent": "codex-cli",
}
if account_id:
headers["ChatGPT-Account-Id"] = account_id
try:
with httpx.Client(timeout=15.0) as client:
usage_resp = client.get(usage_url, headers=headers)
usage_resp.raise_for_status()
payload = usage_resp.json() or {}
reset_credits = payload.get("rate_limit_reset_credits") or {}
raw_count = reset_credits.get("available_count")
available = int(raw_count) if isinstance(raw_count, (int, float)) else 0
if available <= 0:
return CodexResetRedeemResult(
status="no_credits_banked",
message="No banked reset credits on this account — nothing to redeem.",
)
rate_limit = payload.get("rate_limit") or {}
worst_used: Optional[float] = None
for key in ("primary_window", "secondary_window"):
used = (rate_limit.get(key) or {}).get("used_percent")
if isinstance(used, (int, float)):
worst_used = max(worst_used or 0.0, float(used))
exhausted = worst_used is not None and worst_used >= _CODEX_WINDOW_EXHAUSTED_PERCENT
if not exhausted and not force:
usage_note = (
f"your busiest window is only {worst_used:.0f}% used"
if worst_used is not None
else "your current usage could not be confirmed as exhausted"
)
plural = "s" if available != 1 else ""
return CodexResetRedeemResult(
status="not_exhausted",
message=(
f"⚠️ Not redeeming: {usage_note}. A banked reset restores your FULL "
f"5h + weekly limits, so spending it now would waste most of it. "
f"You have {available} reset{plural} banked. "
f"Use `/usage reset --force` to redeem anyway."
),
available_count=available,
)
consume_resp = client.post(
consume_url,
headers={**headers, "Content-Type": "application/json"},
json={"redeem_request_id": str(uuid.uuid4())},
)
consume_resp.raise_for_status()
body = consume_resp.json() or {}
except httpx.HTTPStatusError as exc:
code = exc.response.status_code
if code in (401, 403):
return CodexResetRedeemResult(
status="unavailable",
message=(
"Codex backend rejected the request (HTTP "
f"{code}). Reset credits require ChatGPT-account (OAuth) auth — "
"run `hermes auth` and sign in with your ChatGPT account."
),
)
return CodexResetRedeemResult(
status="unavailable",
message=f"Codex backend error (HTTP {code}) — try again shortly.",
)
except Exception as exc:
return CodexResetRedeemResult(
status="unavailable",
message=f"Could not reach the Codex backend: {exc}",
)
code = str(body.get("code", "") or "").strip().lower()
windows_reset = body.get("windows_reset")
windows_reset = int(windows_reset) if isinstance(windows_reset, (int, float)) else 0
remaining = max(0, available - 1)
plural = "s" if remaining != 1 else ""
if code == "reset":
return CodexResetRedeemResult(
status="reset",
message=(
f"✅ Reset redeemed — your usage limits have been reset. "
f"{remaining} banked reset{plural} remaining."
),
available_count=remaining,
windows_reset=windows_reset,
)
if code == "nothing_to_reset":
return CodexResetRedeemResult(
status="nothing_to_reset",
message=(
"Backend reports nothing to reset — your limits aren't exhausted. "
"The credit was NOT spent."
),
available_count=available,
)
if code == "no_credit":
return CodexResetRedeemResult(
status="no_credit",
message="Backend reports no available reset credit on this account.",
)
if code == "already_redeemed":
return CodexResetRedeemResult(
status="already_redeemed",
message="This redemption was already processed — no additional credit was spent.",
available_count=remaining,
)
return CodexResetRedeemResult(
status="unavailable",
message=f"Unexpected response from the Codex backend: {body!r}",
)
def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]:
token = (resolve_anthropic_token() or "").strip()
if not token:

View file

@ -77,8 +77,8 @@ def _build_codex_gpt5_autoraise_notice(autoraise: Dict[str, Any]) -> str:
include the exact opt-back-out command.
"""
model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1]
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5 family is
# capped at 272K by the Codex OAuth backend.
# gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 family
# is capped at 272K by the Codex OAuth backend.
cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K"
from_pct = int(round(autoraise["from"] * 100))
to_pct = int(round(autoraise["to"] * 100))
@ -187,10 +187,26 @@ def _normalized_custom_base_url(value: Any) -> str:
def _custom_provider_model_matches(agent_model: str, entry: Dict[str, Any]) -> bool:
provider_model = str(entry.get("model", "") or "").strip().lower()
if not provider_model:
agent_model_norm = str(agent_model or "").strip().lower()
# Multi-model entries (v12+ `providers.<name>.models` mapping / legacy
# `models:` list): the agent's model matching ANY catalog entry counts.
# Without this, a provider whose `model`/`default_model` differs from the
# session model silently fails to match and per-provider request settings
# (extra_body, e.g. OpenAI service_tier) are dropped — billing the whole
# session at the wrong tier (July 2026 sweeper incident: flex config
# ignored, ~2.3x overbilling).
models = entry.get("models")
catalog: List[str] = []
if isinstance(models, dict):
catalog = [str(k).strip().lower() for k in models.keys()]
elif isinstance(models, (list, tuple)):
catalog = [str(m).strip().lower() for m in models]
if catalog and agent_model_norm in catalog:
return True
return provider_model == str(agent_model or "").strip().lower()
provider_model = str(entry.get("model", "") or "").strip().lower()
if not provider_model and not catalog:
return True
return provider_model == agent_model_norm
def _custom_provider_extra_body_for_agent(
@ -302,6 +318,7 @@ def init_agent(
notice_callback: callable = None,
notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
reaction_callback: Optional[Callable[[str], None]] = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
service_tier: str = None,
@ -411,13 +428,13 @@ def init_agent(
agent.skip_context_files = skip_context_files
agent.load_soul_identity = load_soul_identity
agent.pass_session_id = pass_session_id
agent._credential_pool = credential_pool
agent.log_prefix_chars = log_prefix_chars
agent.log_prefix = f"{log_prefix} " if log_prefix else ""
# Store effective base URL for feature detection (prompt caching, reasoning, etc.)
agent.base_url = base_url or ""
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
agent.provider = provider_name or ""
agent._credential_pool = credential_pool
agent.acp_command = acp_command or command
agent.acp_args = list(acp_args or args or [])
if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server"}:
@ -453,6 +470,24 @@ def init_agent(
else:
agent.api_mode = "chat_completions"
# Credential-pool validation runs AFTER provider auto-detection so
# a pool scoped to e.g. "anthropic" is not rejected when the agent
# was constructed with provider=None and an anthropic.com URL.
# Regression from #63048 which placed this check before the
# URL-based auto-detection block above (fixed #63425).
if credential_pool is not None:
try:
from agent.credential_pool import credential_pool_matches_provider
if not credential_pool_matches_provider(
credential_pool,
agent.provider,
base_url=agent.base_url,
):
agent._credential_pool = None
except Exception:
agent._credential_pool = None
# Eagerly warm the transport cache so import errors surface at init,
# not mid-conversation. Also validates the api_mode is registered.
try:
@ -535,6 +570,7 @@ def init_agent(
agent.notice_callback = notice_callback
agent.notice_clear_callback = notice_clear_callback
agent.event_callback = event_callback
agent.reaction_callback = reaction_callback
agent.tool_gen_callback = tool_gen_callback
@ -1281,6 +1317,14 @@ def init_agent(
# SQLite session store (optional -- provided by CLI or gateway)
agent._session_db = session_db
agent._parent_session_id = parent_session_id
# A close flush and the worker's turn-start flush can overlap. The durable
# marker is attached to each in-memory message dict, so its test-and-append
# sequence must be serialized per agent rather than relying on SQLite alone.
agent._session_persist_lock = threading.RLock()
# CLI retains its just-accepted user dict until turn setup can reuse it.
# This preserves the message-local durable marker if close persistence wins
# the race before the agent's normal early turn flush.
agent._pending_cli_user_message = None
agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes
agent._session_db_created = False # DB row deferred to run_conversation()
# Most agents own their session row and should finalize it on close().

View file

@ -1208,7 +1208,42 @@ def restore_primary_runtime(agent) -> bool:
api_mode=rt.get("compressor_api_mode", ""),
)
# ── Re-select from the credential pool if one is available ──
# ── Rebind and re-select the primary credential pool ──
# A cross-provider fallback attaches the fallback provider's pool. The
# runtime fields above restore the primary, but leaving that pool in
# place makes the next primary 401/429 hit the provider-mismatch guard
# and disables credential rotation. Reload the primary pool first; if
# auth storage is temporarily unreadable, clear the mismatched pool.
primary_provider = str(rt.get("provider") or "").strip().lower()
pool = getattr(agent, "_credential_pool", None)
pool_provider = str(getattr(pool, "provider", "") or "").strip().lower()
pool_matches_primary = pool_provider == primary_provider
if (
primary_provider == "custom"
and pool_provider.startswith("custom:")
):
try:
from agent.credential_pool import get_custom_provider_pool_key
primary_key = (
get_custom_provider_pool_key(str(rt.get("base_url") or "")) or ""
).strip().lower()
pool_matches_primary = bool(primary_key) and primary_key == pool_provider
except Exception:
pool_matches_primary = False
if pool is not None and pool_provider and not pool_matches_primary:
agent._credential_pool = None
try:
from agent.credential_pool import load_pool
agent._credential_pool = load_pool(primary_provider)
except Exception as exc:
logger.warning(
"Restore could not reload primary credential pool for %s: %s",
primary_provider,
exc,
)
# 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
@ -1222,7 +1257,6 @@ def restore_primary_runtime(agent) -> bool:
entry = pool.select()
if entry is not None:
entry_provider = str(getattr(entry, "provider", "") or "").strip().lower()
primary_provider = str(rt.get("provider") or "").strip().lower()
entry_matches_primary = entry_provider == primary_provider
# Custom endpoints all carry the generic ``custom`` provider on
# the agent while the pool entry is keyed ``custom:<name>`` (see
@ -1271,6 +1305,13 @@ def restore_primary_runtime(agent) -> bool:
primary_provider or "?",
)
# ── Restore reasoning_config if it was saved ──
# switch_model saves reasoning_config in _primary_runtime. If the
# snapshot predates that (older sessions), keep the current value.
saved_reasoning = rt.get("reasoning_config")
if saved_reasoning is not None:
agent.reasoning_config = dict(saved_reasoning)
# ── Reset fallback chain for the new turn ──
agent._fallback_activated = False
agent._fallback_index = 0
@ -1564,6 +1605,17 @@ def anthropic_prompt_cache_policy(
model_lower = eff_model.lower()
provider_lower = eff_provider.lower()
is_claude = "claude" in model_lower
# Kimi / Moonshot family via OpenRouter: same cache_control wire format
# as Claude on OpenRouter (envelope layout). Without this branch
# moonshotai/kimi-k2.6 falls through to (False, False), serving ~1%
# cache hits on 64K-token prompts and re-billing the full prompt on
# every turn. Observed within-turn progression with cache enabled:
# 1% → 67% → 84% → 97% (#25970). Reuses the canonical family matcher
# (covers bare k1./k2./k25 release slugs the substring check missed).
from agent.anthropic_adapter import _model_name_is_kimi_family
is_kimi = (
_model_name_is_kimi_family(eff_model) or "moonshot" in model_lower
)
is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai")
# Nous Portal proxies to OpenRouter behind the scenes — identical
# OpenAI-wire envelope cache_control semantics. Treat it as an
@ -1577,7 +1629,7 @@ def anthropic_prompt_cache_policy(
if is_native_anthropic:
return True, True
if (is_openrouter or is_nous_portal) and is_claude:
if (is_openrouter or is_nous_portal) and (is_claude or is_kimi):
return True, False
# Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout
# cache_control path as Portal Claude. Portal proxies to OpenRouter
@ -1805,13 +1857,30 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Swap core runtime fields ──
agent.model = new_model
agent.provider = new_provider
# Use new base_url when provided; only fall back to current when the
# new provider genuinely has no endpoint (e.g. native SDK providers).
# Without this guard the old provider's URL (e.g. Ollama's localhost
# address) would persist silently after switching to a cloud provider
# that returns an empty base_url string.
# Use the new base_url when provided. When it's empty AND the
# provider is actually changing, do NOT fall back to the current
# (old provider's) URL — that silently pairs the new provider label
# with the previous provider's endpoint (e.g. new_provider=minimax
# paired with the leftover api.githubcopilot.com URL), and every
# request after the switch 400s at the wrong host. This mismatched
# pair also gets snapshotted into _primary_runtime below, so it
# keeps re-applying on every subsequent turn until a full restart.
# Fail loud instead: the caller (model_switch.switch_model())
# already resolves base_url for every real provider, so an empty
# value here means resolution failed upstream, not that the
# provider genuinely has none. Re-selecting the SAME provider with
# an empty base_url (e.g. a credential-only refresh) is still fine
# to keep the current URL. See #47828.
old_norm_provider = (old_provider or "").strip().lower()
new_norm_provider = (new_provider or "").strip().lower()
if base_url:
agent.base_url = base_url
elif old_norm_provider != new_norm_provider:
raise ValueError(
f"switch_model: no base_url resolved for provider "
f"'{new_provider}' (switching from '{old_provider}'); "
"refusing to keep the previous provider's endpoint"
)
agent.api_mode = api_mode
# Invalidate transport cache — new api_mode may need a different transport
if hasattr(agent, "_transport_cache"):
@ -1830,6 +1899,9 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
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:
# A pool bound to the old provider is worse than no pool: the
# recovery guard rejects it and every later 401/429 skips rotation.
agent._credential_pool = None
try:
from agent.credential_pool import load_pool
agent._credential_pool = load_pool(new_provider)
@ -1925,6 +1997,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
if _sm_timeout is not None:
agent._client_kwargs["timeout"] = _sm_timeout
# Reapply provider-specific headers (e.g. OpenRouter HTTP-Referer,
# X-Title) that were lost when _client_kwargs was rebuilt from
# scratch. Without this, model switches clear attribution headers
# and OpenRouter logs show "Unknown" for subsequent requests.
agent._apply_client_headers_for_base_url(effective_base)
agent.client = agent._create_openai_client(
dict(agent._client_kwargs),
reason="switch_model",
@ -1995,6 +2072,24 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
api_mode=agent.api_mode,
)
# ── Re-resolve reasoning_config from per-model override ──
# The new model may have a different reasoning_effort override. Re-read
# config so the override takes effect immediately on /model switch —
# resolved through the shared chokepoint (per-model > global; YAML
# boolean False = disabled).
try:
from hermes_constants import resolve_reasoning_config
from hermes_cli.config import load_config as _sm_load_config
_reasoning_cfg = _sm_load_config() or {}
agent.reasoning_config = resolve_reasoning_config(_reasoning_cfg, agent.model)
logger.info(
"switch_model: reasoning_config resolved for %s: %s",
agent.model, agent.reasoning_config,
)
except Exception as _reasoning_err:
logger.debug("switch_model: could not re-resolve reasoning_config: %s", _reasoning_err)
# ── Invalidate cached system prompt so it rebuilds next turn ──
agent._cached_system_prompt = None
@ -2017,6 +2112,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"client_kwargs": dict(agent._client_kwargs),
"use_prompt_caching": agent._use_prompt_caching,
"use_native_cache_layout": agent._use_native_cache_layout,
"reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None,
"compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model,
"compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url,
"compressor_api_key": getattr(_cc, "api_key", "") if _cc else "",

View file

@ -65,6 +65,7 @@ THINKING_BUDGET = {"xhigh": 32000, "high": 16000, "medium": 8000, "low": 4000}
# maps to low on every model. See:
# https://platform.claude.com/docs/en/about-claude/models/migration-guide
ADAPTIVE_EFFORT_MAP = {
"ultra": "max",
"max": "max",
"xhigh": "xhigh",
"high": "high",
@ -2102,7 +2103,7 @@ def _convert_user_message(content: Any) -> Dict[str, Any]:
if isinstance(content, list):
converted_blocks = _convert_content_to_anthropic(content)
if not converted_blocks or all(
b.get("text", "").strip() == ""
(b.get("text") or "").strip() == ""
for b in converted_blocks
if isinstance(b, dict) and b.get("type") == "text"
):

View file

@ -44,6 +44,7 @@ import contextlib
import json
import logging
import os
import re
import threading
import time
from pathlib import Path # noqa: F401 — used by test mocks
@ -102,7 +103,6 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
from agent.credential_pool import load_pool
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
from agent.process_bootstrap import build_keepalive_http_client
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars
@ -156,22 +156,47 @@ def _resolve_aux_verify(base_url: Optional[str]) -> Any:
return True
_WARNED_KEEPALIVE_IMPORT_SKEW = False
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,
verify=_resolve_aux_verify(base_url),
)
try:
from agent.process_bootstrap import build_keepalive_http_client
client = build_keepalive_http_client(
str(base_url or ""),
async_mode=async_mode,
verify=_resolve_aux_verify(base_url),
)
except (ImportError, AttributeError):
# Version-skewed installs (#64333): a process whose sys.path resolves
# an older agent/process_bootstrap.py without this helper — seen when
# the Desktop app's bundled runtime lags a git-installed source tree
# that newer callers (cron scheduler) were written against. Every cron
# job died on this ImportError before any agent logic ran. Degrade
# gracefully to the OpenAI SDK's default httpx client (respects macOS
# system proxy, no pool-level keepalive expiry) instead of failing the
# whole job, and say so once — silent version skew is how this bug
# went unnoticed until jobs were already dead on arrival.
global _WARNED_KEEPALIVE_IMPORT_SKEW
if not _WARNED_KEEPALIVE_IMPORT_SKEW:
_WARNED_KEEPALIVE_IMPORT_SKEW = True
logger.warning(
"agent.process_bootstrap.build_keepalive_http_client is "
"unavailable — mixed/stale install detected (#64333). Falling "
"back to the SDK default HTTP client. Run `hermes update` (or "
"reinstall the Desktop app) to resync the runtime."
)
client = None
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
@ -314,15 +339,18 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
return bare == "trinity-large-thinking"
# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.4/5.5.
# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the
# Codex backend hard-caps at 272K (verified live: a ~330K-token request to
# Context window enforced by ChatGPT's Codex OAuth backend for the
# gpt-5.4 / gpt-5.5 / gpt-5.6 families. The raw OpenAI API and OpenRouter
# expose 1.05M for the same slugs, but the Codex backend hard-caps at 272K
# (verified live for 5.4/5.5: a ~330K-token request to
# chatgpt.com/backend-api/codex/responses is rejected with
# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the
# default 50% compaction trigger fires at ~136K — wasteful, since the model
# can hold far more raw context before summarization actually buys anything.
# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.4/
# gpt-5.5 sessions use the window they actually have.
# ``context_length_exceeded`` while ~250K succeeds; gpt-5.6 shares the same
# 272K Codex cap — see _CODEX_OAUTH_CONTEXT_FALLBACK in model_metadata.py).
# With a 272K ceiling the default 50% compaction trigger fires at ~136K —
# wasteful, since the model can hold far more raw context before
# summarization actually buys anything. We raise the trigger to 85% (~231K)
# on this exact route so Codex gpt-5.4 / gpt-5.5 / gpt-5.6 sessions use the
# window they actually have.
_CODEX_GPT54_GPT55_COMPACTION_THRESHOLD = 0.85
# gpt-5.3-codex-spark is Codex-OAuth-only (ChatGPT Pro entitlement) with a
@ -336,14 +364,16 @@ _CODEX_SPARK_COMPACTION_THRESHOLD = 0.70
def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
"""True for gpt-5.4 / gpt-5.5 on the ChatGPT Codex OAuth backend.
"""True for gpt-5.4 / gpt-5.5 / gpt-5.6 on the ChatGPT Codex OAuth backend.
Matches only the Codex OAuth route (provider ``openai-codex``), not the
direct OpenAI API, OpenRouter, or GitHub Copilot paths those expose a
larger context window for the same slug and must keep the user's default
compaction threshold. ``gpt-5.4-pro`` / ``gpt-5.5-pro`` and dated snapshots
are matched via prefix so the override tracks both 272K-capped families
without re-listing every variant.
compaction threshold. ``-pro`` variants and dated snapshots are matched
via prefix so the override tracks every 272K-capped family (5.4, 5.5,
5.6 sol/terra/luna incl. their ``-pro`` modes) without re-listing every
variant. (Name kept for backward compatibility with the
``compression.codex_gpt55_autoraise`` config key.)
"""
prov = (provider or "").strip().lower()
if prov != "openai-codex":
@ -356,6 +386,9 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non
or bare == "gpt-5.5"
or bare.startswith("gpt-5.5-")
or bare.startswith("gpt-5.5.")
or bare == "gpt-5.6"
or bare.startswith("gpt-5.6-")
or bare.startswith("gpt-5.6.")
)
@ -410,11 +443,12 @@ def _compression_threshold_for_model(
Per-model/route overrides:
- Arcee Trinity Large Thinking 0.75 (preserve reasoning context).
- gpt-5.4 / gpt-5.5 on the Codex OAuth route 0.85, because Codex caps
both families at 272K and the default 50% trigger would compact at
~136K. Gated by ``allow_codex_gpt55_autoraise`` (historical config-key
name kept for backward compatibility) so the user can opt back down to
the global default (the caller passes the config flag through here).
- gpt-5.4 / gpt-5.5 / gpt-5.6 on the Codex OAuth route 0.85, because
Codex caps all three families at 272K and the default 50% trigger
would compact at ~136K. Gated by ``allow_codex_gpt55_autoraise``
(historical config-key name kept for backward compatibility) so the
user can opt back down to the global default (the caller passes the
config flag through here).
- gpt-5.3-codex-spark on the Codex OAuth route 0.70, because the model
has a native 128K window and the default 50% trigger would compact at
~64K wasting half the usable context. Not gated by the gpt-5.5
@ -465,6 +499,10 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"kilocode": "google/gemini-3-flash-preview",
"ollama-cloud": "nemotron-3-nano:30b",
"tencent-tokenhub": "hy3-preview",
# NB: no "deepinfra" entry — its aux model lives on the ProviderProfile
# (plugins/model-providers/deepinfra: default_aux_model), which
# _get_aux_model_for_provider() reads first. Duplicating it here would be
# dead data that drifts when the profile's value is bumped.
}
# Legacy alias — callers that haven't been updated to _get_aux_model_for_provider()
@ -480,6 +518,33 @@ _PROVIDER_VISION_MODELS: Dict[str, str] = {
"zai": "glm-5v-turbo",
}
def _resolve_provider_vision_default(provider: str) -> Optional[str]:
"""Return the provider's preferred default vision model id, or None.
Static entries in :data:`_PROVIDER_VISION_MODELS` win first (xiaomi /
zai have dedicated vision-only model names that don't live in any
discoverable catalog). Otherwise the provider's :class:`ProviderProfile`
gets a chance to supply one via its ``default_vision_model()`` hook
that's where catalog-backed providers (DeepInfra) resolve a live default,
keeping the discovery logic inside their plugin instead of a name-check
branch here.
"""
static = _PROVIDER_VISION_MODELS.get(provider)
if static:
return static
try:
from providers import get_provider_profile
profile = get_provider_profile(provider)
except Exception:
return None
if profile is None:
return None
try:
return profile.default_vision_model()
except Exception:
return None
# Providers whose endpoint does not accept image input, even though the
# provider's broader ecosystem has vision models available elsewhere. When
# `auxiliary.vision.provider: auto` sees one of these as the main provider,
@ -863,6 +928,7 @@ class _CodexCompletionsAdapter:
# `function_call_output` items with a valid call_id, so every
# Responses path normalizes tool history identically and cannot drift.
from agent.codex_responses_adapter import _chat_messages_to_responses_input
from utils import base_url_host_matches
instructions = "You are a helpful assistant."
replay_messages: List[Dict[str, Any]] = []
@ -874,7 +940,18 @@ class _CodexCompletionsAdapter:
else:
replay_messages.append(msg)
input_items = _chat_messages_to_responses_input(replay_messages)
# Copilot (githubcopilot.com) binds replayed codex_message_items ids
# to a backend "connection" that doesn't survive credential
# rotation/gateway restarts — replaying one gets HTTP 401 "input
# item ID does not belong to this connection" (#32716). Auxiliary
# calls (context compression, flush_memories, MoA aggregation) go
# through this adapter instead of agent/transports/codex.py's
# build_kwargs, so they need the same guard applied independently.
_host_for_input = str(getattr(self._client, "base_url", "") or "")
_is_github_for_input = base_url_host_matches(_host_for_input, "githubcopilot.com")
input_items = _chat_messages_to_responses_input(
replay_messages, is_github_responses=_is_github_for_input,
)
resp_kwargs: Dict[str, Any] = {
"model": model,
@ -1234,6 +1311,7 @@ class _AnthropicCompletionsAdapter:
model = kwargs.get("model", self._model)
tools = kwargs.get("tools")
tool_choice = kwargs.get("tool_choice")
reasoning_config = kwargs.get("_reasoning_config")
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
# models (glm-4v-flash etc.) with error code 1210. When the caller
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
@ -1254,12 +1332,25 @@ class _AnthropicCompletionsAdapter:
elif choice_type in {"auto", "required", "none"}:
normalized_tool_choice = choice_type
# Reasoning priority: explicit per-call reasoning_config (MoA per-slot,
# passed as _reasoning_config by _build_call_kwargs) wins over an
# extra_body.reasoning dict (auxiliary.<task>.extra_body config).
# build_anthropic_kwargs translates the config dict into the native
# ``thinking`` field and handles models where thinking is mandatory.
_reasoning_cfg = reasoning_config
if _reasoning_cfg is None:
_eb = kwargs.get("extra_body")
if isinstance(_eb, dict):
_rc = _eb.get("reasoning")
if isinstance(_rc, dict):
_reasoning_cfg = _rc
anthropic_kwargs = build_anthropic_kwargs(
model=model,
messages=messages,
tools=tools,
max_tokens=max_tokens,
reasoning_config=None,
reasoning_config=_reasoning_cfg,
tool_choice=normalized_tool_choice,
is_oauth=self._is_oauth,
)
@ -1271,6 +1362,31 @@ class _AnthropicCompletionsAdapter:
if not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
# Pass through caller-supplied extra_body so providers behind
# Anthropic-compatible gateways receive their per-vendor request
# fields (thinking control, metadata, portal tags, ...). The dict
# form is the documented Anthropic SDK passthrough for non-standard
# request body keys; merge on top of whatever build_anthropic_kwargs
# already produced (e.g. fast-mode ``speed``) so call-time settings
# survive. Two exclusions:
# - ``reasoning``: the OpenAI-shaped config dict is TRANSLATED into
# the native ``thinking`` field above (build_anthropic_kwargs);
# forwarding the raw field alongside would double-specify
# reasoning and 400 on strict gateways.
# - ``_``-prefixed keys: private Hermes plumbing (_reasoning_config
# et al.), never wire fields.
caller_extra_body = kwargs.get("extra_body")
if caller_extra_body and isinstance(caller_extra_body, dict):
passthrough = {
k: v for k, v in caller_extra_body.items()
if k != "reasoning" and not str(k).startswith("_")
}
if passthrough:
existing = anthropic_kwargs.get("extra_body") or {}
if not isinstance(existing, dict):
existing = {}
anthropic_kwargs["extra_body"] = {**existing, **passthrough}
response = create_anthropic_message(self._client, anthropic_kwargs)
_transport = get_transport("anthropic_messages")
_nr = _transport.normalize_response(
@ -3377,6 +3493,7 @@ def _retry_same_provider_sync(
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
reasoning_config: Optional[dict],
) -> Any:
if task == "vision":
_, retry_client, retry_model = resolve_vision_provider_client(
@ -3410,6 +3527,7 @@ def _retry_same_provider_sync(
tools=tools,
timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=retry_base or resolved_base_url,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
@ -3434,6 +3552,7 @@ async def _retry_same_provider_async(
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
reasoning_config: Optional[dict],
) -> Any:
if task == "vision":
_, retry_client, retry_model = resolve_vision_provider_client(
@ -3467,6 +3586,7 @@ async def _retry_same_provider_async(
tools=tools,
timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=retry_base or resolved_base_url,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
@ -3574,6 +3694,40 @@ def _auth_refresh_provider_for_route(
return normalized
def _fallback_entry_timeout(task: Optional[str], fb_label: str) -> Optional[float]:
"""Resolve a per-entry ``timeout`` for a configured fallback candidate.
A fallback candidate previously inherited the exact timeout the primary
provider was called with. When that deadline was tuned for the primary
(or the primary simply consumed its whole budget before failing over),
the fallback aborted on the same clock even when independently healthy
a 163k-token compression that needs ~90s on the fallback died at the
primary's 30s deadline every turn (#62452).
Entries in ``auxiliary.<task>.fallback_chain`` may declare their own
``timeout`` (seconds). This helper reads it by parsing the entry index
out of the label minted by :func:`_try_configured_fallback_chain`
(``fallback_chain[<i>](<provider>)`` our own stable format). Returns
``None`` when the label is not a configured-chain candidate, the entry
has no ``timeout``, or the value is invalid callers then keep the
task-level timeout, preserving existing behavior.
"""
if not task or not fb_label:
return None
m = re.match(r"fallback_chain\[(\d+)\]", fb_label)
if not m:
return None
try:
chain = _get_auxiliary_task_config(task).get("fallback_chain")
entry = chain[int(m.group(1))] if isinstance(chain, list) else None
raw = entry.get("timeout") if isinstance(entry, dict) else None
except Exception:
return None
if isinstance(raw, (int, float)) and not isinstance(raw, bool) and raw > 0:
return float(raw)
return None
def _call_fallback_candidate_sync(
fb_client: Any,
fb_model: Optional[str],
@ -3586,6 +3740,7 @@ def _call_fallback_candidate_sync(
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
reasoning_config: Optional[dict],
) -> Optional[Any]:
"""Call one fallback candidate with stale-credential recovery.
@ -3601,13 +3756,27 @@ def _call_fallback_candidate_sync(
once with a rebuilt client; if the retry also auth-fails (non-refreshable
expired token), mark the provider unhealthy and return ``None`` so the
caller can continue to the next fallback layer. Non-auth errors raise.
``effective_timeout`` is the task-level deadline; a configured-chain
candidate with its own ``timeout`` entry gets that instead, so a
fallback tuned differently from the primary is allowed its own budget
(#62452).
"""
fb_timeout = _fallback_entry_timeout(task, fb_label)
if fb_timeout is not None and fb_timeout != effective_timeout:
logger.info(
"Auxiliary %s: %s using its configured timeout %.0fs "
"(task-level was %.0fs)",
task or "call", fb_label, fb_timeout, effective_timeout,
)
effective_timeout = fb_timeout
fb_base = str(getattr(fb_client, "base_url", "") or "")
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, base_url=fb_base)
extra_body=effective_extra_body, reasoning_config=reasoning_config,
base_url=fb_base)
try:
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
@ -3623,6 +3792,7 @@ def _call_fallback_candidate_sync(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
try:
return _validate_llm_response(
@ -3655,14 +3825,24 @@ async def _call_fallback_candidate_async(
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
reasoning_config: Optional[dict],
) -> Optional[Any]:
"""Async mirror of :func:`_call_fallback_candidate_sync`."""
fb_timeout = _fallback_entry_timeout(task, fb_label)
if fb_timeout is not None and fb_timeout != effective_timeout:
logger.info(
"Auxiliary %s: %s using its configured timeout %.0fs "
"(task-level was %.0fs)",
task or "call", fb_label, fb_timeout, effective_timeout,
)
effective_timeout = fb_timeout
fb_base = str(getattr(fb_client, "base_url", "") or "")
fb_kwargs = _build_call_kwargs(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, base_url=fb_base)
extra_body=effective_extra_body, reasoning_config=reasoning_config,
base_url=fb_base)
try:
return _validate_llm_response(
await fb_client.chat.completions.create(**fb_kwargs), task)
@ -3679,6 +3859,7 @@ async def _call_fallback_candidate_async(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
try:
return _validate_llm_response(
@ -4707,8 +4888,8 @@ def resolve_provider_client(
if custom_entry is None:
custom_entry = _get_named_custom_provider(provider)
if custom_entry:
custom_base = custom_entry.get("base_url", "").strip()
custom_key = custom_entry.get("api_key", "").strip()
custom_base = (custom_entry.get("base_url") or "").strip()
custom_key = (custom_entry.get("api_key") or "").strip()
custom_key_env = (custom_entry.get("key_env") or custom_entry.get("api_key_env") or "").strip()
if not custom_key and custom_key_env:
custom_key = os.getenv(custom_key_env, "").strip()
@ -5157,6 +5338,7 @@ def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Di
_VISION_AUTO_PROVIDER_ORDER = (
"openrouter",
"nous",
"deepinfra",
)
@ -5213,6 +5395,21 @@ def _resolve_strict_vision_backend(
return resolve_provider_client("openai-codex", model, is_vision=True)
if provider == "anthropic":
return _try_anthropic()
if provider == "deepinfra":
# DeepInfra exposes vision-capable models (Llama-4 Scout/Maverick,
# Qwen3-VL, Gemma 3, Gemini) on the same OpenAI-compatible endpoint
# as its chat models. The default is discovered live via the profile's
# default_vision_model() hook (key-gated, chat-surface + vision tag) so
# we don't pin a hardcoded id that may rot when DeepInfra retires a
# model, and this module stays provider-agnostic.
vision_model = model or _resolve_provider_vision_default("deepinfra")
if not vision_model:
logger.debug(
"Vision auto-detect: deepinfra catalog unreachable or "
"returned no vision-tagged models — skipping"
)
return None, None
return resolve_provider_client("deepinfra", vision_model, is_vision=True)
if provider == "custom":
return _try_custom_endpoint()
return None, None
@ -5298,16 +5495,29 @@ def resolve_vision_provider_client(
# _PROVIDER_VISION_MODELS provides per-provider vision model
# overrides when the provider has a dedicated multimodal model
# that differs from the chat model (e.g. xiaomi → mimo-v2-omni,
# zai → glm-5v-turbo). Nous is the exception: it has a dedicated
# strict vision backend with tier-aware defaults, so it must not
# fall through to the user's text chat model here.
# 2. OpenRouter (vision-capable aggregator fallback)
# zai → glm-5v-turbo). DeepInfra is similar but resolves its
# default vision model live from the catalog (see
# :func:`_resolve_provider_vision_default`). Nous is the
# exception: it has a dedicated strict vision backend with
# tier-aware defaults, so it must not fall through to the
# user's text chat model here.
# 2. OpenRouter (vision-capable aggregator fallback)
# 3. Nous Portal (vision-capable aggregator fallback)
# 4. Stop
# 4. DeepInfra (OpenAI-compatible; vision model discovered
# live from the catalog — tried when
# DEEPINFRA_API_KEY is set)
# 5. Stop
main_provider = _read_main_provider()
main_model = _read_main_model()
if main_provider and main_provider not in {"auto", ""}:
vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model)
# A provider-specific vision default wins over the user's chat model:
# static overrides (xiaomi/zai) and catalog-backed discovery (the
# DeepInfra profile hook) both yield a *known* vision-capable model,
# whereas the pinned chat model is usually NOT multimodal (e.g. the
# DeepSeek-V4-Flash default) and _main_model_supports_vision can't be
# trusted to catch that. Only fall back to the chat model when no
# provider default is available (catalog unreachable).
vision_model = _resolve_provider_vision_default(main_provider) or main_model
if main_provider == "nous":
sync_client, default_model = _resolve_strict_vision_backend(
main_provider, vision_model
@ -6060,12 +6270,49 @@ def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float:
def _get_task_extra_body(task: str) -> Dict[str, Any]:
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid."""
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid.
Also folds in ``auxiliary.<task>.reasoning_effort`` as an
``extra_body.reasoning`` config dict ({"enabled": ..., "effort": ...})
when set. An explicit ``extra_body.reasoning`` in config wins over the
``reasoning_effort`` shorthand (it is the more specific wire control).
Downstream, each wire already translates ``extra_body.reasoning``:
chat.completions passes it through, the Codex Responses adapter maps it
to top-level ``reasoning``/``include``, and the Anthropic auxiliary
client maps it to ``build_anthropic_kwargs(reasoning_config=...)``.
MoA tasks are excluded by design: reasoning depth for MoA is a per-slot
setting in the MoA preset (``moa.presets.<name>.reference_models[].
reasoning_effort`` / ``aggregator.reasoning_effort``), not an
auxiliary-task knob an ensemble-wide value would override the
per-slot ones.
"""
task_config = _get_auxiliary_task_config(task)
raw = task_config.get("extra_body")
if isinstance(raw, dict):
return dict(raw)
return {}
result = dict(raw) if isinstance(raw, dict) else {}
if "reasoning" not in result:
effort = task_config.get("reasoning_effort")
if effort is not None and effort != "":
if task in ("moa_reference", "moa_aggregator"):
logger.warning(
"auxiliary.%s.reasoning_effort is not supported — MoA "
"reasoning depth is per-slot: set reasoning_effort on the "
"preset's reference_models entries / aggregator instead "
"(moa.presets.<name>...). Ignoring.",
task,
)
return result
from hermes_constants import parse_reasoning_effort
parsed = parse_reasoning_effort(effort)
if parsed is not None:
result["reasoning"] = parsed
else:
logger.warning(
"auxiliary.%s.reasoning_effort %r is not a valid level "
"(none, minimal, low, medium, high, xhigh, max, ultra) — ignoring",
task, effort,
)
return result
# ---------------------------------------------------------------------------
@ -6173,6 +6420,32 @@ def _convert_openai_images_to_anthropic(messages: list) -> list:
return converted
_PROFILE_REASONING_KEYS = {
"reasoning",
"reasoning_effort",
"thinking",
"thinking_config",
"thinkingconfig",
"thinking_budget",
"thinkingbudget",
"enable_thinking",
"think",
"verbosity",
}
def _contains_profile_reasoning_fields(value: Any) -> bool:
"""Return whether a profile payload contains a reasoning wire control."""
if not isinstance(value, dict):
return False
for key, nested in value.items():
normalized = str(key).strip().lower()
if normalized in _PROFILE_REASONING_KEYS:
return True
if _contains_profile_reasoning_fields(nested):
return True
return False
def _build_call_kwargs(
provider: str,
@ -6183,6 +6456,7 @@ def _build_call_kwargs(
tools: Optional[list] = None,
timeout: float = 30.0,
extra_body: Optional[dict] = None,
reasoning_config: Optional[dict] = None,
base_url: Optional[str] = None,
) -> dict:
"""Build kwargs for .chat.completions.create() with model/provider adjustments."""
@ -6266,13 +6540,89 @@ def _build_call_kwargs(
_deduped.append(_t)
kwargs["tools"] = _deduped
# Provider-specific extra_body
# Build provider-aware reasoning kwargs through the same profile hooks used
# by the standard chat-completions transport. Some providers require
# top-level controls (Kimi/custom ``reasoning_effort``), others use nested
# body fields (Gemini ``thinking_config``), and OpenRouter/Nous use
# ``extra_body.reasoning``. Profiles are the source of truth for those wire
# shapes. Providers without a reasoning-aware profile retain the generic
# ``extra_body.reasoning`` fallback used by Codex-compatible adapters.
effective_base = base_url or (
_current_custom_base_url() if provider == "custom" else ""
)
profile_body: Dict[str, Any] = {}
profile_reasoning_extra: Dict[str, Any] = {}
profile_top_level: Dict[str, Any] = {}
profile_handles_reasoning = False
try:
from providers import get_provider_profile
from providers.base import ProviderProfile
profile = get_provider_profile(str(provider or "").strip().lower())
if profile is not None:
profile_body = profile.build_extra_body(
model=model,
base_url=effective_base,
reasoning_config=reasoning_config,
) or {}
profile_reasoning_extra, profile_top_level = (
profile.build_api_kwargs_extras(
reasoning_config=reasoning_config,
supports_reasoning=reasoning_config is not None,
model=model,
base_url=effective_base,
)
)
profile_reasoning_extra = profile_reasoning_extra or {}
profile_top_level = profile_top_level or {}
profile_handles_reasoning = (
type(profile).build_api_kwargs_extras
is not ProviderProfile.build_api_kwargs_extras
or _contains_profile_reasoning_fields(profile_body)
or _contains_profile_reasoning_fields(profile_reasoning_extra)
or _contains_profile_reasoning_fields(profile_top_level)
)
except Exception as exc:
logger.debug(
"_build_call_kwargs: provider profile projection failed for %s: %s",
provider,
exc,
)
kwargs.update(profile_top_level)
merged_extra = dict(extra_body or {})
if provider == "nous":
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
merged_extra.update(profile_body)
merged_extra.update(profile_reasoning_extra)
if (
reasoning_config
and isinstance(reasoning_config, dict)
and not profile_handles_reasoning
):
if reasoning_config.get("enabled") is False:
merged_extra["reasoning"] = {"enabled": False}
else:
effort = reasoning_config.get("effort") or "medium"
merged_extra["reasoning"] = {"enabled": True, "effort": effort}
if provider == "nous" and "tags" not in merged_extra:
merged_extra["tags"] = _nous_portal_tags()
if merged_extra:
kwargs["extra_body"] = merged_extra
# Native Anthropic Messages adapters do not consume ``extra_body``. Carry
# the normalized Hermes reasoning config through a private kwarg so the
# adapter can pass it into build_anthropic_kwargs(), where provider-aware
# thinking/output_config projection lives. Do not expose this private kwarg
# to ordinary OpenAI-compatible SDK clients, which would reject it.
if reasoning_config and isinstance(reasoning_config, dict):
provider_norm = str(provider or "").strip().lower()
effective_base = base_url or ""
if (
provider_norm == "anthropic"
or _endpoint_speaks_anthropic_messages(effective_base)
or _is_anthropic_compat_endpoint(provider_norm, effective_base)
):
kwargs["_reasoning_config"] = dict(reasoning_config)
return kwargs
@ -6382,6 +6732,7 @@ def call_llm(
tools: list = None,
timeout: float = None,
extra_body: dict = None,
reasoning_config: Optional[dict] = None,
api_mode: str = None,
stream: bool = False,
stream_options: dict = None,
@ -6405,6 +6756,8 @@ def call_llm(
tools: Tool definitions (for function calling).
timeout: Request timeout in seconds (None = read from auxiliary.{task}.timeout config).
extra_body: Additional request body fields.
reasoning_config: Optional Hermes reasoning config for direct model calls
such as MoA reference/aggregator slots.
stream: When True, return the raw SDK streaming iterator instead of a
validated complete response. The caller is responsible for consuming
chunks (and for any fallback). Used by the MoA aggregator so its
@ -6509,6 +6862,7 @@ def call_llm(
resolved_provider, final_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=_base_info or resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
@ -6761,6 +7115,7 @@ def call_llm(
tools=tools,
effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config,
)
# ── Same-provider credential-pool recovery ─────────────────────
@ -6803,6 +7158,7 @@ def call_llm(
tools=tools,
effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config,
)
except Exception as retry2_err:
# The rotated key also hit a quota/auth wall. Mark it
@ -6924,7 +7280,8 @@ def call_llm(
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config)
if fb_resp is not None:
return fb_resp
# The candidate had a stale/unrefreshable credential and was
@ -6938,7 +7295,8 @@ def call_llm(
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config)
if fb_resp is not None:
return fb_resp
# All fallback layers exhausted — emit a single user-visible
@ -7033,6 +7391,7 @@ async def async_call_llm(
tools: list = None,
timeout: float = None,
extra_body: dict = None,
reasoning_config: Optional[dict] = None,
) -> Any:
"""Centralized asynchronous LLM call.
@ -7112,6 +7471,7 @@ async def async_call_llm(
resolved_provider, final_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=_client_base or resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
@ -7308,6 +7668,7 @@ async def async_call_llm(
tools=tools,
effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config,
)
# ── Same-provider credential-pool recovery (mirrors sync) ─────
@ -7345,6 +7706,7 @@ async def async_call_llm(
tools=tools,
effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config,
)
except Exception as retry2_err:
if (_is_payment_error(retry2_err) or _is_auth_error(retry2_err)
@ -7433,7 +7795,8 @@ async def async_call_llm(
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config)
if fb_resp is not None:
return fb_resp
# Stale/unrefreshable candidate credential — quarantined; walk
@ -7449,7 +7812,8 @@ async def async_call_llm(
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config)
if fb_resp is not None:
return fb_resp
# All fallback layers exhausted — warn before re-raising. (#26882)

View file

@ -62,6 +62,11 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
"api_key": parent_runtime.get("api_key") or None,
"base_url": parent_runtime.get("base_url") or None,
"api_mode": parent_api_mode,
"credential_pool": getattr(agent, "_credential_pool", None),
"request_overrides": dict(getattr(agent, "request_overrides", {}) or {}),
"max_tokens": getattr(agent, "max_tokens", None),
"command": getattr(agent, "acp_command", None),
"args": list(getattr(agent, "acp_args", []) or []),
"routed": False,
}
try:
@ -89,10 +94,15 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
)
return {
"provider": rp.get("provider") or task_provider,
"model": task_model,
"model": rp.get("model") or task_model,
"api_key": rp.get("api_key"),
"base_url": rp.get("base_url"),
"api_mode": rp.get("api_mode"),
"credential_pool": rp.get("credential_pool"),
"request_overrides": dict(rp.get("request_overrides") or {}),
"max_tokens": rp.get("max_output_tokens"),
"command": rp.get("command"),
"args": list(rp.get("args") or []),
"routed": True,
}
except Exception as e:
@ -680,6 +690,25 @@ def _run_review_in_thread(
# Match parent's toolset config so ``tools[]`` is byte-identical
# in the request body — Anthropic's cache key includes it.
# (The runtime whitelist below still restricts dispatch.)
_fork_kwargs: Dict[str, Any] = {}
if isinstance(_rt.get("max_tokens"), int):
_fork_kwargs["max_tokens"] = _rt["max_tokens"]
if isinstance(_rt.get("command"), str) and _rt["command"]:
_fork_kwargs["acp_command"] = _rt["command"]
_fork_kwargs["acp_args"] = _rt.get("args") or []
# Match parent's reasoning config so the fork's ``thinking`` /
# ``output_config`` are byte-identical in the request body —
# Anthropic's cache key is namespaced by ``thinking`` presence.
# Same-model path only: when routed to a different aux model the
# cache is cold regardless (parity buys nothing) and the parent's
# effort vocabulary may not be valid for the routed model/provider
# (e.g. OpenRouter ``extra_body.reasoning.effort`` is forwarded
# unclamped; codex_responses passes ``max``/``ultra`` through
# unmapped except on gpt-5.6/xAI). Let the routed fork use
# provider defaults — matching the ``not _routed`` gate on
# _cached_system_prompt below.
if not _routed:
_fork_kwargs["reasoning_config"] = getattr(agent, "reasoning_config", None)
review_agent = AIAgent(
model=_rt.get("model") or agent.model,
max_iterations=16,
@ -689,11 +718,13 @@ def _run_review_in_thread(
api_mode=_rt.get("api_mode"),
base_url=_rt.get("base_url") or None,
api_key=_rt.get("api_key") or None,
credential_pool=getattr(agent, "_credential_pool", None),
credential_pool=_rt.get("credential_pool"),
request_overrides=_rt.get("request_overrides") or {},
parent_session_id=agent.session_id,
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
skip_memory=True,
**_fork_kwargs,
)
review_agent._memory_write_origin = "background_review"
review_agent._memory_write_context = "background_review"

View file

@ -528,10 +528,19 @@ def _convert_content_to_converse(content) -> List[Dict]:
mime_part = header[5:].split(";")[0]
if mime_part:
media_type = mime_part
# Decode base64 to raw bytes — boto3 re-encodes at the
# wire layer, so passing the base64 string directly
# results in double-encoding and Bedrock rejects it with
# "Failed to sanitize image". Ref: #33317.
import base64
try:
raw_bytes = base64.b64decode(data)
except Exception:
raw_bytes = data.encode("utf-8")
blocks.append({
"image": {
"format": media_type.split("/")[-1] if "/" in media_type else "jpeg",
"source": {"bytes": data},
"source": {"bytes": raw_bytes},
}
})
else:

View file

@ -28,6 +28,7 @@ from typing import Any, Dict, Optional
from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
from agent.error_classifier import FailoverReason
from agent.errors import EmptyStreamError
from agent.gemini_native_adapter import is_native_gemini_base_url
from agent.model_metadata import is_local_endpoint
from agent.message_sanitization import (
@ -164,6 +165,25 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
return None
def _provider_preferences_for_agent(agent) -> Dict[str, Any]:
"""Build the validated provider-routing object shared by request paths."""
preferences: Dict[str, Any] = {}
if agent.providers_allowed:
preferences["only"] = agent.providers_allowed
if agent.providers_ignored:
preferences["ignore"] = agent.providers_ignored
if agent.providers_order:
preferences["order"] = agent.providers_order
provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
if provider_sort:
preferences["sort"] = provider_sort
if agent.provider_require_parameters:
preferences["require_parameters"] = True
if agent.provider_data_collection:
preferences["data_collection"] = agent.provider_data_collection
return preferences
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
@ -217,6 +237,129 @@ def _check_stale_giveup(agent) -> None:
)
def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
"""Run one non-streaming LLM request for the active api_mode and return it.
Shared by the interrupt-worker path (``interruptible_api_call``) and the
inline path (``direct_api_call``) so the per-api_mode dispatch codex /
anthropic / bedrock / MoA / OpenAI-compatible lives in exactly one place.
``make_client(reason)`` builds the per-request OpenAI client for the codex
and OpenAI-compatible branches; the worker path uses it to register the
client with its stranger-thread abort machinery, the inline path uses it to
capture the client for its own ``finally`` close. The anthropic / bedrock /
MoA branches manage their own clients and never call it. All interrupt,
abort, cancellation, and close semantics stay in the callers this helper
only issues the request.
"""
if agent.api_mode == "codex_responses":
request_client = make_client("codex_stream_request")
return agent._run_codex_stream(
api_kwargs,
client=request_client,
on_first_delta=getattr(agent, "_codex_on_first_delta", None),
)
if agent.api_mode == "anthropic_messages":
return agent._anthropic_messages_create(api_kwargs)
if agent.api_mode == "bedrock_converse":
# Bedrock uses boto3 directly — no OpenAI client needed.
# normalize_converse_response produces an OpenAI-compatible
# SimpleNamespace so the rest of the agent loop can treat
# bedrock responses like chat_completions responses.
from agent.bedrock_adapter import (
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
normalize_converse_response,
)
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
api_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
try:
raw_response = client.converse(**api_kwargs)
except Exception as _bedrock_exc:
# Evict the cached client on stale-connection failures
# so the outer retry loop builds a fresh client/pool.
if is_stale_connection_error(_bedrock_exc):
invalidate_runtime_client(region)
raise
return normalize_converse_response(raw_response)
if 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.
return agent.client.chat.completions.create(**api_kwargs)
request_client = make_client("chat_completion_request")
return request_client.chat.completions.create(**api_kwargs)
def should_use_direct_api_call(agent) -> bool:
"""Whether a cron OpenAI-wire request should skip the interrupt worker.
Issue #62151 is specific to OpenRouter's chat-completions path inside the
gateway cron thread stack. Keep native/Codex/Bedrock/MoA transports on their
established workers: their cancellation and client ownership differ, and
the report provides no evidence that those paths share the pre-HTTP wedge.
"""
return (
getattr(agent, "platform", None) == "cron"
and getattr(agent, "api_mode", None) == "chat_completions"
and getattr(agent, "provider", None) != "moa"
)
def direct_api_call(agent, api_kwargs: dict):
"""Run a non-streaming LLM call inline on the conversation thread.
Used when ``should_use_direct_api_call`` is True. Skips the interrupt worker
(whose only job is interactive-interrupt responsiveness, which this context
does not have) so the nested-pool deadlock (#62151) cannot occur. Because the
request runs in-flight normally, the per-request OpenAI client's own httpx
timeout (provider ``request_timeout_seconds`` / ``HERMES_API_TIMEOUT``) bounds
a genuinely hung provider the same bound interactive calls already rely on.
"""
_check_stale_giveup(agent)
agent._touch_activity("waiting for non-streaming API response")
request_client_holder = {"client": None}
request_client_lock = threading.Lock()
def _abort_active_request(reason: str) -> None:
"""Abort the inline request from cron's watchdog/interrupt thread."""
with request_client_lock:
request_client = request_client_holder["client"]
if request_client is not None:
agent._abort_request_openai_client(request_client, reason=reason)
def _make_client(reason: str):
client = agent._create_request_openai_client(reason=reason, api_kwargs=api_kwargs)
with request_client_lock:
request_client_holder["client"] = client
agent._active_request_abort = _abort_active_request
return client
try:
response = _dispatch_nonstreaming_api_request(
agent, api_kwargs, make_client=_make_client
)
except Exception:
if getattr(agent, "_interrupt_requested", False):
raise InterruptedError("Agent interrupted during API call") from None
raise
else:
if getattr(agent, "_interrupt_requested", False):
raise InterruptedError("Agent interrupted during API call")
_reset_stale_streak(agent)
return response
finally:
if getattr(agent, "_active_request_abort", None) is _abort_active_request:
agent._active_request_abort = None
with request_client_lock:
request_client = request_client_holder["client"]
request_client_holder["client"] = None
if request_client is not None:
agent._close_request_openai_client(request_client, reason="request_complete")
def interruptible_api_call(agent, api_kwargs: dict):
"""
Run the API call in a background thread so the main conversation loop
@ -231,6 +374,12 @@ def interruptible_api_call(agent, api_kwargs: dict):
the main retry loop can try again with backoff / credential rotation /
provider fallback.
"""
# Cron and other non-interactive, nested-pool contexts must not spawn the
# interrupt worker — it wedges before the socket opens on the 2nd+ call
# (#62151). Run inline instead. See should_use_direct_api_call.
if should_use_direct_api_call(agent):
return direct_api_call(agent, api_kwargs)
result = {"response": None, "error": None}
# Cross-turn stale-call circuit breaker (#58962) — non-streaming sibling
@ -294,56 +443,19 @@ def interruptible_api_call(agent, api_kwargs: dict):
def _call():
try:
if agent.api_mode == "codex_responses":
request_client = _set_request_client(
# _set_request_client registers each per-request OpenAI client with
# the stranger-thread abort machinery above; the shared dispatch
# helper builds it via this callback so the interrupt / stale-call
# detectors can force-close the worker's connection.
result["response"] = _dispatch_nonstreaming_api_request(
agent,
api_kwargs,
make_client=lambda reason: _set_request_client(
agent._create_request_openai_client(
reason="codex_stream_request",
api_kwargs=api_kwargs,
reason=reason, api_kwargs=api_kwargs
)
)
result["response"] = agent._run_codex_stream(
api_kwargs,
client=request_client,
on_first_delta=getattr(agent, "_codex_on_first_delta", None),
)
elif agent.api_mode == "anthropic_messages":
result["response"] = agent._anthropic_messages_create(api_kwargs)
elif agent.api_mode == "bedrock_converse":
# Bedrock uses boto3 directly — no OpenAI client needed.
# normalize_converse_response produces an OpenAI-compatible
# SimpleNamespace so the rest of the agent loop can treat
# bedrock responses like chat_completions responses.
from agent.bedrock_adapter import (
_get_bedrock_runtime_client,
invalidate_runtime_client,
is_stale_connection_error,
normalize_converse_response,
)
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
api_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
try:
raw_response = client.converse(**api_kwargs)
except Exception as _bedrock_exc:
# Evict the cached client on stale-connection failures
# so the outer retry loop builds a fresh client/pool.
if is_stale_connection_error(_bedrock_exc):
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(
reason="chat_completion_request",
api_kwargs=api_kwargs,
)
)
result["response"] = request_client.chat.completions.create(**api_kwargs)
),
)
except Exception as e:
# If the request was cancelled by the main thread's interrupt
# handler, the transport error is the expected consequence of our
@ -392,6 +504,29 @@ def interruptible_api_call(agent, api_kwargs: dict):
if _codex_floor:
_stale_timeout = max(_stale_timeout, _codex_floor)
# ── Codex absolute hard ceiling (#64507) ──────────────────────────
# ``openai_codex_stale_timeout_floor`` *raises* the stale timeout (up to
# 1200s at >100k tokens) so healthy gateway-scale payloads aren't aborted.
# The scaled no-byte TTFB watchdog catches dead streams that never emit a
# first byte, but a request that emits SOME bytes and then wedges (the
# issue-64507 symptom: vision-inflated request, worker idle, no ended_at)
# is only reclaimed at the (high) stale floor. Add a flat, finite hard
# ceiling on total request time that ALWAYS applies to openai-codex
# requests regardless of the TTFB/stale interaction, so a stalled request
# is recovered (retry loop / visible failure) instead of hanging
# indefinitely. The default sits ABOVE the maximum stale floor (1200s) so
# it never clamps an intentionally-raised timeout for healthy large
# requests — it is a backstop against unbounded growth, not a tighter
# limit. Tunable via HERMES_CODEX_HARD_TIMEOUT_SECONDS (set to 0 to
# disable the ceiling entirely; that restores the pre-fix behavior).
_codex_hard_timeout = _env_float("HERMES_CODEX_HARD_TIMEOUT_SECONDS", 1500.0)
if (
_codex_watchdog_enabled
and _openai_codex_backend
and _codex_hard_timeout > 0
):
_stale_timeout = min(_stale_timeout, _codex_hard_timeout)
if _est_tokens_for_codex_watchdog > 100_000:
_codex_idle_timeout_default = 180.0
elif _est_tokens_for_codex_watchdog > 50_000:
@ -422,25 +557,28 @@ def interruptible_api_call(agent, api_kwargs: dict):
and _ttfb_disable_above > 0
and _est_tokens_for_codex_watchdog >= _ttfb_disable_above
):
_ttfb_enabled = False
logger.info(
"Disabling openai-codex no-byte TTFB watchdog for large request "
"(context=~%s tokens >= %.0f). Waiting for backend response instead. "
"Set HERMES_CODEX_TTFB_STRICT=1 to force early reconnects.",
f"{_est_tokens_for_codex_watchdog:,}",
_ttfb_disable_above,
)
else:
_ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 120.0)
if _ttfb_cap > 0 and _ttfb_timeout > _ttfb_cap:
_large_request_ttfb_timeout = _codex_idle_timeout_default
if _ttfb_timeout < _large_request_ttfb_timeout:
logger.info(
"Capping openai-codex no-byte TTFB timeout from %.0fs to %.0fs "
"(context=~%s tokens). Set HERMES_CODEX_TTFB_MAX_SECONDS to tune.",
"Scaling openai-codex no-byte TTFB watchdog from %.0fs to %.0fs "
"for large request (context=~%s tokens >= %.0f). "
"Set HERMES_CODEX_TTFB_STRICT=1 to keep the smaller cutoff.",
_ttfb_timeout,
_ttfb_cap,
_large_request_ttfb_timeout,
f"{_est_tokens_for_codex_watchdog:,}",
_ttfb_disable_above,
)
_ttfb_timeout = _ttfb_cap
_ttfb_timeout = _large_request_ttfb_timeout
_ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 120.0)
if _ttfb_cap > 0 and _ttfb_timeout > _ttfb_cap:
logger.info(
"Capping openai-codex no-byte TTFB timeout from %.0fs to %.0fs "
"(context=~%s tokens). Set HERMES_CODEX_TTFB_MAX_SECONDS to tune.",
_ttfb_timeout,
_ttfb_cap,
f"{_est_tokens_for_codex_watchdog:,}",
)
_ttfb_timeout = _ttfb_cap
_codex_idle_enabled = _codex_watchdog_enabled
_codex_idle_timeout = _env_float(
@ -466,12 +604,23 @@ def interruptible_api_call(agent, api_kwargs: dict):
t.join(timeout=0.3)
_poll_count += 1
# Touch activity every ~30s so the gateway's inactivity
# monitor knows we're alive while waiting for the response.
# Every ~30s: touch activity for the gateway inactivity monitor AND
# rewrite the live spinner/status line so CLI/TUI/Desktop users see
# what the agent is waiting on instead of an unexplained generic
# spinner (the "infinite thinking" complaint — the wait itself is
# usually a slow/overloaded provider, but the UI never said so).
if _poll_count % 100 == 0: # 100 × 0.3s = 30s
_elapsed = time.time() - _call_start
agent._touch_activity(
f"waiting for non-streaming response ({int(_elapsed)}s elapsed)"
_deadline = _stale_timeout
if (
_ttfb_enabled
and getattr(agent, "_codex_stream_last_event_ts", None) is None
):
_deadline = min(_deadline, _ttfb_timeout)
agent._emit_wait_notice(
f"⏳ waiting on {api_kwargs.get('model', 'the provider')}"
f"{int(_elapsed)}s with no response yet (provider may be slow "
f"or overloaded; auto-reconnect at {int(_deadline)}s)"
)
_elapsed = time.time() - _call_start
@ -516,6 +665,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
_close_request_client_once("codex_ttfb_kill")
except Exception:
pass
agent._emit_wait_notice(
f"⚠ no response from provider in {int(_elapsed)}s — "
f"reconnecting..."
)
agent._touch_activity(
f"codex stream killed after {int(_elapsed)}s with no first byte"
)
@ -801,21 +954,8 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
_omit_temp = False
_fixed_temp = None
# Provider preferences (OpenRouter-style)
_prefs: Dict[str, Any] = {}
if agent.providers_allowed:
_prefs["only"] = agent.providers_allowed
if agent.providers_ignored:
_prefs["ignore"] = agent.providers_ignored
if agent.providers_order:
_prefs["order"] = agent.providers_order
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
if _provider_sort:
_prefs["sort"] = _provider_sort
if agent.provider_require_parameters:
_prefs["require_parameters"] = True
if agent.provider_data_collection:
_prefs["data_collection"] = agent.provider_data_collection
# Provider preferences (aggregator profile decides whether to emit them).
_prefs = _provider_preferences_for_agent(agent)
# Anthropic-compatible max-output fallback (last resort only — applied in
# build_kwargs *after* ephemeral/user/profile max_tokens, never overriding
@ -1399,6 +1539,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_api_mode = "bedrock_converse"
old_model = agent.model
old_provider = agent.provider
# Clear the per-config context_length override so the fallback
# model's actual context window is resolved instead of inheriting
@ -1536,6 +1677,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
api_mode=agent.api_mode,
)
# Re-resolve reasoning_config for the new fallback model (Closes #21256).
# Shared chokepoint: per-model override > global reasoning_effort
# (YAML boolean False = disabled). Wrapped in try/except because a
# config load failure must not kill the swap.
try:
from hermes_cli.config import load_config
from hermes_constants import resolve_reasoning_config
agent.reasoning_config = resolve_reasoning_config(
load_config() or {}, agent.model
)
logger.info(
"Fallback %s: reasoning_config resolved: %s",
agent.model, agent.reasoning_config,
)
except Exception as _reasoning_err:
logger.debug(
"Failed to resolve reasoning_config for fallback %s; keeping current: %s",
agent.model, _reasoning_err,
)
# Keep whatever reasoning_config was active — don't break the fallback swap.
# Keep the prompt's self-identity in sync with the model actually
# answering, so "what model are you?" doesn't report the primary.
rewrite_prompt_model_identity(agent, fb_model, fb_provider)
@ -1544,6 +1707,16 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
f"🔄 Primary model failed — switching to fallback: "
f"{fb_model} via {fb_provider}"
)
# The buffered line above is dropped on successful recovery, but a
# provider/model switch is a durable state change operators must see
# even when the fallback succeeds. Record a one-shot notice that the
# success path surfaces exactly once via _emit_pending_fallback_notice
# (see run_agent.py); it is discarded on terminal failure since the
# buffered line is flushed instead. See fallback-observability fix.
agent._pending_fallback_notice = (
f"🔄 Switched to fallback model: {old_model} via {old_provider} "
f"{fb_model} via {fb_provider}"
)
logger.info(
"Fallback activated: %s%s (%s)",
old_model, fb_model, fb_provider,
@ -1679,18 +1852,28 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
if _lm_reasoning_effort is not None:
summary_kwargs["reasoning_effort"] = _lm_reasoning_effort
# Include provider routing preferences
provider_preferences = {}
if agent.providers_allowed:
provider_preferences["only"] = agent.providers_allowed
if agent.providers_ignored:
provider_preferences["ignore"] = agent.providers_ignored
if agent.providers_order:
provider_preferences["order"] = agent.providers_order
_provider_sort = _validated_openrouter_provider_sort(agent.provider_sort)
if _provider_sort:
provider_preferences["sort"] = _provider_sort
if provider_preferences and (
# Merge the profile's canonical body even when routing is unset:
# profiles may always emit required metadata such as Portal tags.
provider_preferences = _provider_preferences_for_agent(agent)
profile_extra_body = {}
try:
from providers import get_provider_profile
provider_profile = get_provider_profile(agent.provider)
if provider_profile is not None:
profile_extra_body = provider_profile.build_extra_body(
session_id=getattr(agent, "session_id", None),
provider_preferences=provider_preferences or None,
model=agent.model,
base_url=agent.base_url,
reasoning_config=agent.reasoning_config,
)
except Exception:
pass
if profile_extra_body:
summary_extra_body.update(profile_extra_body)
if provider_preferences and "provider" not in profile_extra_body and (
(agent.provider or "").strip().lower() == "openrouter"
or agent._is_openrouter_url()
):
@ -1846,6 +2029,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted before streaming API call")
# Cron and other non-interactive, nested-pool contexts deadlock on the
# spawned worker thread (#62151). They also have no stream consumer, so the
# deltas this path produces go nowhere. Delegate to the non-streaming entry
# (which runs inline via should_use_direct_api_call) exactly like the codex
# branch below — routing through the _interruptible_api_call method keeps the
# outer loop's per-request retry/refresh seam intact.
if should_use_direct_api_call(agent):
return agent._interruptible_api_call(api_kwargs)
if agent.api_mode == "codex_responses":
# Codex streams internally via _run_codex_stream. The main dispatch
# in _interruptible_api_call already calls it; we just need to
@ -2383,7 +2575,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
and not reasoning_parts
and not tool_calls_acc
):
raise RuntimeError(
raise EmptyStreamError(
"Provider returned an empty stream with no finish_reason "
"(possible upstream error or malformed SSE response)."
)
@ -2472,6 +2664,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
works unchanged.
"""
has_tool_use = False
# Zero-event guard parity with the chat_completions path: track
# whether the provider delivered ANY stream event. On an eventless
# stream the real Anthropic SDK's get_final_message() raises
# AssertionError (no message_start ⇒ no final-message snapshot);
# OpenAI-compat shims may instead fabricate a contentless Message
# with no stop_reason, or return None under ``python -O`` (assert
# stripped). Every one of those shapes is normalized below to
# EmptyStreamError so the shared _call() retry loop treats it as
# transient instead of surfacing a raw AssertionError or a
# fabricated "successful" empty turn.
saw_stream_event = False
# Reset stale-stream timer for this attempt
last_chunk_time["t"] = time.time()
@ -2499,6 +2702,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
except Exception:
pass
for event in stream:
saw_stream_event = True
# Update stale-stream timer on every event so the
# outer poll loop knows data is flowing. Without
# this, the detector kills healthy long-running
@ -2559,7 +2763,38 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# this return value is discarded anyway.
if agent._interrupt_requested:
return None
return stream.get_final_message()
# Zero-event guard (parity with the chat_completions zero-chunk
# guard above). Real SDK: an eventless stream has no
# message_start, so get_final_message() raises AssertionError
# (final-message snapshot is None) — normalize that to
# EmptyStreamError so it gets the transient retry budget
# instead of surfacing raw.
try:
_final_message = stream.get_final_message()
except AssertionError:
if not saw_stream_event:
raise EmptyStreamError(
"Provider returned an empty stream with no events "
"(possible upstream error or malformed event stream)."
) from None
raise
# Shim variants of the same failure: an OpenAI-compat adapter
# may fabricate a contentless Message with no stop_reason, or
# return None where the SDK assert would have fired (e.g.
# ``python -O``). A real completed response always carries a
# stop_reason, so this cannot fire on legitimate turns.
if not saw_stream_event and (
_final_message is None
or (
not getattr(_final_message, "content", None)
and getattr(_final_message, "stop_reason", None) is None
)
):
raise EmptyStreamError(
"Provider returned an empty stream with no stop_reason "
"(possible upstream error or malformed event stream)."
)
return _final_message
def _call():
import httpx as _httpx
@ -2606,6 +2841,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
e, (_httpx.ConnectError, _httpx.RemoteProtocolError, ConnectionError)
)
_is_stream_parse_err = agent._is_provider_stream_parse_error(e)
_is_empty_stream = isinstance(e, EmptyStreamError)
# If the stream died AFTER some tokens were delivered:
# normally we don't retry (the user already saw text,
@ -2748,7 +2984,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
for phrase in _SSE_CONN_PHRASES
)
if _is_timeout or _is_conn_err or _is_sse_conn_err or _is_stream_parse_err:
if (
_is_timeout
or _is_conn_err
or _is_sse_conn_err
or _is_stream_parse_err
or _is_empty_stream
):
# Transient network / timeout error. Retry the
# streaming request with a fresh connection first.
if _stream_attempt < _max_stream_retries:
@ -2791,17 +3033,32 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
mid_tool_call=False,
diag=request_client_holder.get("diag"),
)
agent._buffer_status(
"❌ Provider returned malformed streaming data after "
f"{_max_stream_retries + 1} attempts. "
"The provider may be experiencing issues — "
"try again in a moment."
if _is_stream_parse_err else
"❌ Connection to provider failed after "
f"{_max_stream_retries + 1} attempts. "
"The provider may be experiencing issues — "
"try again in a moment."
)
if _is_stream_parse_err:
_exhausted_msg = (
"❌ Provider returned malformed streaming data after "
f"{_max_stream_retries + 1} attempts. "
"The provider may be experiencing issues — "
"try again in a moment."
)
elif _is_empty_stream:
# The connection SUCCEEDED (stream opened) but the
# provider sent no chunks — saying "connection
# failed" here sends users chasing network issues
# when the problem is the provider/endpoint.
_exhausted_msg = (
"❌ Provider returned an empty response stream "
f"after {_max_stream_retries + 1} attempts. "
"The provider may be experiencing issues — "
"try again in a moment."
)
else:
_exhausted_msg = (
"❌ Connection to provider failed after "
f"{_max_stream_retries + 1} attempts. "
"The provider may be experiencing issues — "
"try again in a moment."
)
agent._buffer_status(_exhausted_msg)
else:
_err_lower = str(e).lower()
_is_stream_unsupported = (
@ -2918,9 +3175,29 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if _hb_now - _last_heartbeat >= _HEARTBEAT_INTERVAL:
_last_heartbeat = _hb_now
_waiting_secs = int(_hb_now - last_chunk_time["t"])
agent._touch_activity(
f"waiting for stream response ({_waiting_secs}s, no chunks yet)"
)
if _waiting_secs >= _HEARTBEAT_INTERVAL:
# No chunks for 30s+ — rewrite the live spinner/status line
# so CLI/TUI/Desktop users see WHAT the wait is (slow or
# overloaded provider / long thinking pause) instead of an
# unexplained generic spinner, and WHEN recovery kicks in.
if (
_stream_stale_timeout is not None
and _stream_stale_timeout != float("inf")
):
_recovery = f"; auto-reconnect at {int(_stream_stale_timeout)}s"
else:
_recovery = ""
agent._emit_wait_notice(
f"⏳ waiting on {api_kwargs.get('model', 'the provider')}"
f"{_waiting_secs}s with no output yet (provider may be "
f"slow or overloaded, or the model is thinking{_recovery})"
)
else:
# Chunks are flowing — keep the activity tracker fresh but
# leave the live display alone.
agent._touch_activity(
f"waiting for stream response ({_waiting_secs}s, no chunks yet)"
)
# Detect stale streams: connections kept alive by SSE pings
# but delivering no real chunks. Kill the client so the
@ -2963,6 +3240,10 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# Reset the timer so we don't kill repeatedly while
# the inner thread processes the closure.
last_chunk_time["t"] = time.time()
agent._emit_wait_notice(
f"⚠ no output from provider for {int(_stale_elapsed)}s — "
f"reconnecting..."
)
agent._touch_activity(
f"stale stream detected after {int(_stale_elapsed)}s, reconnecting"
)

View file

@ -288,6 +288,13 @@ _RESPONSES_BUILTIN_TOOL_TYPES = {
_RESPONSE_MESSAGE_STATUSES = {"completed", "incomplete", "in_progress"}
# The Responses API rejects input[].id longer than this with a non-retryable
# HTTP 400 ("string too long"). Codex-issued assistant message ids are
# server-assigned base64 blobs that can run 400+ chars, while Hermes-minted
# ids (msg_...) stay well under this cap and are worth keeping for
# prefix-cache hits. Drop only the oversized ones on replay.
_MAX_RESPONSES_ITEM_ID_LENGTH = 64
def _normalize_responses_message_status(value: Any, *, default: str = "completed") -> str:
"""Normalize a Responses assistant message status for replay.
@ -307,6 +314,7 @@ def _chat_messages_to_responses_input(
messages: List[Dict[str, Any]],
*,
is_xai_responses: bool = False,
is_github_responses: bool = False,
replay_encrypted_reasoning: bool = True,
current_issuer_kind: Optional[str] = None,
) -> List[Dict[str, Any]]:
@ -331,6 +339,16 @@ def _chat_messages_to_responses_input(
items from the conversation history and threads ``replay_enabled=False``
through this converter so subsequent turns send no reasoning items.
``is_github_responses`` drops the ``id`` field from replayed
``codex_message_items`` regardless of length. The Copilot backend
(api.githubcopilot.com/responses) binds these ids to a specific
backend "connection" credential-pool rotation, a gateway restart,
or routine load-balancer churn between turns all invalidate it and
rejects a stale id with HTTP 401 "input item ID does not belong to
this connection" even for short ids (see #32716). ``phase``/
``status``/``content`` are still replayed; only ``id`` is unsafe to
reuse across a Copilot connection.
``current_issuer_kind`` enables a per-item cross-issuer guard. The
Responses API's ``encrypted_content`` blob is decryptable only by the
endpoint that minted it replaying a Codex-issued blob against xAI
@ -463,8 +481,14 @@ def _chat_messages_to_responses_input(
"content": normalized_content_parts,
}
item_id = raw_item.get("id")
if isinstance(item_id, str) and item_id.strip():
replay_item["id"] = item_id.strip()
if (
not is_github_responses
and isinstance(item_id, str)
and item_id.strip()
):
stripped_id = item_id.strip()
if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
replay_item["id"] = stripped_id
phase = raw_item.get("phase")
if isinstance(phase, str) and phase.strip():
replay_item["phase"] = phase.strip()
@ -576,7 +600,11 @@ def _chat_messages_to_responses_input(
# Input preflight / validation
# ---------------------------------------------------------------------------
def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]:
def _preflight_codex_input_items(
raw_items: Any,
*,
is_github_responses: bool = False,
) -> List[Dict[str, Any]]:
if not isinstance(raw_items, list):
raise ValueError("Codex Responses input must be a list of input items.")
@ -717,8 +745,14 @@ def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]:
"content": normalized_content,
}
item_id = item.get("id")
if isinstance(item_id, str) and item_id.strip():
normalized_item["id"] = item_id.strip()
if (
not is_github_responses
and isinstance(item_id, str)
and item_id.strip()
):
stripped_id = item_id.strip()
if len(stripped_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
normalized_item["id"] = stripped_id
phase = item.get("phase")
if isinstance(phase, str) and phase.strip():
normalized_item["phase"] = phase.strip()
@ -790,6 +824,7 @@ def _preflight_codex_api_kwargs(
api_kwargs: Any,
*,
allow_stream: bool = False,
is_github_responses: bool = False,
) -> Dict[str, Any]:
if not isinstance(api_kwargs, dict):
raise ValueError("Codex Responses request must be a dict.")
@ -811,7 +846,10 @@ def _preflight_codex_api_kwargs(
instructions = str(instructions)
instructions = instructions.strip() or DEFAULT_AGENT_IDENTITY
normalized_input = _preflight_codex_input_items(api_kwargs.get("input"))
normalized_input = _preflight_codex_input_items(
api_kwargs.get("input"),
is_github_responses=is_github_responses,
)
tools = api_kwargs.get("tools")
normalized_tools = None
@ -1080,6 +1118,22 @@ def _normalize_codex_response(
differs from the one that minted the encrypted_content blob and drop
the item instead of triggering HTTP 400 invalid_encrypted_content.
"""
response_status = getattr(response, "status", None)
if isinstance(response_status, str):
response_status = response_status.strip().lower()
else:
response_status = None
incomplete_details = getattr(response, "incomplete_details", None)
incomplete_reason = ""
if isinstance(incomplete_details, dict):
incomplete_reason = str(incomplete_details.get("reason") or "").strip().lower()
elif incomplete_details is not None:
incomplete_reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower()
response_incomplete_content_filter = (
response_status == "incomplete" and incomplete_reason == "content_filter"
)
output = getattr(response, "output", None)
if not isinstance(output, list) or not output:
# The Codex backend can return empty output when the answer was
@ -1096,15 +1150,18 @@ def _normalize_codex_response(
content=[SimpleNamespace(type="output_text", text=out_text.strip())],
)]
response.output = output
elif response_incomplete_content_filter:
# This is a deterministic provider safety block, not a partial
# answer. Synthesize an empty message so finish_reason below becomes
# content_filter and the conversation loop can fallback/surface it
# instead of burning three continuation attempts.
output = [SimpleNamespace(
type="message", role="assistant", status="completed", content=[]
)]
response.output = output
else:
raise RuntimeError("Responses API returned no output items")
response_status = getattr(response, "status", None)
if isinstance(response_status, str):
response_status = response_status.strip().lower()
else:
response_status = None
if response_status in {"failed", "cancelled"}:
error_obj = getattr(response, "error", None)
error_msg = _format_responses_error(error_obj, response_status)
@ -1322,6 +1379,45 @@ def _normalize_codex_response(
# so the model keeps its chain-of-thought on the retry.
final_text = ""
# ── Reasoning-channel answer salvage (xAI grok) ──────────────
# grok-4.x on the xAI /v1/responses surface sometimes emits its final
# answer inside the reasoning item instead of as a ``message`` output
# item, marking where the answer starts with grok's internal
# ``<response>`` delimiter. Without salvage, the reasoning-only rule
# below classifies the turn ``incomplete`` — and because reasoning
# items on this surface carry no ``encrypted_content``, the interim
# message replays as nothing, so every continuation request is
# byte-identical to the one that just failed. The turn burns its 3
# retries and dies with "Codex response remained incomplete after 3
# continuation attempts" even though the answer was produced on the
# first attempt. Observed live with grok-4.20 on xai-oauth
# (2026-07-13). Promote the delimited tail to assistant content and
# keep the untagged prefix as thinking text.
if (
issuer_kind == "xai_responses"
and not final_text
and not tool_calls
and reasoning_parts
):
joined_reasoning = "\n\n".join(reasoning_parts)
marker = joined_reasoning.rfind("<response>")
if marker != -1:
salvaged = joined_reasoning[marker + len("<response>"):]
closing = salvaged.find("</response>")
if closing != -1:
salvaged = salvaged[:closing]
salvaged = salvaged.strip()
if salvaged:
logger.warning(
"xAI response delivered its final answer inside the "
"reasoning channel (<response> delimiter); promoting "
"%d chars to assistant content.",
len(salvaged),
)
final_text = salvaged
reasoning_prefix = joined_reasoning[:marker].strip()
reasoning_parts = [reasoning_prefix] if reasoning_prefix else []
assistant_message = SimpleNamespace(
content=final_text,
tool_calls=tool_calls,
@ -1334,6 +1430,8 @@ def _normalize_codex_response(
if tool_calls:
finish_reason = "tool_calls"
elif response_incomplete_content_filter:
finish_reason = "content_filter"
elif leaked_tool_call_text:
finish_reason = "incomplete"
elif saw_streaming_or_item_incomplete:
@ -1342,12 +1440,28 @@ def _normalize_codex_response(
finish_reason = "incomplete"
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
# Response contains only reasoning (encrypted thinking state and/or
# human-readable summary) with no visible content or tool calls. The
# model is still thinking and needs another turn to produce the actual
# answer. Marking this as "stop" would send it into the empty-content
# retry loop which burns retries then fails — treat it as incomplete so
# the Codex continuation path handles it correctly.
finish_reason = "incomplete"
# human-readable summary) with no visible content or tool calls.
#
# For the specially-handled backends (Codex, xAI, GitHub/Copilot),
# reasoning-only with status="completed" means "the model is still
# thinking and needs another turn" — treat it as incomplete so the
# Codex continuation path retries instead of falling into the
# empty-content retry loop.
#
# For all other backends (other:<base_url>, etc.), trust the provider's
# own response.status signal. When status == "completed" and no items
# are queued/in_progress/incomplete, reasoning alone is a valid final
# state — forcing "incomplete" causes multi-minute stalls as the
# continuation path re-issues calls (3 retries × up to 240s each).
# See https://github.com/NousResearch/hermes-agent/issues/64434
if response_status == "completed" and issuer_kind not in (
"codex_backend",
"xai_responses",
"github_responses",
):
finish_reason = "stop"
else:
finish_reason = "incomplete"
else:
finish_reason = "stop"
return assistant_message, finish_reason

View file

@ -113,6 +113,15 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
usage = getattr(turn, "token_usage_last", None)
if not isinstance(usage, dict) or not usage:
compressor = getattr(agent, "context_compressor", None)
if (
compressor is not None
and getattr(compressor, "awaiting_real_usage_after_compression", False)
):
# No usage means this turn cannot adjudicate the pending compaction.
# Consume the marker so a later unrelated reading is not charged to
# it and preflight deferral cannot stay latched indefinitely.
compressor.update_from_response({})
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
@ -120,6 +129,9 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
agent._session_db.update_token_counts(
agent.session_id,
model=agent.model,
billing_provider=agent.provider,
billing_base_url=agent.base_url,
billing_mode="subscription_included",
api_call_count=1,
)
except Exception as exc:
@ -267,6 +279,18 @@ def _record_codex_app_server_compaction(
compressor, "compression_count", 0
) + 1
compressor.last_compression_rough_tokens = approx_tokens or 0
# The app server has already completed a real compaction boundary. Its
# usage update (when supplied) is therefore the same real-vs-real
# effectiveness verdict used by the normal compression path.
record_boundary = getattr(
type(compressor), "record_completed_compaction", None
)
if callable(record_boundary):
# Codex owns this summary. A prior Hermes deterministic-fallback
# flag must not leak into the native boundary's quality verdict.
record_boundary(compressor, used_fallback=False)
elif hasattr(compressor, "_verify_compaction_cleared_threshold"):
compressor._verify_compaction_cleared_threshold = True
if not getattr(turn, "token_usage_last", None):
compressor.last_prompt_tokens = -1
compressor.last_completion_tokens = 0

View file

@ -56,6 +56,7 @@ import logging
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
@ -412,10 +413,18 @@ def _marker_root(cwd: Path) -> Optional[Path]:
"""
current = cwd.resolve()
home = _home()
# Shared world-writable temp roots are never project roots: a stray
# manifest in /tmp (left by any process) must not flip every session
# whose cwd lives under the temp dir into the coding posture. Same
# reasoning as the $HOME skip below.
try:
temp_root = Path(tempfile.gettempdir()).resolve()
except Exception:
temp_root = None
for depth, parent in enumerate([current, *current.parents]):
if depth > 6:
break
if parent == home:
if parent == home or (temp_root is not None and parent == temp_root):
continue
for marker in _PROJECT_MARKERS:
if (parent / marker).exists():

View file

@ -193,8 +193,10 @@ _HISTORICAL_SUMMARY_PREFIXES = (
_MIN_SUMMARY_TOKENS = 2000
# Proportion of compressed content to allocate for summary
_SUMMARY_RATIO = 0.20
# Absolute ceiling for summary tokens (even on very large context windows)
_SUMMARY_TOKENS_CEILING = 12_000
# Absolute ceiling for summary tokens (even on very large context windows).
# Summaries must stay within a 1K-10K token envelope — anything larger is
# itself a context-pressure source and slows every compaction.
_SUMMARY_TOKENS_CEILING = 10_000
# Placeholder used when pruning old tool results
_PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]"
@ -227,6 +229,16 @@ _AUTO_FOCUS_MAX_CHARS = 700
# back the old large-tool-output case where nothing can be compacted.
_MAX_TAIL_MESSAGE_FLOOR = 8
# Models with context windows below this get their compression threshold
# floored at ``_SMALL_CTX_THRESHOLD_PERCENT`` (raise-only — an explicitly
# higher user/model threshold always wins). At the default 50% trigger a
# 128K-262K model compacts with only ~64-131K consumed; the incompressible
# floor (system prompt + tool schemas + protected tail + rolling summary)
# eats most of the reclaimed headroom, so compaction re-fires every 1-2
# turns and the session spends most of its wall-clock summarizing.
_SMALL_CTX_WINDOW_LIMIT = 512_000
_SMALL_CTX_THRESHOLD_PERCENT = 0.75
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
@ -574,6 +586,42 @@ def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, An
return result if changed else messages
def _image_part_label(part: Dict[str, Any]) -> str:
"""Render a multimodal image part as a short text label for the summarizer.
Keeps a real, referenceable URL when the image lives at an http(s)
address the summary can then preserve the handle so the agent (or a
later vision_analyze call) can still reach the image after compaction.
Base64 ``data:`` URLs carry no reusable reference and would flood the
summarizer input, so they collapse to ``[image]``.
"""
url = ""
if isinstance(part.get("image_url"), dict):
url = str(part["image_url"].get("url") or "")
elif isinstance(part.get("image_url"), str):
url = part["image_url"]
elif isinstance(part.get("url"), str):
url = part["url"]
if url.startswith(("http://", "https://")):
return f"[image: {url}]"
return "[image]"
def _str_arg(args: dict, key: str, default: str = "") -> str:
"""Safely get a string argument from parsed tool args.
LLMs sometimes return non-string parameter values (e.g. bool, int) for
tool calls. Calling ``len()`` / ``.count()`` / slicing on those causes
``TypeError`` / ``AttributeError`` which crashes context compression.
This helper coerces any value to ``str`` so downstream code can assume
a string is always returned.
"""
val = args.get(key, default)
if isinstance(val, str):
return val
return str(val) if val is not None else default
def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:
"""Create an informative 1-line summary of a tool call + result.
@ -586,18 +634,37 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->
[terminal] ran `npm test` -> exit 0, 47 lines output
[read_file] read config.py from line 1 (1,200 chars)
[search_files] content search for 'compress' in agent/ -> 12 matches
Never raises: models sometimes emit non-string argument values (bool,
int, None) and the args here come from persisted session history, so a
single malformed historical call must not crash compression which
retries on the same history and would crash-loop. Individual branches
coerce the values they slice/measure (keeping summaries informative);
this wrapper is the backstop for anything they miss.
"""
try:
return _summarize_tool_result_unguarded(tool_name, tool_args, tool_content)
except Exception as exc: # noqa: BLE001 — a summary must never crash compression
logger.debug("Tool-result summary failed for %s: %s", tool_name, exc)
_len = len(tool_content) if isinstance(tool_content, str) else 0
return f"[{tool_name}] ({_len:,} chars result)"
def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_content: str) -> str:
"""Build the summary line (unguarded; see ``_summarize_tool_result``)."""
try:
args = json.loads(tool_args) if tool_args else {}
except (json.JSONDecodeError, TypeError):
args = {}
if not isinstance(args, dict):
args = {}
content = tool_content or ""
content_len = len(content)
line_count = content.count("\n") + 1 if content.strip() else 0
if tool_name == "terminal":
cmd = args.get("command", "")
cmd = _str_arg(args, "command")
if len(cmd) > 80:
cmd = cmd[:77] + "..."
exit_match = re.search(r'"exit_code"\s*:\s*(-?\d+)', content)
@ -611,7 +678,7 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->
if tool_name == "write_file":
path = args.get("path", "?")
written_lines = args.get("content", "").count("\n") + 1 if args.get("content") else "?"
written_lines = _str_arg(args, "content").count("\n") + 1 if args.get("content") else "?"
return f"[write_file] wrote to {path} ({written_lines} lines)"
if tool_name == "search_files":
@ -640,20 +707,30 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->
if tool_name == "web_extract":
urls = args.get("urls", [])
url_desc = urls[0] if isinstance(urls, list) and urls else "?"
first = urls[0] if isinstance(urls, list) and urls else "?"
# web_search results are dicts ({"url"/"href": ...}) and models often
# forward them straight into web_extract. Unwrap to the URL string so
# the summary stays readable and the ``+=`` below never hits the
# ``dict + str`` TypeError that would abort pre-compression pruning.
if isinstance(first, dict):
first = first.get("url") or first.get("href") or "?"
elif not isinstance(first, str):
first = "?"
url_desc = first
if isinstance(urls, list) and len(urls) > 1:
url_desc += f" (+{len(urls) - 1} more)"
return f"[web_extract] {url_desc} ({content_len:,} chars)"
if tool_name == "delegate_task":
goal = args.get("goal", "")
goal = _str_arg(args, "goal")
if len(goal) > 60:
goal = goal[:57] + "..."
return f"[delegate_task] '{goal}' ({content_len:,} chars result)"
if tool_name == "execute_code":
code_preview = (args.get("code") or "")[:60].replace("\n", " ")
if len(args.get("code", "")) > 60:
code_str = _str_arg(args, "code")
code_preview = code_str[:60].replace("\n", " ")
if len(code_str) > 60:
code_preview += "..."
return f"[execute_code] `{code_preview}` ({line_count} lines output)"
@ -662,7 +739,7 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) ->
return f"[{tool_name}] name={name} ({content_len:,} chars)"
if tool_name == "vision_analyze":
question = args.get("question", "")[:50]
question = _str_arg(args, "question")[:50]
return f"[vision_analyze] '{question}' ({content_len:,} chars)"
if tool_name == "memory":
@ -718,12 +795,16 @@ class ContextCompressor(ContextEngine):
self._context_probe_persistable = False
self._previous_summary = None
self._last_summary_error = None
self._consecutive_timeout_failures = 0
self._last_summary_dropped_count = 0
self._last_summary_fallback_used = False
self._last_aux_model_failure_error = None
self._last_aux_model_failure_model = None
self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0
self._fallback_compression_streak = 0
self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
self._last_summary_error = None
self._last_compress_aborted = False
@ -753,12 +834,16 @@ class ContextCompressor(ContextEngine):
"""
self._previous_summary = None
self._last_summary_error = None
self._consecutive_timeout_failures = 0
self._last_summary_dropped_count = 0
self._last_summary_fallback_used = False
self._last_aux_model_failure_error = None
self._last_aux_model_failure_model = None
self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0
self._fallback_compression_streak = 0
self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False
self._summary_failure_cooldown_until = 0.0
self._last_compress_aborted = False
self._context_probed = False
@ -774,12 +859,85 @@ class ContextCompressor(ContextEngine):
self._session_id = session_id or ""
self._summary_failure_cooldown_until = 0.0
self._last_summary_error = None
self._consecutive_timeout_failures = 0
self._fallback_compression_streak = 0
self.get_active_compression_failure_cooldown()
self._load_fallback_compression_streak()
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)
boundary_reason = kwargs.get("boundary_reason")
old_session_id = kwargs.get("old_session_id")
session_db = kwargs.get("session_db", getattr(self, "_session_db", None))
previous_fallback_streak = self._fallback_compression_streak
if boundary_reason == "compression" and old_session_id:
getter = getattr(session_db, "get_compression_fallback_streak", None)
if callable(getter):
try:
stored_streak = getter(old_session_id)
if isinstance(stored_streak, (int, float, str)):
previous_fallback_streak = max(0, int(stored_streak))
except (TypeError, ValueError, sqlite3.Error) as exc:
logger.debug("compression parent fallback streak lookup failed: %s", exc)
except Exception as exc:
logger.debug(
"compression parent fallback streak lookup failed (non-sqlite): %s",
exc,
)
self.bind_session_state(session_db, session_id)
if boundary_reason == "compression":
# Rotation creates a fresh child row before this callback. Preserve
# the logical conversation's streak until boundary bookkeeping
# persists the updated value onto the child row.
self._fallback_compression_streak = previous_fallback_streak
def _load_fallback_compression_streak(self) -> None:
session_db = getattr(self, "_session_db", None)
session_id = getattr(self, "_session_id", "")
getter = getattr(session_db, "get_compression_fallback_streak", None)
if not session_id or not callable(getter):
return
try:
stored_streak = getter(session_id)
self._fallback_compression_streak = max(
0,
int(stored_streak)
if isinstance(stored_streak, (int, float, str))
else 0,
)
except (TypeError, ValueError, sqlite3.Error) as exc:
logger.debug("compression fallback streak lookup failed: %s", exc)
except Exception as exc:
logger.debug("compression fallback streak lookup failed (non-sqlite): %s", exc)
def _persist_fallback_compression_streak(self) -> None:
session_db = getattr(self, "_session_db", None)
session_id = getattr(self, "_session_id", "")
setter = getattr(session_db, "set_compression_fallback_streak", None)
if not session_id or not callable(setter):
return
try:
setter(session_id, self._fallback_compression_streak)
except sqlite3.Error as exc:
logger.debug("compression fallback streak persist failed: %s", exc)
except Exception as exc:
logger.debug("compression fallback streak persist failed (non-sqlite): %s", exc)
def record_completed_compaction(self, *, used_fallback: bool = False) -> None:
"""Record one completed boundary and its summary quality."""
self._verify_compaction_cleared_threshold = True
if used_fallback:
self._fallback_compression_streak += 1
if not self.quiet_mode:
logger.warning(
"Compaction completed with a deterministic fallback summary. "
"fallback_compression_streak=%d",
self._fallback_compression_streak,
)
elif self._fallback_compression_streak:
self._fallback_compression_streak = 0
self._persist_fallback_compression_streak()
def get_active_compression_failure_cooldown(self) -> Optional[Dict[str, Any]]:
"""Return the live compression-failure cooldown for the bound session."""
@ -850,6 +1008,7 @@ class ContextCompressor(ContextEngine):
def _clear_compression_failure_cooldown(self) -> None:
self._summary_failure_cooldown_until = 0.0
self._last_summary_error = None
self._consecutive_timeout_failures = 0
session_db = getattr(self, "_session_db", None)
session_id = getattr(self, "_session_id", "")
@ -877,12 +1036,30 @@ class ContextCompressor(ContextEngine):
max_tokens: int | None = None,
) -> None:
"""Update model info after a model switch or fallback activation."""
runtime_changed = any((
model != self.model,
provider != self.provider,
base_url != self.base_url,
api_mode != self.api_mode,
))
self.model = model
self.base_url = base_url
self.api_key = api_key
self.provider = provider
self.api_mode = api_mode
self.context_length = context_length
# Re-apply the small-context threshold floor for the NEW window,
# starting from the originally-configured percent (not the possibly
# floored live value) so a small -> large switch drops back to the
# configured threshold and a large -> small switch gains the floor.
# Guard with getattr: compressors unpickled/constructed before this
# attribute existed fall back to the live value.
_configured_pct = getattr(
self, "_configured_threshold_percent", self.threshold_percent,
)
self.threshold_percent = self._effective_threshold_percent(
context_length, _configured_pct,
)
# max_tokens=None here means "caller didn't specify" → keep the existing
# output reservation. A switch that genuinely changes the output budget
# passes the new value explicitly. (#43547)
@ -920,6 +1097,11 @@ class ContextCompressor(ContextEngine):
self.last_compression_rough_tokens = 0
self.awaiting_real_usage_after_compression = False
self._ineffective_compression_count = 0
if runtime_changed:
self._fallback_compression_streak = 0
self._persist_fallback_compression_streak()
self._verify_compaction_cleared_threshold = False
self._last_compression_made_progress = False
# When the MINIMUM_CONTEXT_LENGTH floor meets/exceeds a small context
# window, compacting at the percentage (50% → 32K of a 64K window) wastes
@ -945,6 +1127,23 @@ class ContextCompressor(ContextEngine):
return None
return ivalue if ivalue > 0 else None
@staticmethod
def _effective_threshold_percent(
context_length: int, threshold_percent: float,
) -> float:
"""Apply the small-context threshold floor (raise-only).
Models under ``_SMALL_CTX_WINDOW_LIMIT`` (512K) trigger at no less
than ``_SMALL_CTX_THRESHOLD_PERCENT`` (75%) of the window. An
explicitly higher threshold (user config or per-model autoraise,
e.g. Codex gpt-5.5's 85%) always wins; only lower values are raised.
Large-context models keep the configured value at 512K+ the default
50% trigger already leaves ample post-compaction headroom.
"""
if context_length and context_length < _SMALL_CTX_WINDOW_LIMIT:
return max(threshold_percent, _SMALL_CTX_THRESHOLD_PERCENT)
return threshold_percent
@staticmethod
def _compute_threshold_tokens(
context_length: int, threshold_percent: float, max_tokens: int | None = None,
@ -1032,6 +1231,18 @@ class ContextCompressor(ContextEngine):
config_context_length=config_context_length,
provider=provider,
)
# Small-context threshold floor: models under 512K trigger at >=75%
# so compaction doesn't fire with half the window still free (the
# incompressible floor makes 50%-triggered compaction thrash on
# 128K-262K models). Raise-only; must run AFTER context_length is
# resolved and BEFORE threshold_tokens is derived. The pre-floor
# value is kept so update_model() can re-derive for a new window
# (switching small -> large must drop back to the configured value).
self._configured_threshold_percent = self.threshold_percent
self.threshold_percent = self._effective_threshold_percent(
self.context_length, self.threshold_percent,
)
threshold_percent = self.threshold_percent
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if
# the percentage would suggest a lower value. This prevents premature
# compression on large-context models at 50% while keeping the % sane
@ -1078,6 +1289,16 @@ class ContextCompressor(ContextEngine):
# Anti-thrashing: track whether last compression was effective
self._last_compression_savings_pct: float = 100.0
self._ineffective_compression_count: int = 0
# Consecutive completed deterministic-fallback boundaries. Unlike the
# real-usage effectiveness counter, ordinary fitting responses must not
# reset this breaker; only a healthy completed summary does.
self._fallback_compression_streak: int = 0
# Set after a completed compression boundary; consumed by the next
# provider-reported prompt count in update_from_response().
self._verify_compaction_cleared_threshold: bool = False
# Lets the boundary wrapper distinguish a completed rewrite from a
# no-op/abort without inferring progress from message-list length.
self._last_compression_made_progress: bool = False
self._summary_failure_cooldown_until: float = 0.0
self._last_summary_error: Optional[str] = None
# When summary generation fails and a static fallback is inserted,
@ -1124,8 +1345,50 @@ class ContextCompressor(ContextEngine):
if self.last_prompt_tokens < self.threshold_tokens:
if self.awaiting_real_usage_after_compression and self.last_compression_rough_tokens > 0:
self.last_rough_tokens_when_real_prompt_fit = self.last_compression_rough_tokens
# Any real provider reading below the trigger proves the prompt
# fits again. Clear the real-usage effectiveness latch even
# when this response was not immediately after compaction. The
# independent fallback streak is boundary-scoped and survives
# ordinary fitting responses during context regrowth.
self._ineffective_compression_count = 0
else:
self.last_rough_tokens_when_real_prompt_fit = 0
# Anti-thrashing verdict, judged HERE because this is the only place
# that sees the provider's real prompt count for the just-compacted
# conversation. Effectiveness is "did the prompt get under the
# threshold?", not "did the message list shrink?": compaction can
# only shrink messages, while the system prompt and tool schemas are
# an incompressible floor (with 50+ tools, 20-30K tokens — see
# #14695). When that floor alone meets the threshold, every pass
# shrinks messages by a healthy margin yet leaves the prompt over the
# line, so the next turn compacts again, forever.
#
# It must NOT live in should_compress(): that runs twice per turn
# with two different measures (a rough preflight estimate and the
# real post-response count, #36718), and the rough one can dip below
# the threshold and reset the strike every turn, re-opening the loop.
# Keying on real usage compares like with like and fires exactly once
# per compaction.
if self._verify_compaction_cleared_threshold:
if self.last_prompt_tokens >= self.threshold_tokens:
self._ineffective_compression_count += 1
if not self.quiet_mode:
logger.warning(
"Compaction did not clear the threshold: %d real "
"tokens still >= %d. The incompressible prompt "
"(system prompt + tool schemas) may already exceed "
"it, in which case shrinking messages cannot help. "
"ineffective_compression_count=%d",
self.last_prompt_tokens, self.threshold_tokens,
self._ineffective_compression_count,
)
else:
self._ineffective_compression_count = 0
# Consume the pending-verification flag once real usage arrives, whether
# or not prompt_tokens was reported, so a usage-less response can't leave
# it armed for a later, unrelated reading.
self._verify_compaction_cleared_threshold = False
self.awaiting_real_usage_after_compression = False
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
@ -1180,6 +1443,10 @@ class ContextCompressor(ContextEngine):
tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens
if tokens < self.threshold_tokens:
return False
return not self._automatic_compression_blocked()
def _automatic_compression_blocked(self) -> bool:
"""Return whether automatic compaction is in cooldown or tripped."""
# Do not trigger compression while the summary LLM is in cooldown.
# On a 429/transient failure _generate_summary() sets a cooldown and
# returns None; compress() then inserts a static fallback marker and
@ -1196,18 +1463,23 @@ class ContextCompressor(ContextEngine):
"Compression deferred — summary LLM in cooldown for %.0fs more",
_cooldown_remaining,
)
return False
return True
# Anti-thrashing: back off if recent compressions were ineffective
if self._ineffective_compression_count >= 2:
if (
self._ineffective_compression_count >= 2
or self._fallback_compression_streak >= 2
):
if not self.quiet_mode:
logger.warning(
"Compression skipped — last %d compressions saved <10%% each. "
"Consider /new to start a fresh session, or /compress <topic> "
"for focused compression.",
"Compression skipped — repeated compaction attempts did not "
"restore healthy context. ineffective=%d fallback=%d. "
"Consider /new to start fresh, or /compress <topic> for "
"focused compression.",
self._ineffective_compression_count,
self._fallback_compression_streak,
)
return False
return True
return True
return False
# ------------------------------------------------------------------
# Tool output pruning (cheap pre-pass, no LLM call)
@ -1410,11 +1682,43 @@ class ContextCompressor(ContextEngine):
(API keys, tokens, passwords) from leaking into the summary that
gets sent to the auxiliary model and persisted across compactions.
"""
# Lazy import (matches title_generator.py) — agent_runtime_helpers
# pulls in heavy transitive imports we don't want at module load.
from agent.agent_runtime_helpers import strip_think_blocks
parts = []
for msg in turns:
role = msg.get("role", "unknown")
content = redact_sensitive_text(msg.get("content") or "")
content = msg.get("content")
if isinstance(content, list):
text_parts: list[str] = []
for part in content:
if isinstance(part, dict):
ptype = part.get("type")
if ptype == "text":
text_parts.append(part.get("text", ""))
elif ptype in {"image", "image_url", "input_image"}:
text_parts.append(_image_part_label(part))
else:
# Unknown part type — keep a marker so the
# summarizer knows content existed here.
text_parts.append(f"[{ptype or 'attachment'}]")
elif isinstance(part, str):
text_parts.append(part)
content = "\n".join(text_parts)
content = redact_sensitive_text(content or "")
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
# Strip inline reasoning blocks (<think>, <reasoning>, etc.) from
# assistant content before it reaches the summarizer. Reasoning
# traces are transient scratch work — feeding them to the aux
# model wastes summarizer context and risks scratch-work
# conclusions being preserved as facts in the summary. The native
# ``reasoning`` message field is already excluded (only
# ``content`` is serialized); this closes the inline-tag path
# used when native thinking is disabled or the provider inlines
# traces into content.
if role == "assistant" and content:
content = strip_think_blocks(None, content)
# Tool results: keep enough content for the summarizer
if role == "tool":
@ -1880,7 +2184,15 @@ This compaction should PRIORITISE preserving all information related to the focu
"api_mode": self.api_mode,
},
"messages": [{"role": "user", "content": prompt}],
"max_tokens": int(summary_budget * 1.3),
# NO max_tokens: the output cap must never truncate a summary.
# ``summary_budget`` is prompt-level guidance only ("Target ~N
# tokens" above). Most OpenAI-compatible wires already omit the
# param (see _build_call_kwargs), but the Anthropic Messages
# wire and NVIDIA NIM forward it — a hard cap there cut
# summaries mid-section (thinking models burn the cap on
# reasoning first), producing truncated/thinking-only
# summaries and compaction loops. Omitting lets the adapter
# fall back to the model's native output ceiling.
# timeout resolved from auxiliary.compression.timeout config by call_llm
}
if self.summary_model:
@ -1920,6 +2232,16 @@ This compaction should PRIORITISE preserving all information related to the focu
f"(provider={self.provider or 'auto'} "
f"model={self.summary_model or self.model})"
)
# Strip reasoning blocks the summarizer model may have emitted
# (<think>...</think> etc. from thinking models like MiniMax,
# DeepSeek, QwQ). Without this the trace is stored in
# _previous_summary, injected into the conversation, AND fed back
# into every subsequent iterative-update prompt — compounding
# token bloat across compactions. Mirrors title_generator.py.
from agent.agent_runtime_helpers import strip_think_blocks
stripped = strip_think_blocks(None, content).strip()
if stripped:
content = stripped
# Redact the summary output as well — the summarizer LLM may
# ignore prompt instructions and echo back secrets verbatim.
summary = redact_sensitive_text(content.strip())
@ -1970,6 +2292,7 @@ This compaction should PRIORITISE preserving all information related to the focu
_is_timeout = (
_status in {408, 429, 502, 504}
or "timeout" in _err_str
or "timed out" in _err_str
)
# Non-JSON / malformed-body responses from misconfigured providers
# or proxies (e.g. an HTML 502 page returned with
@ -2059,7 +2382,30 @@ This compaction should PRIORITISE preserving all information related to the focu
# Transient errors (timeout, rate limit, network, JSON decode,
# 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
# Timeout-class failures escalate with consecutive occurrences:
# a session whose transcript structurally exceeds what the
# summary route can produce within its deadline will fail the
# same way every time, and re-burning the full timeout every
# 60s turns each subsequent turn into a multi-minute stall
# (#62452). 60s → 300s → 900s (capped); any successful summary
# resets the streak via _clear_compression_failure_cooldown().
# Timeout takes precedence over the streaming-closed short rung:
# a "timed out" error also matches _is_connection_error, but a
# deadline exhaustion is the structural repeat-offender class,
# not a transient mid-stream drop.
if _is_timeout:
self._consecutive_timeout_failures = (
getattr(self, "_consecutive_timeout_failures", 0) + 1
)
_TIMEOUT_COOLDOWN_LADDER = (60, 300, 900)
_transient_cooldown = _TIMEOUT_COOLDOWN_LADDER[
min(self._consecutive_timeout_failures,
len(_TIMEOUT_COOLDOWN_LADDER)) - 1
]
elif _is_json_decode or _is_streaming_closed:
_transient_cooldown = 30
else:
_transient_cooldown = 60
err_text = str(e).strip() or e.__class__.__name__
if len(err_text) > 220:
err_text = err_text[:217].rstrip() + "..."
@ -2734,6 +3080,7 @@ This compaction should PRIORITISE preserving all information related to the focu
self._last_aux_model_failure_error = None
self._last_aux_model_failure_model = None
self._last_compress_aborted = False
self._last_compression_made_progress = False
# NOTE: do NOT reset _last_summary_auth_failure or
# _last_summary_network_failure here. These flags are set by
# _generate_summary() on a terminal failure and are already cleared on
@ -2754,10 +3101,21 @@ This compaction should PRIORITISE preserving all information related to the focu
# Only need head + 3 tail messages minimum (token budget decides the real tail size)
_min_for_compress = self._protect_head_size(messages) + 3 + 1
if n_messages <= _min_for_compress:
# Record the no-op, exactly as the sibling "no compressable window"
# branch below does (#40803). Returning without touching the
# anti-thrashing counter leaves should_compress() saying True on a
# transcript that can never shrink: when the prompt sits above the
# threshold because of the incompressible floor (system prompt +
# tool schemas), every subsequent turn re-fires a compaction that
# returns here unchanged, and the CLI appears frozen.
self._ineffective_compression_count += 1
self._last_compression_savings_pct = 0.0
if not self.quiet_mode:
logger.warning(
"Cannot compress: only %d messages (need > %d)",
"Cannot compress: only %d messages (need > %d). "
"ineffective_compression_count=%d",
n_messages, _min_for_compress,
self._ineffective_compression_count,
)
return messages
@ -3053,15 +3411,26 @@ This compaction should PRIORITISE preserving all information related to the focu
compressed = _strip_historical_media(compressed)
new_estimate = estimate_messages_tokens_rough(compressed)
saved_estimate = display_tokens - new_estimate
# Anti-thrashing: track compression effectiveness
savings_pct = (saved_estimate / display_tokens * 100) if display_tokens > 0 else 0
# Anti-thrashing: measure effectiveness on a like-for-like basis.
#
# ``display_tokens`` is usually ``current_tokens`` — the provider's real
# prompt count, which includes the system prompt and tool schemas.
# ``new_estimate`` covers the messages ONLY. Comparing the two makes a
# compaction that freed almost nothing look like it saved ~96%, so the
# counter below resets every pass and the anti-thrashing guard is dead
# code. Compaction can only shrink messages, so score it against the
# messages it was given.
pre_estimate = estimate_messages_tokens_rough(messages)
saved_estimate = pre_estimate - new_estimate
savings_pct = (saved_estimate / pre_estimate * 100) if pre_estimate > 0 else 0
self._last_compression_savings_pct = savings_pct
if savings_pct < 10:
self._ineffective_compression_count += 1
else:
self._ineffective_compression_count = 0
# Message-only savings are diagnostic. The anti-thrashing verdict is
# owned by the next provider-reported prompt count, which answers the
# actual question: did this completed boundary get under the threshold?
# Counting a low message-savings estimate here as well would give one
# compaction two strikes when that real reading remains over threshold.
if not self.quiet_mode:
logger.info(
@ -3078,5 +3447,6 @@ This compaction should PRIORITISE preserving all information related to the focu
# are positional; this single terminal sweep makes it structural so a
# future copy site cannot re-leak the marker into the child-session flush.
_strip_persistence_markers(compressed)
self._last_compression_made_progress = True
return compressed

View file

@ -28,6 +28,7 @@ these paths see no behavioural change.
from __future__ import annotations
import inspect
import logging
import os
import tempfile
@ -52,6 +53,29 @@ COMPACTION_STATUS = (
)
def _lock_api_is_absent_on_session_db(lock_db: Any) -> bool:
"""Whether the live in-memory SessionDB class structurally predates locks.
In the supported hot-reload skew, this module is new while the already
imported ``hermes_state.SessionDB`` class (and its live instances) is old.
Only that exact class identity may fail open. Proxies, nominal lookalikes,
non-callables, and descriptor failures must fail closed. Static lookup
avoids invoking a present-but-broken descriptor.
"""
try:
from hermes_state import SessionDB
missing = object()
return (
type(lock_db) is SessionDB
and inspect.getattr_static(
SessionDB, "try_acquire_compression_lock", missing
) is missing
)
except Exception:
return False
def _compression_lock_holder(agent: Any) -> str:
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
@ -480,6 +504,21 @@ def compress_context(
force=force,
)
# Every automatic entrypoint must honor compressor-owned cooldown and
# breaker state. Gateway hygiene constructs a fresh AIAgent, so the
# persisted fallback streak is loaded by bind_session_state() before this.
if not force:
blocked = getattr(
type(agent.context_compressor),
"_automatic_compression_blocked",
None,
)
if callable(blocked) and blocked(agent.context_compressor):
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
# Lazy feasibility check — run the auxiliary-provider probe + context
# length lookup just-in-time on the first compression attempt instead of
# at AIAgent.__init__. Saves ~400ms cold off every short session that
@ -541,21 +580,31 @@ def compress_context(
_lock_sid = agent.session_id or ""
_lock_holder: Optional[str] = None
# Probe whether the lock subsystem is actually available on this
# SessionDB instance. A process running mismatched module versions
# (e.g. ``conversation_compression.py`` reloaded after a pull but the
# long-lived ``hermes_state.SessionDB`` class still bound to the
# pre-#34351 version in memory) has the call site but not the method.
# In that case ``try_acquire_compression_lock`` raises AttributeError —
# NOT a ``sqlite3.Error`` — so the method's own fail-open guard never
# runs and the exception propagates to the outer agent loop, which
# prints the error and retries. Because compression never succeeds,
# the token count never drops and the loop re-triggers compaction
# forever (the "API call #47/#48/#49 ... has no attribute
# try_acquire_compression_lock" spin). Fail OPEN here: if the lock
# subsystem is missing or broken in any unexpected way, skip locking
# 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.
# SessionDB instance. A process running mismatched module versions can have
# this call site while its long-lived SessionDB instance predates the lock
# API. Only that structural absence is safe to fail open for: compression
# must make progress rather than spin forever after an update. Once the
# method has been resolved, every exception from its implementation fails
# closed because proceeding without a lock can fork the session lineage.
_try_acquire_lock = None
_lock_lookup_error: Optional[Exception] = None
_legacy_session_db_without_lock_api = False
if _lock_db is not None:
try:
_legacy_session_db_without_lock_api = _lock_api_is_absent_on_session_db(
_lock_db
)
except Exception as exc:
_lock_lookup_error = exc
if _lock_lookup_error is None and not _legacy_session_db_without_lock_api:
try:
_try_acquire_lock = _lock_db.try_acquire_compression_lock
if not callable(_try_acquire_lock):
_lock_lookup_error = TypeError(
"compression lock API is present but not callable"
)
except Exception as exc:
_lock_lookup_error = exc
try:
_lock_ttl = float(getattr(agent, "_compression_lock_ttl_seconds", 300.0) or 300.0)
except (TypeError, ValueError):
@ -564,25 +613,58 @@ def compress_context(
_lock_refresher: Optional[_CompressionLockLeaseRefresher] = None
if _lock_db is not None and _lock_sid:
_lock_holder = _compression_lock_holder(agent)
try:
_lock_acquired = _lock_db.try_acquire_compression_lock(
_lock_sid, _lock_holder, ttl_seconds=_lock_ttl
if _lock_lookup_error is not None:
# Attribute lookup itself failed for a reason other than a missing
# lock API. It is unsafe to proceed without a lock in that case.
_lock_holder = None
logger.warning(
"compression lock lookup raised unexpectedly for session=%s "
"(%s: %s) — skipping compression this cycle",
_lock_sid, type(_lock_lookup_error).__name__, _lock_lookup_error,
)
except Exception as _lock_err:
# Broken/absent lock subsystem (version skew, etc.). Log once
# per session and proceed WITHOUT the lock rather than letting
# the exception spin the outer loop.
_lock_holder = None # we don't own anything to release
_lock_acquired = False
elif _try_acquire_lock is None:
# The lock API itself is absent on this in-memory instance. Log once
# and proceed unlocked so an update-version skew cannot leave the
# outer auto-compression loop making no progress forever.
_lock_holder = None
if getattr(agent, "_last_compression_lock_error_sid", None) != _lock_sid:
agent._last_compression_lock_error_sid = _lock_sid
logger.warning(
"compression lock subsystem unavailable for session=%s "
"(%s: %s) — proceeding without lock. This usually means a "
"stale in-memory module after an update; restart the "
"process (or `hermes update`) to resync.",
"— proceeding without lock. This usually means a stale "
"in-memory module after an update; restart the process "
"(or `hermes update`) to resync.",
_lock_sid,
)
_lock_acquired = True # acquired-but-unlocked compatibility path
else:
try:
_lock_acquired = _try_acquire_lock(
_lock_sid, _lock_holder, ttl_seconds=_lock_ttl
)
except Exception as _lock_err:
# The method exists and entered its implementation but failed.
# Do not mistake an internal AttributeError or TypeError for
# version skew: fail closed and preserve session lineage. A
# failure after SQLite committed the acquire can leave our
# holder row behind, so release it best-effort before returning
# unchanged messages; release is holder-qualified and safe when
# acquisition never succeeded.
try:
_lock_db.release_compression_lock(_lock_sid, _lock_holder)
except Exception as _release_err:
logger.debug(
"compression lock cleanup after failed acquire failed: %s",
_release_err,
)
_lock_holder = None
logger.warning(
"compression lock acquisition raised unexpectedly for "
"session=%s (%s: %s) — skipping compression this cycle",
_lock_sid, type(_lock_err).__name__, _lock_err,
)
_lock_acquired = True # treat as acquired-but-unlocked; proceed
_lock_acquired = False
if not _lock_acquired:
try:
existing = _lock_db.get_compression_lock_holder(_lock_sid)
@ -651,6 +733,17 @@ def compress_context(
_release_lock()
raise
# Capture boundary quality before session-rotation callbacks run. Built-in
# and plugin lifecycle hooks may reset per-session compressor fields while
# rebinding to the child id; the completed attempt's verdict must survive
# that rebind and be recorded only after the full boundary commits.
_compression_made_progress = bool(
getattr(agent.context_compressor, "_last_compression_made_progress", False)
)
_compression_used_fallback = bool(
getattr(agent.context_compressor, "_last_summary_fallback_used", False)
)
# If compression aborted (aux LLM failed to produce a usable summary)
# the compressor returns the input messages unchanged. Surface the
# error to the user, skip the session-rotation work entirely (no
@ -673,6 +766,20 @@ def compress_context(
finally:
_release_lock()
# A compressor that returns the exact input object made no structural
# progress. Do not rotate/rewrite the session or arm post-compression
# deferral in that case; its own anti-thrash counter records the no-op.
if compressed is messages:
logger.info(
"Compression made no progress (session=%s) — skipping boundary rewrite.",
agent.session_id or "none",
)
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
_release_lock()
return messages, _existing_sp
try:
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
if summary_error:
@ -961,6 +1068,23 @@ def compress_context(
agent.context_compressor.last_prompt_tokens = -1
agent.context_compressor.last_completion_tokens = 0
agent.context_compressor.awaiting_real_usage_after_compression = True
# Arm the effectiveness verdict only after a completed rewrite crosses
# the full compaction boundary. Exceptions, aborts, and no-op attempts
# leave this false, so unrelated later usage cannot be charged to an
# attempt that never changed the transcript.
if _compression_made_progress:
record_boundary = getattr(
type(agent.context_compressor),
"record_completed_compaction",
None,
)
if callable(record_boundary):
record_boundary(
agent.context_compressor,
used_fallback=_compression_used_fallback,
)
else:
agent.context_compressor._verify_compaction_cleared_threshold = True
# Clear the file-read dedup cache. After compression the original
# read content is summarised away — if the model re-reads the same
@ -1054,7 +1178,7 @@ def _compress_context_via_codex_app_server(
pass
agent._codex_session = None
if getattr(result, "error", None):
if getattr(result, "interrupted", False) or getattr(result, "error", None):
try:
agent._emit_warning(
f"⚠ Codex app-server compaction failed: {result.error}"
@ -1078,7 +1202,11 @@ def _compress_context_via_codex_app_server(
approx_tokens=approx_tokens,
force=True,
)
if getattr(result, "token_usage_last", None):
# An empty usage report must consume the pending post-compaction verdict
# rather than leaving preflight deferral armed until some unrelated later
# Codex turn supplies usage. Minimal external test engines may not expose
# the ContextEngine update hook; preserve their existing bookkeeping.
if hasattr(agent.context_compressor, "update_from_response"):
_record_codex_app_server_usage(agent, result)
except Exception:
logger.debug("codex compaction bookkeeping failed", exc_info=True)

View file

@ -194,7 +194,6 @@ def _is_nous_inference_route(provider: str, base_url: str) -> bool:
base = str(base_url or "")
return (
base_url_host_matches(base, "inference-api.nousresearch.com")
or base_url_host_matches(base, "inference.nousresearch.com")
)
@ -458,6 +457,21 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List
)
# Continuation nudge for Codex/Responses turns that came back with only
# internal reasoning (no visible content, no tool calls). When the interim
# assistant message also carries no encrypted reasoning items and no
# replayable message items, _chat_messages_to_responses_input emits nothing
# for it — a bare retry would be byte-identical to the request that just
# failed, so the model (observed: grok-4.20 on xai-oauth) deterministically
# repeats the reasoning-only response until the retry budget is exhausted.
_CODEX_INCOMPLETE_NUDGE = (
"[System: Your previous response contained only internal reasoning and "
"never produced a visible answer or tool call. Do not keep thinking. "
"Produce your final answer as plain text now (or make the tool call "
"you were planning).]"
)
# Shared recovery hint appended to every content-policy refusal message. Both
# the HTTP-200 refusal path (``finish_reason=content_filter``) and the
# exception path (a provider moderation error classified as
@ -522,12 +536,12 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt):
def run_conversation(
agent,
user_message: str,
user_message: Any,
system_message: str = None,
conversation_history: List[Dict[str, Any]] = None,
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
@ -613,6 +627,11 @@ def run_conversation(
truncated_response_parts: List[str] = []
compression_attempts = 0
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
# Last composed answer intentionally held back by a verification gate. If
# that continuation consumes the remaining budget, this is the best
# user-facing result available; it must not be confused with error or
# recovery text produced by unrelated exit paths.
_pending_verification_response = None
# Per-turn tally of consecutive successful credential-pool token refreshes,
# keyed by (provider, pool-entry-id). A persistent upstream 401 lets
@ -852,10 +871,19 @@ def run_conversation(
if moa_config:
try:
from agent.message_content import flatten_message_text as _flatten_mt
from agent.moa_loop import _preset_temperature, aggregate_moa_context
_moa_context = aggregate_moa_context(
user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message),
user_prompt=(
original_user_message
if isinstance(original_user_message, str)
# Multimodal / decorated content list: extract the
# visible text instead of str()-ing a Python repr of
# the parts (which would leak base64 image payloads
# into the aggregator prompt).
else _flatten_mt(original_user_message)
),
api_messages=api_messages,
reference_models=moa_config.get("reference_models") or [],
aggregator=moa_config.get("aggregator") or {},
@ -869,6 +897,14 @@ def run_conversation(
_base = _msg.get("content", "")
if isinstance(_base, str):
_msg["content"] = _base + "\n\n" + _moa_context
elif isinstance(_base, list):
# Multimodal user turn (text + image parts):
# append the MoA context as a trailing text
# part instead of silently dropping it.
_msg["content"] = [
*_base,
{"type": "text", "text": "\n\n" + _moa_context},
]
break
except Exception as _moa_exc:
logger.warning("MoA context aggregation failed: %s", _moa_exc)
@ -1162,7 +1198,11 @@ def run_conversation(
if agent._force_ascii_payload:
_sanitize_structure_non_ascii(api_kwargs)
if agent.api_mode == "codex_responses":
api_kwargs = agent._get_transport().preflight_kwargs(api_kwargs, allow_stream=False)
api_kwargs = agent._get_transport().preflight_kwargs(
api_kwargs,
allow_stream=False,
is_github_responses=agent._is_copilot_url(),
)
# Copilot x-initiator: the first API call of a user turn is
# marked "user" so Copilot bills a premium request; tool-loop
# follow-ups keep the default "agent" header (#3040).
@ -1309,6 +1349,12 @@ def run_conversation(
_use_streaming = False
def _perform_api_call(next_api_kwargs):
if agent.api_mode == "codex_responses":
next_api_kwargs = agent._get_transport().preflight_kwargs(
next_api_kwargs,
allow_stream=False,
is_github_responses=agent._is_copilot_url(),
)
if _use_streaming:
return agent._interruptible_streaming_api_call(
next_api_kwargs, on_first_delta=_stop_spinner
@ -1596,12 +1642,16 @@ def run_conversation(
# Check finish_reason before proceeding
if agent.api_mode == "codex_responses":
status = getattr(response, "status", None)
if isinstance(status, str):
status = status.strip().lower()
incomplete_details = getattr(response, "incomplete_details", None)
incomplete_reason = None
if isinstance(incomplete_details, dict):
incomplete_reason = incomplete_details.get("reason")
else:
incomplete_reason = getattr(incomplete_details, "reason", None)
if incomplete_reason is not None:
incomplete_reason = str(incomplete_reason).strip().lower()
if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}:
# Responses API max-output exhaustion is a normal
# Codex incomplete turn. Let the Codex-specific
@ -1611,6 +1661,8 @@ def run_conversation(
# emits "Response truncated due to output length
# limit" and stops gateway turns.
finish_reason = "incomplete"
elif status == "incomplete" and incomplete_reason == "content_filter":
finish_reason = "content_filter"
else:
finish_reason = "stop"
elif agent.api_mode == "anthropic_messages":
@ -2104,7 +2156,19 @@ def run_conversation(
"reasoning_tokens": canonical_usage.reasoning_tokens,
}
agent.context_compressor.update_from_response(usage_dict)
elif getattr(
agent.context_compressor,
"awaiting_real_usage_after_compression",
False,
):
# A response with no usage cannot adjudicate whether the
# prior compaction cleared the threshold. Consume the pending
# verdict now so a much later, unrelated reading is not
# charged to that old compaction, and so preflight deferral
# does not remain latched indefinitely.
agent.context_compressor.update_from_response({})
if hasattr(response, 'usage') and response.usage:
# Cache discovered context length after successful call.
# Only persist limits confirmed by the provider (parsed
# from the error message), not guessed probe tiers.
@ -2561,6 +2625,31 @@ def run_conversation(
)
continue
# ── Bedrock AnthropicBedrock SDK streaming failure ──
# The Anthropic SDK's stream accumulator raises RuntimeError
# "Unexpected event order" when Bedrock returns an error event
# before message_start (throttling, overload, validation).
# Fall back to the native Converse API path for the rest of
# this session — it handles these errors gracefully. Ref: #28156.
if (
isinstance(api_error, RuntimeError)
and "unexpected event order" in str(api_error).lower()
and getattr(agent, "provider", "") == "bedrock"
and agent.api_mode == "anthropic_messages"
and not getattr(agent, "_bedrock_converse_fallback_attempted", False)
):
agent._bedrock_converse_fallback_attempted = True
agent.api_mode = "bedrock_converse"
agent._bedrock_region = getattr(agent, "_bedrock_region", None) or "us-east-1"
agent.client = None # Drop the AnthropicBedrock client
agent._client_kwargs = {}
agent._vprint(
f"{agent.log_prefix}⚠️ AnthropicBedrock SDK streaming failed — "
f"falling back to native Converse API for this session.",
force=True,
)
continue
status_code = getattr(api_error, "status_code", None)
error_context = agent._extract_api_error_context(api_error)
@ -3034,14 +3123,24 @@ def run_conversation(
# (``/new``), switch to a larger-context model, or reduce
# attachments. Forced compaction via ``/compress``
# (``force=True``) is unaffected — it never reaches this loop.
#
# Output-cap errors (max_tokens too large) are NOT input
# overflow — the recovery is a max_tokens-only retry that
# does not require compression. Exempt them from this guard
# so the retry still fires even when compression is disabled.
_overflow_reasons = {
FailoverReason.long_context_tier,
FailoverReason.payload_too_large,
FailoverReason.context_overflow,
}
_is_output_cap_error = (
is_output_cap_error(error_msg)
or parse_available_output_tokens_from_error(error_msg) is not None
)
if (
classified.reason in _overflow_reasons
and not getattr(agent, "compression_enabled", True)
and not _is_output_cap_error
):
agent._flush_status_buffer()
agent._vprint(
@ -3445,15 +3544,33 @@ def run_conversation(
# context_length = total window (input + output combined).
available_out = parse_available_output_tokens_from_error(error_msg)
if available_out is not None:
# Error is purely about the output cap being too large.
# Cap output to the available space and retry without
# touching context_length or triggering compression.
safe_out = max(1, available_out - 64) # small safety margin
# This is an output-cap error, not input overflow.
# The provider's available_tokens is the authoritative
# cap for the failed request, so keep it as an upper
# bound. Also estimate the current API request shape
# (system prompt, injected context, tool schemas) because
# Hermes may add API-only content not present in persisted
# messages. Use the smaller budget and apply a small
# safety margin. Do not alter context_length.
request_input_estimate = estimate_request_tokens_rough(
api_messages, tools=agent.tools or None,
)
local_available_out = old_ctx - request_input_estimate
if local_available_out > 0:
safe_out = max(1, min(available_out, local_available_out) - 64)
else:
# The rough local estimate can overshoot the real
# request size. Fall back to the provider-reported
# budget, which is authoritative for the failed
# request.
safe_out = max(1, available_out - 64)
agent._ephemeral_max_output_tokens = safe_out
agent._buffer_vprint(
f"⚠️ Output cap too large for current prompt — "
f"retrying with max_tokens={safe_out:,} "
f"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})"
f"(provider_available={available_out:,}, "
f"estimated_request_tokens={request_input_estimate:,}; "
f"context_length unchanged at {old_ctx:,})"
)
# Still count against compression_attempts so we don't
# loop forever if the error keeps recurring.
@ -4373,33 +4490,82 @@ def run_conversation(
or interim_has_codex_message_items
):
last_msg = messages[-1] if messages else None
# Duplicate detection: two consecutive incomplete assistant
# messages with identical content AND reasoning are collapsed.
# For provider-state-only changes (encrypted reasoning
# items or replayable message ids/phases/statuses differ
# while visible content/reasoning are unchanged), compare
# those opaque payloads too so we don't silently drop the
# newer continuation state.
last_codex_items = last_msg.get("codex_reasoning_items") if isinstance(last_msg, dict) else None
interim_codex_items = interim_msg.get("codex_reasoning_items")
last_codex_message_items = last_msg.get("codex_message_items") if isinstance(last_msg, dict) else None
interim_codex_message_items = interim_msg.get("codex_message_items")
duplicate_interim = (
# Duplicate detection: compare only visible content
# (content + reasoning). Opaque provider state
# (encrypted reasoning items, message item ids/phases)
# drifts per continuation even when the visible output
# is identical, so including it in the comparison defeats
# dedup and causes message storms (#52711).
visible_duplicate = (
isinstance(last_msg, dict)
and last_msg.get("role") == "assistant"
and last_msg.get("finish_reason") == "incomplete"
and (last_msg.get("content") or "") == (interim_msg.get("content") or "")
and (last_msg.get("reasoning") or "") == (interim_msg.get("reasoning") or "")
and last_codex_items == interim_codex_items
and last_codex_message_items == interim_codex_message_items
)
if not duplicate_interim:
if visible_duplicate:
# Update opaque state in-place so the latest
# provider payload is preserved without emitting
# a duplicate visible message.
for _key in ("codex_reasoning_items", "codex_message_items"):
_new_val = interim_msg.get(_key)
if _new_val is not None:
last_msg[_key] = _new_val
else:
messages.append(interim_msg)
agent._emit_interim_assistant_message(interim_msg)
if agent._codex_incomplete_retries < 3:
# When the interim message has nothing the Responses
# input converter will replay (no visible content, no
# encrypted reasoning items, no replayable message
# items — plain-text reasoning only), a bare retry is
# byte-identical to the request that just came back
# incomplete and fails the same way every time
# (observed with grok-4.20 on xai-oauth, whose
# reasoning items lack encrypted_content). Append a
# user-role nudge so the retry actually differs and
# explicitly asks for the final answer.
interim_replayable = (
interim_has_content
or interim_has_codex_reasoning
or interim_has_codex_message_items
)
if not interim_replayable:
_last_msg = messages[-1] if messages else None
_already_nudged = (
isinstance(_last_msg, dict)
and _last_msg.get("role") == "user"
and _last_msg.get("content") == _CODEX_INCOMPLETE_NUDGE
)
# Alternation guard: the nudge is a user-role message,
# so it may only follow an assistant message. When the
# interim was too empty to append (no content AND no
# reasoning), the last message is still the prior
# user/tool turn — appending the nudge there would
# create a user→user / tool→user sequence that strict
# providers reject.
_last_is_assistant = (
isinstance(_last_msg, dict)
and _last_msg.get("role") == "assistant"
)
if not _already_nudged and _last_is_assistant:
messages.append({
"role": "user",
"content": _CODEX_INCOMPLETE_NUDGE,
})
if not agent.quiet_mode:
agent._vprint(f"{agent.log_prefix}↻ Codex response incomplete; continuing turn ({agent._codex_incomplete_retries}/3)")
# Surface the continuation on the live spinner/status line
# (CLI/TUI/Desktop) and gateway heartbeat: each of these
# retries can spend minutes waiting on the provider, and
# without a distinct notice the user only sees a generic
# thinking spinner ("infinite thinking", #64434).
agent._emit_wait_notice(
f"↻ model returned reasoning with no final answer — "
f"asking it to continue "
f"({agent._codex_incomplete_retries}/3)"
)
agent._session_messages = messages
continue
@ -4423,7 +4589,9 @@ def run_conversation(
if agent.verbose_logging:
for tc in assistant_message.tool_calls:
logging.debug(f"Tool call: {tc.function.name} with args: {tc.function.arguments[:200]}...")
raw_args = tc.function.arguments
args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200]
logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview)
# Validate tool call names - detect model hallucinations
# Repair mismatched tool names before validating
@ -4604,11 +4772,37 @@ def run_conversation(
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
turn_content = assistant_message.content or ""
# Classify tools in this turn to determine if they are all housekeeping.
# This classification is needed regardless of whether the turn has visible content,
# because a substantive tool-only turn must invalidate any older housekeeping fallback.
_HOUSEKEEPING_TOOLS = frozenset({
"memory", "todo", "skill_manage", "session_search",
})
_all_housekeeping = all(
tc.function.name in _HOUSEKEEPING_TOOLS
for tc in assistant_message.tool_calls
)
# If this turn has substantive tools (non-housekeeping), clear any older fallback.
# Prevents a two-turn-old housekeeping narration from being treated as if it belonged
# to the immediately preceding substantive tool turn.
if assistant_message.tool_calls and not _all_housekeeping:
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
# Also clear the mute flag: a prior housekeeping turn may
# have set _mute_post_response (line ~4667), and the
# substantive tools in THIS turn should produce visible
# progress output. Without this reset, _vprint suppresses
# tool progress until the no-tool-call branch clears it at
# line ~4834 — after all tools have finished.
agent._mute_post_response = False
# If this turn has both content AND tool_calls, capture the content
# as a fallback final response. Common pattern: model delivers its
# answer and calls memory/skill tools as a side-effect in the same
# turn. If the follow-up turn after tools is empty, we use this.
turn_content = assistant_message.content or ""
if turn_content and agent._has_content_after_think_block(turn_content):
agent._last_content_with_tools = turn_content
# Only mute subsequent output when EVERY tool call in
@ -4616,13 +4810,6 @@ def run_conversation(
# skill_manage, etc.). If any substantive tool is present
# (search_files, read_file, write_file, terminal, ...),
# keep output visible so the user sees progress.
_HOUSEKEEPING_TOOLS = frozenset({
"memory", "todo", "skill_manage", "session_search",
})
_all_housekeeping = all(
tc.function.name in _HOUSEKEEPING_TOOLS
for tc in assistant_message.tool_calls
)
agent._last_content_tools_all_housekeeping = _all_housekeeping
if _all_housekeeping and agent._has_stream_consumers():
agent._mute_post_response = True
@ -5062,8 +5249,11 @@ def run_conversation(
# Reset retry counter/signature on successful content
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
# Successful content reached — drop any buffered retry
# status from earlier failed attempts in this turn.
# Successful content reached — surface the one-shot fallback
# switch notice (if a fallback activated this turn) before
# dropping the noisy retry buffer, so a provider/model switch
# stays visible even when the fallback succeeds.
agent._emit_pending_fallback_notice()
agent._clear_status_buffer()
from agent.agent_runtime_helpers import (
@ -5096,6 +5286,10 @@ def run_conversation(
}
messages.append(continue_msg)
agent._session_messages = messages
# An acknowledgment is explicitly non-final. Do not let its
# text suppress iteration-limit summarization if this
# continuation consumes the remaining budget.
final_response = None
continue
codex_ack_continuations = 0
@ -5170,6 +5364,12 @@ def run_conversation(
# terminal. Keep a debug breadcrumb in agent.log for tracing.
logger.debug("verification stop-loop nudge issued (attempt %d)",
agent._verification_stop_nudges)
# Keep the attempted answer only as an explicit fallback for
# continuation-budget exhaustion. ``final_response`` itself
# must be cleared so the finalizer can distinguish this gate
# from unrelated error/recovery exits. (#61631)
_pending_verification_response = final_response
final_response = None
continue
# User verification-loop gate: when the agent edited code this
@ -5221,6 +5421,55 @@ def run_conversation(
agent._session_messages = messages
logger.debug("pre_verify nudge issued (attempt %d)",
agent._pre_verify_nudges)
_pending_verification_response = final_response
final_response = None
continue
# ── Kanban worker terminal-tool stop guard ─────────────
# Workers must end with kanban_complete / kanban_block.
# Models sometimes narrate the next step ("Let me write the
# report") and stop with finish_reason=stop — a clean exit
# that the dispatcher records as protocol_violation. Nudge
# once or twice before allowing that exit.
try:
from agent.kanban_stop import build_kanban_stop_nudge
_kanban_nudge = build_kanban_stop_nudge(
messages=messages,
attempts=getattr(agent, "_kanban_stop_nudges", 0),
)
except Exception:
logger.debug("kanban stop-loop check failed", exc_info=True)
_kanban_nudge = None
if _kanban_nudge:
agent._kanban_stop_nudges = (
getattr(agent, "_kanban_stop_nudges", 0) + 1
)
final_msg["finish_reason"] = "kanban_terminal_required"
final_msg["_kanban_stop_synthetic"] = True
messages.append(final_msg)
messages.append({
"role": "user",
"content": _kanban_nudge,
"_kanban_stop_synthetic": True,
})
agent._session_messages = messages
logger.info(
"kanban stop-loop nudge issued (attempt %d) task=%s",
agent._kanban_stop_nudges,
os.environ.get("HERMES_KANBAN_TASK", ""),
)
agent._emit_status(
"⚠️ Kanban worker tried to exit without "
"kanban_complete/kanban_block — nudging to finish"
)
# Same finalizer contract as verify-on-stop: clear
# final_response while continuing so a later budget
# exhaustion path does not treat the narrated stop as
# a completed answer.
_pending_verification_response = final_response
final_response = None
continue
messages.append(final_msg)
@ -5305,6 +5554,7 @@ def run_conversation(
original_user_message=original_user_message,
_should_review_memory=_should_review_memory,
_turn_exit_reason=_turn_exit_reason,
_pending_verification_response=_pending_verification_response,
)

View file

@ -26,7 +26,7 @@ from openai.types.chat.chat_completion_message_tool_call import (
Function,
)
from agent.file_safety import get_read_block_error, is_write_denied
from agent.file_safety import get_read_block_error, get_write_denied_error
from agent.redact import redact_sensitive_text
from tools.environments.local import hermes_subprocess_env
@ -727,10 +727,9 @@ class CopilotACPClient:
elif method == "fs/write_text_file":
try:
path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
if is_write_denied(str(path)):
raise PermissionError(
f"Write denied: '{path}' is a protected system/credential file."
)
denied = get_write_denied_error(str(path))
if denied:
raise PermissionError(denied)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(params.get("content") or ""))
response = {

View file

@ -128,6 +128,17 @@ _EXTRA_KEYS = frozenset({
})
def _normalize_pool_auth_type(provider: str, token: Any, auth_type: Any) -> str:
"""Infer pool auth metadata for token formats with one unambiguous meaning."""
if (
provider == "anthropic"
and isinstance(token, str)
and token.startswith("sk-ant-oat")
):
return AUTH_TYPE_OAUTH
return str(auth_type or AUTH_TYPE_API_KEY)
@dataclass
class PooledCredential:
provider: str
@ -157,6 +168,11 @@ class PooledCredential:
def __post_init__(self):
if self.extra is None:
self.extra = {}
self.auth_type = _normalize_pool_auth_type(
self.provider,
self.access_token,
self.auth_type,
)
def __getattr__(self, name: str):
if name in _EXTRA_KEYS:
@ -445,6 +461,44 @@ def get_pool_strategy(provider: str) -> str:
return STRATEGY_FILL_FIRST
def credential_pool_matches_provider(
pool_or_provider: Any,
provider: Optional[str],
*,
base_url: Optional[str] = None,
) -> bool:
"""Return whether a pool belongs to the requested runtime provider.
Named custom endpoints intentionally use two identities: the live agent is
``custom`` while its pool is keyed ``custom:<name>``. Accept that pair only
when the runtime base URL resolves to the exact same custom pool key.
Empty string identities fail closed. Legacy pool adapters without a
``provider`` attribute remain compatible; production pools are scoped.
"""
raw_pool_provider = getattr(pool_or_provider, "provider", None)
if raw_pool_provider is None:
if isinstance(pool_or_provider, str):
raw_pool_provider = pool_or_provider
else:
# Backward compatibility for lightweight/unscoped pool adapters.
# Production CredentialPool instances always carry ``provider``;
# old plugins and tests may expose only select()/has_credentials().
return True
pool_provider = str(raw_pool_provider or "").strip().lower()
provider_norm = str(provider or "").strip().lower()
if not pool_provider or not provider_norm:
return False
if pool_provider == provider_norm:
return True
if provider_norm != "custom" or not pool_provider.startswith(CUSTOM_POOL_PREFIX):
return False
try:
matched_pool = get_custom_provider_pool_key(base_url or "")
except Exception:
return False
return str(matched_pool or "").strip().lower() == pool_provider
DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1
@ -1378,6 +1432,11 @@ class CredentialPool:
entries_to_prune: List[str] = []
available: List[PooledCredential] = []
for entry in self._entries:
# Borrowed credentials persist as metadata-only references and are
# hydrated from their live source on load. A stale duplicate row
# can remain unhydrated; never lease or select it as an empty key.
if entry.auth_type == AUTH_TYPE_API_KEY and not entry.runtime_api_key:
continue
# For anthropic claude_code entries, sync from the credentials file
# before any status/refresh checks. This picks up tokens refreshed
# by other processes (Claude Code CLI, other Hermes profiles).
@ -1694,11 +1753,15 @@ class CredentialPool:
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
existing_idx = None
matching_indices = []
for idx, entry in enumerate(entries):
if entry.source == source:
existing_idx = idx
break
matching_indices.append(idx)
existing_idx = matching_indices[0] if matching_indices else None
duplicate_indices = set(matching_indices[1:])
if duplicate_indices:
entries[:] = [entry for idx, entry in enumerate(entries) if idx not in duplicate_indices]
if existing_idx is None:
payload.setdefault("id", uuid.uuid4().hex[:6])
@ -1730,8 +1793,8 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
# Runtime-only borrowed secret updates should refresh the in-memory
# entry without forcing auth.json churn when the disk-safe payload is
# unchanged (for example env keys with the same fingerprint).
return existing.to_dict() != updated.to_dict()
return False
return bool(duplicate_indices) or existing.to_dict() != updated.to_dict()
return bool(duplicate_indices)
def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool:
@ -2205,12 +2268,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
if _is_source_suppressed(provider, source):
continue
active_sources.add(source)
# Claude Code OAuth tokens are the only Anthropic credentials that should flow into the OAuth refresh path.
auth_type = (
AUTH_TYPE_OAUTH
if provider == "anthropic" and token.startswith("sk-ant-oat")
else AUTH_TYPE_API_KEY
)
base_url = env_url or pconfig.inference_base_url
if provider == "kimi-coding":
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
@ -2225,7 +2282,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
env_var=env_var,
token=token,
base_url=base_url,
auth_type=auth_type,
),
)
return changed, active_sources
@ -2353,16 +2409,37 @@ def load_pool(provider: str) -> CredentialPool:
for payload in raw_entries
)
entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]
raw_needs_auth_normalization = any(
isinstance(payload, dict)
and _normalize_pool_auth_type(
provider,
payload.get("access_token"),
payload.get("auth_type", AUTH_TYPE_API_KEY),
) != payload.get("auth_type", AUTH_TYPE_API_KEY)
for payload in raw_entries
)
if raw_needs_auth_normalization:
# A profile may be reading this provider from the global-root fallback.
# Keep that fallback read-only: only the store that owns these rows may
# rewrite them. Loading the default/root profile will heal global rows.
active_pool = _load_auth_store().get("credential_pool")
active_entries = active_pool.get(provider) if isinstance(active_pool, dict) else None
raw_needs_auth_normalization = bool(active_entries)
if provider.startswith(CUSTOM_POOL_PREFIX):
# Custom endpoint pool — seed from custom_providers config and model config
custom_changed, custom_sources = _seed_custom_pool(provider, entries)
changed = raw_needs_sanitization or custom_changed
changed = raw_needs_sanitization or raw_needs_auth_normalization or custom_changed
changed |= _prune_stale_seeded_entries(entries, custom_sources)
else:
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
env_changed, env_sources = _seed_from_env(provider, entries)
changed = raw_needs_sanitization or singleton_changed or env_changed
changed = (
raw_needs_sanitization
or raw_needs_auth_normalization
or singleton_changed
or env_changed
)
# ``load_pool()`` is a non-destructive read for env-seeded entries: a
# process missing a provider env var must not delete the persisted
# pool entry for every other process (#9331). File-backed singletons

View file

@ -45,12 +45,26 @@ def _strip_aux_credential(value: Any) -> Optional[str]:
class _ReviewRuntimeBinding(NamedTuple):
"""Provider/model for the curator review fork plus optional per-slot overrides."""
"""Provider/model for the curator review fork plus per-slot overrides."""
provider: str
model: str
explicit_api_key: Optional[str]
explicit_base_url: Optional[str]
request_overrides: Dict[str, Any]
def _merge_request_overrides(
runtime_overrides: Any,
slot_extra_body: Any,
) -> Dict[str, Any]:
"""Merge resolver metadata with task-local request body fields."""
merged = dict(runtime_overrides or {})
if isinstance(slot_extra_body, dict) and slot_extra_body:
extra_body = dict(merged.get("extra_body") or {})
extra_body.update(slot_extra_body)
merged["extra_body"] = extra_body
return merged
DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
@ -1764,6 +1778,7 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
_task_model,
_strip_aux_credential(_cur_task.get("api_key")),
_strip_aux_credential(_cur_task.get("base_url")),
_merge_request_overrides({}, _cur_task.get("extra_body")),
)
# 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification)
@ -1781,10 +1796,11 @@ def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
str(_legacy_model),
_strip_aux_credential(_legacy.get("api_key")),
_strip_aux_credential(_legacy.get("base_url")),
_merge_request_overrides({}, _legacy.get("extra_body")),
)
# 3. Fall through to the main chat model
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None)
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None, {})
def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:
@ -1850,6 +1866,11 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
_base_url = None
_api_mode = None
_resolved_provider = None
_credential_pool = None
_request_overrides: Dict[str, Any] = {}
_max_tokens = None
_acp_command = None
_acp_args = None
_model_name = ""
try:
from hermes_cli.config import load_config
@ -1867,6 +1888,16 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
_base_url = _rp.get("base_url")
_api_mode = _rp.get("api_mode")
_resolved_provider = _rp.get("provider") or _provider
_credential_pool = _rp.get("credential_pool")
_request_overrides = _merge_request_overrides(
_rp.get("request_overrides"),
_binding.request_overrides.get("extra_body"),
)
_max_tokens = _rp.get("max_output_tokens")
_acp_command = _rp.get("command")
_acp_args = list(_rp.get("args") or [])
if isinstance(_rp.get("model"), str) and _rp["model"].strip():
_model_name = _rp["model"].strip()
except Exception as e:
logger.debug("Curator provider resolution failed: %s", e, exc_info=True)
@ -1875,12 +1906,21 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
review_agent = None
try:
_agent_kwargs: Dict[str, Any] = {}
if isinstance(_max_tokens, int):
_agent_kwargs["max_tokens"] = _max_tokens
if isinstance(_acp_command, str) and _acp_command:
_agent_kwargs["acp_command"] = _acp_command
_agent_kwargs["acp_args"] = _acp_args or []
review_agent = AIAgent(
model=_model_name,
provider=_resolved_provider,
api_key=_api_key,
base_url=_base_url,
api_mode=_api_mode,
credential_pool=_credential_pool,
request_overrides=_request_overrides,
**_agent_kwargs,
# Umbrella-building over a large skill collection is worth a
# high iteration ceiling — the pass typically takes 50-100
# API calls against hundreds of candidate skills. The

View file

@ -27,6 +27,14 @@ logger = logging.getLogger(__name__)
_ANSI_RESET = "\033[0m"
def _display_url(value: Any) -> str:
"""Extract a display-only URL without assuming model argument types."""
if isinstance(value, dict):
value = value.get("url") or value.get("href")
return value.strip() if isinstance(value, str) else ""
# Diff colors — resolved lazily from the skin engine so they adapt
# to light/dark themes. Falls back to sensible defaults on import
# failure. We cache after first resolution for performance.
@ -454,13 +462,14 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
sid = args.get("session_id", "")
data = args.get("data", "")
timeout_val = args.get("timeout")
parts = [action]
parts = [str(action) if action else ""]
if sid:
parts.append(sid[:16])
parts.append(str(sid)[:16])
if data:
parts.append(f'"{_oneline(data[:20])}"')
parts.append(f'"{_oneline(str(data)[:20])}"')
if timeout_val and action == "wait":
parts.append(f"{timeout_val}s")
parts = [p for p in parts if p]
return " ".join(parts) if parts else None
if tool_name == "todo":
@ -1259,7 +1268,7 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
return False, ""
def get_cute_tool_message(
def _get_cute_tool_message(
tool_name: str, args: dict, duration: float, result: str | None = None,
) -> str:
"""Generate a formatted tool completion line for CLI quiet mode.
@ -1301,9 +1310,11 @@ def get_cute_tool_message(
if tool_name == "web_extract":
urls = args.get("urls", [])
if urls:
url = urls[0] if isinstance(urls, list) else str(urls)
url = _display_url(urls[0] if isinstance(urls, list) else urls)
if not url:
return _wrap(f"┊ 📄 fetch pages {dur}")
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else ""
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
return _wrap(f"┊ 📄 fetch pages {dur}")
if tool_name == "terminal":
@ -1433,6 +1444,19 @@ def get_cute_tool_message(
return _wrap(f"┊ ⚡ {tool_name[:9]:9} {_trunc(preview, 35)} {dur}")
def get_cute_tool_message(
tool_name: str, args: dict, duration: float, result: str | None = None,
) -> str:
"""Render a completion label without letting cosmetic failures escape."""
try:
return _get_cute_tool_message(tool_name, args, duration, result=result)
except Exception as exc: # noqa: BLE001 — display must never abort a turn
logger.debug("Tool completion label failed for %s: %s", tool_name, exc)
safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else "tool"
safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else "done"
return f"┊ ⚡ {safe_name:9} completed {safe_duration}"
# =========================================================================
# Honcho session line (one-liner with clickable OSC 8 hyperlink)
# =========================================================================

View file

@ -840,12 +840,34 @@ def classify_api_error(
)
return _result(FailoverReason.timeout, retryable=True)
# ── 7. Transport / timeout heuristics ───────────────────────────
# ── 7b. Stale-call circuit breaker → failover immediately ──────
# _check_stale_giveup() in agent/chat_completion_helpers.py raises a
# RuntimeError when the provider has been unresponsive for N
# consecutive stale attempts (default 5). The error is NOT a transport
# timeout — the circuit breaker fires *before* any network call to avoid
# an indefinite stall. Without this classification the RuntimeError
# falls through to FailoverReason.unknown (retryable=True), which burns
# all max_retries against the same dead provider (each retry hitting the
# circuit breaker instantly with zero network overhead) before fallback
# is attempted. Classify as non-retryable + should_fallback so the
# retry loop activates the next fallback provider on the first hit.
if (
error_type == "RuntimeError"
and "consecutive stale attempts" in error_msg
and "aborting this call" in error_msg
):
return _result(
FailoverReason.timeout,
retryable=False,
should_fallback=True,
)
# ── 8. Transport / timeout heuristics ───────────────────────────
if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)):
return _result(FailoverReason.timeout, retryable=True)
# ── 8. Fallback: unknown ────────────────────────────────────────
# ── 9. Fallback: unknown ────────────────────────────────────────
return _result(FailoverReason.unknown, retryable=True)
@ -1147,6 +1169,7 @@ def _classify_400(
"encrypted content for item" in error_msg
and "could not be verified" in error_msg
)
or "could not decrypt the provided encrypted_content" in error_msg
):
return result_fn(
FailoverReason.invalid_encrypted_content,

View file

@ -1,3 +1,9 @@
class SSLConfigurationError(Exception):
"""Raised when SSL/TLS certificate bundle configuration fails."""
pass
class EmptyStreamError(RuntimeError):
"""Raised when a provider closes a stream without yielding a response."""
pass

View file

@ -95,16 +95,16 @@ def get_safe_write_roots() -> set[str]:
return roots
def is_write_denied(path: str) -> bool:
"""Return True if path is blocked by the write denylist or safe root."""
def _classify_write_denial(path: str) -> Optional[str]:
"""Return ``'credential'``, ``'safe_root'``, or ``None`` if writes are allowed."""
home = os.path.realpath(os.path.expanduser("~"))
resolved = os.path.realpath(os.path.expanduser(str(path)))
if resolved in build_write_denied_paths(home):
return True
return "credential"
for prefix in build_write_denied_prefixes(home):
if resolved.startswith(prefix):
return True
return "credential"
mcp_tokens_dir_name = "mcp-tokens"
@ -121,13 +121,13 @@ def is_write_denied(path: str) -> bool:
try:
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
return True
return "credential"
except Exception:
pass
try:
pairing_real = os.path.realpath(os.path.join(base_real, "pairing"))
if resolved == pairing_real or resolved.startswith(pairing_real + os.sep):
return True
return "credential"
except Exception:
pass
@ -139,9 +139,28 @@ def is_write_denied(path: str) -> bool:
allowed = True
break
if not allowed:
return True
return "safe_root"
return False
return None
def is_write_denied(path: str) -> bool:
"""Return True if path is blocked by the write denylist or safe root."""
return _classify_write_denial(path) is not None
def get_write_denied_error(path: str, *, verb: str = "Write") -> Optional[str]:
"""Return a user/model-facing error when writes to ``path`` are blocked."""
denial = _classify_write_denial(path)
if denial is None:
return None
if denial == "safe_root":
roots_display = os.pathsep.join(sorted(get_safe_write_roots()))
return (
f"{verb} denied: '{path}' is outside HERMES_WRITE_SAFE_ROOT "
f"({roots_display}). Unset the variable or add this path's directory prefix."
)
return f"{verb} denied: '{path}' is a protected system/credential file."
# Common secret-bearing project-local environment file basenames.

View file

@ -32,6 +32,13 @@ from agent.gemini_schema import sanitize_gemini_tool_parameters
logger = logging.getLogger(__name__)
try:
import hermes_cli as _hermes_cli
_HERMES_VERSION = str(_hermes_cli.__version__)
except Exception:
_HERMES_VERSION = "0.0.0"
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
# Published max output-token ceiling shared by every current Gemini text model
@ -99,7 +106,10 @@ def probe_gemini_tier(
url,
params={"key": key},
json=payload,
headers={"Content-Type": "application/json"},
headers={
"Content-Type": "application/json",
"X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}",
},
)
except Exception as exc:
logger.debug("probe_gemini_tier: network error: %s", exc)
@ -901,7 +911,11 @@ class GeminiNativeClient:
"Content-Type": "application/json",
"Accept": "application/json",
"x-goog-api-key": self.api_key,
"User-Agent": "hermes-agent (gemini-native)",
# Include Hermes client context following Gemini's partner
# integration guidance.
# See https://ai.google.dev/gemini-api/docs/partner-integration
"User-Agent": f"hermes-agent/{_HERMES_VERSION} (gemini-native)",
"X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}",
}
headers.update(self._default_headers)
return headers

View file

@ -17,6 +17,7 @@ Usage:
"""
import json
import sqlite3
import time
from collections import Counter, defaultdict
from datetime import datetime
@ -141,8 +142,8 @@ class InsightsEngine:
}
# Compute insights
overview = self._compute_overview(sessions, message_stats)
models = self._compute_model_breakdown(sessions)
models = self._compute_model_breakdown(sessions, cutoff, source)
overview = self._compute_overview(sessions, message_stats, models)
platforms = self._compute_platform_breakdown(sessions)
tools = self._compute_tool_breakdown(tool_usage)
skills = self._compute_skill_breakdown(skill_usage)
@ -172,7 +173,7 @@ class InsightsEngine:
"message_count, tool_call_count, input_tokens, output_tokens, "
"cache_read_tokens, cache_write_tokens, billing_provider, "
"billing_base_url, billing_mode, estimated_cost_usd, "
"actual_cost_usd, cost_status, cost_source")
"actual_cost_usd, cost_status, cost_source, api_call_count")
# Pre-computed query strings — f-string evaluated once at class definition,
# not at runtime, so no user-controlled value can alter the query structure.
@ -399,7 +400,12 @@ class InsightsEngine:
# Computation
# =========================================================================
def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> Dict:
def _compute_overview(
self,
sessions: List[Dict],
message_stats: Dict,
models: Optional[List[Dict]] = None,
) -> Dict:
"""Compute high-level overview statistics."""
total_input = sum(s.get("input_tokens") or 0 for s in sessions)
total_output = sum(s.get("output_tokens") or 0 for s in sessions)
@ -431,6 +437,9 @@ class InsightsEngine:
else:
models_without_pricing.add(display)
if models:
total_cost = sum(float(m.get("cost") or 0.0) for m in models)
# Session duration stats (guard against negative durations from clock drift)
durations = []
for s in sessions:
@ -473,39 +482,189 @@ class InsightsEngine:
"included_cost_sessions": included_cost_sessions,
}
def _compute_model_breakdown(self, sessions: List[Dict]) -> List[Dict]:
"""Break down usage by model."""
_GET_MODEL_USAGE_WITH_SOURCE = (
"SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url,"
" u.api_call_count, u.input_tokens, u.output_tokens,"
" u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens,"
" u.estimated_cost_usd, u.actual_cost_usd, u.cost_status,"
" u.cost_source, u.billing_mode"
" FROM session_model_usage u"
" JOIN sessions s ON s.id = u.session_id"
" WHERE s.started_at >= ? AND s.source = ?"
)
_GET_MODEL_USAGE_ALL = (
"SELECT u.session_id, u.model, u.billing_provider, u.billing_base_url,"
" u.api_call_count, u.input_tokens, u.output_tokens,"
" u.cache_read_tokens, u.cache_write_tokens, u.reasoning_tokens,"
" u.estimated_cost_usd, u.actual_cost_usd, u.cost_status,"
" u.cost_source, u.billing_mode"
" FROM session_model_usage u"
" JOIN sessions s ON s.id = u.session_id"
" WHERE s.started_at >= ?"
)
def _get_model_usage(self, cutoff: float, source: str = None) -> List[Dict]:
"""Fetch per-model usage rows within the window (issue #51607).
Returns an empty list when the table is missing (e.g. a DB opened by
older code that never created it) so the caller can fall back to the
per-session aggregate.
"""
try:
if source:
cursor = self._conn.execute(
self._GET_MODEL_USAGE_WITH_SOURCE, (cutoff, source)
)
else:
cursor = self._conn.execute(self._GET_MODEL_USAGE_ALL, (cutoff,))
return [dict(row) for row in cursor.fetchall()]
except sqlite3.OperationalError:
return []
def _compute_model_breakdown(
self, sessions: List[Dict], cutoff: float, source: str = None
) -> List[Dict]:
"""Break down token usage and cost by model.
Tokens and cost are attributed per model from session_model_usage, so a
session that switched models mid-flight (via ``/model``) splits across
every model it used instead of dumping everything on the initial model
(issue #51607). Sessions without per-model rows — e.g. data written
before this table existed and not yet backfilled fall back to their
single recorded (model, billing_provider) aggregate so nothing is lost.
Tool calls aren't tied to a specific API invocation, so they stay
attributed to the session's recorded model.
"""
model_data = defaultdict(lambda: {
"sessions": 0, "input_tokens": 0, "output_tokens": 0,
"sessions": set(), "input_tokens": 0, "output_tokens": 0,
"cache_read_tokens": 0, "cache_write_tokens": 0,
"total_tokens": 0, "tool_calls": 0, "cost": 0.0,
"reasoning_tokens": 0, "total_tokens": 0, "api_calls": 0,
"tool_calls": 0, "cost": 0.0, "actual_cost": 0.0,
})
for s in sessions:
model = s.get("model") or "unknown"
def _accumulate(model, provider, base_url, session_id, inp, out,
cache_read, cache_write, reasoning, *,
stored_cost=None, actual_cost=None, cost_status=None):
model = model or "unknown"
# Normalize: strip provider prefix for display
display_model = model.split("/")[-1] if "/" in model else model
d = model_data[display_model]
d["sessions"] += 1
inp = s.get("input_tokens") or 0
out = s.get("output_tokens") or 0
cache_read = s.get("cache_read_tokens") or 0
cache_write = s.get("cache_write_tokens") or 0
d: Dict[str, Any] = model_data[display_model]
d["sessions"].add(session_id)
d["input_tokens"] += inp
d["output_tokens"] += out
d["cache_read_tokens"] += cache_read
d["cache_write_tokens"] += cache_write
d["reasoning_tokens"] += reasoning
d["total_tokens"] += inp + out + cache_read + cache_write
d["tool_calls"] += s.get("tool_call_count") or 0
estimate, status = _estimate_cost(s)
if stored_cost is None:
estimate, status = _estimate_cost(
model, inp, out,
cache_read_tokens=cache_read, cache_write_tokens=cache_write,
provider=provider or None, base_url=base_url,
)
else:
estimate = float(stored_cost or 0.0)
status = cost_status or "unknown"
d["cost"] += estimate
d["has_pricing"] = has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url"))
d["actual_cost"] += float(actual_cost or 0.0)
d["cost_status"] = status
if has_known_pricing(model, provider or None, base_url):
d["has_pricing"] = True
else:
d.setdefault("has_pricing", False)
return display_model
result = [
{"model": model, **data}
for model, data in model_data.items()
]
usage_rows = self._get_model_usage(cutoff, source)
usage_totals = defaultdict(lambda: {
"input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0,
"cache_write_tokens": 0, "reasoning_tokens": 0,
"api_call_count": 0, "estimated_cost_usd": 0.0,
"actual_cost_usd": 0.0,
})
for r in usage_rows:
totals: Dict[str, Any] = usage_totals[r["session_id"]]
for key in (
"input_tokens", "output_tokens", "cache_read_tokens",
"cache_write_tokens", "reasoning_tokens", "api_call_count",
):
totals[key] += r[key] or 0
totals["estimated_cost_usd"] += r["estimated_cost_usd"] or 0.0
totals["actual_cost_usd"] += r["actual_cost_usd"] or 0.0
d = _accumulate(
r["model"], r["billing_provider"], r.get("billing_base_url"),
r["session_id"], r["input_tokens"] or 0, r["output_tokens"] or 0,
r["cache_read_tokens"] or 0, r["cache_write_tokens"] or 0,
r["reasoning_tokens"] or 0,
stored_cost=(
r["estimated_cost_usd"]
if r.get("cost_status") or r.get("cost_source")
else None
),
actual_cost=r["actual_cost_usd"],
cost_status=r.get("cost_status"),
)
model_data[d]["api_calls"] += r["api_call_count"] or 0
# Reconcile against the aggregate row. This covers legacy sessions,
# interrupted migrations, and absolute cumulative updates without
# double-counting already-attributed route deltas.
for s in sessions:
totals = usage_totals[s["id"]]
inp = max(0, (s.get("input_tokens") or 0) - totals["input_tokens"])
out = max(0, (s.get("output_tokens") or 0) - totals["output_tokens"])
cache_read = max(
0, (s.get("cache_read_tokens") or 0) - totals["cache_read_tokens"]
)
cache_write = max(
0, (s.get("cache_write_tokens") or 0) - totals["cache_write_tokens"]
)
residual_cost = max(
0.0, float(s.get("estimated_cost_usd") or 0.0)
- totals["estimated_cost_usd"],
)
residual_actual = max(
0.0, float(s.get("actual_cost_usd") or 0.0)
- totals["actual_cost_usd"],
)
residual_calls = max(
0, (s.get("api_call_count") or 0) - totals["api_call_count"]
)
if not (
inp or out or cache_read or cache_write or residual_cost
or residual_actual or residual_calls
):
continue
d = _accumulate(
s.get("model"), s.get("billing_provider"),
s.get("billing_base_url"), s["id"],
inp, out, cache_read, cache_write, 0,
stored_cost=residual_cost,
actual_cost=residual_actual,
cost_status=s.get("cost_status"),
)
residual_bucket: Dict[str, Any] = model_data[d]
residual_bucket["api_calls"] += residual_calls
# Tool calls are attributed by the session's recorded model.
for s in sessions:
tool_calls = s.get("tool_call_count") or 0
if not tool_calls:
continue
model = s.get("model") or "unknown"
display_model = model.split("/")[-1] if "/" in model else model
model_data[display_model]["tool_calls"] += tool_calls
result = []
for model, data in model_data.items():
entry = {"model": model, **data}
entry["sessions"] = len(data["sessions"])
# Models that surfaced only via tool-call attribution (no token
# rows) won't have these set by _accumulate — default them so the
# output shape is uniform for downstream/JSON consumers.
entry.setdefault("has_pricing", False)
entry.setdefault("cost_status", "unknown")
result.append(entry)
# Sort by tokens first, fall back to session count when tokens are 0
result.sort(key=lambda x: (x["total_tokens"], x["sessions"]), reverse=True)
return result

108
agent/kanban_stop.py Normal file
View file

@ -0,0 +1,108 @@
"""Turn-end guard for kanban workers.
Kanban workers must end with ``kanban_complete`` or ``kanban_block``. Models
(especially GLM / Qwen families) sometimes narrate the next step
("Let me write the report now") and stop with ``finish_reason=stop`` and no
tool calls. Hermes treats that as a clean exit ``rc=0`` dispatcher
``protocol_violation``.
This module is policy-only: when a kanban worker tries to finish without a
terminal board tool, return a bounded synthetic nudge so the conversation
loop continues instead of exiting.
"""
from __future__ import annotations
import os
from typing import Any, Iterable, Optional
_TERMINAL_KANBAN_TOOLS = frozenset({"kanban_complete", "kanban_block"})
_DEFAULT_MAX_ATTEMPTS = 2
def kanban_stop_nudge_enabled() -> bool:
"""Return whether the kanban stop-guard is active for this process.
On when ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), unless
``HERMES_KANBAN_STOP_NUDGE`` explicitly disables it.
"""
env = os.environ.get("HERMES_KANBAN_STOP_NUDGE")
if env is not None and env.strip().lower() in {"0", "false", "no", "off"}:
return False
task = (os.environ.get("HERMES_KANBAN_TASK") or "").strip()
return bool(task)
def _tool_call_name(tc: Any) -> str:
if isinstance(tc, dict):
fn = tc.get("function")
if isinstance(fn, dict):
return str(fn.get("name") or "")
return str(tc.get("name") or "")
fn = getattr(tc, "function", None)
if fn is not None:
return str(getattr(fn, "name", "") or "")
return str(getattr(tc, "name", "") or "")
def session_called_kanban_terminal(messages: Iterable[dict] | None) -> bool:
"""True if this conversation already invoked a terminal kanban tool."""
if not messages:
return False
for msg in messages:
if not isinstance(msg, dict):
continue
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if _tool_call_name(tc) in _TERMINAL_KANBAN_TOOLS:
return True
elif role == "tool":
name = str(msg.get("name") or "")
if name in _TERMINAL_KANBAN_TOOLS:
return True
return False
def build_kanban_stop_nudge(
*,
messages: Iterable[dict] | None = None,
attempts: int = 0,
max_attempts: int = _DEFAULT_MAX_ATTEMPTS,
task_id: Optional[str] = None,
) -> Optional[str]:
"""Return a synthetic follow-up when a kanban worker exits without a terminal tool.
Returns ``None`` when the guard should not fire (not a kanban worker,
already completed/blocked, or nudge budget exhausted).
"""
if not kanban_stop_nudge_enabled():
return None
if attempts >= max_attempts:
return None
if session_called_kanban_terminal(messages):
return None
tid = (task_id or os.environ.get("HERMES_KANBAN_TASK") or "").strip() or "this task"
return (
"[System: You are a Hermes kanban worker. A plain-text reply is NOT a "
"terminal state for the board.\n\n"
f"Task `{tid}` is still `running`. Ending now without a board tool "
"causes a protocol violation (clean exit with no "
"`kanban_complete` / `kanban_block`).\n\n"
"Do this immediately in your next response — do not narrate intent:\n"
"1. Finish any remaining deliverable (write the required file(s) now).\n"
"2. Call `kanban_complete(summary=..., artifacts=[...])` if the work "
"is done, OR `kanban_block(reason=...)` if you are blocked.\n\n"
"Never end a turn with only a promise of future action. Repeated "
"protocol violations will block this task and require manual intervention.]"
)
__all__ = [
"build_kanban_stop_nudge",
"kanban_stop_nudge_enabled",
"session_called_kanban_terminal",
]

View file

@ -783,6 +783,55 @@ class MemoryManager:
exc_info=True,
)
def commit_session_boundary_async(
self,
messages: List[Dict[str, Any]],
*,
new_session_id: str,
parent_session_id: str = "",
reason: str = "new_session",
) -> None:
"""Queue old-session extraction + provider rebinding as ONE serialized task.
Session rotation (/new) must deliver ``on_session_end`` (end-of-session
extraction an LLM-bound call that can take seconds) strictly BEFORE
``on_session_switch`` (which rebinds provider-internal ``_session_id`` /
turn buffers to the new session). Running extraction inline blocked the
/new command for the whole LLM round-trip (#16454); running it on an
ad-hoc thread raced the inline switch providers key off internal
state, so a late ``on_session_end`` ran against post-switch bindings
(transcript misattributed to the new session id, double-ingest of the
old turn buffer, new-session buffers cleared).
Submitting BOTH hooks as one task on the manager's single background
worker gives both properties at a single chokepoint: the caller returns
immediately, and the worker's FIFO order serializes end→switch against
every other provider write (per-turn ``sync_all``, prefetches), which
already share the same worker. If the executor is unavailable,
``_submit_background`` degrades to inline execution the pre-#16454
synchronous behavior, slow but correct.
"""
if not self._providers:
return
snapshot = list(messages or [])
def _run() -> None:
try:
self.on_session_end(snapshot)
except Exception as e: # pragma: no cover - on_session_end guards per-provider
logger.warning("Session-boundary extraction failed: %s", e)
try:
self.on_session_switch(
new_session_id,
parent_session_id=parent_session_id,
reset=True,
reason=reason,
)
except Exception as e: # pragma: no cover - on_session_switch guards per-provider
logger.warning("Session-boundary switch failed: %s", e)
self._submit_background(_run)
def on_session_switch(
self,
new_session_id: str,

View file

@ -14,6 +14,7 @@ from concurrent.futures import ThreadPoolExecutor
from typing import Any
from agent.auxiliary_client import call_llm
from agent.message_content import flatten_message_text
from agent.transports import get_transport
logger = logging.getLogger(__name__)
@ -119,11 +120,54 @@ _REFERENCE_SYSTEM_PROMPT = (
def _slot_label(slot: dict[str, str]) -> str:
return f"{slot.get('provider', '').strip()}:{slot.get('model', '').strip()}"
def _slot_label(slot: dict[str, Any]) -> str:
label = f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}"
effort = str(slot.get("reasoning_effort") or "").strip()
return f"{label}[reasoning={effort}]" if effort else label
def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:
def _slot_reasoning_config(slot: dict[str, Any]) -> dict[str, Any] | None:
"""Translate optional per-MoA-slot reasoning_effort into runtime config."""
effort = slot.get("reasoning_effort")
try:
from hermes_constants import parse_reasoning_effort
return parse_reasoning_effort(effort)
except Exception: # pragma: no cover - defensive; bad config must not break MoA
return None
def _aggregator_reasoning_config(aggregator: dict[str, Any]) -> dict[str, Any] | None:
"""Resolve the aggregator's reasoning config: slot > per-model > global.
The aggregator is MoA's ACTING model, so when its slot doesn't pin a
reasoning_effort it must resolve exactly like any other acting model:
through the shared chokepoint (``resolve_reasoning_config``), which
applies ``agent.reasoning_overrides`` for the slot's model first, then
the global ``agent.reasoning_effort``. Without this the main loop's
reasoning gates (keyed to the virtual ``moa://local`` identity) never
fire, so the aggregator silently ran at the backend default (#64187).
Reference advisors intentionally do NOT get this fallback: they are side
calls (like auxiliary tasks), and inheriting a global ``xhigh`` into every
advisor fan-out would silently multiply cost. Their depth is slot-or-
provider-default only.
"""
cfg = _slot_reasoning_config(aggregator)
if cfg is not None:
return cfg
try:
from hermes_cli.config import load_config
from hermes_constants import resolve_reasoning_config
return resolve_reasoning_config(
load_config() or {}, str(aggregator.get("model") or "")
)
except Exception: # pragma: no cover - defensive; bad config must not break MoA
return None
def _slot_runtime(slot: dict[str, Any]) -> 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
@ -275,6 +319,7 @@ def _run_reference(
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
reasoning_config=_slot_reasoning_config(slot),
**runtime,
)
usage = CanonicalUsage()
@ -470,13 +515,52 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
for msg in messages:
role = msg.get("role")
content = msg.get("content")
text = content if isinstance(content, str) else ""
# Flatten structured content (lists of parts) to visible text. Content
# arrives as a list — not a string — in two common cases:
# 1. Anthropic prompt-cache decoration: conversation_loop runs
# apply_anthropic_cache_control BEFORE the MoA facade, converting
# string content to [{"type": "text", "text": ..., "cache_control":
# ...}]. A str-only read here flattened the user's ENTIRE prompt to
# "" — Claude references then 400'd ("messages: at least one
# message is required") while tolerant models answered "no user
# request is present".
# 2. Multimodal turns (pasted image → text + image_url parts) and
# multimodal tool results (screenshots).
# flatten_message_text extracts the text parts and skips image parts,
# and returns strings unchanged — so a decorated and an undecorated
# transcript produce a byte-identical advisory view (which keeps the
# advisory prefix stable across iterations for advisor prompt caching).
text = flatten_message_text(content)
if role == "system":
continue
if role == "user":
if text.strip():
last_user_content = text
if not text.strip() and isinstance(content, list) and content:
# Structured content with no extractable text (e.g. an
# image-only turn). Emitting an empty user message would be
# dropped/rejected by strict providers (Anthropic 400s on
# empty text blocks — the original "closed" preset failure
# mode), and silently skipping the turn would break
# user/assistant alternation in the advisory view. Substitute
# a placeholder so the reference knows a non-text turn
# happened. Only structured content qualifies — an empty or
# whitespace-only STRING turn carries nothing and is dropped
# below instead.
text = "[user sent non-text content (e.g. an image attachment)]"
if not text.strip():
# Genuinely empty user turn (content="" / None). It carries
# nothing advisory, and strict providers (Kimi/Moonshot, ZAI,
# and others that enforce non-empty user content) reject it
# with 400 "message ... with role 'user' must not be empty" —
# the same way the assistant branch below drops turns with no
# parts. Lenient providers (DeepSeek) accept the empty turn,
# which is why a MoA fan-out would fail on one reference and
# pass on another for the identical rendered view. The
# advisory view is already not strictly alternating (adjacent
# assistant turns occur in every tool loop), so dropping a
# contentless turn is safe.
continue
last_user_content = text
rendered.append({"role": "user", "content": text})
elif role == "assistant":
parts: list[str] = []
@ -517,8 +601,10 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
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"]}]
if msg.get("role") == "user":
fallback_text = flatten_message_text(msg.get("content"))
if fallback_text.strip():
return [{"role": "user", "content": fallback_text}]
return rendered
@ -638,6 +724,7 @@ def aggregate_moa_context(
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=max_tokens,
reasoning_config=_aggregator_reasoning_config(aggregator),
**agg_runtime,
)
synthesis = _extract_text(response)
@ -671,13 +758,28 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str
Appending at the very end keeps the ``[system][task][tool-history]`` prefix
stable and cache-reusable (only the new block re-prefills), and gives the
aggregator the references with recency. Merge into the last message only when
it is already a trailing string ``user`` turn (plain chat still at the end).
it is already a trailing ``user`` turn (plain chat still at the end).
A trailing user turn's content may be a STRING or a LIST of content parts —
Anthropic prompt-cache decoration (which runs before the MoA facade)
converts string content to ``[{"type": "text", ..., "cache_control": ...}]``,
and multimodal turns are lists natively. Both shapes are merged in place:
appending a new text part AFTER the cache_control-marked part keeps the
cached prefix byte-stable (the marker still terminates it) while the
turn-varying guidance rides outside the cached span. Appending a SEPARATE
user message here instead would produce two consecutive user turns
strict providers reject that.
"""
last = agg_messages[-1] if agg_messages else None
if last is not None and last.get("role") == "user" and isinstance(last.get("content"), str):
last["content"] = last["content"] + "\n\n" + guidance
else:
agg_messages.append({"role": "user", "content": guidance})
if last is not None and last.get("role") == "user":
last_content = last.get("content")
if isinstance(last_content, str):
last["content"] = last_content + "\n\n" + guidance
return
if isinstance(last_content, list):
last["content"] = [*last_content, {"type": "text", "text": "\n\n" + guidance}]
return
agg_messages.append({"role": "user", "content": guidance})
class MoAChatCompletions:
@ -1017,6 +1119,7 @@ class MoAChatCompletions:
max_tokens=agg_kwargs.get("max_tokens"),
tools=agg_kwargs.get("tools"),
extra_body=agg_kwargs.get("extra_body"),
reasoning_config=_aggregator_reasoning_config(aggregator),
**stream_kwargs,
**_slot_runtime(aggregator),
)

View file

@ -47,7 +47,7 @@ def _resolve_requests_verify() -> bool | str:
# are preserved so the full model name reaches cache lookups and server queries.
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "deepinfra",
"opencode-zen", "opencode-go", "kilocode", "alibaba", "novita",
"qwen-oauth",
"xiaomi",
@ -58,7 +58,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
# Common aliases
"google", "google-gemini", "google-ai-studio",
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "deep-infra",
"ollama",
"stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen",
"mimo", "xiaomi-mimo",
@ -111,6 +111,15 @@ _MODEL_CACHE_TTL = 3600
_endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
_ENDPOINT_MODEL_CACHE_TTL = 300
# Bounded-lifetime cache: after the first successful probe we remember the
# server type so subsequent refreshes skip the full waterfall (no more 404
# spam every 5 minutes on non-matching endpoints like /api/v1/models on vllm).
# Entries expire after _ENDPOINT_PROBE_TTL_SECONDS so a server swap on the
# same port (stop Ollama, start LM Studio) is eventually re-detected instead
# of being pinned to the stale type for the whole process lifetime.
# Values are (server_type, monotonic_timestamp).
_ENDPOINT_PROBE_TTL_SECONDS = 3600.0
_endpoint_probe_path_cache: Dict[str, tuple] = {}
def _get_model_metadata_cache_path() -> Path:
@ -220,6 +229,12 @@ DEFAULT_CONTEXT_LENGTHS = {
# ChatGPT Codex OAuth caps it at 272K; both paths resolve via their own
# provider-aware branches (_resolve_codex_oauth_context_length + models.dev).
# This hardcoded value is only reached when every probe misses.
# GPT-5.6 series (Sol/Terra/Luna, GA 2026-07-09) — 1.05M on the direct
# OpenAI API (same as gpt-5.5). Codex OAuth caps these at 272K.
# (Lookups length-sort keys at match time, so dict order is cosmetic.)
"gpt-5.6-luna": 1050000,
"gpt-5.6-terra": 1050000,
"gpt-5.6-sol": 1050000,
"gpt-5.5": 1050000,
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
@ -289,11 +304,13 @@ DEFAULT_CONTEXT_LENGTHS = {
# Premium+); /v1/responses additionally enforces a ~262144 input+output
# budget, but the usable context (what we track here) is 200k.
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
"grok-build-latest": 500000, # alias of grok-4.5 (early access)
"grok-build": 256000, # grok-build-0.1
"grok-code-fast": 256000, # grok-code-fast-1
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
"grok-4-fast": 2000000, # grok-4-fast-(non-)reasoning, also matches -reasoning
"grok-4.20": 2000000, # grok-4.20-0309-(non-)reasoning, -multi-agent-0309
"grok-4.5": 500000, # grok-4.5, grok-4.5-latest — 500K context per docs.x.ai
"grok-4.3": 1000000, # grok-4.3, grok-4.3-latest — 1M context per docs.x.ai
"grok-4": 256000, # grok-4, grok-4-0709
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
@ -301,6 +318,16 @@ DEFAULT_CONTEXT_LENGTHS = {
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
# Kimi
"kimi": 262144,
# Upstage Solar — api.upstage.ai/v1/models does not return context_length,
# so these fallbacks keep token budgeting / compression from probing down
# to the 128k default. Ids are matched longest-first, so dated variants
# (e.g. solar-pro3-250127) resolve via their family prefix.
# Sources: Solar Pro 3 = 128K, Solar Pro 2 = 64K, Solar Mini = 32K,
# Solar Open 2 = 256K.
"solar-open2": 262144, # 256K
"solar-pro3": 131072,
"solar-pro2": 65536,
"solar-mini": 32768,
# Tencent — Hy3 Preview (Hunyuan) with 256K context window.
# OpenRouter live metadata reports 262144 (256 × 1024); align the
# static fallback so cache and offline both agree (issue #22268).
@ -347,6 +374,11 @@ _GROK_EFFORT_CAPABLE_PREFIXES = (
"grok-3-mini",
"grok-4.20-multi-agent",
"grok-4.3",
# grok-4.5: verified live against /v1/responses 2026-07-08 — accepts
# effort low/medium/high (default: high when omitted) but REJECTS
# "none" ("This model does not support `reasoning_effort` value `none`"),
# unlike grok-4.3. models.dev agrees: effort values [low, medium, high].
"grok-4.5",
)
@ -625,66 +657,109 @@ def is_local_endpoint(base_url: str) -> bool:
return False
def _localhost_to_ipv4(url: str) -> str:
"""Rewrite a ``localhost`` HOST to ``127.0.0.1`` in a probe URL.
On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1``
first and pays a ~2s IPv6 connect timeout before falling back to IPv4
when the local server only listens on IPv4 (LM Studio, Ollama defaults).
Probing the IPv4 loopback directly skips that penalty.
Only the URL's own host component is rewritten (anchored at the scheme),
so a non-localhost URL whose path or query merely embeds the substring
``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``)
passes through untouched.
"""
if not url:
return url
return re.sub(
r"^(https?://)localhost(?=[:/]|$)",
r"\g<1>127.0.0.1",
url,
count=1,
)
def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
"""Detect which local server is running at base_url by probing known endpoints.
Returns one of: "ollama", "lm-studio", "vllm", "llamacpp", or None.
The result is cached for the lifetime of the process so that repeated
calls (e.g. every 5-minute metadata refresh) never re-run the waterfall
and never spray 404s at endpoints the server does not expose.
"""
import httpx
normalized = _normalize_base_url(base_url)
# Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack.
# Applied to ``normalized`` before deriving server/LM Studio URLs AND
# before the cache lookup, so localhost and 127.0.0.1 share a cache entry.
normalized = _localhost_to_ipv4(normalized)
server_url = normalized
if server_url.endswith("/v1"):
server_url = server_url[:-3]
lmstudio_url = _lmstudio_server_root(base_url)
lmstudio_url = _lmstudio_server_root(normalized)
cached = _endpoint_probe_path_cache.get(server_url)
if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS:
return cached[0]
headers = _auth_headers(api_key)
result: Optional[str] = None
try:
with httpx.Client(timeout=2.0, headers=headers) as client:
# LM Studio exposes /api/v1/models — check first (most specific)
try:
r = client.get(f"{lmstudio_url}/api/v1/models")
if r.status_code == 200:
return "lm-studio"
result = "lm-studio"
except Exception:
pass
# Ollama exposes /api/tags and responds with {"models": [...]}
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
# on this path, so we must verify the response contains "models".
try:
r = client.get(f"{server_url}/api/tags")
if r.status_code == 200:
try:
if result is None:
# Ollama exposes /api/tags and responds with {"models": [...]}
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
# on this path, so we must verify the response contains "models".
try:
r = client.get(f"{server_url}/api/tags")
if r.status_code == 200:
try:
data = r.json()
if "models" in data:
result = "ollama"
except Exception:
pass
except Exception:
pass
if result is None:
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
try:
r = client.get(f"{server_url}/v1/props")
if r.status_code != 200:
r = client.get(f"{server_url}/props") # fallback for older builds
if r.status_code == 200 and "default_generation_settings" in r.text:
result = "llamacpp"
except Exception:
pass
if result is None:
# vLLM: /version
try:
r = client.get(f"{server_url}/version")
if r.status_code == 200:
data = r.json()
if "models" in data:
return "ollama"
except Exception:
pass
except Exception:
pass
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
try:
r = client.get(f"{server_url}/v1/props")
if r.status_code != 200:
r = client.get(f"{server_url}/props") # fallback for older builds
if r.status_code == 200 and "default_generation_settings" in r.text:
return "llamacpp"
except Exception:
pass
# vLLM: /version
try:
r = client.get(f"{server_url}/version")
if r.status_code == 200:
data = r.json()
if "version" in data:
return "vllm"
except Exception:
pass
if "version" in data:
result = "vllm"
except Exception:
pass
except Exception:
pass
return None
if result is not None:
_endpoint_probe_path_cache[server_url] = (result, time.monotonic())
return result
def _iter_nested_dicts(value: Any):
@ -742,6 +817,24 @@ def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]:
pricing["completion"] = str(float(novita_output) / 10_000 / 1_000_000)
return pricing
# DeepInfra ships pricing under ``metadata.pricing`` with $/MTok values:
# ``input_tokens``, ``output_tokens``, ``cache_read_tokens``. Convert to
# per-token strings so the generic cost machinery (usage_pricing.py)
# consumes them through the same path as OpenRouter / OpenAI.
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else None
deepinfra_pricing = metadata.get("pricing") if metadata else None
if isinstance(deepinfra_pricing, dict) and any(
k in deepinfra_pricing for k in ("input_tokens", "output_tokens", "cache_read_tokens")
):
result: Dict[str, Any] = {}
if deepinfra_pricing.get("input_tokens") is not None:
result["prompt"] = str(float(deepinfra_pricing["input_tokens"]) / 1_000_000)
if deepinfra_pricing.get("output_tokens") is not None:
result["completion"] = str(float(deepinfra_pricing["output_tokens"]) / 1_000_000)
if deepinfra_pricing.get("cache_read_tokens") is not None:
result["cache_read"] = str(float(deepinfra_pricing["cache_read_tokens"]) / 1_000_000)
return result
alias_map = {
"prompt": ("prompt", "input", "input_cost_per_token", "prompt_token_cost"),
"completion": ("completion", "output", "output_cost_per_token", "completion_token_cost"),
@ -788,7 +881,10 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
return _model_metadata_cache
try:
response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify())
# Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per
# retry stage through proxies that 403 CONNECT, ballooning to minutes
# (#46620). 5s connect / 10s read fails fast on unreachable hosts.
response = requests.get(OPENROUTER_MODELS_URL, timeout=(5, 10), verify=_resolve_requests_verify())
response.raise_for_status()
data = response.json()
@ -866,7 +962,7 @@ def fetch_endpoint_model_metadata(
response = requests.get(
server_url.rstrip("/") + "/api/v1/models",
headers=headers,
timeout=10,
timeout=(5, 10),
verify=_resolve_requests_verify(),
)
response.raise_for_status()
@ -914,7 +1010,7 @@ def fetch_endpoint_model_metadata(
for candidate in candidates:
url = candidate.rstrip("/") + "/models"
try:
response = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
response.raise_for_status()
payload = response.json()
cache: Dict[str, Dict[str, Any]] = {}
@ -1009,19 +1105,29 @@ def _load_context_cache() -> Dict[str, int]:
try:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data.get("context_lengths", {})
return data.get("context_lengths") or {}
except Exception as e:
logger.debug("Failed to load context length cache: %s", e)
return {}
def _context_cache_key(model: str, base_url: str) -> str:
"""Canonical ``model@base_url`` key for the persistent context cache.
Trailing slashes are stripped so ``http://host/v1`` and
``http://host/v1/`` share one entry instead of creating duplicates
that can go stale independently.
"""
return f"{model}@{(base_url or '').rstrip('/')}"
def save_context_length(model: str, base_url: str, length: int) -> None:
"""Persist a discovered context length for a model+provider combo.
Cache key is ``model@base_url`` so the same model name served from
different providers can have different limits.
"""
key = f"{model}@{base_url}"
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
if cache.get(key) == length:
return # already stored
@ -1038,18 +1144,43 @@ def save_context_length(model: str, base_url: str, length: int) -> None:
def get_cached_context_length(model: str, base_url: str) -> Optional[int]:
"""Look up a previously discovered context length for model+provider."""
key = f"{model}@{base_url}"
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
return cache.get(key)
hit = cache.get(key)
if hit is not None:
return hit
# Legacy rows written before key normalization may carry a trailing
# slash — honor them rather than re-probing. Checked regardless of the
# caller's slash form: the row's shape and the caller's shape can differ
# in either direction (old slashed row + new normalized config, or the
# reverse), so probe the literal form and the slashed canonical form.
for legacy_key in (f"{model}@{base_url}", f"{key}/"):
if legacy_key != key:
hit = cache.get(legacy_key)
if hit is not None:
return hit
return None
def _invalidate_cached_context_length(model: str, base_url: str) -> None:
"""Drop a stale cache entry so it gets re-resolved on the next lookup."""
key = f"{model}@{base_url}"
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
if key not in cache:
# Invalidation must also drop the in-memory TTL probe entries for this
# pair — otherwise the next resolution inside the TTL window reuses the
# very value we just declared stale and re-persists it.
bare = _strip_provider_prefix(model)
stripped = (base_url or "").rstrip("/")
_LOCAL_CTX_PROBE_CACHE.pop((bare, stripped), None)
_LOCAL_CTX_PROBE_CACHE.pop(("ollama_show", bare, stripped), None)
# Clear every key shape for this pair: canonical, the caller's literal
# form, and the slashed legacy form — same set get_cached_context_length
# consults, so a lookup can never resurrect a row invalidation missed.
stale_keys = {key, f"{model}@{base_url}", f"{key}/"}
if not any(k in cache for k in stale_keys):
return
del cache[key]
for k in stale_keys:
cache.pop(k, None)
path = _get_context_cache_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
@ -1336,7 +1467,7 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option
import httpx
bare_model = _strip_provider_prefix(model)
server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]
@ -1397,7 +1528,7 @@ def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") -
except Exception:
return None
server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]
@ -1436,6 +1567,12 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
hosting behind a reverse proxy, etc. For non-Ollama servers the POST
returns 404/405 quickly; the function handles errors gracefully.
Results are cached in ``_LOCAL_CTX_PROBE_CACHE`` (same 30s TTL,
positive-only see ``_query_local_context_length``) so back-to-back
resolutions during one startup issue a single POST instead of one per
call site. Failures are never memoized: a server that isn't up yet must
be re-probed once it comes up.
For hosted servers the GGUF ``model_info.*.context_length`` is the
authoritative source: the user can't set their own ``num_ctx``, and the
OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length``
@ -1447,9 +1584,28 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
The order is flipped vs ``query_ollama_num_ctx()`` because local users
control ``num_ctx`` themselves; hosted users can't.
"""
import time as _time
# Namespaced cache key: shares the TTL store with
# _query_local_context_length but never collides with its (model, url)
# keys — the two probes can return different values for the same pair.
cache_key = ("ollama_show", _strip_provider_prefix(model), base_url.rstrip("/"))
now = _time.monotonic()
cached = _LOCAL_CTX_PROBE_CACHE.get(cache_key)
if cached is not None and (now - cached[1]) < _LOCAL_CTX_PROBE_TTL_SECONDS:
return cached[0]
result = _query_ollama_api_show_uncached(model, base_url, api_key=api_key)
if result: # positive-only — never memoize a failed probe
_LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now)
return result
def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``."""
import httpx
server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]
@ -1568,10 +1724,10 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str
model = _strip_provider_prefix(model)
# Strip /v1 suffix to get the server root
server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]
lmstudio_url = _lmstudio_server_root(base_url)
lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url))
headers = _auth_headers(api_key)
@ -1681,7 +1837,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) ->
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
if resp.status_code != 200:
return None
data = resp.json()
@ -1715,6 +1871,9 @@ _CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
"gpt-5.3-codex-spark": 128_000,
"gpt-5.2-codex": 272_000,
"gpt-5.4-mini": 272_000,
"gpt-5.6-sol": 272_000,
"gpt-5.6-terra": 272_000,
"gpt-5.6-luna": 272_000,
"gpt-5.5": 272_000,
"gpt-5.4": 272_000,
"gpt-5.2": 272_000,
@ -1748,7 +1907,7 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
resp = requests.get(
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
timeout=(5, 10),
verify=_resolve_requests_verify(),
)
if resp.status_code != 200:
@ -2432,5 +2591,82 @@ def estimate_request_tokens_rough(
if messages:
total += estimate_messages_tokens_rough(messages)
if tools:
total += (len(str(tools)) + 3) // 4
total += _estimate_tools_tokens_rough(tools)
return total
# NOTE: tool schemas can be large. Avoid repeated `str(tools)` conversions,
# which are CPU-heavy and can stall GUI event loops under GIL pressure.
#
# Keyed by ``id(tools)``. A long-lived gateway/desktop backend builds many
# transient tool lists over its lifetime, so the cache is bounded and evicts
# oldest-first (insertion-ordered dict) once it exceeds the cap. The cap is
# generous relative to how rarely toolsets are rebuilt within a process.
_TOOLS_TOKENS_CACHE: dict[int, Tuple[int, str, str, int]] = {}
_TOOLS_TOKENS_CACHE_MAX = 256
def _tool_name_for_cache(tool: Any) -> str:
if not isinstance(tool, dict):
return ""
fn = tool.get("function")
if isinstance(fn, dict):
name = fn.get("name")
if isinstance(name, str):
return name
name = tool.get("name")
return name if isinstance(name, str) else ""
def _estimate_tools_tokens_rough(tools: List[Dict[str, Any]]) -> int:
if not tools:
return 0
# Cache by list identity. Tools are rebuilt rarely (toolset changes),
# but token estimates are requested frequently (preflight, compaction).
key = id(tools)
n = len(tools)
first = _tool_name_for_cache(tools[0]) if n else ""
last = _tool_name_for_cache(tools[-1]) if n else ""
cached = _TOOLS_TOKENS_CACHE.get(key)
if cached is not None:
cached_n, cached_first, cached_last, cached_tokens = cached
if cached_n == n and cached_first == first and cached_last == last:
return cached_tokens
# Fast, stable rough estimate: sum lengths of the major schema fields.
# This avoids the pathological `str(tools)` path while still scaling with
# schema size (descriptions + parameters dominate).
total_chars = 0
for tool in tools:
if not isinstance(tool, dict):
continue
fn = tool.get("function")
if isinstance(fn, dict):
name = fn.get("name") or ""
desc = fn.get("description") or ""
params = fn.get("parameters") or {}
else:
name = tool.get("name") or ""
desc = tool.get("description") or ""
params = tool.get("parameters") or {}
if isinstance(name, str):
total_chars += len(name)
if isinstance(desc, str):
total_chars += len(desc)
# Parameters can be nested; JSON is closer to over-the-wire size than repr().
try:
total_chars += len(json.dumps(params, ensure_ascii=False, separators=(",", ":")))
except Exception:
total_chars += len(str(params))
tokens = (total_chars + 3) // 4
# Bound the cache: drop the oldest entry when the cap is exceeded so a
# long-running process can't accumulate an unbounded number of stale
# ``id(tools)`` entries (id values are recycled after GC anyway).
if len(_TOOLS_TOKENS_CACHE) >= _TOOLS_TOKENS_CACHE_MAX:
_TOOLS_TOKENS_CACHE.pop(next(iter(_TOOLS_TOKENS_CACHE)), None)
_TOOLS_TOKENS_CACHE[key] = (n, first, last, tokens)
return tokens

View file

@ -7,6 +7,7 @@ assemble pieces, then combines them with memory and ephemeral prompts.
import json
import logging
import os
import sys
import threading
import contextvars
from collections import OrderedDict
@ -17,6 +18,8 @@ from typing import Optional
from agent.runtime_cwd import resolve_agent_cwd
from agent.skill_utils import (
EXCLUDED_SKILL_DIRS,
SKILL_SUPPORT_DIRS,
extract_skill_conditions,
extract_skill_description,
get_all_skills_dirs,
@ -25,6 +28,7 @@ from agent.skill_utils import (
parse_frontmatter,
skill_matches_environment,
skill_matches_platform,
skill_matches_platform_list,
)
from utils import atomic_json_write
@ -655,19 +659,7 @@ PLATFORM_HINTS = {
"Standard Markdown is automatically converted to Telegram formatting. "
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
"`inline code`, ```code blocks```, [links](url), and ## headers. "
"Telegram now supports rich Markdown, so lean into it: whenever it "
"makes the answer clearer or easier to scan, actively reach for real "
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
"collapsible details, footnotes/references, math/formulas (`$...$`, "
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
"text, and anchors. Default to structured formatting over dense "
"paragraphs for any comparison, set of steps, key/value summary, or "
"tabular data. Prefer real Markdown tables and task lists over "
"hand-built bullet substitutes when presenting structured data; these "
"degrade gracefully (tables become readable bullet groups) when rich "
"rendering is unavailable, but advanced constructs like math and "
"collapsible details may render as plain source text in that case. "
"Prefer bullet lists and labeled key:value pairs for structured data. "
"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 (.ogg) sends as voice "
@ -861,6 +853,27 @@ PLATFORM_HINTS = {
),
}
# Telegram rich-messages extension — only injected when the user has opted in
# to ``platforms.telegram.extra.rich_messages: true``. The base
# PLATFORM_HINTS["telegram"] covers MarkdownV2-compatible constructs; this
# extension adds the Bot API 10.1 rich-Markdown guidance (tables, task lists,
# collapsible details, math, etc.).
TELEGRAM_RICH_MESSAGES_HINT = (
"Telegram now supports rich Markdown, so lean into it: whenever it "
"makes the answer clearer or easier to scan, actively reach for real "
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
"collapsible details, footnotes/references, math/formulas (`$...$`, "
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
"text, and anchors. Default to structured formatting over dense "
"paragraphs for any comparison, set of steps, key/value summary, or "
"tabular data. Prefer real Markdown tables and task lists over "
"hand-built bullet substitutes when presenting structured data; these "
"degrade gracefully (tables become readable bullet groups) when rich "
"rendering is unavailable, but advanced constructs like math and "
"collapsible details may render as plain source text in that case. "
)
# ---------------------------------------------------------------------------
# Environment hints — execution-environment awareness for the agent.
# Unlike PLATFORM_HINTS (which describe the messaging channel), these describe
@ -1271,13 +1284,26 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
manifest: dict[str, list[int]] = {}
for filename in ("SKILL.md", "DESCRIPTION.md"):
for path in iter_skill_index_files(skills_dir, filename):
skills_dir_str = str(skills_dir)
base = os.path.join(skills_dir_str, "")
prefix_len = len(base)
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
for d in dirs
if d not in EXCLUDED_SKILL_DIRS
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
for filename in ("SKILL.md", "DESCRIPTION.md"):
if filename not in files:
continue
path = os.path.join(root, filename)
try:
st = path.stat()
st = os.stat(path)
except OSError:
continue
manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size]
manifest[path[prefix_len:]] = [st.st_mtime_ns, st.st_size]
return manifest
@ -1409,6 +1435,22 @@ def _skill_should_show(
return True
def _current_session_platform_hint() -> str:
"""Return the active platform without importing the gateway package on CLI startup."""
platform = os.environ.get("HERMES_PLATFORM") or os.environ.get("HERMES_SESSION_PLATFORM")
if platform:
return platform
session_context = sys.modules.get("gateway.session_context")
get_session_env = getattr(session_context, "get_session_env", None) if session_context else None
if get_session_env is None:
return ""
try:
return get_session_env("HERMES_SESSION_PLATFORM") or ""
except Exception:
return ""
def build_skills_system_prompt(
available_tools: "set[str] | None" = None,
available_toolsets: "set[str] | None" = None,
@ -1443,15 +1485,10 @@ def build_skills_system_prompt(
# ── Layer 1: in-process LRU cache ─────────────────────────────────
# Include the resolved platform so per-platform disabled-skill lists
# produce distinct cache entries (gateway serves multiple platforms).
from gateway.session_context import get_session_env
_platform_hint = (
os.environ.get("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
or ""
)
_platform_hint = _current_session_platform_hint()
disabled = get_disabled_skill_names(_platform_hint or None)
cache_key = (
str(skills_dir.resolve()),
str(skills_dir),
tuple(str(d) for d in external_dirs),
tuple(sorted(str(t) for t in (available_tools or set()))),
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
@ -1480,7 +1517,7 @@ def build_skills_system_prompt(
category = entry.get("category") or "general"
frontmatter_name = entry.get("frontmatter_name") or skill_name
platforms = entry.get("platforms") or []
if not skill_matches_platform({"platforms": platforms}):
if not skill_matches_platform_list(platforms):
continue
if frontmatter_name in disabled or skill_name in disabled:
continue

56
agent/reactions.py Normal file
View file

@ -0,0 +1,56 @@
"""Token-free detection of user *reactions* to the agent.
Currently the only reaction is ``vibe`` an expression of affection or
gratitude toward the agent (``ily``, ``<3``, ``love you``, ``good bot``, a heart
emoji, ). Detection is a curated regex/lexicon: **no model call, no tokens**.
This is the single source of truth shared by every surface the CLI pet, the
TUI heart, and the desktop floating hearts all react off the same signal,
delivered via ``AIAgent.reaction_callback`` (wired per interactive host).
Generalized on purpose: :func:`detect_reaction` returns a reaction *kind*
string, so new kinds (other emoji reactions, etc.) can be added here without
touching any caller. We match affection specifically not general positive
sentiment so "this is great" does NOT fire, but "good bot" / "❤️" do.
"""
from __future__ import annotations
import re
#: The affection/gratitude reaction — the only kind today.
VIBE = "vibe"
# Curated affection lexicon. Kept deliberately narrow: gratitude + love aimed at
# the agent, heart emoji, and ``<3`` (but not the broken heart ``</3``).
_VIBE_RE = re.compile(
"|".join(
(
r"\bgood\s*bot\b",
r"\bi\s*(?:love|luv)\s*(?:you|u|ya)\b",
r"\b(?:love|luv)\s*(?:you|u|ya)\b",
r"\bily(?:sm)?\b",
r"\bthank\s*(?:you|u)\b",
r"\b(?:thanks|thx|tysm|ty)\b",
r"<3+", # <3, <33 … but not </3
# Hearts + affection faces (❤ ♥ 🥰 😍 😘 💕 💖 💗 💞 💛 💜 💚 💙 💓 💘 💝 🩷).
r"[\u2764\u2665"
r"\U0001F970\U0001F60D\U0001F618"
r"\U0001F495\U0001F496\U0001F497\U0001F49E"
r"\U0001F49B\U0001F49C\U0001F49A\U0001F499"
r"\U0001F493\U0001F498\U0001F49D\U0001FA77]",
)
),
re.IGNORECASE,
)
def detect_reaction(text: str | None) -> str | None:
"""Return the reaction kind for *text* (currently :data:`VIBE`), or ``None``.
Pure, token-free, and safe to call on every user turn.
"""
if not text:
return None
return VIBE if _VIBE_RE.search(text) else None

View file

@ -66,9 +66,13 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
("nemotron-3-ultra", 600),
("nemotron-3-super", 600),
("nemotron-3-nano", 300),
# DeepSeek — R1 reasoning model on hosted NIM / DeepSeek direct.
# DeepSeek — R1 and V4 reasoning models on hosted NIM / DeepSeek direct.
# V4 series emits reasoning_content in a separate delta field before
# final content, requiring the same extended stale timeout floor.
("deepseek-r1", 600),
("deepseek-reasoner", 600),
("deepseek-v4-flash", 600),
("deepseek-v4-pro", 600),
# Qwen — QwQ reasoning + Qwen3 thinking variants. QwQ-32B
# preview is the stable slug; ``qwen3`` covers the family of
# thinking-mode Qwen3 models (qwen3-235b-a22b, qwen3-32b, etc.)
@ -190,6 +194,10 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:
300.0
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-r1")
600.0
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-flash")
600.0
>>> get_reasoning_stale_timeout_floor("deepseek/deepseek-v4-pro")
600.0
>>> get_reasoning_stale_timeout_floor("qwen/qwen3-235b-a22b-thinking")
180.0
>>> get_reasoning_stale_timeout_floor("x-ai/grok-4-fast-reasoning")

View file

@ -20,6 +20,9 @@ from __future__ import annotations
import logging
from typing import Any, Dict, List
from agent.tool_dispatch_helpers import make_tool_result_message
from agent.tool_result_classification import tool_may_have_side_effect
logger = logging.getLogger(__name__)
@ -64,8 +67,40 @@ def strip_interrupted_tool_tails(
is_interrupted_tool_result(m.get("content", ""))
for m in tool_results
):
calls = msg.get("tool_calls") or []
if any(
tool_may_have_side_effect(
str((call.get("function") or {}).get("name") or "")
)
for call in calls
):
call_names = {
str(call.get("id") or call.get("call_id") or ""): str(
(call.get("function") or {}).get("name") or ""
)
for call in calls
}
cleaned.append(msg)
for tool_result in tool_results:
if not is_interrupted_tool_result(tool_result.get("content", "")):
cleaned.append(tool_result)
continue
recovered = dict(tool_result)
name = call_names.get(str(tool_result.get("tool_call_id") or ""), "")
recovered["effect_disposition"] = (
"unknown" if tool_may_have_side_effect(name) else "none"
)
recovered["content"] = (
"[Orphan recovery: interrupted side-effecting tool may have "
"executed; its effect is UNKNOWN. Inspect state before retrying.]"
if recovered["effect_disposition"] == "unknown"
else "[Orphan recovery: interrupted read-only tool did not complete.]"
)
cleaned.append(recovered)
i = j
continue
logger.debug(
"Stripping interrupted assistant→tool replay block "
"Stripping interrupted read-only assistant→tool replay block "
"(indices %d%d, tool_results=%d)",
i, j - 1, len(tool_results),
)
@ -116,11 +151,36 @@ def strip_dangling_tool_call_tail(
):
return agent_history
tool_calls = last.get("tool_calls") or []
if any(
tool_may_have_side_effect(
str((call.get("function") or {}).get("name") or "")
)
for call in tool_calls
):
recovered = list(agent_history)
for call in tool_calls:
function = call.get("function") or {}
name = str(function.get("name") or "unknown")
call_id = str(call.get("id") or call.get("call_id") or "")
disposition = "unknown" if tool_may_have_side_effect(name) else "none"
content = (
"[Orphan recovery: this tool may have executed before Hermes stopped; "
"its effect is UNKNOWN. Inspect current state before retrying.]"
if disposition == "unknown"
else "[Orphan recovery: this read-only tool did not complete and had no effect.]"
)
recovered.append(make_tool_result_message(
name, content, call_id, effect_disposition=disposition,
))
logger.warning(
"Recovered dangling side-effecting tool call(s) as UNKNOWN instead of erasing them"
)
return recovered
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 []),
"Stripping dangling unanswered read-only assistant(tool_calls) tail (%d call(s))",
len(tool_calls),
)
return agent_history[:-1]

View file

@ -329,6 +329,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
try:
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names
from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files
from hermes_cli.commands import resolve_command
disabled = _get_disabled_skill_names()
seen_names: set = set()
@ -374,7 +375,32 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
cmd_name = _SKILL_MULTI_HYPHEN.sub('-', cmd_name).strip('-')
if not cmd_name:
continue
_skill_commands[f"/{cmd_name}"] = {
# Skip if this skill's auto-generated /command collides
# with a core Hermes slash command (name or alias). The
# skill remains fully loadable via /skill <name>.
# Uses resolve_command() so aliases and case variants are
# covered without maintaining a separate cache.
if resolve_command(cmd_name) is not None:
logger.warning(
"Skill %r generates slash command '/%s' which "
"collides with a core Hermes command; skipping "
"auto-registration. Use '/skill %s' instead.",
name, cmd_name, name,
)
continue
# Dedup on the resolved slug, not just the raw name: two
# distinct frontmatter names can normalize to the same
# slug (e.g. "git_helper" vs "git-helper"). First-wins
# preserves local-before-external precedence.
cmd_key = f"/{cmd_name}"
if cmd_key in _skill_commands:
logger.warning(
"Skill %r maps to slash command %s already claimed "
"by %r; keeping the first and skipping this one.",
name, cmd_key, _skill_commands[cmd_key]["name"],
)
continue
_skill_commands[cmd_key] = {
"name": name,
"description": description or f"Invoke the {name} skill",
"skill_md_path": str(skill_md),

View file

@ -160,27 +160,8 @@ def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
# ── Platform matching ─────────────────────────────────────────────────────
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is compatible with the current OS.
Skills declare platform requirements via a top-level ``platforms`` list
in their YAML frontmatter::
platforms: [macos] # macOS only
platforms: [macos, linux] # macOS and Linux
If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
Linux userland riding on the Android kernel, so skills tagged
``linux`` are treated as compatible in Termux regardless of which
``sys.platform`` value Python reports. Individual Linux commands
inside a skill may still misbehave (no systemd, BusyBox utils, no
apt/dnf, etc.) but that is on the skill, not on platform gating.
"""
platforms = frontmatter.get("platforms")
def skill_matches_platform_list(platforms: Any) -> bool:
"""Return True when *platforms* is compatible with the current OS."""
if not platforms:
return True
if not isinstance(platforms, list):
@ -204,6 +185,29 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
return False
def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is compatible with the current OS.
Skills declare platform requirements via a top-level ``platforms`` list
in their YAML frontmatter::
platforms: [macos] # macOS only
platforms: [macos, linux] # macOS and Linux
If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).
Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
Linux userland riding on the Android kernel, so skills tagged
``linux`` are treated as compatible in Termux regardless of which
``sys.platform`` value Python reports. Individual Linux commands
inside a skill may still misbehave (no systemd, BusyBox utils, no
apt/dnf, etc.) but that is on the skill, not on platform gating.
"""
return skill_matches_platform_list(frontmatter.get("platforms"))
# ── Environment matching ──────────────────────────────────────────────────
# Recognized environment tags and how each is detected. An environment tag is
@ -787,8 +791,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
"""
matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True):
skills_dir_str = str(skills_dir)
matches: list[str] = []
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
@ -797,9 +802,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
if filename in files:
matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
yield path
matches.append(os.path.join(root, filename))
for path in sorted(matches):
yield Path(path)
# ── Namespace helpers for plugin-provided skills ───────────────────────────

View file

@ -40,6 +40,7 @@ from agent.prompt_builder import (
SKILLS_GUIDANCE,
STEER_CHANNEL_NOTE,
TASK_COMPLETION_GUIDANCE,
TELEGRAM_RICH_MESSAGES_HINT,
TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS,
drain_truncation_warnings,
@ -429,6 +430,20 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
except Exception:
pass
# For Telegram: append the rich-messages extension only when the user has
# opted in to ``platforms.telegram.extra.rich_messages: true``. The base
# hint covers MarkdownV2-compatible constructs; the extension adds Bot API
# 10.1 guidance (tables, task lists, math, collapsible details, etc.).
if platform_key == "telegram" and _default_hint:
try:
from hermes_cli.config import load_config_readonly
_cfg = load_config_readonly()
_tg_extra = ((_cfg.get("platforms") or {}).get("telegram") or {}).get("extra") or {}
if _tg_extra.get("rich_messages"):
_default_hint = _default_hint.rstrip() + " " + TELEGRAM_RICH_MESSAGES_HINT
except Exception:
pass # Config read failure — fall back to base hint only
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
if platform_key == "tui" and _effective_hint:
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)

View file

@ -208,19 +208,29 @@ class StreamingThinkScrubber:
discarded leaking partial reasoning is worse than a
truncated answer. Otherwise the held-back partial-tag tail is
emitted verbatim (it turned out not to be a real tag prefix).
Always treats the next ``feed()`` as a fresh stream boundary.
Intra-turn retries (thinking-only prefill, empty-response
retry) flush then stream again without calling ``reset()``;
leaving ``_last_emitted_ended_newline`` False made a new
stream's opening ``<think>`` look mid-line and leak into the
visible reply.
"""
if self._in_block:
self._buf = ""
self._in_block = False
# Next feed() is a new stream — start-of-stream is a boundary.
self._last_emitted_ended_newline = True
return ""
tail = self._buf
self._buf = ""
# Same for the non-block path: do NOT derive the boundary flag
# from the flushed tail (e.g. a held-back '<'). End-of-stream
# means the next feed() starts a new model response.
self._last_emitted_ended_newline = True
if not tail:
return ""
tail = self._strip_orphan_close_tags(tail)
if tail:
self._last_emitted_ended_newline = tail.endswith("\n")
return tail
return self._strip_orphan_close_tags(tail)
# ── internal helpers ───────────────────────────────────────────────

View file

@ -34,6 +34,7 @@ from typing import Any, Dict, List, Optional
from agent.tool_result_classification import (
FILE_MUTATING_TOOL_NAMES as _FILE_MUTATING_TOOLS,
)
from tools.threat_patterns import scan_for_threats
logger = logging.getLogger(__name__)
@ -101,50 +102,119 @@ def _is_mcp_tool_parallel_safe(tool_name: str) -> bool:
return False
def _should_parallelize_tool_batch(tool_calls) -> bool:
"""Return True when a tool-call batch is safe to run concurrently."""
if len(tool_calls) <= 1:
return False
def _plan_tool_batch_segments(tool_calls) -> List[tuple]:
"""Split a tool-call batch into ordered ``(kind, calls)`` segments.
tool_names = [tc.function.name for tc in tool_calls]
if any(name in _NEVER_PARALLEL_TOOLS for name in tool_names):
return False
``kind`` is ``"parallel"`` (a maximal contiguous run of parallel-safe
calls) or ``"sequential"`` (one or more barrier calls that must run
in-order on the sequential path). Segments preserve the model's
original call order exactly a later call never crosses an earlier
barrier so tool-result ordering and side-effect boundaries are
identical to fully-sequential execution. The per-call safety rules
are the same ones the old all-or-nothing gate applied to the whole
batch:
* ``_NEVER_PARALLEL_TOOLS`` (interactive tools) barrier.
* Unparseable / non-dict arguments barrier.
* Path-scoped tools (``read_file``/``write_file``/``patch``) join a
parallel run only when their target path does not overlap another
path already reserved in the same run; an overlap closes the run so
the conflicting call starts a NEW run after the first completes.
* Anything not in ``_PARALLEL_SAFE_TOOLS`` and not an opted-in MCP
tool barrier.
Parallel runs shorter than two calls are demoted to sequential (no
concurrency win, and the sequential executor owns the richer inline
dispatch), and adjacent sequential segments are merged.
"""
segments: list[list] = [] # [kind, calls] pairs, normalized to tuples on return
current: list = []
reserved_paths: list[Path] = []
def _close_parallel() -> None:
nonlocal current, reserved_paths
if current:
segments.append(["parallel", current])
current = []
reserved_paths = []
def _add_sequential(tc) -> None:
_close_parallel()
if segments and segments[-1][0] == "sequential":
segments[-1][1].append(tc)
else:
segments.append(["sequential", [tc]])
for tool_call in tool_calls:
tool_name = tool_call.function.name
if tool_name in _NEVER_PARALLEL_TOOLS:
_add_sequential(tool_call)
continue
try:
function_args = json.loads(tool_call.function.arguments)
except Exception:
_raw = tool_call.function.arguments
logging.debug(
"Could not parse args for %s — defaulting to sequential; raw=%s",
"Could not parse args for %streating as sequential barrier; raw=%s",
tool_name,
tool_call.function.arguments[:200],
_raw[:200] if isinstance(_raw, str) else repr(_raw)[:200],
)
return False
_add_sequential(tool_call)
continue
if not isinstance(function_args, dict):
logging.debug(
"Non-dict args for %s (%s) — defaulting to sequential",
"Non-dict args for %s (%s) — treating as sequential barrier",
tool_name,
type(function_args).__name__,
)
return False
_add_sequential(tool_call)
continue
if tool_name in _PATH_SCOPED_TOOLS:
scoped_path = _extract_parallel_scope_path(tool_name, function_args)
if scoped_path is None:
return False
_add_sequential(tool_call)
continue
if any(_paths_overlap(scoped_path, existing) for existing in reserved_paths):
return False
# Same-subtree conflict inside this run: close it so this
# call starts a fresh run AFTER the conflicting one lands.
_close_parallel()
reserved_paths.append(scoped_path)
current.append(tool_call)
continue
if tool_name not in _PARALLEL_SAFE_TOOLS:
# Check if it's an MCP tool from a server that opted into parallel calls.
if not _is_mcp_tool_parallel_safe(tool_name):
return False
if tool_name in _PARALLEL_SAFE_TOOLS or _is_mcp_tool_parallel_safe(tool_name):
current.append(tool_call)
continue
return True
_add_sequential(tool_call)
_close_parallel()
normalized: list[list] = []
for kind, calls in segments:
if kind == "parallel" and len(calls) < 2:
kind = "sequential"
if normalized and normalized[-1][0] == "sequential" and kind == "sequential":
normalized[-1][1].extend(calls)
else:
normalized.append([kind, calls])
return [(kind, calls) for kind, calls in normalized]
def _should_parallelize_tool_batch(tool_calls) -> bool:
"""Return True when the WHOLE tool-call batch is safe to run concurrently.
Thin view over ``_plan_tool_batch_segments`` kept for callers/tests that
only care about the homogeneous case: True iff the planner produces a
single all-parallel segment.
"""
if len(tool_calls) <= 1:
return False
segments = _plan_tool_batch_segments(tool_calls)
return len(segments) == 1 and segments[0][0] == "parallel"
def _extract_parallel_scope_path(tool_name: str, function_args: dict) -> Optional[Path]:
@ -358,7 +428,13 @@ def _trajectory_normalize_msg(msg: Dict[str, Any]) -> Dict[str, Any]:
return msg
def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict:
def make_tool_result_message(
name: str,
content: Any,
tool_call_id: str,
*,
effect_disposition: str | None = None,
) -> dict:
"""Build a tool-result message dict with both the OpenAI-format ``name``
field (required by the wire format and provider adapters) and the internal
``tool_name`` field (written to the session DB messages table).
@ -379,13 +455,23 @@ def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict
callers should compare by value, not by ``is``.
"""
wrapped = _maybe_wrap_untrusted(name, content)
return {
message = {
"role": "tool",
"name": name,
"tool_name": name,
"content": wrapped,
"tool_call_id": tool_call_id,
}
try:
risk_metadata = _tool_output_risk_metadata(name, content)
except Exception as exc:
logger.debug("Tool output risk scan failed for %s: %s", name, exc)
else:
if risk_metadata is not None:
message["_tool_output_risk"] = risk_metadata
if effect_disposition is not None:
message["effect_disposition"] = effect_disposition
return message
# Tools whose results carry attacker-controllable content. Wrapping their
@ -419,6 +505,42 @@ def _is_untrusted_tool(name: Optional[str]) -> bool:
return any(name.startswith(p) for p in _UNTRUSTED_TOOL_PREFIXES)
def _tool_output_risk_metadata(name: str, content: Any) -> Optional[Dict[str, Any]]:
"""Classify textual attacker-controlled output without retaining a copy.
The advisory metadata is internal-only. It records deterministic finding
identifiers, never blocks or redacts the normal result, and deliberately
omits raw scanned text.
"""
if not _is_untrusted_tool(name):
return None
if isinstance(content, str):
text_parts = [content]
elif isinstance(content, list):
text_parts = [
item["text"]
for item in content
if isinstance(item, dict)
and item.get("type") == "text"
and isinstance(item.get("text"), str)
]
if not text_parts:
return None
else:
return None
findings: List[str] = []
for text in text_parts:
for finding in scan_for_threats(text, scope="context"):
if finding not in findings:
findings.append(finding)
return {
"risk": "high" if findings else "low",
"findings": findings,
"redacted": False,
}
def _neutralize_delimiters(content: str) -> str:
"""Defang any literal ``untrusted_tool_result`` delimiter embedded in
attacker-controlled content so it can't break out of the wrapper.
@ -489,6 +611,7 @@ __all__ = [
"_DESTRUCTIVE_PATTERNS",
"_REDIRECT_OVERWRITE",
"_is_destructive_command",
"_plan_tool_batch_segments",
"_should_parallelize_tool_batch",
"_extract_parallel_scope_path",
"_paths_overlap",

View file

@ -36,6 +36,7 @@ from agent.tool_dispatch_helpers import (
_is_multimodal_tool_result,
_multimodal_text_summary,
_append_subdir_hint_to_multimodal,
_plan_tool_batch_segments,
make_tool_result_message,
)
from tools.terminal_tool import (
@ -74,6 +75,25 @@ _MAX_TOOL_WORKERS = 8
_DEFAULT_CONCURRENT_TOOL_TIMEOUT_S = 420.0
def _parse_tool_arguments(raw_arguments: Any) -> tuple[dict, Optional[str]]:
"""Parse model-emitted arguments without repairing or coercing them."""
try:
arguments = json.loads(raw_arguments)
except (json.JSONDecodeError, TypeError):
arguments = None
if isinstance(arguments, dict):
return arguments, None
return {}, json.dumps(
{
"error": "Invalid tool arguments",
"message": (
"Tool arguments must be a valid JSON object; tool was not executed."
),
},
ensure_ascii=False,
)
def _resolve_concurrent_tool_timeout() -> float | None:
raw = os.getenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "").strip()
if not raw:
@ -303,11 +323,15 @@ def _run_agent_tool_execution_middleware(
return result, observed_args
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None:
"""Execute multiple tool calls concurrently using a thread pool.
Results are collected in the original tool-call order and appended to
messages so the API sees them in the expected sequence.
``finalize=False`` skips the end-of-batch aggregate budget enforcement
and /steer injection used when this call is one segment of a larger
mixed batch and the segmented dispatcher owns the turn-end work.
"""
tool_calls = assistant_message.tool_calls
num_tools = len(tool_calls)
@ -324,6 +348,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
tc.function.name,
f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
tc.id,
effect_disposition="none",
))
_flush_session_db_after_tool_progress(
agent,
@ -337,19 +362,29 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
for tool_call in tool_calls:
function_name = tool_call.function.name
# Reset nudge counters
function_args, malformed_args_result = _parse_tool_arguments(
tool_call.function.arguments
)
if malformed_args_result is not None:
parsed_calls.append(
(
tool_call,
function_name,
function_args,
[],
malformed_args_result,
False,
)
)
continue
# Reset nudge counters only for a structurally valid invocation.
if function_name == "memory":
agent._turns_since_memory = 0
elif function_name == "skill_manage":
agent._iters_since_skill = 0
try:
function_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError:
function_args = {}
if not isinstance(function_args, dict):
function_args = {}
# ── Tool Search unwrap ────────────────────────────────────────
# When the model invokes the tool_call bridge, peel it open so
# every downstream check (checkpointing, guardrails, plugin
@ -798,9 +833,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# deadline snapshot (timed_out_indices, taken from not_done) and this
# loop. Prefer that real result over a fabricated timeout message — the
# tool genuinely succeeded, just slightly late.
effect_disposition = None
if i in timed_out_indices and r is None:
suffix = f"{timeout_s:.1f}s" if timeout_s is not None else "the configured timeout"
function_result = f"Error executing tool '{name}': timed out after {suffix}"
effect_disposition = "unknown"
_emit_terminal_post_tool_call(
agent,
function_name=name,
@ -847,6 +884,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
tool_duration = 0.0
else:
function_name, function_args, function_result, tool_duration, is_error, blocked, middleware_trace = r
if blocked:
effect_disposition = "none"
if not blocked:
function_result = agent._append_guardrail_observation(
@ -935,7 +974,30 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# image tool result never poisons canonical session history.
# String results pass through unchanged.
_tool_content = agent._tool_result_content_for_active_model(name, function_result)
messages.append(make_tool_result_message(name, _tool_content, tc.id))
tool_message = make_tool_result_message(
name,
_tool_content,
tc.id,
effect_disposition=effect_disposition,
)
messages.append(tool_message)
risk_metadata = tool_message.get("_tool_output_risk")
if (
risk_metadata is not None
and risk_metadata.get("risk") != "low"
and agent.tool_progress_callback
):
try:
agent.tool_progress_callback(
"tool.output_risk",
name,
None,
None,
tool_call_id=tc.id,
risk_metadata=risk_metadata,
)
except Exception as cb_err:
logging.debug("Tool output risk callback error: %s", cb_err)
_flush_session_db_after_tool_progress(
agent,
messages,
@ -949,7 +1011,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# ── Per-turn aggregate budget enforcement ─────────────────────────
num_tools = len(parsed_calls)
if num_tools > 0:
if finalize and num_tools > 0:
turn_tool_msgs = messages[-num_tools:]
enforce_turn_budget(turn_tool_msgs, env=get_active_env(effective_task_id), config=_tool_budget)
@ -957,13 +1019,18 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# Append any pending user steer text to the last tool result so the
# agent sees it on its next iteration. Runs AFTER budget enforcement
# so the steer marker is never truncated. See steer() for details.
if num_tools > 0:
if finalize and num_tools > 0:
agent._apply_pending_steer_to_tool_results(messages, num_tools)
def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
"""Execute tool calls sequentially (original behavior). Used for single calls or interactive tools."""
def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None:
"""Execute tool calls sequentially (original behavior). Used for single calls or interactive tools.
``finalize=False`` skips the end-of-batch aggregate budget enforcement
and /steer injection used when this call is one segment of a larger
mixed batch and the segmented dispatcher owns the turn-end work.
"""
# Resolve the context-scaled tool-output budget once per turn.
_tool_budget = _budget_for_agent(agent)
for i, tool_call in enumerate(assistant_message.tool_calls, 1):
@ -980,6 +1047,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
skipped_name,
f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
skipped_tc.id,
effect_disposition="none",
))
_flush_session_db_after_tool_progress(
agent,
@ -990,13 +1058,24 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
function_name = tool_call.function.name
try:
function_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as e:
logger.warning(f"Unexpected JSON error after validation: {e}")
function_args = {}
if not isinstance(function_args, dict):
function_args = {}
function_args, malformed_args_result = _parse_tool_arguments(
tool_call.function.arguments
)
if malformed_args_result is not None:
messages.append(
make_tool_result_message(
function_name,
malformed_args_result,
tool_call.id,
)
)
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"invalid tool arguments {function_name}",
)
agent._apply_pending_steer_to_tool_results(messages, 1)
continue
# Tool Search unwrap — see execute_tool_calls_concurrent for full
# rationale, including the scope gate (the unwrap dispatches the
@ -1584,7 +1663,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# Unwrap _multimodal dicts to an OpenAI-style content list
# (see parallel path for rationale). String results pass through.
_tool_content = agent._tool_result_content_for_active_model(function_name, function_result)
messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id))
tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id)
messages.append(tool_message)
risk_metadata = tool_message.get("_tool_output_risk")
if (
risk_metadata is not None
and risk_metadata.get("risk") != "low"
and agent.tool_progress_callback
):
try:
agent.tool_progress_callback(
"tool.output_risk",
function_name,
None,
None,
tool_call_id=tool_call.id,
risk_metadata=risk_metadata,
)
except Exception as cb_err:
logging.debug("Tool output risk callback error: %s", cb_err)
_flush_session_db_after_tool_progress(
agent,
messages,
@ -1615,6 +1712,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
skipped_name,
f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
skipped_tc.id,
effect_disposition="none",
))
_flush_session_db_after_tool_progress(
agent,
@ -1628,19 +1726,73 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# ── Per-turn aggregate budget enforcement ─────────────────────────
num_tools_seq = len(assistant_message.tool_calls)
if num_tools_seq > 0:
if finalize and num_tools_seq > 0:
enforce_turn_budget(messages[-num_tools_seq:], env=get_active_env(effective_task_id), config=_tool_budget)
# ── /steer injection ──────────────────────────────────────────────
# See _execute_tool_calls_parallel for the rationale. Same hook,
# applied to sequential execution as well.
if num_tools_seq > 0:
if finalize and num_tools_seq > 0:
agent._apply_pending_steer_to_tool_results(messages, num_tools_seq)
def execute_tool_calls_segmented(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, segments=None) -> None:
"""Execute a mixed tool-call batch as ordered parallel/sequential segments.
``segments`` is the ``(kind, calls)`` plan from
``_plan_tool_batch_segments``: maximal contiguous runs of parallel-safe
calls execute on the concurrent path, barrier calls on the sequential
path, strictly in the model's original call order. Because segments are
contiguous, every tool result is still appended one-per-call in emission
order and no call ever starts before an earlier barrier finishes
identical ordering and side-effect boundaries to fully-sequential
execution, with I/O parallelism recovered inside the safe runs.
Turn-end work (aggregate budget enforcement + /steer injection) is done
once here for the WHOLE batch; the per-segment executor calls run with
``finalize=False`` so a multi-segment turn cannot multiply the budget or
truncate a steer marker.
Interrupt semantics: each segment executor already checks
``agent._interrupt_requested`` up front and appends a cancelled/skipped
result per call, so an interrupt during segment *k* drains segments
*k+1..n* without executing them while preserving one result per
tool_call_id.
"""
from types import SimpleNamespace
if segments is None:
segments = _plan_tool_batch_segments(assistant_message.tool_calls)
for kind, calls in segments:
segment_message = SimpleNamespace(tool_calls=list(calls))
if kind == "parallel":
execute_tool_calls_concurrent(
agent, segment_message, messages, effective_task_id, api_call_count,
finalize=False,
)
else:
execute_tool_calls_sequential(
agent, segment_message, messages, effective_task_id, api_call_count,
finalize=False,
)
# ── Whole-turn finalize (budget + /steer) ─────────────────────────
total_tools = len(assistant_message.tool_calls)
if total_tools > 0:
_tool_budget = _budget_for_agent(agent)
enforce_turn_budget(
messages[-total_tools:],
env=get_active_env(effective_task_id),
config=_tool_budget,
)
agent._apply_pending_steer_to_tool_results(messages, total_tools)
__all__ = [
"execute_tool_calls_concurrent",
"execute_tool_calls_sequential",
"execute_tool_calls_segmented",
]

View file

@ -9,6 +9,20 @@ from typing import Any
FILE_MUTATING_TOOL_NAMES = frozenset({"write_file", "patch"})
# Tools whose interrupted/dangling execution is safe to discard because they
# cannot mutate either external state or Hermes session state. Unknown/plugin/
# MCP tools stay effect-capable by default.
NO_EFFECT_TOOL_NAMES = frozenset({
"read_file", "search_files", "session_search", "skill_view", "skills_list",
"web_extract", "web_search", "vision_analyze", "browser_snapshot",
"browser_get_images", "browser_console", "read_terminal",
})
def tool_may_have_side_effect(tool_name: str) -> bool:
return tool_name not in NO_EFFECT_TOOL_NAMES
def file_mutation_result_landed(tool_name: str, result: Any) -> bool:
"""Return True when a file mutation result proves the write landed."""
if tool_name not in FILE_MUTATING_TOOL_NAMES or not isinstance(result, str):

View file

@ -44,6 +44,8 @@ _BUILTIN_NAMES = frozenset({
"openai",
"mistral",
"xai",
"elevenlabs",
"deepinfra",
})

View file

@ -9,7 +9,6 @@ which has provider-specific conditionals for max_tokens defaults,
reasoning configuration, temperature handling, and extra_body assembly.
"""
import copy
from typing import Any, Dict
from agent.lmstudio_reasoning import resolve_lmstudio_effort
@ -19,6 +18,20 @@ from agent.transports.base import ProviderTransport
from agent.transports.types import NormalizedResponse, ToolCall, Usage
def _reasoning_config_for_model(model: str, reasoning_config: dict | None) -> dict | None:
"""Return the model's wire-compatible reasoning config."""
if not isinstance(reasoning_config, dict):
return reasoning_config
if (
"gpt-5.6" in (model or "").lower()
and str(reasoning_config.get("effort") or "").strip().lower() == "ultra"
):
normalized = dict(reasoning_config)
normalized["effort"] = "max"
return normalized
return reasoning_config
def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) -> dict | None:
"""Translate Hermes/OpenRouter-style reasoning config to Gemini thinkingConfig."""
if reasoning_config is None or not isinstance(reasoning_config, dict):
@ -53,7 +66,7 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
if normalized_model.startswith("gemini-2.5-"):
return thinking_config
if effort not in {"minimal", "low", "medium", "high", "xhigh"}:
if effort not in {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"}:
effort = "medium"
# Gemini 3 Flash documents low/medium/high thinking levels; Gemini 3 Pro
@ -63,13 +76,13 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
if "flash" in normalized_model:
if effort in {"minimal", "low"}:
thinking_config["thinkingLevel"] = "low"
elif effort in {"high", "xhigh"}:
elif effort in {"high", "xhigh", "max", "ultra"}:
thinking_config["thinkingLevel"] = "high"
else:
thinking_config["thinkingLevel"] = "medium"
elif "pro" in normalized_model:
thinking_config["thinkingLevel"] = (
"high" if effort in {"high", "xhigh"} else "low"
"high" if effort in {"high", "xhigh", "max", "ultra"} else "low"
)
return thinking_config
@ -172,6 +185,7 @@ class ChatCompletionsTransport(ProviderTransport):
"codex_reasoning_items" in msg
or "codex_message_items" in msg
or "tool_name" in msg
or "effect_disposition" in msg
or "timestamp" in msg # #47868 — strict providers reject this
):
needs_sanitize = True
@ -195,27 +209,65 @@ class ChatCompletionsTransport(ProviderTransport):
if not needs_sanitize:
return messages
sanitized = copy.deepcopy(messages)
for msg in sanitized:
sanitized = list(messages)
for msg_idx, msg in enumerate(messages):
if not isinstance(msg, dict):
continue
msg.pop("codex_reasoning_items", None)
msg.pop("codex_message_items", None)
msg.pop("tool_name", None)
msg.pop("timestamp", None) # #47868 — leak into strict providers
copied_msg: dict[str, Any] | None = None
def mutable_msg() -> dict[str, Any]:
nonlocal copied_msg
if copied_msg is None:
copied_msg = dict(msg)
sanitized[msg_idx] = copied_msg
return copied_msg
if (
"codex_reasoning_items" in msg
or "codex_message_items" in msg
or "tool_name" in msg
or "effect_disposition" in msg
or "timestamp" in msg # #47868 — leak into strict providers
):
out_msg = mutable_msg()
out_msg.pop("codex_reasoning_items", None)
out_msg.pop("codex_message_items", None)
out_msg.pop("tool_name", None)
out_msg.pop("effect_disposition", None)
out_msg.pop("timestamp", None) # #47868 — leak into strict providers
# Drop all Hermes-internal scaffolding markers (``_``-prefixed).
# OpenAI's message schema has no ``_``-prefixed fields, so this
# is safe and future-proofs against new markers being added.
for key in [k for k in msg if isinstance(k, str) and k.startswith("_")]:
msg.pop(key, None)
internal_keys = [k for k in msg if isinstance(k, str) and k.startswith("_")]
if internal_keys:
out_msg = mutable_msg()
for key in internal_keys:
out_msg.pop(key, None)
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
for tc in tool_calls:
copied_tool_calls: list[Any] | None = None
for tc_idx, tc in enumerate(tool_calls):
if isinstance(tc, dict):
tc.pop("call_id", None)
tc.pop("response_item_id", None)
if strip_extra_content:
tc.pop("extra_content", None)
should_copy_tc = (
"call_id" in tc
or "response_item_id" in tc
or (strip_extra_content and "extra_content" in tc)
)
if should_copy_tc:
if copied_tool_calls is None:
copied_tool_calls = list(tool_calls)
copied_tc = dict(tc)
copied_tc.pop("call_id", None)
copied_tc.pop("response_item_id", None)
if strip_extra_content:
copied_tc.pop("extra_content", None)
copied_tool_calls[tc_idx] = copied_tc
if copied_tool_calls is not None:
mutable_msg()["tool_calls"] = copied_tool_calls
return sanitized
def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
@ -326,7 +378,7 @@ class ChatCompletionsTransport(ProviderTransport):
is_nvidia_nim = params.get("is_nvidia_nim", False)
is_kimi = params.get("is_kimi", False)
is_tokenhub = params.get("is_tokenhub", False)
reasoning_config = params.get("reasoning_config")
reasoning_config = _reasoning_config_for_model(model, params.get("reasoning_config"))
if ephemeral is not None and max_tokens_fn:
api_kwargs.update(max_tokens_fn(ephemeral))
@ -525,7 +577,7 @@ class ChatCompletionsTransport(ProviderTransport):
api_kwargs["max_tokens"] = anthropic_max
# Provider-specific api_kwargs extras (reasoning_effort, metadata, etc.)
reasoning_config = params.get("reasoning_config")
reasoning_config = _reasoning_config_for_model(model, params.get("reasoning_config"))
extra_body_from_profile, top_level_from_profile = (
profile.build_api_kwargs_extras(
reasoning_config=reasoning_config,

View file

@ -67,9 +67,9 @@ class ResponsesApiTransport(ProviderTransport):
"""Classify the current Responses endpoint from transport params."""
from agent.codex_responses_adapter import _classify_responses_issuer
return _classify_responses_issuer(
is_xai_responses=bool(params.get("is_xai_responses")),
is_github_responses=bool(params.get("is_github_responses")),
is_codex_backend=bool(params.get("is_codex_backend")),
is_xai_responses=params.get("is_xai_responses") is True,
is_github_responses=params.get("is_github_responses") is True,
is_codex_backend=params.get("is_codex_backend") is True,
base_url=params.get("base_url"),
)
@ -80,7 +80,8 @@ class ResponsesApiTransport(ProviderTransport):
self._last_issuer_kind = issuer
return _chat_messages_to_responses_input(
messages,
is_xai_responses=bool(kwargs.get("is_xai_responses")),
is_xai_responses=kwargs.get("is_xai_responses") is True,
is_github_responses=kwargs.get("is_github_responses") is True,
replay_encrypted_reasoning=bool(
kwargs.get("replay_encrypted_reasoning", True)
),
@ -137,9 +138,9 @@ class ResponsesApiTransport(ProviderTransport):
if not instructions:
instructions = DEFAULT_AGENT_IDENTITY
is_github_responses = params.get("is_github_responses", False)
is_codex_backend = params.get("is_codex_backend", False)
is_xai_responses = params.get("is_xai_responses", False)
is_github_responses = params.get("is_github_responses") is True
is_codex_backend = params.get("is_codex_backend") is True
is_xai_responses = params.get("is_xai_responses") is True
replay_encrypted_reasoning = bool(
params.get("replay_encrypted_reasoning", True)
)
@ -163,6 +164,12 @@ class ResponsesApiTransport(ProviderTransport):
reasoning_effort = reasoning_config["effort"]
_effort_clamp = {"minimal": "low"}
if "gpt-5.6" in (model or "").lower():
# Ultra is the Codex product tier; the Responses API wire value is max.
_effort_clamp["ultra"] = "max"
if params.get("is_xai_responses", False):
# xAI Responses tops out at high; keep generic stronger values usable.
_effort_clamp.update({"xhigh": "high", "max": "high", "ultra": "high"})
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
response_tools = _responses_tools(tools)
@ -239,6 +246,7 @@ class ResponsesApiTransport(ProviderTransport):
"input": _chat_messages_to_responses_input(
payload_messages,
is_xai_responses=is_xai_responses,
is_github_responses=is_github_responses,
replay_encrypted_reasoning=replay_encrypted_reasoning,
current_issuer_kind=issuer_kind,
),
@ -427,24 +435,46 @@ class ResponsesApiTransport(ProviderTransport):
def validate_response(self, response: Any) -> bool:
"""Check Codex Responses API response has valid output structure.
Returns True only if response.output is a non-empty list.
Does NOT check output_text fallback the caller handles that
with diagnostic logging for stream backfill recovery.
Returns True only if response.output is a non-empty list. Also treats
terminal content-filter incomplete responses as valid: the Responses API
may return status=incomplete with incomplete_details.reason='content_filter'
and no output items. That is a provider refusal signal, not a malformed
response, and must reach normalization so the agent loop can use the
content-policy / fallback path instead of invalid-response retries.
Does NOT check output_text fallback the caller handles that with
diagnostic logging for stream backfill recovery.
"""
if response is None:
return False
output = getattr(response, "output", None)
if not isinstance(output, list) or not output:
return False
status = str(getattr(response, "status", "") or "").strip().lower()
incomplete_details = getattr(response, "incomplete_details", None)
if isinstance(incomplete_details, dict):
reason = str(incomplete_details.get("reason") or "").strip().lower()
else:
reason = str(getattr(incomplete_details, "reason", "") or "").strip().lower()
return status == "incomplete" and reason == "content_filter"
return True
def preflight_kwargs(self, api_kwargs: Any, *, allow_stream: bool = False) -> dict:
def preflight_kwargs(
self,
api_kwargs: Any,
*,
allow_stream: bool = False,
is_github_responses: bool = False,
) -> dict:
"""Validate and sanitize Codex API kwargs before the call.
Normalizes input items, strips unsupported fields, validates structure.
"""
from agent.codex_responses_adapter import _preflight_codex_api_kwargs
return _preflight_codex_api_kwargs(api_kwargs, allow_stream=allow_stream)
return _preflight_codex_api_kwargs(
api_kwargs,
allow_stream=allow_stream,
is_github_responses=is_github_responses,
)
def map_finish_reason(self, raw_reason: str) -> str:
"""Map Codex response.status to OpenAI finish_reason.

View file

@ -755,22 +755,22 @@ class CodexAppServerSession:
turn_obj = (note.get("params") or {}).get("turn") or {}
result.turn_id = turn_obj.get("id") or result.turn_id
turn_status = turn_obj.get("status")
if turn_status and turn_status not in {"completed", "interrupted"}:
if turn_status == "interrupted":
result.interrupted = True
result.error = result.error or "compact turn interrupted"
elif turn_status and turn_status != "completed":
err_obj = turn_obj.get("error")
if err_obj:
err_msg = _format_responses_error(err_obj, str(turn_status))
stderr_blob = "\n".join(
self._client.stderr_tail(40)
err_msg = _format_responses_error(err_obj, str(turn_status))
stderr_blob = "\n".join(self._client.stderr_tail(40))
hint = _classify_oauth_failure(err_msg, stderr_blob)
if hint is not None:
result.error = hint
result.should_retire = True
else:
result.error = self._format_error_with_stderr(
f"compact turn ended status={turn_status}",
err_msg,
)
hint = _classify_oauth_failure(err_msg, stderr_blob)
if hint is not None:
result.error = hint
result.should_retire = True
else:
result.error = self._format_error_with_stderr(
f"compact turn ended status={turn_status}",
err_msg,
)
if not turn_complete and not result.interrupted:
self._issue_interrupt(result.turn_id)

View file

@ -44,6 +44,7 @@ Spawned by: CodexAppServerSession.ensure_started() when the runtime is
from __future__ import annotations
import inspect
import json
import logging
import os
@ -52,6 +53,49 @@ from typing import Any, Optional
logger = logging.getLogger(__name__)
# JSON Schema type -> Python type mapping for signature generation
_JSON_TO_PY = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
"array": list,
"object": dict,
}
def _signature_from_schema(schema: dict | None) -> tuple[inspect.Signature, dict[str, type]]:
"""Build a Python function signature and annotations from a JSON schema.
Args:
schema: JSON Schema dict with "properties" and "required" keys.
Returns:
(signature, annotations_dict) where signature has KEYWORD_ONLY params
and annotations maps param names to Python types.
"""
props = (schema or {}).get("properties") or {}
required = set((schema or {}).get("required") or [])
params, annots = [], {}
for pname, pspec in props.items():
if pname.startswith("_"):
continue
py = _JSON_TO_PY.get((pspec or {}).get("type"), Any)
ann, default = (
(py, inspect.Parameter.empty)
if pname in required
else (Optional[py], None)
)
annots[pname] = ann
params.append(
inspect.Parameter(
pname, inspect.Parameter.KEYWORD_ONLY, annotation=ann, default=default
)
)
return inspect.Signature(params, return_annotation=str), annots
# Tools we expose. Each name MUST match a registered Hermes tool that
# `model_tools.handle_function_call()` can dispatch.
@ -159,29 +203,36 @@ def _build_server() -> Any:
# the result string. We use add_tool() for full control over the
# input schema (FastMCP's @tool() decorator inspects type hints,
# which we can't get from a JSON schema at runtime).
def _make_handler(tool_name: str):
def _make_handler(tool_name: str, schema: dict | None):
sig, annots = _signature_from_schema(schema)
def _dispatch(**kwargs: Any) -> str:
try:
return handle_function_call(tool_name, kwargs or {})
# Filter out None values before dispatch so unset optionals
# aren't forwarded to the handler.
args = {k: v for k, v in kwargs.items() if v is not None}
return handle_function_call(tool_name, args or {})
except Exception as exc:
logger.exception("tool %s raised", tool_name)
return json.dumps({"error": str(exc), "tool": tool_name})
_dispatch.__name__ = tool_name
_dispatch.__doc__ = description
_dispatch.__signature__ = sig
_dispatch.__annotations__ = {**annots, "return": str}
return _dispatch
try:
mcp.add_tool(
_make_handler(name),
_make_handler(name, params_schema),
name=name,
description=description,
# FastMCP accepts JSON schema directly via the
# input_schema parameter on newer versions; older
# versions use parameters_schema. Try both for compat.
)
except TypeError:
# Older mcp SDK signature — fall back to decorator-style.
handler = _make_handler(name)
# Older mcp SDK signature — fall back to decorator-style. The
# synthesized __signature__ on the handler still drives schema
# generation there.
handler = _make_handler(name, params_schema)
handler = mcp.tool(name=name, description=description)(handler)
exposed_count += 1

View file

@ -56,6 +56,7 @@ _BUILTIN_NAMES = frozenset({
"neutts",
"kittentts",
"piper",
"deepinfra",
})

View file

@ -118,12 +118,12 @@ class TurnContext:
def build_turn_context(
agent,
user_message: str,
user_message: Any,
system_message: Optional[str],
conversation_history: Optional[List[Dict[str, Any]]],
task_id: Optional[str],
stream_callback,
persist_user_message: Optional[str],
persist_user_message: Optional[Any],
persist_user_timestamp: Optional[float] = None,
*,
restore_or_build_system_prompt,
@ -271,6 +271,29 @@ def build_turn_context(
# Initialize conversation (copy to avoid mutating the caller's list).
messages = list(conversation_history) if conversation_history else []
# The CLI may already have staged this input outside the history passed to
# ``run_conversation``. Reuse it only when its clean transcript text matches
# this turn; a stale handoff from a failed prior turn must not replace a
# later, different user input. Voice turns compare against their explicit
# clean persistence override rather than the API-only prefixed payload.
pending_cli_message = getattr(agent, "_pending_cli_user_message", None)
expected_persist_content = (
persist_user_message if persist_user_message is not None else user_message
)
if (
isinstance(pending_cli_message, dict)
and pending_cli_message.get("content") == expected_persist_content
):
user_msg = pending_cli_message
# The CLI-staged value is the clean transcript text. Restore the
# API-facing variant (for example, a voice-mode prefix) while retaining
# the same dict and any close-path durable marker.
user_msg["content"] = user_message
else:
user_msg = {"role": "user", "content": user_message}
if isinstance(pending_cli_message, dict):
agent._pending_cli_user_message = None
# Hydrate todo store from conversation history.
if conversation_history and not agent._todo_store.has_items():
agent._hydrate_todo_store(conversation_history)
@ -285,6 +308,13 @@ def build_turn_context(
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
# Add the current user message after the prompt/session setup has made
# close persistence safe. The handoff above preserves any marker already
# stamped by an earlier close flush.
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
# Track user turns for memory flush and periodic nudge logic.
agent._user_turn_count += 1
# Copilot x-initiator: the first API call of this user turn is
@ -313,11 +343,19 @@ def build_turn_context(
should_review_memory = True
agent._turns_since_memory = 0
# Add user message.
user_msg = {"role": "user", "content": user_message}
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
# Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot)
# and notify the host so it can play hearts. Token-free, never touches the
# conversation, and never fatal — a purely optional UI beat.
reaction_callback = getattr(agent, "reaction_callback", None)
if reaction_callback is not None:
try:
from agent.reactions import detect_reaction
kind = detect_reaction(original_user_message)
if kind:
reaction_callback(kind)
except Exception:
pass
if not agent.quiet_mode:
_print_preview = summarize_user_message_for_log(user_message)
@ -334,18 +372,33 @@ def build_turn_context(
# Create the DB session row now that _cached_system_prompt is populated, so
# the persisted snapshot is written non-NULL on the first turn (Issue
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
agent._ensure_db_session()
# #45499). Keep row creation and the marker-based append in the same
# per-agent critical section as CLI close persistence.
persist_lock = getattr(agent, "_session_persist_lock", None)
def _ensure_and_persist() -> None:
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
try:
agent._persist_session(messages, conversation_history)
if persist_lock is None:
_ensure_and_persist()
else:
with persist_lock:
_ensure_and_persist()
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
finally:
# Keep an unmarked staged input available to a later close retry if the
# normal persistence attempt failed. Once the marker is present, the
# close path must no longer treat it as a pre-worker UI input.
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None
# ── Preflight context compression ──
# Gate the (expensive) full token estimate behind a cheap pre-check.

View file

@ -42,6 +42,7 @@ def finalize_turn(
original_user_message,
_should_review_memory,
_turn_exit_reason,
_pending_verification_response=None,
):
"""Run the post-loop finalization and return the turn ``result`` dict.
@ -50,10 +51,35 @@ def finalize_turn(
"""
from agent.conversation_loop import logger
if final_response is None and (
budget_exhausted = (
api_call_count >= agent.max_iterations
or agent.iteration_budget.remaining <= 0
):
)
budget_fallback_eligible = (
budget_exhausted
and not interrupted
and not failed
and str(_turn_exit_reason) in {"unknown", "budget_exhausted"}
)
continuation_budget_exhausted = (
final_response is None
and bool(_pending_verification_response)
and budget_fallback_eligible
)
iteration_limit_fallback = False
preserved_verification_fallback = False
if continuation_budget_exhausted:
# A verification/continuation gate deliberately withheld a composed
# answer, then consumed the remaining budget before producing a newer
# one. Preserve that exact answer instead of replacing it with another
# fallible model call. The explicit pending value is the provenance
# guard: unrelated error/recovery exits can never enter this branch.
final_response = _pending_verification_response
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
iteration_limit_fallback = True
preserved_verification_fallback = True
elif final_response is None and budget_fallback_eligible:
# Budget exhausted — ask the model for a summary via one extra
# API call with tools stripped. _handle_max_iterations injects a
# user message and makes a single toolless request.
@ -68,20 +94,18 @@ def finalize_turn(
"— requesting summary..."
)
final_response = agent._handle_max_iterations(messages, api_call_count)
iteration_limit_fallback = True
if iteration_limit_fallback:
# If running as a kanban worker, signal the dispatcher that the
# worker could not complete (rather than treating it as a
# protocol violation). The agent loop strips tools before calling
# _handle_max_iterations, so the model cannot call kanban_block
# itself — we must do it on its behalf.
# protocol violation). This applies whether the user-facing fallback
# came from the summary call or an explicitly pending continuation;
# both exhausted the task budget and must advance the failure circuit.
#
# We route through ``_record_task_failure(outcome="timed_out")``
# rather than ``kanban_block`` so this counts toward the
# ``consecutive_failures`` counter and the dispatcher's
# ``failure_limit`` circuit breaker (#29747 gap 2). Without this,
# a task whose worker keeps exhausting its budget would block
# silently each run, get auto-promoted by the operator (or never
# surface), and re-block in an endless loop with no signal.
# rather than ``kanban_block`` so this counts toward the dispatcher's
# consecutive-failure circuit breaker (#29747 gap 2).
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
if _kanban_task:
try:
@ -204,6 +228,15 @@ def finalize_turn(
if _tail_role != "assistant":
messages.append({"role": "assistant", "content": final_response})
# The model has completed its request, so replace API-local
# voice/model/skill guidance with the clean user input before writing the
# final durable snapshot and returning the continuation history. Earlier
# turn-start flushes use the DB-only override because their messages are
# still needed for the API request; this finalizer runs after that request
# is complete (#48677 / #63766).
_apply_override = getattr(agent, "_apply_persist_user_message_override", None)
if callable(_apply_override):
_apply_override(messages)
agent._persist_session(messages, conversation_history)
except Exception as _persist_err:
_cleanup_errors.append(f"persist_session: {_persist_err}")
@ -304,6 +337,7 @@ def finalize_turn(
# truncated partial (the "The" case from #34452).
_is_partial_fragment = (
not _is_empty_terminal
and not preserved_verification_fallback
and not str(_turn_exit_reason).startswith("text_response")
and len(_stripped) <= 24
and _stripped[-1:] not in {".", "!", "?", "", "", "", "`", ")"}
@ -424,6 +458,11 @@ def finalize_turn(
"estimated_cost_usd": agent.session_estimated_cost_usd,
"cost_status": agent.session_cost_status,
"cost_source": agent.session_cost_source,
# Requested service tier (from request_overrides.extra_body), for
# billing audits by callers like `hermes -z --usage-file`.
"service_tier": (
(getattr(agent, "request_overrides", {}) or {}).get("extra_body") or {}
).get("service_tier"),
"session_id": agent.session_id,
}
if agent._tool_guardrail_halt_decision is not None:

View file

@ -103,6 +103,54 @@ _UTC_NOW = lambda: datetime.now(timezone.utc)
# Official docs snapshot entries. Models whose published pricing and cache
# semantics are stable enough to encode exactly.
_OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
# ── OpenAI GPT-5.6 series (Sol/Terra/Luna) ───────────────────────────
# Announced in limited preview 2026-06-26; GA 2026-07-09 at the same
# rates (Sol $5/$30, Terra $2.50/$15, Luna $1/$6 per 1M in/out). Cache
# writes are billed at 1.25x the uncached input rate; cache reads get the
# standard 90% discount (0.10x input, confirmed: Sol $0.50/M cached).
# Note: "Sol Fast mode" ($12.5/$75, up to 750 tok/s via Cerebras) is a
# separate serving tier, not covered by these entries. The "-pro"
# variants (high-effort modes, GA alongside base tiers) bill at the
# SAME per-token rates and are aliased onto these entries below the
# dict (they cost more per task by consuming more tokens, not by a
# higher rate — verified against OpenRouter's live pricing 2026-07-09).
# Source: https://openai.com/index/previewing-gpt-5-6-sol/
(
"openai",
"gpt-5.6-sol",
): PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("30.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
pricing_version="openai-gpt-5.6-2026-07",
),
(
"openai",
"gpt-5.6-terra",
): PricingEntry(
input_cost_per_million=Decimal("2.50"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.25"),
cache_write_cost_per_million=Decimal("3.125"),
source="official_docs_snapshot",
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
pricing_version="openai-gpt-5.6-2026-07",
),
(
"openai",
"gpt-5.6-luna",
): PricingEntry(
input_cost_per_million=Decimal("1.00"),
output_cost_per_million=Decimal("6.00"),
cache_read_cost_per_million=Decimal("0.10"),
cache_write_cost_per_million=Decimal("1.25"),
source="official_docs_snapshot",
source_url="https://openai.com/index/previewing-gpt-5-6-sol/",
pricing_version="openai-gpt-5.6-2026-07",
),
# ── Anthropic Claude 4.8 ─────────────────────────────────────────────
# Same $5/$25 base pricing as 4.6/4.7. Fast-mode variant is a separate
# model ID with 2x premium (vs the 6x premium on older Opus generations).
@ -563,6 +611,15 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
),
}
# GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as
# their base tiers (more tokens per task, not a higher rate). Alias them
# onto the base entries so the snapshot stays single-source.
for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"):
_OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[
("openai", _base_56)
]
del _base_56
def _to_decimal(value: Any) -> Optional[Decimal]:
if value is None:
@ -602,7 +659,11 @@ def resolve_billing_route(
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
if provider_name == "anthropic":
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name == "openai":
# "openai-api" is the picker/registry slug for direct api.openai.com; it
# bills identically to bare "openai", so normalize it here — otherwise the
# ("openai", <model>) _OFFICIAL_DOCS_PRICING keys are unreachable from the
# openai-api provider path.
if provider_name in {"openai", "openai-api"}:
return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name in {"minimax", "minimax-cn"}:
return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")

View file

@ -289,7 +289,7 @@ def build_verify_on_stop_nudge(
+ "), read any failure, repair the code, and summarize what passed."
)
else:
temp_dir = tempfile.gettempdir()
temp_dir = os.path.realpath(tempfile.gettempdir())
command_instruction = (
"No canonical test/lint/build command was detected. Create a focused "
f"temporary verification script under `{temp_dir}` using an OS-safe "

View file

@ -244,6 +244,78 @@ def save_bytes_video(
return path
_URL_VIDEO_CONTENT_TYPES = {
"video/mp4": "mp4",
"video/webm": "webm",
"video/quicktime": "mov",
"video/x-matroska": "mkv",
}
def save_url_video(
url: str,
*,
prefix: str = "video",
timeout: float = 180.0,
max_bytes: int = 200 * 1024 * 1024,
) -> Path:
"""Download a video URL and write it under ``$HERMES_HOME/cache/videos/``.
The video twin of :func:`agent.image_gen_provider.save_url_image`: several
backends (DeepInfra, FAL) return an *ephemeral* delivery URL that expires
before a downstream consumer can fetch it, so we materialise the bytes
locally at tool-completion time. Streams with a size cap.
Raises on any network / HTTP / oversize error so callers can fall back to
returning the bare URL.
"""
import requests
response = requests.get(url, timeout=timeout, stream=True)
response.raise_for_status()
content_type = (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
extension = _URL_VIDEO_CONTENT_TYPES.get(content_type)
if extension is None:
url_path = url.split("?", 1)[0].lower()
for ext in ("mp4", "webm", "mov", "mkv"):
if url_path.endswith(f".{ext}"):
extension = ext
break
if extension is None:
extension = "mp4"
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
short = uuid.uuid4().hex[:8]
path = _videos_cache_dir() / f"{prefix}_{ts}_{short}.{extension}"
bytes_written = 0
with path.open("wb") as fh:
for chunk in response.iter_content(chunk_size=256 * 1024):
if not chunk:
continue
bytes_written += len(chunk)
if bytes_written > max_bytes:
fh.close()
try:
path.unlink()
except OSError:
pass
raise ValueError(
f"Video at {url} exceeds {max_bytes // (1024 * 1024)}MB cap; refusing to cache."
)
fh.write(chunk)
if bytes_written == 0:
try:
path.unlink()
except OSError:
pass
raise ValueError(f"Video at {url} was empty (0 bytes).")
return path
def success_response(
*,
video: str,
@ -297,3 +369,222 @@ def error_response(
"aspect_ratio": aspect_ratio,
"provider": provider,
}
# ---------------------------------------------------------------------------
# Reusable OpenAI-compatible backend
# ---------------------------------------------------------------------------
class OpenAICompatibleVideoGenProvider(VideoGenProvider):
"""Generic text/image-to-video over the OpenAI ``client.videos`` API.
DeepInfra, OpenAI/Sora, and OpenRouter all expose the same
``POST /videos`` async-job shape (``create`` poll ``download_content``),
so the SDK call lives here once. A concrete backend only needs to declare
its identity and credentials::
class FooVideoGenProvider(OpenAICompatibleVideoGenProvider):
name = "foo"
_env_key = "FOO_API_KEY"
_default_base_url = "https://api.foo.com/v1/openai"
def list_models(self):
return [...] # entries with an "id" key; default_model() uses [0]
``image_url`` routes to image-to-video; its absence routes to text-to-video.
Provider-specific fields (``image_url``/``negative_prompt``/``seed``) ride
in ``extra_body`` so they pass through the SDK unchanged.
"""
_env_key: str = "OPENAI_API_KEY"
_default_base_url: str = "https://api.openai.com/v1"
# Polling cadence for the async video job. The OpenAI SDK's
# ``create_and_poll`` defaults to ~1 poll/second and loops forever on a
# non-terminal status, so a multi-minute job issues hundreds of sequential
# requests and a stuck job pins its tool-executor worker thread with no way
# out. We hand-roll a bounded poll instead: a coarse interval plus a hard
# wall-clock deadline that surfaces a timeout error.
_poll_interval_s: float = 5.0
_poll_deadline_s: float = 900.0
def _api_key(self) -> str:
import os
return os.environ.get(self._env_key, "").strip()
def is_available(self) -> bool:
return bool(self._api_key())
def _create_and_poll(self, client: Any, call_kwargs: Dict[str, Any]) -> Any:
"""Create the video job and poll to completion with a hard deadline.
Replaces ``client.videos.create_and_poll`` (unbounded 1/s loop) with a
coarse interval and a wall-clock cap. Returns the terminal video object
(any status); raises :class:`TimeoutError` if the deadline passes
first.
"""
import time
video = client.videos.create(**call_kwargs)
terminal = {"completed", "succeeded", "failed", "error", "cancelled", "canceled"}
deadline = time.monotonic() + self._poll_deadline_s
while getattr(video, "status", None) not in terminal:
if time.monotonic() >= deadline:
raise TimeoutError(
f"video job {getattr(video, 'id', '?')} did not reach a terminal "
f"status within {int(self._poll_deadline_s)}s "
f"(last status={getattr(video, 'status', None)!r})"
)
time.sleep(self._poll_interval_s)
video = client.videos.retrieve(video.id)
return video
def _base_url(self) -> str:
import os
override = os.environ.get(f"{self.name.upper()}_BASE_URL", "").strip()
return override or self._default_base_url
def generate(
self,
prompt: str,
*,
model: Optional[str] = None,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
duration: Optional[int] = None,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
resolution: str = DEFAULT_RESOLUTION,
negative_prompt: Optional[str] = None,
audio: Optional[bool] = None,
seed: Optional[int] = None,
**kwargs: Any,
) -> Dict[str, Any]:
if not prompt or not prompt.strip():
return error_response(
error="prompt is required", error_type="invalid_request", provider=self.name
)
if not self._api_key():
return error_response(
error=f"{self._env_key} is not set",
error_type="missing_credentials",
provider=self.name,
)
try:
import openai
except ImportError:
return error_response(
error="openai Python package not installed (pip install openai)",
error_type="missing_dependency",
provider=self.name,
)
model_id = model or self.default_model()
if not model_id:
return error_response(
error=f"no {self.name} video model available (live catalog empty?)",
error_type="no_model",
provider=self.name,
)
# Provider-specific fields the OpenAI ``videos.create`` signature does
# not name natively — pass them through ``extra_body``.
extra_body = {
k: v
for k, v in {
"negative_prompt": negative_prompt,
"aspect_ratio": aspect_ratio,
"image_url": image_url, # presence ⇒ image-to-video
"seed": seed,
}.items()
if v is not None
}
call_kwargs: Dict[str, Any] = {"model": model_id, "prompt": prompt}
if duration:
call_kwargs["seconds"] = str(duration)
if resolution:
call_kwargs["size"] = resolution
if extra_body:
call_kwargs["extra_body"] = extra_body
client = openai.OpenAI(api_key=self._api_key(), base_url=self._base_url())
try:
try:
video = self._create_and_poll(client, call_kwargs)
except Exception as exc: # noqa: BLE001 - surface any SDK/API/timeout failure uniformly
logger.debug("%s video generation failed", self.name, exc_info=True)
return error_response(
error=f"{self.name} video generation failed: {exc}",
error_type="api_error",
provider=self.name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect_ratio,
)
# Terminal success status differs across backends: DeepInfra reports
# "succeeded", OpenAI/Sora reports "completed". Accept both.
status = getattr(video, "status", None)
if status not in ("completed", "succeeded"):
# ``video.error`` is a structured SDK object (pydantic
# VideoCreateError), not a string — str() it so the response
# dict stays JSON-serializable for the tool layer.
job_error = getattr(video, "error", None)
return error_response(
error=str(job_error) if job_error else f"video job ended with status={status!r}",
error_type="job_failed",
provider=self.name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect_ratio,
)
# Resolve the output. Providers expose it either as a delivery URL in
# the job's ``data`` list (DeepInfra, FAL-style) or only via the SDK
# download endpoint (OpenAI/Sora). Download the bytes and save locally
# so the caller gets a durable file — DeepInfra's delivery URLs in
# particular are short-lived. Matches plugins/image_gen/deepinfra.
url = None
for item in getattr(video, "data", None) or []:
candidate = item.get("url") if isinstance(item, dict) else getattr(item, "url", None)
if candidate:
url = candidate
break
try:
if url:
# Materialise the (often short-lived) delivery URL locally.
video_ref = str(save_url_video(url, prefix=self.name))
else:
# OpenAI/Sora style: no public URL — pull bytes via the SDK.
raw = client.videos.download_content(video.id).read()
video_ref = str(save_bytes_video(raw, prefix=self.name))
except Exception as exc: # noqa: BLE001
if url:
# Best-effort: hand back the URL rather than fail outright.
logger.debug("%s: saving video locally failed (%s); returning URL", self.name, exc)
video_ref = url
else:
return error_response(
error=f"{self.name} video job succeeded but no output could be retrieved: {exc}",
error_type="empty_response",
provider=self.name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect_ratio,
)
return success_response(
video=video_ref,
model=model_id,
prompt=prompt,
modality="image" if image_url else "text",
aspect_ratio=aspect_ratio,
duration=duration or 0,
provider=self.name,
)
finally:
close = getattr(client, "close", None)
if callable(close):
close()

View file

@ -11,12 +11,15 @@ Active selection
The active provider is chosen by ``video_gen.provider`` in ``config.yaml``.
If unset, :func:`get_active_provider` applies fallback logic:
1. If exactly one provider is registered, use it.
1. If exactly one *available* provider is registered, use it.
2. Otherwise return ``None`` (the tool surfaces a helpful error pointing
the user at ``hermes tools``).
Mirrors ``agent/image_gen_registry.py`` so the two surfaces behave the
same.
same: the unconfigured fallback is filtered by ``is_available()`` so a box
that has credentials for only one backend (e.g. DeepInfra, while the
``fal``/``xai`` plugins also register unconditionally) auto-selects it
instead of returning ``None``.
"""
from __future__ import annotations
@ -100,13 +103,26 @@ def get_active_provider() -> Optional[VideoGenProvider]:
if provider is not None:
return provider
logger.debug(
"video_gen.provider='%s' configured but not registered; falling back",
"video_gen.provider='%s' configured but not registered; failing closed",
configured,
)
return None
# Fallback: single-provider case
if len(snapshot) == 1:
return next(iter(snapshot.values()))
def _is_available_safe(p: VideoGenProvider) -> bool:
"""Wrap ``is_available()`` so a buggy provider doesn't kill resolution."""
try:
return bool(p.is_available())
except Exception as exc: # noqa: BLE001
logger.debug("video_gen provider %s.is_available() raised %s", p.name, exc)
return False
# Fallback: single *available* provider — filter by is_available() so a
# box with credentials for only one backend auto-selects it even when
# other providers (fal/xai) register unconditionally without keys.
# Mirrors agent/image_gen_registry.get_active_provider().
available = [p for p in snapshot.values() if _is_available_safe(p)]
if len(available) == 1:
return available[0]
return None

View file

@ -0,0 +1,5 @@
import shared from '../../eslint.config.shared.mjs'
export default [
...shared
]

View file

@ -12,7 +12,11 @@
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:build:debug": "tauri build --debug",
"typecheck": "tsc -p . --noEmit"
"typecheck": "tsc -p . --noEmit",
"check": "npm run typecheck",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"fix": "npm run lint:fix"
},
"dependencies": {
"@nous-research/ui": "0.16.0",
@ -37,11 +41,19 @@
"tw-shimmer": "^0.4.11"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.16"
}
}

View file

@ -1,6 +1,6 @@
//! Bootstrap orchestration.
//!
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`.
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`.
//! Drives install.ps1 / install.sh stage-by-stage, emits progress events
//! over the Tauri `bootstrap` channel, writes a forensic log to
//! HERMES_HOME/logs/bootstrap-<timestamp>.log.

View file

@ -1,6 +1,6 @@
//! Event types streamed from Rust → React.
//!
//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape
//! These mirror `apps/desktop/electron/bootstrap-runner.ts`'s event shape
//! 1:1 so the React installer code can be roughly identical to the Electron
//! install-overlay we'll replace.
//!

View file

@ -8,7 +8,7 @@
//! 3. Network: download from GitHub raw at a pinned commit or branch.
//! Commit pins are immutable; branch pins are HEAD-tracking.
//!
//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`,
//! Mirrors `apps/desktop/electron/bootstrap-runner.ts`'s `resolveInstallScript`,
//! but the dev-checkout resolution is driven by an env var rather than the
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
//! to live OUTSIDE any repo checkout.
@ -64,7 +64,7 @@ impl ScriptKind {
}
/// Validates a string looks like a git SHA (7+ hex chars). Mirrors
/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs.
/// `STAMP_COMMIT_RE` from bootstrap-runner.ts.
fn is_valid_commit(s: &str) -> bool {
let len = s.len();
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())

View file

@ -150,7 +150,7 @@ fn repair_macos_installer_helper(path: &Path) {
fn repair_macos_installer_helper(_path: &Path) {}
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
/// the Electron app also checks). Per main.cjs:
/// the Electron app also checks). Per main.ts:
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
/// this is a probe helper, not a definitive path.

View file

@ -1,6 +1,6 @@
//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh.
//!
//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same
//! Port of `spawnPowerShell` from bootstrap-runner.ts, with the same
//! line-buffered stdout/stderr streaming + cancellation semantics.
//!
//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`.
@ -19,7 +19,7 @@ pub struct StreamSink {
pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>,
}
/// Outcome of a script invocation. Mirrors bootstrap-runner.cjs's
/// Outcome of a script invocation. Mirrors bootstrap-runner.ts's
/// `{stdout, stderr, code, signal, killed}` shape.
#[derive(Debug)]
pub struct ScriptResult {
@ -258,7 +258,7 @@ fn interpreter_label() -> String {
/// Parses the LAST line of stdout that looks like a JSON object matching
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
///
/// Mirrors `parseStageResult` from bootstrap-runner.cjs. install.ps1 may
/// Mirrors `parseStageResult` from bootstrap-runner.ts. install.ps1 may
/// print info/banner lines before the result frame; we scan from the end.
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
for line in stdout.lines().rev() {

200
apps/desktop/AGENTS.md Normal file
View file

@ -0,0 +1,200 @@
# Desktop Engineering Guide
How to build Hermes Desktop well. This is a judgment guide, not an inventory —
it teaches the invariants and the reasoning behind them so a change fits the app
even as files move. Read it with the repository `AGENTS.md` (root rules still
apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract.
When a rule here and the code disagree, trust the code and fix whichever is
wrong — but never break an invariant to make a change easier.
## What this app is
Desktop is its own native chat surface. It is not the browser dashboard and it
does not embed the TUI. Three parties, each authoritative for one thing:
- **Electron** owns the machine: process lifecycle, native filesystem/git/
windows, install/update, and a narrow, typed capability bridge.
- **The renderer** owns the experience: navigation, presentation, and ephemeral
interaction state.
- **The agent backend** owns the work: sessions, tools, model calls, streaming.
Keep the seams clean. The renderer never reaches for Node or Electron directly;
native power arrives through a deliberate capability, not a general escape hatch.
Agent behavior lives behind the gateway, never reimplemented in React. When a
change blurs a seam, that is the smell — fix the seam, don't widen it.
## Decide state by authority
The first question for any piece of state is *who is allowed to be right about
it*, not where it is convenient to store it. Put state with its authority:
- The **backend** is authoritative for anything another Hermes surface can also
change. Treat the renderer's copy as a cache of that truth.
- **Electron** is authoritative for machine and runtime facts.
- The **renderer** owns only what is purely about this window's presentation.
From that, everything else follows: shared renderer state lives in small stores
owned by the feature that owns the concern; request-shaped server data that wants
invalidation lives in the query layer; short-lived interaction detail stays in
the component; hot coordination that must not paint stays in a ref. Reach for the
narrowest home that still lets the state be correct. A new global store is a
claim that many distant surfaces need it — earn that claim.
Persisted state must declare its scope in its own key: is this global, or does it
belong to a connection, a profile, a stored session, a project, or a window?
Getting the scope wrong is how one profile's setting bleeds into another.
## Identity is not incidental
Sessions have more than one identity, and conflating them is a recurring source
of "session not found" and vanishing history. Reason about which identity a
surface needs: durable navigation and anything the user pins or persists key off
the stable/durable identity; live streaming keys off the runtime identity; state
that must outlive compression keys off the lineage root. Keep the mapping between
them explicit and translate at the boundary rather than passing the wrong id
inward.
## Server truth is cached, not owned
The renderer paints from a cache of backend truth, so it must reconcile, not
assume:
- **Merge, don't clobber.** A refresh is new information layered over what you
already know, not a replacement that can drop live or pinned rows.
- **Be optimistic, then honest.** Direct manipulation should paint immediately
from a snapshot; a failed write rolls back visibly and an authoritative
refresh gets the last word.
- **Guard against the past.** Async results can arrive out of order; a stale
response must never overwrite newer intent. Generation counters and request
tokens exist for this.
- **Isolate the foreground.** Only the surface the user is looking at may publish
into the shared view; background work updates its own cache quietly.
- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but
let terminal transitions (a turn finishing, needing input, failing) reach the
user immediately.
- **Preserve reference identity on no-ops.** Handing React a fresh array that
contains the same data re-renders expensive trees for nothing.
## Switching context is a re-home, not a reboot
Changing profile, connection, or mode is a workspace switch, not a cold start.
The shell and whatever the user was doing stay put; only the gateway-bound view
is cleared and repopulated, and the previous context must not leak into the next
one. Reserve the full-screen boot/connecting experience for a genuinely unusable
backend.
There are three distinct switch shapes, and conflating them is the classic bug:
- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home:
shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query
invalidation alone cannot evict live session stores — wipe them.
- A **runtime home change** (switching the underlying `HERMES_HOME` profile) is
a hard re-home: the window legitimately reloads and state resets by remount.
- A **live profile swap** in the same window activates another profile's socket
while background profiles keep streaming; lists merge rather than wipe, and
only an explicit user selection starts a fresh foreground draft.
Treating a soft switch as hard flickers the app; treating a hard one as soft
strands stale rows. After any swap, the active socket, active profile, and
connection atoms must agree, or REST and filesystem calls route to the wrong
backend.
## Cross everything as an observable ladder
Desktop lives at the seams: versions, profiles, local vs remote vs cloud,
partially installed runtimes, stale caches, older backends. The durable technique
for all of it is the same — an ordered ladder of candidates:
1. Precedence is written down, in one place, as data or a pure function.
2. A candidate is trusted only after it is validated at the right boundary.
Existence is not proof; probe what you're about to rely on.
3. A failed *read* falls to the next rung; a failed *authoritative write*
surfaces or rolls back rather than silently retargeting.
4. A missing capability and a transient failure are different: the first may
enable a compatibility path or a disabled state; the second should retry.
5. Retries are bounded and end in a real recovery affordance — never an infinite
spinner or a hot loop.
6. One resolver owns each policy so every caller gets the same answer. Scatter is
how two call sites drift apart.
This is the shape of backend discovery, command/version fallbacks, connection and
auth resolution, workspace-cwd selection, capability detection, and preview
normalization alike. Learn the shape, not a snapshot of the current rungs.
Two auth-flavored corollaries worth naming because they are easy to get wrong:
- **One-time credentials are never reused.** An OAuth gateway connection mints a
fresh WebSocket ticket on every dial; a mint failure means reauthentication,
not "fall back to the cached URL." Only long-lived token/local auth may reuse
a cached URL as a lower rung.
- **A connection test must exercise the leg you'll actually use.** An HTTP
status probe passing while the WebSocket/auth leg fails is a false positive
that ships as "it said connected but nothing works."
## Compatibility without carrying the past forever
Desktop and its runtime update on separate clocks, so a change can meet an older
backend. Keep those users working: preserve the current feature, keep the
fallback narrow and tied to an identified older runtime, and cover it with a
test. A fallback that quietly degrades the feature it's meant to protect is worse
than the crash it replaced.
## Keep the waist narrow, grow at the edges
The root contribution rubric governs here too. New capability should arrive at
the smallest surface that solves it: extend what exists, add a feature locally,
lean on an existing seam — before you invent a framework. The shell's internal
registries are composition seams, not a public plugin ABI; do not build a
universal extension system, a manifest, or a plugin adapter for a single
consumer. Design a shared contract only once more than one real consumer proves
its shape. "Plugin" means several unrelated things across Hermes — do not assume
one surface's extension model runs in another.
## Respect the person using it
Design and engineering meet at intent. The user's attention and context are
sacred:
- Never navigate, move focus, or open a surface because something *happened* in
the background. Offer; don't hijack.
- The states around loading are distinct experiences — empty, loading,
reconnecting, degraded/stale, and exhausted-recovery each deserve their own
honest copy and their own way out.
- Keyboard ownership follows focus. The focused surface wins its keys; one
cancel gesture does exactly one thing.
- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden.
Visibility is not lifecycle.
## Make it feel instant
Performance is a feature the user feels, especially in drag, resize, scroll,
typing, streaming, and terminals. The principles are timeless even as the code
changes: keep hot-path state local or narrowly derived; don't subscribe heavy
trees to per-frame updates; coalesce pointer work; avoid reading layout right
after writing style; and don't mount expensive content mid-gesture. Prove speed
against realistic content — a fast empty demo proves nothing about a long
transcript. If motion is masking latency, remove the motion, don't tune it.
## Testing as a habit of proof
Test the behavior that would actually break a user, not a snapshot of today's
data. Favor invariants over frozen values. Exercise the real path for anything
at a seam — resolver precedence and its failure rungs, identity and scope
boundaries, optimistic rollback and stale-response ordering, and both sides of a
local/remote adapter with its profile routing intact. Match how the suite is
actually run rather than inventing a command; when in doubt, read the scripts.
## The taste test before you hand off
- Does every piece of state live with its authority, at the narrowest scope?
- Would a background event ever steal the foreground or the user's focus?
- Does each resolver have one home, a validated ladder, and a bounded, recoverable
end?
- Do local, remote, and profile routing still agree?
- Does async failure leave a usable UI and a way forward?
- Do hot interactions stay cheap under realistic load?
- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all
locales?
If any answer is "not sure," that's the part to go verify.

View file

@ -6,12 +6,29 @@ concern, tokens over literals, flat over boxed.** If you reach for a raw color,
a one-off shadow, a bespoke button, or a hardcoded `px-*` on a control — stop,
there's already a primitive for it.
This file owns the visual and interaction contract. Read
[`AGENTS.md`](./AGENTS.md) for architecture, state, resolver, transport, and
testing rules.
This doc contains two kinds of content, maintained differently:
- **Principles** (flatness, intent, feedback, motion, cancellation) are durable.
They hold as components come and go.
- **Named contracts** (tokens, `Button` variants, primitive names) are the
design system's current API. They are maintained *with* the code: if you
change a primitive, token, or variant, update its entry here **in the same
change** — a stale name in this file is a bug, exactly like a stale type.
When a rule and the code disagree, fix whichever is wrong rather than forking a
one-off at the call site.
## Principles
1. **Flat, not boxed.** No card-in-card, no divider borders inside a panel.
Group with whitespace and a single hairline, never nested rounded boxes.
2. **Borderless + shadow for elevation.** Overlays float on `shadow-nous` + a
`--stroke-nous` hairline, not hard borders.
2. **Borderless elevation for floating panels.** Overlays float on
`shadow-nous` + a `--stroke-nous` hairline, not thick framed boxes. In-panel
structure may use token hairlines sparingly.
3. **One primitive per concern.** One `Button`, one set of control variants,
one `SearchField`, one `Loader`, one `ErrorState`. Migrate onto them; don't
fork.
@ -20,11 +37,40 @@ there's already a primitive for it.
5. **Style lives in the primitive.** Variants and sizes own padding, radius,
color, chrome. Call sites pass a `variant`/`size`, not `className` overrides
that re-specify those.
6. **Intent before automation.** Surface useful actions and previews, but do not
open panes, move focus, or navigate because a tool happened to produce
something.
7. **Immediate feedback.** Direct manipulation updates the view first. Network
or disk persistence reconciles afterward and rolls back visibly on failure.
## Information architecture
- **Chat is the home surface.** The transcript and composer stay primary; tools,
previews, files, review, and terminal complement the conversation.
- **Pages are durable destinations.** Chat, Skills, Messaging, and Artifacts
remain in shell chrome. Do not hide a distinct product noun inside an
unrelated page.
- **Route overlays are short tasks.** Settings, Command Center, Cron, Profiles,
Agents, and Starmap render as `OverlayView` cards and return to the previous
route on close. Model/session pickers and dialogs layer above the current
surface; they are not navigation stacks.
- **Panes are working context.** Preview, files, review, and terminal remain
attached to the current task. Their state survives temporary hiding and chat
switches where the underlying tool is meant to persist.
- **One action, one home.** A command may have keyboard, palette, and visible
affordances, but they invoke the same action and state. Do not fork behavior
per entry point.
- **Projects own workspace cwd.** Use Sidebar → Projects for local folders and
worktrees; do not reintroduce a per-session/right-sidebar folder-picker flow.
Navigation must preserve context. A background session finishing, a tool result
arriving, or a project refresh may update badges and cached data; it must not
replace the foreground transcript or steal focus.
## Surfaces & elevation
Every overlay / dialog / toast (boot-failure, install, notifications,
model-picker, onboarding, prompt-overlays, updates, base `Dialog`) uses:
Floating panels (base `Dialog`, route overlays, boot/install/update surfaces,
model-picker, onboarding, prompt overlays, notifications) use:
```
shadow-nous /* downward-weighted, layered contact→ambient falloff */
@ -35,6 +81,11 @@ Both are CSS vars in `src/styles.css` — tune in one place, everything inherits
Don't add per-overlay `shadow-[…]` or `border-(--ui-stroke-secondary)`
one-offs; if elevation needs to change, change the token.
Menus and popovers use their own shared `shadow-md` +
`--ui-stroke-secondary` primitive treatment. Drag affordances may use tokenized
dashed targets and local blur. These are semantic surface classes, not licenses
for call-site shadow or border inventions.
## Stroke & color tokens
| Token | Use |
@ -62,8 +113,9 @@ fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel",
"Open logs").
**Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), and the icon
family `icon` / `icon-xs` / `icon-sm` / `icon-lg` / `icon-titlebar`.
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro`
(status-stack/table-footers), and the icon family `icon` / `icon-xs` /
`icon-sm` / `icon-lg` / `icon-titlebar`.
Notes:
- Text buttons are square (no radius) and sized by padding + line-height (no
@ -107,11 +159,36 @@ Notes:
through for a11y.
- **Logs:** `LogView` — no bg, hairline border, tight padding, small mono.
Every place we surface raw logs uses it.
- **Empty:** `EmptyState` / `EmptyPanel` — don't hand-roll centered empties.
- **Empty:** `EmptyState` for plain page bodies; `PanelEmpty` for overlay
master/detail empties with an icon and action. Don't hand-roll a third
centered empty.
## Chat, tools & boot surfaces
- The transcript and composer are built on `@assistant-ui/react`. Extend the
existing components under `src/components/assistant-ui` and
`src/app/chat/composer`; do not fork a second markdown, message, tool-call, or
approval renderer for one feature.
- A tool result may expose an inline action that opens a preview. It must not
open the rail automatically.
- Install, onboarding, connecting, boot failure, and reauthentication are
distinct states with shared visual primitives. Preserve their recovery
semantics when unifying appearance.
- Respect `AppShell` overlay ownership. Persistent terminal/content layers,
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
z-index literals.
## Iconography & brand
- **`Codicon`** is the icon set. No mixing icon libraries inline.
- **Tabler** is the default component/chrome set. Import its curated aliases and
`iconSize` scale from `src/lib/icons.ts`; do not import icon packages directly
in feature code.
- **`Codicon`** is the compact editor/tool/status vocabulary. Use
`src/components/ui/codicon.tsx`, including `codiconIcon()` where a
Tabler-shaped component is required.
- Pick the vocabulary by semantic context and reuse the existing icon for an
action. Do not introduce a third icon set or mix styles within one control
group.
- **`BrandMark`** (`src/components/brand-mark.tsx`) is the brand glyph — the
`nous-girl` mark on a white tile, softly rounded, identical in light/dark.
It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it
@ -124,6 +201,43 @@ Notes:
- Choreographed exits (e.g. onboarding's "matrix" fade-down) stagger per-element
then settle the surface — the outer container's fade is *delayed* so it
doesn't swallow the inner animation. Don't let a global fade race the detail.
- Motion follows state; it never delays state. Selection, drag targets, cancel,
and pressed feedback paint in the current frame.
- Do not animate layout geometry with `transition-all` on a hot interaction.
Name the properties, avoid backdrop-filter repaints during movement, and
remove animation before masking a performance problem.
## Direct manipulation & performance
The app should feel instant under real load — long transcripts, several panes,
live streams. Design toward that:
- Direct manipulation paints first; persistence reconciles after and rolls back
visibly on failure.
- Keep interaction feedback cheap: hot-path state stays local or narrowly
derived, not wired into heavy trees; pointer work coalesces per frame.
- One drop region has one visual owner, and drop targets speak one affordance
language across files, sessions, tabs, and panes. Overlapping targets resolve
to the active one instead of stacking overlays.
- Forgiving geometry beats pixel-perfect triggers; edge actions live near their
edge, not clustered in the center.
- Expensive stateful surfaces stay mounted when hidden. Visibility is not
lifecycle.
Prove speed with realistic content. A fast empty-state demo says nothing about a
long transcript or a busy terminal.
## Keyboard & cancellation
- Keyboard ownership follows focus. The focused surface wins its keys; shell
shortcuts must not steal a terminal's or editor's bindings.
- Register global shortcuts through the shared layer, not ad-hoc listeners.
- One cancel gesture does one thing: cancel the active interaction, or close the
topmost dismissable surface — never both, never the control underneath.
- Cancellation is synchronous in the UI even if cleanup is async: overlays,
cursors, and pending gesture state clear at once.
- Flows that deliberately cannot be dismissed (install/onboarding, destructive
confirmation) must make that explicit.
## i18n
@ -135,12 +249,15 @@ Notes:
## State (TypeScript)
Mirrors the repo TS style (see root `AGENTS.md`):
The detailed state contract lives in the scoped
[`AGENTS.md`](./AGENTS.md). Visual code follows these essentials:
- Shared/cross-component state → small **nanostores**, not prop-drilling.
Each feature owns its atoms; shared atoms live in `src/store`.
- Rendering components subscribe with `useStore`; non-render actions read with
`$atom.get()`.
- Subscribe to derived coarse facts instead of high-frequency source atoms when
the component does not render the full value.
- Colocated action modules over god hooks. A hook owns one narrow job.
- Keep persistence beside the atom that owns it. Route roots stay thin.
- Prefer `interface` for public props; extend React primitives
@ -163,5 +280,13 @@ Mirrors the repo TS style (see root `AGENTS.md`):
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
- [ ] Flat — no card-in-card, no gratuitous row dividers?
- [ ] No automatic navigation, focus steal, or pane opening from background
events?
- [ ] Direct manipulation paints immediately and rolls back cleanly on failure?
- [ ] Hot interactions avoid broad subscriptions, layout thrash, and
`transition-all`?
- [ ] Keyboard ownership and single-action `Esc` behavior are correct?
- [ ] All four locales updated for any new/changed string?
- [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave?
- [ ] Touched a primitive, token, or variant? Its named-contract entry in this
file is updated in the same change.

View file

@ -67,6 +67,8 @@ npm run dev # Vite renderer + Electron, which boots the Python backend
Point the app at a specific source checkout, or sandbox it away from your real config:
```bash
# throwaway HERMES_HOME, separate Electron userData, distinct app name to avoid the single-instance lock
../scripts/dev-sandbox.sh npm run dev
HERMES_DESKTOP_HERMES_ROOT=/path/to/clone npm run dev
HERMES_HOME=/tmp/throwaway npm run dev
npm run dev:fake-boot # exercise the startup overlay with deterministic delays
@ -85,7 +87,63 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
### How it works
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.cjs` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
The packaged app ships the Electron shell and a native React chat surface. On
first launch it can install the Hermes Agent runtime into `HERMES_HOME`
(`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows), using the same layout as a
CLI install.
The app has three boundaries:
- **Electron** resolves and validates a runnable backend, owns native
filesystem/git/window capabilities, and exposes a narrow preload bridge.
- **React** owns the Desktop routes, panes, interaction state, and
`@assistant-ui/react` transcript.
- **Hermes Agent** runs as a headless `hermes serve` process and exposes the
`tui_gateway` JSON-RPC/WebSocket API. The renderer connects through
[`apps/shared`](../shared/), which is also used by the browser dashboard.
Backend resolution is an ordered ladder:
1. `HERMES_DESKTOP_HERMES_ROOT`
2. the current source checkout during development
3. a completed managed install
4. `HERMES_DESKTOP_HERMES`, or `hermes` on `PATH`
5. a system Python that can import the Hermes runtime
6. the first-launch bootstrap installer
Candidates are probed before use; an existing shim or interpreter is not enough.
A runtime that predates `serve` falls back to headless
`dashboard --no-open`. This is compatibility for the backend command only and
does not launch or embed the dashboard UI.
The Electron orchestration entry point is `electron/main.ts`; pure resolution,
probe, hardening, and platform policies live in focused modules beside it. The
renderer is under `src/`, with shared atoms in `src/store` and transport/native
adapters in `src/lib`.
Before changing the app, read:
- [`AGENTS.md`](./AGENTS.md): architecture, state ownership, resolver/fallback,
transport, performance, and testing rules.
- [`DESIGN.md`](./DESIGN.md): visual system, information architecture, motion,
direct manipulation, and keyboard behavior.
### Connections, projects, and switching
Desktop supports a managed local backend, explicit remote gateways, and Hermes
Cloud connections. Remote and cloud modes use the same remote-capability path;
authentication and discovery differ, not the renderer feature model.
Projects are the workspace abstraction. A project may own multiple folders,
repositories, worktrees, and sessions; a bare new chat remains detached unless
the user enters a project or configures a default project directory. Use the
Projects UI rather than adding a second per-session folder-picker workflow.
Changing profiles or connection modes is a soft workspace switch, not another
cold boot. The shell and current management overlay remain mounted while
gateway-bound nanostores are wiped, query-backed data is invalidated, and the
new connection repopulates skeletons. This prevents rows or transcripts from
the previous gateway bleeding into the next one.
### Verification
@ -95,9 +153,13 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl
npm run fix
npm run typecheck
npm run lint
npm run test:desktop:all
npm run test:ui
npm run test:desktop:platforms
```
Run `npm run test:desktop:all` for install, boot, update, packaging, or other
release-path changes.
### Troubleshooting
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.

View file

@ -0,0 +1,55 @@
/**
* backend-child.ts
*
* Windows-aware teardown for the desktop's managed backend child process.
*
* Node's `child.kill()` only signals the direct child. On Windows a backend
* that spawned its own grandchildren (a `hermes` REPL, a pty terminal
* session, the gateway) survives a plain SIGTERM and keeps files (e.g. the
* venv shim) locked. So on Windows we tree-kill via `forceKillProcessTree`;
* everywhere else a plain SIGTERM is correct and sufficient (POSIX has no
* mandatory locks, and the backend is not spawned detached so there's no
* process-group to negative-pid-kill).
*
* Extracted into its own dependency-free module (no electron import) so the
* SIGTERM-vs-tree-kill branching can be asserted directly with a fake child
* object and a spy `forceKillProcessTree`, instead of grepping main.ts source
* text for the function body.
*/
export interface StopBackendChildDeps {
/** Defaults to the real platform check; injectable for tests. */
isWindows?: boolean
/** Windows tree-kill implementation (real: taskkill /T /F via execFileSync). */
forceKillProcessTree: (pid: number) => void
}
export interface KillableChild {
pid?: number | null
killed?: boolean
kill: (signal: string) => void
}
/**
* Stop a managed child process, choosing the right strategy for the platform.
* No-ops silently if `child` is falsy, already killed, or the kill attempt
* throws (the process may already be gone) -- mirrors the original inline
* best-effort semantics in main.ts.
*/
export function stopBackendChild(child: KillableChild | null | undefined, deps: StopBackendChildDeps) {
if (!child || child.killed) {
return
}
const isWindows = deps.isWindows ?? process.platform === 'win32'
try {
if (isWindows && Number.isInteger(child.pid)) {
deps.forceKillProcessTree(child.pid as number)
} else {
child.kill('SIGTERM')
}
} catch {
// Already gone.
}
}

View file

@ -1,9 +1,8 @@
'use strict'
import assert from 'node:assert/strict'
const test = require('node:test')
const assert = require('node:assert/strict')
import { test } from 'vitest'
const { serveBackendArgs, dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs')
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
test('serveBackendArgs builds a headless serve invocation', () => {
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0'])
@ -61,5 +60,6 @@ test('sourceDeclaresServe does not false-positive on the substring "server"', ()
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)
})

View file

@ -1,5 +1,3 @@
'use strict'
// Backend subcommand routing for the desktop-managed Hermes process.
//
// The desktop app launches its own headless backend via `hermes serve` — it
@ -17,8 +15,9 @@
* Build the canonical headless backend argv (always `serve`).
* @param {string} [profile] optional Hermes profile to pin via `--profile`.
*/
function serveBackendArgs(profile) {
export function serveBackendArgs(profile?: string) {
const head = profile ? ['--profile', profile] : []
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
}
@ -28,9 +27,13 @@ function serveBackendArgs(profile) {
* `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is
* no `serve` token the argv is returned unchanged.
*/
function dashboardFallbackArgs(args) {
export function dashboardFallbackArgs(args) {
const i = args.indexOf('serve')
if (i === -1) return args.slice()
if (i === -1) {
return args.slice()
}
return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
}
@ -40,12 +43,6 @@ function dashboardFallbackArgs(args) {
* specifically so the substring "server" (e.g. "start_server", "web server")
* never produces a false positive.
*/
function sourceDeclaresServe(dashboardPySource) {
export function sourceDeclaresServe(dashboardPySource) {
return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
}
module.exports = {
serveBackendArgs,
dashboardFallbackArgs,
sourceDeclaresServe
}

View file

@ -1,15 +1,16 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')
import assert from 'node:assert/strict'
import path from 'node:path'
const {
POSIX_SANE_PATH_ENTRIES,
import { test } from 'vitest'
import {
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
normalizeHermesHomeRoot,
pathEnvKey
} = require('./backend-env.cjs')
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
} from './backend-env'
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
const result = buildDesktopBackendPath({

View file

@ -1,4 +1,4 @@
const path = require('node:path')
import path from 'node:path'
// Match the POSIX fallback surface used by the Python terminal environment.
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
@ -23,12 +23,16 @@ function pathModuleForPlatform(platform = process.platform) {
}
function pathEnvKey(env = process.env, platform = process.platform) {
if (platform !== 'win32') return 'PATH'
if (platform !== 'win32') {
return 'PATH'
}
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
}
function currentPathValue(env = process.env, platform = process.platform) {
const key = pathEnvKey(env, platform)
return env?.[key] || ''
}
@ -37,10 +41,17 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
const ordered = []
for (const entry of entries) {
if (!entry) continue
if (!entry) {
continue
}
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
for (const part of parts) {
if (!part || seen.has(part)) continue
if (!part || seen.has(part)) {
continue
}
seen.add(part)
ordered.push(part)
}
@ -55,7 +66,7 @@ function buildDesktopBackendPath({
currentPath = '',
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
}: any = {}) {
const delimiter = delimiterForPlatform(platform)
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
@ -64,13 +75,18 @@ function buildDesktopBackendPath({
return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter })
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
if (!hermesHome) return hermesHome
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) {
if (!hermesHome) {
return hermesHome
}
const resolved = pathModule.resolve(String(hermesHome))
const parent = pathModule.dirname(resolved)
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
return pathModule.dirname(parent)
}
return resolved
}
@ -81,7 +97,7 @@ function buildDesktopBackendEnv({
currentEnv = process.env,
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
} = {}) {
}: any = {}) {
const delimiter = delimiterForPlatform(platform)
const currentPythonPath = currentEnv?.PYTHONPATH || ''
const key = pathEnvKey(currentEnv, platform)
@ -98,12 +114,12 @@ function buildDesktopBackendEnv({
}
}
module.exports = {
POSIX_SANE_PATH_ENTRIES,
export {
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
normalizeHermesHomeRoot,
pathEnvKey
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
}

View file

@ -1,17 +1,18 @@
/**
* Tests for electron/backend-probes.cjs.
* Tests for electron/backend-probes.ts.
*
* Run with: node --test electron/backend-probes.test.cjs
* Run with: node --test electron/backend-probes.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } = require('./backend-probes.cjs')
import { test } from 'vitest'
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
// 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
@ -67,6 +68,7 @@ test('verifyHermesCli returns true when --version exits 0', () => {
// verifyHermesCli only cares about the exit code.
const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`)
fs.writeFileSync(scriptPath, 'process.exit(0)\n')
try {
// Use node as the launcher and our script as the "command". Pass
// shell:false (default) -- node is a real binary, no shim.

View file

@ -1,8 +1,8 @@
/**
* backend-probes.cjs
* backend-probes.ts
*
* Cheap "does this candidate backend actually work" checks used by
* resolveHermesBackend (main.cjs). The resolver walks a ladder of
* resolveHermesBackend (main.ts). The resolver walks a ladder of
* candidates -- bootstrap marker, `hermes` on PATH, system Python with
* hermes_cli installed -- and historically returned the first candidate
* whose binary existed on disk. That assumption breaks when a user has
@ -27,12 +27,12 @@
* via the caller's catch block if it chooses)
* - any throw -> false (never propagate -- resolver wants a boolean)
*
* Kept in a standalone cjs module so it can be unit-tested with
* Kept in a standalone ts module so it can be unit-tested with
* `node --test` without dragging in the electron runtime (same pattern
* as bootstrap-platform.cjs and hardening.cjs).
* as bootstrap-platform.ts and hardening.ts).
*/
const { execFileSync } = require('node:child_process')
import { execFileSync } from 'node:child_process'
const PROBE_TIMEOUT_MS = 5000
@ -62,12 +62,14 @@ function hermesRuntimeImportProbe() {
* through PYTHONPATH but lack PyYAML, then die on the first real CLI import.
*
* @param {string} pythonPath - Absolute path to a python.exe / python.
* @param {object} [opts]
* @param {object} [opts.env] - Additional environment for the probe.
* @returns {boolean}
*/
function canImportHermesCli(pythonPath, opts = {}) {
if (!pythonPath) return false
function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) {
if (!pythonPath) {
return false
}
try {
execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
env: { ...process.env, ...(opts.env || {}) },
@ -75,6 +77,7 @@ function canImportHermesCli(pythonPath, opts = {}) {
timeout: PROBE_TIMEOUT_MS,
windowsHide: true
})
return true
} catch {
return false
@ -95,31 +98,29 @@ function canImportHermesCli(pythonPath, opts = {}) {
*
* @param {string} hermesCommand - Resolved absolute path to a hermes
* executable (or an interpreter+script wrapper).
* @param {object} [opts]
* @param {boolean} [opts.shell] - Whether to run through a shell. For
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
* the cmd interpreter; mirrors the same flag isCommandScript() drives
* in resolveHermesBackend.
* @returns {boolean}
*/
function verifyHermesCli(hermesCommand, opts = {}) {
if (!hermesCommand) return false
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
if (!hermesCommand) {
return false
}
try {
execFileSync(hermesCommand, ['--version'], {
stdio: 'ignore',
timeout: PROBE_TIMEOUT_MS,
shell: Boolean(opts.shell),
shell: Boolean(opts?.shell),
windowsHide: true
})
return true
} catch {
return false
}
}
module.exports = {
canImportHermesCli,
hermesRuntimeImportProbe,
verifyHermesCli,
PROBE_TIMEOUT_MS
}
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, verifyHermesCli }

View file

@ -1,7 +1,7 @@
/**
* Tests for electron/backend-ready.cjs.
* Tests for electron/backend-ready.ts.
*
* Run with: node --test electron/backend-ready.test.cjs
* Run with: node --test electron/backend-ready.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*
* Covers the cold-start port-announcement deadline (issue #50209): the clock
@ -11,29 +11,35 @@
* HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { EventEmitter } = require('node:events')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const {
import { test } from 'vitest'
import {
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
} = require('./backend-ready.cjs')
waitForDashboardReadyFile
} from './backend-ready'
type FakeChildProcess = EventEmitter & {
stdout: EventEmitter
}
// A minimal stand-in for a spawned child process: an EventEmitter with a
// stdout EventEmitter, matching the surface waitForDashboardPort consumes
// (child.stdout.on('data'), child.on('exit'|'error') + the .off() teardown).
function makeFakeChild() {
const child = new EventEmitter()
function makeFakeChild(): FakeChildProcess {
const child = new EventEmitter() as FakeChildProcess
child.stdout = new EventEmitter()
return child
}
@ -139,6 +145,7 @@ test('a late announcement after timeout does not throw (listeners torn down)', a
function mkTmpReadyFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
return {
dir,
file: path.join(dir, 'ready.json'),
@ -148,6 +155,7 @@ function mkTmpReadyFile() {
test('readDashboardReadyFile returns a valid port from JSON', () => {
const tmp = mkTmpReadyFile()
try {
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
assert.equal(readDashboardReadyFile(tmp.file), 4567)
@ -158,6 +166,7 @@ test('readDashboardReadyFile returns a valid port from JSON', () => {
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
const tmp = mkTmpReadyFile()
try {
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, '{')
@ -172,6 +181,7 @@ test('readDashboardReadyFile ignores missing, malformed, or invalid files', () =
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
@ -184,6 +194,7 @@ test('waitForDashboardReadyFile resolves when the ready file appears', async ()
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
@ -196,6 +207,7 @@ test('waitForDashboardPortAnnouncement uses ready file when provided', async ()
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
child.emit('exit', 1, null)

View file

@ -1,4 +1,4 @@
const fs = require('node:fs')
import fs from 'node:fs'
// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard`
// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn
@ -26,9 +26,11 @@ const MIN_PORT_ANNOUNCE_TIMEOUT_MS = 45_000
*/
function resolvePortAnnounceTimeoutMs(env = process.env) {
const parsed = Number(env.HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS)
if (Number.isFinite(parsed) && parsed > 0) {
return Math.max(MIN_PORT_ANNOUNCE_TIMEOUT_MS, Math.round(parsed))
}
return DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS
}
@ -55,7 +57,10 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
let done = false
function cleanup() {
if (done) return
if (done) {
return
}
done = true
clearTimeout(timer)
child.stdout.off('data', onData)
@ -66,13 +71,16 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
function onData(chunk) {
buf += chunk.toString()
let nl
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
const m = line.match(_READY_RE)
if (m) {
cleanup()
resolve(parseInt(m[1], 10))
return
}
}
@ -99,11 +107,15 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs())
})
}
function readDashboardReadyFile(readyFile) {
if (!readyFile) return null
function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) {
if (!readyFile) {
return null
}
try {
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
const port = Number(parsed?.port)
return Number.isInteger(port) && port > 0 ? port : null
} catch {
return null
@ -116,16 +128,24 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
let interval = null
function cleanup() {
if (done) return
if (done) {
return
}
done = true
clearTimeout(timer)
if (interval) clearInterval(interval)
if (interval) {
clearInterval(interval)
}
child.off('exit', onExit)
child.off('error', onError)
}
function check() {
const port = readDashboardReadyFile(readyFile)
if (port) {
cleanup()
resolve(port)
@ -150,25 +170,37 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno
child.on('exit', onExit)
child.on('error', onError)
interval = setInterval(check, 50)
if (typeof interval.unref === 'function') interval.unref()
if (typeof interval.unref === 'function') {
interval.unref()
}
check()
})
}
function waitForDashboardPortAnnouncement(child, options = {}) {
function waitForDashboardPortAnnouncement(
child,
options: {
readyFile?: fs.PathOrFileDescriptor
timeoutMs?: number
} = {}
) {
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
if (options.readyFile) {
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs)
}
return waitForDashboardPort(child, timeoutMs)
}
module.exports = {
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile,
export {
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile
}

View file

@ -1,14 +1,13 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
import assert from 'node:assert/strict'
const {
import { test } from 'vitest'
import {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
} = require('./bootstrap-platform.cjs')
} from './bootstrap-platform'
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
@ -85,27 +84,3 @@ test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both wa
null
)
})
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
const electronDir = __dirname
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
// - electron: provided by the electron runtime, always resolvable in packaged builds.
// - node-pty: hoisted by workspace dedup AND shipped via extraResources to
// resources/native-deps/node-pty (see scripts/stage-native-deps.cjs). main.cjs
// has a try/catch fallback at line ~38 that resolves the staged copy when the
// bare require fails in the packaged asar, so the bare require itself is by
// design rather than an oversight.
const allowedBareRequires = new Set(['electron', 'node-pty'])
const requirePattern = /require\(['"]([^'"]+)['"]\)/g
for (const entrypoint of entrypoints) {
const source = fs.readFileSync(path.join(electronDir, entrypoint), 'utf8')
const bareRequires = Array.from(source.matchAll(requirePattern))
.map(match => match[1])
.filter(specifier => !specifier.startsWith('node:'))
.filter(specifier => !specifier.startsWith('.'))
.filter(specifier => !allowedBareRequires.has(specifier))
assert.deepEqual(bareRequires, [], `${entrypoint} has unpackaged runtime requires`)
}
})

View file

@ -1,20 +1,32 @@
const fs = require('node:fs')
import fs from 'node:fs'
function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) {
if (platform !== 'linux') return false
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true
if (platform !== 'linux') {
return false
}
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
return true
}
try {
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
return /microsoft|wsl/i.test(release)
} catch {
return false
}
}
function isWindowsBinaryPathInWsl(filePath, options = {}) {
function isWindowsBinaryPathInWsl(
filePath,
options: { isWsl?: boolean; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}
) {
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
if (!isWsl) return false
if (!isWsl) {
return false
}
const normalized = String(filePath || '')
.replace(/\\/g, '/')
@ -48,19 +60,27 @@ const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
*
* Pure + dependency-free so it can be unit-tested and called before app ready.
*/
function detectRemoteDisplay(options = {}) {
function detectRemoteDisplay(options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim()
.toLowerCase()
if (GPU_OVERRIDE_ON.has(override)) return 'override (HERMES_DESKTOP_DISABLE_GPU)'
if (GPU_OVERRIDE_OFF.has(override)) return null
if (GPU_OVERRIDE_ON.has(override)) {
return 'override (HERMES_DESKTOP_DISABLE_GPU)'
}
if (GPU_OVERRIDE_OFF.has(override)) {
return null
}
// Launched from an SSH session → the display is X11-forwarded or otherwise
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return 'ssh-session'
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
return 'ssh-session'
}
if (platform === 'linux') {
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
@ -68,6 +88,7 @@ function detectRemoteDisplay(options = {}) {
// NB: WSLg deliberately isn't treated as remote — it reports
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
const display = String(env.DISPLAY || '')
if (display.includes(':') && display.split(':')[0]) {
return `x11-forwarding (DISPLAY=${display})`
}
@ -77,15 +98,13 @@ function detectRemoteDisplay(options = {}) {
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
// "Console".
const sessionName = String(env.SESSIONNAME || '')
if (/^rdp-/i.test(sessionName)) return `rdp (SESSIONNAME=${sessionName})`
if (/^rdp-/i.test(sessionName)) {
return `rdp (SESSIONNAME=${sessionName})`
}
}
return null
}
module.exports = {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
}
export { bundledRuntimeImportCheck, detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment }

View file

@ -1,15 +1,19 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const {
runBootstrap,
resolveInstallScript,
import { test } from 'vitest'
import {
buildPinArgs,
buildPosixPinArgs,
cachedScriptPath,
hasExistingGitCheckout,
installedAgentInstallScript,
cachedScriptPath
} = require('./bootstrap-runner.cjs')
resolveInstallScript,
runBootstrap
} from './bootstrap-runner'
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
@ -22,6 +26,7 @@ test('runBootstrap bails immediately when the signal is already aborted', async
controller.abort()
const events = []
const result = await runBootstrap({
installStamp: null,
activeRoot: '/tmp/hermes-runner-test',
@ -42,6 +47,7 @@ test('runBootstrap bails immediately when the signal is already aborted', async
test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
const home = mkTmpHome()
try {
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
@ -57,8 +63,61 @@ test('installedAgentInstallScript resolves the installer in the agent checkout',
}
})
test('existing checkout detection requires git metadata', () => {
const home = mkTmpHome()
try {
const activeRoot = path.join(home, 'hermes-agent')
assert.equal(hasExistingGitCheckout(activeRoot), false)
fs.mkdirSync(path.join(activeRoot, '.git'), { recursive: true })
assert.equal(hasExistingGitCheckout(activeRoot), true)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('fresh bootstrap args include the packaged commit pin', () => {
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
assert.deepEqual(buildPinArgs(installStamp), ['-Commit', installStamp.commit, '-Branch', 'main'])
assert.deepEqual(
buildPosixPinArgs({
installStamp,
activeRoot: '/tmp/hermes-agent',
hermesHome: '/tmp/hermes'
}),
[
'--dir',
'/tmp/hermes-agent',
'--hermes-home',
'/tmp/hermes',
'--branch',
'main',
'--commit',
installStamp.commit
]
)
})
test('existing-checkout bootstrap args keep branch but skip the packaged commit pin', () => {
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
assert.deepEqual(buildPinArgs(installStamp, { pinCommit: false }), ['-Branch', 'main'])
assert.deepEqual(
buildPosixPinArgs({
installStamp,
activeRoot: '/tmp/hermes-agent',
hermesHome: '/tmp/hermes',
pinCommit: false
}),
['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main']
)
})
test('resolveInstallScript prefers a cached script without touching the network', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
const cached = cachedScriptPath(home, commit)
@ -66,6 +125,7 @@ test('resolveInstallScript prefers a cached script without touching the network'
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
@ -82,6 +142,7 @@ test('resolveInstallScript prefers a cached script without touching the network'
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// Seed the installed agent checkout so the fallback has something to resolve.
@ -91,6 +152,7 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
@ -117,6 +179,7 @@ test('resolveInstallScript falls back to the installed agent checkout on a 404',
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// No installed agent checkout seeded -> nothing to fall back to.

View file

@ -1,16 +1,14 @@
'use strict'
/**
* bootstrap-runner.cjs
* bootstrap-runner.ts
*
* Drives apps/desktop's first-launch install of Hermes Agent by spawning
* scripts/install.ps1 stage-by-stage and streaming progress events back to
* the renderer.
*
* Wired from electron/main.cjs:
* const { runBootstrap } = require('./bootstrap-runner.cjs')
* Wired from electron/main.ts:
* import { runBootstrap }from './bootstrap-runner'
* const result = await runBootstrap({
* installStamp, // INSTALL_STAMP from main.cjs (may be null in dev)
* installStamp, // INSTALL_STAMP from main.ts (may be null in dev)
* activeRoot, // ACTIVE_HERMES_ROOT
* sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup)
* hermesHome, // HERMES_HOME
@ -34,21 +32,16 @@
* no UI consumes them yet)
*/
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import https from 'node:https'
import path from 'node:path'
import { hiddenWindowsChildOptions } from './windows-child-options'
const IS_WINDOWS = process.platform === 'win32'
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
// Stages flagged needs_user_input=true in the manifest are skipped by the
@ -71,10 +64,15 @@ function installScriptKind() {
}
function resolveLocalInstallScript(sourceRepoRoot) {
if (!sourceRepoRoot) return null
if (!sourceRepoRoot) {
return null
}
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
try {
fs.accessSync(candidate, fs.constants.R_OK)
return candidate
} catch {
return null
@ -90,16 +88,33 @@ function bootstrapCacheDir(hermesHome) {
// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop
// app stamped to an unpushed HEAD).
function installedAgentInstallScript(hermesHome) {
if (!hermesHome) return null
if (!hermesHome) {
return null
}
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
try {
fs.accessSync(candidate, fs.constants.R_OK)
return candidate
} catch {
return null
}
}
function hasExistingGitCheckout(activeRoot) {
if (!activeRoot) {
return false
}
try {
return fs.existsSync(path.join(activeRoot, '.git'))
} catch {
return false
}
}
function cachedScriptPath(hermesHome, commit) {
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
}
@ -110,6 +125,7 @@ function downloadInstallScript(commit, destPath) {
// verification beyond "did the file we wrote pass a syntax probe."
const scriptName = installScriptName()
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}`
return new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(destPath), { recursive: true })
const tmpPath = destPath + '.tmp'
@ -129,8 +145,10 @@ function downloadInstallScript(commit, destPath) {
`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`
)
)
return
}
const out2 = fs.createWriteStream(tmpPath)
res2.pipe(out2)
out2.on('finish', () => {
@ -141,18 +159,24 @@ function downloadInstallScript(commit, destPath) {
out2.on('error', reject)
})
.on('error', reject)
return
}
if (res.statusCode !== 200) {
out.close()
try {
fs.unlinkSync(tmpPath)
} catch {
void 0
}
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
return
}
res.pipe(out)
out.on('finish', () => {
out.close()
@ -165,6 +189,7 @@ function downloadInstallScript(commit, destPath) {
} catch {
void 0
}
reject(err)
})
})
@ -174,6 +199,7 @@ function downloadInstallScript(commit, destPath) {
} catch {
void 0
}
reject(err)
})
})
@ -187,11 +213,13 @@ async function resolveInstallScript({
_download = downloadInstallScript
}) {
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
// without pushing. SOURCE_REPO_ROOT comes from main.ts (path.resolve
// of APP_ROOT/../..).
const localScript = resolveLocalInstallScript(sourceRepoRoot)
if (localScript) {
emit({ type: 'log', line: `[bootstrap] using local ${installScriptName()} at ${localScript}` })
return { path: localScript, source: 'local', kind: installScriptKind() }
}
@ -204,12 +232,14 @@ async function resolveInstallScript({
}
const cached = cachedScriptPath(hermesHome, installStamp.commit)
try {
await fsp.access(cached, fs.constants.R_OK)
emit({
type: 'log',
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
})
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// not cached; download
@ -219,17 +249,20 @@ async function resolveInstallScript({
type: 'log',
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
})
try {
await _download(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
} catch (err) {
// The pinned commit may not be fetchable from GitHub -- most commonly a
// locally-built desktop app stamped to an unpushed HEAD (see
// write-build-stamp.cjs fromLocalGit). Fall back to the installer that
// write-build-stamp.mjs fromLocalGit). Fall back to the installer that
// ships inside the already-installed agent checkout so dev/self-builds can
// still bootstrap instead of dying with a fatal 404.
const installed = installedAgentInstallScript(hermesHome)
if (installed) {
emit({
type: 'log',
@ -237,15 +270,18 @@ async function resolveInstallScript({
`[bootstrap] GitHub fetch failed (${err.message}); ` +
`falling back to installed agent ${installScriptName()} at ${installed}`
})
try {
fs.mkdirSync(path.dirname(cached), { recursive: true })
fs.copyFileSync(installed, cached)
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// Cache copy failed (read-only FS, etc.) -- use the source path directly.
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
}
}
throw err
}
}
@ -271,31 +307,41 @@ function powershellUnderRoot(root) {
function resolveWindowsPowerShell() {
for (const v of ['SystemRoot', 'windir']) {
const root = process.env[v]
if (root) {
const candidate = powershellUnderRoot(root)
try {
if (fs.statSync(candidate).isFile()) return candidate
if (fs.statSync(candidate).isFile()) {
return candidate
}
} catch {
void 0
}
}
}
const pathDirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)
for (const exe of ['powershell.exe', 'pwsh.exe']) {
for (const dir of pathDirs) {
const candidate = path.join(dir, exe)
try {
if (fs.statSync(candidate).isFile()) return candidate
if (fs.statSync(candidate).isFile()) {
return candidate
}
} catch {
void 0
}
}
}
return 'powershell.exe'
}
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
return new Promise((resolve, reject) => {
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
return new Promise<any>((resolve, reject) => {
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
@ -319,12 +365,14 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const onAbort = () => {
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
onAbort()
@ -342,10 +390,14 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
stdout += chunk
stdoutBuf += chunk
let nl
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
}
}
})
@ -354,30 +406,46 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
stderr += chunk
stderrBuf += chunk
let nl
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
}
}
})
child.on('error', err => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
reject(err)
})
child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
// Flush any trailing bytes
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
resolve({ stdout, stderr, code, signal, killed })
if (stdoutBuf) {
emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' } as any)
}
if (stderrBuf) {
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' } as any)
}
resolve({ stdout, stderr, code, signal, killed } as any)
})
})
}
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
return new Promise((resolve, reject) => {
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome }: any = {}) {
return new Promise<any>((resolve, reject) => {
const child = spawn('bash', [scriptPath, ...args], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
@ -392,12 +460,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
const onAbort = () => {
killed = true
try {
child.kill('SIGTERM')
} catch {
void 0
}
}
if (abortSignal) {
if (abortSignal.aborted) {
onAbort()
@ -414,10 +484,14 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
stdout += chunk
stdoutBuf += chunk
let nl
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
stdoutBuf = stdoutBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stdout' })
}
}
})
@ -426,22 +500,38 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
stderr += chunk
stderrBuf += chunk
let nl
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
stderrBuf = stderrBuf.slice(nl + 1)
if (line) emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
if (line) {
emit && emit({ type: 'log', stage: stageName, line, stream: 'stderr' })
}
}
})
child.on('error', err => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
reject(err)
})
child.on('close', (code, signal) => {
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
if (abortSignal) {
abortSignal.removeEventListener('abort', onAbort)
}
if (stdoutBuf) {
emit && emit({ type: 'log', stage: stageName, line: stdoutBuf, stream: 'stdout' })
}
if (stderrBuf) {
emit && emit({ type: 'log', stage: stageName, line: stderrBuf, stream: 'stderr' })
}
resolve({ stdout, stderr, code, signal, killed })
})
})
@ -451,53 +541,74 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
// Manifest + stage dispatch
// ---------------------------------------------------------------------------
// Build the install.ps1 pin args (-Commit / -Branch) from the install-stamp
// so the repository stage clones the exact SHA the .exe was tested with
// instead of falling back to install.ps1's default ($Branch = "main").
function buildPinArgs(installStamp) {
// Build the installer branch/pin args from the install stamp. The commit pin
// is fresh-install only: once a managed checkout already exists, bootstrap is
// a repair/update path and must not let an old packaged app detach the checkout
// back to the commit baked into that app.
function buildPinArgs(installStamp, { pinCommit = true } = {}) {
const args = []
if (installStamp && installStamp.commit) {
if (pinCommit && installStamp && installStamp.commit) {
args.push('-Commit', installStamp.commit)
}
if (installStamp && installStamp.branch) {
args.push('-Branch', installStamp.branch)
}
return args
}
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) {
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit = true }) {
const args = ['--dir', activeRoot, '--hermes-home', hermesHome]
if (installStamp && installStamp.branch) {
args.push('--branch', installStamp.branch)
}
if (installStamp && installStamp.commit) {
if (pinCommit && installStamp && installStamp.commit) {
args.push('--commit', installStamp.commit)
}
return args
}
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) {
async function fetchManifest({
scriptPath,
installerKind,
emit,
hermesHome,
activeRoot,
installStamp,
pinCommit
}) {
const isPosix = installerKind === 'posix'
const args = isPosix
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
: ['-Manifest', ...buildPinArgs(installStamp)]
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit })]
: ['-Manifest', ...buildPinArgs(installStamp, { pinCommit })]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
stageName: '__manifest__',
hermesHome
})
if (result.code !== 0) {
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`
)
}
// The manifest is the LAST JSON line on stdout (install.ps1 may print
// banner / info lines first depending on Console.OutputEncoding effects).
// Find the last line that parses as JSON with a `stages` field.
const lines = result.stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i])
if (parsed && Array.isArray(parsed.stages)) {
return parsed
}
@ -505,6 +616,7 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
void 0
}
}
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`
)
@ -515,9 +627,11 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
// for the double-emit bug we addressed in the install.ps1 PR).
function parseStageResult(stdout) {
const lines = stdout.split(/\r?\n/).filter(Boolean)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const parsed = JSON.parse(lines[i])
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
return parsed
}
@ -525,23 +639,36 @@ function parseStageResult(stdout) {
void 0
}
}
return null
}
async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, activeRoot, abortSignal, installStamp }) {
async function runStage({
scriptPath,
installerKind,
stage,
emit,
hermesHome,
activeRoot,
abortSignal,
installStamp,
pinCommit
}) {
const startedAt = Date.now()
emit({ type: 'stage', name: stage.name, state: 'running' })
const isPosix = installerKind === 'posix'
const args = isPosix
? [
'--stage',
stage.name,
'--non-interactive',
'--json',
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })
...buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit })
]
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp, { pinCommit })]
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
emit,
stageName: stage.name,
@ -554,6 +681,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
if (result.killed) {
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' }
emit(ev)
return ev
}
@ -568,20 +696,26 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
error: `${isPosix ? 'install.sh --stage' : 'install.ps1 -Stage'} ${stage.name} produced no JSON result frame (exit=${result.code})`,
json: null
}
emit(ev)
return ev
}
if (json.ok && json.skipped) {
const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json }
emit(ev)
return ev
}
if (json.ok) {
const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json }
emit(ev)
return ev
}
const ev = {
type: 'stage',
name: stage.name,
@ -590,7 +724,9 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
json,
error: json.reason || `exit code ${result.code}`
}
emit(ev)
return ev
}
@ -603,6 +739,7 @@ function openRunLog(logRoot) {
const ts = new Date().toISOString().replace(/[:.]/g, '-')
const logPath = path.join(logRoot, `bootstrap-${ts}.log`)
const stream = fs.createWriteStream(logPath, { flags: 'a' })
return { path: logPath, stream }
}
@ -619,7 +756,7 @@ async function runBootstrap(opts) {
logRoot,
onEvent,
abortSignal,
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
writeMarker // callback to write the bootstrap-complete marker; main.ts provides
} = opts
// Bail before spawning anything if the user already cancelled — otherwise an
@ -633,6 +770,7 @@ async function runBootstrap(opts) {
void 0
}
}
return { ok: false, cancelled: true }
}
@ -646,8 +784,11 @@ async function runBootstrap(opts) {
} catch {
void 0
}
try {
if (typeof onEvent === 'function') onEvent(ev)
if (typeof onEvent === 'function') {
onEvent(ev)
}
} catch (err) {
// Don't let a subscriber bug crash the bootstrap
runLog.stream.write(`emit error: ${err && err.message}\n`)
@ -664,6 +805,18 @@ async function runBootstrap(opts) {
})
try {
const existingCheckout = hasExistingGitCheckout(activeRoot)
const pinCommit = !existingCheckout
if (existingCheckout && installStamp && installStamp.commit) {
emit({
type: 'log',
line:
`[bootstrap] existing checkout detected at ${activeRoot}; ` +
`not pinning to packaged install stamp ${installStamp.commit.slice(0, 12)}`
})
}
// 1. Resolve the platform installer.
const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit })
const installerKind = scriptInfo.kind || 'powershell'
@ -675,8 +828,10 @@ async function runBootstrap(opts) {
emit,
hermesHome,
activeRoot,
installStamp
installStamp,
pinCommit
})
emit({
type: 'manifest',
stages: manifest.stages,
@ -690,8 +845,10 @@ async function runBootstrap(opts) {
for (const stage of manifest.stages) {
if (abortSignal && abortSignal.aborted) {
emit({ type: 'failed', error: 'bootstrap cancelled by user' })
return { ok: false, cancelled: true }
}
const ev = await runStage({
scriptPath: scriptInfo.path,
installerKind,
@ -700,11 +857,14 @@ async function runBootstrap(opts) {
hermesHome,
activeRoot,
abortSignal,
installStamp
installStamp,
pinCommit
})
if (ev.state === 'failed') {
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' })
return { ok: false, failedStage: stage.name, error: ev.error }
emit({ type: 'failed', stage: stage.name, error: (ev as any).error || 'stage failed' })
return { ok: false, failedStage: stage.name, error: (ev as any).error }
}
}
@ -713,11 +873,14 @@ async function runBootstrap(opts) {
pinnedCommit: installStamp ? installStamp.commit : null,
pinnedBranch: installStamp ? installStamp.branch : null
}
const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload
emit({ type: 'complete', marker })
return { ok: true, marker }
} catch (err) {
emit({ type: 'failed', error: err.message || String(err) })
return { ok: false, error: err.message || String(err) }
} finally {
try {
@ -728,12 +891,15 @@ async function runBootstrap(opts) {
}
}
module.exports = {
runBootstrap,
export {
buildPinArgs,
buildPosixPinArgs,
cachedScriptPath,
hasExistingGitCheckout,
installedAgentInstallScript,
// Exposed for testability
parseStageResult,
resolveLocalInstallScript,
resolveInstallScript,
installedAgentInstallScript,
cachedScriptPath
resolveLocalInstallScript,
runBootstrap
}

View file

@ -1,7 +1,7 @@
/**
* Tests for electron/connection-config.cjs.
* Tests for electron/connection-config.ts.
*
* Run with: node --test electron/connection-config.test.cjs
* Run with: node --test electron/connection-config.test.ts
* (Wire into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the remote-gateway connection settings:
@ -10,26 +10,29 @@
* and the OAuth session-cookie detector.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
const {
import { test } from 'vitest'
import {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
cookiesHavePrivySession,
cookiesHaveSession,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
tokenPreview
} = require('./connection-config.cjs')
} from './connection-config'
// --- connectionScopeKey / normAuthMode ---
@ -47,6 +50,19 @@ test('normAuthMode coerces to token unless explicitly oauth', () => {
assert.equal(normAuthMode('weird'), 'token')
})
// --- modeIsRemoteLike ---
test('modeIsRemoteLike is true for remote and cloud, false otherwise', () => {
// cloud resolves to a remote backend under the hood (Q6), so every resolution
// site treats it like remote.
assert.equal(modeIsRemoteLike('remote'), true)
assert.equal(modeIsRemoteLike('cloud'), true)
assert.equal(modeIsRemoteLike('local'), false)
assert.equal(modeIsRemoteLike(undefined), false)
assert.equal(modeIsRemoteLike(null), false)
assert.equal(modeIsRemoteLike('weird'), false)
})
// --- profileRemoteOverride ---
test('profileRemoteOverride returns null when no profile is given', () => {
@ -73,6 +89,7 @@ test('profileRemoteOverride returns the per-profile remote with defaulted auth m
coder: { mode: 'remote', url: ' https://coder.example.com/hermes ', token: { value: 'sek' } }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://coder.example.com/hermes',
authMode: 'token',
@ -85,6 +102,21 @@ test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
})
test('profileRemoteOverride treats a cloud entry as a remote override', () => {
// A 'cloud' per-profile entry resolves to the same remote backend a 'remote'
// entry would (Q6) — the override must be returned, not dropped.
const config = {
profiles: {
coder: { mode: 'cloud', url: 'https://agent-1.agents.nousresearch.com', authMode: 'oauth' }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://agent-1.agents.nousresearch.com',
authMode: 'oauth',
token: undefined
})
})
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride({}, 'coder'), null)
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
@ -331,6 +363,35 @@ test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', ()
assert.equal(cookiesHaveLiveSession([]), false)
})
// --- cookiesHavePrivySession (Nous portal / Privy auth, NOT gateway cookies) ---
test('cookiesHavePrivySession detects the privy-token access cookie', () => {
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: 'jwt' }]), true)
})
test('cookiesHavePrivySession detects __Host-/__Secure- prefixes and the legacy privy-session name', () => {
assert.equal(cookiesHavePrivySession([{ name: '__Host-privy-token', value: 'x' }]), true)
assert.equal(cookiesHavePrivySession([{ name: '__Secure-privy-token', value: 'x' }]), true)
assert.equal(cookiesHavePrivySession([{ name: 'privy-session', value: 'x' }]), true)
})
test('cookiesHavePrivySession is false for an empty value', () => {
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: '' }]), false)
})
test('cookiesHavePrivySession does NOT treat hermes gateway cookies as a portal session', () => {
// The whole point of Q7: a gateway session cookie is NOT a portal sign-in.
assert.equal(cookiesHavePrivySession([{ name: 'hermes_session_at', value: 'x' }]), false)
assert.equal(cookiesHavePrivySession([{ name: '__Host-hermes_session_rt', value: 'x' }]), false)
})
test('cookiesHavePrivySession is false for unrelated cookies and non-arrays', () => {
assert.equal(cookiesHavePrivySession([{ name: 'other', value: 'x' }]), false)
assert.equal(cookiesHavePrivySession(null), false)
assert.equal(cookiesHavePrivySession(undefined), false)
assert.equal(cookiesHavePrivySession([]), false)
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
@ -365,6 +426,7 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
const url = await resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
mintTicket: async () => 'tkt-9'
})
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
})
@ -376,13 +438,14 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio
throw new Error('401 ticket mint failed')
}
}),
err => {
(err: any) => {
// Actionable, points the user at re-auth, and preserves the cause + flag
// the boot overlay uses to offer a sign-in prompt.
assert.match(err.message, /WebSocket ticket/i)
assert.match(err.message, /sign in again/i)
assert.equal(err.needsOauthLogin, true)
assert.ok(err.cause instanceof Error)
return true
}
)

View file

@ -1,13 +1,13 @@
/**
* connection-config.cjs
* connection-config.ts
*
* Pure, electron-free helpers for the desktop's remote-gateway connection
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
* auth-mode classification, and the auth-mode coercion rules.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` same pattern as backend-probes.cjs / bootstrap-platform.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
* Kept standalone (no `import 'electron'`) so it can be unit-tested with
* `node --test` same pattern as backend-probes.ts / bootstrap-platform.ts.
* main.ts requires these and wires them into the electron-coupled IPC layer.
*
* Background on the two auth models a remote gateway can use:
* - 'token': legacy static dashboard session token. REST uses an
@ -37,6 +37,15 @@
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
// The Nous portal (NAS) does NOT use Hermes gateway session cookies — it is a
// Privy-authed Next.js app. NAS `auth()` (src/server/auth/session.ts) reads the
// `privy-token` access-token cookie (with `privy-id-token` alongside), which is
// also exactly what the `/api/agents` cookie-auth path validates. So portal
// sign-in / discovery liveness must look for the Privy cookie, NOT the gateway
// cookies above. `privy-token` is the access token (the required signal);
// variants cover the secured-prefix forms and the older `privy-session` name.
const PRIVY_SESSION_COOKIE_VARIANTS = ['__Host-privy-token', '__Secure-privy-token', 'privy-token', 'privy-session']
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
@ -45,6 +54,7 @@ function normalizeRemoteBaseUrl(rawUrl) {
}
let parsed
try {
parsed = new URL(value)
} catch (error) {
@ -83,7 +93,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* exercise the same transport the app actually uses.
*
* The OAuth ticket-minter is injected (`mintTicket(baseUrl) -> Promise<ticket>`)
* so this stays electron-free and unit-testable; main.cjs passes the real
* so this stays electron-free and unit-testable; main.ts passes the real
* `mintGatewayWsTicket`.
*
* Return semantics:
@ -93,7 +103,7 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* - oauth, mint fails THROWS (NOT a skip)
*
* The oauth-mint-failure throw is the important case: the real boot path
* (resolveRemoteBackend in main.cjs) treats a mint failure as a hard
* (resolveRemoteBackend in main.ts) treats a mint failure as a hard
* "session expired" auth error and refuses to connect. Swallowing it here
* would re-introduce the exact false-positive this test exists to catch
* HTTP /api/status passes, the test reports "reachable", then the renderer
@ -105,13 +115,16 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
* @param {{ mintTicket: (baseUrl: string) => Promise<string> }} deps
* @returns {Promise<string|null>}
*/
async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
if (authMode === 'oauth') {
const mintTicket = deps.mintTicket
if (typeof mintTicket !== 'function') {
throw new Error('resolveTestWsUrl: a mintTicket function is required in OAuth mode.')
}
let ticket
try {
ticket = await mintTicket(baseUrl)
} catch (error) {
@ -119,15 +132,19 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps = {}) {
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
'(it may have expired). Open Settings → Gateway and sign in again.'
)
err.needsOauthLogin = true
;(err as any).needsOauthLogin = true
err.cause = error
throw err
}
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
}
if (!token) {
return null
}
return buildGatewayWsUrl(baseUrl, token)
}
@ -142,23 +159,36 @@ function normAuthMode(mode) {
return mode === 'oauth' ? 'oauth' : 'token'
}
// True for connection modes that resolve to a REMOTE backend. 'cloud' is a
// Hermes Cloud connection (cloud-auto-discovery Q3/Q6): it carries a
// remote-shaped block and reuses the entire remote connect/probe/reconnect
// path, so every resolution site treats it exactly like 'remote'. The only
// places that distinguish cloud from remote are the settings UI (which card to
// show) and config persistence (remembering the provenance). Centralized here
// so no resolution site forgets the third arm.
function modeIsRemoteLike(mode) {
return mode === 'remote' || mode === 'cloud'
}
/**
* Select a profile's explicit remote override from a connection config, or null
* when it has none (so the caller falls back to env global remote local).
*
* The config may carry a `profiles` map keyed by name; an entry counts as an
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
* is the raw stored secret; main.cjs decrypts it. Returns
* override only with a remote-like `mode` (remote or cloud) and a non-empty
* `url`. Pure: `token` is the raw stored secret; main.ts decrypts it. Returns
* `{ url, authMode, token } | null`.
*/
function profileRemoteOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
if (!entry || typeof entry !== 'object' || !modeIsRemoteLike(entry.mode)) {
return null
}
const url = String(entry.url || '').trim()
if (!url) {
return null
}
@ -172,18 +202,21 @@ function profileRemoteOverride(config, profile) {
* query parameter. Local pooled backends and per-profile remote overrides do not
* need this: they already run against a backend scoped to the target profile.
*/
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) {
const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
return path
}
const rawPath = String(path || '')
if (!rawPath) {
return path
}
let parsed
try {
parsed = new URL(rawPath, 'http://hermes.local')
} catch {
@ -224,9 +257,18 @@ function authModeFromStatus(statusBody) {
* Returns 'oauth' | 'token'.
*/
function resolveAuthMode(inputAuthMode, existingAuthMode) {
if (inputAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'token') return 'token'
if (existingAuthMode === 'oauth') return 'oauth'
if (inputAuthMode === 'oauth') {
return 'oauth'
}
if (inputAuthMode === 'token') {
return 'token'
}
if (existingAuthMode === 'oauth') {
return 'oauth'
}
return 'token'
}
@ -242,7 +284,10 @@ function resolveAuthMode(inputAuthMode, existingAuthMode) {
* need to know whether an unexpired access token is present right now.
*/
function cookiesHaveSession(cookies) {
if (!Array.isArray(cookies)) return false
if (!Array.isArray(cookies)) {
return false
}
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
}
@ -260,24 +305,46 @@ function cookiesHaveSession(cookies) {
* the RT is also dead/revoked).
*/
function cookiesHaveLiveSession(cookies) {
if (!Array.isArray(cookies)) return false
if (!Array.isArray(cookies)) {
return false
}
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
}
module.exports = {
/**
* True if the cookie jar holds a live Nous PORTAL (Privy) session a non-empty
* `privy-token` (access-token) cookie, or a variant. This is the portal
* analogue of `cookiesHaveLiveSession`: the portal authenticates via Privy, not
* the Hermes gateway session cookies, so cloud sign-in / discovery liveness
* must check THIS, not the gateway helpers. (NAS `auth()` and the `/api/agents`
* cookie path both key off `privy-token`.)
*/
function cookiesHavePrivySession(cookies) {
if (!Array.isArray(cookies)) {
return false
}
return cookies.some(c => c && c.value && PRIVY_SESSION_COOKIE_VARIANTS.includes(c.name))
}
export {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
normAuthMode,
cookiesHavePrivySession,
cookiesHaveSession,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile,
PRIVY_SESSION_COOKIE_VARIANTS,
profileRemoteOverride,
resolveAuthMode,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
tokenPreview
}

View file

@ -1,21 +1,22 @@
/**
* Tests for electron/dashboard-token.cjs.
* Tests for electron/dashboard-token.ts.
*
* Run with: node --test electron/dashboard-token.test.cjs
* Run with: node --test electron/dashboard-token.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
const {
import { test } from 'vitest'
import {
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
} = require('./dashboard-token.cjs')
} from './dashboard-token'
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
@ -39,9 +40,11 @@ test('dashboardIndexUrl preserves dashboard path prefixes', () => {
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
},
rememberLog: line => logs.push(line)
@ -100,8 +103,9 @@ test('isForeignBackendToken only flags a mismatched token from a dead child', ()
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
]
for (const [input, expected] of cases) {
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
assert.equal(isForeignBackendToken(input as any), expected, JSON.stringify(input))
}
})
@ -128,6 +132,7 @@ test('adoptServedDashboardToken refuses a foreign token when our child is dead',
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
const logs = []
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => {

View file

@ -9,29 +9,39 @@
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
async function fetchPublicText(url, options = {}) {
async function fetchPublicText(url, options: any = {}) {
const { protocol } = new URL(url)
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
if (error.name === 'TimeoutError') {
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
}
throw error
})
const text = await res.text()
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
if (!res.ok) {
throw new Error(`${res.status}: ${text || res.statusText}`)
}
return text
}
function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) return null
if (!match) {
return null
}
try {
return JSON.parse(match[1])
} catch {
@ -43,11 +53,13 @@ function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
}
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) {
const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
})
const servedToken = extractInjectedDashboardToken(html)
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
@ -76,6 +88,7 @@ function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
return spawnToken
})
@ -88,10 +101,10 @@ async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, labe
return servedToken
}
module.exports = {
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
export {
adoptServedDashboardToken,
dashboardIndexUrl,
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,

View file

@ -0,0 +1,98 @@
/**
* Regression: the desktop Electron dependency must be an exact, consistent pin.
*
* The Windows desktop install failed at "Building desktop app" because Electron
* changed its install mechanism mid patch-series:
*
* electron 40.9.3 .. 40.10.2 -> @electron/get@^2 + extract-zip@^2 (pure JS)
* electron 40.10.3 / 40.10.4 -> @electron/get@^5 +
* @electron-internal/extract-zip@^1 (native napi)
*
* ``apps/desktop/package.json`` declared ``electronVersion: 40.9.3`` (the tested,
* JS-extract build) but pinned the dependency loosely as ``electron: ^40.9.3``.
* ``npm ci`` then resolved 40.10.3/40.10.4 the new *native* extract-zip whose
* win32-x64 binding fails to ``dlopen`` on some Windows hosts
* (``ERR_DLOPEN_FAILED loading index.win32-x64-msvc.node``).
*
* These tests lock the contract that prevents that drift, without hard-coding the
* specific version (which is allowed to move):
*
* 1. the Electron dependency is an *exact* version (Electron Builder needs the
* installed binary to match ``electronVersion`` / ``electronDist``), and
* 2. the dependency, ``build.electronVersion``, and the resolved lockfile entry
* all agree so ``npm ci`` installs exactly what the build packages.
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { test } from 'vitest'
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..')
const DESKTOP_PKG = path.join(REPO_ROOT, 'apps', 'desktop', 'package.json')
const ROOT_LOCK = path.join(REPO_ROOT, 'package-lock.json')
// An exact semver: digits.digits.digits with an optional prerelease/build tag,
// but NO range operators (^ ~ > < = * x || spaces || -range).
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
function desktopPkg(): Record<string, unknown> {
assert.ok(fs.existsSync(DESKTOP_PKG), `missing ${DESKTOP_PKG}`)
return JSON.parse(fs.readFileSync(DESKTOP_PKG, 'utf-8'))
}
function electronSpec(pkg: Record<string, unknown>): string {
for (const section of ['dependencies', 'devDependencies'] as const) {
const deps = (pkg[section] ?? {}) as Record<string, string>
const spec = deps['electron']
if (spec) return spec
}
assert.fail('electron is not listed in apps/desktop dependencies')
}
test('electron dependency is exactly pinned', () => {
const spec = electronSpec(desktopPkg())
assert.match(
spec,
EXACT_SEMVER,
`electron must be pinned to an exact version, got "${spec}". ` +
'A range (^/~) lets npm ci resolve a newer Electron whose postinstall ' +
'may differ from the one the build was validated against.'
)
})
test('electron dependency matches build.electronVersion', () => {
const pkg = desktopPkg()
const spec = electronSpec(pkg)
const build = (pkg.build ?? {}) as Record<string, unknown>
const builderVersion = build.electronVersion as string | undefined
assert.ok(builderVersion, 'build.electronVersion is missing')
assert.equal(
spec,
builderVersion,
`electron dependency ("${spec}") must equal build.electronVersion ` +
`("${builderVersion}"); otherwise electron-builder packages a different ` +
'version than npm installs into electronDist.'
)
})
test('lockfile resolves the pinned electron', () => {
if (!fs.existsSync(ROOT_LOCK)) return // skip if lockfile not present
const spec = electronSpec(desktopPkg())
const lock = JSON.parse(fs.readFileSync(ROOT_LOCK, 'utf-8'))
const packages = (lock.packages ?? {}) as Record<string, { version?: string }>
const resolved = Object.entries(packages)
.filter(([key]) => key.endsWith('node_modules/electron'))
.map(([, meta]) => meta.version)
.filter((v): v is string => !!v)
assert.ok(resolved.length > 0, 'no electron entry found in package-lock.json')
for (const v of resolved) {
assert.equal(
v,
spec,
`package-lock.json resolves electron to ${v}, but the pin is "${spec}"; ` +
'run `npm install --package-lock-only` so `npm ci` stays consistent.'
)
}
})

View file

@ -1,7 +1,7 @@
/**
* Tests for electron/desktop-uninstall.cjs.
* Tests for electron/desktop-uninstall.ts.
*
* Run with: node --test electron/desktop-uninstall.test.cjs
* Run with: node --test electron/desktop-uninstall.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
@ -9,19 +9,20 @@
* cleanup-script builders (POSIX + Windows).
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
const {
UNINSTALL_MODES,
import { test } from 'vitest'
import {
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
UNINSTALL_MODES,
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
} from './desktop-uninstall'
// --- uninstallArgsForMode ---
@ -132,6 +133,7 @@ test('buildPosixCleanupScript waits for the PID, runs the uninstall module, remo
appPath: '/opt/hermes/linux-unpacked',
hermesHome: '/home/x/.hermes'
})
assert.match(script, /^#!\/bin\/bash/)
assert.match(script, /pid=4321/)
assert.match(script, /kill -0 "\$pid"/)
@ -152,6 +154,7 @@ test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/fu
appPath: null,
hermesHome: '/home/x/.hermes'
})
// System python + source on PYTHONPATH so import hermes_cli works while the
// venv is torn down.
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
@ -168,6 +171,7 @@ test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', (
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /export PYTHONPATH/)
})
@ -181,6 +185,7 @@ test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /rm -rf '\//)
// Still runs the uninstall.
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
@ -196,6 +201,7 @@ test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () =
appPath: null,
hermesHome: '/h'
})
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
assert.match(script, /'\/home\/o'\\''brien\/python'/)
})
@ -212,6 +218,7 @@ test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir b
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
})
assert.match(script, /@echo off/)
assert.match(script, /set "PID=9988"/)
// PYTHONPATH set so a system python can import hermes_cli from source.
@ -238,6 +245,7 @@ test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, n
appPath: null,
hermesHome: 'C:\\h'
})
assert.doesNotMatch(script, /rmdir/)
assert.doesNotMatch(script, /set "PYTHONPATH=/)
})

View file

@ -1,14 +1,14 @@
/**
* desktop-uninstall.cjs
* desktop-uninstall.ts
*
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
* resolve the running app bundle/exe so a detached cleanup script can remove
* it after the app quits, and build that cleanup script for each OS.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` same pattern as connection-config.cjs / backend-probes.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
* Kept standalone (no ` import 'electron'`) so it can be unit-tested with
* `node --test` same pattern as connection-config.ts / backend-probes.ts.
* main.ts requires these and wires them into the electron-coupled IPC layer.
*
* The three modes mirror the CLI's options exactly:
* - 'gui' remove ONLY the Chat GUI, keep the agent + all user data.
@ -23,10 +23,10 @@
* app bundle (locked on macOS/Windows while the process is alive). So we hand
* the work to a detached child that waits for this app's PID to exit, runs the
* Python uninstall, then removes the app bundle then the app quits. Same
* shape as the self-update swap-and-relaunch flow already in main.cjs.
* shape as the self-update swap-and-relaunch flow already in main.ts.
*/
const path = require('node:path')
import path from 'node:path'
const UNINSTALL_MODES = ['gui', 'lite', 'full']
@ -41,6 +41,7 @@ function uninstallArgsForMode(mode) {
if (!UNINSTALL_MODES.includes(mode)) {
throw new Error(`Unknown uninstall mode: ${mode}`)
}
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
}
@ -65,9 +66,12 @@ function modeRemovesUserData(mode) {
* Returns null when we can't confidently identify a removable bundle (e.g.
* running from a dev checkout, or a system-package install we must not rmtree).
*/
function resolveRemovableAppPath(execPath, platform, env = {}) {
function resolveRemovableAppPath(execPath, platform, env: any = {}) {
const exe = String(execPath || '')
if (!exe) return null
if (!exe) {
return null
}
// Use the path flavor that matches the TARGET platform, not the host running
// this code — so the Windows branch parses backslash paths correctly even
@ -79,22 +83,37 @@ function resolveRemovableAppPath(execPath, platform, env = {}) {
const macOsDir = p.dirname(exe) // …/Contents/MacOS
const contents = p.dirname(macOsDir) // …/Contents
const appBundle = p.dirname(contents) // …/Hermes.app
if (appBundle.endsWith('.app')) return appBundle
if (appBundle.endsWith('.app')) {
return appBundle
}
return null
}
if (platform === 'win32') {
// NSIS per-user installs Hermes.exe directly in the install dir.
const dir = p.dirname(exe)
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) {
return dir
}
return null
}
// Linux: an AppImage exposes its own path via the APPIMAGE env var.
if (env.APPIMAGE) return env.APPIMAGE
if (env.APPIMAGE) {
return env.APPIMAGE
}
// Unpacked electron-builder tree: …/linux-unpacked/hermes
const dir = p.dirname(exe)
if (/-unpacked$/.test(dir)) return dir
if (/-unpacked$/.test(dir)) {
return dir
}
return null
}
@ -121,6 +140,7 @@ function shouldRemoveAppBundle(isPackaged, appPath) {
*/
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
const lines = [
'#!/bin/bash',
'set -u',
@ -135,16 +155,21 @@ function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot,
'fi',
`export HERMES_HOME=${q(hermesHome)}`
]
if (pythonPath) {
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
}
lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`)
}
// Self-delete the script.
lines.push('rm -f "$0" 2>/dev/null || true')
lines.push('')
return lines.join('\n')
}
@ -180,15 +205,18 @@ function buildWindowsCleanupScript({
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
// a problem, but Hermes install paths don't use them.
const q = s => `"${String(s).replace(/"/g, '')}"`
const lines = [
'@echo off',
'setlocal enableextensions',
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
`set "PID=${pid}"`
]
if (pythonPath) {
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
}
lines.push(
'set /a waited=0',
':waitloop',
@ -206,6 +234,7 @@ function buildWindowsCleanupScript({
`cd /d ${q(agentRoot)}`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
)
if (appPath) {
lines.push(
'set /a tries=0',
@ -220,18 +249,20 @@ function buildWindowsCleanupScript({
':rmdone'
)
}
lines.push('del "%~f0"')
lines.push('')
return lines.join('\r\n')
}
module.exports = {
UNINSTALL_MODES,
export {
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
UNINSTALL_MODES,
uninstallArgsForMode
}

View file

@ -1,9 +1,8 @@
'use strict'
const { session } = require('electron')
import { session } from '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
@ -23,6 +22,7 @@ function installEmbedRefererForSession(embedSession) {
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
callback({ requestHeaders: details.requestHeaders })
return
}
@ -45,4 +45,4 @@ function installEmbedReferer() {
}
}
module.exports = { installEmbedReferer }
export { installEmbedReferer }

View file

@ -1,19 +1,18 @@
'use strict'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { pathToFileURL } = require('node:url')
import { test } from 'vitest'
const { readDirForIpc } = require('./fs-read-dir.cjs')
import { readDirForIpc } from './fs-read-dir'
function mkTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
}
function fakeDirent(name, flags = {}) {
function fakeDirent(name, flags: any = {}) {
return {
name,
isDirectory: () => Boolean(flags.directory),
@ -109,10 +108,12 @@ test('readDirForIpc accepts file URLs for directories', async () => {
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
return []
}
}
@ -126,10 +127,12 @@ test('readDirForIpc returns invalid-path for blank or non-string input', async (
test('readDirForIpc rejects Windows device paths before readdir', async () => {
let readdirCalls = 0
const fsImpl = {
promises: {
readdir: async () => {
readdirCalls += 1
return []
}
}
@ -224,6 +227,7 @@ test('readDirForIpc allows expanding symlink or junction directories outside the
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
const linkPath = path.join(root, 'outside-link')
try {
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
} catch (error) {
@ -252,6 +256,7 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
const input = path.join('virtual-root')
const resolved = path.resolve(input)
const statCalls = []
const fsImpl = {
promises: {
readdir: async () => [
@ -266,9 +271,11 @@ test('readDirForIpc stats symbolic links and unknown entries without dropping th
}
statCalls.push(fullPath)
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
return { isDirectory: () => true }
}
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
}
}
@ -301,12 +308,15 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
let peak = 0
let releaseStats
let markFirstStatStarted
const statsReleased = new Promise(resolve => {
releaseStats = resolve
})
const firstStatStarted = new Promise(resolve => {
markFirstStatStarted = resolve
})
const fsImpl = {
promises: {
readdir: async () => [
@ -326,6 +336,7 @@ test('readDirForIpc bounds concurrent stats while preserving complete sorted out
active -= 1
const name = path.basename(fullPath)
if (name === failedName) {
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
}

View file

@ -1,8 +1,8 @@
'use strict'
import fs from 'node:fs'
import path from 'node:path'
const fs = require('node:fs')
const path = require('node:path')
const { resolveDirectoryForIpc } = require('./hardening.cjs')
import { resolveDirectoryForIpc } from './hardening'
import { resolveLocalReadPath } from './wsl-path-bridge'
const FS_READDIR_STAT_CONCURRENCY = 16
@ -37,7 +37,9 @@ function direntIsSymbolicLink(dirent) {
}
function shouldStatDirent(dirent) {
if (direntIsDirectory(dirent)) return false
if (direntIsDirectory(dirent)) {
return false
}
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
}
@ -70,18 +72,22 @@ async function mapWithStatConcurrency(items, mapper) {
}
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
const workers = Array.from({ length: workerCount }, () => runWorker())
const workers = Array.from({ length: workerCount } as any, () => runWorker())
await Promise.all(workers)
return results
}
async function readDirForIpc(dirPath, options = {}) {
async function readDirForIpc(dirPath, options: any = {}) {
const fsImpl = options.fs || fs
let resolved
// On a Windows host with a WSL backend, a WSL/POSIX cwd (`/home/...`,
// `/mnt/c/...`) isn't readable as-is; bridge it to a UNC/drive form first.
const readPath = resolveLocalReadPath(String(dirPath ?? ''))
try {
;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, {
;({ resolvedPath: resolved } = await resolveDirectoryForIpc(readPath, {
fs: fsImpl,
purpose: 'Directory read'
}))
@ -102,6 +108,4 @@ async function readDirForIpc(dirPath, options = {}) {
}
}
module.exports = {
readDirForIpc
}
export { readDirForIpc }

View file

@ -1,7 +1,7 @@
/**
* Tests for electron/gateway-ws-probe.cjs.
* Tests for electron/gateway-ws-probe.ts.
*
* Run with: node --test electron/gateway-ws-probe.test.cjs
* Run with: node --test electron/gateway-ws-probe.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*
* The probe drives a real WebSocket handshake for the "Test remote" button.
@ -9,16 +9,21 @@
* outcome (open, frame, error, early close, never-opens) without a network.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
import assert from 'node:assert/strict'
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
import { test } from 'vitest'
import { probeGatewayWebSocket } from './gateway-ws-probe'
// Minimal WebSocket double: records listeners synchronously (the probe attaches
// them in its executor) and exposes emit() so the test can replay events.
function makeFakeWs() {
function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } {
const instances = []
class FakeWs {
url: string
closed = false
listeners: Record<string, any[]> = {}
constructor(url) {
this.url = url
this.listeners = {}
@ -32,9 +37,12 @@ function makeFakeWs() {
this.closed = true
}
emit(type, event) {
for (const fn of this.listeners[type] || []) fn(event)
for (const fn of this.listeners[type] || []) {
fn(event)
}
}
}
return { FakeWs, instances }
}
@ -51,11 +59,13 @@ test('probe resolves ok when the socket opens and stays open', async () => {
test('probe resolves ok immediately when a frame arrives', async () => {
const { FakeWs, instances } = makeFakeWs()
const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 1_000,
readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer
})
instances[0].emit('open')
instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' })
const result = await promise
@ -95,11 +105,13 @@ test('probe fails when the gateway accepts then immediately closes (auth rejecte
test('probe times out when the socket never opens', async () => {
const { FakeWs } = makeFakeWs()
const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', {
WebSocketImpl: FakeWs,
connectTimeoutMs: 20,
readyGraceMs: 10
})
assert.equal(result.ok, false)
assert.match(result.reason, /Timed out/)
})

View file

@ -36,13 +36,16 @@ const DEFAULT_READY_GRACE_MS = 750
* Attempt a live WebSocket connection and classify the outcome.
*
* @param {string} wsUrl - Fully-formed ws(s):// URL including the credential.
* @param {object} [options]
* @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor.
* @param {number} [options.connectTimeoutMs]
* @param {number} [options.readyGraceMs]
* @returns {Promise<{ ok: boolean, reason?: string }>}
*/
function probeGatewayWebSocket(wsUrl, options = {}) {
function probeGatewayWebSocket<T>(
wsUrl: string,
options: {
WebSocketImpl?: any
connectTimeoutMs?: number
readyGraceMs?: number
} = {}
) {
const WebSocketImpl = options.WebSocketImpl
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS
@ -54,7 +57,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
})
}
return new Promise(resolve => {
return new Promise<any>(resolve => {
let settled = false
let opened = false
let connectTimer = null
@ -66,6 +69,7 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
clearTimeout(connectTimer)
connectTimer = null
}
if (graceTimer !== null) {
clearTimeout(graceTimer)
graceTimer = null
@ -73,14 +77,19 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
}
const finish = result => {
if (settled) return
if (settled) {
return
}
settled = true
clearTimers()
try {
socket?.close?.()
} catch {
// ignore — best effort teardown
}
resolve(result)
}
@ -91,11 +100,15 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
ok: false,
reason: error instanceof Error ? error.message : String(error)
})
return
}
const onOpen = () => {
if (settled) return
if (settled) {
return
}
opened = true
// Upgrade accepted. Give the server a brief window to reject the
// credential post-handshake (early close) before declaring success.
@ -118,7 +131,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
}
const onClose = event => {
if (settled) return
if (settled) {
return
}
if (opened) {
// Opened, then closed inside the grace window: the upgrade was accepted
// but the session was refused (e.g. ws-ticket/token rejected, or a
@ -127,8 +143,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
ok: false,
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
})
return
}
finish({
ok: false,
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
@ -154,8 +172,10 @@ function probeGatewayWebSocket(wsUrl, options = {}) {
function addListener(socket, type, handler) {
if (typeof socket.addEventListener === 'function') {
socket.addEventListener(type, handler)
return
}
// Node's global WebSocket implements addEventListener; this fallback keeps the
// helper usable with the `ws` package's EventEmitter shape too.
if (typeof socket.on === 'function') {
@ -164,25 +184,44 @@ function addListener(socket, type, handler) {
}
function extractErrorReason(event) {
if (!event) return ''
if (event instanceof Error) return event.message
if (!event) {
return ''
}
if (event instanceof Error) {
return event.message
}
const err = event.error || event.message
if (err instanceof Error) return err.message
if (typeof err === 'string') return err
if (err instanceof Error) {
return err.message
}
if (typeof err === 'string') {
return err
}
return ''
}
function closeReason(event, fallback) {
const code = event && typeof event.code === 'number' ? event.code : null
const reason = event && typeof event.reason === 'string' ? event.reason.trim() : ''
if (code && reason) return `${fallback} (code ${code}: ${reason})`
if (code) return `${fallback} (code ${code})`
if (reason) return `${fallback} (${reason})`
if (code && reason) {
return `${fallback} (code ${code}: ${reason})`
}
if (code) {
return `${fallback} (code ${code})`
}
if (reason) {
return `${fallback} (${reason})`
}
return fallback
}
module.exports = {
DEFAULT_CONNECT_TIMEOUT_MS,
DEFAULT_READY_GRACE_MS,
probeGatewayWebSocket
}
export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_READY_GRACE_MS, probeGatewayWebSocket }

View file

@ -1,14 +1,12 @@
'use strict'
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
// — no native addon, so it just works for anyone who pulls main (no
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
// first scan stays fast. Results are cached by the backend after the first run.
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const fsp = fs.promises
@ -36,14 +34,14 @@ async function mapLimit(items, limit, fn) {
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
await Promise.all(Array.from({ length: Math.min(limit, items.length) } as any, worker))
}
/**
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
*/
async function scanGitRepos(roots, options = {}) {
async function scanGitRepos(roots, options: any = {}) {
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
const found = new Map()
@ -54,6 +52,7 @@ async function scanGitRepos(roots, options = {}) {
}
let entries
try {
entries = await fsp.readdir(dir, { withFileTypes: true })
} catch {
@ -73,6 +72,7 @@ async function scanGitRepos(roots, options = {}) {
}
const subdirs = []
for (const entry of entries) {
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
// known heavy trees.
@ -93,4 +93,4 @@ async function scanGitRepos(roots, options = {}) {
return [...found.entries()].map(([root, label]) => ({ label, root }))
}
module.exports = { scanGitRepos }
export { scanGitRepos }

Some files were not shown because too many files have changed in this diff Show more