mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
commit
155a792013
1090 changed files with 89299 additions and 13320 deletions
20
.env.example
20
.env.example
|
|
@ -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
|
||||
# =============================================================================
|
||||
|
|
|
|||
5
.github/actions/detect-changes/action.yml
vendored
5
.github/actions/detect-changes/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
|
|
@ -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
235
.github/workflows/js-autofix.yml
vendored
Normal 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
49
.github/workflows/js-tests.yml
vendored
Normal 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
|
||||
112
.github/workflows/lint.yml
vendored
112
.github/workflows/lint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
51
.github/workflows/typecheck.yml
vendored
51
.github/workflows/typecheck.yml
vendored
|
|
@ -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
16
.gitignore
vendored
|
|
@ -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
4
.prettierignore
Normal 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
|
||||
79
AGENTS.md
79
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
108
agent/kanban_stop.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
56
agent/reactions.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %s — treating 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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ _BUILTIN_NAMES = frozenset({
|
|||
"openai",
|
||||
"mistral",
|
||||
"xai",
|
||||
"elevenlabs",
|
||||
"deepinfra",
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ _BUILTIN_NAMES = frozenset({
|
|||
"neutts",
|
||||
"kittentts",
|
||||
"piper",
|
||||
"deepinfra",
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
5
apps/bootstrap-installer/eslint.config.mjs
Normal file
5
apps/bootstrap-installer/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import shared from '../../eslint.config.shared.mjs'
|
||||
|
||||
export default [
|
||||
...shared
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
200
apps/desktop/AGENTS.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
55
apps/desktop/electron/backend-child.ts
Normal file
55
apps/desktop/electron/backend-child.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
@ -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,
|
||||
98
apps/desktop/electron/desktop-electron-pin.test.ts
Normal file
98
apps/desktop/electron/desktop-electron-pin.test.ts
Normal 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.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -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=/)
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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' })
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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/)
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue