Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b29bb6ef9d | ||
|
|
025c8f0604 |
@@ -97,6 +97,9 @@ packaging/
|
||||
plans/
|
||||
.plans/
|
||||
|
||||
# ACP registry manifest (icon + agent.json) — not consumed at runtime
|
||||
acp_registry/
|
||||
|
||||
# Repo-level dotfiles that are git-only or dev-tooling config
|
||||
.env.example
|
||||
.envrc
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
# 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)
|
||||
# =============================================================================
|
||||
@@ -115,10 +108,6 @@
|
||||
# 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)
|
||||
# =============================================================================
|
||||
@@ -136,15 +125,6 @@
|
||||
# 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
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
watch_file pyproject.toml uv.lock hermes
|
||||
watch_file pyproject.toml uv.lock
|
||||
watch_file package-lock.json package.json web/package.json ui-tui/package.json website/package.json apps/shared/package.json apps/desktop/package.json ui-tui/packages/hermes-ink/package.json
|
||||
watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix nix/hermes-agent.nix nix/desktop.nix
|
||||
|
||||
|
||||
@@ -5,18 +5,12 @@ description: >-
|
||||
the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch
|
||||
events and fail open (everything "true") when the diff cannot be computed.
|
||||
|
||||
inputs:
|
||||
github-token:
|
||||
description: Token for the GitHub API (gh CLI). Pass steps.app-token.outputs.token from the calling workflow.
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
|
||||
outputs:
|
||||
python:
|
||||
description: Run Python tests / ruff / ty / windows-footguns.
|
||||
value: ${{ steps.classify.outputs.python }}
|
||||
frontend:
|
||||
description: Run the TypeScript testing matrix + desktop build.
|
||||
description: Run the TypeScript typecheck matrix + desktop build.
|
||||
value: ${{ steps.classify.outputs.frontend }}
|
||||
docker_meta:
|
||||
description: Docker setup and meta files have changed.
|
||||
@@ -30,18 +24,9 @@ outputs:
|
||||
deps:
|
||||
description: Check pyproject.toml dependency upper bounds.
|
||||
value: ${{ steps.classify.outputs.deps }}
|
||||
npm_lock:
|
||||
description: Post/update the semantic package-lock.json diff PR comment.
|
||||
value: ${{ steps.classify.outputs.npm_lock }}
|
||||
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 }}
|
||||
ci_review_files:
|
||||
description: JSON list of CI-sensitive files changed by the pull request.
|
||||
value: ${{ steps.classify.outputs.ci_review_files }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -50,12 +35,7 @@ runs:
|
||||
id: classify
|
||||
shell: bash
|
||||
env:
|
||||
# Fall back to the built-in read-only token when the caller passes an
|
||||
# empty value. Fork PRs get no repo secrets, so AUTOFIX_BOT_PAT is ""
|
||||
# there, and an input `default:` only applies when the input is omitted,
|
||||
# not when it's passed empty. Without this fallback the compare API
|
||||
# fails on forks and the classifier fails open (every lane forced on).
|
||||
GH_TOKEN: ${{ inputs.github-token || github.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -71,33 +51,10 @@ runs:
|
||||
# event payload instead of the "current PR files" endpoint. The SHAs
|
||||
# are frozen at trigger time, so the file list is deterministic even
|
||||
# if the PR receives a new push between trigger and detect.
|
||||
#
|
||||
# Retried: a rate-limit blip or eventual-consistency 404 on a
|
||||
# freshly-pushed HEAD would otherwise silently fall open (all lanes
|
||||
# run — safe, but wasteful and it masks the API failure).
|
||||
#
|
||||
# `.files[]?` (null-safe): with --paginate, a PR more than 100
|
||||
# commits ahead of its merge-base paginates the compare, and pages
|
||||
# after the first carry `files: null` — bare `.files[]` makes jq
|
||||
# die with "cannot iterate over: null", which fails every retry
|
||||
# and forces the fail-open path (seen on stacked PRs). The full
|
||||
# file list (up to the API's 300-file cap) is on page one.
|
||||
CHANGED=""
|
||||
for i in 1 2 3; do
|
||||
if CHANGED="$(gh api \
|
||||
--paginate \
|
||||
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
|
||||
--jq '.files[]?.filename')"; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" = 3 ]; then
|
||||
echo "::warning::compare API failed after 3 attempts — failing open (all lanes run)"
|
||||
CHANGED=""
|
||||
break
|
||||
fi
|
||||
echo "::warning::compare API failed (attempt $i); retrying in 10s"
|
||||
sleep 10
|
||||
done
|
||||
CHANGED="$(gh api \
|
||||
--paginate \
|
||||
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
|
||||
--jq '.files[].filename' || true)"
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Get GitHub App Token
|
||||
description: >-
|
||||
Mint a short-lived (1-hour) installation access token from the repo's
|
||||
GitHub App, replacing the long-lived AUTOFIX_BOT_PAT. App tokens get
|
||||
5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN)
|
||||
and are scoped to the App's installation permissions, not a user account.
|
||||
|
||||
Callers must source App credentials from a protected, main-only environment.
|
||||
Never pass an App private key to a pull_request job, a local action, or a
|
||||
reusable workflow resolved from an untrusted PR ref. The fallback keeps a
|
||||
trusted caller functional when its protected environment is misconfigured.
|
||||
|
||||
Composite actions cannot access contexts directly, so callers pass the
|
||||
public vars.APP_CLIENT_ID and protected secrets.APP_PRIVATE_KEY as inputs.
|
||||
When the private key is empty, the fallback fires.
|
||||
|
||||
inputs:
|
||||
client-id:
|
||||
description: GitHub App Client ID. Pass vars.APP_CLIENT_ID from the calling workflow.
|
||||
required: false
|
||||
default: ''
|
||||
private-key:
|
||||
description: GitHub App private key PEM. Pass secrets.APP_PRIVATE_KEY from the calling workflow.
|
||||
required: false
|
||||
default: ''
|
||||
owner:
|
||||
description: GitHub App installation owner. Empty scopes the token to the current repository.
|
||||
required: false
|
||||
default: ''
|
||||
repositories:
|
||||
description: Comma- or newline-separated repositories to scope within the installation owner.
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
outputs:
|
||||
token:
|
||||
description: A GitHub App installation access token (1-hour TTL), or GITHUB_TOKEN on forks.
|
||||
value: ${{ steps.app-token.outputs.token || steps.fallback.outputs.token }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check if App credentials exist
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
CLIENT_ID: ${{ inputs.client-id }}
|
||||
run: |
|
||||
if [ -n "$CLIENT_ID" ]; then
|
||||
echo "has_app=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_app=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create GitHub App token
|
||||
id: app-token
|
||||
if: steps.check.outputs.has_app == 'true'
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ inputs.client-id }}
|
||||
private-key: ${{ inputs.private-key }}
|
||||
owner: ${{ inputs.owner }}
|
||||
repositories: ${{ inputs.repositories }}
|
||||
|
||||
- name: Fall back to GITHUB_TOKEN
|
||||
id: fallback
|
||||
if: steps.check.outputs.has_app != 'true'
|
||||
shell: bash
|
||||
run: echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT"
|
||||
@@ -3,8 +3,7 @@ description: >-
|
||||
Run a shell command, retrying on non-zero exit. For dependency installs
|
||||
(npm ci, uv sync) whose only failures are transient network/toolchain
|
||||
flakes — a node-gyp header fetch, a registry blip — so CI self-heals
|
||||
instead of needing a manual re-run. Can also capture stdout as a step
|
||||
output for commands whose result must be consumed by later steps.
|
||||
instead of needing a manual re-run.
|
||||
|
||||
inputs:
|
||||
command:
|
||||
@@ -20,16 +19,10 @@ inputs:
|
||||
description: Directory to run in.
|
||||
default: "."
|
||||
|
||||
outputs:
|
||||
stdout:
|
||||
description: Captured stdout from the successful attempt (empty if not needed).
|
||||
value: ${{ steps.retry.outputs.stdout }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: retry
|
||||
shell: bash
|
||||
- shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
# command goes through env, never interpolated into the script body, so
|
||||
# a command with quotes/specials can't break or inject into the runner.
|
||||
@@ -39,25 +32,12 @@ runs:
|
||||
_DELAY: ${{ inputs.delay }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
_OUTFILE="$(mktemp)"
|
||||
trap 'rm -f "$_OUTFILE"' EXIT
|
||||
n=0
|
||||
while :; do
|
||||
n=$((n + 1))
|
||||
echo "::group::attempt $n/$_ATTEMPTS: $_CMD"
|
||||
# Run the command, capturing stdout to a temp file while still
|
||||
# streaming to the log. We redirect first, then tee the file to
|
||||
# stdout — this avoids pipefail + tee exit-code interactions that
|
||||
# can cause the if-branch to be skipped under set -e.
|
||||
if bash -c "$_CMD" > "$_OUTFILE"; then
|
||||
cat "$_OUTFILE"
|
||||
if bash -c "$_CMD"; then
|
||||
echo "::endgroup::"
|
||||
# Preserve newlines in the output via heredoc delimiter.
|
||||
{
|
||||
echo 'stdout<<__RETRY_STDOUT_EOF__'
|
||||
cat "$_OUTFILE"
|
||||
echo '__RETRY_STDOUT_EOF__'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
+21
-187
@@ -9,10 +9,6 @@ name: CI
|
||||
# definitions, matrices, and concurrency settings. They no longer have
|
||||
# ``push:`` / ``pull_request:`` triggers of their own — everything flows
|
||||
# through this file.
|
||||
#
|
||||
# SECURITY: this workflow runs PR-controlled actions, workflows, and code.
|
||||
# Do not add ``secrets: inherit`` or GitHub App credentials here. Trusted
|
||||
# main-only automation uses protected environments in its own workflows.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -21,7 +17,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed by lint (PR comment) + supply-chain review_status
|
||||
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
|
||||
actions: read # needed by osv-scanner (SARIF upload)
|
||||
security-events: write # needed by osv-scanner (SARIF upload)
|
||||
packages: write # needed by docker build
|
||||
@@ -39,26 +35,20 @@ jobs:
|
||||
detect:
|
||||
name: Detect affected areas
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
python: ${{ steps.classify.outputs.python }}
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
site: ${{ steps.classify.outputs.site }}
|
||||
scan: ${{ steps.classify.outputs.scan }}
|
||||
deps: ${{ steps.classify.outputs.deps }}
|
||||
npm_lock: ${{ steps.classify.outputs.npm_lock }}
|
||||
docker_meta: ${{ steps.classify.outputs.docker_meta }}
|
||||
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
ci_review: ${{ steps.classify.outputs.ci_review }}
|
||||
ci_review_files: ${{ steps.classify.outputs.ci_review_files }}
|
||||
event_name: ${{ github.event_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Detect affected areas
|
||||
id: classify
|
||||
uses: ./.github/actions/detect-changes
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
|
||||
@@ -80,17 +70,11 @@ jobs:
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
|
||||
js-tests:
|
||||
name: JS & TS checks
|
||||
typecheck:
|
||||
name: TypeScript
|
||||
needs: detect
|
||||
if: needs.detect.outputs.frontend == 'true'
|
||||
uses: ./.github/workflows/js-tests.yml
|
||||
|
||||
e2e-desktop:
|
||||
name: Desktop E2E
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true'
|
||||
uses: ./.github/workflows/e2e-desktop.yml
|
||||
uses: ./.github/workflows/typecheck.yml
|
||||
|
||||
docs-site:
|
||||
name: Docs Site
|
||||
@@ -115,12 +99,6 @@ jobs:
|
||||
needs: detect
|
||||
uses: ./.github/workflows/uv-lockfile-check.yml
|
||||
|
||||
lockfile-diff:
|
||||
name: package-lock.json diff
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true'
|
||||
uses: ./.github/workflows/lockfile-diff.yml
|
||||
|
||||
docker-lint:
|
||||
name: Lint Docker scripts
|
||||
needs: detect
|
||||
@@ -130,170 +108,60 @@ jobs:
|
||||
docker:
|
||||
name: Build&Test Docker image
|
||||
needs: detect
|
||||
# Trusted main pushes run docker.yml directly so its container-publish
|
||||
# environment secrets never cross this reusable-workflow call. PR runs
|
||||
# remain build/test-only and secret-free.
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true')
|
||||
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true'
|
||||
uses: ./.github/workflows/docker.yml
|
||||
secrets: inherit
|
||||
|
||||
supply-chain:
|
||||
name: Supply-chain scan
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true')
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
|
||||
uses: ./.github/workflows/supply-chain-audit.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
scan: ${{ needs.detect.outputs.scan == 'true' }}
|
||||
deps: ${{ needs.detect.outputs.deps == 'true' }}
|
||||
|
||||
review-labels:
|
||||
name: Review label gate
|
||||
needs: [detect, supply-chain]
|
||||
if: always() && needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true' || needs.supply-chain.outputs.critical_findings == 'true')
|
||||
uses: ./.github/workflows/review-labels.yml
|
||||
with:
|
||||
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
|
||||
ci_review_files: ${{ needs.detect.outputs.ci_review_files }}
|
||||
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
|
||||
supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }}
|
||||
|
||||
osv-scanner:
|
||||
name: OSV scan
|
||||
uses: ./.github/workflows/osv-scanner.yml
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Live-updating PR review comment.
|
||||
#
|
||||
# A single ``comment-live`` job polls the GitHub Actions API every 15s
|
||||
# for job statuses in this run, re-assembles the review comment from
|
||||
# whatever results are available, and upserts it via the
|
||||
# ``<!-- hermes-ci-review-bot -->`` marker.
|
||||
#
|
||||
# When the visible job set goes quiet, the poller waits 10 seconds and polls
|
||||
# once more so downstream jobs created by an aggregate gate get included.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
comment-live:
|
||||
name: CI review comment (live)
|
||||
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check, e2e-desktop]
|
||||
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork != true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run live comment poller
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
# Commit info for the review comment header.
|
||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message }}
|
||||
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}
|
||||
# Structured review statuses from workflow_call jobs.
|
||||
# Each job outputs a JSON array of {source, results: [...]} objects
|
||||
# that the assembler renders directly — no hardcoded job-name
|
||||
# matching. We merge all available outputs into one array.
|
||||
REVIEW_STATUSES: ${{ toJSON(needs.*.outputs.review_status) }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
# REVIEW_STATUSES is a JSON array of strings (some may be empty
|
||||
# when a job was skipped). Parse each string and merge into one
|
||||
# flat array for the assembler.
|
||||
python3 - <<'PYEOF'
|
||||
import json, os, sys
|
||||
|
||||
raw = os.environ.get("REVIEW_STATUSES", "")
|
||||
merged = []
|
||||
if raw:
|
||||
try:
|
||||
arr = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
arr = []
|
||||
for item in arr:
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
statuses = json.loads(item)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(statuses, list):
|
||||
merged.extend(statuses)
|
||||
|
||||
# Write merged array to a temp file the poller reads.
|
||||
with open("/tmp/review_statuses.json", "w") as f:
|
||||
json.dump(merged, f)
|
||||
print(f"Merged {len(merged)} review status entries")
|
||||
PYEOF
|
||||
|
||||
python3 scripts/ci/live_comment.py \
|
||||
--interval 15 \
|
||||
--timeout 2100 \
|
||||
--review-statuses-file /tmp/review_statuses.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Gate: runs after everything. ``if: always()`` ensures it reports a
|
||||
# status even when some deps were skipped. Only actual ``failure``
|
||||
# results cause it to fail; ``skipped`` is treated as success.
|
||||
#
|
||||
# Branch protection should require ONLY this check.
|
||||
#
|
||||
# Outputs ``needs-json`` — a compact ``{job_name: result}`` dict — so
|
||||
# the live comment poller can list failed jobs in the PR comment.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
all-checks-pass:
|
||||
name: All required checks pass
|
||||
needs:
|
||||
- detect
|
||||
- tests
|
||||
- lint
|
||||
- js-tests
|
||||
- e2e-desktop
|
||||
- typecheck
|
||||
- docs-site
|
||||
- history-check
|
||||
- contributor-check
|
||||
- uv-lockfile
|
||||
- lockfile-diff
|
||||
- docker-lint
|
||||
- supply-chain
|
||||
- review-labels
|
||||
- osv-scanner
|
||||
# comment-live is a polling job — it doesn't block the gate.
|
||||
# we don't require docker to pass rn because it's so slow lol
|
||||
# - docker
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
needs-json: ${{ steps.evaluate.outputs.needs-json }}
|
||||
steps:
|
||||
- name: Evaluate job results
|
||||
id: evaluate
|
||||
env:
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
RESULTS: ${{ toJSON(needs.*.result) }}
|
||||
run: |
|
||||
echo "$NEEDS" | python3 -c "
|
||||
echo "$RESULTS" | python3 -c "
|
||||
import json, sys
|
||||
needs = json.load(sys.stdin)
|
||||
# Emit compact {job_name: result} for the comment assembler.
|
||||
compact = {name: info['result'] for name, info in needs.items()}
|
||||
print(f'needs-json={json.dumps(compact)}')
|
||||
with open('$GITHUB_OUTPUT', 'a') as f:
|
||||
f.write(f'needs-json={json.dumps(compact)}\n')
|
||||
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}')
|
||||
results = json.load(sys.stdin)
|
||||
failed = [r for r in results if r == 'failure']
|
||||
if failed:
|
||||
print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}')
|
||||
print(f'::error::{len(failed)} job(s) failed')
|
||||
sys.exit(1)
|
||||
print('All checks passed (or were skipped)')
|
||||
"
|
||||
@@ -303,16 +171,12 @@ jobs:
|
||||
# cache them on main (as a baseline), and on PRs generate an HTML diff
|
||||
# report with a gantt chart + per-step breakdown. The report is uploaded
|
||||
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
|
||||
#
|
||||
# The live comment poller can read the standalone review-status artifact
|
||||
# after the HTML report is uploaded, so its link points straight at that report.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
ci-timings:
|
||||
name: CI timing report
|
||||
needs: [all-checks-pass, docker]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -330,7 +194,7 @@ jobs:
|
||||
|
||||
- name: Collect timings and generate report
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python3 scripts/ci/timings_report.py \
|
||||
--baseline ci-timings-baseline.json \
|
||||
@@ -339,58 +203,28 @@ jobs:
|
||||
--summary-out ci-timings-summary.md
|
||||
|
||||
- name: Upload HTML report
|
||||
# Advisory report — artifact-service blips must not fail the job.
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
id: ci-timings-html
|
||||
id: ci-timings-artifact
|
||||
with:
|
||||
name: ci-timings-report
|
||||
path: ci-timings-report.html
|
||||
retention-days: 14
|
||||
|
||||
- name: Build linked review status
|
||||
if: hashFiles('ci-timings.json') != ''
|
||||
env:
|
||||
CI_TIMINGS_REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url }}
|
||||
run: |
|
||||
python3 scripts/ci/timings_report.py \
|
||||
--from-json ci-timings.json \
|
||||
--baseline ci-timings-baseline.json \
|
||||
--review-status-out review-status.json \
|
||||
--review-status-only
|
||||
|
||||
- name: Upload review status
|
||||
if: hashFiles('review-status.json') != ''
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-timings-review-status
|
||||
path: review-status.json
|
||||
retention-days: 14
|
||||
archive: false
|
||||
|
||||
- name: Output summary
|
||||
env:
|
||||
REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url}}
|
||||
REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}}
|
||||
run: |
|
||||
{
|
||||
echo "# CI Timing report"
|
||||
echo "[View the full interactive report]($REPORT_URL)"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY"
|
||||
cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save baseline cache (main only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
# Degraded runs (API rate-limited) produce no ci-timings.json —
|
||||
# skip rather than fail, and never cache an empty baseline.
|
||||
if [ -f ci-timings.json ]; then
|
||||
cp ci-timings.json ci-timings-baseline.json
|
||||
else
|
||||
echo "No timings JSON this run — skipping baseline update"
|
||||
fi
|
||||
run: cp ci-timings.json ci-timings-baseline.json
|
||||
|
||||
- name: Upload baseline to cache (main only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('ci-timings-baseline.json') != ''
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ci-timings-baseline.json
|
||||
|
||||
@@ -2,10 +2,6 @@ name: Contributor Attribution Check
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: "JSON array of review status objects"
|
||||
value: ${{ jobs.check-attribution.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -13,16 +9,12 @@ permissions:
|
||||
jobs:
|
||||
check-attribution:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
review_status: ${{ steps.check-emails.outputs.review_status }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
id: check-emails
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
@@ -32,13 +24,10 @@ jobs:
|
||||
|
||||
if [ -z "$NEW_EMAILS" ]; then
|
||||
echo "No new commits to check."
|
||||
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# An email is mapped if it has a file in contributors/emails/
|
||||
# (one file per email — conflict-free) or an entry in the frozen
|
||||
# legacy AUTHOR_MAP in scripts/release.py.
|
||||
# Check each email against AUTHOR_MAP in release.py
|
||||
MISSING=""
|
||||
while IFS= read -r email; do
|
||||
# Skip teknium and bot emails
|
||||
@@ -47,12 +36,9 @@ jobs:
|
||||
continue ;;
|
||||
esac
|
||||
|
||||
# Check if email is in AUTHOR_MAP (either as a key or matches noreply pattern)
|
||||
if echo "$email" | grep -qP '\+.*@users\.noreply\.github\.com'; then
|
||||
continue # GitHub id+login noreply emails auto-resolve
|
||||
fi
|
||||
|
||||
if [ -f "contributors/emails/${email}" ]; then
|
||||
continue # mapped via the contributors directory
|
||||
continue # GitHub noreply emails auto-resolve
|
||||
fi
|
||||
|
||||
if ! grep -qF "\"${email}\"" scripts/release.py 2>/dev/null; then
|
||||
@@ -63,29 +49,19 @@ jobs:
|
||||
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo ""
|
||||
echo "⚠️ New contributor email(s) without a mapping:"
|
||||
echo "⚠️ New contributor email(s) not in AUTHOR_MAP:"
|
||||
echo -e "$MISSING"
|
||||
echo ""
|
||||
echo "Add a mapping file (do NOT edit AUTHOR_MAP in release.py):"
|
||||
echo "Please add mappings to scripts/release.py AUTHOR_MAP:"
|
||||
echo -e "$MISSING" | while read -r line; do
|
||||
email=$(echo "$line" | sed 's/^ *//' | cut -d' ' -f1)
|
||||
[ -z "$email" ] && continue
|
||||
echo " python3 scripts/add_contributor.py ${email} <github-username>"
|
||||
echo " \"${email}\": \"<github-username>\","
|
||||
done
|
||||
echo ""
|
||||
echo "To find the GitHub username for an email:"
|
||||
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
|
||||
|
||||
# Emit review_status for unmapped emails
|
||||
DETAIL=$(echo -e "$MISSING" | sed '/^$/d; s/^ //')
|
||||
HOW_TO_FIX=$'Add mappings to scripts/release.py AUTHOR_MAP:\n```\n"<email>": "<github-username>",\n```\nTo find the GitHub username for an email:\n```\ngh api \'search/users?q=EMAIL+in:email\' --jq \'.items[0].login\'\n```\n'
|
||||
REVIEW_STATUS=$(jq -nc \
|
||||
--arg detail "$DETAIL" \
|
||||
--arg how_to_fix "$HOW_TO_FIX" \
|
||||
'[{"source":"contributor attribution","results":[{"kind":"action_required","title":"Unmapped contributor email(s)","summary":"New contributor email(s) are not in AUTHOR_MAP.","detail":$detail,"how_to_fix":$how_to_fix}]}]')
|
||||
echo "review_status=$REVIEW_STATUS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All contributor emails are mapped."
|
||||
echo "✅ All contributor emails are mapped in AUTHOR_MAP."
|
||||
fi
|
||||
|
||||
@@ -41,28 +41,19 @@ jobs:
|
||||
# doesn't auto-deploy via the deploy-docs path.
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Trigger Vercel Deploy
|
||||
run: curl -fsS --retry 3 --retry-delay 10 -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"
|
||||
run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"
|
||||
|
||||
deploy-docs:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -74,14 +65,12 @@ jobs:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyYAML for skill extraction
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
run: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
|
||||
- name: Prepare skills index (unified multi-source catalog)
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
|
||||
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
|
||||
run: |
|
||||
@@ -161,10 +150,8 @@ jobs:
|
||||
run: python3 website/scripts/generate-skill-docs.py
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
working-directory: website
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
|
||||
- name: Build Docusaurus
|
||||
run: npm run build
|
||||
|
||||
+64
-109
@@ -1,15 +1,8 @@
|
||||
name: Docker Build, Test, and Publish
|
||||
|
||||
on:
|
||||
# Trusted main pushes run this workflow directly so environment-scoped
|
||||
# Docker Hub secrets are resolved by the top-level workflow, never across
|
||||
# a reusable-workflow boundary.
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
# CI calls this only for untrusted PR build/test coverage. Those runs never
|
||||
# reach the protected publish or merge jobs below.
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
@@ -27,9 +20,7 @@ env:
|
||||
IMAGE_NAME: nousresearch/hermes-agent
|
||||
|
||||
jobs:
|
||||
# Build and test the image for each architecture. This job runs PR code,
|
||||
# so it must remain secret-free. Publishing happens in the separate,
|
||||
# protected publish job after these tests pass.
|
||||
# Build, test, and optionally push the image for each architecture.
|
||||
build:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
strategy:
|
||||
@@ -71,6 +62,49 @@ jobs:
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Push by digest only (no tag). The merge job assembles the
|
||||
# tagged manifest list. `push-by-digest=true` is docker's recommended
|
||||
# pattern for multi-runner multi-platform builds.
|
||||
- name: Push ${{ matrix.arch }} by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ matrix.cache-to }}
|
||||
|
||||
# Write the digest to a file and upload it as an artifact so the
|
||||
# merge job can stitch both per-arch digests into a manifest list.
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Run the docker-integration test suite against the freshly-built
|
||||
# image already loaded into the local daemon (`:test`).
|
||||
@@ -93,13 +127,12 @@ jobs:
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install Python dependencies (for docker tests)
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||
# everything tests/docker/ needs. We deliberately avoid ``all``
|
||||
# here because the docker tests only drive the container via
|
||||
# subprocess and don't import hermes_agent's optional deps.
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra dev
|
||||
run: |
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||
# everything tests/docker/ needs. We deliberately avoid ``all``
|
||||
# here because the docker tests only drive the container via
|
||||
# subprocess and don't import hermes_agent's optional deps.
|
||||
uv sync --locked --python 3.11 --extra dev
|
||||
|
||||
- name: Run docker integration tests
|
||||
env:
|
||||
@@ -113,74 +146,6 @@ jobs:
|
||||
run: |
|
||||
scripts/run_tests.sh tests/docker/ --file-timeout 600
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rebuild and push each architecture only after the unprivileged build/test
|
||||
# matrix passes. This job is the sole Docker Hub credential boundary.
|
||||
# ---------------------------------------------------------------------------
|
||||
publish:
|
||||
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
|
||||
needs: [build]
|
||||
environment: container-publish
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout trusted source
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Push by digest only (no tag). The merge job assembles the tagged
|
||||
# manifest list after both architecture publishers complete.
|
||||
- name: Push ${{ matrix.arch }} by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ matrix.cache-to }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stitch both per-arch digests into a single tagged multi-arch manifest.
|
||||
# This is a registry-side operation — no building, no layer re-push —
|
||||
@@ -192,9 +157,8 @@ jobs:
|
||||
merge:
|
||||
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
|
||||
runs-on: ubuntu-latest
|
||||
needs: [publish]
|
||||
needs: [build]
|
||||
timeout-minutes: 10
|
||||
environment: container-publish
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -214,9 +178,6 @@ jobs:
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
@@ -224,31 +185,25 @@ jobs:
|
||||
args+=("${IMAGE_NAME}@sha256:${digest_file}")
|
||||
done
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
tags=(-t "${IMAGE_NAME}:${RELEASE_TAG}")
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:${TAG}" \
|
||||
"${args[@]}"
|
||||
else
|
||||
tags=(-t "${IMAGE_NAME}:main" -t "${IMAGE_NAME}:latest")
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:main" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
"${args[@]}"
|
||||
fi
|
||||
# Retry: Docker Hub API + just-pushed digest eventual consistency
|
||||
# can transiently fail the create; the operation is idempotent.
|
||||
for i in 1 2 3; do
|
||||
if docker buildx imagetools create "${tags[@]}" "${args[@]}"; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" = 3 ]; then
|
||||
echo "::error::imagetools create failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::imagetools create failed (attempt $i); retrying in 20s"
|
||||
sleep 20
|
||||
done
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}"
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${{ github.event.release.tag_name }}"
|
||||
else
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:main"
|
||||
fi
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
|
||||
@@ -9,7 +9,6 @@ permissions:
|
||||
jobs:
|
||||
docs-site-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
name: E2E Desktop
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: Screenshot and visual-diff status for the CI review comment.
|
||||
value: ${{ jobs.e2e.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: e2e-desktop-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: Playwright E2E (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
review_status: ${{ steps.review-status.outputs.review_status }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# ── System deps for Electron on headless Ubuntu ───────────────────
|
||||
# Electron needs GTK, NSS,atk, etc. even under xvfb. Playwright's
|
||||
# install-deps covers browsers; for Electron we install the apt
|
||||
# packages directly.
|
||||
- name: Install system dependencies for Electron
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq \
|
||||
xvfb \
|
||||
libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 \
|
||||
xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64
|
||||
|
||||
# ── Node ───────────────────────────────────────────────────────────
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# Full npm ci (not --ignore-scripts): electron's postinstall
|
||||
# downloads the binary we launch, and node-pty's native build is
|
||||
# needed for the terminal pane.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
|
||||
# ── Python (for the hermes serve backend) ──────────────────────────
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Install Python dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
|
||||
# ── Build desktop app ─────────────────────────────────────────────
|
||||
# The Playwright step below runs `npm run build` before testing so
|
||||
# dist/ is always fresh — no separate build step needed here.
|
||||
|
||||
# ── Restore visual baseline screenshots from main ──────────────────
|
||||
# Baselines are generated on main (via --update-snapshots) and cached.
|
||||
# On PRs, we restore them so toHaveScreenshot has something to compare
|
||||
# against. The cache key is keyed on the desktop source files so a
|
||||
# UI change naturally invalidates it — but we fall back to the main
|
||||
# cache to avoid cold starts on unrelated PRs.
|
||||
- name: Restore visual baseline screenshots
|
||||
id: restore-baselines
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: apps/desktop/e2e/*-snapshots
|
||||
key: visual-baselines-${{ github.ref_name }}
|
||||
restore-keys: |
|
||||
visual-baselines-main
|
||||
|
||||
# ── Run Playwright E2E under xvfb ─────────────────────────────────
|
||||
# xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron
|
||||
# window always has a consistent viewport for screenshot comparison.
|
||||
# On main, we run with --update-snapshots to generate baselines.
|
||||
# `npm run test:e2e` builds dist/ as a pretest hook so the renderer
|
||||
# is always fresh — no separate build step needed.
|
||||
- name: Run Playwright E2E tests
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
echo "On main — generating/updating baseline screenshots"
|
||||
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
npx playwright test --reporter=list --update-snapshots
|
||||
else
|
||||
echo "On PR — comparing against cached baselines"
|
||||
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
npx playwright test --reporter=list
|
||||
fi
|
||||
env:
|
||||
CI: "true"
|
||||
# Ensure no real API keys leak into the test env.
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
# ── Save updated baselines to cache (main only) ───────────────────
|
||||
- name: Save updated baselines to cache
|
||||
if: github.ref_name == 'main' && always()
|
||||
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: apps/desktop/e2e/*-snapshots
|
||||
key: visual-baselines-main
|
||||
|
||||
# ── Upload Playwright report (HTML + traces) ──────────────────────
|
||||
- name: Upload Playwright report
|
||||
id: upload-report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-${{ github.sha }}
|
||||
path: apps/desktop/playwright-report
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
|
||||
# ── Upload test results (screenshots, traces, diffs) ───────────────
|
||||
- name: Upload test results
|
||||
id: upload-results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-test-results-${{ github.sha }}
|
||||
path: apps/desktop/test-results
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
|
||||
# ── Upload just the visual diffs (small, fast to review) ──────────
|
||||
- name: Upload visual diffs
|
||||
id: upload-diffs
|
||||
if: always() && github.ref_name != 'main'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: visual-diffs-${{ github.sha }}
|
||||
path: |
|
||||
apps/desktop/test-results/**/*-diff.png
|
||||
apps/desktop/test-results/**/*-actual.png
|
||||
apps/desktop/test-results/**/*-expected.png
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Build screenshot review status
|
||||
id: review-status
|
||||
if: always()
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
|
||||
run: |
|
||||
python3 ../../scripts/ci/e2e_screenshot_status.py \
|
||||
--results-dir test-results \
|
||||
--manifest-output /tmp/e2e-screenshot-manifest.json \
|
||||
--evidence-dir /tmp/e2e-evidence \
|
||||
--artifact-url "$RESULTS_URL" \
|
||||
--output /tmp/e2e-review-status.json
|
||||
{
|
||||
echo 'review_status<<__E2E_REVIEW_STATUS__'
|
||||
cat /tmp/e2e-review-status.json
|
||||
echo '__E2E_REVIEW_STATUS__'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The trusted workflow_run publisher consumes only this flat, bounded
|
||||
# artifact. It turns selected images into GitHub attachment URLs; it
|
||||
# never checks out or runs this PR's code.
|
||||
- name: Upload inline E2E evidence
|
||||
if: always() && github.ref_name != 'main'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-evidence-${{ github.sha }}
|
||||
path: /tmp/e2e-evidence
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
if-no-files-found: error
|
||||
|
||||
# ── Generate step summary with visual diff info ───────────────────
|
||||
# Parse the JSON report + scan for diff images, then post a summary
|
||||
# to the GitHub Actions step output so reviewers can see what changed
|
||||
# without downloading artifacts. Runs AFTER uploads so it can link
|
||||
# the artifact download URLs from their step outputs.
|
||||
- name: Generate visual diff summary
|
||||
if: always()
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
REPORT_URL: ${{ steps.upload-report.outputs.artifact-url }}
|
||||
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
|
||||
DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }}
|
||||
run: |
|
||||
{
|
||||
echo "## Desktop E2E — Visual Diff Report"
|
||||
echo ""
|
||||
|
||||
# Count diff images (playwright writes *-diff.png on mismatch)
|
||||
DIFF_COUNT=$(find test-results -name '*-diff.png' 2>/dev/null | wc -l)
|
||||
ACTUAL_COUNT=$(find test-results -name '*-actual.png' 2>/dev/null | wc -l)
|
||||
|
||||
if [ "$DIFF_COUNT" -eq 0 ]; then
|
||||
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)."
|
||||
else
|
||||
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**"
|
||||
echo ""
|
||||
echo "| Test | Diff | Actual | Expected |"
|
||||
echo "|------|------|--------|----------|"
|
||||
|
||||
# List each diff image with a link to the artifact
|
||||
for diff in $(find test-results -name '*-diff.png' 2>/dev/null | sort); do
|
||||
base=${diff%-diff.png}
|
||||
test_name=$(basename "$base")
|
||||
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |"
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📥 **Artifacts:**"
|
||||
echo ""
|
||||
if [ -n "$RESULTS_URL" ]; then
|
||||
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces"
|
||||
fi
|
||||
if [ -n "$REPORT_URL" ]; then
|
||||
echo "- [playwright-report]($REPORT_URL) — interactive HTML report"
|
||||
fi
|
||||
if [ -n "$DIFFS_URL" ]; then
|
||||
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)"
|
||||
fi
|
||||
echo ""
|
||||
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally."
|
||||
|
||||
# Also parse the JSON report for pass/fail counts
|
||||
if [ -f playwright-report/results.json ]; then
|
||||
echo ""
|
||||
echo "### Test Results"
|
||||
echo ""
|
||||
node -e "
|
||||
const r = require('./playwright-report/results.json');
|
||||
const stats = r.stats || {};
|
||||
console.log('| Status | Count |');
|
||||
console.log('|--------|-------|');
|
||||
console.log('| ✅ Passed | ' + (stats.expected || 0) + ' |');
|
||||
console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |');
|
||||
console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |');
|
||||
console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |');
|
||||
" 2>/dev/null || true
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -15,10 +15,6 @@ name: History Check
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: "JSON array of review_status objects for the synthesizer."
|
||||
value: ${{ jobs.check-common-ancestor.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -26,24 +22,18 @@ permissions:
|
||||
jobs:
|
||||
check-common-ancestor:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
review_status: ${{ steps.merge-base-check.outputs.review_status }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # full history both sides for merge-base
|
||||
|
||||
- id: merge-base-check
|
||||
name: Reject PRs with no common ancestor on main
|
||||
- name: Reject PRs with no common ancestor on main
|
||||
run: |
|
||||
# `git merge-base` exits non-zero AND prints nothing when the two
|
||||
# commits share no ancestor. We check both conditions explicitly
|
||||
# so the failure message is clear regardless of which signal fires
|
||||
# first.
|
||||
if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then
|
||||
STATUS='[{"source":"unrelated histories","results":[{"kind":"action_required","title":"Unrelated histories","summary":"This PR has no common ancestor with main.","detail":"","how_to_fix":"Rebase your changes onto current main:\n```\ngit fetch origin main\ngit checkout -b fix-branch origin/main\n# re-apply your changes (cherry-pick, copy files, etc.)\ngit push -f origin fix-branch\n```\n"}]}]'
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
echo ""
|
||||
echo "::error::This PR has no common ancestor with main."
|
||||
echo ""
|
||||
@@ -65,4 +55,3 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Common ancestor with main: $BASE"
|
||||
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
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.
|
||||
#
|
||||
# NOTE: App token pushes DO trigger further workflow runs (unlike
|
||||
# secrets.GITHUB_TOKEN). The concurrency group (ts-autofix-${{ github.ref }})
|
||||
# with cancel-in-progress: true prevents an infinite loop — a re-triggered
|
||||
# run cancels the in-flight one, and since the second run finds no new fixes
|
||||
# (the first run already applied them), it exits with an empty patch.
|
||||
#
|
||||
# ── 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.
|
||||
# Skipped entirely when generate-patch reports no fixes (has-fixes != true).
|
||||
# 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
|
||||
outputs:
|
||||
has-fixes: ${{ steps.produce-patch.outputs.has-fixes }}
|
||||
# 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
|
||||
id: produce-patch
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "No fixes needed."
|
||||
echo "has-fixes=false" >> "$GITHUB_OUTPUT"
|
||||
# Empty patch signals "nothing to do" to apply-patch.
|
||||
: > js-fix.patch
|
||||
else
|
||||
git diff > js-fix.patch
|
||||
echo "has-fixes=true" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
# Skip entirely when generate-patch found no fixes — saves a runner,
|
||||
# avoids a redundant checkout/download, and keeps the job graph honest.
|
||||
if: needs.generate-patch.outputs.has-fixes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment: trusted-automation
|
||||
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: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- 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: ${{ steps.app-token.outputs.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: ${{ steps.app-token.outputs.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, the PR may have already merged (which moves
|
||||
# main) or another commit landed. Re-check state first.
|
||||
CURRENT_SHA=$(gh api "repos/${{ github.repository }}/branches/main" --jq '.commit.sha')
|
||||
if [ "$CURRENT_SHA" != "$START_SHA" ]; then
|
||||
STATE=$(gh pr view "$PR_NUM" --json state --jq '.state')
|
||||
if [ "$STATE" = "MERGED" ]; then
|
||||
echo "PR #$PR_NUM merged (main moved to $CURRENT_SHA)."
|
||||
exit 0
|
||||
fi
|
||||
echo "Main moved ($START_SHA → $CURRENT_SHA). Closing stale PR."
|
||||
gh pr close "$PR_NUM" --delete-branch || true
|
||||
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."
|
||||
@@ -1,51 +0,0 @@
|
||||
# .github/workflows/js-tests.yml
|
||||
name: JS Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
workspaces:
|
||||
name: List npm workspaces
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
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
|
||||
timeout-minutes: 20
|
||||
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
|
||||
@@ -1,81 +0,0 @@
|
||||
name: Label rerun
|
||||
|
||||
# When the ``ci-reviewed`` label is added to a PR, rerun all failed jobs in
|
||||
# the latest CI run. This re-evaluates ``review-labels`` (which now sees the
|
||||
# label) and GitHub automatically reruns dependent jobs (``comment-live``,
|
||||
# ``all-checks-pass``) — so the review comment gets updated too.
|
||||
#
|
||||
# If the CI run is still in progress when the label is added, we wait for it
|
||||
# to finish before rerunning (``gh run rerun`` only works on completed runs).
|
||||
# The wait can be long (20+ min for a full CI run), but it's better than
|
||||
# silently failing and leaving the reviewer stuck.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: label-rerun-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rerun-review-labels:
|
||||
name: Rerun review-labels job
|
||||
if: github.event.label.name == 'ci-reviewed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Wait for CI run to finish, then rerun failed jobs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
# Find the latest CI run for this PR's head SHA.
|
||||
RUN_ID=$(gh run list \
|
||||
--repo "$REPO" \
|
||||
--commit "$HEAD_SHA" \
|
||||
--workflow ci.yml \
|
||||
--limit 1 \
|
||||
--json databaseId,status \
|
||||
--jq '.[0] | "\(.databaseId) \(.status)"' 2>/dev/null || true)
|
||||
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "No CI run found for this PR — nothing to rerun."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Split "RUN_ID STATUS" into two vars.
|
||||
RUN_ID="${RUN_ID%% *}"
|
||||
STATUS="${RUN_ID##* }"
|
||||
|
||||
echo "Latest CI run: $RUN_ID (status: $STATUS)"
|
||||
|
||||
# If the run is still in progress, wait for it to finish.
|
||||
# gh run rerun only works on completed runs — if we try while it's
|
||||
# running, GitHub rejects with "cannot be rerun; This workflow is
|
||||
# already running".
|
||||
if [ "$STATUS" != "completed" ]; then
|
||||
echo "Run is $STATUS — waiting for completion (this may take a while)..."
|
||||
# gh run watch --exit-status exits non-zero if the run fails,
|
||||
# which is expected (the label gate fails). Don't let that kill
|
||||
# the workflow — we WANT to rerun failed jobs.
|
||||
timeout 2100 gh run watch "$RUN_ID" --repo "$REPO" --interval 15 || true
|
||||
|
||||
# Verify it's actually completed now.
|
||||
STATUS=$(gh run view "$RUN_ID" --repo "$REPO" --json status --jq '.status' 2>/dev/null || echo "unknown")
|
||||
if [ "$STATUS" != "completed" ]; then
|
||||
echo "Run is still $STATUS after wait — giving up."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Run completed. Rerunning all failed jobs..."
|
||||
gh run rerun "$RUN_ID" --repo "$REPO" --failed || true
|
||||
echo "Done. GitHub will rerun review-labels and all dependent jobs."
|
||||
@@ -2,14 +2,11 @@ name: Lint (ruff + ty)
|
||||
|
||||
# Two things here:
|
||||
# 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch.
|
||||
# Writes a Markdown summary to the run page. Exit zero always.
|
||||
# Posts a Markdown summary and a PR comment. Exit zero always.
|
||||
# 2. Blocking ``ruff check .`` — enforces the explicit rules in
|
||||
# ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge.
|
||||
# Separate job so the advisory diff still runs even when enforcement
|
||||
# fails.
|
||||
#
|
||||
# CI-sensitive file review was previously here as a ``ci-review`` job but
|
||||
# has moved to ``review-labels.yml`` so it can be rerun independently.
|
||||
# Separate job so the advisory diff still runs and posts even when
|
||||
# enforcement fails.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -21,6 +18,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed to post/update PR comments
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.ref }}
|
||||
@@ -100,8 +98,6 @@ jobs:
|
||||
echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes"
|
||||
|
||||
- name: Generate diff summary
|
||||
env:
|
||||
HEAD_REF: ${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
python scripts/lint_diff.py \
|
||||
--base-ruff .lint-reports/base/ruff.json \
|
||||
@@ -109,7 +105,7 @@ jobs:
|
||||
--base-ty .lint-reports/base/ty.json \
|
||||
--head-ty .lint-reports/head/ty.json \
|
||||
--base-ref "${{ steps.base.outputs.ref }}" \
|
||||
--head-ref "$HEAD_REF" \
|
||||
--head-ref "${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
|
||||
--output .lint-reports/summary.md
|
||||
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
name: Lockfile diff
|
||||
|
||||
# Advisory PR comment showing the *semantic* diff of package-lock.json
|
||||
# changes — which packages were added/removed/updated and their versions.
|
||||
# The raw textual diff of a lockfile is unreadable (npm reorders entries
|
||||
# and rewrites integrity hashes), so scripts/ci/lockfile_diff.py parses
|
||||
# the ``packages`` map at the merge base and at HEAD and set-diffs the
|
||||
# {install path: version} maps instead.
|
||||
#
|
||||
# The semantic diff is exposed as a workflow_call output ``review_status``
|
||||
# (a JSON array in the unified status format) and an artifact
|
||||
# (``lockfile-diff`` containing the markdown fragment) for the step
|
||||
# summary.
|
||||
#
|
||||
# Never blocking — this is review signal, not enforcement.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
changed:
|
||||
description: Whether package-lock.json changed relative to the target branch.
|
||||
value: ${{ jobs.diff.outputs.changed }}
|
||||
review_status:
|
||||
description: JSON array of review status objects for the unified PR comment.
|
||||
value: ${{ jobs.diff.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lockfile-diff-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
diff:
|
||||
name: package-lock.json semantic diff
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
changed: ${{ steps.diff.outputs.changed }}
|
||||
review_status: ${{ steps.emit-status.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # need history for the merge base
|
||||
|
||||
- name: Generate semantic lockfile diff
|
||||
id: diff
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Three-dot semantics by hand: diff from the merge base with the
|
||||
# target branch to the PR head, so changes that landed on main
|
||||
# after the branch point don't show up as this PR's doing.
|
||||
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
||||
echo "Merge base: ${BASE_SHA}"
|
||||
python3 scripts/ci/lockfile_diff.py \
|
||||
--base "$BASE_SHA" \
|
||||
--head HEAD \
|
||||
--output /tmp/lockfile-diff.md
|
||||
if [ -s /tmp/lockfile-diff.md ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## package-lock.json semantic diff"
|
||||
echo ""
|
||||
cat /tmp/lockfile-diff.md
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
: > /tmp/lockfile-diff.md
|
||||
fi
|
||||
|
||||
- name: Emit review_status
|
||||
id: emit-status
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CHANGED="${{ steps.diff.outputs.changed }}"
|
||||
STATUS="[]"
|
||||
|
||||
if [ "$CHANGED" = "true" ]; then
|
||||
CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
||||
STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Add the \`ci-reviewed\` label after verifying the version changes are expected.\"}]}"
|
||||
else
|
||||
STATUS="[]"
|
||||
fi
|
||||
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload diff artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: lockfile-diff
|
||||
path: /tmp/lockfile-diff.md
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
@@ -18,21 +18,17 @@ name: OSV-Scanner
|
||||
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
|
||||
# fail-on-vuln is disabled so the job does not block merges on pre-existing
|
||||
# vulnerabilities in pinned deps that we may need to patch deliberately.
|
||||
#
|
||||
# The reusable workflow can't emit custom outputs, so a wrapper job
|
||||
# downloads the SARIF result and summarizes the vulnerability count into
|
||||
# a review_status for the unified PR comment.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
schedule:
|
||||
# Weekly scan against main — catches CVEs published after merge for
|
||||
# deps that haven't changed since.
|
||||
- cron: '0 9 * * 1'
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
# Required to upload SARIF file to CodeQL. See: https://github.com/github/codeql-action/issues/2117
|
||||
# Required by the reusable workflow to upload SARIF to the Security tab.
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
@@ -48,80 +44,4 @@ jobs:
|
||||
--lockfile=uv.lock
|
||||
--lockfile=package-lock.json
|
||||
--lockfile=website/package-lock.json
|
||||
# The upstream reusable workflow uploads this exact file under its
|
||||
# fixed artifact name, which the wrapper downloads below.
|
||||
results-file-name: osv-results.sarif
|
||||
fail-on-vuln: false
|
||||
|
||||
emit-status:
|
||||
name: Emit review status
|
||||
runs-on: ubuntu-latest
|
||||
needs: scan
|
||||
if: always()
|
||||
outputs:
|
||||
review_status: ${{ steps.emit.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Download SARIF result
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: OSV Scanner SARIF file
|
||||
path: /tmp/osv-results
|
||||
continue-on-error: true
|
||||
|
||||
- name: Emit review_status
|
||||
id: emit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STATUS="[]"
|
||||
|
||||
if [ -f /tmp/osv-results/osv-results.sarif ]; then
|
||||
# Count vulnerabilities from the SARIF file
|
||||
VULN_COUNT=$(python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
with open('/tmp/osv-results/osv-results.sarif') as f:
|
||||
data = json.load(f)
|
||||
count = 0
|
||||
vulns = []
|
||||
for run in data.get('runs', []):
|
||||
for result in run.get('results', []):
|
||||
count += 1
|
||||
rule_id = result.get('ruleId', 'unknown')
|
||||
message = result.get('message', {}).get('text', '')
|
||||
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
|
||||
vulns.append(f'- {rule_id} in {loc}: {message}')
|
||||
print(count)
|
||||
if vulns:
|
||||
print('\n'.join(vulns[:20]), file=sys.stderr)
|
||||
except Exception:
|
||||
print(0)
|
||||
")
|
||||
|
||||
VULN_DETAIL=""
|
||||
if [ "$VULN_COUNT" -gt 0 ] 2>/dev/null; then
|
||||
VULN_PLURAL=$([ "$VULN_COUNT" -eq 1 ] && echo "y" || echo "ies")
|
||||
VULN_DETAIL=$(python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
with open('/tmp/osv-results/osv-results.sarif') as f:
|
||||
data = json.load(f)
|
||||
vulns = []
|
||||
for run in data.get('runs', []):
|
||||
for result in run.get('results', []):
|
||||
rule_id = result.get('ruleId', 'unknown')
|
||||
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
|
||||
vulns.append(f'- {rule_id} in {loc}')
|
||||
print(json.dumps('\n'.join(vulns[:20])))
|
||||
except Exception:
|
||||
print(json.dumps(''))
|
||||
")
|
||||
STATUS="[{\"source\":\"osv scan\",\"results\":[{\"kind\":\"warning\",\"title\":\"OSV vulnerability scan\",\"summary\":\"${VULN_COUNT} known vulnerabilit${VULN_PLURAL} found in pinned dependencies.\",\"detail\":${VULN_DETAIL},\"how_to_fix\":\"Review the findings in the [Security tab](../../security/code-scanning). Update the affected dependencies if a patched version is available.\"}]}]"
|
||||
else
|
||||
STATUS="[]"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
name: Publish E2E evidence
|
||||
|
||||
# This runs only from the default branch after CI completes. It intentionally
|
||||
# checks out main, never the PR ref, and treats the downloaded artifact as
|
||||
# untrusted input before uploading validated GitHub attachments.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: publish-e2e-evidence-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish inline E2E evidence
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: gh-image
|
||||
steps:
|
||||
- name: Check out trusted publisher
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
# v1.2.0 resolves to 44f4b93ecbbe22de6c45fa2f62f519aee564ca8c.
|
||||
- name: Install gh-image
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh extension install drogers0/gh-image --pin v1.2.0
|
||||
|
||||
- name: Download and attach evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GH_SESSION_TOKEN: ${{ secrets.GH_IMAGE_SESSION_TOKEN }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
PR_NUMBER=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID" --jq '.pull_requests[0].number // empty')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No pull request is associated with CI run $SOURCE_RUN_ID."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ARTIFACT_NAME=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID/artifacts" \
|
||||
--jq '.artifacts[] | select(.expired == false and (.name | startswith("e2e-evidence-"))) | .name' \
|
||||
| python3 -c 'import sys; print(next(iter(sys.stdin), "").strip())')
|
||||
if [ -z "$ARTIFACT_NAME" ]; then
|
||||
echo "No E2E evidence artifact was produced for CI run $SOURCE_RUN_ID."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EVIDENCE_DIR="$RUNNER_TEMP/e2e-evidence"
|
||||
mkdir -p "$EVIDENCE_DIR"
|
||||
gh run download "$SOURCE_RUN_ID" --repo "$SOURCE_REPO" --name "$ARTIFACT_NAME" --dir "$EVIDENCE_DIR"
|
||||
|
||||
python3 scripts/ci/publish_e2e_evidence.py \
|
||||
--evidence-dir "$EVIDENCE_DIR" \
|
||||
--source-repo "$SOURCE_REPO" \
|
||||
--pr-number "$PR_NUMBER"
|
||||
@@ -1,109 +0,0 @@
|
||||
name: Review labels
|
||||
|
||||
# Require explicit maintainer review when CI-sensitive files or the MCP
|
||||
# catalog change. Previously this was split across two jobs in two
|
||||
# workflows: ``ci-review`` in lint.yml (gated on ``ci_review``) and
|
||||
# ``mcp-catalog-review`` in supply-chain-audit.yml (gated on
|
||||
# ``mcp_catalog``). Both checked for their own label.
|
||||
#
|
||||
# Now consolidated: a single ``ci-reviewed`` label covers both. The
|
||||
# comment sections tell the reviewer exactly what to verify per area,
|
||||
# so one label is enough — the human reads the comment, not the label
|
||||
# name.
|
||||
#
|
||||
# Outputs:
|
||||
# ci_reviewed — "true" / "false" / "" (empty when neither lane ran)
|
||||
# review_status — JSON array of status objects consumed by the review
|
||||
# comment assembler. See scripts/ci/emit_review_status.py.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ci_review:
|
||||
description: Whether CI-sensitive files (eslint config, workflows, actions) changed.
|
||||
type: boolean
|
||||
default: false
|
||||
ci_review_files:
|
||||
description: JSON list of CI-sensitive files changed by the pull request.
|
||||
type: string
|
||||
default: '[]'
|
||||
mcp_catalog:
|
||||
description: Whether the MCP catalog / installer changed.
|
||||
type: boolean
|
||||
default: false
|
||||
supply_chain:
|
||||
description: Whether the critical supply-chain scan found a risk requiring review.
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
ci_reviewed:
|
||||
description: Whether the ci-reviewed label is present. Empty when neither input was true.
|
||||
value: ${{ jobs.check.outputs.ci_reviewed }}
|
||||
review_status:
|
||||
description: JSON array of status objects for the review comment assembler.
|
||||
value: ${{ jobs.check.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read # read PR labels
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Review label gate
|
||||
if: inputs.ci_review || inputs.mcp_catalog || inputs.supply_chain
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
outputs:
|
||||
ci_reviewed: ${{ steps.label-check.outputs.ci_reviewed }}
|
||||
review_status: ${{ steps.build-status.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check ci-reviewed label
|
||||
id: label-check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name' || true)
|
||||
|
||||
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
|
||||
echo "ci-reviewed label present."
|
||||
echo "ci_reviewed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ci-reviewed label missing."
|
||||
echo "ci_reviewed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build review_status JSON
|
||||
id: build-status
|
||||
env:
|
||||
CI_REVIEW: ${{ inputs.ci_review }}
|
||||
CI_REVIEW_FILES: ${{ inputs.ci_review_files }}
|
||||
MCP_CATALOG: ${{ inputs.mcp_catalog }}
|
||||
SUPPLY_CHAIN: ${{ inputs.supply_chain }}
|
||||
LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }}
|
||||
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi
|
||||
args+=(--ci-review-files "$CI_REVIEW_FILES")
|
||||
if [ "$MCP_CATALOG" = "true" ]; then args+=(--mcp-catalog); fi
|
||||
if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi
|
||||
if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi
|
||||
|
||||
python3 scripts/ci/emit_review_status.py "${args[@]}" \
|
||||
--repo-url "$REPO_URL" --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" \
|
||||
--output "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fail on missing label
|
||||
if: steps.label-check.outputs.ci_reviewed != 'true'
|
||||
run: |
|
||||
echo "::error::CI-sensitive changes require the ci-reviewed label. Add the label and re-run this check."
|
||||
exit 1
|
||||
@@ -20,8 +20,6 @@ jobs:
|
||||
check-freshness:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: trusted-automation
|
||||
steps:
|
||||
- name: Probe live index
|
||||
id: probe
|
||||
@@ -30,7 +28,7 @@ jobs:
|
||||
URL="https://hermes-agent.nousresearch.com/docs/api/skills-index.json"
|
||||
echo "Probing $URL"
|
||||
# -L follows redirects; -f fails on HTTP errors; -s suppresses progress
|
||||
if ! curl -fsSL --retry 3 --retry-delay 10 -o /tmp/skills-index.json "$URL"; then
|
||||
if ! curl -fsSL -o /tmp/skills-index.json "$URL"; then
|
||||
echo "status=fetch-failed" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=Could not download $URL" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
@@ -109,18 +107,10 @@ jobs:
|
||||
echo "Summary: ${{ steps.probe.outputs.summary }}"
|
||||
fi
|
||||
|
||||
- name: Get GitHub App token
|
||||
if: steps.probe.outputs.status != 'ok'
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Open issue on degraded / failed probe
|
||||
if: steps.probe.outputs.status != 'ok'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
STATUS: ${{ steps.probe.outputs.status }}
|
||||
DETAIL: ${{ steps.probe.outputs.detail }}
|
||||
run: |
|
||||
|
||||
@@ -20,30 +20,19 @@ jobs:
|
||||
# Only run on the upstream repository, not on forks
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment: trusted-automation
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: pip install httpx==0.28.1 pyyaml==6.0.2
|
||||
run: pip install httpx==0.28.1 pyyaml==6.0.2
|
||||
|
||||
- name: Build skills index
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: python scripts/build_skills_index.py
|
||||
|
||||
- name: Upload index artifact
|
||||
@@ -60,16 +49,8 @@ jobs:
|
||||
needs: build-index
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
environment: trusted-automation
|
||||
steps:
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
|
||||
|
||||
@@ -10,18 +10,9 @@ name: Supply Chain Audit
|
||||
# advisory-only workflow instead.
|
||||
#
|
||||
# Path-gating is handled centrally by the ``ci.yml`` orchestrator's
|
||||
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` booleans as
|
||||
# inputs; this workflow's jobs gate on those inputs instead of re-computing
|
||||
# the diff. MCP catalog review was previously here but has moved to
|
||||
# ``review-labels.yml`` so it can be rerun independently.
|
||||
#
|
||||
# Outputs:
|
||||
# review_status — JSON array of status objects consumed by the review
|
||||
# comment assembler (scripts/ci/assemble_review_comment.py).
|
||||
# critical_findings — "true" when the narrow critical-pattern scan found
|
||||
# something. The review-label gate consumes this and
|
||||
# owns the action-required result, so adding
|
||||
# ``ci-reviewed`` can heal the run on rerun.
|
||||
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` /
|
||||
# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
|
||||
# inputs instead of re-computing the diff.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -38,13 +29,10 @@ on:
|
||||
description: Whether pyproject.toml changed.
|
||||
type: boolean
|
||||
required: true
|
||||
outputs:
|
||||
review_status:
|
||||
description: JSON array of review status objects for the review comment assembler.
|
||||
value: ${{ jobs.aggregate.outputs.review_status }}
|
||||
critical_findings:
|
||||
description: Whether the critical-pattern scan found a risk requiring maintainer review.
|
||||
value: ${{ jobs.aggregate.outputs.critical_findings }}
|
||||
mcp_catalog:
|
||||
description: Whether the MCP catalog / installer changed.
|
||||
type: boolean
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -55,10 +43,6 @@ jobs:
|
||||
name: Scan PR for critical supply chain risks
|
||||
if: inputs.scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
review_status: ${{ steps.emit-status.outputs.review_status }}
|
||||
critical_findings: ${{ steps.scan.outputs.found }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -68,8 +52,7 @@ jobs:
|
||||
- name: Scan diff for critical patterns
|
||||
id: scan
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -77,7 +60,7 @@ jobs:
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# Added lines only, excluding lockfiles.
|
||||
# Three-point diff (base...head) diffs from the merge base to HEAD,
|
||||
# Three-dot diff (base...head) diffs from the merge base to HEAD,
|
||||
# so only changes introduced by this PR are included — not changes
|
||||
# that landed on main after the PR branched off.
|
||||
DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
|
||||
@@ -87,7 +70,7 @@ jobs:
|
||||
# --- .pth files (auto-execute on Python startup) ---
|
||||
# The exact mechanism used in the litellm supply chain attack:
|
||||
# https://github.com/BerriAI/litellm/issues/24512
|
||||
PTH_FILES=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
|
||||
PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
|
||||
if [ -n "$PTH_FILES" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: .pth file added or modified
|
||||
@@ -135,11 +118,8 @@ jobs:
|
||||
# auto-loaded by the interpreter via site.py. Any nested file with the
|
||||
# same name (e.g. hermes_cli/setup.py — the CLI setup wizard) is unrelated
|
||||
# and produced false positives that trained reviewers to ignore the scanner.
|
||||
SETUP_HITS=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
|
||||
# A maintainer-applied ci-reviewed label records the manual review
|
||||
# required for intentional changes to an install hook. The scanner
|
||||
# still blocks every unreviewed addition or modification.
|
||||
if [ -n "$SETUP_HITS" ] && [ "$CI_REVIEWED" != "true" ]; then
|
||||
SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
|
||||
if [ -n "$SETUP_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: Install-hook file added or modified
|
||||
These files can execute code during package installation or interpreter startup.
|
||||
@@ -158,32 +138,32 @@ jobs:
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Emit review_status
|
||||
id: emit-status
|
||||
if: always()
|
||||
- name: Post critical finding comment
|
||||
if: steps.scan.outputs.found == 'true'
|
||||
env:
|
||||
FOUND: ${{ steps.scan.outputs.found }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python3 - <<'PYEOF'
|
||||
import json, os
|
||||
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
|
||||
|
||||
# The review-label gate renders and blocks critical findings. Keep
|
||||
# this scan a fact-finder so adding ci-reviewed can rerun the gate
|
||||
# without requiring the scanner itself to fail again.
|
||||
status = []
|
||||
This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
f.write(f"review_status={json.dumps(status)}\n")
|
||||
PYEOF
|
||||
$(cat /tmp/findings.md)
|
||||
|
||||
---
|
||||
*Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*"
|
||||
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
||||
|
||||
- name: Fail on critical findings
|
||||
if: steps.scan.outputs.found == 'true'
|
||||
run: |
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
dep-bounds:
|
||||
name: Check PyPI dependency upper bounds
|
||||
if: inputs.deps
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
review_status: ${{ steps.emit-status.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -206,7 +186,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Match PyPI dep specs that have >= and no < ceiling.
|
||||
# Match PyPI dep specs that have >= but no < ceiling.
|
||||
# Pattern: "package>=version" without a following ",<" bound.
|
||||
# Excludes git+ URLs (which use commit SHAs) and comments.
|
||||
UNBOUNDED=$(echo "$ADDED" | grep -oE '"[a-zA-Z0-9_-]+(\[[^\]]*\])?>=[ 0-9.]+"' | grep -v ',<' || true)
|
||||
@@ -218,36 +198,26 @@ jobs:
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Emit review_status
|
||||
id: emit-status
|
||||
if: always()
|
||||
- name: Post unbounded dep warning
|
||||
if: steps.bounds.outputs.found == 'true'
|
||||
env:
|
||||
FOUND: ${{ steps.bounds.outputs.found }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python3 - <<'PYEOF'
|
||||
import json, os
|
||||
BODY="## ⚠️ Unbounded PyPI Dependency Detected
|
||||
|
||||
found = os.environ.get("FOUND", "") == "true"
|
||||
This PR adds PyPI dependencies without a \`<next_major\` upper bound. Per our [supply chain policy](../blob/main/CONTRIBUTING.md#dependency-pinning-policy-supply-chain-hardening), all PyPI deps must be pinned as \`>=floor,<next_major\`.
|
||||
|
||||
if found:
|
||||
with open("/tmp/unbounded.txt", encoding="utf-8") as f:
|
||||
detail = f.read()
|
||||
status = [{
|
||||
"source": "supply chain",
|
||||
"results": [{
|
||||
"kind": "action_required",
|
||||
"title": "Unbounded PyPI dependencies",
|
||||
"summary": "This PR adds PyPI dependencies without upper bounds.",
|
||||
"detail": detail,
|
||||
"how_to_fix": 'Add a `<next_major` upper bound, e.g. `"package>=1.2.0,<2"`. See CONTRIBUTING.md dependency pinning policy.'
|
||||
}]
|
||||
}]
|
||||
else:
|
||||
status = []
|
||||
**Unbounded specs found:**
|
||||
\`\`\`
|
||||
$(cat /tmp/unbounded.txt)
|
||||
\`\`\`
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
f.write(f"review_status={json.dumps(status)}\n")
|
||||
PYEOF
|
||||
**Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
|
||||
|
||||
---
|
||||
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
|
||||
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
|
||||
- name: Fail on unbounded deps
|
||||
if: steps.bounds.outputs.found == 'true'
|
||||
@@ -255,39 +225,40 @@ jobs:
|
||||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
aggregate:
|
||||
name: Aggregate review statuses
|
||||
needs: [scan, dep-bounds]
|
||||
if: always()
|
||||
mcp-catalog-review:
|
||||
name: MCP catalog security review
|
||||
if: inputs.mcp_catalog
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
review_status: ${{ steps.merge.outputs.review_status }}
|
||||
critical_findings: ${{ steps.merge.outputs.critical_findings }}
|
||||
steps:
|
||||
- name: Merge review statuses
|
||||
id: merge
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require explicit MCP catalog review label
|
||||
env:
|
||||
SCAN_STATUS: ${{ needs.scan.outputs.review_status }}
|
||||
DEP_STATUS: ${{ needs.dep-bounds.outputs.review_status }}
|
||||
CRITICAL_FINDINGS: ${{ needs.scan.outputs.critical_findings }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python3 - <<'PYEOF'
|
||||
import json, os
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
|
||||
echo "MCP catalog review label present."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
merged = []
|
||||
for key in ("SCAN_STATUS", "DEP_STATUS"):
|
||||
raw = os.environ.get(key, "")
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(data, list):
|
||||
merged.extend(data)
|
||||
BODY="## ⚠️ MCP catalog security review required
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
f.write(f"review_status={json.dumps(merged)}\n")
|
||||
f.write("critical_findings=" + os.environ.get("CRITICAL_FINDINGS", "false") + "\n")
|
||||
PYEOF
|
||||
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
|
||||
|
||||
A maintainer should verify:
|
||||
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
|
||||
- stdio transports do not use shell+egress/exfiltration payloads,
|
||||
- git install refs are pinned and bootstrap commands are minimal,
|
||||
- requested env vars/secrets match the upstream MCP's documented needs.
|
||||
|
||||
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
|
||||
|
||||
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
|
||||
exit 1
|
||||
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
generate:
|
||||
name: "Generate slices"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
@@ -32,12 +31,6 @@ jobs:
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations
|
||||
# Saves use test-durations-${run_id}, so the exact key above never
|
||||
# matches — without this prefix fallback the cache ALWAYS missed,
|
||||
# LPT slicing ran on no data, and unbalanced slices pushed heavy
|
||||
# files toward the per-file timeout under load.
|
||||
restore-keys: |
|
||||
test-durations-
|
||||
|
||||
- name: Generate test slices
|
||||
id: matrix
|
||||
@@ -121,9 +114,6 @@ jobs:
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Upload per-slice durations
|
||||
# Advisory artifact (feeds slice balancing) — a transient artifact-
|
||||
# service blip must not fail an otherwise-green test slice.
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-durations-slice-${{ matrix.slice.index }}
|
||||
@@ -136,7 +126,6 @@ jobs:
|
||||
needs: test
|
||||
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Download all slice durations
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -215,6 +204,11 @@ jobs:
|
||||
# re-download, keeping the persisted cache small and fast to restore.
|
||||
run: uv cache prune --ci
|
||||
|
||||
- name: Packaged-wheel i18n smoke test
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python -m pytest -m integration tests/test_wheel_locales_e2e.py -v
|
||||
|
||||
- name: Run e2e tests
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# .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
|
||||
@@ -0,0 +1,164 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
# Triggered by CalVer tag pushes from scripts/release.py (e.g. v2026.5.15)
|
||||
# Can also be triggered manually from the Actions tab as an escape hatch.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_tag:
|
||||
description: "Tag to publish (e.g. v2026.5.15). Must already exist."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# Restrict default token to read-only; each job escalates as needed.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Prevent overlapping publishes (e.g. two same-day tags pushed quickly).
|
||||
concurrency:
|
||||
group: pypi-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build distribution 📦
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# On workflow_dispatch, check out the confirmed tag.
|
||||
ref: ${{ inputs.confirm_tag || github.ref }}
|
||||
fetch-tags: true
|
||||
|
||||
- name: Validate tag exists
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if ! git tag -l "${{ inputs.confirm_tag }}" | grep -q .; then
|
||||
echo "::error::Tag '${{ inputs.confirm_tag }}' does not exist in the repo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Build web dashboard
|
||||
run: cd web && npm ci && npm run build
|
||||
|
||||
- name: Build TUI bundle
|
||||
run: cd ui-tui && npm ci && npm run build
|
||||
|
||||
- name: Bundle TUI into hermes_cli
|
||||
run: |
|
||||
mkdir -p hermes_cli/tui_dist
|
||||
cp ui-tui/dist/entry.js hermes_cli/tui_dist/entry.js
|
||||
|
||||
- name: Verify frontend assets exist
|
||||
run: |
|
||||
test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; }
|
||||
test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; }
|
||||
|
||||
- name: Bundle install scripts into wheel
|
||||
run: |
|
||||
mkdir -p hermes_cli/scripts
|
||||
cp scripts/install.sh hermes_cli/scripts/install.sh
|
||||
cp scripts/install.ps1 hermes_cli/scripts/install.ps1
|
||||
|
||||
- name: Build wheel and sdist
|
||||
run: uv build --sdist --wheel
|
||||
|
||||
- name: Upload distribution artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/hermes-agent
|
||||
permissions:
|
||||
id-token: write # OIDC trusted publishing
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
sign:
|
||||
name: Sign and attach to GitHub Release
|
||||
# Only runs on tag pushes — release.py creates the GitHub Release,
|
||||
# and workflow_dispatch won't have a matching release to attach to.
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # attach assets to the existing release
|
||||
id-token: write # sigstore signing
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Wait for GitHub Release to exist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# release.py creates the GitHub Release after pushing the tag,
|
||||
# but this workflow starts from the tag push — wait for it.
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "Release $GITHUB_REF_NAME found"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for release... ($i/30)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::warning::Release $GITHUB_REF_NAME not found after 5 minutes — skipping signature upload"
|
||||
echo "skip_sign=true" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Sign with Sigstore
|
||||
if: env.skip_sign != 'true'
|
||||
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
|
||||
with:
|
||||
inputs: >-
|
||||
./dist/*.tar.gz
|
||||
./dist/*.whl
|
||||
|
||||
- name: Attach signed artifacts to GitHub Release
|
||||
if: env.skip_sign != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# release.py already created the GitHub Release — just upload
|
||||
# the Sigstore signatures alongside the existing assets.
|
||||
run: >-
|
||||
gh release upload
|
||||
"$GITHUB_REF_NAME" dist/*.sigstore.json
|
||||
--repo "$GITHUB_REPOSITORY"
|
||||
--clobber
|
||||
@@ -45,10 +45,6 @@ name: uv.lock check
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: "JSON review status for the review-status aggregator"
|
||||
value: ${{ jobs.check.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -62,8 +58,6 @@ jobs:
|
||||
name: uv lock --check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
review_status: ${{ steps.verify.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -79,22 +73,8 @@ jobs:
|
||||
# of this file) — failures often mean "your branch is behind main,
|
||||
# rebase and regenerate uv.lock."
|
||||
- name: Verify uv.lock is up-to-date
|
||||
id: verify
|
||||
run: |
|
||||
# uv lock --check re-resolves against PyPI (network). Retry so a
|
||||
# registry blip doesn't read as "lockfile stale". A genuinely stale
|
||||
# lockfile fails all attempts (deterministic), costing only seconds.
|
||||
ok=false
|
||||
for i in 1 2 3; do
|
||||
if uv lock --check; then
|
||||
ok=true
|
||||
break
|
||||
fi
|
||||
[ "$i" = 3 ] && break
|
||||
echo "::warning::uv lock --check failed (attempt $i); retrying in 10s"
|
||||
sleep 10
|
||||
done
|
||||
if [ "$ok" != true ]; then
|
||||
if ! uv lock --check; then
|
||||
cat <<'EOF' >> "$GITHUB_STEP_SUMMARY"
|
||||
## ❌ uv.lock is out of sync with pyproject.toml
|
||||
|
||||
@@ -124,9 +104,5 @@ jobs:
|
||||
on `main` post-merge.
|
||||
EOF
|
||||
echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first."
|
||||
review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `uv lock` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\nuv lock\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]'
|
||||
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
review_status='[]'
|
||||
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
+2
-34
@@ -4,13 +4,10 @@
|
||||
/_pycache/
|
||||
*.pyc*
|
||||
__pycache__/
|
||||
act/
|
||||
.act-sandbox-agent.*
|
||||
.venv/
|
||||
.venv
|
||||
.vscode/
|
||||
.env
|
||||
.op.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
@@ -44,10 +41,7 @@ run_datagen_sonnet.sh
|
||||
source-data/*
|
||||
run_datagen_megascience_glm4-6.sh
|
||||
data/*
|
||||
# No trailing slash: also matches node_modules SYMLINKS (worktrees often
|
||||
# symlink node_modules to the main checkout; the dir-only pattern let one
|
||||
# slip into a commit and break `npm ci` on CI with ENOTDIR).
|
||||
node_modules
|
||||
node_modules/
|
||||
browser-use/
|
||||
agent-browser/
|
||||
# Private keys
|
||||
@@ -59,10 +53,6 @@ __pycache__/
|
||||
hermes_agent.egg-info/
|
||||
wandb/
|
||||
testlogs
|
||||
playwright-report/
|
||||
test-results/
|
||||
# Playwright visual regression baselines — cached from main in CI, not committed
|
||||
*-snapshots/
|
||||
|
||||
# CLI config (may contain sensitive SSH paths)
|
||||
cli-config.yaml
|
||||
@@ -75,23 +65,10 @@ environments/benchmarks/evals/
|
||||
|
||||
# Web UI build output
|
||||
hermes_cli/web_dist/
|
||||
# Cross-process web UI build lock (flock target, always empty)
|
||||
.web_ui_build.lock
|
||||
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/
|
||||
*.tsbuildinfo
|
||||
apps/desktop/*.tsbuildinfo
|
||||
|
||||
# Web UI assets — synced from @nous-research/ui at build time via
|
||||
# `npm run sync-assets` (see web/package.json).
|
||||
@@ -141,20 +118,12 @@ 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.
|
||||
.update-incomplete
|
||||
.update-incomplete.lock
|
||||
|
||||
# Installer-written method stamp in the managed checkout root (scripts/install.sh).
|
||||
# Runtime metadata only — never a code change. Ignore so `git status` stays clean
|
||||
# and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855).
|
||||
/.install_method
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
@@ -174,4 +143,3 @@ apps/desktop/demo/
|
||||
# PR body is the archive. See the hermes-agent-dev skill's
|
||||
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
|
||||
infographic/
|
||||
native/fts5_cjk/*.so
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# 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
|
||||
@@ -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.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`.
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
|
||||
|
||||
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
|
||||
|
||||
- **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_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.
|
||||
- **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.
|
||||
- `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/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.
|
||||
- **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.
|
||||
|
||||
**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).
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
@@ -998,8 +998,7 @@ Two shapes:
|
||||
Roles:
|
||||
|
||||
- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
|
||||
`clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code`
|
||||
(programmatic tool calling).
|
||||
`clarify`, `memory`, `send_message`, `execute_code`.
|
||||
- `role="orchestrator"` — retains `delegate_task` so it can spawn its
|
||||
own workers. Gated by `delegation.orchestrator_enabled` (default true)
|
||||
and bounded by `delegation.max_spawn_depth` (default 2).
|
||||
@@ -1095,16 +1094,14 @@ kanban task.
|
||||
|
||||
- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
|
||||
`init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
|
||||
`unlink`, `comment`, `attach`, `attachments`, `attach-rm`, `complete`,
|
||||
`block`, `unblock`, `archive`, `tail`, plus less-commonly-used `watch`,
|
||||
`stats`, `runs`, `log`, `assignees`, `heartbeat`, `notify-*`,
|
||||
`dispatch`, `daemon`, `gc`.
|
||||
`unlink`, `comment`, `complete`, `block`, `unblock`, `archive`,
|
||||
`tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`,
|
||||
`assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`.
|
||||
- **Worker/orchestrator toolset:** `tools/kanban_tools.py` exposes
|
||||
`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`,
|
||||
`kanban_comment`, `kanban_create`, `kanban_link`, `kanban_attach`,
|
||||
`kanban_attach_url`, `kanban_attachments`; profiles that explicitly
|
||||
enable the `kanban` toolset outside a dispatcher-spawned task also get
|
||||
`kanban_list` and `kanban_unblock` for board routing.
|
||||
`kanban_comment`, `kanban_create`, `kanban_link`; profiles that
|
||||
explicitly enable the `kanban` toolset outside a dispatcher-spawned
|
||||
task also get `kanban_list` and `kanban_unblock` for board routing.
|
||||
- **Dispatcher:** long-lived loop that (default every 60s) reclaims
|
||||
stale claims, promotes ready tasks, atomically claims, and spawns
|
||||
assigned profiles. Runs **inside the gateway** by default via
|
||||
@@ -1281,7 +1278,6 @@ 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`
|
||||
@@ -1295,20 +1291,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
|
||||
```
|
||||
|
||||
**Flake policy:** the runner auto-retries a failing test FILE once in a fresh
|
||||
subprocess (`--file-retries`, default 1; `HERMES_TEST_FILE_RETRIES=0` to
|
||||
disable). Pass-on-retry counts as green but is printed in a `⚠ FLAKY` summary
|
||||
section with both attempts' output. A FLAKY report is a bug to fix, not noise
|
||||
to ignore — timing-sensitive tests must not assume a quiet runner (loose
|
||||
wall-clock bounds ≥ 2s, event-based sync, no `assert not _wait_until(...)`
|
||||
negative-timing races).
|
||||
|
||||
#### 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 |
|
||||
| ------------------- | ------------------------------------------- | ----------------------------------------- |
|
||||
@@ -1317,17 +1305,6 @@ 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
|
||||
|
||||
@@ -1377,58 +1354,3 @@ 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.
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ Esto no es una barra de calidad — es una decisión de acoplamiento y mantenimi
|
||||
| Requisito | Notas |
|
||||
|-----------|-------|
|
||||
| **Git** | Con la extensión `git-lfs` instalada |
|
||||
| **Python 3.11–3.13** | uv lo instalará si falta |
|
||||
| **Python 3.11+** | uv lo instalará si falta |
|
||||
| **uv** | Gestor de paquetes Python rápido ([instalar](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 20+** | Opcional — necesario para herramientas de navegador y puente WhatsApp (coincide con los engines de `package.json` raíz) |
|
||||
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ A well-built third-party-product plugin can clear automated review and still be
|
||||
| Requirement | Notes |
|
||||
|-------------|-------|
|
||||
| **Git** | With the `git-lfs` extension installed |
|
||||
| **Python 3.11–3.13** | uv will install it if missing |
|
||||
| **Python 3.11+** | uv will install it if missing |
|
||||
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
|
||||
|
||||
|
||||
+29
-31
@@ -26,8 +26,8 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# replaces tini with s6-overlay's /init (PID 1 = s6-svscan), which reaps
|
||||
# zombies non-blockingly on SIGCHLD and additionally supervises the main
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get -o Acquire::Retries=3 update && \
|
||||
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -40,30 +40,33 @@ RUN apt-get -o Acquire::Retries=3 update && \
|
||||
# we map between them inline. The noarch + symlinks tarballs are
|
||||
# architecture-independent and reused as-is.
|
||||
#
|
||||
# We use `curl` instead of `ADD` for ALL three tarballs: `ADD` evaluates its
|
||||
# URL at parse time (no ARG / TARGETARCH substitution) and — critically for
|
||||
# CI reliability — cannot retry, so a single GitHub-release CDN blip fails
|
||||
# the whole 15-45 min build. curl -fsSL --retry 3 self-heals those blips,
|
||||
# and every tarball is still checksum-verified below before extraction.
|
||||
# We use `curl` instead of `ADD` for the per-arch tarball because `ADD`
|
||||
# evaluates its URL at parse time, before any ARG / TARGETARCH substitution
|
||||
# — splitting one URL per arch into two ADDs would download both on every
|
||||
# build and leave dead bytes in the cache. A single curl + arch-keyed URL
|
||||
# is simpler and cache-friendlier.
|
||||
#
|
||||
# Supply-chain integrity: every tarball is checksum-verified against the
|
||||
# upstream-published SHA256. To bump S6_OVERLAY_VERSION, fetch the four
|
||||
# `.sha256` files from the corresponding release and update the ARGs. The
|
||||
# checksum lookup happens during build, so a compromised release artifact
|
||||
# fails the build loudly instead of silently producing a tampered image.
|
||||
ARG TARGETARCH
|
||||
ARG S6_OVERLAY_VERSION=3.2.3.0
|
||||
ARG S6_OVERLAY_NOARCH_SHA256=b720f9d9340efc8bb07528b9743813c836e4b02f8693d90241f047998b4c53cf
|
||||
ARG S6_OVERLAY_X86_64_SHA256=a93f02882c6ed46b21e7adb5c0add86154f01236c93cd82c7d682722e8840563
|
||||
ARG S6_OVERLAY_AARCH64_SHA256=0952056ff913482163cc30e35b2e944b507ba1025d78f5becbb89367bf344581
|
||||
ARG S6_OVERLAY_SYMLINKS_SHA256=a60dc5235de3ecbcf874b9c1f18d73263ab99b289b9329aa950e8729c4789f0e
|
||||
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp/
|
||||
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-symlinks-noarch.tar.xz /tmp/
|
||||
RUN set -eu; \
|
||||
case "${TARGETARCH:-amd64}" in \
|
||||
amd64) s6_arch="x86_64"; s6_arch_sha="${S6_OVERLAY_X86_64_SHA256}" ;; \
|
||||
arm64) s6_arch="aarch64"; s6_arch_sha="${S6_OVERLAY_AARCH64_SHA256}" ;; \
|
||||
*) echo "Unsupported TARGETARCH=${TARGETARCH} for s6-overlay" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
base="https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}"; \
|
||||
curl -fsSL --retry 3 -o /tmp/s6-overlay-noarch.tar.xz \
|
||||
"${base}/s6-overlay-noarch.tar.xz"; \
|
||||
curl -fsSL --retry 3 -o /tmp/s6-overlay-symlinks-noarch.tar.xz \
|
||||
"${base}/s6-overlay-symlinks-noarch.tar.xz"; \
|
||||
curl -fsSL --retry 3 -o /tmp/s6-overlay-arch.tar.xz \
|
||||
"${base}/s6-overlay-${s6_arch}.tar.xz"; \
|
||||
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${s6_arch}.tar.xz"; \
|
||||
{ \
|
||||
printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" /tmp/s6-overlay-noarch.tar.xz; \
|
||||
printf '%s %s\n' "${s6_arch_sha}" /tmp/s6-overlay-arch.tar.xz; \
|
||||
@@ -73,19 +76,17 @@ RUN set -eu; \
|
||||
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
|
||||
tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \
|
||||
tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \
|
||||
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256
|
||||
|
||||
# #34192 / #66679: backward-compat shim for orchestration templates that
|
||||
# still reference the legacy /usr/bin/tini entrypoint (Hostinger's
|
||||
# 'Hermes WebUI' catalog, NAS compose projects that preserve an old
|
||||
# entrypoint on image update, etc.). A plain symlink to /init made the
|
||||
# path exist, but forwarded tini flags like `-g` into s6-overlay's
|
||||
# rc.init as the container CMD (`rc.init: 91: -g: not found`) and
|
||||
# boot-looped any `restart: unless-stopped` deploy. The shim strips the
|
||||
# tini CLI surface, then exec's /init + main-wrapper — see
|
||||
# docker/tini-shim.sh. Safe to drop once the affected catalogs are
|
||||
# updated.
|
||||
COPY --chmod=0755 docker/tini-shim.sh /usr/bin/tini
|
||||
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256; \
|
||||
# #34192: backward-compat shim for orchestration templates that still\
|
||||
# reference the legacy /usr/bin/tini entrypoint (e.g. Hostinger's\
|
||||
# 'Hermes WebUI' catalog). The image has moved to s6-overlay /init\
|
||||
# as PID 1 (see ENTRYPOINT below + the migration comment at the top\
|
||||
# of this file), but external wrappers pinned to /usr/bin/tini will\
|
||||
# crash with 'tini: No such file or directory' on startup. The shim\
|
||||
# symlinks /usr/bin/tini -> /init so legacy wrappers exec the right\
|
||||
# PID-1 reaper without behavior change for users on the current\
|
||||
# ENTRYPOINT. Safe to drop once the affected catalogs are updated.\
|
||||
ln -sf /init /usr/bin/tini
|
||||
|
||||
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
||||
RUN useradd -u 10000 -m -d /opt/data hermes
|
||||
@@ -134,11 +135,8 @@ COPY apps/shared/ apps/shared/
|
||||
# guards against a future regression if the source npm version changes.
|
||||
ENV npm_config_install_links=false
|
||||
|
||||
RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \
|
||||
for i in 1 2 3; do \
|
||||
npx playwright install --with-deps chromium --only-shell && break || \
|
||||
{ [ "$i" = 3 ] && exit 1; echo "playwright install failed (attempt $i); retrying in 10s"; sleep 10; }; \
|
||||
done && \
|
||||
RUN npm install --prefer-offline --no-audit && \
|
||||
npx playwright install --with-deps chromium --only-shell && \
|
||||
npm cache clean --force
|
||||
|
||||
# ---------- Layer-cached Python dependency install ----------
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
graft skills
|
||||
graft optional-skills
|
||||
graft optional-mcps
|
||||
graft locales
|
||||
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
|
||||
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
|
||||
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
|
||||
# below covers the wheel; this covers the sdist. See #34034 / #28149.
|
||||
recursive-include plugins plugin.yaml plugin.yml
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
@@ -109,7 +109,6 @@ hermes # Interactive CLI — start a conversation
|
||||
hermes model # Choose your LLM provider and model
|
||||
hermes tools # Configure which tools are enabled
|
||||
hermes config set # Set individual config values
|
||||
hermes config get # Print individual config values
|
||||
hermes gateway # Start the messaging gateway (Telegram, Discord, etc.)
|
||||
hermes setup # Run the full setup wizard (configures everything at once)
|
||||
hermes claw migrate # Migrate from OpenClaw (if coming from OpenClaw)
|
||||
|
||||
@@ -10,7 +10,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
from concurrent.futures import TimeoutError as FutureTimeout
|
||||
from contextvars import ContextVar, Token
|
||||
@@ -128,64 +127,13 @@ def _proposal_for_patch_replace(arguments: dict[str, Any]) -> EditProposal:
|
||||
)
|
||||
|
||||
|
||||
def _extract_v4a_patch_paths(patch_body: str) -> list[str]:
|
||||
paths: list[str] = []
|
||||
for match in re.finditer(
|
||||
r'^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$',
|
||||
patch_body,
|
||||
re.MULTILINE,
|
||||
):
|
||||
path = match.group(1).strip()
|
||||
if path:
|
||||
paths.append(path)
|
||||
for match in re.finditer(
|
||||
r'^\*\*\*\s+Move\s+File:\s*(.+?)\s*->\s*(.+)$',
|
||||
patch_body,
|
||||
re.MULTILINE,
|
||||
):
|
||||
src = match.group(1).strip()
|
||||
dst = match.group(2).strip()
|
||||
if src:
|
||||
paths.append(src)
|
||||
if dst:
|
||||
paths.append(dst)
|
||||
return paths
|
||||
|
||||
|
||||
def _proposal_for_patch_v4a(arguments: dict[str, Any]) -> EditProposal:
|
||||
patch_body = arguments.get("patch")
|
||||
if not isinstance(patch_body, str) or not patch_body:
|
||||
raise ValueError("patch content required")
|
||||
|
||||
paths = _extract_v4a_patch_paths(patch_body)
|
||||
if not paths:
|
||||
raise ValueError("no file paths found in V4A patch")
|
||||
|
||||
proposal_path = paths[0] if len(paths) == 1 else ", ".join(paths)
|
||||
old_text = _read_text_if_exists(paths[0]) if len(paths) == 1 else None
|
||||
return EditProposal(
|
||||
tool_name="patch",
|
||||
path=proposal_path,
|
||||
old_text=old_text,
|
||||
# ACP only supports a single diff payload here. Surface the exact V4A
|
||||
# patch content before execution so patch-mode calls are permissioned
|
||||
# and denied patches cannot mutate.
|
||||
new_text=patch_body,
|
||||
arguments=dict(arguments),
|
||||
)
|
||||
|
||||
|
||||
def build_edit_proposal(tool_name: str, arguments: dict[str, Any]) -> EditProposal | None:
|
||||
"""Return an edit proposal for supported file mutation calls."""
|
||||
|
||||
if tool_name == "write_file":
|
||||
return _proposal_for_write_file(arguments)
|
||||
if tool_name == "patch":
|
||||
mode = arguments.get("mode", "replace")
|
||||
if mode == "replace":
|
||||
return _proposal_for_patch_replace(arguments)
|
||||
if mode == "patch":
|
||||
return _proposal_for_patch_v4a(arguments)
|
||||
if tool_name == "patch" and arguments.get("mode", "replace") == "replace":
|
||||
return _proposal_for_patch_replace(arguments)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ def _run_setup_browser(assume_yes: bool = False) -> int:
|
||||
"""Bootstrap agent-browser + Chromium.
|
||||
|
||||
Routes through dep_ensure -> install.{sh,ps1} --ensure, sharing code
|
||||
with the runtime lazy installer.
|
||||
with ``hermes postinstall`` and the runtime lazy installer.
|
||||
|
||||
Returns 0 on success, 1 on failure.
|
||||
"""
|
||||
|
||||
@@ -38,22 +38,19 @@ def _permission_option_supports_kind(kind: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _build_permission_options(
|
||||
*, allow_permanent: bool, smart_denied: bool = False,
|
||||
) -> list[PermissionOption]:
|
||||
def _build_permission_options(*, allow_permanent: bool) -> list[PermissionOption]:
|
||||
"""Return ACP options that match Hermes approval semantics."""
|
||||
options = [PermissionOption(
|
||||
option_id="allow_once", kind="allow_once", name="Allow once",
|
||||
)]
|
||||
if not smart_denied:
|
||||
options.append(PermissionOption(
|
||||
options = [
|
||||
PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"),
|
||||
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 and not smart_denied:
|
||||
),
|
||||
]
|
||||
if allow_permanent:
|
||||
options.append(
|
||||
PermissionOption(
|
||||
option_id="allow_always",
|
||||
@@ -62,7 +59,7 @@ def _build_permission_options(
|
||||
),
|
||||
)
|
||||
options.append(PermissionOption(option_id="deny", kind="reject_once", name="Deny"))
|
||||
if not smart_denied and _permission_option_supports_kind("reject_always"):
|
||||
if _permission_option_supports_kind("reject_always"):
|
||||
options.append(
|
||||
PermissionOption(
|
||||
option_id="deny_always",
|
||||
@@ -132,15 +129,11 @@ 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,
|
||||
smart_denied=smart_denied,
|
||||
)
|
||||
options = _build_permission_options(allow_permanent=allow_permanent)
|
||||
|
||||
tool_call = _build_permission_tool_call(command, description)
|
||||
coro = request_permission_fn(
|
||||
|
||||
+46
-194
@@ -74,14 +74,6 @@ from acp_adapter.permissions import make_approval_callback
|
||||
from acp_adapter.provenance import session_provenance_meta
|
||||
from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
|
||||
from acp_adapter.tools import build_tool_complete, build_tool_start
|
||||
from agent.context_compressor import (
|
||||
COMPRESSED_SUMMARY_METADATA_KEY,
|
||||
ContextCompressor,
|
||||
)
|
||||
from tools.approval import (
|
||||
reset_hermes_interactive_context,
|
||||
set_hermes_interactive_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -460,7 +452,7 @@ class HermesACPAgent(acp.Agent):
|
||||
"tools": "List available tools",
|
||||
"context": "Show conversation context info",
|
||||
"reset": "Clear conversation history",
|
||||
"compress": "Compress conversation context",
|
||||
"compact": "Compress conversation context",
|
||||
"steer": "Inject guidance into the currently running agent turn",
|
||||
"queue": "Queue a prompt to run after the current turn finishes",
|
||||
"version": "Show Hermes version",
|
||||
@@ -489,7 +481,7 @@ class HermesACPAgent(acp.Agent):
|
||||
"description": "Clear conversation history",
|
||||
},
|
||||
{
|
||||
"name": "compress",
|
||||
"name": "compact",
|
||||
"description": "Compress conversation context",
|
||||
},
|
||||
{
|
||||
@@ -973,49 +965,11 @@ class HermesACPAgent(acp.Agent):
|
||||
return text
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _history_summary_meta(message: dict[str, Any], text: str) -> dict[str, Any] | None:
|
||||
"""Build the ``_meta`` payload for a replayed compaction summary.
|
||||
|
||||
Compaction summaries are persisted as ordinary history messages —
|
||||
standalone handoffs under ``role="user"`` OR ``role="assistant"``
|
||||
(the compressor picks whichever role keeps alternation valid), and
|
||||
merge-into-tail messages where the summary is appended after the
|
||||
first preserved tail message's real content. Without a wire flag,
|
||||
ACP frontends render all of these as ordinary turns.
|
||||
|
||||
Two distinct keys under ``_meta.hermes`` (ACP's extensibility
|
||||
channel), so clients cannot accidentally hide real content:
|
||||
|
||||
* ``compactionSummary: true`` — the entire chunk is the handoff
|
||||
summary. Safe to restyle or collapse wholesale.
|
||||
* ``containsCompactionSummary: true`` — a merged-tail message: real
|
||||
preserved turn content followed by the summary. Clients may style
|
||||
it, but collapsing the whole chunk would hide the preserved
|
||||
content, hence the separate key.
|
||||
|
||||
Detection honors the in-process ``_compressed_summary`` flag and
|
||||
falls back to content classification, so it also works for a
|
||||
DB-reloaded session that lost the in-memory flag.
|
||||
"""
|
||||
kind = ContextCompressor.classify_summary_content(text)
|
||||
if kind is None and message.get(COMPRESSED_SUMMARY_METADATA_KEY):
|
||||
# Flagged in-process but content didn't classify (e.g. future
|
||||
# prefix drift): treat as a standalone summary — the flag is only
|
||||
# ever set on summary-bearing messages.
|
||||
kind = "standalone"
|
||||
if kind == "standalone":
|
||||
return {"hermes": {"compactionSummary": True}}
|
||||
if kind == "merged":
|
||||
return {"hermes": {"containsCompactionSummary": True}}
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _history_message_update(
|
||||
*,
|
||||
role: str,
|
||||
text: str,
|
||||
field_meta: dict[str, Any] | None = None,
|
||||
) -> UserMessageChunk | AgentMessageChunk | None:
|
||||
"""Build an ACP history replay update for a user/assistant message."""
|
||||
block = TextContentBlock(type="text", text=text)
|
||||
@@ -1023,13 +977,11 @@ class HermesACPAgent(acp.Agent):
|
||||
return UserMessageChunk(
|
||||
session_update="user_message_chunk",
|
||||
content=block,
|
||||
field_meta=field_meta,
|
||||
)
|
||||
if role == "assistant":
|
||||
return AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=block,
|
||||
field_meta=field_meta,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1100,11 +1052,7 @@ class HermesACPAgent(acp.Agent):
|
||||
if role == "user":
|
||||
text = self._history_message_text(message)
|
||||
if text:
|
||||
update = self._history_message_update(
|
||||
role=role,
|
||||
text=text,
|
||||
field_meta=self._history_summary_meta(message, text),
|
||||
)
|
||||
update = self._history_message_update(role=role, text=text)
|
||||
if update is not None and not await _send(update):
|
||||
return
|
||||
continue
|
||||
@@ -1116,11 +1064,7 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
text = self._history_message_text(message)
|
||||
if text:
|
||||
update = self._history_message_update(
|
||||
role=role,
|
||||
text=text,
|
||||
field_meta=self._history_summary_meta(message, text),
|
||||
)
|
||||
update = self._history_message_update(role=role, text=text)
|
||||
if update is not None and not await _send(update):
|
||||
return
|
||||
|
||||
@@ -1270,19 +1214,12 @@ class HermesACPAgent(acp.Agent):
|
||||
with state.runtime_lock:
|
||||
if state.is_running and state.current_prompt_text:
|
||||
state.interrupted_prompt_text = state.current_prompt_text
|
||||
# Publish cancellation and hard-stop the agent before another
|
||||
# prompt can acquire this lock and mistake the turn for
|
||||
# redirectable work.
|
||||
state.cancel_event.set()
|
||||
try:
|
||||
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
|
||||
state.agent.interrupt()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to interrupt ACP session %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
state.cancel_event.set()
|
||||
try:
|
||||
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
|
||||
state.agent.interrupt()
|
||||
except Exception:
|
||||
logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True)
|
||||
logger.info("Cancelled session %s", session_id)
|
||||
|
||||
async def fork_session(
|
||||
@@ -1411,26 +1348,6 @@ class HermesACPAgent(acp.Agent):
|
||||
elif rewrite_idle:
|
||||
user_text = steer_text
|
||||
user_content = steer_text
|
||||
elif (
|
||||
text_only_prompt
|
||||
and isinstance(user_content, str)
|
||||
and not user_text.startswith("/")
|
||||
):
|
||||
# Some ACP clients implement "stop and send" as two protocol calls:
|
||||
# cancel the active prompt, then submit plain correction text. Keep
|
||||
# the cancelled request attached so deictic follow-ups ("not that
|
||||
# file") still have an explicit target.
|
||||
interrupted_prompt = ""
|
||||
with state.runtime_lock:
|
||||
if not state.is_running and state.interrupted_prompt_text:
|
||||
interrupted_prompt = state.interrupted_prompt_text
|
||||
state.interrupted_prompt_text = ""
|
||||
if interrupted_prompt:
|
||||
user_text = (
|
||||
f"{interrupted_prompt}\n\n"
|
||||
f"User correction/guidance after interrupt: {user_text}"
|
||||
)
|
||||
user_content = user_text
|
||||
|
||||
# Intercept slash commands — handle locally without calling the LLM.
|
||||
# Slash commands are text-only; if the client included images/resources,
|
||||
@@ -1445,54 +1362,23 @@ class HermesACPAgent(acp.Agent):
|
||||
await self._send_usage_update(state)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
# If the client sends another regular text prompt while this ACP session
|
||||
# is running, route it through the core active-turn redirect. Rich media
|
||||
# and older runtimes retain the proven next-turn queue fallback.
|
||||
redirected = False
|
||||
queued_depth: int | None = None
|
||||
# If Zed sends another regular prompt while the same ACP session is
|
||||
# still running, queue it instead of racing two AIAgent loops against
|
||||
# the same state.history. /steer and /queue are handled above and can
|
||||
# land immediately.
|
||||
with state.runtime_lock:
|
||||
if state.is_running:
|
||||
if (
|
||||
text_only_prompt
|
||||
and isinstance(user_content, str)
|
||||
and getattr(
|
||||
state.agent,
|
||||
"_supports_active_turn_redirect",
|
||||
False,
|
||||
queued_text = user_text or "[Image attachment]"
|
||||
state.queued_prompts.append(queued_text)
|
||||
depth = len(state.queued_prompts)
|
||||
if self._conn:
|
||||
update = acp.update_agent_message_text(
|
||||
f"Queued for the next turn. ({depth} queued)"
|
||||
)
|
||||
is True
|
||||
and hasattr(state.agent, "redirect")
|
||||
):
|
||||
try:
|
||||
redirected = bool(state.agent.redirect(user_content))
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"ACP active-turn redirect failed for %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
if not redirected:
|
||||
queued_text = user_text or "[Image attachment]"
|
||||
state.queued_prompts.append(queued_text)
|
||||
queued_depth = len(state.queued_prompts)
|
||||
else:
|
||||
state.is_running = True
|
||||
state.current_prompt_text = user_text or "[Image attachment]"
|
||||
|
||||
if redirected:
|
||||
if self._conn:
|
||||
update = acp.update_agent_message_text(
|
||||
"Redirected the active turn with your correction."
|
||||
)
|
||||
await self._conn.session_update(session_id, update)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
if queued_depth is not None:
|
||||
if self._conn:
|
||||
update = acp.update_agent_message_text(
|
||||
f"Queued for the next turn. ({queued_depth} queued)"
|
||||
)
|
||||
await self._conn.session_update(session_id, update)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
await self._conn.session_update(session_id, update)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
state.is_running = True
|
||||
state.current_prompt_text = user_text or "[Image attachment]"
|
||||
|
||||
logger.info("Prompt on session %s: %s", session_id, user_text[:100])
|
||||
|
||||
@@ -1560,23 +1446,20 @@ class HermesACPAgent(acp.Agent):
|
||||
# Approval callback is per-thread (thread-local, GHSA-qg5c-hvr5-hjgr).
|
||||
# Set it INSIDE _run_agent so the TLS write happens in the executor
|
||||
# thread — setting it here would write to the event-loop thread's TLS,
|
||||
# not the executor's. Interactive routing uses a contextvar in
|
||||
# tools.approval (set_hermes_interactive_context) rather than
|
||||
# os.environ["HERMES_INTERACTIVE"], so concurrent executor workers can't
|
||||
# race on a process-global flag — one session's restore can't drop
|
||||
# another onto the non-interactive auto-approve path mid-run
|
||||
# (GHSA-96vc-wcxf-jjff). The contextvar write is isolated by the
|
||||
# contextvars.copy_context() wrapper around the executor call below.
|
||||
# not the executor's. Also set HERMES_INTERACTIVE so approval.py
|
||||
# takes the CLI-interactive path (which calls the registered
|
||||
# callback via prompt_dangerous_approval) instead of the
|
||||
# non-interactive auto-approve branch (GHSA-96vc-wcxf-jjff).
|
||||
# ACP's conn.request_permission maps cleanly to the interactive
|
||||
# callback shape — not the gateway-queue HERMES_EXEC_ASK path,
|
||||
# which requires a notify_cb registered in _gateway_notify_cbs.
|
||||
previous_approval_cb = None
|
||||
interactive_token = None
|
||||
previous_interactive = None
|
||||
edit_approval_token = None
|
||||
previous_session_id = None
|
||||
|
||||
def _run_agent() -> dict:
|
||||
nonlocal previous_approval_cb, interactive_token, edit_approval_token, previous_session_id
|
||||
nonlocal previous_approval_cb, previous_interactive, edit_approval_token, previous_session_id
|
||||
# Bind HERMES_SESSION_KEY for this session so per-session caches
|
||||
# (e.g. the interactive sudo password cache in tools.terminal_tool)
|
||||
# scope to the ACP session rather than leaking across sessions
|
||||
@@ -1608,10 +1491,9 @@ class HermesACPAgent(acp.Agent):
|
||||
except Exception:
|
||||
logger.debug("Could not set ACP edit approval requester", exc_info=True)
|
||||
# Signal to tools.approval that we have an interactive callback
|
||||
# and the non-interactive auto-approve path must not fire. Uses a
|
||||
# contextvar (not os.environ) so concurrent executor workers don't
|
||||
# race on the flag (GHSA-96vc-wcxf-jjff).
|
||||
interactive_token = set_hermes_interactive_context(True)
|
||||
# and the non-interactive auto-approve path must not fire.
|
||||
previous_interactive = os.environ.get("HERMES_INTERACTIVE")
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
# Propagate the originating ACP session id to tools that want to
|
||||
# tag side-effects with it (e.g. ``kanban_create`` stamps it on
|
||||
# the new task so clients can render a per-session board). Save
|
||||
@@ -1631,9 +1513,11 @@ class HermesACPAgent(acp.Agent):
|
||||
logger.exception("Agent error in session %s", session_id)
|
||||
return {"final_response": f"Error: {e}", "messages": state.history}
|
||||
finally:
|
||||
# Restore the interactive contextvar for this context.
|
||||
if interactive_token is not None:
|
||||
reset_hermes_interactive_context(interactive_token)
|
||||
# Restore HERMES_INTERACTIVE.
|
||||
if previous_interactive is None:
|
||||
os.environ.pop("HERMES_INTERACTIVE", None)
|
||||
else:
|
||||
os.environ["HERMES_INTERACTIVE"] = previous_interactive
|
||||
# Restore HERMES_SESSION_ID symmetrically.
|
||||
if previous_session_id is None:
|
||||
os.environ.pop("HERMES_SESSION_ID", None)
|
||||
@@ -1727,28 +1611,12 @@ class HermesACPAgent(acp.Agent):
|
||||
self._send_session_info_update(session_id),
|
||||
)
|
||||
|
||||
# Snapshot the runtime identity; the validator lets the
|
||||
# background titler skip its LLM call if the session's model
|
||||
# changed before it fires (#19027).
|
||||
_title_model = getattr(state.agent, "model", None)
|
||||
_title_provider = getattr(state.agent, "provider", None)
|
||||
maybe_auto_title(
|
||||
self.session_manager._get_db(),
|
||||
session_id,
|
||||
user_text,
|
||||
final_response,
|
||||
state.history,
|
||||
main_runtime={
|
||||
"model": getattr(state.agent, "model", None),
|
||||
"provider": getattr(state.agent, "provider", None),
|
||||
"base_url": getattr(state.agent, "base_url", None),
|
||||
"api_key": getattr(state.agent, "api_key", None),
|
||||
"api_mode": getattr(state.agent, "api_mode", None),
|
||||
},
|
||||
runtime_validator=lambda: (
|
||||
getattr(state.agent, "model", None) == _title_model
|
||||
and getattr(state.agent, "provider", None) == _title_provider
|
||||
),
|
||||
title_callback=_notify_title_update,
|
||||
)
|
||||
except Exception:
|
||||
@@ -1866,7 +1734,7 @@ class HermesACPAgent(acp.Agent):
|
||||
"tools": self._cmd_tools,
|
||||
"context": self._cmd_context,
|
||||
"reset": self._cmd_reset,
|
||||
"compress": self._cmd_compress,
|
||||
"compact": self._cmd_compact,
|
||||
"steer": self._cmd_steer,
|
||||
"queue": self._cmd_queue,
|
||||
"version": self._cmd_version,
|
||||
@@ -2008,7 +1876,7 @@ class HermesACPAgent(acp.Agent):
|
||||
lines.append(
|
||||
f"Compression: due now (threshold ~{threshold_tokens:,}"
|
||||
+ (f", {threshold_pct:.0f}%" if threshold_pct else "")
|
||||
+ "). Run /compress."
|
||||
+ "). Run /compact."
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
@@ -2021,39 +1889,24 @@ class HermesACPAgent(acp.Agent):
|
||||
lines.append(f"Compression threshold: ~{threshold_tokens:,} tokens")
|
||||
|
||||
if getattr(agent, "compression_enabled", True) is False:
|
||||
lines.append(
|
||||
"Auto-compaction is disabled (compression.enabled: false); "
|
||||
"/compress still compresses manually."
|
||||
)
|
||||
lines.append("Compression is disabled for this agent.")
|
||||
else:
|
||||
lines.append("Tip: run /compress to compress manually before the threshold.")
|
||||
lines.append("Tip: run /compact to compress manually before the threshold.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _cmd_reset(self, args: str, state: SessionState) -> str:
|
||||
state.history.clear()
|
||||
reset_failed = False
|
||||
try:
|
||||
reset_session_state = getattr(state.agent, "reset_session_state", None)
|
||||
if callable(reset_session_state):
|
||||
reset_session_state()
|
||||
except Exception:
|
||||
reset_failed = True
|
||||
logger.warning("ACP session state reset failed for %s", state.session_id, exc_info=True)
|
||||
finally:
|
||||
self.session_manager.save_session(state.session_id)
|
||||
if reset_failed:
|
||||
return "Conversation history cleared. Agent session state reset failed; see logs."
|
||||
self.session_manager.save_session(state.session_id)
|
||||
return "Conversation history cleared."
|
||||
|
||||
def _cmd_compress(self, args: str, state: SessionState) -> str:
|
||||
def _cmd_compact(self, args: str, state: SessionState) -> str:
|
||||
if not state.history:
|
||||
return "Nothing to compress — conversation is empty."
|
||||
try:
|
||||
agent = state.agent
|
||||
# No compression_enabled gate: the flag disables *automatic*
|
||||
# compaction only; manual /compress must keep working (matches
|
||||
# the CLI /compress and gateway handlers).
|
||||
if not getattr(agent, "compression_enabled", True):
|
||||
return "Context compression is disabled for this agent."
|
||||
if not hasattr(agent, "_compress_context"):
|
||||
return "Context compression not available for this agent."
|
||||
|
||||
@@ -2078,7 +1931,6 @@ class HermesACPAgent(acp.Agent):
|
||||
getattr(agent, "_cached_system_prompt", "") or "",
|
||||
approx_tokens=approx_tokens,
|
||||
task_id=state.session_id,
|
||||
force=True,
|
||||
)
|
||||
finally:
|
||||
agent._session_db = original_session_db
|
||||
|
||||
+26
-58
@@ -26,18 +26,31 @@ 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 (``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.
|
||||
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.
|
||||
"""
|
||||
from hermes_constants import translate_cwd_for_wsl_backend
|
||||
from hermes_constants import is_wsl
|
||||
|
||||
return translate_cwd_for_wsl_backend(str(cwd))
|
||||
if not is_wsl():
|
||||
return cwd
|
||||
translated = _win_path_to_wsl(str(cwd))
|
||||
return translated if translated is not None else cwd
|
||||
|
||||
|
||||
def _normalize_cwd_for_compare(cwd: str | None) -> str:
|
||||
@@ -48,9 +61,7 @@ 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.
|
||||
from hermes_constants import windows_path_to_wsl
|
||||
|
||||
translated = windows_path_to_wsl(expanded)
|
||||
translated = _win_path_to_wsl(expanded)
|
||||
if translated is not None:
|
||||
expanded = translated
|
||||
elif re.match(r"^/mnt/[A-Za-z]/", expanded):
|
||||
@@ -450,47 +461,10 @@ class SessionManager:
|
||||
except Exception:
|
||||
logger.debug("Failed to update ACP session metadata", exc_info=True)
|
||||
|
||||
# When the agent owns persistence to this same SessionDB it has
|
||||
# already flushed the live transcript incrementally during
|
||||
# run_conversation (append_message), and it preserves pre-compaction
|
||||
# turns non-destructively via archive_and_compact() — keeping them on
|
||||
# disk as searchable active=0/compacted=1 rows. Calling
|
||||
# replace_messages() here would then be a redundant double-write that
|
||||
# DELETEs exactly those archived rows (and, after a compression-driven
|
||||
# id rotation where agent.session_id no longer equals
|
||||
# state.session_id, clobbers the ended parent transcript) — silent
|
||||
# data loss for any ACP conversation long enough to compress.
|
||||
#
|
||||
# Only fall back to the destructive atomic replace when the agent is
|
||||
# NOT persisting itself to this DB (e.g. a test agent factory, or a
|
||||
# fresh create/fork whose copied history the agent has not flushed
|
||||
# yet). That path still rolls back on a mid-rewrite failure so the
|
||||
# previously persisted conversation survives (salvaged from #13675).
|
||||
agent = state.agent
|
||||
agent_db = getattr(agent, "_session_db", None)
|
||||
agent_owns_persistence = (
|
||||
agent_db is not None
|
||||
and agent_db is db
|
||||
and bool(getattr(agent, "_session_db_created", False))
|
||||
)
|
||||
if not agent_owns_persistence:
|
||||
# Even when the current agent doesn't "own" persistence, the
|
||||
# session on disk may already carry compaction-archived rows —
|
||||
# e.g. after a model switch or a /restore, both of which mint a
|
||||
# fresh agent with _session_db_created=False (so the check above
|
||||
# is False) yet leave the durable archived transcript in place.
|
||||
# A full-history replace would DELETE those archived rows just
|
||||
# like the owned-agent case. Guard against it: when archived
|
||||
# rows exist, replace ONLY the live (active=1) set and leave the
|
||||
# archived turns untouched; otherwise the destructive replace is
|
||||
# safe (fresh create/fork with no archived history to lose).
|
||||
try:
|
||||
has_archived = db.has_archived_messages(state.session_id)
|
||||
except Exception:
|
||||
has_archived = False
|
||||
db.replace_messages(
|
||||
state.session_id, state.history, active_only=has_archived
|
||||
)
|
||||
# Replace stored messages with current history atomically so a
|
||||
# mid-rewrite failure rolls back and the previously persisted
|
||||
# conversation is preserved (salvaged from #13675).
|
||||
db.replace_messages(state.session_id, state.history)
|
||||
except Exception:
|
||||
logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True)
|
||||
|
||||
@@ -534,15 +508,9 @@ class SessionManager:
|
||||
|
||||
model = row.get("model") or None
|
||||
|
||||
# Load conversation history. repair_alternation: this restore feeds
|
||||
# LIVE REPLAY — the loaded list becomes the resumed agent's working
|
||||
# conversation. A durable ``user;user`` violation left in state.db would
|
||||
# otherwise re-fire the pre-request defensive repair on every request
|
||||
# for the rest of the session (see hermes_state.get_messages_as_conversation).
|
||||
# Load conversation history.
|
||||
try:
|
||||
history = db.get_messages_as_conversation(
|
||||
session_id, repair_alternation=True
|
||||
)
|
||||
history = db.get_messages_as_conversation(session_id)
|
||||
except Exception:
|
||||
logger.warning("Failed to load messages for ACP session %s", session_id, exc_info=True)
|
||||
history = []
|
||||
|
||||
+3
-59
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -15,8 +14,6 @@ from acp.schema import (
|
||||
ToolKind,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Map hermes tool names -> ACP ToolKind
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -113,12 +110,7 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:
|
||||
if tool_name == "web_extract":
|
||||
urls = args.get("urls", [])
|
||||
if urls:
|
||||
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 f"extract: {urls[0]}" + (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"
|
||||
@@ -387,24 +379,6 @@ def _format_execute_code_result(result: Optional[str]) -> Optional[str]:
|
||||
error = str(data.get("error") or "")
|
||||
exit_code = data.get("exit_code")
|
||||
parts = [f"Exit code: {exit_code}" if exit_code is not None else "Execution complete"]
|
||||
if data.get("stdout_truncated"):
|
||||
total = data.get("stdout_bytes_total")
|
||||
captured = data.get("stdout_bytes_captured")
|
||||
omitted = data.get("stdout_bytes_omitted")
|
||||
if all(isinstance(v, int) for v in (captured, total, omitted)):
|
||||
parts.extend([
|
||||
"",
|
||||
(
|
||||
"Output truncated: "
|
||||
f"captured {captured:,} of {total:,} bytes "
|
||||
f"({omitted:,} omitted)."
|
||||
),
|
||||
])
|
||||
else:
|
||||
parts.extend(["", "Output truncated."])
|
||||
warning = str(data.get("warning") or "").strip()
|
||||
if warning:
|
||||
parts.extend(["", "Warning:", warning])
|
||||
if output:
|
||||
parts.extend(["", "Output:", output])
|
||||
if error:
|
||||
@@ -643,7 +617,7 @@ def _format_session_search_result(result: Optional[str]) -> Optional[str]:
|
||||
return None
|
||||
mode = data.get("mode") or "search"
|
||||
query = data.get("query")
|
||||
lines = ["Recent sessions" if mode == "recent" else "Session search results" + (f" for `{query}`" if query else "")]
|
||||
lines = ["Recent sessions" if mode == "recent" else f"Session search results" + (f" for `{query}`" if query else "")]
|
||||
if not results:
|
||||
lines.append(str(data.get("message") or "No matching sessions found."))
|
||||
return "\n".join(lines)
|
||||
@@ -1047,37 +1021,7 @@ def build_tool_start(
|
||||
*,
|
||||
edit_diff: Any = None,
|
||||
) -> ToolCallStart:
|
||||
"""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``)."""
|
||||
"""Create a ToolCallStart event for the given hermes tool invocation."""
|
||||
kind = get_tool_kind(tool_name)
|
||||
title = build_tool_title(tool_name, arguments)
|
||||
locations = extract_locations(arguments)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.17.0",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
"authors": ["Nous Research"],
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.17.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none">
|
||||
<path d="M8 1.5v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M8 3.25c-2.35-1.4-4.7-.95-6.25.35 1.85-.2 3.8.2 5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 3.25c2.35-1.4 4.7-.95 6.25.35-1.85-.2-3.8.2-5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c-2.3-1-3.05-2.65-1.35-4.15-2 .8-2.35 2.95-.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c2.3-1 3.05-2.65 1.35-4.15 2 .8 2.35 2.95.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="8" cy="1.8" r="1.1" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 882 B |
+20
-284
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Optional
|
||||
import httpx
|
||||
|
||||
from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token
|
||||
from hermes_cli.auth import AuthError, _read_codex_tokens, resolve_codex_runtime_credentials
|
||||
from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -214,7 +214,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
return None
|
||||
|
||||
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
|
||||
details.append("(or run /topup)")
|
||||
details.append("(or run /credits)")
|
||||
|
||||
plan = getattr(sub, "plan", None) if sub is not None else None
|
||||
return AccountUsageSnapshot(
|
||||
@@ -340,7 +340,7 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreditsView:
|
||||
"""Surface-agnostic data for the ``/topup`` balance view.
|
||||
"""Surface-agnostic data for the ``/credits`` command.
|
||||
|
||||
One portal fetch, one parse — consumed identically by the CLI panel, the
|
||||
gateway button, and any other money surface. Fail-open: when not logged in
|
||||
@@ -356,11 +356,11 @@ class CreditsView:
|
||||
|
||||
|
||||
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
|
||||
"""Build the /topup balance view: balance block + identity line + top-up URL.
|
||||
"""Build the /credits view: balance block + identity line + top-up URL.
|
||||
|
||||
Reuses the same account fetch + snapshot + URL builder as the /usage credits
|
||||
block, so the numbers always match. The balance block is the rendered
|
||||
snapshot MINUS its trailing top-up/command-hint lines (the /topup surface
|
||||
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
|
||||
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
|
||||
"""
|
||||
not_logged_in = CreditsView(logged_in=False)
|
||||
@@ -386,7 +386,7 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
|
||||
timeout=timeout
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("credits ▸ /topup portal fetch failed (fail-open)", exc_info=True)
|
||||
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
|
||||
return not_logged_in
|
||||
|
||||
if account is None or not getattr(account, "logged_in", False):
|
||||
@@ -394,8 +394,8 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
|
||||
|
||||
snapshot = build_nous_credits_snapshot(account)
|
||||
# Balance lines = the snapshot block minus the two trailing affordance lines
|
||||
# ("Top up: <url>" + "(or run /topup)") that build_nous_credits_snapshot
|
||||
# appends for the /usage surface. /topup renders its own button/panel.
|
||||
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
|
||||
# appends for the /usage surface. /credits renders its own button/panel.
|
||||
balance_lines: list[str] = []
|
||||
if snapshot is not None:
|
||||
rendered = render_account_usage_lines(snapshot, markdown=markdown)
|
||||
@@ -425,102 +425,31 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
|
||||
)
|
||||
|
||||
|
||||
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/...``.
|
||||
"""
|
||||
def _resolve_codex_usage_url(base_url: str) -> str:
|
||||
normalized = (base_url or "").strip().rstrip("/")
|
||||
if not normalized:
|
||||
normalized = "https://chatgpt.com/backend-api/codex"
|
||||
if normalized.endswith("/codex"):
|
||||
normalized = normalized[: -len("/codex")]
|
||||
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",
|
||||
)
|
||||
if "/backend-api" in normalized:
|
||||
return normalized + "/wham/usage"
|
||||
return normalized + "/api/codex/usage"
|
||||
|
||||
|
||||
def _resolve_codex_usage_url(base_url: str) -> str:
|
||||
return _codex_backend_urls(base_url)[0]
|
||||
|
||||
|
||||
def _resolve_codex_usage_credentials(
|
||||
base_url: Optional[str],
|
||||
api_key: Optional[str],
|
||||
) -> tuple[str, str, Optional[str]]:
|
||||
"""Resolve Codex quota credentials from the native runtime path.
|
||||
|
||||
Prefer explicit live-agent credentials, then the legacy singleton OAuth
|
||||
state, then the credential pool. Hermes's native OAuth setup now stores
|
||||
device-code logins in the pool, so quota diagnostics must not depend only
|
||||
on the older singleton store.
|
||||
"""
|
||||
explicit_key = str(api_key or "").strip()
|
||||
if explicit_key:
|
||||
return explicit_key, str(base_url or "").strip(), None
|
||||
|
||||
# Tier 2: the native runtime resolver. It ALREADY falls back to the
|
||||
# credential pool when the singleton is empty (see
|
||||
# ``resolve_codex_runtime_credentials`` — issue #32992), so in a pool-only
|
||||
# setup this returns a usable ``source="credential_pool"`` token.
|
||||
#
|
||||
# Only ``AuthError`` ("no creds" / rate-limited) is caught so tier 3 can
|
||||
# run: a broad ``except Exception`` would (a) mask a transient refresh /
|
||||
# network failure and silently hand back a DIFFERENT pool account's usage,
|
||||
# and (b) hide genuine programming errors. A refresh/network error must
|
||||
# propagate — the outer ``fetch_account_usage`` guard fails open (shows
|
||||
# nothing this turn) rather than reporting the wrong account.
|
||||
#
|
||||
# The ``account_id`` (for the ``ChatGPT-Account-Id`` header) is read
|
||||
# best-effort: a partial/missing singleton token store must not sink an
|
||||
# otherwise-usable resolver credential and force a header-less pool fallback.
|
||||
try:
|
||||
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
|
||||
account_id: Optional[str] = None
|
||||
try:
|
||||
token_data = _read_codex_tokens()
|
||||
tokens = token_data.get("tokens") or {}
|
||||
account_id = str(tokens.get("account_id", "") or "").strip() or None
|
||||
except AuthError:
|
||||
# Pool-only creds carry no singleton account_id; header is optional.
|
||||
logger.debug("codex ▸ /usage account_id read failed (best-effort)", exc_info=True)
|
||||
return creds["api_key"], str(creds.get("base_url", "") or "").strip(), account_id
|
||||
except AuthError:
|
||||
logger.debug("codex ▸ /usage runtime resolver returned no creds; trying pool", exc_info=True)
|
||||
|
||||
# Tier 3: direct pool select. Reached only when the resolver itself raises
|
||||
# AuthError (e.g. singleton missing AND its own pool read found nothing at
|
||||
# resolve time, but a pool entry is usable now). Pool credentials have no
|
||||
# account_id concept, so the ChatGPT-Account-Id header is intentionally
|
||||
# omitted here.
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openai-codex")
|
||||
entry = pool.select()
|
||||
if entry is None:
|
||||
raise RuntimeError("No available openai-codex credential in credential pool")
|
||||
return entry.runtime_api_key, str(entry.runtime_base_url or base_url or "").strip(), None
|
||||
|
||||
|
||||
def _fetch_codex_account_usage(
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
) -> Optional[AccountUsageSnapshot]:
|
||||
token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key)
|
||||
def _fetch_codex_account_usage() -> Optional[AccountUsageSnapshot]:
|
||||
creds = resolve_codex_runtime_credentials(refresh_if_expiring=True)
|
||||
token_data = _read_codex_tokens()
|
||||
tokens = token_data.get("tokens") or {}
|
||||
account_id = str(tokens.get("account_id", "") or "").strip() or None
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Authorization": f"Bearer {creds['api_key']}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "codex-cli",
|
||||
}
|
||||
if account_id:
|
||||
headers["ChatGPT-Account-Id"] = account_id
|
||||
with httpx.Client(timeout=15.0) as client:
|
||||
response = client.get(_resolve_codex_usage_url(resolved_base_url), headers=headers)
|
||||
response = client.get(_resolve_codex_usage_url(creds.get("base_url", "")), headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json() or {}
|
||||
rate_limit = payload.get("rate_limit") or {}
|
||||
@@ -538,14 +467,6 @@ 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")
|
||||
@@ -563,191 +484,6 @@ 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":
|
||||
# The redeemed reset restores the account's quota upstream — lift any
|
||||
# persisted pool cooldowns so Hermes doesn't keep the credential
|
||||
# frozen behind the now-stale ``last_error_reset_at`` (issue #43747).
|
||||
try:
|
||||
from hermes_cli.auth import clear_codex_pool_quota_cooldowns
|
||||
|
||||
clear_codex_pool_quota_cooldowns()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to clear Codex pool cooldowns after reset redemption",
|
||||
exc_info=True,
|
||||
)
|
||||
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:
|
||||
@@ -892,7 +628,7 @@ def fetch_account_usage(
|
||||
return None
|
||||
try:
|
||||
if normalized == "openai-codex":
|
||||
return _fetch_codex_account_usage(base_url=base_url, api_key=api_key)
|
||||
return _fetch_codex_account_usage()
|
||||
if normalized == "anthropic":
|
||||
return _fetch_anthropic_account_usage()
|
||||
if normalized == "openrouter":
|
||||
|
||||
+51
-766
File diff suppressed because it is too large
Load Diff
+32
-699
File diff suppressed because it is too large
Load Diff
+80
-174
@@ -65,7 +65,6 @@ 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",
|
||||
@@ -127,8 +126,6 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
|
||||
_ANTHROPIC_OUTPUT_LIMITS = {
|
||||
# Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
|
||||
"claude-fable": 128_000,
|
||||
# Claude Sonnet 5
|
||||
"claude-sonnet-5": 128_000,
|
||||
# Claude 4.8
|
||||
"claude-opus-4-8": 128_000,
|
||||
# Claude 4.7
|
||||
@@ -249,13 +246,7 @@ def _supports_adaptive_thinking(model: str) -> bool:
|
||||
only returns False for the explicit legacy list of older Claude families
|
||||
that require manual budget-based thinking. Non-Claude Anthropic-Messages
|
||||
models (minimax, qwen3, …) return False so they keep the manual path.
|
||||
|
||||
Kimi / Moonshot models are the exception: their Anthropic-compatible
|
||||
endpoints implement the adaptive contract (``thinking.type="adaptive"``
|
||||
+ ``output_config.effort``, including ``xhigh`` and ``display``).
|
||||
"""
|
||||
if _model_name_is_kimi_family(model):
|
||||
return True
|
||||
if not _is_claude_model(model):
|
||||
return False
|
||||
m = model.lower()
|
||||
@@ -457,8 +448,7 @@ def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
|
||||
|
||||
# Model-name prefixes that identify the Kimi / Moonshot family. Covers
|
||||
# - official slugs: ``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``
|
||||
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``,
|
||||
# and the bare Coding Plan slug ``k3`` (plus ``k3.x``/``k3-...`` variants)
|
||||
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``
|
||||
# Matched case-insensitively against the post-``normalize_model_name`` form,
|
||||
# so a caller's ``provider/vendor/model`` slug is handled the same as a
|
||||
# bare name.
|
||||
@@ -468,14 +458,8 @@ _KIMI_FAMILY_MODEL_PREFIXES = (
|
||||
"k1.", "k1-",
|
||||
"k2.", "k2-",
|
||||
"k25", "k2.5",
|
||||
"k3.", "k3-",
|
||||
)
|
||||
|
||||
# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3
|
||||
# as the exact slug ``k3``). Kept exact-match so unrelated model names that
|
||||
# merely start with the same characters don't get misclassified.
|
||||
_KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"})
|
||||
|
||||
|
||||
def _model_name_is_kimi_family(model: str | None) -> bool:
|
||||
if not isinstance(model, str):
|
||||
@@ -486,8 +470,6 @@ def _model_name_is_kimi_family(model: str | None) -> bool:
|
||||
# Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``)
|
||||
if "/" in m:
|
||||
m = m.rsplit("/", 1)[-1]
|
||||
if m in _KIMI_FAMILY_EXACT_SLUGS:
|
||||
return True
|
||||
return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)
|
||||
|
||||
|
||||
@@ -551,9 +533,8 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
|
||||
Some third-party /anthropic endpoints implement Anthropic's Messages API but
|
||||
require Authorization: Bearer instead of Anthropic's native x-api-key header.
|
||||
MiniMax's global and China Anthropic-compatible endpoints, Azure AI
|
||||
Foundry's Anthropic-style endpoint, and Palantir Foundry's LLM proxy
|
||||
follow this pattern.
|
||||
MiniMax's global and China Anthropic-compatible endpoints, and Azure AI
|
||||
Foundry's Anthropic-style endpoint follow this pattern.
|
||||
"""
|
||||
normalized = _normalize_base_url_text(base_url)
|
||||
if not normalized:
|
||||
@@ -562,11 +543,6 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
return (
|
||||
normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"))
|
||||
or "azure.com" in normalized
|
||||
# Palantir Foundry LLM proxy (<org>.palantirfoundry.com/api/v2/llm/proxy/anthropic)
|
||||
# rejects x-api-key with 401 and requires Authorization: Bearer.
|
||||
# Hostname match (not substring) so e.g. evil.com/palantirfoundry
|
||||
# paths don't trigger Bearer auth.
|
||||
or base_url_host_matches(normalized, "palantirfoundry.com")
|
||||
)
|
||||
|
||||
|
||||
@@ -841,7 +817,7 @@ def build_anthropic_client(
|
||||
kwargs["auth_token"] = api_key
|
||||
kwargs["default_headers"] = {
|
||||
"anthropic-beta": ",".join(all_betas),
|
||||
"user-agent": f"claude-code/{_get_claude_code_version()} (external, cli)",
|
||||
"user-agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
||||
"x-app": "cli",
|
||||
}
|
||||
else:
|
||||
@@ -1069,7 +1045,7 @@ def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False)
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": content_type,
|
||||
"User-Agent": _OAUTH_TOKEN_USER_AGENT,
|
||||
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
@@ -1402,20 +1378,9 @@ _OAUTH_TOKEN_URLS = [
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
]
|
||||
_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
|
||||
# User-Agent sent on the OAuth *token endpoint* (login exchange + refresh).
|
||||
# Anthropic rate-limits (HTTP 429) any token-endpoint request whose UA starts
|
||||
# with ``claude-code/`` — verified empirically against platform.claude.com:
|
||||
# ``claude-code/2.1.200`` and ``Mozilla/5.0`` -> 429; ``axios/*``, ``node``,
|
||||
# and SDK-style UAs -> 400 (reached code validation). The real Claude Code CLI
|
||||
# exchanges the auth code with a bare axios client (``axios/<ver>``), NOT its
|
||||
# ``claude-code/`` inference UA. We mirror that here. NOTE: the *inference* path
|
||||
# (build_anthropic_kwargs) still uses the ``claude-code/`` UA + ``x-app: cli`` —
|
||||
# that fingerprint is required there and is NOT throttled on the messages API.
|
||||
_OAUTH_TOKEN_USER_AGENT = "axios/1.7.9"
|
||||
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
||||
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
|
||||
def _get_hermes_oauth_file() -> Path:
|
||||
return get_hermes_home() / ".anthropic_oauth.json"
|
||||
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
|
||||
|
||||
|
||||
def _generate_pkce() -> tuple:
|
||||
@@ -1513,9 +1478,6 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
|
||||
# console.anthropic.com now 404s. Try the new host first, then fall
|
||||
# back to console for older deployments (mirrors the refresh path).
|
||||
# UA is _OAUTH_TOKEN_USER_AGENT (a non-claude-code UA) — see the
|
||||
# constant's definition for why the token endpoint must not send
|
||||
# claude-code/ (429 UA-prefix block).
|
||||
result = None
|
||||
last_error = None
|
||||
for endpoint in _OAUTH_TOKEN_URLS:
|
||||
@@ -1524,7 +1486,7 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
data=exchange_data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": _OAUTH_TOKEN_USER_AGENT,
|
||||
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
@@ -1563,10 +1525,9 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
|
||||
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
|
||||
oauth_file = _get_hermes_oauth_file()
|
||||
if oauth_file.exists():
|
||||
if _HERMES_OAUTH_FILE.exists():
|
||||
try:
|
||||
data = json.loads(oauth_file.read_text(encoding="utf-8"))
|
||||
data = json.loads(_HERMES_OAUTH_FILE.read_text(encoding="utf-8"))
|
||||
if data.get("accessToken"):
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
@@ -1591,10 +1552,7 @@ def _is_bedrock_model_id(model: str) -> bool:
|
||||
"""
|
||||
lower = model.lower()
|
||||
# Regional inference-profile prefixes
|
||||
if any(lower.startswith(p) for p in (
|
||||
"global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.",
|
||||
"ca.", "sa.", "me.", "af.",
|
||||
)):
|
||||
if any(lower.startswith(p) for p in ("global.", "us.", "eu.", "ap.", "jp.")):
|
||||
return True
|
||||
# Bare Bedrock model IDs: provider.model-family
|
||||
if lower.startswith("anthropic."):
|
||||
@@ -1933,18 +1891,6 @@ def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def _apply_assistant_cache_control_to_last_cacheable_block(
|
||||
blocks: List[Dict[str, Any]],
|
||||
cache_control: Any,
|
||||
) -> None:
|
||||
if not isinstance(cache_control, dict):
|
||||
return
|
||||
for block in reversed(blocks):
|
||||
if isinstance(block, dict) and block.get("type") in {"text", "tool_use"}:
|
||||
block.setdefault("cache_control", dict(cache_control))
|
||||
break
|
||||
|
||||
|
||||
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Convert an assistant message to Anthropic content blocks.
|
||||
|
||||
@@ -1999,9 +1945,6 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
clean["input"] = redacted
|
||||
replayed.append(clean)
|
||||
if replayed:
|
||||
_apply_assistant_cache_control_to_last_cacheable_block(
|
||||
replayed, m.get("cache_control")
|
||||
)
|
||||
return {"role": "assistant", "content": replayed}
|
||||
|
||||
blocks = _extract_preserved_thinking_blocks(m)
|
||||
@@ -2027,9 +1970,6 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"name": fn.get("name", ""),
|
||||
"input": parsed_args,
|
||||
})
|
||||
_apply_assistant_cache_control_to_last_cacheable_block(
|
||||
blocks, m.get("cache_control")
|
||||
)
|
||||
# Kimi's /coding endpoint (Anthropic protocol) requires assistant
|
||||
# tool-call messages to carry reasoning_content when thinking is
|
||||
# enabled server-side. Preserve it as a thinking block so Kimi
|
||||
@@ -2129,7 +2069,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") or "").strip() == ""
|
||||
b.get("text", "").strip() == ""
|
||||
for b in converted_blocks
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
):
|
||||
@@ -2145,81 +2085,57 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
|
||||
"""Strip tool_use blocks with no matching tool_result, and vice versa.
|
||||
|
||||
Context compression or session truncation can remove either side of a
|
||||
tool-call pair, or insert messages between a tool_use and its result.
|
||||
Anthropic requires each tool_use to have a matching tool_result in the
|
||||
IMMEDIATELY FOLLOWING user message — a global ID match is not enough.
|
||||
tool-call pair. Anthropic rejects both orphans with HTTP 400.
|
||||
|
||||
Mutates ``result`` in place.
|
||||
"""
|
||||
# Pass 1: For each assistant message with tool_use blocks, check that
|
||||
# EACH tool_use ID has a matching tool_result in the immediately following
|
||||
# user message. Strip tool_use blocks that lack an adjacent result —
|
||||
# Anthropic rejects non-adjacent pairs with HTTP 400 even when the IDs
|
||||
# match somewhere later in the conversation.
|
||||
for i, m in enumerate(result):
|
||||
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
|
||||
continue
|
||||
tool_use_ids_in_turn = {
|
||||
b.get("id")
|
||||
for b in m["content"]
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use"
|
||||
}
|
||||
if not tool_use_ids_in_turn:
|
||||
continue
|
||||
|
||||
# Collect result IDs from the immediately following user message only.
|
||||
adjacent_result_ids: set = set()
|
||||
if i + 1 < len(result):
|
||||
nxt = result[i + 1]
|
||||
if nxt.get("role") == "user" and isinstance(nxt.get("content"), list):
|
||||
for block in nxt["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
adjacent_result_ids.add(block.get("tool_use_id"))
|
||||
|
||||
orphaned = tool_use_ids_in_turn - adjacent_result_ids
|
||||
if not orphaned:
|
||||
continue
|
||||
|
||||
kept = [
|
||||
b
|
||||
for b in m["content"]
|
||||
if not (isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id") in orphaned)
|
||||
]
|
||||
# If stripping an orphaned tool_use mutated a turn that also carries a
|
||||
# signed thinking block, that block's Anthropic signature was computed
|
||||
# against the ORIGINAL (un-stripped) turn content and is now invalid.
|
||||
# Anthropic rejects the replayed turn with HTTP 400 "thinking blocks in
|
||||
# the latest assistant message cannot be modified". Flag the turn so
|
||||
# _manage_thinking_signatures can demote the dead signature instead of
|
||||
# replaying it verbatim. See hermes-agent: extended-thinking + parallel
|
||||
# tool batch interrupted mid-flight → non-retryable 400 crash-loop.
|
||||
if len(kept) != len(m["content"]) and any(
|
||||
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
|
||||
for b in m["content"]
|
||||
):
|
||||
m["_thinking_signature_invalidated"] = True
|
||||
m["content"] = kept if kept else [{"type": "text", "text": "(tool call removed)"}]
|
||||
|
||||
# Pass 2: Rebuild the set of tool_use IDs that survived pass 1, then
|
||||
# strip tool_result blocks that no longer have any matching tool_use
|
||||
# anywhere in the conversation.
|
||||
surviving_tool_use_ids: set = set()
|
||||
# Strip orphaned tool_use blocks (no matching tool_result follows)
|
||||
tool_result_ids = set()
|
||||
for m in result:
|
||||
if m.get("role") == "assistant" and isinstance(m.get("content"), list):
|
||||
if m["role"] == "user" and isinstance(m["content"], list):
|
||||
for block in m["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||
surviving_tool_use_ids.add(block.get("id"))
|
||||
|
||||
if block.get("type") == "tool_result":
|
||||
tool_result_ids.add(block.get("tool_use_id"))
|
||||
for m in result:
|
||||
if m.get("role") != "user" or not isinstance(m.get("content"), list):
|
||||
continue
|
||||
new_content = [
|
||||
b
|
||||
for b in m["content"]
|
||||
if not (isinstance(b, dict) and b.get("type") == "tool_result")
|
||||
or b.get("tool_use_id") in surviving_tool_use_ids
|
||||
]
|
||||
if len(new_content) != len(m["content"]):
|
||||
m["content"] = new_content if new_content else [{"type": "text", "text": "(tool result removed)"}]
|
||||
if m["role"] == "assistant" and isinstance(m["content"], list):
|
||||
kept = [
|
||||
b
|
||||
for b in m["content"]
|
||||
if b.get("type") != "tool_use" or b.get("id") in tool_result_ids
|
||||
]
|
||||
# If stripping an orphaned tool_use mutated a turn that also carries a
|
||||
# signed thinking block, that block's Anthropic signature was computed
|
||||
# against the ORIGINAL (un-stripped) turn content and is now invalid.
|
||||
# Anthropic rejects the replayed turn with HTTP 400 "thinking blocks in
|
||||
# the latest assistant message cannot be modified". Flag the turn so
|
||||
# _manage_thinking_signatures can demote the dead signature instead of
|
||||
# replaying it verbatim. See hermes-agent: extended-thinking + parallel
|
||||
# tool batch interrupted mid-flight → non-retryable 400 crash-loop.
|
||||
if len(kept) != len(m["content"]) and any(
|
||||
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
|
||||
for b in m["content"]
|
||||
):
|
||||
m["_thinking_signature_invalidated"] = True
|
||||
m["content"] = kept
|
||||
if not m["content"]:
|
||||
m["content"] = [{"type": "text", "text": "(tool call removed)"}]
|
||||
|
||||
# Strip orphaned tool_result blocks (no matching tool_use precedes them)
|
||||
tool_use_ids = set()
|
||||
for m in result:
|
||||
if m["role"] == "assistant" and isinstance(m["content"], list):
|
||||
for block in m["content"]:
|
||||
if block.get("type") == "tool_use":
|
||||
tool_use_ids.add(block.get("id"))
|
||||
for m in result:
|
||||
if m["role"] == "user" and isinstance(m["content"], list):
|
||||
m["content"] = [
|
||||
b
|
||||
for b in m["content"]
|
||||
if b.get("type") != "tool_result" or b.get("tool_use_id") in tool_use_ids
|
||||
]
|
||||
if not m["content"]:
|
||||
m["content"] = [{"type": "text", "text": "(tool result removed)"}]
|
||||
|
||||
|
||||
def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
@@ -2296,6 +2212,13 @@ def _manage_thinking_signatures(
|
||||
"""
|
||||
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
|
||||
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
|
||||
# Kimi / DeepSeek share a contract: strip signed Anthropic blocks
|
||||
# (neither upstream can validate Anthropic signatures), preserve unsigned
|
||||
# ones synthesised from reasoning_content. See #13848, #16748.
|
||||
_preserve_unsigned_thinking = (
|
||||
_is_kimi_family_endpoint(base_url, model)
|
||||
or _is_deepseek_anthropic_endpoint(base_url)
|
||||
)
|
||||
|
||||
last_assistant_idx = None
|
||||
for i in range(len(result) - 1, -1, -1):
|
||||
@@ -2307,12 +2230,8 @@ def _manage_thinking_signatures(
|
||||
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
|
||||
continue
|
||||
|
||||
if _is_kimi_family_endpoint(base_url, model):
|
||||
# Kimi does not enforce thinking signatures — replay as-is
|
||||
# (shared cleanup below still strips cache markers + the internal flag).
|
||||
pass
|
||||
elif _is_deepseek_anthropic_endpoint(base_url):
|
||||
# DeepSeek: strip signed, preserve unsigned.
|
||||
if _preserve_unsigned_thinking:
|
||||
# Kimi / DeepSeek: strip signed, preserve unsigned.
|
||||
new_content = []
|
||||
for b in m["content"]:
|
||||
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
|
||||
@@ -2412,24 +2331,6 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
|
||||
]
|
||||
|
||||
|
||||
def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
|
||||
"""Anthropic requires messages[0] to have role=user.
|
||||
|
||||
After a second context compaction on the auto path the summary can be
|
||||
emitted as role=assistant with nothing in front of it (the system prompt
|
||||
lives outside messages[] or is extracted into the separate ``system``
|
||||
param), so messages[0] ends up assistant and the Messages API rejects
|
||||
the request with HTTP 400 — often masked by a misleading
|
||||
"tool_use ids were found without tool_result blocks" error (#52160).
|
||||
|
||||
Mirror the Bedrock Converse adapter, which unconditionally prepends a
|
||||
minimal user turn when the first message is not user
|
||||
(convert_messages_to_converse).
|
||||
"""
|
||||
if result and result[0].get("role") != "user":
|
||||
result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]})
|
||||
|
||||
|
||||
def convert_messages_to_anthropic(
|
||||
messages: List[Dict],
|
||||
base_url: str | None = None,
|
||||
@@ -2488,7 +2389,6 @@ def convert_messages_to_anthropic(
|
||||
|
||||
_strip_orphaned_tool_blocks(result)
|
||||
result = _merge_consecutive_roles(result)
|
||||
_ensure_leading_user_turn(result)
|
||||
_manage_thinking_signatures(result, base_url, model)
|
||||
_evict_old_screenshots(result)
|
||||
|
||||
@@ -2663,19 +2563,25 @@ def build_anthropic_kwargs(
|
||||
# MiniMax Anthropic-compat endpoints support thinking (manual mode only,
|
||||
# not adaptive). Haiku does NOT support extended thinking — skip entirely.
|
||||
#
|
||||
# Kimi / Moonshot models also use adaptive thinking: their
|
||||
# Anthropic-compatible endpoints (api.moonshot.cn/anthropic,
|
||||
# api.kimi.com/coding) accept ``thinking.type="adaptive"`` +
|
||||
# ``output_config.effort``, and the replay-validation 400s that
|
||||
# originally motivated dropping the parameter (#13848) no longer
|
||||
# occur. (Kimi on chat_completions enables thinking via extra_body
|
||||
# in the ChatCompletionsTransport — see #13503.)
|
||||
# Kimi's /coding endpoint speaks the Anthropic Messages protocol but has
|
||||
# its own thinking semantics: when ``thinking.enabled`` is sent, Kimi
|
||||
# validates the message history and requires every prior assistant
|
||||
# tool-call message to carry OpenAI-style ``reasoning_content``. The
|
||||
# Anthropic path never populates that field, and
|
||||
# ``convert_messages_to_anthropic`` strips all Anthropic thinking blocks
|
||||
# on third-party endpoints — so the request fails with HTTP 400
|
||||
# "thinking is enabled but reasoning_content is missing in assistant
|
||||
# tool call message at index N". Kimi's reasoning is driven server-side
|
||||
# on the /coding route, so skip Anthropic's thinking parameter entirely
|
||||
# for that host. (Kimi on chat_completions enables thinking via
|
||||
# extra_body in the ChatCompletionsTransport — see #13503.)
|
||||
#
|
||||
# On 4.7+ the `thinking.display` field defaults to "omitted", which
|
||||
# silently hides reasoning text that Hermes surfaces in its CLI. We
|
||||
# request "summarized" so the reasoning blocks stay populated — matching
|
||||
# 4.6 behavior and preserving the activity-feed UX during long tool runs.
|
||||
if reasoning_config and isinstance(reasoning_config, dict):
|
||||
_is_kimi_coding = _is_kimi_family_endpoint(base_url, model)
|
||||
if reasoning_config and isinstance(reasoning_config, dict) and not _is_kimi_coding:
|
||||
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
|
||||
effort = str(reasoning_config.get("effort", "medium")).lower()
|
||||
budget = THINKING_BUDGET.get(effort, 8000)
|
||||
|
||||
@@ -66,19 +66,3 @@ def safe_schedule_threadsafe(
|
||||
coro.close()
|
||||
log.log(log_level, "%s: %s", log_message, exc)
|
||||
return None
|
||||
|
||||
|
||||
def consume_detached_task_result(task: "asyncio.Future[Any]") -> None:
|
||||
"""Retrieve a detached task's result without surfacing cancellation.
|
||||
|
||||
Used as an ``add_done_callback`` on tasks that were cancelled and
|
||||
detached (e.g. an adapter close path that swallows ``CancelledError``
|
||||
past its teardown deadline). Observing ``task.exception()`` prevents
|
||||
"exception was never retrieved" noise on the event loop; cancellation
|
||||
and any terminal error are deliberately swallowed — the task's owner
|
||||
already gave up on it.
|
||||
"""
|
||||
try:
|
||||
task.exception()
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"""Ambient session-accounting context for auxiliary LLM calls.
|
||||
|
||||
Auxiliary calls (vision, compression, title generation, web_extract,
|
||||
session_search, ...) funnel through ``agent.auxiliary_client`` which has no
|
||||
session handle — so their token usage was historically discarded, leaving
|
||||
dashboard analytics blind to aux model spend (issue #23270).
|
||||
|
||||
Instead of threading ``session_db``/``session_id`` parameters through every
|
||||
aux call site, the agent loop publishes them here (mirroring the Nous Portal
|
||||
conversation context in ``agent.portal_tags``) and the auxiliary client
|
||||
records usage at its single response-validation chokepoint.
|
||||
|
||||
ContextVar semantics give us the right isolation for free:
|
||||
|
||||
* concurrent agents in one process (gateway sessions, delegate subagents)
|
||||
never see each other's accounting context;
|
||||
* worker threads spawned via ``tools.thread_context.propagate_context_to_thread``
|
||||
(MoA fan-out, background review) inherit the parent turn's context;
|
||||
* asyncio tasks inherit the context of the code that created them.
|
||||
|
||||
MoA reference/aggregator slots are explicitly EXCLUDED from recording:
|
||||
``agent/conversation_loop.py`` already folds MoA advisor usage and cost into
|
||||
the main loop's ``update_token_counts`` delta, so recording them here would
|
||||
double-count (see ``_EXCLUDED_TASKS``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (session_db, session_id) for the active agent turn, or None outside one.
|
||||
_accounting: ContextVar[Optional[tuple]] = ContextVar(
|
||||
"aux_accounting_context", default=None
|
||||
)
|
||||
|
||||
# Aux tasks whose usage is already accounted by the main loop — recording
|
||||
# them here would double-count. MoA advisor/aggregator usage is folded into
|
||||
# conversation_loop's update_token_counts delta (tokens AND cost).
|
||||
_EXCLUDED_TASKS = frozenset({"moa_reference", "moa_aggregator"})
|
||||
|
||||
|
||||
def set_accounting_context(session_db: Any, session_id: Optional[str]):
|
||||
"""Publish the active session's accounting handles for aux usage recording.
|
||||
|
||||
Called by the agent loop at turn entry. Returns the ContextVar token so
|
||||
callers can ``reset_accounting_context(token)`` on turn exit. Publishing
|
||||
``None`` handles (no DB / no session id) clears the context.
|
||||
"""
|
||||
if session_db is None or not session_id:
|
||||
return _accounting.set(None)
|
||||
return _accounting.set((session_db, session_id))
|
||||
|
||||
|
||||
def reset_accounting_context(token) -> None:
|
||||
"""Restore the previous accounting context (pair with ``set_...``)."""
|
||||
try:
|
||||
_accounting.reset(token)
|
||||
except Exception:
|
||||
_accounting.set(None)
|
||||
|
||||
|
||||
def get_accounting_context() -> Optional[tuple]:
|
||||
"""Return ``(session_db, session_id)`` for the active turn, or ``None``."""
|
||||
return _accounting.get()
|
||||
|
||||
|
||||
def record_aux_usage(
|
||||
response: Any,
|
||||
task: Optional[str],
|
||||
*,
|
||||
provider: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Record an auxiliary response's token usage against the ambient session.
|
||||
|
||||
Called from the auxiliary client's response-validation chokepoint. Strictly
|
||||
best-effort: any failure is swallowed (accounting must never break an aux
|
||||
call). No-ops when:
|
||||
|
||||
* no accounting context is published (call is outside any agent turn),
|
||||
* the task is main-loop-accounted (MoA slots — see ``_EXCLUDED_TASKS``),
|
||||
* the response carries no usage object.
|
||||
|
||||
The model is read from ``response.model`` (accurate even after the aux
|
||||
client's provider-fallback chains); *provider*/*base_url* reflect the
|
||||
originally-resolved route and are best-effort.
|
||||
"""
|
||||
try:
|
||||
if not task or task in _EXCLUDED_TASKS:
|
||||
return
|
||||
ctx = _accounting.get()
|
||||
if ctx is None:
|
||||
return
|
||||
session_db, session_id = ctx
|
||||
raw_usage = getattr(response, "usage", None)
|
||||
if raw_usage is None:
|
||||
return
|
||||
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
|
||||
usage = normalize_usage(raw_usage, provider=provider)
|
||||
if not (
|
||||
usage.input_tokens or usage.output_tokens
|
||||
or usage.cache_read_tokens or usage.cache_write_tokens
|
||||
or usage.reasoning_tokens
|
||||
):
|
||||
return
|
||||
|
||||
model = str(getattr(response, "model", "") or "") or "unknown"
|
||||
estimated_cost = None
|
||||
try:
|
||||
cost = estimate_usage_cost(
|
||||
model, usage, provider=provider, base_url=base_url
|
||||
)
|
||||
if cost.amount_usd is not None:
|
||||
estimated_cost = float(cost.amount_usd)
|
||||
except Exception:
|
||||
logger.debug("Aux usage cost estimation failed", exc_info=True)
|
||||
|
||||
session_db.record_auxiliary_usage(
|
||||
session_id,
|
||||
task,
|
||||
model=model,
|
||||
billing_provider=provider,
|
||||
billing_base_url=base_url,
|
||||
input_tokens=usage.input_tokens,
|
||||
output_tokens=usage.output_tokens,
|
||||
cache_read_tokens=usage.cache_read_tokens,
|
||||
cache_write_tokens=usage.cache_write_tokens,
|
||||
reasoning_tokens=usage.reasoning_tokens,
|
||||
estimated_cost_usd=estimated_cost,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Aux usage recording failed (non-fatal)", exc_info=True)
|
||||
+210
-1625
File diff suppressed because it is too large
Load Diff
+26
-143
@@ -18,13 +18,12 @@ for invariants and PR review criteria.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.thread_scoped_output import thread_scoped_silence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -62,11 +61,6 @@ 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:
|
||||
@@ -94,15 +88,10 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
|
||||
)
|
||||
return {
|
||||
"provider": rp.get("provider") or task_provider,
|
||||
"model": rp.get("model") or task_model,
|
||||
"model": 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:
|
||||
@@ -459,21 +448,10 @@ def summarize_background_review_actions(
|
||||
data = json.loads(msg.get("content", "{}"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
# ``data`` may not be a dict — some memory/skill tool responses in
|
||||
# older codepaths or wrapper MCP servers return a top-level JSON
|
||||
# list (e.g. ``[{"success": true, ...}]``) or a scalar. The original
|
||||
# isinstance check below silently skips non-dict payloads, which
|
||||
# is correct, but ``data.get("_change")`` further down can still
|
||||
# hand back a list and break ``change.get("description", "")``.
|
||||
# Defensively normalize everything through a dict-typed alias so
|
||||
# the rest of the function can stay terse without per-call
|
||||
# ``isinstance`` guards (#59437).
|
||||
if not isinstance(data, dict) or not data.get("success"):
|
||||
continue
|
||||
message = data.get("message", "")
|
||||
detail = call_details.get(tcid) or {}
|
||||
if not isinstance(detail, dict):
|
||||
detail = {}
|
||||
detail = call_details.get(tcid, {})
|
||||
target = data.get("target", "") or detail.get("target", "")
|
||||
is_skill = detail.get("tool") == "skill_manage"
|
||||
|
||||
@@ -501,30 +479,12 @@ def summarize_background_review_actions(
|
||||
content = detail.get("content", "")
|
||||
old_text = detail.get("old_text", "")
|
||||
skill_name = detail.get("name", "")
|
||||
# ``operations`` may be anything callable put into the JSON
|
||||
# arguments. Anything non-iterable that isn't a list[str]
|
||||
# of dicts becomes unusable here, so coerce defensively.
|
||||
ops_raw = detail.get("operations")
|
||||
operations: list = (
|
||||
ops_raw if isinstance(ops_raw, list) else []
|
||||
)
|
||||
operations = detail.get("operations") or []
|
||||
max_preview = 120
|
||||
if is_skill:
|
||||
# ``_change`` is a free-form dict the skill tool leaves in
|
||||
# the response. Older / wrapper MCP backends return it
|
||||
# as a list, an int, or a JSON-shaped scalar — normalize
|
||||
# to a dict so the .get() calls downstream don't
|
||||
# AttributeError (#59437).
|
||||
change_raw = data.get("_change")
|
||||
change: dict = (
|
||||
change_raw if isinstance(change_raw, dict) else {}
|
||||
)
|
||||
old_string = (
|
||||
change.get("old", "") or detail.get("old_string", "")
|
||||
)
|
||||
new_string = (
|
||||
change.get("new", "") or detail.get("new_string", "")
|
||||
)
|
||||
change = data.get("_change", {})
|
||||
old_string = change.get("old", "") or detail.get("old_string", "")
|
||||
new_string = change.get("new", "") or detail.get("new_string", "")
|
||||
description = change.get("description", "")
|
||||
if action == "patch" and (old_string or new_string):
|
||||
old_preview = old_string[:80].replace("\n", " ") + (
|
||||
@@ -545,13 +505,7 @@ def summarize_background_review_actions(
|
||||
actions.append(f"📝 {message}" if message else f"Skill {action}")
|
||||
elif operations:
|
||||
for op in operations:
|
||||
# Each element must be a dict-of-fields; some
|
||||
# legacy codepaths serialize the entry as a bare
|
||||
# string and the message dict doesn't exist. Skip
|
||||
# non-dict items defensively — they have no
|
||||
# actionable fields anyway (#59437).
|
||||
if not isinstance(op, dict):
|
||||
continue
|
||||
op = op or {}
|
||||
op_act = op.get("action", "")
|
||||
op_content = (op.get("content") or "")
|
||||
op_old = (op.get("old_text") or "")
|
||||
@@ -648,15 +602,9 @@ def _run_review_in_thread(
|
||||
review_agent = None
|
||||
review_messages: List[Dict] = []
|
||||
try:
|
||||
# Silence stdout/stderr for THIS worker thread only. A process-global
|
||||
# ``contextlib.redirect_stdout(devnull)`` here would also blank
|
||||
# ``sys.stdout``/``sys.stderr`` for every other thread — including a
|
||||
# gateway event-loop thread driving a Telegram long-poll — for the full
|
||||
# duration of the review (tens of seconds), swallowing their console
|
||||
# output (#55769 / #55925). ``thread_scoped_silence`` routes only this
|
||||
# thread's writes to devnull and leaves all other threads on the real
|
||||
# streams.
|
||||
with thread_scoped_silence():
|
||||
with open(os.devnull, "w", encoding="utf-8") as _devnull, \
|
||||
contextlib.redirect_stdout(_devnull), \
|
||||
contextlib.redirect_stderr(_devnull):
|
||||
# Inherit the parent agent's live runtime (provider, model,
|
||||
# base_url, api_key, api_mode) so the fork uses the exact
|
||||
# same credentials the main turn is using. Without this,
|
||||
@@ -690,25 +638,6 @@ 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,
|
||||
@@ -718,13 +647,11 @@ 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=_rt.get("credential_pool"),
|
||||
request_overrides=_rt.get("request_overrides") or {},
|
||||
credential_pool=getattr(agent, "_credential_pool", None),
|
||||
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"
|
||||
@@ -740,20 +667,6 @@ def _run_review_in_thread(
|
||||
review_agent._user_profile_enabled = agent._user_profile_enabled
|
||||
review_agent._memory_nudge_interval = 0
|
||||
review_agent._skill_nudge_interval = 0
|
||||
# PERSISTENCE ISOLATION (the curator-takeover root cause): the fork
|
||||
# shares the parent's session_id (set below, for prompt-cache
|
||||
# warmth), so without this it would write its harness turn ("Review
|
||||
# the conversation above and update the skill library…") + its own
|
||||
# response straight into the user's REAL session in state.db. On the
|
||||
# user's next live turn the agent re-reads that injected user message
|
||||
# as a standing instruction and "becomes" the curator, refusing the
|
||||
# actual task. _persist_disabled hard-stops every DB write/lazy-open
|
||||
# path (_flush_messages_to_session_db, _ensure_db_session,
|
||||
# _get_session_db_for_recall); the review writes only to the skill
|
||||
# and memory stores via its tools, which is all it needs.
|
||||
review_agent._persist_disabled = True
|
||||
review_agent._session_db = None
|
||||
review_agent._session_json_enabled = False
|
||||
# Suppress all status/warning emits from the fork so the
|
||||
# user only sees the final successful-action summary.
|
||||
# Without this, mid-review "Iteration budget exhausted",
|
||||
@@ -812,17 +725,10 @@ def _run_review_in_thread(
|
||||
clear_thread_tool_whitelist,
|
||||
)
|
||||
|
||||
# Gate the built-in memory tool on the profile's memory_enabled flag.
|
||||
# Hardcoding ["memory", "skills"] granted the review LLM the MEMORY.md
|
||||
# read/write tool even when a profile set memory_enabled: false,
|
||||
# contaminating a memory-disabled profile (#54937 layer 2).
|
||||
review_toolsets = ["skills"]
|
||||
if review_agent._memory_enabled or review_agent._user_profile_enabled:
|
||||
review_toolsets.insert(0, "memory")
|
||||
review_whitelist = {
|
||||
t["function"]["name"]
|
||||
for t in get_tool_definitions(
|
||||
enabled_toolsets=review_toolsets,
|
||||
enabled_toolsets=["memory", "skills"],
|
||||
quiet_mode=True,
|
||||
)
|
||||
}
|
||||
@@ -833,13 +739,6 @@ def _run_review_in_thread(
|
||||
"{tool_name}. Only memory/skill tools are allowed."
|
||||
),
|
||||
)
|
||||
try:
|
||||
from tools.skill_manager_tool import _reset_background_review_read_marks
|
||||
|
||||
_reset_background_review_read_marks()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Routed to a different model -> replay a digest (cache is cold
|
||||
# on that model anyway, so minimise cold-written tokens). Same
|
||||
@@ -885,29 +784,11 @@ def _run_review_in_thread(
|
||||
# the review agent inherits that history and would otherwise
|
||||
# re-surface stale "created"/"updated" messages from the prior
|
||||
# conversation as if they just happened (issue #14944).
|
||||
#
|
||||
# Wrapped in try/except: a buggy/legacy tool response shape
|
||||
# (e.g. ``_change`` returned as a list instead of a dict, #59437)
|
||||
# must NOT take down the whole review with an AttributeError,
|
||||
# since the caller's outer except logs only "Background
|
||||
# memory/skill review failed" and discards every successful
|
||||
# action the fork DID complete before the crash. Coerce an
|
||||
# exception into an empty actions list so the partial valid
|
||||
# actions from earlier in the messages are returned instead.
|
||||
try:
|
||||
actions = summarize_background_review_actions(
|
||||
review_messages,
|
||||
messages_snapshot,
|
||||
notification_mode=getattr(agent, "memory_notifications", "on"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"summarize_background_review_actions returned partial results "
|
||||
"after exception (treating as empty); suppressing AttributeError "
|
||||
"that previously aborted the entire review (#59437): %s",
|
||||
e,
|
||||
)
|
||||
actions = []
|
||||
actions = summarize_background_review_actions(
|
||||
review_messages,
|
||||
messages_snapshot,
|
||||
notification_mode=getattr(agent, "memory_notifications", "on"),
|
||||
)
|
||||
|
||||
if actions:
|
||||
summary = " · ".join(dict.fromkeys(actions))
|
||||
@@ -927,14 +808,16 @@ def _run_review_in_thread(
|
||||
logger.warning("Background memory/skill review failed: %s", e)
|
||||
agent._emit_auxiliary_failure("background review", e)
|
||||
finally:
|
||||
# Safety-net cleanup for the exception path. Normal completion already
|
||||
# shut down inside the thread-scoped silence above. Re-enter the
|
||||
# thread-scoped silence here so teardown output (Honcho flush, Hindsight
|
||||
# sync, background thread joins) stays quiet even on the exception path,
|
||||
# without blanking other threads' streams.
|
||||
# Safety-net cleanup for the exception path. Normal
|
||||
# completion already shut down inside redirect_stdout above.
|
||||
# Re-open devnull here so any teardown output (Honcho flush,
|
||||
# Hindsight sync, background thread joins) stays silent even
|
||||
# on the exception path where redirect_stdout already exited.
|
||||
if review_agent is not None:
|
||||
try:
|
||||
with thread_scoped_silence():
|
||||
with open(os.devnull, "w", encoding="utf-8") as _fn, \
|
||||
contextlib.redirect_stdout(_fn), \
|
||||
contextlib.redirect_stderr(_fn):
|
||||
try:
|
||||
review_agent.shutdown_memory_provider()
|
||||
except Exception:
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""System-battery read-out for the CLI/TUI status bar.
|
||||
|
||||
Reads the host battery through ``psutil`` (already a Hermes dependency) and
|
||||
exposes a compact, colour-coded label. Everything degrades to "unavailable"
|
||||
when there is no battery (desktops, servers, VMs) or when the read fails, so
|
||||
callers can render the result unconditionally and simply show nothing.
|
||||
|
||||
The status bar repaints often (every keystroke and on a ~1s idle refresh), so
|
||||
:func:`read_battery` memoises the last reading for a few seconds instead of
|
||||
hitting ``psutil`` on every frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BatteryStatus:
|
||||
"""A single battery reading.
|
||||
|
||||
``available`` is False on machines without a battery (or when the read
|
||||
failed). ``percent`` is clamped to 0-100. ``plugged`` is True when on AC
|
||||
power, False on battery, and None when the platform can't tell.
|
||||
"""
|
||||
|
||||
available: bool
|
||||
percent: Optional[int] = None
|
||||
plugged: Optional[bool] = None
|
||||
|
||||
@property
|
||||
def charging(self) -> bool:
|
||||
return bool(self.plugged)
|
||||
|
||||
|
||||
UNAVAILABLE = BatteryStatus(available=False)
|
||||
|
||||
# Colour buckets, mirroring the status-bar context styles but inverted (a full
|
||||
# battery is "good", an empty one is "critical").
|
||||
CATEGORY_GOOD = "good"
|
||||
CATEGORY_WARN = "warn"
|
||||
CATEGORY_BAD = "bad"
|
||||
CATEGORY_CRITICAL = "critical"
|
||||
CATEGORY_DIM = "dim"
|
||||
|
||||
_CACHE_TTL_SECONDS = 8.0
|
||||
_cache: Optional[tuple[float, BatteryStatus]] = None
|
||||
|
||||
|
||||
def _read_battery_uncached() -> BatteryStatus:
|
||||
try:
|
||||
import psutil
|
||||
except Exception:
|
||||
return UNAVAILABLE
|
||||
|
||||
# ``sensors_battery`` is missing on some platforms/builds of psutil.
|
||||
reader = getattr(psutil, "sensors_battery", None)
|
||||
if reader is None:
|
||||
return UNAVAILABLE
|
||||
|
||||
try:
|
||||
batt = reader()
|
||||
except Exception:
|
||||
return UNAVAILABLE
|
||||
|
||||
if batt is None:
|
||||
return UNAVAILABLE
|
||||
|
||||
percent: Optional[int] = None
|
||||
raw_percent = getattr(batt, "percent", None)
|
||||
if raw_percent is not None:
|
||||
try:
|
||||
percent = max(0, min(100, int(round(float(raw_percent)))))
|
||||
except (TypeError, ValueError):
|
||||
percent = None
|
||||
|
||||
plugged = getattr(batt, "power_plugged", None)
|
||||
if plugged is not None:
|
||||
plugged = bool(plugged)
|
||||
|
||||
return BatteryStatus(available=True, percent=percent, plugged=plugged)
|
||||
|
||||
|
||||
def read_battery(use_cache: bool = True) -> BatteryStatus:
|
||||
"""Return the current battery status (cached for a few seconds)."""
|
||||
global _cache
|
||||
if use_cache and _cache is not None:
|
||||
ts, cached = _cache
|
||||
if time.monotonic() - ts < _CACHE_TTL_SECONDS:
|
||||
return cached
|
||||
|
||||
status = _read_battery_uncached()
|
||||
_cache = (time.monotonic(), status)
|
||||
return status
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop the memoised reading (used by tests)."""
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def battery_category(status: BatteryStatus) -> str:
|
||||
"""Bucket a reading into a colour category: good/warn/bad/critical/dim."""
|
||||
if not status.available or status.percent is None:
|
||||
return CATEGORY_DIM
|
||||
# On AC power the level isn't a concern — always read as healthy.
|
||||
if status.charging:
|
||||
return CATEGORY_GOOD
|
||||
pct = status.percent
|
||||
if pct <= 10:
|
||||
return CATEGORY_CRITICAL
|
||||
if pct <= 20:
|
||||
return CATEGORY_BAD
|
||||
if pct <= 50:
|
||||
return CATEGORY_WARN
|
||||
return CATEGORY_GOOD
|
||||
|
||||
|
||||
def battery_glyph(status: BatteryStatus) -> str:
|
||||
"""Return the leading glyph: a bolt while charging, else a battery."""
|
||||
return "\u26a1" if status.charging else "\U0001f50b" # ⚡ / 🔋
|
||||
|
||||
|
||||
def format_battery(status: BatteryStatus) -> str:
|
||||
"""Return a compact label like ``🔋 82%`` / ``⚡ 82%`` (empty if N/A)."""
|
||||
if not status.available or status.percent is None:
|
||||
return ""
|
||||
return f"{battery_glyph(status)} {status.percent}%"
|
||||
+23
-204
@@ -448,10 +448,7 @@ def is_anthropic_bedrock_model(model_id: str) -> bool:
|
||||
"""
|
||||
model_lower = model_id.lower()
|
||||
# Strip regional prefix if present
|
||||
for prefix in (
|
||||
"global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.",
|
||||
"ca.", "sa.", "me.", "af.",
|
||||
):
|
||||
for prefix in ("us.", "global.", "eu.", "ap.", "jp."):
|
||||
if model_lower.startswith(prefix):
|
||||
model_lower = model_lower[len(prefix):]
|
||||
break
|
||||
@@ -493,26 +490,6 @@ def convert_tools_to_converse(tools: List[Dict]) -> List[Dict]:
|
||||
return result
|
||||
|
||||
|
||||
# Bedrock's Converse API rejects any text content block whose text is empty
|
||||
# OR whitespace-only (ValidationException: "text content blocks must contain
|
||||
# non-whitespace text"). A lone space is whitespace and is rejected too — the
|
||||
# placeholder MUST itself be non-whitespace. Ref: issue #9486.
|
||||
_EMPTY_TEXT_PLACEHOLDER = "(empty)"
|
||||
|
||||
|
||||
def _safe_text(text) -> str:
|
||||
"""Return ``text`` if it's non-whitespace, else a non-whitespace placeholder.
|
||||
|
||||
Handles None, empty string, and whitespace-only string (spaces, tabs,
|
||||
newlines) — all of which Bedrock's Converse API rejects as text content.
|
||||
"""
|
||||
if text is None:
|
||||
return _EMPTY_TEXT_PLACEHOLDER
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER
|
||||
|
||||
|
||||
def _convert_content_to_converse(content) -> List[Dict]:
|
||||
"""Convert OpenAI message content (string or list) to Converse content blocks.
|
||||
|
||||
@@ -520,27 +497,26 @@ def _convert_content_to_converse(content) -> List[Dict]:
|
||||
- Plain text strings → [{"text": "..."}]
|
||||
- Content arrays with text/image_url parts → mixed text/image blocks
|
||||
|
||||
Replaces empty/whitespace-only text blocks with a non-whitespace
|
||||
placeholder — Bedrock's Converse API rejects messages where a text
|
||||
content block is empty or whitespace-only (ValidationException:
|
||||
"text content blocks must contain non-whitespace text"). Ref: issue #9486.
|
||||
Filters out empty text blocks — Bedrock's Converse API rejects messages
|
||||
where a text content block has an empty ``text`` field (ValidationException:
|
||||
"text content blocks must be non-empty"). Ref: issue #9486.
|
||||
"""
|
||||
if content is None:
|
||||
return [{"text": _safe_text(content)}]
|
||||
return [{"text": " "}]
|
||||
if isinstance(content, str):
|
||||
return [{"text": _safe_text(content)}]
|
||||
return [{"text": content}] if content.strip() else [{"text": " "}]
|
||||
if isinstance(content, list):
|
||||
blocks = []
|
||||
for part in content:
|
||||
if isinstance(part, str):
|
||||
blocks.append({"text": _safe_text(part)})
|
||||
blocks.append({"text": part})
|
||||
continue
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
part_type = part.get("type", "")
|
||||
if part_type == "text":
|
||||
text = part.get("text", "")
|
||||
blocks.append({"text": _safe_text(text)})
|
||||
blocks.append({"text": text if text else " "})
|
||||
elif part_type == "image_url":
|
||||
image_url = part.get("image_url", {})
|
||||
url = image_url.get("url", "") if isinstance(image_url, dict) else ""
|
||||
@@ -552,27 +528,18 @@ 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": raw_bytes},
|
||||
"source": {"bytes": data},
|
||||
}
|
||||
})
|
||||
else:
|
||||
# Remote URL — Converse doesn't support URLs directly,
|
||||
# include as text reference for the model.
|
||||
blocks.append({"text": f"[Image: {url}]"})
|
||||
return blocks if blocks else [{"text": _EMPTY_TEXT_PLACEHOLDER}]
|
||||
return [{"text": _safe_text(content)}]
|
||||
return blocks if blocks else [{"text": " "}]
|
||||
return [{"text": str(content)}]
|
||||
|
||||
|
||||
def convert_messages_to_converse(
|
||||
@@ -602,18 +569,14 @@ def convert_messages_to_converse(
|
||||
content = msg.get("content")
|
||||
|
||||
if role == "system":
|
||||
# System messages become the system prompt. Blank/whitespace-only
|
||||
# parts are dropped entirely (not placeholder-filled) since a
|
||||
# system prompt made up of only placeholder text is meaningless.
|
||||
# System messages become the system prompt
|
||||
if isinstance(content, str) and content.strip():
|
||||
system_blocks.append({"text": content})
|
||||
elif isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
text = part.get("text", "")
|
||||
if isinstance(text, str) and text.strip():
|
||||
system_blocks.append({"text": text})
|
||||
elif isinstance(part, str) and part.strip():
|
||||
system_blocks.append({"text": part.get("text", "")})
|
||||
elif isinstance(part, str):
|
||||
system_blocks.append({"text": part})
|
||||
continue
|
||||
|
||||
@@ -624,7 +587,7 @@ def convert_messages_to_converse(
|
||||
tool_result_block = {
|
||||
"toolResult": {
|
||||
"toolUseId": tool_call_id,
|
||||
"content": [{"text": _safe_text(result_content)}],
|
||||
"content": [{"text": result_content}],
|
||||
}
|
||||
}
|
||||
# In Converse, tool results go in a "user" role message
|
||||
@@ -663,7 +626,7 @@ def convert_messages_to_converse(
|
||||
})
|
||||
|
||||
if not content_blocks:
|
||||
content_blocks = [{"text": _EMPTY_TEXT_PLACEHOLDER}]
|
||||
content_blocks = [{"text": " "}]
|
||||
|
||||
# Merge with previous assistant message if needed (strict alternation)
|
||||
if converse_msgs and converse_msgs[-1]["role"] == "assistant":
|
||||
@@ -689,11 +652,11 @@ def convert_messages_to_converse(
|
||||
|
||||
# Converse requires the first message to be from the user
|
||||
if converse_msgs and converse_msgs[0]["role"] != "user":
|
||||
converse_msgs.insert(0, {"role": "user", "content": [{"text": _EMPTY_TEXT_PLACEHOLDER}]})
|
||||
converse_msgs.insert(0, {"role": "user", "content": [{"text": " "}]})
|
||||
|
||||
# Converse requires the last message to be from the user
|
||||
if converse_msgs and converse_msgs[-1]["role"] != "user":
|
||||
converse_msgs.append({"role": "user", "content": [{"text": _EMPTY_TEXT_PLACEHOLDER}]})
|
||||
converse_msgs.append({"role": "user", "content": [{"text": " "}]})
|
||||
|
||||
return (system_blocks if system_blocks else None, converse_msgs)
|
||||
|
||||
@@ -817,7 +780,6 @@ def stream_converse_with_callbacks(
|
||||
on_tool_start=None,
|
||||
on_reasoning_delta=None,
|
||||
on_interrupt_check=None,
|
||||
on_event=None,
|
||||
) -> SimpleNamespace:
|
||||
"""Process a Bedrock ConverseStream event stream with real-time callbacks.
|
||||
|
||||
@@ -837,12 +799,6 @@ def stream_converse_with_callbacks(
|
||||
on supported models (Claude 4.6+).
|
||||
on_interrupt_check: Called on each event. Should return True if the
|
||||
agent has been interrupted and streaming should stop.
|
||||
on_event: Called once at the top of the loop body for EVERY yielded
|
||||
Bedrock event (text/tool-input/reasoning/metadata deltas alike),
|
||||
before any branching. Provides a wire-level liveness signal so an
|
||||
external watchdog can distinguish "still receiving events" from
|
||||
"stream wedged with no data". Errors raised by the callback are
|
||||
swallowed so a liveness hook can never abort the stream.
|
||||
|
||||
Returns:
|
||||
An OpenAI-compatible SimpleNamespace response, identical in shape to
|
||||
@@ -858,15 +814,6 @@ def stream_converse_with_callbacks(
|
||||
usage_data: Dict[str, int] = {}
|
||||
|
||||
for event in event_stream.get("stream", []):
|
||||
# Wire-level liveness signal: fire on EVERY yielded event (text, tool
|
||||
# input, reasoning, metadata) before branching so an external watchdog
|
||||
# can tell a still-flowing stream from a wedged one. Best-effort — a
|
||||
# liveness callback must never be able to abort the stream.
|
||||
if on_event is not None:
|
||||
try:
|
||||
on_event()
|
||||
except Exception:
|
||||
pass
|
||||
# Check for interrupt
|
||||
if on_interrupt_check and on_interrupt_check():
|
||||
break
|
||||
@@ -1349,24 +1296,9 @@ def classify_bedrock_error(error_message: str) -> str:
|
||||
# detection is unavailable.
|
||||
|
||||
BEDROCK_CONTEXT_LENGTHS: Dict[str, int] = {
|
||||
# Anthropic Claude models on Bedrock.
|
||||
# Context windows per Anthropic's official models comparison
|
||||
# (https://platform.claude.com/docs/en/about-claude/models/overview).
|
||||
# Fable / Sonnet 5 / Opus 4.8 / 4.7 / 4.6 / Sonnet 4.6 have 1M generally
|
||||
# available (no beta header required as of April 2026). Sonnet 4.5 and
|
||||
# Sonnet 4 had their `context-1m-2025-08-07` beta retired on
|
||||
# April 30, 2026, so they are standard 200K; Haiku 4.5 is 200K.
|
||||
# These 1M entries must match agent/model_metadata.py
|
||||
# DEFAULT_CONTEXT_LENGTHS or the agent compresses context prematurely.
|
||||
# Keys are matched by longest-substring, so the versioned 4-6/4-7/4-8
|
||||
# entries win over the generic "anthropic.claude-opus-4" fallback.
|
||||
"anthropic.claude-fable-5": 1_000_000,
|
||||
"anthropic.claude-fable": 1_000_000,
|
||||
"anthropic.claude-sonnet-5": 1_000_000,
|
||||
"anthropic.claude-opus-4-8": 1_000_000,
|
||||
"anthropic.claude-opus-4-7": 1_000_000,
|
||||
"anthropic.claude-opus-4-6": 1_000_000,
|
||||
"anthropic.claude-sonnet-4-6": 1_000_000,
|
||||
# Anthropic Claude models on Bedrock
|
||||
"anthropic.claude-opus-4-6": 200_000,
|
||||
"anthropic.claude-sonnet-4-6": 200_000,
|
||||
"anthropic.claude-sonnet-4-5": 200_000,
|
||||
"anthropic.claude-haiku-4-5": 200_000,
|
||||
"anthropic.claude-opus-4": 200_000,
|
||||
@@ -1393,22 +1325,9 @@ BEDROCK_CONTEXT_LENGTHS: Dict[str, int] = {
|
||||
# Default for unknown Bedrock models
|
||||
BEDROCK_DEFAULT_CONTEXT_LENGTH = 128_000
|
||||
|
||||
# Probe tiers (in tokens). We send a request padded just past each tier and
|
||||
# read the real window from Bedrock's length-validation error. Two reasons
|
||||
# this is tiered rather than one giant request:
|
||||
# 1. A wildly oversized payload (e.g. 5M tokens) makes Bedrock return an
|
||||
# opaque InternalServerException after retries instead of a clean
|
||||
# ValidationException — so we must stay within a sane overage.
|
||||
# 2. Stepping up lets us discover larger windows (2M+) without over-padding
|
||||
# smaller ones.
|
||||
# Each tier value is the *padding target*; the error reports the true maximum,
|
||||
# which is what we actually return.
|
||||
_BEDROCK_PROBE_TIERS = (1_300_000, 2_200_000)
|
||||
_WORDS_PER_TOKEN = 0.9 # conservative: ensures the padded prompt clears the tier
|
||||
|
||||
|
||||
def _static_bedrock_context_length(model_id: str) -> int:
|
||||
"""Longest-substring-match lookup against the static fallback table.
|
||||
def get_bedrock_context_length(model_id: str) -> int:
|
||||
"""Look up the context window size for a Bedrock model.
|
||||
|
||||
Uses substring matching so versioned IDs like
|
||||
``anthropic.claude-sonnet-4-6-20250514-v1:0`` resolve correctly.
|
||||
@@ -1421,103 +1340,3 @@ def _static_bedrock_context_length(model_id: str) -> int:
|
||||
best_key = key
|
||||
best_val = val
|
||||
return best_val
|
||||
|
||||
|
||||
def probe_bedrock_context_length(model_id: str, region: str) -> Optional[int]:
|
||||
"""Discover a Bedrock model's real context window by provoking a length error.
|
||||
|
||||
Bedrock does not expose the context window via any metadata API
|
||||
(``get-foundation-model`` omits it, ``Converse`` metrics omit it,
|
||||
``CountTokens`` is unsupported on several models). The only authoritative
|
||||
source is the ``ValidationException`` raised when a prompt exceeds the
|
||||
window:
|
||||
|
||||
"The model returned the following errors: prompt is too long:
|
||||
1300032 tokens > 1000000 maximum"
|
||||
|
||||
Length validation happens *before* inference, so an oversized request is
|
||||
rejected immediately and cheaply — no tokens are generated and no input is
|
||||
actually processed. We pad a request just past each tier in
|
||||
``_BEDROCK_PROBE_TIERS`` and parse the reported ``maximum``. Tiers exist
|
||||
because (a) a *wildly* oversized payload makes Bedrock fail with an opaque
|
||||
InternalServerException instead of a clean length error, and (b) stepping
|
||||
up discovers larger windows without over-padding smaller ones.
|
||||
|
||||
Returns the detected window, or ``None`` if the probe could not run
|
||||
(missing credentials, network error, or no parseable limit) so the caller
|
||||
can fall back to the static table.
|
||||
"""
|
||||
try:
|
||||
from agent.model_metadata import parse_context_limit_from_error
|
||||
except ImportError: # pragma: no cover — same package
|
||||
return None
|
||||
|
||||
try:
|
||||
client = _get_bedrock_runtime_client(region)
|
||||
except Exception as exc: # boto3 missing / credential resolution failure
|
||||
logger.debug("Bedrock context probe skipped for %s: %s", model_id, exc)
|
||||
return None
|
||||
|
||||
last_error = ""
|
||||
for tier_tokens in _BEDROCK_PROBE_TIERS:
|
||||
pad_words = int(tier_tokens / _WORDS_PER_TOKEN)
|
||||
oversized = "data " * pad_words
|
||||
try:
|
||||
client.converse(
|
||||
modelId=model_id,
|
||||
messages=[{"role": "user", "content": [{"text": oversized}]}],
|
||||
inferenceConfig={"maxTokens": 8},
|
||||
)
|
||||
# Accepted a prompt this large → the window is at least this tier.
|
||||
# Returning the tier as a lower bound is safe and avoids inventing
|
||||
# a number we can't confirm.
|
||||
logger.debug(
|
||||
"Bedrock context probe for %s accepted ~%s-token prompt; "
|
||||
"window is at least that", model_id, f"{tier_tokens:,}",
|
||||
)
|
||||
return tier_tokens
|
||||
except Exception as exc:
|
||||
msg = str(exc)
|
||||
last_error = msg
|
||||
limit = parse_context_limit_from_error(msg)
|
||||
if limit and limit >= 1024:
|
||||
logger.info(
|
||||
"Probed Bedrock context window for %s: %s tokens",
|
||||
model_id, f"{limit:,}",
|
||||
)
|
||||
return limit
|
||||
# No parseable limit at this tier (opaque server error, auth,
|
||||
# throttle). Try the next, smaller-overage strategy is N/A here —
|
||||
# tiers ascend — so just continue; if all fail we return None.
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
"Bedrock context probe for %s returned no parseable limit: %s",
|
||||
model_id, last_error[:200],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_bedrock_context_length(model_id: str, region: str = "", probe: bool = True) -> int:
|
||||
"""Resolve the context window for a Bedrock model.
|
||||
|
||||
Resolution order:
|
||||
1. Live probe against Bedrock (authoritative; cached by the caller).
|
||||
2. Static fallback table (longest-substring match).
|
||||
3. Conservative default.
|
||||
|
||||
The static table is intentionally a *fallback*, not the primary source:
|
||||
AWS ships new model versions (opus-4-7, opus-4-8, ...) faster than the
|
||||
table can track, and a stale entry silently caps the window (e.g. a
|
||||
1M-token Opus pinned to 200K via an ``opus-4`` substring match). The
|
||||
probe asks Bedrock directly so every model — current or future — gets its
|
||||
real window with no table maintenance.
|
||||
|
||||
``probe=False`` (or an empty ``region``) skips the network call and uses
|
||||
the static table only — used by pure-offline/display code paths.
|
||||
"""
|
||||
if probe and region:
|
||||
probed = probe_bedrock_context_length(model_id, region)
|
||||
if probed:
|
||||
return probed
|
||||
return _static_bedrock_context_length(model_id)
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Provider-agnostic billing/credit recovery links.
|
||||
|
||||
Maps a billing-classified failure onto a recovery link + label. *Detection*
|
||||
is not done here — that is :mod:`agent.error_classifier`
|
||||
(``FailoverReason.billing``), the single source of truth for "credit wall vs.
|
||||
rate limit / auth / transport". The resulting :class:`BillingBlock` rides the
|
||||
turn result and the gateway ``message.complete`` event so every surface (CLI,
|
||||
TUI, desktop) renders one structured signal instead of re-parsing error text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Optional
|
||||
|
||||
from utils import base_url_host_matches
|
||||
|
||||
|
||||
@dataclass
|
||||
class BillingBlock:
|
||||
"""Structured billing-wall descriptor shared across every surface.
|
||||
|
||||
``is_nous`` is the routing bit: Nous has a first-class in-app billing surface
|
||||
(desktop Settings → Billing, TUI/CLI ``/topup``), so surfaces prefer that over
|
||||
``billing_url``; third-party providers have no in-app flow, so ``billing_url``
|
||||
is the deep link the user actually needs.
|
||||
"""
|
||||
|
||||
provider: str
|
||||
provider_label: str
|
||||
model: str
|
||||
billing_url: Optional[str]
|
||||
is_nous: bool
|
||||
message: str
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Provider:
|
||||
label: str
|
||||
url: str
|
||||
slugs: tuple[str, ...]
|
||||
hosts: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# Single source of truth: internal slug(s) + base_url host(s) → billing page.
|
||||
# Curated "add credits / manage billing" landing pages, not marketing homes.
|
||||
# Hosts back the OpenAI-compatible fallback where the slug is a generic bucket
|
||||
# (e.g. "openai_compatible") but base_url reveals the real upstream. An unknown
|
||||
# provider degrades to a readable label with no invented URL.
|
||||
_PROVIDERS: tuple[_Provider, ...] = (
|
||||
_Provider("OpenAI", "https://platform.openai.com/settings/organization/billing", ("openai",), ("api.openai.com",)),
|
||||
_Provider("Anthropic", "https://console.anthropic.com/settings/billing", ("anthropic",), ("api.anthropic.com",)),
|
||||
_Provider("OpenRouter", "https://openrouter.ai/settings/credits", ("openrouter",), ("openrouter.ai",)),
|
||||
_Provider("xAI", "https://console.x.ai/team/default/billing", ("xai", "xai-oauth"), ("api.x.ai",)),
|
||||
_Provider("DeepSeek", "https://platform.deepseek.com/top_up", ("deepseek",), ("api.deepseek.com",)),
|
||||
_Provider("Groq", "https://console.groq.com/settings/billing", ("groq",), ("api.groq.com",)),
|
||||
_Provider("Mistral", "https://console.mistral.ai/billing", ("mistral",), ("api.mistral.ai",)),
|
||||
_Provider("Together AI", "https://api.together.ai/settings/billing", ("together",), ("api.together.ai", "api.together.xyz")),
|
||||
_Provider("Fireworks AI", "https://fireworks.ai/account/billing", ("fireworks",), ("fireworks.ai",)),
|
||||
_Provider("Perplexity", "https://www.perplexity.ai/settings/api", ("perplexity",), ("perplexity.ai",)),
|
||||
_Provider("Google AI", "https://aistudio.google.com/app/billing", ("google", "gemini"), ("generativelanguage.googleapis.com",)),
|
||||
_Provider("Cohere", "https://dashboard.cohere.com/billing", ("cohere",)),
|
||||
_Provider("Moonshot AI", "https://platform.moonshot.ai/console/pay", ("moonshot",)),
|
||||
_Provider("NVIDIA", "https://build.nvidia.com/settings/billing", ("nvidia",)),
|
||||
)
|
||||
|
||||
_BY_SLUG: dict[str, _Provider] = {slug: p for p in _PROVIDERS for slug in p.slugs}
|
||||
|
||||
|
||||
def is_nous_inference_route(provider: str, base_url: str) -> bool:
|
||||
"""True when the failing route is the Nous-managed inference gateway."""
|
||||
if (provider or "").strip().lower() == "nous":
|
||||
return True
|
||||
return base_url_host_matches(str(base_url or ""), "inference-api.nousresearch.com")
|
||||
|
||||
|
||||
def _nous_billing_url() -> Optional[str]:
|
||||
"""Best-effort Nous portal billing URL (text-surface fallback; Nous prefers the in-app flow)."""
|
||||
try:
|
||||
from hermes_cli.nous_account import nous_portal_billing_url
|
||||
|
||||
return nous_portal_billing_url(None)
|
||||
except Exception:
|
||||
return "https://portal.nousresearch.com/billing"
|
||||
|
||||
|
||||
def _resolve_provider_link(slug: str, base_url: str) -> tuple[str, Optional[str]]:
|
||||
"""Resolve ``(label, url)``: exact slug → base_url host → readable-label fallback."""
|
||||
hit = _BY_SLUG.get(slug)
|
||||
if hit:
|
||||
return hit.label, hit.url
|
||||
|
||||
base = str(base_url or "")
|
||||
for p in _PROVIDERS:
|
||||
if any(base_url_host_matches(base, host) for host in p.hosts):
|
||||
return p.label, p.url
|
||||
|
||||
return slug.replace("_", " ").replace("-", " ").strip().title() or "your provider", None
|
||||
|
||||
|
||||
def build_billing_block(
|
||||
*,
|
||||
provider: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
message: str = "",
|
||||
) -> BillingBlock:
|
||||
"""Build the billing descriptor for a billing-classified failure.
|
||||
|
||||
``message`` is the guidance already assembled by the agent loop
|
||||
(:func:`agent.conversation_loop._billing_or_entitlement_message`), carried
|
||||
through unchanged so every surface shows identical copy.
|
||||
"""
|
||||
slug = (provider or "").strip().lower()
|
||||
model = (model or "").strip()
|
||||
|
||||
if is_nous_inference_route(slug, base_url):
|
||||
return BillingBlock(slug or "nous", "Nous Portal", model, _nous_billing_url(), True, message or "")
|
||||
|
||||
label, url = _resolve_provider_link(slug, base_url)
|
||||
return BillingBlock(slug, label, model, url, False, message or "")
|
||||
@@ -1,323 +0,0 @@
|
||||
"""Shared dollar-denominated usage model for the billing/subscription surfaces.
|
||||
|
||||
The single source of truth behind the ``/usage`` and ``/subscription`` usage
|
||||
bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show
|
||||
**dollars**, never "credits", and every usage bar must make the monthly
|
||||
subscription allowance and separately-purchased top-up dollars distinctly
|
||||
visible.
|
||||
|
||||
Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose
|
||||
``paid_service_access_info`` carries the three dollar magnitudes we render
|
||||
(despite the legacy ``*_credits`` field names, these are USD floats):
|
||||
|
||||
- ``subscription_credits_remaining`` -> plan dollars left this month
|
||||
- ``purchased_credits_remaining`` -> top-up dollars left (rolls over)
|
||||
- ``total_usable_credits`` -> total spendable
|
||||
|
||||
plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the
|
||||
denominator for the "% used" plan bar) and ``current_period_end`` (renewal).
|
||||
|
||||
Design: two SEPARATE bars (decided with the user) rather than one crammed
|
||||
three-segment bar — at terminal widths three same-glyph density segments are
|
||||
unreadable. The plan bar is "spent vs allowance this month" (carries % used);
|
||||
the top-up bar is "money you bought, doesn't expire". Each gets full
|
||||
resolution and a single fill glyph, so the bar is never ambiguous and never
|
||||
relies on color.
|
||||
|
||||
Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a
|
||||
magnitudes-only view; a logged-out / unreachable portal yields
|
||||
``available=False`` and the surface shows nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert
|
||||
# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold
|
||||
# (user feedback): "any amount below $5 should be an alert status."
|
||||
LOW_BALANCE_THRESHOLD_USD = 5.0
|
||||
|
||||
|
||||
def _finite(value: Any) -> Optional[float]:
|
||||
"""Return value as a float iff it's a real finite number (not bool/NaN/Inf)."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
f = float(value)
|
||||
return f if math.isfinite(f) else None
|
||||
|
||||
|
||||
def _fmt_usd(value: Optional[float]) -> str:
|
||||
"""``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence)."""
|
||||
return f"${(value or 0.0):,.2f}"
|
||||
|
||||
|
||||
def format_renews(value: Optional[str]) -> Optional[str]:
|
||||
"""Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``.
|
||||
|
||||
Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw
|
||||
string unchanged if it can't be parsed (never raises), and ``None`` for
|
||||
empty input.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
from datetime import datetime
|
||||
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
iso = text[:-1] + "+00:00" if text.endswith("Z") else text
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso)
|
||||
except ValueError:
|
||||
# Fall back to a bare date prefix (YYYY-MM-DD) if present.
|
||||
try:
|
||||
dt = datetime.strptime(text[:10], "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return text
|
||||
# %-d isn't portable to Windows; build the day without a leading zero.
|
||||
return f"{dt.strftime('%b')} {dt.day}, {dt.year}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageBar:
|
||||
"""One full-resolution bar: ``spent`` of ``total``, plus a remaining figure.
|
||||
|
||||
``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"``
|
||||
(purchased dollars, no denominator — ``spent`` is 0 and ``total`` ==
|
||||
``remaining`` so it renders as a full bar of available balance).
|
||||
"""
|
||||
|
||||
kind: str # "plan" | "topup"
|
||||
remaining_usd: float
|
||||
total_usd: float
|
||||
spent_usd: float = 0.0
|
||||
|
||||
@property
|
||||
def pct_used(self) -> Optional[int]:
|
||||
if self.kind != "plan" or self.total_usd <= 0:
|
||||
return None
|
||||
return max(0, min(100, round(self.spent_usd / self.total_usd * 100)))
|
||||
|
||||
@property
|
||||
def fill_fraction(self) -> float:
|
||||
"""Fraction of the bar that should read as 'remaining' (filled)."""
|
||||
if self.total_usd <= 0:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, self.remaining_usd / self.total_usd))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageModel:
|
||||
"""Surface-agnostic dollar usage model shared by /usage and /subscription.
|
||||
|
||||
``status`` classifies the account for copy selection:
|
||||
- ``"free"`` : no paid access / no subscription (free models only)
|
||||
- ``"low"`` : paid, but total spendable < $5 (ALERT)
|
||||
- ``"healthy"`` : paid, total spendable >= $5
|
||||
- ``"depleted"`` : paid access lost (balance exhausted)
|
||||
"""
|
||||
|
||||
available: bool
|
||||
status: str = "free"
|
||||
plan_name: Optional[str] = None
|
||||
renews_at: Optional[str] = None
|
||||
renews_display: Optional[str] = None
|
||||
subscription_remaining_usd: Optional[float] = None
|
||||
topup_remaining_usd: Optional[float] = None
|
||||
total_spendable_usd: Optional[float] = None
|
||||
plan_bar: Optional[UsageBar] = None
|
||||
topup_bar: Optional[UsageBar] = None
|
||||
|
||||
@property
|
||||
def has_topup(self) -> bool:
|
||||
return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0)
|
||||
|
||||
|
||||
def usage_model_from_account(account_info: Any) -> UsageModel:
|
||||
"""Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open.
|
||||
|
||||
Returns ``UsageModel(available=False)`` when there's no usable account info
|
||||
(logged out, no entitlement block). Never raises.
|
||||
"""
|
||||
try:
|
||||
if account_info is None or not getattr(account_info, "logged_in", False):
|
||||
return UsageModel(available=False)
|
||||
|
||||
access = getattr(account_info, "paid_service_access_info", None)
|
||||
sub = getattr(account_info, "subscription", None)
|
||||
paid = getattr(account_info, "paid_service_access", None)
|
||||
|
||||
sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None
|
||||
topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None
|
||||
total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None
|
||||
|
||||
plan_name = getattr(sub, "plan", None) if sub is not None else None
|
||||
renews_at = getattr(sub, "current_period_end", None) if sub is not None else None
|
||||
monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None
|
||||
|
||||
has_subscription = bool(plan_name) or (monthly is not None and monthly > 0)
|
||||
|
||||
# Total spendable: prefer the server's total; else sum the parts we have.
|
||||
if total_usable is not None:
|
||||
total_spendable = total_usable
|
||||
else:
|
||||
parts = [v for v in (sub_remaining, topup_remaining) if v is not None]
|
||||
total_spendable = sum(parts) if parts else None
|
||||
|
||||
# Status classification.
|
||||
if paid is False:
|
||||
status = "depleted"
|
||||
elif not has_subscription and not (topup_remaining and topup_remaining > 0):
|
||||
# No plan and no purchased balance -> free-models-only.
|
||||
status = "free"
|
||||
elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD:
|
||||
status = "low"
|
||||
else:
|
||||
status = "healthy"
|
||||
|
||||
# Plan bar — only with a positive monthly allowance AND a remaining we
|
||||
# can place on it. spent = cap - remaining, clamped (a debt/over-cap
|
||||
# balance reads as fully spent rather than a nonsensical negative).
|
||||
plan_bar: Optional[UsageBar] = None
|
||||
if monthly is not None and monthly > 0 and sub_remaining is not None:
|
||||
remaining = max(0.0, min(monthly, sub_remaining))
|
||||
plan_bar = UsageBar(
|
||||
kind="plan",
|
||||
remaining_usd=remaining,
|
||||
total_usd=monthly,
|
||||
spent_usd=max(0.0, monthly - sub_remaining),
|
||||
)
|
||||
|
||||
# Top-up bar — only when there are purchased dollars to show. No
|
||||
# denominator (top-up has no monthly cap), so it renders full = balance.
|
||||
topup_bar: Optional[UsageBar] = None
|
||||
if topup_remaining is not None and topup_remaining > 0:
|
||||
topup_bar = UsageBar(
|
||||
kind="topup",
|
||||
remaining_usd=topup_remaining,
|
||||
total_usd=topup_remaining,
|
||||
spent_usd=0.0,
|
||||
)
|
||||
|
||||
return UsageModel(
|
||||
available=True,
|
||||
status=status,
|
||||
plan_name=plan_name,
|
||||
renews_at=renews_at,
|
||||
renews_display=format_renews(renews_at),
|
||||
subscription_remaining_usd=sub_remaining,
|
||||
topup_remaining_usd=topup_remaining,
|
||||
total_spendable_usd=total_spendable,
|
||||
plan_bar=plan_bar,
|
||||
topup_bar=topup_bar,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("usage ▸ model build failed (fail-open)", exc_info=True)
|
||||
return UsageModel(available=False)
|
||||
|
||||
|
||||
def build_usage_model(*, timeout: float = 10.0) -> UsageModel:
|
||||
"""Fetch account-info and build the shared usage model. Fail-open.
|
||||
|
||||
Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so
|
||||
every usage state is testable without a live account (mirrors the existing
|
||||
``/usage`` credits-block fixture path).
|
||||
"""
|
||||
fixture = _dev_fixture_usage_model()
|
||||
if fixture is not None:
|
||||
return fixture
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
tok = (get_provider_auth_state("nous") or {}).get("access_token")
|
||||
if not (isinstance(tok, str) and tok.strip()):
|
||||
return UsageModel(available=False)
|
||||
except Exception:
|
||||
return UsageModel(available=False)
|
||||
|
||||
try:
|
||||
import concurrent.futures
|
||||
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout)
|
||||
return usage_model_from_account(account)
|
||||
except Exception:
|
||||
logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True)
|
||||
return UsageModel(available=False)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _dev_fixture_usage_model() -> Optional[UsageModel]:
|
||||
"""Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work.
|
||||
|
||||
Recognized names: ``free | healthy | low | topup | depleted``. Returns
|
||||
``None`` when the env var is unset (real portal path runs).
|
||||
"""
|
||||
name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
if name == "free":
|
||||
return UsageModel(available=True, status="free", plan_name=None)
|
||||
|
||||
if name in ("healthy", "mid"):
|
||||
return UsageModel(
|
||||
available=True,
|
||||
status="healthy",
|
||||
plan_name="Plus",
|
||||
renews_at="2026-07-01",
|
||||
subscription_remaining_usd=14.0,
|
||||
total_spendable_usd=14.0,
|
||||
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
|
||||
)
|
||||
|
||||
if name in ("topup", "top-up"):
|
||||
return UsageModel(
|
||||
available=True,
|
||||
status="healthy",
|
||||
plan_name="Plus",
|
||||
renews_at="2026-07-01",
|
||||
subscription_remaining_usd=14.0,
|
||||
topup_remaining_usd=12.0,
|
||||
total_spendable_usd=26.0,
|
||||
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
|
||||
topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0),
|
||||
)
|
||||
|
||||
if name == "low":
|
||||
return UsageModel(
|
||||
available=True,
|
||||
status="low",
|
||||
plan_name="Plus",
|
||||
renews_at="2026-07-01",
|
||||
subscription_remaining_usd=3.4,
|
||||
total_spendable_usd=3.4,
|
||||
plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6),
|
||||
)
|
||||
|
||||
if name == "depleted":
|
||||
return UsageModel(
|
||||
available=True,
|
||||
status="depleted",
|
||||
plan_name="Plus",
|
||||
renews_at="2026-07-01",
|
||||
subscription_remaining_usd=0.0,
|
||||
total_spendable_usd=0.0,
|
||||
plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0),
|
||||
)
|
||||
|
||||
return None
|
||||
+9
-172
@@ -1,4 +1,4 @@
|
||||
"""Surface-agnostic core for the Phase 2b Remote Spending screens.
|
||||
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
|
||||
|
||||
One fetch/parse per concern, consumed identically by the CLI handler
|
||||
(``cli.py::_show_billing``), the TUI JSON-RPC methods
|
||||
@@ -15,7 +15,6 @@ We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal, InvalidOperation
|
||||
@@ -65,47 +64,15 @@ def format_money(value: Optional[Decimal]) -> str:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# resolvedVia → the human answer to "why THIS card?". Keys are the server's card
|
||||
# resolution rungs (NAS card-on-file ladder); absent/unknown rungs render no label
|
||||
# so the display degrades cleanly on servers that don't send resolvedVia yet.
|
||||
_CARD_PROVENANCE_LABELS = {
|
||||
"subPin": "the card on your subscription",
|
||||
"customerDefault": "your default card saved on the portal",
|
||||
"autoRefill": "your auto-reload card",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CardInfo:
|
||||
brand: str
|
||||
last4: str
|
||||
# NAS card-on-file field (post card-resolver): which ladder rung found the
|
||||
# card. Defaults off so pre-resolver payloads parse unchanged.
|
||||
resolved_via: Optional[str] = None
|
||||
|
||||
@property
|
||||
def masked(self) -> str:
|
||||
# A Link payment method has no card number (last4 = "") — render the
|
||||
# brand alone, not "Link ····".
|
||||
if not self.last4:
|
||||
return self.brand
|
||||
return f"{self.brand} ····{self.last4}"
|
||||
|
||||
@property
|
||||
def provenance(self) -> Optional[str]:
|
||||
"""Human label for why this card was picked, or None (unknown rung /
|
||||
server too old to say)."""
|
||||
if self.resolved_via is None:
|
||||
return None
|
||||
return _CARD_PROVENANCE_LABELS.get(self.resolved_via)
|
||||
|
||||
@property
|
||||
def display(self) -> str:
|
||||
"""The one-line card display: ``Visa ····4242 — the card on your
|
||||
subscription`` (or just the masked card when provenance is unknown)."""
|
||||
label = self.provenance
|
||||
return f"{self.masked} — {label}" if label else self.masked
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthlyCap:
|
||||
@@ -114,20 +81,11 @@ class MonthlyCap:
|
||||
is_default_ceiling: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutoReloadCard:
|
||||
kind: str # "canonical" | "distinct" | "none"
|
||||
payment_method_id: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
last4: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutoReload:
|
||||
enabled: bool = False
|
||||
threshold_usd: Optional[Decimal] = None
|
||||
reload_to_usd: Optional[Decimal] = None
|
||||
card: Optional[AutoReloadCard] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -142,8 +100,7 @@ class BillingState:
|
||||
org_id: Optional[str] = None
|
||||
org_slug: Optional[str] = None
|
||||
org_name: Optional[str] = None
|
||||
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
|
||||
can_change_plan_raw: Optional[bool] = None
|
||||
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
|
||||
balance_usd: Optional[Decimal] = None
|
||||
cli_billing_enabled: bool = False
|
||||
charge_presets: tuple[Decimal, ...] = ()
|
||||
@@ -158,33 +115,17 @@ class BillingState:
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Deprecated/display only — a legacy OWNER/ADMIN check.
|
||||
|
||||
NOT a capability check; use :attr:`can_change_plan` for gating billing
|
||||
plan-change actions.
|
||||
"""
|
||||
"""True for OWNER/ADMIN — the roles that can manage billing."""
|
||||
return (self.role or "").upper() in ("OWNER", "ADMIN")
|
||||
|
||||
@property
|
||||
def can_change_plan(self) -> bool:
|
||||
"""Server capability when supplied; otherwise the legacy role fallback."""
|
||||
if self.can_change_plan_raw is not None:
|
||||
return self.can_change_plan_raw
|
||||
return self.is_admin
|
||||
|
||||
@property
|
||||
def can_charge(self) -> bool:
|
||||
"""True when the UI should offer charge/auto-reload actions.
|
||||
|
||||
Uses the server-granted plan-change capability (``can_change_plan``,
|
||||
which itself falls back to the legacy OWNER/ADMIN role check when the
|
||||
server omits ``canChangePlan``) AND the per-org kill-switch. This lets
|
||||
the server grant charge capability to non-OWNER/ADMIN roles (e.g.
|
||||
FINANCE_ADMIN) via ``canChangePlan``, instead of hard-coding the
|
||||
deprecated 3-role admin check. (The server still enforces; this is
|
||||
just for graying out actions the user can't take.)
|
||||
Admin role AND the per-org kill-switch on. (The server still enforces;
|
||||
this is just for graying out actions the user can't take.)
|
||||
"""
|
||||
return self.can_change_plan and self.cli_billing_enabled
|
||||
return self.is_admin and self.cli_billing_enabled
|
||||
|
||||
|
||||
def _parse_card(raw: Any) -> Optional[CardInfo]:
|
||||
@@ -192,13 +133,9 @@ def _parse_card(raw: Any) -> Optional[CardInfo]:
|
||||
return None
|
||||
brand = raw.get("brand")
|
||||
last4 = raw.get("last4")
|
||||
if not (isinstance(brand, str) and isinstance(last4, str)):
|
||||
return None
|
||||
# Post-resolver fields — all optional so both payload generations parse.
|
||||
resolved_via = raw.get("resolvedVia")
|
||||
if not isinstance(resolved_via, str):
|
||||
resolved_via = None
|
||||
return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via)
|
||||
if isinstance(brand, str) and isinstance(last4, str):
|
||||
return CardInfo(brand=brand, last4=last4)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
|
||||
@@ -218,27 +155,6 @@ def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
|
||||
enabled=bool(raw.get("enabled")),
|
||||
threshold_usd=parse_money(raw.get("thresholdUsd")),
|
||||
reload_to_usd=parse_money(raw.get("reloadToUsd")),
|
||||
card=_parse_auto_reload_card(raw.get("card")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
kind = raw.get("kind")
|
||||
if kind not in ("canonical", "distinct", "none"):
|
||||
return None
|
||||
if kind in ("canonical", "none"):
|
||||
return AutoReloadCard(kind=kind)
|
||||
|
||||
payment_method_id = raw.get("paymentMethodId")
|
||||
brand = raw.get("brand")
|
||||
last4 = raw.get("last4")
|
||||
return AutoReloadCard(
|
||||
kind=kind,
|
||||
payment_method_id=payment_method_id if isinstance(payment_method_id, str) else None,
|
||||
brand=brand if isinstance(brand, str) else None,
|
||||
last4=last4 if isinstance(last4, str) else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -263,11 +179,6 @@ def billing_state_from_payload(
|
||||
org_slug=org.get("slug"),
|
||||
org_name=org.get("name"),
|
||||
role=org.get("role"),
|
||||
can_change_plan_raw=(
|
||||
payload.get("canChangePlan")
|
||||
if isinstance(payload.get("canChangePlan"), bool)
|
||||
else None
|
||||
),
|
||||
balance_usd=parse_money(payload.get("balanceUsd")),
|
||||
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
|
||||
charge_presets=tuple(presets),
|
||||
@@ -291,15 +202,7 @@ def build_billing_state(*, timeout: float = 15.0) -> BillingState:
|
||||
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
|
||||
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
|
||||
a clear message rather than crashing.
|
||||
|
||||
Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the
|
||||
card-on-file / admin / scope states are testable offline (mirrors
|
||||
``HERMES_DEV_CREDITS_FIXTURE`` for the usage model).
|
||||
"""
|
||||
fixture = _dev_fixture_billing_state()
|
||||
if fixture is not None:
|
||||
return fixture
|
||||
|
||||
try:
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingAuthError,
|
||||
@@ -340,72 +243,6 @@ def _fallback_portal_url(base: str) -> str:
|
||||
return f"{base.rstrip('/')}/billing?topup=open"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _dev_fixture_billing_state() -> Optional[BillingState]:
|
||||
"""Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX.
|
||||
|
||||
Recognized names::
|
||||
|
||||
nocard logged in · billing on · admin · NO card on file
|
||||
card card on file · auto-reload off
|
||||
card-autoreload card on file · auto-reload on
|
||||
notadmin logged in · MEMBER role (billing actions disabled)
|
||||
billing-off logged in · admin · per-org kill-switch OFF
|
||||
logged-out not logged in
|
||||
|
||||
Returns ``None`` when the env var is unset (the real portal path runs).
|
||||
Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from
|
||||
``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state).
|
||||
"""
|
||||
name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
# Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL —
|
||||
# prod host, not staging; the ?topup=open suffix is the /topup deep-link).
|
||||
portal = "https://portal.nousresearch.com/billing?topup=open"
|
||||
common: dict[str, Any] = dict(
|
||||
org_id="org_acme",
|
||||
org_slug="acme",
|
||||
org_name="Acme Inc",
|
||||
role="OWNER",
|
||||
balance_usd=Decimal("3.40"),
|
||||
cli_billing_enabled=True,
|
||||
charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")),
|
||||
min_usd=Decimal("5"),
|
||||
max_usd=Decimal("500"),
|
||||
portal_url=portal,
|
||||
)
|
||||
card = CardInfo(brand="Visa", last4="4242")
|
||||
autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25"))
|
||||
|
||||
if name in ("logged-out", "logged_out", "loggedout"):
|
||||
return BillingState(logged_in=False)
|
||||
if name == "nocard":
|
||||
return BillingState(logged_in=True, card=None, **common)
|
||||
if name == "card":
|
||||
return BillingState(logged_in=True, card=card, **common)
|
||||
if name in ("card-sub", "card_sub"):
|
||||
# Post-resolver: the card came from the subscription (provenance label).
|
||||
_sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin")
|
||||
return BillingState(logged_in=True, card=_sub_card, **common)
|
||||
if name in ("card-autoreload", "card_autoreload", "autoreload"):
|
||||
return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common)
|
||||
if name in ("notadmin", "not-admin", "member"):
|
||||
opts = {**common, "role": "MEMBER"}
|
||||
return BillingState(logged_in=True, card=card, **opts)
|
||||
if name in ("billing-off", "billing_off", "off"):
|
||||
opts = {**common, "cli_billing_enabled": False}
|
||||
return BillingState(logged_in=True, card=None, **opts)
|
||||
|
||||
# Unknown name → logged-out so the misconfiguration is visible.
|
||||
return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Idempotency
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Bounded reads of HTTP error response bodies.
|
||||
|
||||
When a provider returns a non-OK status on a *streaming* request, Hermes reads
|
||||
the response body to build a useful diagnostic error. A bare ``response.read()``
|
||||
on a streaming httpx response is unbounded in two dangerous ways:
|
||||
|
||||
1. A server can declare (or stream) an arbitrarily large body, so the read can
|
||||
balloon memory.
|
||||
2. A server can open the body and then stall forever (no ``Content-Length``,
|
||||
no further bytes), so the read hangs the agent indefinitely.
|
||||
|
||||
Both are realistic against a misbehaving proxy, a hijacked endpoint, or a
|
||||
provider having a bad day. The diagnostic body is only ever shown to the user
|
||||
truncated to a few hundred characters, so reading megabytes — or blocking
|
||||
forever — buys nothing.
|
||||
|
||||
``read_streaming_error_body`` bounds the read to a byte cap and enforces a
|
||||
hard wall-clock deadline, returning the decoded text snippet. Callers pass the
|
||||
returned text into their existing error builders instead of touching
|
||||
``response.text`` (which would be unbounded / would raise after a partial
|
||||
stream read).
|
||||
|
||||
A subtlety the implementation must respect: ``httpx``'s ``iter_bytes()`` blocks
|
||||
*inside* the C/socket read while waiting for the next chunk. A wall-clock check
|
||||
placed only between yielded chunks cannot interrupt a server that opens the
|
||||
body and then stalls mid-chunk — control never returns to Python until httpx's
|
||||
own (often 30s+) read timeout fires. To guarantee a bounded stop regardless of
|
||||
socket behavior, the read runs on a daemon worker thread and the caller waits
|
||||
on it with a hard deadline; on timeout we close the response (which unblocks /
|
||||
cancels the read) and return whatever partial bytes were collected.
|
||||
|
||||
Ported and adapted from openclaw/openclaw#95108 ("bound Anthropic error
|
||||
streams"), generalized to cover Hermes's three streaming error-body sites
|
||||
(native Gemini, Gemini Cloud Code, Antigravity Cloud Code).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Defaults chosen to comfortably hold any real provider error envelope (Google
|
||||
# RPC error JSON, Anthropic error JSON) while rejecting pathological bodies.
|
||||
DEFAULT_ERROR_BODY_MAX_BYTES = 64 * 1024
|
||||
# Hard wall-clock deadline for the whole bounded read. A streaming error body
|
||||
# that does not finish within this window is abandoned and the connection is
|
||||
# closed; we keep whatever partial bytes arrived.
|
||||
DEFAULT_ERROR_BODY_TIMEOUT_S = 10.0
|
||||
|
||||
|
||||
def read_streaming_error_body(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
max_bytes: int = DEFAULT_ERROR_BODY_MAX_BYTES,
|
||||
timeout_s: float = DEFAULT_ERROR_BODY_TIMEOUT_S,
|
||||
) -> str:
|
||||
"""Read a non-OK streaming response body with a byte cap and a hard deadline.
|
||||
|
||||
Returns the decoded body text (UTF-8, errors replaced), truncated to
|
||||
``max_bytes``. Never raises: any transport error, stall, or oversize
|
||||
condition is swallowed and the best-effort partial text (or an empty
|
||||
string) is returned, because this runs on the error path and must not
|
||||
mask the original HTTP failure with a read error.
|
||||
|
||||
The byte cap protects against huge bodies; the wall-clock deadline (enforced
|
||||
via a worker thread so it can interrupt a socket read that stalls mid-chunk)
|
||||
protects against bodies that open and then hang.
|
||||
"""
|
||||
chunks: List[bytes] = []
|
||||
state = {"truncated": False}
|
||||
done = threading.Event()
|
||||
|
||||
def _drain() -> None:
|
||||
total = 0
|
||||
try:
|
||||
for chunk in response.iter_bytes():
|
||||
if not chunk:
|
||||
continue
|
||||
remaining = max_bytes - total
|
||||
if remaining <= 0:
|
||||
state["truncated"] = True
|
||||
break
|
||||
if len(chunk) > remaining:
|
||||
chunks.append(chunk[:remaining])
|
||||
total += remaining
|
||||
state["truncated"] = True
|
||||
break
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
except Exception as exc: # noqa: BLE001 - error path must not raise
|
||||
logger.debug("bounded error-body read failed: %s", exc)
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
worker = threading.Thread(
|
||||
target=_drain, name="bounded-error-body-read", daemon=True
|
||||
)
|
||||
worker.start()
|
||||
finished = done.wait(timeout=timeout_s)
|
||||
|
||||
if not finished:
|
||||
logger.debug(
|
||||
"bounded error-body read: hard timeout after %.1fs (%d bytes so far)",
|
||||
timeout_s,
|
||||
sum(len(c) for c in chunks),
|
||||
)
|
||||
# Closing the response cancels the in-flight socket read, letting the
|
||||
# worker thread unwind. We do not join (it is a daemon and may be
|
||||
# blocked in C); the partial `chunks` collected so far are returned.
|
||||
_safe_close(response)
|
||||
else:
|
||||
_safe_close(response)
|
||||
|
||||
if state["truncated"]:
|
||||
logger.debug(
|
||||
"bounded error-body read: capped at %d bytes (max=%d)",
|
||||
sum(len(c) for c in chunks),
|
||||
max_bytes,
|
||||
)
|
||||
return b"".join(chunks).decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _safe_close(response: httpx.Response) -> None:
|
||||
try:
|
||||
response.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def read_error_body_or_default(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
max_bytes: int = DEFAULT_ERROR_BODY_MAX_BYTES,
|
||||
timeout_s: float = DEFAULT_ERROR_BODY_TIMEOUT_S,
|
||||
) -> Optional[str]:
|
||||
"""Like ``read_streaming_error_body`` but returns ``None`` on empty body.
|
||||
|
||||
Convenience for callers that distinguish "no body" from "empty string".
|
||||
"""
|
||||
text = read_streaming_error_body(
|
||||
response, max_bytes=max_bytes, timeout_s=timeout_s
|
||||
)
|
||||
return text or None
|
||||
+213
-1195
File diff suppressed because it is too large
Load Diff
@@ -288,13 +288,6 @@ _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.
|
||||
@@ -314,7 +307,6 @@ 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]]:
|
||||
@@ -339,16 +331,6 @@ 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
|
||||
@@ -481,14 +463,8 @@ def _chat_messages_to_responses_input(
|
||||
"content": normalized_content_parts,
|
||||
}
|
||||
item_id = raw_item.get("id")
|
||||
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
|
||||
if isinstance(item_id, str) and item_id.strip():
|
||||
replay_item["id"] = item_id.strip()
|
||||
phase = raw_item.get("phase")
|
||||
if isinstance(phase, str) and phase.strip():
|
||||
replay_item["phase"] = phase.strip()
|
||||
@@ -600,11 +576,7 @@ def _chat_messages_to_responses_input(
|
||||
# Input preflight / validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _preflight_codex_input_items(
|
||||
raw_items: Any,
|
||||
*,
|
||||
is_github_responses: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
def _preflight_codex_input_items(raw_items: Any) -> List[Dict[str, Any]]:
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError("Codex Responses input must be a list of input items.")
|
||||
|
||||
@@ -745,14 +717,8 @@ def _preflight_codex_input_items(
|
||||
"content": normalized_content,
|
||||
}
|
||||
item_id = item.get("id")
|
||||
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
|
||||
if isinstance(item_id, str) and item_id.strip():
|
||||
normalized_item["id"] = item_id.strip()
|
||||
phase = item.get("phase")
|
||||
if isinstance(phase, str) and phase.strip():
|
||||
normalized_item["phase"] = phase.strip()
|
||||
@@ -824,7 +790,6 @@ 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.")
|
||||
@@ -846,10 +811,7 @@ 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"),
|
||||
is_github_responses=is_github_responses,
|
||||
)
|
||||
normalized_input = _preflight_codex_input_items(api_kwargs.get("input"))
|
||||
|
||||
tools = api_kwargs.get("tools")
|
||||
normalized_tools = None
|
||||
@@ -1118,22 +1080,6 @@ 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
|
||||
@@ -1150,18 +1096,15 @@ 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)
|
||||
@@ -1223,28 +1166,15 @@ def _normalize_codex_response(
|
||||
if item_type == "message":
|
||||
item_phase = getattr(item, "phase", None)
|
||||
normalized_phase = None
|
||||
is_commentary_phase = False
|
||||
if isinstance(item_phase, str):
|
||||
normalized_phase = item_phase.strip().lower()
|
||||
if normalized_phase in {"commentary", "analysis"}:
|
||||
saw_commentary_phase = True
|
||||
is_commentary_phase = True
|
||||
elif normalized_phase in {"final_answer", "final"}:
|
||||
saw_final_answer_phase = True
|
||||
message_text = _extract_responses_message_text(item)
|
||||
if message_text:
|
||||
# Responses ``commentary``/``analysis`` phase text is mid-turn
|
||||
# preamble/progress narration, never the turn's final answer
|
||||
# (Codex CLI excludes it from last-message extraction; issues
|
||||
# #24933 / #41293). Keep it out of assistant content so it
|
||||
# can't be concatenated into — or leak as — the final response,
|
||||
# but surface it through the reasoning channel so the CLI/
|
||||
# gateway display it like thinking text. The exact message
|
||||
# item is still preserved below for replay/cache continuity.
|
||||
if is_commentary_phase:
|
||||
reasoning_parts.append(message_text)
|
||||
else:
|
||||
content_parts.append(message_text)
|
||||
content_parts.append(message_text)
|
||||
raw_message_item: Dict[str, Any] = {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
@@ -1339,11 +1269,7 @@ def _normalize_codex_response(
|
||||
))
|
||||
|
||||
final_text = "\n".join([p for p in content_parts if p]).strip()
|
||||
if (
|
||||
not final_text
|
||||
and hasattr(response, "output_text")
|
||||
and not (saw_commentary_phase and not saw_final_answer_phase)
|
||||
):
|
||||
if not final_text and hasattr(response, "output_text"):
|
||||
out_text = getattr(response, "output_text", "")
|
||||
if isinstance(out_text, str):
|
||||
final_text = out_text.strip()
|
||||
@@ -1379,45 +1305,6 @@ 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,
|
||||
@@ -1430,8 +1317,6 @@ 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:
|
||||
@@ -1440,28 +1325,12 @@ 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.
|
||||
#
|
||||
# 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"
|
||||
# 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"
|
||||
else:
|
||||
finish_reason = "stop"
|
||||
return assistant_message, finish_reason
|
||||
|
||||
+82
-676
@@ -16,18 +16,70 @@ compatibility.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _codex_note_to_tool_progress(note: dict) -> tuple[str, str, dict] | None:
|
||||
"""Map a Codex app-server ``item/started`` notification to a Hermes
|
||||
tool-progress event ``(tool_name, preview, args)``.
|
||||
|
||||
The Codex app-server runtime processes ``item/started`` notifications for
|
||||
command execution, file changes, and MCP/dynamic tool calls, but never
|
||||
surfaced them as Hermes tool-progress events — so gateways (Telegram, etc.)
|
||||
showed no verbose "running X" breadcrumbs on this route while every other
|
||||
provider did (#38835). Returns None for items that aren't tool-shaped.
|
||||
"""
|
||||
if not isinstance(note, dict) or note.get("method") != "item/started":
|
||||
return None
|
||||
params = note.get("params") or {}
|
||||
item = params.get("item") or {}
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
|
||||
item_type = item.get("type") or ""
|
||||
if item_type == "commandExecution":
|
||||
command = item.get("command") or ""
|
||||
return "exec_command", command, {"command": command, "cwd": item.get("cwd") or ""}
|
||||
|
||||
if item_type == "fileChange":
|
||||
changes = item.get("changes") or []
|
||||
preview = "file changes"
|
||||
if isinstance(changes, list) and changes:
|
||||
paths = [
|
||||
str(change.get("path"))
|
||||
for change in changes
|
||||
if isinstance(change, dict) and change.get("path")
|
||||
]
|
||||
if paths:
|
||||
preview = ", ".join(paths[:3])
|
||||
if len(paths) > 3:
|
||||
preview += f", +{len(paths) - 3} more"
|
||||
return "apply_patch", preview, {"changes": changes}
|
||||
|
||||
if item_type == "mcpToolCall":
|
||||
server = item.get("server") or "mcp"
|
||||
tool = item.get("tool") or "unknown"
|
||||
args = item.get("arguments") or {}
|
||||
if not isinstance(args, dict):
|
||||
args = {"arguments": args}
|
||||
return f"mcp.{server}.{tool}", tool, args
|
||||
|
||||
if item_type == "dynamicToolCall":
|
||||
tool = item.get("tool") or "unknown"
|
||||
args = item.get("arguments") or {}
|
||||
if not isinstance(args, dict):
|
||||
args = {"arguments": args}
|
||||
return tool, tool, args
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_usage_int(value: Any) -> int:
|
||||
if isinstance(value, bool):
|
||||
return 0
|
||||
@@ -61,15 +113,6 @@ 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:
|
||||
@@ -77,9 +120,6 @@ 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:
|
||||
@@ -188,430 +228,6 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _record_codex_app_server_compaction(
|
||||
agent,
|
||||
turn,
|
||||
*,
|
||||
approx_tokens: int | None = None,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
"""Record a Codex-native context compaction boundary in Hermes state.
|
||||
|
||||
The app-server owns the compacted thread context, so Hermes should not
|
||||
rewrite local transcript rows here; state.db records the boundary via the
|
||||
session event/usage counters while preserving the visible transcript.
|
||||
"""
|
||||
if not force and not getattr(turn, "compacted", False):
|
||||
return False
|
||||
|
||||
thread_id = getattr(turn, "thread_id", None) or ""
|
||||
turn_id = getattr(turn, "turn_id", None) or ""
|
||||
logger.info(
|
||||
"codex app-server compaction observed: session=%s thread=%s turn=%s force=%s",
|
||||
getattr(agent, "session_id", None) or "none",
|
||||
thread_id,
|
||||
turn_id,
|
||||
force,
|
||||
)
|
||||
if not force:
|
||||
try:
|
||||
from agent.conversation_compression import COMPACTION_STATUS
|
||||
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
compressor = getattr(agent, "context_compressor", None)
|
||||
if compressor is not None:
|
||||
compressor.compression_count = getattr(
|
||||
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
|
||||
compressor.awaiting_real_usage_after_compression = True
|
||||
|
||||
agent._last_compaction_in_place = False
|
||||
try:
|
||||
if getattr(agent, "event_callback", None):
|
||||
agent.event_callback(
|
||||
"session:compress",
|
||||
{
|
||||
"platform": getattr(agent, "platform", None) or "",
|
||||
"session_id": getattr(agent, "session_id", None) or "",
|
||||
"old_session_id": "",
|
||||
"in_place": False,
|
||||
"compression_count": getattr(
|
||||
compressor, "compression_count", 0
|
||||
)
|
||||
if compressor is not None
|
||||
else 0,
|
||||
"runtime": "codex_app_server",
|
||||
"thread_id": thread_id,
|
||||
"turn_id": turn_id,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("event_callback error on codex session:compress", exc_info=True)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Codex app-server → Hermes UI bridge (#33200)
|
||||
#
|
||||
# The codex_app_server runtime hands the entire turn to a subprocess and
|
||||
# bypasses the normal Hermes tool loop. Without this bridge gateway
|
||||
# adapters (Discord, Telegram, TUI) never see live tool-progress bubbles
|
||||
# or interim assistant commentary while codex is working — the user just
|
||||
# stares at a quiet channel until the final answer lands. The bridge
|
||||
# translates raw codex JSON-RPC notifications into the same three agent
|
||||
# callbacks the standard runtime fires:
|
||||
# - tool_progress_callback("tool.started"|"tool.completed", name, ...)
|
||||
# - _fire_stream_delta(text) for streaming agentMessage chunks
|
||||
# - _emit_interim_assistant_message({...}) for completed agentMessages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Codex item types that map to a Hermes tool_call in the projector (and
|
||||
# therefore deserve a tool_progress bubble pair). The projector lives in
|
||||
# agent/transports/codex_event_projector.py — keep these in sync so the
|
||||
# tool name shown in the UI matches the name recorded in messages.
|
||||
# webSearch is codex's built-in web search tool — it has no projector
|
||||
# entry (codex handles it internally) but still deserves a bubble.
|
||||
_CODEX_TOOL_ITEM_TYPES = frozenset(
|
||||
{"commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "webSearch"}
|
||||
)
|
||||
|
||||
# Internal MCP server that wraps Hermes' native tools for codex. When
|
||||
# codex calls back through it, the inner dispatch runs in a SEPARATE
|
||||
# hermes-tools-mcp-server subprocess that has no access to the parent
|
||||
# agent's tool_progress_callback — so the inner call can never surface
|
||||
# its own native progress event. The codex-level mcpToolCall event IS
|
||||
# the display event for those calls; we strip the mcp.hermes-tools.*
|
||||
# namespacing and emit the bare tool name (web_search, browser_navigate,
|
||||
# vision_analyze, ...) since the user thinks of these as Hermes tools,
|
||||
# not as MCP calls.
|
||||
_INTERNAL_MCP_SERVER = "hermes-tools"
|
||||
|
||||
|
||||
def _codex_item_to_tool_name(item: dict) -> str:
|
||||
"""Synthetic Hermes tool name for a codex item. Mirrors
|
||||
CodexEventProjector so the progress bubble and the projected
|
||||
tool_calls entry use the same identifier."""
|
||||
item_type = item.get("type") or ""
|
||||
if item_type == "commandExecution":
|
||||
return "exec_command"
|
||||
if item_type == "fileChange":
|
||||
return "apply_patch"
|
||||
if item_type == "mcpToolCall":
|
||||
server = item.get("server") or "mcp"
|
||||
tool = item.get("tool") or "unknown"
|
||||
if server == _INTERNAL_MCP_SERVER:
|
||||
return tool
|
||||
return f"mcp.{server}.{tool}"
|
||||
if item_type == "dynamicToolCall":
|
||||
return item.get("tool") or "dynamic"
|
||||
if item_type == "webSearch":
|
||||
return "web_search"
|
||||
return item_type or "unknown"
|
||||
|
||||
|
||||
def _codex_item_to_args(item: dict) -> dict:
|
||||
"""Args dict surfaced to tool_progress_callback("tool.started", ...).
|
||||
Mirrors the projector's _project_command / _project_file_change /
|
||||
_project_mcp_tool_call / _project_dynamic_tool_call shapes."""
|
||||
item_type = item.get("type") or ""
|
||||
if item_type == "commandExecution":
|
||||
return {"command": item.get("command") or "",
|
||||
"cwd": item.get("cwd") or ""}
|
||||
if item_type == "fileChange":
|
||||
return {"changes": [
|
||||
{"kind": (c.get("kind") or {}).get("type") or "update",
|
||||
"path": c.get("path") or ""}
|
||||
for c in (item.get("changes") or []) if isinstance(c, dict)
|
||||
]}
|
||||
if item_type in {"mcpToolCall", "dynamicToolCall"}:
|
||||
args = item.get("arguments") or {}
|
||||
return args if isinstance(args, dict) else {"arguments": args}
|
||||
if item_type == "webSearch":
|
||||
return {"query": item.get("query") or ""}
|
||||
return {}
|
||||
|
||||
|
||||
def _codex_item_to_preview(item: dict) -> Any:
|
||||
"""Short human-readable preview for the tool.started bubble. Returns
|
||||
None when no useful preview is available (Hermes' UI tolerates None)."""
|
||||
item_type = item.get("type") or ""
|
||||
if item_type == "commandExecution":
|
||||
cmd = item.get("command") or ""
|
||||
return cmd[:120] if cmd else None
|
||||
if item_type == "fileChange":
|
||||
paths = [c.get("path") for c in (item.get("changes") or [])
|
||||
if isinstance(c, dict) and c.get("path")]
|
||||
if not paths:
|
||||
return None
|
||||
preview = ", ".join(paths[:3])
|
||||
if len(paths) > 3:
|
||||
preview += f", +{len(paths) - 3} more"
|
||||
return preview
|
||||
if item_type in {"mcpToolCall", "dynamicToolCall"}:
|
||||
args = item.get("arguments") or {}
|
||||
if not isinstance(args, dict) or not args:
|
||||
return None
|
||||
try:
|
||||
return json.dumps(args, ensure_ascii=False)[:120]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if item_type == "webSearch":
|
||||
query = item.get("query") or ""
|
||||
return query[:120] if query else None
|
||||
return None
|
||||
|
||||
|
||||
def _codex_item_completion_payload(item: dict) -> tuple[str, bool]:
|
||||
"""Return (result_text, is_error) for a completed codex tool item.
|
||||
Mirrors the projector's tool-result content so the bubble shows the
|
||||
same outcome string that ends up in the messages list."""
|
||||
item_type = item.get("type") or ""
|
||||
if item_type == "commandExecution":
|
||||
out = item.get("aggregatedOutput") or ""
|
||||
exit_code = item.get("exitCode")
|
||||
is_error = bool(exit_code is not None and exit_code != 0)
|
||||
if is_error:
|
||||
out = f"[exit {exit_code}]\n{out}"
|
||||
return out, is_error
|
||||
if item_type == "fileChange":
|
||||
status = item.get("status") or "unknown"
|
||||
n = len(item.get("changes") or [])
|
||||
return (
|
||||
f"apply_patch status={status}, {n} change(s)",
|
||||
status not in {"completed", "applied", "success"},
|
||||
)
|
||||
if item_type == "mcpToolCall":
|
||||
error = item.get("error")
|
||||
if error:
|
||||
return (
|
||||
f"[error] {json.dumps(error, ensure_ascii=False)[:1000]}",
|
||||
True,
|
||||
)
|
||||
result = item.get("result")
|
||||
return (
|
||||
json.dumps(result, ensure_ascii=False)[:4000]
|
||||
if result is not None else "",
|
||||
False,
|
||||
)
|
||||
if item_type == "dynamicToolCall":
|
||||
content_items = item.get("contentItems") or []
|
||||
if isinstance(content_items, list) and content_items:
|
||||
return (
|
||||
json.dumps(content_items, ensure_ascii=False)[:4000],
|
||||
not bool(item.get("success", True)),
|
||||
)
|
||||
success = item.get("success", True)
|
||||
return f"success={success}", not bool(success)
|
||||
return "", False
|
||||
|
||||
|
||||
def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
|
||||
"""Build an ``on_event`` callback that wires codex app-server JSON-RPC
|
||||
notifications into Hermes' gateway UI callbacks.
|
||||
|
||||
Returns a single-argument callable suitable for
|
||||
``CodexAppServerSession(on_event=...)``.
|
||||
|
||||
Translation map:
|
||||
* ``item/started`` for tool-shaped items → ``tool_progress_callback(
|
||||
"tool.started", name, preview, args)``
|
||||
* ``item/completed`` for tool-shaped items → ``tool_progress_callback(
|
||||
"tool.completed", name, None, None, duration=..., is_error=...,
|
||||
result=...)``
|
||||
* ``item/agentMessage/delta`` → ``_fire_stream_delta(text)`` so chat
|
||||
adapters can render the assistant's reply as it streams.
|
||||
* ``item/reasoning/delta`` → ``_fire_reasoning_delta(text)``
|
||||
* ``item/completed`` for ``agentMessage`` →
|
||||
``_emit_interim_assistant_message({"role": "assistant",
|
||||
"content": text})``. The gateway's ``already_streamed`` check
|
||||
dedupes against any text the stream-delta callback already
|
||||
rendered for the same message.
|
||||
|
||||
All callback invocations are guarded — a buggy display callback must
|
||||
not tear down the codex turn loop. Errors are logged at DEBUG so the
|
||||
notification stream keeps flowing regardless.
|
||||
"""
|
||||
# item_id -> (tool_name, args, started_wall_time). Populated on
|
||||
# item/started and consumed on item/completed so duration is correct
|
||||
# even when codex doesn't report durationMs.
|
||||
started: dict[str, tuple[str, dict, float]] = {}
|
||||
|
||||
def _stable_call_id(item: dict, name: str) -> str:
|
||||
"""Deterministic tool_call id mirroring CodexEventProjector, so a
|
||||
live TUI tool card correlates with the same tool call after the
|
||||
session is resumed and history is projected."""
|
||||
from agent.transports.codex_event_projector import _deterministic_call_id
|
||||
|
||||
item_id = item.get("id") or ""
|
||||
item_type = item.get("type") or ""
|
||||
if item_type == "commandExecution":
|
||||
return _deterministic_call_id("exec", item_id)
|
||||
if item_type == "fileChange":
|
||||
return _deterministic_call_id("apply_patch", item_id)
|
||||
if item_type == "mcpToolCall":
|
||||
server = item.get("server") or "mcp"
|
||||
tool = item.get("tool") or "unknown"
|
||||
return _deterministic_call_id(f"mcp__{server}__{tool}", item_id)
|
||||
if item_type == "dynamicToolCall":
|
||||
tool = item.get("tool") or "unknown"
|
||||
return _deterministic_call_id(f"dyn_{tool}", item_id)
|
||||
return _deterministic_call_id(name, item_id)
|
||||
|
||||
def _fire_tool_started(item: dict) -> None:
|
||||
item_id = item.get("id") or ""
|
||||
name = _codex_item_to_tool_name(item)
|
||||
args = _codex_item_to_args(item)
|
||||
if item_id:
|
||||
started[item_id] = (name, args, time.monotonic())
|
||||
cb = getattr(agent, "tool_progress_callback", None)
|
||||
if cb is not None:
|
||||
try:
|
||||
cb("tool.started", name, _codex_item_to_preview(item), args)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"tool_progress_callback raised on tool.started for %s",
|
||||
name, exc_info=True,
|
||||
)
|
||||
# Authoritative stable-ID tool card (TUI / desktop). Fires
|
||||
# alongside tool_progress so surfaces that render structured tool
|
||||
# cards (not just progress bubbles) stay correlated with the
|
||||
# projected history entry after a resume.
|
||||
start_cb = getattr(agent, "tool_start_callback", None)
|
||||
if start_cb is not None:
|
||||
try:
|
||||
start_cb(_stable_call_id(item, name), name, args)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"tool_start_callback raised for %s", name, exc_info=True,
|
||||
)
|
||||
|
||||
def _fire_tool_completed(item: dict) -> None:
|
||||
item_id = item.get("id") or ""
|
||||
name = _codex_item_to_tool_name(item)
|
||||
prior = started.pop(item_id, None)
|
||||
# Prefer codex's own durationMs when present so the bubble shows
|
||||
# exact tool wall-time; fall back to our started timestamp; fall
|
||||
# back to None if we never saw an item/started (some codex
|
||||
# versions only emit completed for fast items).
|
||||
duration: Any = None
|
||||
codex_ms = item.get("durationMs")
|
||||
if isinstance(codex_ms, (int, float)) and codex_ms >= 0:
|
||||
duration = codex_ms / 1000.0
|
||||
elif prior is not None:
|
||||
duration = time.monotonic() - prior[2]
|
||||
result, is_error = _codex_item_completion_payload(item)
|
||||
cb = getattr(agent, "tool_progress_callback", None)
|
||||
if cb is not None:
|
||||
try:
|
||||
cb("tool.completed", name, None, None,
|
||||
duration=duration, is_error=is_error, result=result)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"tool_progress_callback raised on tool.completed for %s",
|
||||
name, exc_info=True,
|
||||
)
|
||||
complete_cb = getattr(agent, "tool_complete_callback", None)
|
||||
if complete_cb is not None:
|
||||
args = prior[1] if prior is not None else _codex_item_to_args(item)
|
||||
try:
|
||||
complete_cb(_stable_call_id(item, name), name, args, result)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"tool_complete_callback raised for %s", name, exc_info=True,
|
||||
)
|
||||
|
||||
def _fire_text_delta(params: dict) -> None:
|
||||
text = params.get("delta") or params.get("text") or ""
|
||||
if not isinstance(text, str) or not text:
|
||||
return
|
||||
fn = getattr(agent, "_fire_stream_delta", None)
|
||||
if fn is None:
|
||||
return
|
||||
try:
|
||||
fn(text)
|
||||
except Exception:
|
||||
logger.debug("_fire_stream_delta raised", exc_info=True)
|
||||
|
||||
def _fire_reasoning_delta(params: dict) -> None:
|
||||
text = params.get("delta") or params.get("text") or ""
|
||||
if not isinstance(text, str) or not text:
|
||||
return
|
||||
fn = getattr(agent, "_fire_reasoning_delta", None)
|
||||
if fn is None:
|
||||
return
|
||||
try:
|
||||
fn(text)
|
||||
except Exception:
|
||||
logger.debug("_fire_reasoning_delta raised", exc_info=True)
|
||||
|
||||
def _fire_agent_message_completed(item: dict) -> None:
|
||||
text = item.get("text") or ""
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return
|
||||
# display.show_commentary=false — mid-turn narration stays off the
|
||||
# visible interim path on this runtime too (same contract as the
|
||||
# codex_responses commentary channel).
|
||||
if not getattr(agent, "show_commentary", True):
|
||||
return
|
||||
emit = getattr(agent, "_emit_interim_assistant_message", None)
|
||||
if emit is None:
|
||||
return
|
||||
try:
|
||||
emit({"role": "assistant", "content": text})
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"_emit_interim_assistant_message raised", exc_info=True,
|
||||
)
|
||||
|
||||
def on_event(note: dict) -> None:
|
||||
if not isinstance(note, dict):
|
||||
return
|
||||
method = note.get("method") or ""
|
||||
params = note.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
if method == "item/agentMessage/delta":
|
||||
_fire_text_delta(params)
|
||||
return
|
||||
if method in {"item/reasoning/delta", "item/reasoning/summaryDelta"}:
|
||||
_fire_reasoning_delta(params)
|
||||
return
|
||||
item = params.get("item")
|
||||
if not isinstance(item, dict):
|
||||
return
|
||||
item_type = item.get("type") or ""
|
||||
if method == "item/started" and item_type in _CODEX_TOOL_ITEM_TYPES:
|
||||
_fire_tool_started(item)
|
||||
return
|
||||
if method == "item/completed":
|
||||
if item_type in _CODEX_TOOL_ITEM_TYPES:
|
||||
_fire_tool_completed(item)
|
||||
elif item_type == "agentMessage":
|
||||
_fire_agent_message_completed(item)
|
||||
|
||||
return on_event
|
||||
|
||||
|
||||
def run_codex_app_server_turn(
|
||||
agent,
|
||||
*,
|
||||
@@ -628,10 +244,7 @@ def run_codex_app_server_turn(
|
||||
Called from run_conversation() when agent.api_mode == "codex_app_server".
|
||||
Returns the same dict shape as the chat_completions path.
|
||||
"""
|
||||
from agent.transports.codex_app_server_session import (
|
||||
CodexAppServerSession,
|
||||
_ServerRequestRouting,
|
||||
)
|
||||
from agent.transports.codex_app_server_session import CodexAppServerSession
|
||||
|
||||
# Lazy session: one CodexAppServerSession per AIAgent instance.
|
||||
# Spawned on first turn, reused across turns, closed at AIAgent
|
||||
@@ -649,42 +262,26 @@ def run_codex_app_server_turn(
|
||||
except Exception:
|
||||
approval_callback = None
|
||||
|
||||
# Gateway / cron contexts have no UI to surface codex's approval
|
||||
# requests through, so codex app-server exec / apply_patch requests
|
||||
# fail closed (silently decline) by default. When the user has
|
||||
# explicitly opted out of Hermes approvals — via `approvals.mode: off`
|
||||
# in config, the /yolo session toggle, or --yolo / HERMES_YOLO_MODE —
|
||||
# honor that and let codex's own sandbox permission profile
|
||||
# (~/.codex/config.toml) be the policy gate instead of double-gating
|
||||
# with a missing Hermes UI. Defaults (manual/smart/unset) preserve the
|
||||
# current fail-closed behavior — this is a no-op for those users.
|
||||
auto_approve_requests = False
|
||||
try:
|
||||
from tools.approval import is_approval_bypass_active
|
||||
def _on_codex_event(note: dict) -> None:
|
||||
# Bridge Codex app-server item/started notifications to Hermes
|
||||
# tool-progress so gateways show verbose "running X" breadcrumbs
|
||||
# on this route too (#38835).
|
||||
progress_callback = getattr(agent, "tool_progress_callback", None)
|
||||
if progress_callback is None:
|
||||
return
|
||||
mapped = _codex_note_to_tool_progress(note)
|
||||
if mapped is None:
|
||||
return
|
||||
tool_name, preview, args = mapped
|
||||
try:
|
||||
progress_callback("tool.started", tool_name, preview, args)
|
||||
except Exception:
|
||||
logger.debug("codex tool-progress callback raised", exc_info=True)
|
||||
|
||||
auto_approve_requests = is_approval_bypass_active()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"codex app-server: approval-bypass lookup failed; "
|
||||
"keeping fail-closed default",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Bridge codex JSON-RPC notifications (item/started, item/completed,
|
||||
# item/agentMessage/delta, ...) into Hermes' gateway UI callbacks
|
||||
# (tool_progress_callback, _fire_stream_delta,
|
||||
# _emit_interim_assistant_message). Without this, Discord/Telegram
|
||||
# users see no live tool-progress or interim commentary while
|
||||
# codex_app_server is running — only the final answer (#33200).
|
||||
# Supersedes the narrower item/started-only bridge from #38835.
|
||||
agent._codex_session = CodexAppServerSession(
|
||||
cwd=cwd,
|
||||
approval_callback=approval_callback,
|
||||
request_routing=_ServerRequestRouting(
|
||||
auto_approve_exec=auto_approve_requests,
|
||||
auto_approve_apply_patch=auto_approve_requests,
|
||||
),
|
||||
on_event=make_codex_app_server_event_bridge(agent),
|
||||
on_event=_on_codex_event,
|
||||
)
|
||||
|
||||
# NOTE: the user message is ALREADY appended to messages by the
|
||||
@@ -702,16 +299,6 @@ def run_codex_app_server_turn(
|
||||
except Exception:
|
||||
pass
|
||||
agent._codex_session = None
|
||||
_user_interrupted = bool(
|
||||
getattr(agent, "_interrupt_requested", False)
|
||||
)
|
||||
_interrupt_message = (
|
||||
getattr(agent, "_interrupt_message", None)
|
||||
if _user_interrupted
|
||||
else None
|
||||
)
|
||||
if _user_interrupted:
|
||||
agent.clear_interrupt()
|
||||
return {
|
||||
"final_response": (
|
||||
f"Codex app-server turn failed: {exc}. "
|
||||
@@ -721,27 +308,9 @@ def run_codex_app_server_turn(
|
||||
"api_calls": 0,
|
||||
"completed": False,
|
||||
"partial": True,
|
||||
"interrupted": _user_interrupted,
|
||||
**(
|
||||
{"interrupt_message": _interrupt_message}
|
||||
if _interrupt_message
|
||||
else {}
|
||||
),
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
# This runtime bypasses the normal conversation-loop finalizer. Mirror its
|
||||
# interrupt handoff/cleanup so a hard stop cannot poison the next turn and a
|
||||
# message-bearing compatibility interrupt can still be replayed by callers.
|
||||
_user_interrupted = bool(
|
||||
turn.interrupted and getattr(agent, "_interrupt_requested", False)
|
||||
)
|
||||
_interrupt_message = (
|
||||
getattr(agent, "_interrupt_message", None) if _user_interrupted else None
|
||||
)
|
||||
if _user_interrupted:
|
||||
agent.clear_interrupt()
|
||||
|
||||
# If the turn signalled the underlying client is wedged (deadline
|
||||
# blown, post-tool watchdog tripped, OAuth refresh died, subprocess
|
||||
# exited), retire the session so the next turn respawns codex
|
||||
@@ -764,28 +333,6 @@ def run_codex_app_server_turn(
|
||||
if turn.projected_messages:
|
||||
messages.extend(turn.projected_messages)
|
||||
|
||||
# Persist the newly-projected assistant/tool messages ourselves.
|
||||
# This path is an early return that bypasses conversation_loop, whose
|
||||
# normal per-step _persist_session() calls would otherwise flush them.
|
||||
# The inbound user turn was already flushed at turn start
|
||||
# (turn_context.py _persist_session), and _flush_messages_to_session_db
|
||||
# is idempotent via the intrinsic _DB_PERSISTED_MARKER — so this writes
|
||||
# ONLY the new codex projected rows and does NOT re-write the user turn.
|
||||
# Keeping the agent as the sole persister lets us return
|
||||
# agent_persisted=True below, so the gateway skips its own DB write and
|
||||
# we avoid the #860/#42039 duplicate user-message write (append_message
|
||||
# is a raw INSERT with no dedup, so a gateway re-write would duplicate
|
||||
# the already-flushed user turn). See gateway/run.py agent_persisted.
|
||||
if getattr(agent, "_session_db", None) is not None:
|
||||
try:
|
||||
agent._flush_messages_to_session_db(messages)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"codex app-server projected-message flush failed",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
# Counter ticks for the agent-improvement loop.
|
||||
# _turns_since_memory and _user_turn_count are ALREADY incremented
|
||||
# in the run_conversation() pre-loop block (lines ~11793-11817) so we
|
||||
@@ -796,7 +343,6 @@ def run_codex_app_server_turn(
|
||||
agent._iters_since_skill = (
|
||||
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
|
||||
)
|
||||
_record_codex_app_server_compaction(agent, turn)
|
||||
usage_result = _record_codex_app_server_usage(agent, turn)
|
||||
api_calls = 1
|
||||
|
||||
@@ -847,25 +393,7 @@ def run_codex_app_server_turn(
|
||||
"api_calls": api_calls,
|
||||
"completed": not turn.interrupted and turn.error is None,
|
||||
"partial": turn.interrupted or turn.error is not None,
|
||||
"interrupted": _user_interrupted,
|
||||
**(
|
||||
{"interrupt_message": _interrupt_message}
|
||||
if _interrupt_message
|
||||
else {}
|
||||
),
|
||||
"error": turn.error,
|
||||
# The codex app-server runtime IS an early-return path that bypasses
|
||||
# conversation_loop, but we flush the projected assistant/tool messages
|
||||
# ourselves above (see the _flush_messages_to_session_db call after
|
||||
# messages.extend). The inbound user turn was already flushed at turn
|
||||
# start (turn_context._persist_session) and the flush dedups via
|
||||
# _DB_PERSISTED_MARKER, so state.db ends up with each real message
|
||||
# exactly once and session_search / conversation-distill see the full
|
||||
# gateway conversation. Report agent_persisted=True so the gateway
|
||||
# skips its own append_to_transcript DB write — writing again there
|
||||
# would re-INSERT the already-flushed user turn (append_message has no
|
||||
# dedup), reintroducing the #860 / #42039 duplicate-write bug.
|
||||
"agent_persisted": True,
|
||||
"codex_thread_id": turn.thread_id,
|
||||
"codex_turn_id": turn.turn_id,
|
||||
**usage_result,
|
||||
@@ -910,48 +438,18 @@ def _event_field(event: Any, name: str, default: Any = None) -> Any:
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
def _item_field(item: Any, name: str, default: Any = None) -> Any:
|
||||
"""Field access for nested Response items (attr-style SDK object or dict)."""
|
||||
value = getattr(item, name, None)
|
||||
if value is None and isinstance(item, dict):
|
||||
value = item.get(name, default)
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
def _raise_stream_error(event: Any) -> None:
|
||||
"""Raise a ``_StreamErrorEvent`` from a ``type=error`` SSE frame.
|
||||
|
||||
The Responses spec puts the failure details at the top level of the
|
||||
frame (``{"type": "error", "code": ..., "message": ..., "param": ...}``),
|
||||
but the official OpenAI SDK and several OpenAI-compatible proxies wrap
|
||||
them in an HTTP-style nested envelope instead
|
||||
(``{"type": "error", "error": {"code": ..., "message": ..., "param": ...}}``).
|
||||
Read the top-level fields first, then fall back to the nested envelope so
|
||||
the error classifier sees the provider's real code/message (rate-limit vs
|
||||
context-overflow vs entitlement) rather than the generic placeholder.
|
||||
Port of anomalyco/opencode#36130.
|
||||
|
||||
Imported lazily so this module stays importable from places that don't
|
||||
pull in ``run_agent`` (e.g. plugin code, doc tools).
|
||||
"""
|
||||
from run_agent import _StreamErrorEvent
|
||||
|
||||
nested = _event_field(event, "error")
|
||||
|
||||
def _error_field(name: str) -> Any:
|
||||
value = _event_field(event, name)
|
||||
if value is None and nested is not None:
|
||||
value = _item_field(nested, name)
|
||||
return value
|
||||
|
||||
raw_message = _error_field("message")
|
||||
if raw_message is not None and not isinstance(raw_message, str):
|
||||
raw_message = str(raw_message)
|
||||
message = (raw_message or "stream emitted error event").strip() or "stream emitted error event"
|
||||
message = (_event_field(event, "message", "") or "stream emitted error event").strip()
|
||||
raise _StreamErrorEvent(
|
||||
message,
|
||||
code=_error_field("code"),
|
||||
param=_error_field("param"),
|
||||
code=_event_field(event, "code"),
|
||||
param=_event_field(event, "param"),
|
||||
)
|
||||
|
||||
|
||||
@@ -961,7 +459,6 @@ def _consume_codex_event_stream(
|
||||
model: str,
|
||||
on_text_delta=None,
|
||||
on_reasoning_delta=None,
|
||||
on_commentary_message=None,
|
||||
on_first_delta=None,
|
||||
on_event=None,
|
||||
interrupt_check=None,
|
||||
@@ -993,11 +490,7 @@ def _consume_codex_event_stream(
|
||||
* ``on_text_delta(str)`` — fires per ``response.output_text.delta``, suppressed
|
||||
once a function_call event is seen (so tool-call turns don't bleed text
|
||||
into the chat).
|
||||
* ``on_reasoning_delta(str)`` — fires per ``response.reasoning.*.delta`` and
|
||||
``phase=analysis`` message deltas. When no dedicated commentary callback
|
||||
is supplied, commentary also uses this legacy fallback.
|
||||
* ``on_commentary_message(str)`` — fires once per completed
|
||||
``phase=commentary`` message, before any following tool item executes.
|
||||
* ``on_reasoning_delta(str)`` — fires per ``response.reasoning.*.delta``.
|
||||
* ``on_first_delta()`` — one-shot, fires on the first text delta only.
|
||||
* ``on_event(event)`` — fires for every event before any other processing.
|
||||
Used for watchdog activity, debug logging, anything wire-shape-agnostic.
|
||||
@@ -1007,8 +500,6 @@ def _consume_codex_event_stream(
|
||||
collected_text_deltas: List[str] = []
|
||||
has_tool_calls = False
|
||||
first_delta_fired = False
|
||||
active_message_phase: str | None = None
|
||||
commentary_text_deltas: List[str] = []
|
||||
terminal_status: str = "completed"
|
||||
terminal_usage: Any = None
|
||||
terminal_response_id: str = None
|
||||
@@ -1042,43 +533,9 @@ def _consume_codex_event_stream(
|
||||
if event_type == "error":
|
||||
_raise_stream_error(event)
|
||||
|
||||
# Track the phase of the active streamed message item. Codex/Harmony
|
||||
# ``commentary``/``analysis`` text is mid-turn preamble/progress
|
||||
# narration, never the final answer. We still collect completed output
|
||||
# items for replay, but route those deltas to the reasoning callback so
|
||||
# they display like thinking text instead of assistant content.
|
||||
if event_type == "response.output_item.added":
|
||||
item = _event_field(event, "item")
|
||||
item_type = _item_field(item, "type", "")
|
||||
if item_type == "message":
|
||||
phase = _item_field(item, "phase", None)
|
||||
active_message_phase = phase.strip().lower() if isinstance(phase, str) else None
|
||||
if active_message_phase == "commentary":
|
||||
commentary_text_deltas = []
|
||||
else:
|
||||
active_message_phase = None
|
||||
if "function_call" in str(item_type):
|
||||
has_tool_calls = True
|
||||
continue
|
||||
|
||||
if "output_text.delta" in event_type or event_type == "response.output_text.delta":
|
||||
delta_text = _event_field(event, "delta", "")
|
||||
if delta_text and active_message_phase == "commentary":
|
||||
commentary_text_deltas.append(delta_text)
|
||||
# Preserve CLI/backward compatibility when no first-class
|
||||
# commentary consumer is installed.
|
||||
if on_commentary_message is None and on_reasoning_delta is not None:
|
||||
try:
|
||||
on_reasoning_delta(delta_text)
|
||||
except Exception:
|
||||
logger.debug("Codex stream on_reasoning_delta raised", exc_info=True)
|
||||
elif delta_text and active_message_phase == "analysis":
|
||||
if on_reasoning_delta is not None:
|
||||
try:
|
||||
on_reasoning_delta(delta_text)
|
||||
except Exception:
|
||||
logger.debug("Codex stream on_reasoning_delta raised", exc_info=True)
|
||||
elif delta_text:
|
||||
if delta_text:
|
||||
collected_text_deltas.append(delta_text)
|
||||
if not has_tool_calls:
|
||||
if not first_delta_fired:
|
||||
@@ -1112,27 +569,6 @@ def _consume_codex_event_stream(
|
||||
done_item = _event_field(event, "item")
|
||||
if done_item is not None:
|
||||
collected_output_items.append(done_item)
|
||||
done_phase = _item_field(done_item, "phase", None)
|
||||
done_phase = done_phase.strip().lower() if isinstance(done_phase, str) else None
|
||||
if done_phase == "commentary" and on_commentary_message is not None:
|
||||
commentary_text = "".join(commentary_text_deltas).strip()
|
||||
if not commentary_text:
|
||||
content_parts = _item_field(done_item, "content", [])
|
||||
if isinstance(content_parts, list):
|
||||
commentary_text = "".join(
|
||||
str(_item_field(part, "text", "") or "")
|
||||
for part in content_parts
|
||||
if _item_field(part, "type", "") == "output_text"
|
||||
).strip()
|
||||
if commentary_text:
|
||||
try:
|
||||
on_commentary_message(commentary_text)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Codex stream on_commentary_message raised",
|
||||
exc_info=True,
|
||||
)
|
||||
commentary_text_deltas = []
|
||||
continue
|
||||
|
||||
if event_type in _TERMINAL_EVENT_TYPES:
|
||||
@@ -1233,14 +669,14 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
|
||||
def _on_reasoning_delta(text: str) -> None:
|
||||
agent._fire_reasoning_delta(text)
|
||||
|
||||
def _on_commentary_message(text: str) -> None:
|
||||
agent._fire_streamed_codex_commentary(text)
|
||||
|
||||
def _on_event(event: Any) -> None:
|
||||
# TTFB watchdog and activity touch — runs once per SSE event.
|
||||
agent._codex_stream_last_event_ts = time.time()
|
||||
agent._touch_activity("receiving stream response")
|
||||
|
||||
def _interrupt_check() -> bool:
|
||||
return bool(agent._interrupt_requested)
|
||||
|
||||
for attempt in range(max_stream_retries + 1):
|
||||
if agent._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted before Codex stream retry")
|
||||
@@ -1260,27 +696,6 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
|
||||
continue
|
||||
raise
|
||||
|
||||
# Claim the delta sink for THIS attempt (#65991) — parity with the
|
||||
# chat_completions/anthropic/bedrock paths. If a prior attempt's
|
||||
# stream is somehow still alive, this claim supersedes it so its
|
||||
# late deltas are fenced out of the turn; conversely, a newer
|
||||
# attempt supersedes us and the interrupt_check below stops our
|
||||
# consumption immediately.
|
||||
_writer_token = claim_stream_writer(agent)
|
||||
|
||||
def _interrupt_or_superseded(_tok=_writer_token) -> bool:
|
||||
if agent._interrupt_requested:
|
||||
return True
|
||||
if not stream_writer_is_current(agent, _tok):
|
||||
logger.warning(
|
||||
"Codex streaming attempt superseded by a newer stream; "
|
||||
"stopping consumption to preserve the single-writer "
|
||||
"invariant (model=%s).",
|
||||
api_kwargs.get("model", "unknown"),
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
try:
|
||||
# Compatibility: some mocks/providers return a concrete response
|
||||
# instead of an iterable. Pass it straight through.
|
||||
@@ -1293,17 +708,9 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
|
||||
model=api_kwargs.get("model"),
|
||||
on_text_delta=_on_text_delta,
|
||||
on_reasoning_delta=_on_reasoning_delta,
|
||||
on_commentary_message=(
|
||||
_on_commentary_message
|
||||
if (
|
||||
getattr(agent, "interim_assistant_callback", None) is not None
|
||||
and getattr(agent, "show_commentary", True)
|
||||
)
|
||||
else None
|
||||
),
|
||||
on_first_delta=on_first_delta,
|
||||
on_event=_on_event,
|
||||
interrupt_check=_interrupt_or_superseded,
|
||||
interrupt_check=_interrupt_check,
|
||||
)
|
||||
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
|
||||
if attempt < max_stream_retries:
|
||||
@@ -1352,5 +759,4 @@ __all__ = [
|
||||
"run_codex_stream",
|
||||
"run_codex_create_stream_fallback",
|
||||
"_consume_codex_event_stream",
|
||||
"make_codex_app_server_event_bridge",
|
||||
]
|
||||
|
||||
+15
-19
@@ -55,12 +55,12 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_cli._subprocess_compat import bounded_git_probe
|
||||
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
|
||||
|
||||
logger = logging.getLogger("hermes.coding_context")
|
||||
|
||||
@@ -412,18 +412,10 @@ 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 or (temp_root is not None and parent == temp_root):
|
||||
if parent == home:
|
||||
continue
|
||||
for marker in _PROJECT_MARKERS:
|
||||
if (parent / marker).exists():
|
||||
@@ -688,14 +680,18 @@ def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.
|
||||
|
||||
Uses the shared :func:`bounded_git_probe` so the post-kill cleanup is bounded
|
||||
on Windows — a plain ``subprocess.run(timeout=...)`` here deadlocked the agent
|
||||
turn inside ``build_coding_workspace_block`` when a killed git left a suspended
|
||||
descendant holding the pipe handles (issue #66037).
|
||||
"""
|
||||
return bounded_git_probe(["git", "-C", str(cwd), *args], timeout=_GIT_TIMEOUT)
|
||||
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(cwd), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
**_popen_kwargs,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return out.stdout.strip() if out.returncode == 0 else ""
|
||||
|
||||
|
||||
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
|
||||
|
||||
+198
-2146
File diff suppressed because it is too large
Load Diff
+5
-57
@@ -26,31 +26,7 @@ Lifecycle:
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
|
||||
MEMORY_CONTEXT_MAX_CHARS = 6_000
|
||||
_MEMORY_CONTEXT_HEAD_CHARS = 4_000
|
||||
_MEMORY_CONTEXT_TAIL_CHARS = 1_500
|
||||
_MEMORY_CONTEXT_TRUNCATION_MARKER = "\n...[memory provider context truncated]...\n"
|
||||
|
||||
|
||||
def sanitize_memory_context(memory_context: str) -> str:
|
||||
"""Prepare provider context for a context-engine/LLM egress boundary."""
|
||||
sanitized = redact_sensitive_text(
|
||||
memory_context.strip(),
|
||||
force=True,
|
||||
redact_url_credentials=True,
|
||||
)
|
||||
if len(sanitized) <= MEMORY_CONTEXT_MAX_CHARS:
|
||||
return sanitized
|
||||
return (
|
||||
sanitized[:_MEMORY_CONTEXT_HEAD_CHARS]
|
||||
+ _MEMORY_CONTEXT_TRUNCATION_MARKER
|
||||
+ sanitized[-_MEMORY_CONTEXT_TAIL_CHARS:]
|
||||
)
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class ContextEngine(ABC):
|
||||
@@ -111,10 +87,8 @@ class ContextEngine(ABC):
|
||||
def compress(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
current_tokens: Optional[int] = None,
|
||||
focus_topic: Optional[str] = None,
|
||||
force: bool = False,
|
||||
memory_context: str = "",
|
||||
current_tokens: int = None,
|
||||
focus_topic: str = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Compact the message list and return the new message list.
|
||||
|
||||
@@ -129,12 +103,6 @@ class ContextEngine(ABC):
|
||||
Engines that support guided compression should prioritise
|
||||
preserving information related to this topic. Engines that
|
||||
don't support it may simply ignore this argument.
|
||||
force: Whether a user-requested compression should bypass an
|
||||
engine-owned cooldown. Engines without cooldowns may ignore it.
|
||||
memory_context: Text returned by memory providers immediately before
|
||||
compaction. Summarizing engines should include non-empty text in
|
||||
their handoff prompt. Older engines may omit this parameter; the
|
||||
host filters unsupported optional arguments by signature.
|
||||
"""
|
||||
|
||||
# -- Optional: pre-flight check ----------------------------------------
|
||||
@@ -226,17 +194,12 @@ class ContextEngine(ABC):
|
||||
|
||||
Default returns the standard fields run_agent.py expects.
|
||||
"""
|
||||
# Clamp the -1 "compression just ran, awaiting real usage" sentinel
|
||||
# (set by conversation_compression) to 0 so status readers don't see a
|
||||
# raw -1 or a negative usage_percent on the transitional turn. Mirrors
|
||||
# the CLI/gateway status-bar paths (cli.py, tui_gateway/server.py).
|
||||
last_prompt = self.last_prompt_tokens if self.last_prompt_tokens > 0 else 0
|
||||
return {
|
||||
"last_prompt_tokens": last_prompt,
|
||||
"last_prompt_tokens": self.last_prompt_tokens,
|
||||
"threshold_tokens": self.threshold_tokens,
|
||||
"context_length": self.context_length,
|
||||
"usage_percent": (
|
||||
min(100, last_prompt / self.context_length * 100)
|
||||
min(100, self.last_prompt_tokens / self.context_length * 100)
|
||||
if self.context_length else 0
|
||||
),
|
||||
"compression_count": self.compression_count,
|
||||
@@ -260,19 +223,4 @@ class ContextEngine(ABC):
|
||||
(e.g. recalculate DAG budgets, switch summary models).
|
||||
"""
|
||||
self.context_length = context_length
|
||||
# Apply per-model threshold overrides if set (longest substring match).
|
||||
# Falls back to _config_threshold_percent (the raw config value) when
|
||||
# no override matches. Plugin engines that override update_model() can
|
||||
# call resolve_model_threshold() for the same logic.
|
||||
from agent.context_compressor import resolve_model_threshold
|
||||
if not hasattr(self, "_config_threshold_percent"):
|
||||
# Snapshot the pre-override percent ONCE so repeated model
|
||||
# switches fall back to the engine's configured value, not the
|
||||
# previous model's override.
|
||||
self._config_threshold_percent = self.threshold_percent
|
||||
self._base_threshold_percent = resolve_model_threshold(
|
||||
model, getattr(self, "model_thresholds", {}),
|
||||
self._config_threshold_percent,
|
||||
)
|
||||
self.threshold_percent = self._base_threshold_percent
|
||||
self.threshold_tokens = int(context_length * self.threshold_percent)
|
||||
|
||||
@@ -152,24 +152,13 @@ async def preprocess_context_references_async(
|
||||
blocks: list[str] = []
|
||||
injected_tokens = 0
|
||||
|
||||
# Expand all references concurrently. Each _expand_reference is independent
|
||||
# (no shared state during expansion) — a message with several @url: refs
|
||||
# would otherwise pay one full web_extract round-trip per ref in series.
|
||||
# gather preserves positional order, so we reassemble warnings/blocks in the
|
||||
# original ref order exactly as the prior serial loop did; the token-budget
|
||||
# check below is unchanged (it runs once, after all refs are expanded).
|
||||
expanded = await asyncio.gather(
|
||||
*(
|
||||
_expand_reference(
|
||||
ref,
|
||||
cwd_path,
|
||||
url_fetcher=url_fetcher,
|
||||
allowed_root=allowed_root_path,
|
||||
)
|
||||
for ref in refs
|
||||
for ref in refs:
|
||||
warning, block = await _expand_reference(
|
||||
ref,
|
||||
cwd_path,
|
||||
url_fetcher=url_fetcher,
|
||||
allowed_root=allowed_root_path,
|
||||
)
|
||||
)
|
||||
for warning, block in expanded:
|
||||
if warning:
|
||||
warnings.append(warning)
|
||||
if block:
|
||||
@@ -381,37 +370,6 @@ def _ensure_reference_path_allowed(path: Path) -> None:
|
||||
continue
|
||||
raise ValueError("path is a sensitive credential or internal Hermes path and cannot be attached")
|
||||
|
||||
# Anchor to the canonical read deny-list (agent/file_safety.get_read_block_error),
|
||||
# the single source of truth used by the file/terminal read path. The narrow
|
||||
# list above predates that guard and never caught the real credential stores:
|
||||
# provider keys (auth.json), Anthropic OAuth tokens (.anthropic_oauth.json),
|
||||
# MCP OAuth material (mcp-tokens/), webhook HMAC secrets, and project-local
|
||||
# .env files. That gap matters because the gateway feeds UNTRUSTED remote
|
||||
# message text into reference expansion, so `@file:~/.hermes/auth.json` from a
|
||||
# chat peer would otherwise read the operator's keys straight into context.
|
||||
# Routing through the canonical guard closes the gap today and keeps this path
|
||||
# protected automatically whenever that deny-list grows.
|
||||
try:
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
if get_read_block_error(str(path)) is not None:
|
||||
raise ValueError(
|
||||
"path is a sensitive credential or internal Hermes path and cannot be attached"
|
||||
)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception:
|
||||
# Fail CLOSED on the security path. This guard exists specifically to
|
||||
# cover credential stores the narrow list above misses (auth.json,
|
||||
# .anthropic_oauth.json, mcp-tokens/, ...). If the canonical lookup
|
||||
# ever fails, silently falling through would re-open that exact hole —
|
||||
# the gateway feeds untrusted remote text here, so a probe could then
|
||||
# attach the operator's keys. Refuse instead: a spurious block on a
|
||||
# legitimate file is a recoverable annoyance; a leaked credential is not.
|
||||
raise ValueError(
|
||||
"path could not be verified against the credential deny-list and cannot be attached"
|
||||
)
|
||||
|
||||
|
||||
def _strip_trailing_punctuation(value: str) -> str:
|
||||
stripped = value.rstrip(TRAILING_PUNCTUATION)
|
||||
|
||||
+337
-1549
File diff suppressed because it is too large
Load Diff
+193
-1310
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ from openai.types.chat.chat_completion_message_tool_call import (
|
||||
Function,
|
||||
)
|
||||
|
||||
from agent.file_safety import get_read_block_error, get_write_denied_error
|
||||
from agent.file_safety import get_read_block_error, is_write_denied
|
||||
from agent.redact import redact_sensitive_text
|
||||
from tools.environments.local import hermes_subprocess_env
|
||||
|
||||
@@ -727,9 +727,10 @@ class CopilotACPClient:
|
||||
elif method == "fs/write_text_file":
|
||||
try:
|
||||
path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
|
||||
denied = get_write_denied_error(str(path))
|
||||
if denied:
|
||||
raise PermissionError(denied)
|
||||
if is_write_denied(str(path)):
|
||||
raise PermissionError(
|
||||
f"Write denied: '{path}' is a protected system/credential file."
|
||||
)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(str(params.get("content") or ""))
|
||||
response = {
|
||||
|
||||
@@ -22,7 +22,7 @@ _PERSISTABLE_PROVIDER_SOURCES = frozenset({
|
||||
("minimax-oauth", "oauth"),
|
||||
("nous", "device_code"),
|
||||
("openai-codex", "device_code"),
|
||||
("xai-oauth", "device_code"),
|
||||
("xai-oauth", "loopback_pkce"),
|
||||
})
|
||||
|
||||
_SAFE_SECRETISH_METADATA_KEYS = frozenset({
|
||||
|
||||
+56
-390
@@ -43,19 +43,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_config_safe() -> Optional[dict]:
|
||||
"""Load config.yaml read-only, returning None on any error.
|
||||
|
||||
Uses ``load_config_readonly()``: every consumer in this module only reads
|
||||
(``get_pool_strategy``, ``_iter_custom_providers``, the model-config seed),
|
||||
and the deepcopy that ``load_config()`` pays per call is what made
|
||||
credential-pool checks the dominant cost of ``model.options`` — the picker
|
||||
calls ``load_pool()`` once per provider row, each of which loaded (and
|
||||
deep-copied) the full config again.
|
||||
"""
|
||||
"""Load config.yaml, returning None on any error."""
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
return load_config_readonly()
|
||||
return load_config()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -90,7 +82,7 @@ _TERMINAL_AUTH_REASONS = frozenset({
|
||||
# without losing recoverability — the user always has the option to re-add
|
||||
# via ``hermes auth add``.
|
||||
#
|
||||
# Singleton-seeded entries (``device_code``, ``claude_code``)
|
||||
# Singleton-seeded entries (``device_code``, ``loopback_pkce``, ``claude_code``)
|
||||
# are NOT pruned because ``_seed_from_singletons`` would just re-create them
|
||||
# on the next ``load_pool()`` with the same stale singleton tokens, defeating
|
||||
# the cleanup. They remain in the pool marked DEAD until an explicit re-auth
|
||||
@@ -122,20 +114,6 @@ EXHAUSTED_TTL_401_SECONDS = 5 * 60 # 5 minutes
|
||||
EXHAUSTED_TTL_429_SECONDS = 60 * 60 # 1 hour
|
||||
EXHAUSTED_TTL_DEFAULT_SECONDS = 60 * 60 # 1 hour
|
||||
|
||||
# Throttle window for the "no available entries" INFO line. Credential
|
||||
# selection runs on a hot path (every model call, plus auxiliary tasks like
|
||||
# compression/moa/titles), so when a pool is empty or fully exhausted the
|
||||
# un-throttled log fires on *every* selection. On Windows several Hermes
|
||||
# processes share one rotating log guarded by concurrent-log-handler's
|
||||
# cross-process lock; that per-selection volume storms the lock
|
||||
# (``RuntimeError: Cannot acquire lock after 20 attempts``), pegs a core, and
|
||||
# stalls the asyncio event loop long enough to fail the Desktop backend
|
||||
# readiness handshake ("Timed out connecting to Hermes backend after
|
||||
# 15000ms"). Logging the condition at most once per window preserves the
|
||||
# signal while removing the storm — same class of fix as the warn-once
|
||||
# dedup in #58265.
|
||||
NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS = 60.0
|
||||
|
||||
# Pool key prefix for custom OpenAI-compatible endpoints.
|
||||
# Custom endpoints all share provider='custom' but are keyed by their
|
||||
# custom_providers name: 'custom:<normalized_name>'.
|
||||
@@ -150,17 +128,6 @@ _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
|
||||
@@ -190,11 +157,6 @@ 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:
|
||||
@@ -483,44 +445,6 @@ 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
|
||||
|
||||
|
||||
@@ -565,12 +489,14 @@ def _write_through_provider_state_to_global_root(
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
auth_mod._persist_provider_state_to_store(
|
||||
provider_id,
|
||||
state,
|
||||
global_path,
|
||||
set_active=False,
|
||||
)
|
||||
if global_path.exists():
|
||||
global_store = _load_auth_store(global_path)
|
||||
else:
|
||||
global_store = {}
|
||||
if not isinstance(global_store, dict):
|
||||
return
|
||||
_store_provider_state(global_store, provider_id, dict(state), set_active=False)
|
||||
auth_mod._save_auth_store(global_store, global_path)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.debug(
|
||||
"%s pool refresh: write-through to global root failed: %s",
|
||||
@@ -588,12 +514,6 @@ class CredentialPool:
|
||||
self._lock = threading.Lock()
|
||||
self._active_leases: Dict[str, int] = {}
|
||||
self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL
|
||||
# Monotonic timestamp of the last "no available entries" log, used to
|
||||
# throttle that message so an empty/exhausted pool cannot storm the
|
||||
# shared rotating log (see NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS).
|
||||
# Re-armed to None on every successful selection so a recover→re-exhaust
|
||||
# transition logs promptly instead of being swallowed by a stale window.
|
||||
self._last_no_entries_log_at: Optional[float] = None
|
||||
|
||||
def has_credentials(self) -> bool:
|
||||
return bool(self._entries)
|
||||
@@ -696,32 +616,17 @@ class CredentialPool:
|
||||
file_refresh = creds.get("refreshToken", "")
|
||||
file_access = creds.get("accessToken", "")
|
||||
file_expires = creds.get("expiresAt", 0)
|
||||
# Sync when either token changed. Access tokens can be re-issued
|
||||
# without a new refresh token (silent re-issue path), so checking
|
||||
# only refresh_token misses that case and leaves a stale
|
||||
# access_token in the pool → 401 on every request until the pool
|
||||
# entry's exhausted TTL expires.
|
||||
entry_access = entry.access_token or ""
|
||||
entry_refresh = entry.refresh_token or ""
|
||||
if (file_access or file_refresh) and (
|
||||
(file_access and file_access != entry_access)
|
||||
or (file_refresh and file_refresh != entry_refresh)
|
||||
):
|
||||
logger.debug(
|
||||
"Pool entry %s: syncing tokens from credentials file (tokens changed)",
|
||||
entry.id,
|
||||
)
|
||||
# If the credentials file has a different token pair, sync it
|
||||
if file_refresh and file_refresh != entry.refresh_token:
|
||||
logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
|
||||
updated = replace(
|
||||
entry,
|
||||
access_token=file_access or entry.access_token,
|
||||
refresh_token=file_refresh or entry.refresh_token,
|
||||
expires_at_ms=file_expires or entry.expires_at_ms,
|
||||
access_token=file_access,
|
||||
refresh_token=file_refresh,
|
||||
expires_at_ms=file_expires,
|
||||
last_status=None,
|
||||
last_status_at=None,
|
||||
last_error_code=None,
|
||||
last_error_reason=None,
|
||||
last_error_message=None,
|
||||
last_error_reset_at=None,
|
||||
)
|
||||
self._replace_entry(entry, updated)
|
||||
self._persist()
|
||||
@@ -804,11 +709,11 @@ class CredentialPool:
|
||||
keeps the consumed refresh_token and the next ``_refresh_entry`` call
|
||||
would replay it and get a ``refresh_token_reused``-style 4xx.
|
||||
|
||||
Only applies to entries seeded from the singleton (``device_code``);
|
||||
manually added entries are independent credentials with their own
|
||||
refresh-token lifecycle.
|
||||
Only applies to entries seeded from the singleton (``loopback_pkce``);
|
||||
manually added entries (``manual:xai_pkce``) are independent
|
||||
credentials with their own refresh-token lifecycle.
|
||||
"""
|
||||
if self.provider != "xai-oauth" or entry.source != "device_code":
|
||||
if self.provider != "xai-oauth" or entry.source != "loopback_pkce":
|
||||
return entry
|
||||
try:
|
||||
with _auth_store_lock():
|
||||
@@ -852,45 +757,6 @@ class CredentialPool:
|
||||
logger.debug("Failed to sync xAI OAuth entry from auth.json: %s", exc)
|
||||
return entry
|
||||
|
||||
def _sync_xai_oauth_entry_from_pool_store(
|
||||
self, entry: PooledCredential
|
||||
) -> PooledCredential:
|
||||
"""Adopt a token pair rotated by another pool instance.
|
||||
|
||||
Direct xAI integrations load a fresh ``CredentialPool`` for each
|
||||
request. Their in-memory locks therefore cannot protect xAI's
|
||||
single-use refresh token across concurrent requests or processes.
|
||||
This helper is called while the shared auth-store lock is held and
|
||||
re-reads the exact persisted row before a refresh POST is attempted.
|
||||
"""
|
||||
if self.provider != "xai-oauth":
|
||||
return entry
|
||||
try:
|
||||
persisted = next(
|
||||
(
|
||||
payload
|
||||
for payload in read_credential_pool(self.provider)
|
||||
if isinstance(payload, dict) and payload.get("id") == entry.id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not isinstance(persisted, dict):
|
||||
return entry
|
||||
stored = PooledCredential.from_dict(self.provider, persisted)
|
||||
if (
|
||||
stored.access_token != entry.access_token
|
||||
or stored.refresh_token != entry.refresh_token
|
||||
):
|
||||
logger.debug(
|
||||
"Pool entry %s: adopting xAI OAuth tokens rotated by another pool instance",
|
||||
entry.id,
|
||||
)
|
||||
self._replace_entry(entry, stored)
|
||||
return stored
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to sync xAI OAuth entry from credential pool: %s", exc)
|
||||
return entry
|
||||
|
||||
def _sync_nous_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential:
|
||||
"""Sync a Nous pool entry from auth.json if tokens differ.
|
||||
|
||||
@@ -987,9 +853,8 @@ class CredentialPool:
|
||||
"""
|
||||
# Only sync entries that were seeded *from* a singleton. Manually
|
||||
# added pool entries (source="manual:*") are independent credentials
|
||||
# and must not write back to the singleton. All singleton-seeded
|
||||
# device-code sources (nous, openai-codex, xAI) use ``device_code``.
|
||||
if entry.source != "device_code":
|
||||
# and must not write back to the singleton.
|
||||
if entry.source not in {"device_code", "loopback_pkce"}:
|
||||
return
|
||||
try:
|
||||
with _auth_store_lock():
|
||||
@@ -1084,61 +949,6 @@ class CredentialPool:
|
||||
self._mark_exhausted(entry, None)
|
||||
return None
|
||||
|
||||
# Codex and xAI OAuth refresh tokens are single-use. The
|
||||
# sync→POST→write-back sequence below must run atomically across Hermes
|
||||
# processes: otherwise two processes can both adopt the same on-disk
|
||||
# token, both POST it, and the loser gets ``refresh_token_reused``.
|
||||
# Serialize the whole sequence through the shared cross-process
|
||||
# auth-store flock (the same lock and extended-timeout pattern used by
|
||||
# resolve_codex_runtime_credentials()). When a waiter finally acquires
|
||||
# the lock, the in-lock re-sync below picks up the rotated token the
|
||||
# winner persisted and skips the POST.
|
||||
if self.provider in ("openai-codex", "xai-oauth"):
|
||||
sync_entry = (
|
||||
self._sync_codex_entry_from_auth_store
|
||||
if self.provider == "openai-codex"
|
||||
else self._sync_xai_oauth_entry_from_pool_store
|
||||
)
|
||||
with _auth_store_lock(
|
||||
timeout_seconds=self._single_use_refresh_lock_timeout()
|
||||
):
|
||||
synced = sync_entry(entry)
|
||||
if self.provider == "openai-codex":
|
||||
if synced is not entry:
|
||||
entry = synced
|
||||
if not force and not self._entry_needs_refresh(entry):
|
||||
return entry
|
||||
return self._refresh_entry_impl(entry, force=force)
|
||||
if (
|
||||
synced.access_token != entry.access_token
|
||||
or synced.refresh_token != entry.refresh_token
|
||||
):
|
||||
return synced
|
||||
return self._refresh_entry_impl(synced, force=force)
|
||||
return self._refresh_entry_impl(entry, force=force)
|
||||
|
||||
def _single_use_refresh_lock_timeout(self) -> float:
|
||||
"""Lock timeout for single-use-refresh-token providers.
|
||||
|
||||
Covers the configured refresh POST timeout plus a margin so a slow
|
||||
token endpoint cannot make the flock give up before the refresh
|
||||
resolves. Reads the provider's ``HERMES_*_REFRESH_TIMEOUT_SECONDS``
|
||||
override.
|
||||
"""
|
||||
env_var = (
|
||||
"HERMES_CODEX_REFRESH_TIMEOUT_SECONDS"
|
||||
if self.provider == "openai-codex"
|
||||
else "HERMES_XAI_REFRESH_TIMEOUT_SECONDS"
|
||||
)
|
||||
refresh_timeout_seconds = auth_mod.env_float(env_var, 20)
|
||||
return max(
|
||||
float(auth_mod.AUTH_LOCK_TIMEOUT_SECONDS),
|
||||
float(refresh_timeout_seconds) + 5.0,
|
||||
)
|
||||
|
||||
def _refresh_entry_impl(
|
||||
self, entry: PooledCredential, *, force: bool
|
||||
) -> Optional[PooledCredential]:
|
||||
try:
|
||||
if self.provider == "anthropic":
|
||||
from agent.anthropic_adapter import refresh_anthropic_oauth_pure
|
||||
@@ -1259,8 +1069,8 @@ class CredentialPool:
|
||||
# consumed the refresh token between our proactive sync and the
|
||||
# HTTP call. Re-check auth.json and adopt the fresh tokens if
|
||||
# they have rotated since. Only meaningful for singleton-seeded
|
||||
# (device_code) entries; manual entries don't share
|
||||
# state with the singleton.
|
||||
# (loopback_pkce) entries; manual entries don't share state with
|
||||
# the singleton.
|
||||
if self.provider == "xai-oauth":
|
||||
synced = self._sync_xai_oauth_entry_from_auth_store(entry)
|
||||
if synced.refresh_token != entry.refresh_token:
|
||||
@@ -1282,8 +1092,8 @@ class CredentialPool:
|
||||
# Terminal error: auth.json has no newer tokens — the stored
|
||||
# refresh_token is dead. Clear it from auth.json so the next
|
||||
# session does not re-seed the same revoked credentials, and
|
||||
# remove all singleton-seeded xAI entries from the in-memory
|
||||
# pool. Mirrors the Nous quarantine path above.
|
||||
# remove all singleton-seeded (loopback_pkce) entries from the
|
||||
# in-memory pool. Mirrors the Nous quarantine path above.
|
||||
if auth_mod._is_terminal_xai_oauth_refresh_error(exc):
|
||||
logger.debug(
|
||||
"xAI OAuth refresh token is terminally invalid; clearing local token state"
|
||||
@@ -1317,11 +1127,11 @@ class CredentialPool:
|
||||
)
|
||||
removed_ids = [
|
||||
item.id for item in self._entries
|
||||
if item.source == "device_code"
|
||||
if item.source == "loopback_pkce"
|
||||
]
|
||||
self._entries = [
|
||||
item for item in self._entries
|
||||
if item.source != "device_code"
|
||||
if item.source != "loopback_pkce"
|
||||
]
|
||||
if self._current_id == entry.id:
|
||||
self._current_id = None
|
||||
@@ -1484,43 +1294,6 @@ class CredentialPool:
|
||||
self._sync_device_code_entry_to_auth_store(updated)
|
||||
return updated
|
||||
|
||||
def _codex_quota_restored_upstream(self, entry: PooledCredential) -> bool:
|
||||
"""Live-check whether an exhausted Codex entry's quota reset early.
|
||||
|
||||
A Codex 429 persists a ``last_error_reset_at`` that can be days in
|
||||
the future (weekly windows), but the upstream window can reopen
|
||||
before then — the user redeems a banked rate-limit reset via the
|
||||
Codex CLI / ChatGPT UI, upgrades their plan, or OpenAI resets the
|
||||
window. Without this check the pool keeps the credential frozen
|
||||
until the stale timestamp elapses even though the account is
|
||||
usable (issue #43747).
|
||||
|
||||
Only fires for openai-codex entries frozen by a 429/quota-shaped
|
||||
error. The underlying probe is throttled per token (5 min) so this
|
||||
is safe on the hot selection path.
|
||||
"""
|
||||
if self.provider != "openai-codex" or entry.last_status != STATUS_EXHAUSTED:
|
||||
return False
|
||||
if not auth_mod._is_codex_rate_limit_shaped(
|
||||
entry.last_error_code,
|
||||
entry.last_error_reason,
|
||||
entry.last_error_message,
|
||||
):
|
||||
return False
|
||||
token = entry.access_token or ""
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
return bool(
|
||||
auth_mod._probe_codex_quota_restored(
|
||||
token,
|
||||
base_url=entry.base_url,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Codex quota-restored probe failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
|
||||
if entry.auth_type != AUTH_TYPE_OAUTH:
|
||||
return False
|
||||
@@ -1536,7 +1309,7 @@ class CredentialPool:
|
||||
if self.provider == "xai-oauth":
|
||||
return auth_mod._xai_access_token_is_expiring(
|
||||
entry.access_token,
|
||||
auth_mod._xai_proactive_refresh_skew_seconds(entry.access_token),
|
||||
auth_mod.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
)
|
||||
if self.provider == "nous":
|
||||
# Nous refresh can require network access and should happen when
|
||||
@@ -1561,11 +1334,6 @@ 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).
|
||||
@@ -1603,7 +1371,7 @@ class CredentialPool:
|
||||
# tokens that another process (or a fresh `hermes model` ->
|
||||
# xAI Grok OAuth login) has since rotated in auth.json.
|
||||
if (self.provider == "xai-oauth"
|
||||
and entry.source == "device_code"
|
||||
and entry.source == "loopback_pkce"
|
||||
and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}):
|
||||
synced = self._sync_xai_oauth_entry_from_auth_store(entry)
|
||||
if synced is not entry:
|
||||
@@ -1642,18 +1410,7 @@ class CredentialPool:
|
||||
if entry.last_status == STATUS_EXHAUSTED:
|
||||
exhausted_until = _exhausted_until(entry)
|
||||
if exhausted_until is not None and now < exhausted_until:
|
||||
# Codex quota windows can reopen EARLY: the user redeems a
|
||||
# banked rate-limit reset (Codex CLI / ChatGPT UI), upgrades
|
||||
# their plan, or OpenAI resets the window. The persisted
|
||||
# ``last_error_reset_at`` can then be days in the future
|
||||
# while the account is already usable again — a throttled
|
||||
# live probe of the Codex usage endpoint detects that and
|
||||
# lifts the stale cooldown (issue #43747).
|
||||
if not (
|
||||
clear_expired
|
||||
and self._codex_quota_restored_upstream(entry)
|
||||
):
|
||||
continue
|
||||
continue
|
||||
if clear_expired:
|
||||
cleared = replace(
|
||||
entry,
|
||||
@@ -1680,32 +1437,13 @@ class CredentialPool:
|
||||
self._persist(removed_ids=entries_to_prune)
|
||||
return available
|
||||
|
||||
def _log_no_available_entries(self) -> None:
|
||||
"""Emit the empty-pool INFO line at most once per throttle window.
|
||||
|
||||
Called on every selection while the pool is empty/exhausted. Without
|
||||
throttling this storms the Windows cross-process log lock and stalls the
|
||||
event loop (see NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS).
|
||||
"""
|
||||
now = time.monotonic()
|
||||
last = self._last_no_entries_log_at
|
||||
if last is not None and (now - last) < NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS:
|
||||
return
|
||||
self._last_no_entries_log_at = now
|
||||
logger.info("credential pool: no available entries (all exhausted or empty)")
|
||||
|
||||
def _select_unlocked(self, *, refresh: bool = True) -> Optional[PooledCredential]:
|
||||
available = self._available_entries(clear_expired=True, refresh=refresh)
|
||||
def _select_unlocked(self) -> Optional[PooledCredential]:
|
||||
available = self._available_entries(clear_expired=True, refresh=True)
|
||||
if not available:
|
||||
self._current_id = None
|
||||
self._log_no_available_entries()
|
||||
logger.info("credential pool: no available entries (all exhausted or empty)")
|
||||
return None
|
||||
|
||||
# A successful selection means the pool recovered; re-arm the throttle
|
||||
# so a later re-exhaustion logs immediately rather than being silenced
|
||||
# by a window opened during the previous empty stretch.
|
||||
self._last_no_entries_log_at = None
|
||||
|
||||
if self._strategy == STRATEGY_RANDOM:
|
||||
entry = random.choice(available)
|
||||
self._current_id = entry.id
|
||||
@@ -1829,35 +1567,6 @@ class CredentialPool:
|
||||
with self._lock:
|
||||
return self._try_refresh_current_unlocked()
|
||||
|
||||
def try_refresh_matching(
|
||||
self, api_key_hint: Optional[str] = None
|
||||
) -> Optional[PooledCredential]:
|
||||
"""Force-refresh the entry that supplied ``api_key_hint``.
|
||||
|
||||
Direct provider integrations may reload the pool after a request has
|
||||
already failed, so they cannot rely on ``current_id`` identifying the
|
||||
issuing credential. With no hint, select an entry without first doing
|
||||
the normal proactive refresh; the forced refresh below must consume a
|
||||
rotating refresh token exactly once.
|
||||
"""
|
||||
with self._lock:
|
||||
entry = None
|
||||
if api_key_hint:
|
||||
entry = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in self._entries
|
||||
if candidate.runtime_api_key == api_key_hint
|
||||
),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
entry = self.current() or self._select_unlocked(refresh=False)
|
||||
if entry is None:
|
||||
return None
|
||||
self._current_id = entry.id
|
||||
return self._try_refresh_current_unlocked()
|
||||
|
||||
def _try_refresh_current_unlocked(self) -> Optional[PooledCredential]:
|
||||
entry = self.current()
|
||||
if entry is None:
|
||||
@@ -1941,15 +1650,11 @@ class CredentialPool:
|
||||
|
||||
|
||||
def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, payload: Dict[str, Any]) -> bool:
|
||||
matching_indices = []
|
||||
existing_idx = None
|
||||
for idx, entry in enumerate(entries):
|
||||
if entry.source == source:
|
||||
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]
|
||||
existing_idx = idx
|
||||
break
|
||||
|
||||
if existing_idx is None:
|
||||
payload.setdefault("id", uuid.uuid4().hex[:6])
|
||||
@@ -1981,8 +1686,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 bool(duplicate_indices) or existing.to_dict() != updated.to_dict()
|
||||
return bool(duplicate_indices)
|
||||
return existing.to_dict() != updated.to_dict()
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_pool_priorities(provider: str, entries: List[PooledCredential]) -> bool:
|
||||
@@ -2179,16 +1884,11 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token
|
||||
token, source = resolve_copilot_token()
|
||||
if token:
|
||||
api_token, enterprise_base_url = get_copilot_api_token(token)
|
||||
api_token = get_copilot_api_token(token)
|
||||
source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}"
|
||||
if not _is_suppressed(provider, source_name):
|
||||
active_sources.add(source_name)
|
||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||
# Use enterprise base URL from token exchange if available,
|
||||
# otherwise fall back to the provider's default.
|
||||
effective_base_url = enterprise_base_url or (
|
||||
pconfig.inference_base_url if pconfig else ""
|
||||
)
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
@@ -2197,7 +1897,7 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
"source": source_name,
|
||||
"auth_type": AUTH_TYPE_API_KEY,
|
||||
"access_token": api_token,
|
||||
"base_url": effective_base_url,
|
||||
"base_url": pconfig.inference_base_url if pconfig else "",
|
||||
"label": source,
|
||||
},
|
||||
)
|
||||
@@ -2316,30 +2016,28 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
|
||||
# (``providers["xai-oauth"]``). Surface them in the pool too so
|
||||
# ``hermes auth list`` reflects the logged-in state and so the pool
|
||||
# is the single source of truth for refresh during runtime resolution.
|
||||
if _is_suppressed(provider, "loopback_pkce"):
|
||||
return changed, active_sources
|
||||
|
||||
state = _load_provider_state(auth_store, "xai-oauth")
|
||||
tokens = state.get("tokens") if isinstance(state, dict) else None
|
||||
if isinstance(tokens, dict) and tokens.get("access_token"):
|
||||
# Device code is the only supported xAI OAuth flow; the singleton is
|
||||
# always surfaced as ``device_code`` (consistent with nous/codex).
|
||||
source = "device_code"
|
||||
if _is_suppressed(provider, source):
|
||||
return changed, active_sources
|
||||
active_sources.add(source)
|
||||
active_sources.add("loopback_pkce")
|
||||
from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
|
||||
|
||||
base_url = DEFAULT_XAI_OAUTH_BASE_URL
|
||||
changed |= _upsert_entry(
|
||||
entries,
|
||||
provider,
|
||||
source,
|
||||
"loopback_pkce",
|
||||
{
|
||||
"source": source,
|
||||
"source": "loopback_pkce",
|
||||
"auth_type": AUTH_TYPE_OAUTH,
|
||||
"access_token": tokens.get("access_token", ""),
|
||||
"refresh_token": tokens.get("refresh_token"),
|
||||
"base_url": base_url,
|
||||
"last_refresh": state.get("last_refresh"),
|
||||
"label": label_from_token(tokens.get("access_token", ""), source),
|
||||
"label": label_from_token(tokens.get("access_token", ""), "loopback_pkce"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2356,21 +2054,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
# changes to the .env file.
|
||||
def _get_env_prefer_dotenv(key: str) -> str:
|
||||
env_file = load_env()
|
||||
raw = env_file.get(key, "").strip()
|
||||
scoped_value = (_get_secret(key, "") or "").strip()
|
||||
# If .env contains an unresolved op:// reference, prefer the
|
||||
# already-resolved value supplied by the active secret scope (or by
|
||||
# os.environ in legacy single-profile mode), set by
|
||||
# load_hermes_dotenv() -> apply_onepassword_secrets()). The raw
|
||||
# "op://Vault/Item/field" string would otherwise win and every
|
||||
# provider auth attempt would receive a URL instead of a key. This
|
||||
# happens during a partial migration, or when the user wrote op://
|
||||
# references straight into .env rather than the secrets.onepassword
|
||||
# config block. For every non-op:// value the original
|
||||
# .env-takes-precedence behaviour is preserved unchanged.
|
||||
if raw.startswith("op://") and scoped_value:
|
||||
return scoped_value
|
||||
return raw or scoped_value
|
||||
val = env_file.get(key) or _get_secret(key, "") or ""
|
||||
return val.strip()
|
||||
|
||||
# Honour user suppression — `hermes auth remove <provider> <N>` for an
|
||||
# env-seeded credential marks the env:<VAR> source as suppressed so it
|
||||
@@ -2457,6 +2142,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
if _is_source_suppressed(provider, source):
|
||||
continue
|
||||
active_sources.add(source)
|
||||
auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") 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)
|
||||
@@ -2471,6 +2157,7 @@ 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
|
||||
@@ -2598,37 +2285,16 @@ 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 raw_needs_auth_normalization or custom_changed
|
||||
changed = raw_needs_sanitization 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 raw_needs_auth_normalization
|
||||
or singleton_changed
|
||||
or env_changed
|
||||
)
|
||||
changed = raw_needs_sanitization or singleton_changed or env_changed
|
||||
# ``load_pool()`` is a non-destructive read for env-seeded entries: a
|
||||
# process missing a provider env var must not delete the persisted
|
||||
# pool entry for every other process (#9331). File-backed singletons
|
||||
|
||||
@@ -265,7 +265,7 @@ def _remove_minimax_oauth(provider: str, removed) -> RemovalResult:
|
||||
return result
|
||||
|
||||
|
||||
def _remove_xai_oauth_device_code(provider: str, removed) -> RemovalResult:
|
||||
def _remove_xai_oauth_loopback_pkce(provider: str, removed) -> RemovalResult:
|
||||
"""xAI OAuth tokens live in auth.json providers.xai-oauth — clear them.
|
||||
|
||||
Without this step, ``hermes auth remove xai-oauth <N>`` silently undoes
|
||||
@@ -275,6 +275,11 @@ def _remove_xai_oauth_device_code(provider: str, removed) -> RemovalResult:
|
||||
entry from the still-present singleton — credentials reappear with no
|
||||
user feedback. Clearing the singleton in step with the suppression set
|
||||
by the central dispatcher makes the removal stick.
|
||||
|
||||
Belt-and-braces against the manual entry path: ``hermes auth add
|
||||
xai-oauth`` produces a ``manual:xai_pkce`` entry whose removal step
|
||||
falls through to "unregistered → nothing to clean up" (correct —
|
||||
manual entries are pool-only).
|
||||
"""
|
||||
result = RemovalResult()
|
||||
if _clear_auth_store_provider(provider):
|
||||
@@ -418,8 +423,8 @@ def _register_all_sources() -> None:
|
||||
description="auth.json providers.openai-codex + ~/.codex/auth.json",
|
||||
))
|
||||
register(RemovalStep(
|
||||
provider="xai-oauth", source_id="device_code",
|
||||
remove_fn=_remove_xai_oauth_device_code,
|
||||
provider="xai-oauth", source_id="loopback_pkce",
|
||||
remove_fn=_remove_xai_oauth_loopback_pkce,
|
||||
description="auth.json providers.xai-oauth",
|
||||
))
|
||||
register(RemovalStep(
|
||||
|
||||
@@ -316,21 +316,12 @@ def evaluate_credits_notices(
|
||||
active.discard(CREDITS_USAGE_KEY)
|
||||
if target_band is not None:
|
||||
# Belt-and-suspenders: a producer could set subscription_limit_micros
|
||||
# without subscription_limit_usd. Render "$?" rather than "$None".
|
||||
# without subscription_limit_usd. Render "$? cap" rather than "$None cap".
|
||||
_cap_usd = state.subscription_limit_usd or "?"
|
||||
_level = current_band[1] # type: ignore[index] (current_band set when target_band set)
|
||||
# Report absolute dollars used, not a bare "N% used": the percentage is
|
||||
# only meaningful against a Nous subscription cap (no cap → never fires),
|
||||
# so dollars are clearer and don't imply a universal %. Used = cap −
|
||||
# remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band
|
||||
# change (50 → 75 → 90), not every turn — a snapshot, not a live ticker.
|
||||
_lim = state.subscription_limit_micros or 0
|
||||
_used_micros = max(0, min(_lim, _lim - state.subscription_micros))
|
||||
_used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?"
|
||||
_glyph = "⚠" if _level == "warn" else "•"
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap",
|
||||
text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap",
|
||||
level=_level,
|
||||
kind=CREDITS_NOTICE_KIND,
|
||||
key=CREDITS_USAGE_KEY,
|
||||
@@ -364,7 +355,7 @@ def evaluate_credits_notices(
|
||||
if show_depleted and "credits.depleted" not in active:
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✕ Credit access paused · run /topup to top up",
|
||||
text="✕ Credit access paused · run /credits to top up",
|
||||
level="error",
|
||||
kind=CREDITS_NOTICE_KIND,
|
||||
key="credits.depleted",
|
||||
|
||||
+2
-42
@@ -45,26 +45,12 @@ def _strip_aux_credential(value: Any) -> Optional[str]:
|
||||
|
||||
|
||||
class _ReviewRuntimeBinding(NamedTuple):
|
||||
"""Provider/model for the curator review fork plus per-slot overrides."""
|
||||
"""Provider/model for the curator review fork plus optional 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
|
||||
@@ -1778,7 +1764,6 @@ 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)
|
||||
@@ -1796,11 +1781,10 @@ 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]:
|
||||
@@ -1866,11 +1850,6 @@ 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
|
||||
@@ -1888,16 +1867,6 @@ 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)
|
||||
|
||||
@@ -1906,21 +1875,12 @@ 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
|
||||
|
||||
@@ -98,12 +98,7 @@ def _backup_cron_jobs_into(dest: Path) -> Dict[str, Any]:
|
||||
info["reason"] = "no cron/jobs.json present"
|
||||
return info
|
||||
try:
|
||||
# utf-8-sig: same dialect as cron/jobs.load_jobs — a UTF-8 BOM left
|
||||
# by Windows editors otherwise survives decoding as U+FEFF, breaks
|
||||
# json.loads below, and misreports jobs_count as 0 with a spurious
|
||||
# parse warning. The BOM-less text is also what gets written to the
|
||||
# backup, so a later rollback restores a loadable file.
|
||||
raw = src.read_text(encoding="utf-8-sig")
|
||||
raw = src.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
logger.debug("Failed to read cron/jobs.json for backup: %s", e)
|
||||
info["reason"] = f"read error: {e}"
|
||||
@@ -561,7 +556,7 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
|
||||
if target is None:
|
||||
return (
|
||||
False,
|
||||
"no matching backup found"
|
||||
f"no matching backup found"
|
||||
+ (f" for id '{backup_id}'" if backup_id else "")
|
||||
+ " (use `hermes curator rollback --list` to see available snapshots)",
|
||||
None,
|
||||
|
||||
+7
-91
@@ -27,14 +27,6 @@ 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.
|
||||
@@ -462,14 +454,13 @@ 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 = [str(action) if action else ""]
|
||||
parts = [action]
|
||||
if sid:
|
||||
parts.append(str(sid)[:16])
|
||||
parts.append(sid[:16])
|
||||
if data:
|
||||
parts.append(f'"{_oneline(str(data)[:20])}"')
|
||||
parts.append(f'"{_oneline(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":
|
||||
@@ -524,16 +515,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
msg = msg[:17] + "..."
|
||||
return f"to {target}: \"{msg}\""
|
||||
|
||||
if tool_name == "skill_view":
|
||||
name = _oneline(str(args.get("name") or ""))
|
||||
file_path = args.get("file_path")
|
||||
if file_path:
|
||||
file_path = _oneline(str(file_path))
|
||||
preview = f"{name} → {file_path}" if name else file_path
|
||||
else:
|
||||
preview = name
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
key = primary_args.get(tool_name)
|
||||
if not key:
|
||||
for fallback_key in ("query", "text", "command", "path", "name", "prompt", "code", "goal"):
|
||||
@@ -645,52 +626,6 @@ def verb_drops_preview(tool_name: str) -> bool:
|
||||
return tool_name in _TOOL_VERBS_NO_PREVIEW
|
||||
|
||||
|
||||
def build_status_phrase(tool_name: str, args: dict | None, max_len: int = 49) -> str | None:
|
||||
"""Build a short present-tense status phrase for platform status surfaces.
|
||||
|
||||
Used by text-rendering "typing" indicators (Slack's
|
||||
``assistant.threads.setStatus`` line) to show what the agent is doing
|
||||
right now: ``is running scripts/run_tests.sh…`` instead of a static
|
||||
``is thinking...``. The phrase is phrased to follow the bot's display
|
||||
name ("Hermes is running …"), so it starts lowercase with "is".
|
||||
|
||||
Pass ``args=None`` for a verb-only phrase (``is running…``) — used when
|
||||
``display.live_status`` is ``verb`` to keep argument previews out of
|
||||
shared channels.
|
||||
|
||||
Returns None for the ``_thinking`` pseudo-tool and when friendly labels
|
||||
are disabled (callers fall back to their static default). ``max_len``
|
||||
caps the total phrase length; Slack truncates its status line around 50
|
||||
characters, so the default stays just under that.
|
||||
"""
|
||||
if not tool_name or tool_name == "_thinking":
|
||||
return None
|
||||
if not _friendly_tool_labels:
|
||||
return None
|
||||
|
||||
verb = _TOOL_VERBS.get(tool_name)
|
||||
if verb:
|
||||
head = f"is {verb[0].lower()}{verb[1:]}"
|
||||
else:
|
||||
# Custom / plugin / MCP tools: generic but still informative.
|
||||
head = f"is using {tool_name}"
|
||||
|
||||
phrase = head
|
||||
if args and verb and tool_name not in _TOOL_VERBS_NO_PREVIEW:
|
||||
preview = build_tool_preview(tool_name, args, max_len=None)
|
||||
if preview:
|
||||
# Previews can contain newlines (terminal commands); keep the
|
||||
# status to the first line.
|
||||
preview = preview.splitlines()[0].strip()
|
||||
phrase = f"{head}{tool_verb_connector(tool_name)}{preview}"
|
||||
|
||||
if len(phrase) > max_len - 1:
|
||||
phrase = phrase[: max_len - 2].rstrip() + "…"
|
||||
else:
|
||||
phrase = phrase + "…"
|
||||
return phrase
|
||||
|
||||
|
||||
def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
|
||||
"""Build a human-phrased status label for a tool call.
|
||||
|
||||
@@ -1314,7 +1249,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.
|
||||
@@ -1356,11 +1291,9 @@ def _get_cute_tool_message(
|
||||
if tool_name == "web_extract":
|
||||
urls = args.get("urls", [])
|
||||
if urls:
|
||||
url = _display_url(urls[0] if isinstance(urls, list) else urls)
|
||||
if not url:
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
url = urls[0] if isinstance(urls, list) else str(urls)
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else ""
|
||||
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
@@ -1451,11 +1384,7 @@ def _get_cute_tool_message(
|
||||
if tool_name == "skills_list":
|
||||
return _wrap(f"┊ 📚 skills list {args.get('category', 'all')} {dur}")
|
||||
if tool_name == "skill_view":
|
||||
label = args.get("name", "")
|
||||
file_path = args.get("file_path")
|
||||
if file_path:
|
||||
label = f"{label} → {file_path}" if label else str(file_path)
|
||||
return _wrap(f"┊ 📚 skill {_trunc(label, 44)} {dur}")
|
||||
return _wrap(f"┊ 📚 skill {_trunc(args.get('name', ''), 30)} {dur}")
|
||||
if tool_name == "image_generate":
|
||||
return _wrap(f"┊ 🎨 create {_trunc(args.get('prompt', ''), 35)} {dur}")
|
||||
if tool_name == "text_to_speech":
|
||||
@@ -1490,19 +1419,6 @@ 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)
|
||||
# =========================================================================
|
||||
|
||||
+15
-266
@@ -31,9 +31,6 @@ class FailoverReason(enum.Enum):
|
||||
# Billing / quota
|
||||
billing = "billing" # 402 or confirmed credit exhaustion — rotate immediately
|
||||
rate_limit = "rate_limit" # 429 or quota-based throttling — backoff then rotate
|
||||
# Upstream model rate-limited (aggregator 429) — fallback to a different
|
||||
# model, NOT credential rotation. The user's key is healthy.
|
||||
upstream_rate_limit = "upstream_rate_limit"
|
||||
|
||||
# Server-side
|
||||
overloaded = "overloaded" # 503/529 — provider overloaded, backoff
|
||||
@@ -41,11 +38,6 @@ class FailoverReason(enum.Enum):
|
||||
|
||||
# Transport
|
||||
timeout = "timeout" # Connection/read timeout — rebuild client + retry
|
||||
# TLS certificate verification failure — deterministic for the host
|
||||
# (TLS-inspecting proxy, missing/expired CA bundle, self-signed cert).
|
||||
# Retrying reproduces the identical handshake failure, so fail fast
|
||||
# with actionable guidance instead of burning retries.
|
||||
ssl_cert_verification = "ssl_cert_verification"
|
||||
|
||||
# Context / payload
|
||||
context_overflow = "context_overflow" # Context too large — compress, not failover
|
||||
@@ -115,7 +107,6 @@ _BILLING_PATTERNS = [
|
||||
"exceeded your current quota",
|
||||
"account is deactivated",
|
||||
"plan does not include",
|
||||
"out of extra usage", # Anthropic OAuth Pro/Max overage bucket depleted (HTTP 400)
|
||||
"out of funds",
|
||||
"run out of funds",
|
||||
"balance_depleted",
|
||||
@@ -123,25 +114,6 @@ _BILLING_PATTERNS = [
|
||||
"not available on the free tier",
|
||||
]
|
||||
|
||||
# xAI's explicit Grok credit-exhaustion code. Keep the HTTP 403 special case
|
||||
# provider-scoped: other providers' generic billing codes historically remain
|
||||
# auth failures when they arrive as 403.
|
||||
_XAI_SPENDING_LIMIT_ERROR_CODE = "personal-team-blocked:spending-limit"
|
||||
|
||||
# Structured provider codes that mean the account cannot serve paid traffic
|
||||
# until credits/subscription capacity is restored. xAI returns its explicit
|
||||
# Grok spending-limit signal as HTTP 403 rather than 402.
|
||||
_BILLING_ERROR_CODES = frozenset({
|
||||
"insufficient_quota",
|
||||
"billing_not_active",
|
||||
"payment_required",
|
||||
"insufficient_credits",
|
||||
"no_usable_credits",
|
||||
"balance_depleted",
|
||||
"model_not_supported_on_free_tier",
|
||||
_XAI_SPENDING_LIMIT_ERROR_CODE,
|
||||
})
|
||||
|
||||
# Patterns that indicate rate limiting (transient, will resolve)
|
||||
_RATE_LIMIT_PATTERNS = [
|
||||
"rate limit",
|
||||
@@ -269,11 +241,6 @@ _CONTEXT_OVERFLOW_PATTERNS = [
|
||||
"context window",
|
||||
"prompt is too long",
|
||||
"prompt exceeds max length",
|
||||
# NOTE: bare "max_tokens" is load-bearing — the output-cap-retry path keys
|
||||
# off it (e.g. "max_tokens: 65536 > context_window: 200000 ..."). Do NOT
|
||||
# remove it. Provider empty-response advisories also contain "very low
|
||||
# max_tokens", but those are intercepted by _EMPTY_PROVIDER_RESPONSE_PATTERNS
|
||||
# BEFORE this list is consulted, so they never mis-route into compression.
|
||||
"max_tokens",
|
||||
"maximum number of tokens",
|
||||
# vLLM / local inference server patterns
|
||||
@@ -291,8 +258,6 @@ _CONTEXT_OVERFLOW_PATTERNS = [
|
||||
# Chinese error messages (some providers return these)
|
||||
"超过最大长度",
|
||||
"上下文长度",
|
||||
# Z.AI / Zhipu GLM pattern (English form; error code 1210)
|
||||
"tokens in request more than max tokens allowed",
|
||||
# AWS Bedrock Converse API error patterns
|
||||
"input is too long",
|
||||
"max input token",
|
||||
@@ -310,15 +275,6 @@ _MODEL_NOT_FOUND_PATTERNS = [
|
||||
"no such model",
|
||||
"unknown model",
|
||||
"unsupported model",
|
||||
# OpenRouter returns 404 with this message when none of the candidate
|
||||
# endpoints for the selected model support tool/function calling.
|
||||
# Classifying this as model_not_found triggers fallback to a different
|
||||
# model or provider that does support tools. Without this entry the
|
||||
# pattern falls through to ``unknown`` with ``retryable=True``, the
|
||||
# retry loop burns all attempts on the same deterministic rejection,
|
||||
# and the error surfaces as a confusing "model not found" message
|
||||
# instead of automatically failing over. See PR #58446.
|
||||
"no endpoints found that support tool use",
|
||||
]
|
||||
|
||||
# Request-validation patterns — the request is malformed and will fail
|
||||
@@ -431,19 +387,6 @@ _THINKING_SIG_PATTERNS = [
|
||||
# the exception type is generic (e.g. RuntimeError from a local shim that
|
||||
# wraps a subprocess timeout). Checked before the type-based transport
|
||||
# heuristics so custom-provider "timed out" errors don't fall through to
|
||||
# Provider empty-response advisories (OpenRouter / nano-gpt / similar).
|
||||
# Checked before context-overflow matching because the advisory text often
|
||||
# mentions "max_tokens" as a possible cause, which historically sat in
|
||||
# _CONTEXT_OVERFLOW_PATTERNS and sent healthy sessions into a compression
|
||||
# death spiral ending in "Cannot compress further".
|
||||
_EMPTY_PROVIDER_RESPONSE_PATTERNS = [
|
||||
"returned an empty response",
|
||||
"empty response despite retries",
|
||||
"provider returned an empty response",
|
||||
"model returning empty responses",
|
||||
"empty response stream",
|
||||
]
|
||||
|
||||
# the unknown bucket and get misreported as empty responses.
|
||||
_TIMEOUT_MESSAGE_PATTERNS = [
|
||||
"timed out",
|
||||
@@ -491,29 +434,6 @@ _SERVER_DISCONNECT_PATTERNS = [
|
||||
"incomplete chunked read",
|
||||
]
|
||||
|
||||
# SSL certificate verification failures — deterministic, NOT transient.
|
||||
#
|
||||
# A failed certificate chain (TLS-inspecting corporate proxy, missing
|
||||
# custom CA in the trust store, expired certificate, self-signed cert)
|
||||
# fails identically on every retry. Burning the retry budget before
|
||||
# surfacing the error hides the actionable fix from the user for minutes.
|
||||
# Inspired by Claude Code v2.1.199 (July 2026), which made SSL certificate
|
||||
# errors fail immediately with a fix hint instead of retrying.
|
||||
#
|
||||
# Must be checked BEFORE _SSL_TRANSIENT_PATTERNS — "certificate verify
|
||||
# failed" messages usually also contain "[SSL:" which would otherwise
|
||||
# match the transient list and retry forever.
|
||||
_SSL_CERT_VERIFY_PATTERNS = [
|
||||
"certificate verify failed", # Python ssl module canonical text
|
||||
"certificate_verify_failed", # OpenSSL error token
|
||||
"unable to get local issuer certificate",
|
||||
"self-signed certificate",
|
||||
"self signed certificate",
|
||||
"certificate has expired",
|
||||
"hostname mismatch, certificate is not valid",
|
||||
"unable to verify the first certificate", # Node/undici phrasing (MCP bridges)
|
||||
]
|
||||
|
||||
# SSL/TLS transient failure patterns — intentionally distinct from
|
||||
# _SERVER_DISCONNECT_PATTERNS above.
|
||||
#
|
||||
@@ -793,14 +713,6 @@ def classify_api_error(
|
||||
if classified is not None:
|
||||
return classified
|
||||
|
||||
# Local MoA config drift is deterministic: a persisted session can retain
|
||||
# a preset name that was later renamed/deleted. Retrying the same lookup
|
||||
# cannot recover and makes a clear config error look like an API outage.
|
||||
from agent.errors import MoAPresetNotFoundError
|
||||
|
||||
if isinstance(error, MoAPresetNotFoundError):
|
||||
return _result(FailoverReason.model_not_found, retryable=False)
|
||||
|
||||
# ── 3. Error code classification ────────────────────────────────
|
||||
|
||||
if error_code:
|
||||
@@ -819,22 +731,7 @@ def classify_api_error(
|
||||
if classified is not None:
|
||||
return classified
|
||||
|
||||
# ── 5. SSL certificate verification failures → fail fast ────────
|
||||
# A broken certificate chain (TLS-inspecting proxy, missing custom CA,
|
||||
# expired/self-signed cert) is deterministic for the host — every retry
|
||||
# reproduces the identical handshake failure. Fail immediately with
|
||||
# actionable guidance instead of burning the retry budget first.
|
||||
# Checked BEFORE the transient-SSL patterns: cert-verify messages also
|
||||
# contain "[ssl:" which would otherwise match the transient list.
|
||||
# Inspired by Claude Code v2.1.199 (July 2026).
|
||||
if any(p in error_msg for p in _SSL_CERT_VERIFY_PATTERNS):
|
||||
return _result(
|
||||
FailoverReason.ssl_cert_verification,
|
||||
retryable=False,
|
||||
should_fallback=False,
|
||||
)
|
||||
|
||||
# ── 5b. SSL/TLS transient errors → retry as timeout (not compression) ──
|
||||
# ── 5. SSL/TLS transient errors → retry as timeout (not compression) ──
|
||||
# SSL alerts mid-stream are transport hiccups, not server-side context
|
||||
# overflow signals. Classify before the disconnect check so a large
|
||||
# session doesn't incorrectly trigger context compression when the real
|
||||
@@ -887,34 +784,12 @@ def classify_api_error(
|
||||
)
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 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 ───────────────────────────
|
||||
# ── 7. Transport / timeout heuristics ───────────────────────────
|
||||
|
||||
if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)):
|
||||
return _result(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# ── 9. Fallback: unknown ────────────────────────────────────────
|
||||
# ── 8. Fallback: unknown ────────────────────────────────────────
|
||||
|
||||
return _result(FailoverReason.unknown, retryable=True)
|
||||
|
||||
@@ -953,11 +828,7 @@ def _classify_by_status(
|
||||
# OpenRouter 403 "key limit exceeded" is actually billing. Other
|
||||
# providers also use 403 for account-plan or credit exhaustion.
|
||||
if (
|
||||
(
|
||||
provider == "xai-oauth"
|
||||
and error_code.lower() == _XAI_SPENDING_LIMIT_ERROR_CODE
|
||||
)
|
||||
or "key limit exceeded" in error_msg
|
||||
"key limit exceeded" in error_msg
|
||||
or "spending limit" in error_msg
|
||||
or any(p in error_msg for p in _BILLING_PATTERNS)
|
||||
):
|
||||
@@ -1038,22 +909,6 @@ def _classify_by_status(
|
||||
FailoverReason.overloaded,
|
||||
retryable=True,
|
||||
)
|
||||
# Distinguish an OpenRouter-aggregator upstream 429 (an upstream model
|
||||
# like DeepSeek rate-limited OpenRouter's aggregate traffic) from an
|
||||
# account-level 429 (the user's key is actually throttled). OpenRouter
|
||||
# wraps upstream errors with the outer message "Provider returned
|
||||
# error" — the user's key is healthy, so marking it exhausted / rotating
|
||||
# is wrong and burns the key for ~24min. Fall back to a different model.
|
||||
if _is_openrouter_upstream_error(body, provider):
|
||||
upstream_provider = _extract_upstream_provider_name(body)
|
||||
ctx = {"upstream_provider": upstream_provider} if upstream_provider else {}
|
||||
return result_fn(
|
||||
FailoverReason.upstream_rate_limit,
|
||||
retryable=True,
|
||||
should_rotate_credential=False,
|
||||
should_fallback=True,
|
||||
error_context=ctx,
|
||||
)
|
||||
return result_fn(
|
||||
FailoverReason.rate_limit,
|
||||
retryable=True,
|
||||
@@ -1089,58 +944,11 @@ def _classify_by_status(
|
||||
retryable=False,
|
||||
should_fallback=True,
|
||||
)
|
||||
# Some local inference servers (notably llama.cpp / llama-server)
|
||||
# report context overflow with an HTTP 500 instead of the standard
|
||||
# 400/413. The request-validation guard above already ran, so any
|
||||
# remaining explicit context-overflow signal routes into the
|
||||
# compression-and-retry path (mirroring _classify_400) instead of
|
||||
# blind server_error retries that exhaust and drop the turn.
|
||||
# Empty-response advisories that mention "max_tokens" must not enter
|
||||
# that compression path.
|
||||
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.server_error,
|
||||
retryable=True,
|
||||
should_compress=False,
|
||||
)
|
||||
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.context_overflow,
|
||||
retryable=True,
|
||||
should_compress=True,
|
||||
)
|
||||
return result_fn(FailoverReason.server_error, retryable=True)
|
||||
|
||||
if status_code in {503, 529}:
|
||||
# Same overflow-as-5xx variant (server busy / model-load OOM, or a
|
||||
# Cloudflare/Tailscale hop relabeling the status). Route explicit
|
||||
# overflow bodies into compression; otherwise treat as transient
|
||||
# overload and retry.
|
||||
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.server_error,
|
||||
retryable=True,
|
||||
should_compress=False,
|
||||
)
|
||||
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.context_overflow,
|
||||
retryable=True,
|
||||
should_compress=True,
|
||||
)
|
||||
return result_fn(FailoverReason.overloaded, retryable=True)
|
||||
|
||||
# 408 Request Timeout — a transient timing failure the server itself flags
|
||||
# as safe to retry (RFC 9110 §15.5.9), not a malformed request. Commonly
|
||||
# emitted by reverse proxies sitting in front of self-hosted backends
|
||||
# (llama.cpp / Ollama / vLLM) when a long generation outruns the proxy's
|
||||
# request-read window. Route to the dedicated ``timeout`` reason (rebuild
|
||||
# client + retry) instead of falling through to the generic 4xx bucket
|
||||
# below, which would abort the turn on a retry-safe error the same way it
|
||||
# aborts a 400 Bad Request.
|
||||
if status_code == 408:
|
||||
return result_fn(FailoverReason.timeout, retryable=True)
|
||||
|
||||
# Other 4xx — non-retryable
|
||||
if 400 <= status_code < 500:
|
||||
return result_fn(
|
||||
@@ -1234,7 +1042,6 @@ 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,
|
||||
@@ -1247,8 +1054,8 @@ def _classify_400(
|
||||
# returns:
|
||||
# "Unsupported parameter: 'max_tokens' is not supported with this model.
|
||||
# Use 'max_completion_tokens' instead."
|
||||
# That string contains the literal substring "max_tokens", which historically
|
||||
# sat in _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
|
||||
# That string contains the literal substring "max_tokens", which is one of
|
||||
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
|
||||
# misclassified as context_overflow, routed into the compression loop,
|
||||
# re-sent with the same bad parameter, and ends in "Cannot compress
|
||||
# further". These errors are deterministic (every retry gets the identical
|
||||
@@ -1270,17 +1077,6 @@ def _classify_400(
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Empty-provider-response advisories must not enter compression. They
|
||||
# often mention "max_tokens" as a possible cause and used to match the
|
||||
# bare overflow pattern, then thrash compress until "Cannot compress
|
||||
# further" on an otherwise healthy session (custom endpoints / nano-gpt).
|
||||
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.server_error,
|
||||
retryable=True,
|
||||
should_compress=False,
|
||||
)
|
||||
|
||||
# Context overflow from 400
|
||||
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
||||
return result_fn(
|
||||
@@ -1368,7 +1164,15 @@ def _classify_by_error_code(
|
||||
should_rotate_credential=True,
|
||||
)
|
||||
|
||||
if code_lower in _BILLING_ERROR_CODES:
|
||||
if code_lower in {
|
||||
"insufficient_quota",
|
||||
"billing_not_active",
|
||||
"payment_required",
|
||||
"insufficient_credits",
|
||||
"no_usable_credits",
|
||||
"balance_depleted",
|
||||
"model_not_supported_on_free_tier",
|
||||
}:
|
||||
return result_fn(
|
||||
FailoverReason.billing,
|
||||
retryable=False,
|
||||
@@ -1484,15 +1288,6 @@ def _classify_by_message(
|
||||
should_fallback=True,
|
||||
)
|
||||
|
||||
# Empty-provider-response advisories (often mention "max_tokens") must
|
||||
# retry without compression — see the matching 400-path guard above.
|
||||
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.server_error,
|
||||
retryable=True,
|
||||
should_compress=False,
|
||||
)
|
||||
|
||||
# Context overflow patterns
|
||||
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
||||
return result_fn(
|
||||
@@ -1650,49 +1445,3 @@ def _extract_message(error: Exception, body: dict) -> str:
|
||||
return msg.strip()[:500]
|
||||
# Fallback to str(error)
|
||||
return str(error)[:500]
|
||||
|
||||
|
||||
def _is_openrouter_upstream_error(body: Any, provider: str) -> bool:
|
||||
"""Detect OpenRouter's aggregator-wrapped upstream provider errors.
|
||||
|
||||
OpenRouter returns errors from upstream model providers (DeepSeek,
|
||||
Anthropic, etc.) wrapped with the outer message "Provider returned error"
|
||||
and the real error nested in ``metadata.raw``. This signal means the
|
||||
user's OpenRouter key is healthy — the upstream provider is the one that
|
||||
failed — so credential rotation is the wrong recovery.
|
||||
"""
|
||||
if not isinstance(body, dict):
|
||||
return False
|
||||
provider_lower = (provider or "").strip().lower()
|
||||
err = body.get("error")
|
||||
if not isinstance(err, dict):
|
||||
return False
|
||||
outer_msg = str(err.get("message") or "").strip().lower()
|
||||
if outer_msg != "provider returned error":
|
||||
return False
|
||||
# Require either the explicit OpenRouter provider OR the metadata shape
|
||||
# that only OpenRouter produces (metadata.raw / metadata.provider_name).
|
||||
if provider_lower == "openrouter":
|
||||
return True
|
||||
metadata = err.get("metadata")
|
||||
if isinstance(metadata, dict) and (
|
||||
"raw" in metadata or "provider_name" in metadata
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extract_upstream_provider_name(body: Any) -> Optional[str]:
|
||||
"""Pull the upstream provider name out of OpenRouter's error metadata."""
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
err = body.get("error")
|
||||
if not isinstance(err, dict):
|
||||
return None
|
||||
metadata = err.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
name = metadata.get("provider_name")
|
||||
if isinstance(name, str) and name.strip():
|
||||
return name.strip()
|
||||
return None
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
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
|
||||
|
||||
|
||||
class MoAPresetNotFoundError(ValueError):
|
||||
"""Raised when a persisted MoA preset no longer exists in config."""
|
||||
|
||||
+9
-66
@@ -46,9 +46,6 @@ def build_write_denied_paths(home: str) -> set[str]:
|
||||
# Top-level Anthropic PKCE credential store remains sensitive even
|
||||
# when a profile is active; default/non-profile sessions still read it.
|
||||
str(hermes_root / ".anthropic_oauth.json"),
|
||||
# Bitwarden Secrets Manager encrypted disk cache.
|
||||
str(hermes_home / "cache" / "bws_cache.enc.json"),
|
||||
str(hermes_root / "cache" / "bws_cache.enc.json"),
|
||||
os.path.join(home, ".netrc"),
|
||||
os.path.join(home, ".pgpass"),
|
||||
os.path.join(home, ".npmrc"),
|
||||
@@ -98,16 +95,16 @@ def get_safe_write_roots() -> set[str]:
|
||||
return roots
|
||||
|
||||
|
||||
def _classify_write_denial(path: str) -> Optional[str]:
|
||||
"""Return ``'credential'``, ``'safe_root'``, or ``None`` if writes are allowed."""
|
||||
def is_write_denied(path: str) -> bool:
|
||||
"""Return True if path is blocked by the write denylist or safe root."""
|
||||
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 "credential"
|
||||
return True
|
||||
for prefix in build_write_denied_prefixes(home):
|
||||
if resolved.startswith(prefix):
|
||||
return "credential"
|
||||
return True
|
||||
|
||||
mcp_tokens_dir_name = "mcp-tokens"
|
||||
|
||||
@@ -121,27 +118,16 @@ def _classify_write_denial(path: str) -> Optional[str]:
|
||||
continue
|
||||
|
||||
for base_real in hermes_dirs:
|
||||
# Session transcripts are application-owned state. Letting the agent's
|
||||
# generic file tools rewrite state.db or legacy JSON snapshots can
|
||||
# falsify conversation history and invalidate resume/compression state.
|
||||
try:
|
||||
if resolved == os.path.realpath(os.path.join(base_real, "state.db")):
|
||||
return True
|
||||
sessions_real = os.path.realpath(os.path.join(base_real, "sessions"))
|
||||
if resolved == sessions_real or resolved.startswith(sessions_real + os.sep):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
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 "credential"
|
||||
return True
|
||||
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 "credential"
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -153,28 +139,9 @@ def _classify_write_denial(path: str) -> Optional[str]:
|
||||
allowed = True
|
||||
break
|
||||
if not allowed:
|
||||
return "safe_root"
|
||||
return True
|
||||
|
||||
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."
|
||||
return False
|
||||
|
||||
|
||||
# Common secret-bearing project-local environment file basenames.
|
||||
@@ -326,7 +293,7 @@ def get_read_block_error(path: str) -> Optional[str]:
|
||||
# .env contents — .env.example is the documented-shape substitute. The
|
||||
# terminal tool can still ``cat .env``; this is defense-in-depth, not a
|
||||
# boundary (see module docstring).
|
||||
if resolved.name.lower() in _BLOCKED_PROJECT_ENV_BASENAMES:
|
||||
if resolved.name in _BLOCKED_PROJECT_ENV_BASENAMES:
|
||||
return (
|
||||
f"Access denied: {path} is a secret-bearing environment file "
|
||||
"and cannot be read to prevent credential leakage. "
|
||||
@@ -337,30 +304,6 @@ def get_read_block_error(path: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def raise_if_read_blocked(path: str) -> None:
|
||||
"""Raise ``ValueError`` if ``path`` is a denied Hermes read (see
|
||||
:func:`get_read_block_error`), else return.
|
||||
|
||||
Shared chokepoint for provider input-loading sites that read a local
|
||||
file the model/tool supplied (e.g. image-gen ``image_url`` /
|
||||
``reference_image_urls`` paths). Centralizes the guard so every provider
|
||||
enforces the same read boundary with identical semantics instead of each
|
||||
open-coding the try/except block (#57698).
|
||||
|
||||
Best-effort by design: if ``agent.file_safety`` machinery is somehow
|
||||
unavailable at the call site the guard no-ops rather than breaking local
|
||||
image loading — consistent with the defense-in-depth (not security
|
||||
boundary) framing of the denylist itself. The blocking ``ValueError`` from
|
||||
a real hit still propagates; only unexpected internal errors are swallowed.
|
||||
"""
|
||||
try:
|
||||
blocked = get_read_block_error(path)
|
||||
except Exception: # noqa: BLE001 - guard must never break local-file loading
|
||||
return
|
||||
if blocked:
|
||||
raise ValueError(blocked)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-profile write guard (#TBD)
|
||||
#
|
||||
|
||||
@@ -27,18 +27,10 @@ from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.bounded_response import read_streaming_error_body
|
||||
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
|
||||
@@ -106,10 +98,7 @@ def probe_gemini_tier(
|
||||
url,
|
||||
params={"key": key},
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}",
|
||||
},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("probe_gemini_tier: network error: %s", exc)
|
||||
@@ -348,22 +337,6 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
|
||||
if parts:
|
||||
contents.append({"role": gemini_role, "parts": parts})
|
||||
|
||||
# Gemini's generateContent requires strict user/model alternation;
|
||||
# consecutive same-role contents are rejected with HTTP 400 "Please ensure
|
||||
# that multiturn requests alternate between user and model". The loop above
|
||||
# emits one content per source message, so parallel tool calls (N tool
|
||||
# results become N user functionResponse contents), back-to-back user turns,
|
||||
# or merged assistant turns would each violate that. Merge adjacent
|
||||
# same-role contents by concatenating their parts. For parallel calls this
|
||||
# also produces the grouped multi-functionResponse turn Gemini expects.
|
||||
merged_contents: List[Dict[str, Any]] = []
|
||||
for content in contents:
|
||||
if merged_contents and merged_contents[-1]["role"] == content["role"]:
|
||||
merged_contents[-1]["parts"].extend(content["parts"])
|
||||
else:
|
||||
merged_contents.append(content)
|
||||
contents = merged_contents
|
||||
|
||||
system_instruction = None
|
||||
joined_system = "\n".join(part for part in system_text_parts if part).strip()
|
||||
if joined_system:
|
||||
@@ -753,17 +726,14 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices:
|
||||
return chunks
|
||||
|
||||
|
||||
def gemini_http_error(
|
||||
response: httpx.Response, *, body_text: Optional[str] = None
|
||||
) -> GeminiAPIError:
|
||||
def gemini_http_error(response: httpx.Response) -> GeminiAPIError:
|
||||
status = response.status_code
|
||||
body_text = ""
|
||||
body_json: Dict[str, Any] = {}
|
||||
if body_text is None:
|
||||
try:
|
||||
body_text = response.text
|
||||
except Exception:
|
||||
body_text = ""
|
||||
body_text = body_text or ""
|
||||
try:
|
||||
body_text = response.text
|
||||
except Exception:
|
||||
body_text = ""
|
||||
if body_text:
|
||||
try:
|
||||
parsed = json.loads(body_text)
|
||||
@@ -911,11 +881,7 @@ class GeminiNativeClient:
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"x-goog-api-key": self.api_key,
|
||||
# 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}",
|
||||
"User-Agent": "hermes-agent (gemini-native)",
|
||||
}
|
||||
headers.update(self._default_headers)
|
||||
return headers
|
||||
@@ -986,8 +952,8 @@ class GeminiNativeClient:
|
||||
try:
|
||||
with self._http.stream("POST", url, json=request, headers=stream_headers, timeout=timeout) as response:
|
||||
if response.status_code != 200:
|
||||
body_text = read_streaming_error_body(response)
|
||||
raise gemini_http_error(response, body_text=body_text)
|
||||
response.read()
|
||||
raise gemini_http_error(response)
|
||||
tool_call_indices: Dict[str, Dict[str, Any]] = {}
|
||||
for event in _iter_sse_events(response):
|
||||
for chunk in translate_stream_event(event, model, tool_call_indices):
|
||||
|
||||
@@ -87,30 +87,6 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
|
||||
if any(not isinstance(item, str) for item in enum_val):
|
||||
cleaned.pop("enum", None)
|
||||
|
||||
# Gemini validates ``required`` strictly against the same node's
|
||||
# ``properties`` — GenerateContentRequest fails with HTTP 400
|
||||
# "...items.required[0]: property is not defined" when a required name
|
||||
# has no matching property in that node. MCP servers routinely emit
|
||||
# this shape (e.g. the GitHub remote MCP's array item schemas carry
|
||||
# ``required`` without ``properties``), and one bad tool schema fails
|
||||
# the ENTIRE request before any model output. Filter ``required`` to
|
||||
# names that exist in this node's ``properties`` and drop it when
|
||||
# nothing valid remains. The tool handler still validates required
|
||||
# fields at execution time, so this only removes what Gemini couldn't
|
||||
# accept anyway. (Port of Kilo-Org/kilocode#11955.)
|
||||
required_val = cleaned.get("required")
|
||||
if isinstance(required_val, list):
|
||||
props_val = cleaned.get("properties")
|
||||
prop_names = set(props_val.keys()) if isinstance(props_val, dict) else set()
|
||||
valid_required = [
|
||||
name for name in required_val
|
||||
if isinstance(name, str) and name in prop_names
|
||||
]
|
||||
if not valid_required:
|
||||
cleaned.pop("required", None)
|
||||
elif len(valid_required) != len(required_val):
|
||||
cleaned["required"] = valid_required
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
|
||||
+25
-1
@@ -32,6 +32,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sysconfig
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -91,8 +92,12 @@ def _locales_dir() -> Path:
|
||||
|
||||
1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any
|
||||
sealed-packaging system) to point at the installed catalog directory.
|
||||
2. ``<repo-root>/locales`` -- source checkouts and editable installs,
|
||||
2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,
|
||||
where the working tree sits next to ``agent/``.
|
||||
3. ``<sysconfig data|purelib|platlib>/locales`` -- pip wheel installs.
|
||||
setuptools ``data-files`` extracts ``locales/*.yaml`` under the
|
||||
interpreter's ``data`` scheme; the other schemes are checked as a
|
||||
safety net for nonstandard layouts.
|
||||
|
||||
Falling through to the source-style path (even when missing) keeps
|
||||
``_load_catalog`` error messages informative -- it logs the path it
|
||||
@@ -111,6 +116,25 @@ def _locales_dir() -> Path:
|
||||
|
||||
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
|
||||
source_dir = Path(__file__).resolve().parent.parent / "locales"
|
||||
if source_dir.is_dir():
|
||||
return source_dir
|
||||
|
||||
# pip wheel install: data-files lands under the interpreter data scheme.
|
||||
# ``data`` (== sys.prefix in a venv) is where setuptools data-files extract
|
||||
# and is checked first. ``purelib``/``platlib`` (site-packages) are a safety
|
||||
# net for nonstandard layouts. NOTE: this does NOT cover ``pip install
|
||||
# --user`` (user scheme, ~/.local/locales) or ``pip install --target`` --
|
||||
# both are out of scope; see the plan header.
|
||||
for scheme in ("data", "purelib", "platlib"):
|
||||
raw = sysconfig.get_path(scheme)
|
||||
if not raw:
|
||||
continue
|
||||
candidate = Path(raw) / "locales"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
|
||||
# Last resort: return the source-style path so _load_catalog's catalog-missing
|
||||
# log (logger.debug "i18n catalog missing for %s at %s") stays informative.
|
||||
return source_dir
|
||||
|
||||
|
||||
|
||||
+19
-51
@@ -17,17 +17,13 @@ It reads ``agent.image_input_mode`` from config.yaml (``auto`` | ``native``
|
||||
| ``text``, default ``auto``) and the active model's capability metadata.
|
||||
|
||||
In ``auto`` mode:
|
||||
- If the active model reports ``supports_vision=True`` (via config
|
||||
override or models.dev metadata), we attach natively — vision-capable
|
||||
main models should always see the original pixels, even when an
|
||||
auxiliary vision backend is configured. That auxiliary backend then
|
||||
acts as a *fallback* for sessions whose main model can't take images.
|
||||
- Otherwise, if the user has explicitly configured ``auxiliary.vision``
|
||||
(provider/model/base_url not ``auto``/empty), we route through the
|
||||
text pipeline so the auxiliary vision backend can describe the image
|
||||
for the text-only main model.
|
||||
- Otherwise (non-vision model, no explicit override), we fall back to
|
||||
text via the default vision_analyze flow.
|
||||
- If the user has explicitly configured ``auxiliary.vision.provider``
|
||||
(i.e. not ``auto`` and not empty), we assume they want the text pipeline
|
||||
regardless of the main model — they've opted in to a specific vision
|
||||
backend for a reason (cost, quality, local-only, etc.).
|
||||
- Otherwise, if the active model reports ``supports_vision=True`` in its
|
||||
models.dev metadata, we attach natively.
|
||||
- Otherwise (non-vision model, no explicit override), we fall back to text.
|
||||
|
||||
This keeps ``vision_analyze`` surfaced as a tool in every session — skills
|
||||
and agent flows that chain it (browser screenshots, deeper inspection of
|
||||
@@ -189,8 +185,7 @@ def _supports_vision_override(
|
||||
2. ``providers.<provider>.models.<model>.supports_vision``
|
||||
(named custom providers — ``provider`` may be the runtime-resolved
|
||||
value ``"custom"`` and/or the user-declared name under
|
||||
``model.provider``; both are tried. For ``custom:<name>`` syntax,
|
||||
the stripped ``<name>`` is also tried as a provider key.)
|
||||
``model.provider``; both are tried)
|
||||
|
||||
Returns None when no override is set, so the caller falls through to
|
||||
models.dev. Returns False explicitly only when the user wrote a
|
||||
@@ -210,21 +205,11 @@ def _supports_vision_override(
|
||||
# get rewritten to provider="custom" at runtime
|
||||
# (hermes_cli/runtime_provider.py:_resolve_named_custom_runtime), so the
|
||||
# config still holds the user-declared name under model.provider. Try
|
||||
# both as candidate provider keys. Either identity may use the
|
||||
# "custom:<name>" form while providers: is keyed by bare <name>.
|
||||
# both as candidate provider keys.
|
||||
config_provider = str(model_cfg.get("provider") or "").strip()
|
||||
provider_candidates: List[str] = []
|
||||
for candidate in (provider, config_provider):
|
||||
if not candidate:
|
||||
continue
|
||||
provider_candidates.append(candidate)
|
||||
if candidate.startswith("custom:"):
|
||||
stripped_candidate = candidate[len("custom:"):]
|
||||
if stripped_candidate:
|
||||
provider_candidates.append(stripped_candidate)
|
||||
providers_raw = cfg.get("providers")
|
||||
providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {}
|
||||
for p in dict.fromkeys(provider_candidates):
|
||||
for p in dict.fromkeys(filter(None, (provider, config_provider))):
|
||||
entry_raw = providers_cfg.get(p)
|
||||
entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {}
|
||||
models_raw = entry.get("models")
|
||||
@@ -272,12 +257,10 @@ def _resolve_inference_base_url(
|
||||
) -> str:
|
||||
"""Best-effort base URL for the active inference provider."""
|
||||
try:
|
||||
from agent.auxiliary_client import _runtime_main_value
|
||||
from agent.auxiliary_client import _RUNTIME_MAIN_BASE_URL
|
||||
|
||||
runtime = str(_runtime_main_value("base_url") or "").strip()
|
||||
runtime_provider = str(_runtime_main_value("provider") or "").strip().lower()
|
||||
requested_provider = str(provider or "").strip().lower()
|
||||
if runtime and (not requested_provider or requested_provider == runtime_provider):
|
||||
runtime = str(_RUNTIME_MAIN_BASE_URL or "").strip()
|
||||
if runtime:
|
||||
return runtime
|
||||
except Exception:
|
||||
pass
|
||||
@@ -353,10 +336,8 @@ def _coerce_mode(raw: Any) -> str:
|
||||
def _explicit_aux_vision_override(cfg: Optional[Dict[str, Any]]) -> bool:
|
||||
"""True when the user configured a specific auxiliary vision backend.
|
||||
|
||||
An explicit override means the user has a dedicated vision backend
|
||||
available; it's used as a *fallback* when the main model can't take
|
||||
images natively. In ``auto`` mode, native vision on a vision-capable
|
||||
main model still wins over this fallback — see issue #29135.
|
||||
An explicit override means the user *wants* the text pipeline (they're
|
||||
paying for a dedicated vision model), so we don't silently bypass it.
|
||||
"""
|
||||
if not isinstance(cfg, dict):
|
||||
return False
|
||||
@@ -445,15 +426,13 @@ def decide_image_input_mode(
|
||||
if mode_cfg == "text":
|
||||
return "text"
|
||||
|
||||
# auto: prefer native vision when the main model supports it. An
|
||||
# explicit auxiliary.vision config acts as a *fallback* for text-only
|
||||
# main models — it should not preempt native vision on a model that
|
||||
# can natively inspect the pixels (issue #29135).
|
||||
# auto
|
||||
if _explicit_aux_vision_override(cfg):
|
||||
return "text"
|
||||
|
||||
supports = _lookup_supports_vision(provider, model, cfg)
|
||||
if supports is True:
|
||||
return "native"
|
||||
if _explicit_aux_vision_override(cfg):
|
||||
return "text"
|
||||
return "text"
|
||||
|
||||
|
||||
@@ -639,17 +618,6 @@ def _file_to_data_url(path: Path) -> Optional[str]:
|
||||
caller reports those paths in ``skipped`` and the rest of the turn
|
||||
proceeds.
|
||||
"""
|
||||
try:
|
||||
from agent.file_safety import raise_if_read_blocked
|
||||
|
||||
raise_if_read_blocked(str(path))
|
||||
except ValueError as exc:
|
||||
logger.warning("image_routing: blocked local image attachment %s -- %s", path, exc)
|
||||
return None
|
||||
except Exception:
|
||||
# Keep attachment routing best-effort if the guard itself is unavailable.
|
||||
pass
|
||||
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except Exception as exc:
|
||||
|
||||
+23
-194
@@ -17,7 +17,6 @@ Usage:
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime
|
||||
@@ -142,8 +141,8 @@ class InsightsEngine:
|
||||
}
|
||||
|
||||
# Compute insights
|
||||
models = self._compute_model_breakdown(sessions, cutoff, source)
|
||||
overview = self._compute_overview(sessions, message_stats, models)
|
||||
overview = self._compute_overview(sessions, message_stats)
|
||||
models = self._compute_model_breakdown(sessions)
|
||||
platforms = self._compute_platform_breakdown(sessions)
|
||||
tools = self._compute_tool_breakdown(tool_usage)
|
||||
skills = self._compute_skill_breakdown(skill_usage)
|
||||
@@ -173,7 +172,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, api_call_count")
|
||||
"actual_cost_usd, cost_status, cost_source")
|
||||
|
||||
# Pre-computed query strings — f-string evaluated once at class definition,
|
||||
# not at runtime, so no user-controlled value can alter the query structure.
|
||||
@@ -400,12 +399,7 @@ class InsightsEngine:
|
||||
# Computation
|
||||
# =========================================================================
|
||||
|
||||
def _compute_overview(
|
||||
self,
|
||||
sessions: List[Dict],
|
||||
message_stats: Dict,
|
||||
models: Optional[List[Dict]] = None,
|
||||
) -> Dict:
|
||||
def _compute_overview(self, sessions: List[Dict], message_stats: Dict) -> 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)
|
||||
@@ -437,21 +431,6 @@ class InsightsEngine:
|
||||
else:
|
||||
models_without_pricing.add(display)
|
||||
|
||||
if models:
|
||||
total_cost = sum(float(m.get("cost") or 0.0) for m in models)
|
||||
# Token totals likewise: the per-model breakdown includes
|
||||
# auxiliary usage rows (vision/compression/titles — task
|
||||
# dimension in session_model_usage, #23270) plus reconciled
|
||||
# residuals, while the sessions counters carry main-loop usage
|
||||
# only. Summing the breakdown keeps overview totals consistent
|
||||
# with the per-model table and stops `hermes insights`
|
||||
# undercounting aux spend (#58592, #9979).
|
||||
total_input = sum(int(m.get("input_tokens") or 0) for m in models)
|
||||
total_output = sum(int(m.get("output_tokens") or 0) for m in models)
|
||||
total_cache_read = sum(int(m.get("cache_read_tokens") or 0) for m in models)
|
||||
total_cache_write = sum(int(m.get("cache_write_tokens") or 0) for m in models)
|
||||
total_tokens = total_input + total_output + total_cache_read + total_cache_write
|
||||
|
||||
# Session duration stats (guard against negative durations from clock drift)
|
||||
durations = []
|
||||
for s in sessions:
|
||||
@@ -494,189 +473,39 @@ class InsightsEngine:
|
||||
"included_cost_sessions": included_cost_sessions,
|
||||
}
|
||||
|
||||
_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.
|
||||
"""
|
||||
def _compute_model_breakdown(self, sessions: List[Dict]) -> List[Dict]:
|
||||
"""Break down usage by model."""
|
||||
model_data = defaultdict(lambda: {
|
||||
"sessions": set(), "input_tokens": 0, "output_tokens": 0,
|
||||
"sessions": 0, "input_tokens": 0, "output_tokens": 0,
|
||||
"cache_read_tokens": 0, "cache_write_tokens": 0,
|
||||
"reasoning_tokens": 0, "total_tokens": 0, "api_calls": 0,
|
||||
"tool_calls": 0, "cost": 0.0, "actual_cost": 0.0,
|
||||
"total_tokens": 0, "tool_calls": 0, "cost": 0.0,
|
||||
})
|
||||
|
||||
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"
|
||||
for s in sessions:
|
||||
model = s.get("model") or "unknown"
|
||||
# Normalize: strip provider prefix for display
|
||||
display_model = model.split("/")[-1] if "/" in model else model
|
||||
d: Dict[str, Any] = model_data[display_model]
|
||||
d["sessions"].add(session_id)
|
||||
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["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
|
||||
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["tool_calls"] += s.get("tool_call_count") or 0
|
||||
estimate, status = _estimate_cost(s)
|
||||
d["cost"] += estimate
|
||||
d["actual_cost"] += float(actual_cost or 0.0)
|
||||
d["has_pricing"] = has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url"))
|
||||
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
|
||||
|
||||
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)
|
||||
result = [
|
||||
{"model": model, **data}
|
||||
for model, data in model_data.items()
|
||||
]
|
||||
# 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
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
+8
-22
@@ -117,29 +117,15 @@ def build_learn_prompt(user_request: str) -> str:
|
||||
|
||||
return (
|
||||
"[/learn] The user wants you to learn a reusable skill from the "
|
||||
"request below, and save it.\n\n"
|
||||
f"THE REQUEST:\n{req}\n\n"
|
||||
"The request is open-ended and may mix two kinds of content, in any "
|
||||
"order: SOURCES to gather (directories, file paths, URLs, \"what we "
|
||||
"just did\", pasted notes) AND REQUIREMENTS that shape the skill "
|
||||
"(what to focus on, what to leave out, scope, naming, the angle to "
|
||||
"take). Treat EVERY part of the request as load-bearing. In "
|
||||
"particular, prose that comes after a path or link is NOT incidental "
|
||||
"— it is the user telling you what they want from that source. A "
|
||||
"request like `<url> focus on the auth flow, skip the deprecated "
|
||||
"endpoints` means: gather the URL AND honor \"focus on auth, skip "
|
||||
"deprecated\" as authoring requirements. Never fetch the first source "
|
||||
"and ignore the rest.\n\n"
|
||||
"source(s) they described below, and save it.\n\n"
|
||||
f"WHAT TO LEARN FROM:\n{req}\n\n"
|
||||
"Do this:\n"
|
||||
"1. Gather every source the user named, using the tools you already "
|
||||
"have — `read_file`/`search_files` for local files or directories, "
|
||||
"`web_extract` for URLs, the current conversation history if they "
|
||||
"referred to something you just did, and the text they pasted as-is. "
|
||||
"If the request is ambiguous about scope, make a reasonable choice "
|
||||
"and note it; do not stall.\n"
|
||||
"1b. Apply every requirement, focus, and constraint in the request to "
|
||||
"the skill you author — these govern what the SKILL.md covers and "
|
||||
"emphasizes, not just which sources you read.\n"
|
||||
"1. Gather the material. Resolve whatever the user named using the "
|
||||
"tools you already have — `read_file`/`search_files` for local files "
|
||||
"or directories, `web_extract` for URLs, the current conversation "
|
||||
"history if they referred to something you just did, and the text "
|
||||
"they pasted as-is. If the request is ambiguous about scope, make a "
|
||||
"reasonable choice and note it; do not stall.\n"
|
||||
"2. Author ONE SKILL.md and save it with the `skill_manage` tool "
|
||||
"(action=\"create\"). Pick a sensible category. If the procedure needs "
|
||||
"a non-trivial script, add it under the skill's `scripts/` with "
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
"""Assemble the "learning made visible" graph for desktop.
|
||||
|
||||
This graph is intentionally scoped to what a user actually learns over time:
|
||||
- non-base, learned/profile skills (agent-created or used),
|
||||
- memory chunks from ``MEMORY.md`` / ``USER.md`` as first-class nodes.
|
||||
|
||||
Skill links come from declared ``related_skills``. Memory-to-skill links are
|
||||
derived from lexical overlap so the graph can answer "which learned skills are
|
||||
connected to the things I remember?".
|
||||
|
||||
Run as a module to print edge-density stats against real data:
|
||||
|
||||
python -m agent.learning_graph
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillNode:
|
||||
name: str
|
||||
category: str
|
||||
source: str = "profile"
|
||||
timestamp: Optional[int] = None
|
||||
use_count: int = 0
|
||||
state: str = "active"
|
||||
created_by: Optional[str] = None
|
||||
pinned: bool = False
|
||||
related: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _frontmatter(text: str) -> dict[str, Any]:
|
||||
try:
|
||||
from agent.skill_utils import parse_frontmatter
|
||||
|
||||
fm, _ = parse_frontmatter(text)
|
||||
return fm or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _hermes_meta(fm: dict[str, Any]) -> dict[str, Any]:
|
||||
"""``metadata.hermes`` as a dict, tolerant of the string-valued frontmatter
|
||||
that ``parse_frontmatter``'s malformed-YAML fallback produces."""
|
||||
meta = fm.get("metadata")
|
||||
hermes = meta.get("hermes") if isinstance(meta, dict) else None
|
||||
return hermes if isinstance(hermes, dict) else {}
|
||||
|
||||
|
||||
def _related(fm: dict[str, Any]) -> list[str]:
|
||||
raw = fm.get("related_skills") or _hermes_meta(fm).get("related_skills")
|
||||
if isinstance(raw, list):
|
||||
return [str(r).strip() for r in raw if str(r).strip()]
|
||||
if isinstance(raw, str):
|
||||
return [r.strip() for r in raw.strip("[]").split(",") if r.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def _category(fm: dict[str, Any], skill_md: Path) -> str:
|
||||
cat = fm.get("category") or _hermes_meta(fm).get("category")
|
||||
if cat:
|
||||
return str(cat)
|
||||
# …/skills/<category>/<skill>/SKILL.md
|
||||
parts = skill_md.parts
|
||||
return parts[-3] if len(parts) >= 3 else "general"
|
||||
|
||||
|
||||
def _iter_skill_files(roots: list[tuple[str, Path]]):
|
||||
for source, root in roots:
|
||||
if root.exists():
|
||||
for path in root.rglob("SKILL.md"):
|
||||
yield source, path
|
||||
|
||||
|
||||
def _load_usage() -> dict[str, dict[str, Any]]:
|
||||
try:
|
||||
from tools.skill_usage import load_usage
|
||||
|
||||
return load_usage()
|
||||
except Exception:
|
||||
path = get_hermes_home() / "skills" / ".usage.json"
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _to_int_ts(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return int(float(s))
|
||||
except ValueError:
|
||||
parsed = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return int(parsed.timestamp())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _usage_timestamp(rec: dict[str, Any]) -> Optional[int]:
|
||||
for key in ("last_activity_at", "last_used_at", "last_viewed_at", "last_patched_at", "created_at"):
|
||||
ts = _to_int_ts(rec.get(key))
|
||||
if ts is not None:
|
||||
return ts
|
||||
return None
|
||||
|
||||
|
||||
def build_skill_nodes(skill_roots: list[tuple[str, Path]]) -> dict[str, SkillNode]:
|
||||
usage = _load_usage()
|
||||
nodes: dict[str, SkillNode] = {}
|
||||
|
||||
for source, skill_md in _iter_skill_files(skill_roots):
|
||||
if any(p in {".archive", ".hub", "node_modules", ".git"} for p in skill_md.parts):
|
||||
continue
|
||||
try:
|
||||
fm = _frontmatter(skill_md.read_text(encoding="utf-8")[:4000])
|
||||
except OSError:
|
||||
continue
|
||||
name = str(fm.get("name") or skill_md.parent.name).strip()
|
||||
if not name or name in nodes:
|
||||
continue
|
||||
rec = usage.get(name, {})
|
||||
last_activity = _usage_timestamp(rec)
|
||||
file_ts = _to_int_ts(skill_md.stat().st_mtime)
|
||||
nodes[name] = SkillNode(
|
||||
name=name,
|
||||
category=_category(fm, skill_md),
|
||||
source=source,
|
||||
timestamp=last_activity or file_ts,
|
||||
use_count=int(rec.get("use_count", 0) or 0),
|
||||
state=str(rec.get("state", "active") or "active"),
|
||||
created_by=rec.get("created_by"),
|
||||
pinned=bool(rec.get("pinned", False)),
|
||||
related=_related(fm),
|
||||
)
|
||||
return nodes
|
||||
|
||||
|
||||
def build_edges(nodes: dict[str, SkillNode]) -> list[tuple[str, str]]:
|
||||
"""Undirected related_skills edges where BOTH endpoints exist (deduped)."""
|
||||
seen: set[tuple[str, str]] = set()
|
||||
edges: list[tuple[str, str]] = []
|
||||
for node in nodes.values():
|
||||
for target in node.related:
|
||||
if target in nodes and target != node.name:
|
||||
a, b = sorted((node.name, target))
|
||||
key = (a, b)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
edges.append(key)
|
||||
return edges
|
||||
|
||||
|
||||
def density_stats(nodes: dict[str, SkillNode], edges: list[tuple[str, str]]) -> dict[str, Any]:
|
||||
linked: set[str] = set()
|
||||
for a, b in edges:
|
||||
linked.add(a)
|
||||
linked.add(b)
|
||||
cats: dict[str, int] = {}
|
||||
for n in nodes.values():
|
||||
cats[n.category] = cats.get(n.category, 0) + 1
|
||||
n = len(nodes) or 1
|
||||
return {
|
||||
"nodes": len(nodes),
|
||||
"related_edges": len(edges),
|
||||
"edges_per_node": round(len(edges) / n, 3),
|
||||
"linked_nodes": len(linked),
|
||||
"isolated_pct": round(100 * (n - len(linked)) / n, 1),
|
||||
"categories": len(cats),
|
||||
"agent_created": sum(1 for x in nodes.values() if x.created_by == "agent"),
|
||||
"used": sum(1 for x in nodes.values() if x.use_count > 0),
|
||||
"top_categories": sorted(cats.items(), key=lambda kv: -kv[1])[:8],
|
||||
}
|
||||
|
||||
|
||||
def _memory_cards() -> list[dict[str, Any]]:
|
||||
"""Freeform memory as readable cards.
|
||||
|
||||
``MEMORY.md`` / ``USER.md`` are prose split on bare ``§`` separators; each
|
||||
chunk becomes one card. Every chunk is surfaced — the graph shows everything.
|
||||
"""
|
||||
base = get_hermes_home() / "memories"
|
||||
cards: list[dict[str, Any]] = []
|
||||
for fname, source in (("MEMORY.md", "memory"), ("USER.md", "profile")):
|
||||
path = base / fname
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
file_ts = _to_int_ts(path.stat().st_mtime)
|
||||
except OSError:
|
||||
continue
|
||||
for chunk_idx, chunk in enumerate(c.strip() for c in text.split("\n§\n")):
|
||||
if not chunk:
|
||||
continue
|
||||
first = chunk.splitlines()[0].strip().lstrip("# ").strip()
|
||||
cards.append(
|
||||
{
|
||||
"source": source,
|
||||
"timestamp": file_ts + chunk_idx if file_ts is not None else None,
|
||||
"title": (first[:80] + "…") if len(first) > 80 else first,
|
||||
"body": chunk[:1200],
|
||||
}
|
||||
)
|
||||
return cards
|
||||
|
||||
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
return {t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) >= 3}
|
||||
|
||||
|
||||
def _memory_skill_edges(memory_cards: list[dict[str, Any]], skills: list[SkillNode]) -> list[tuple[str, str]]:
|
||||
edges: list[tuple[str, str]] = []
|
||||
skill_meta = [(s, _tokenize(s.name), s.name.lower()) for s in skills]
|
||||
for idx, card in enumerate(memory_cards):
|
||||
mem_id = f"memory:{card['source']}:{idx}"
|
||||
text = f"{card.get('title', '')}\n{card.get('body', '')}".lower()
|
||||
text_tokens = _tokenize(text)
|
||||
scored: list[tuple[int, str]] = []
|
||||
for skill, tokens, skill_name_lower in skill_meta:
|
||||
score = 0
|
||||
if skill_name_lower in text:
|
||||
score += 6
|
||||
score += len(tokens & text_tokens)
|
||||
if score > 0:
|
||||
scored.append((score, skill.name))
|
||||
scored.sort(key=lambda x: (-x[0], x[1]))
|
||||
for _, skill_name in scored[:4]:
|
||||
edges.append((mem_id, skill_name))
|
||||
return edges
|
||||
|
||||
|
||||
def _skill_roots() -> list[tuple[str, Path]]:
|
||||
repo = Path(__file__).resolve().parent.parent
|
||||
home_skills = get_hermes_home() / "skills"
|
||||
return [("base", repo / "skills"), ("profile", home_skills)]
|
||||
|
||||
|
||||
def build_learning_graph() -> dict[str, Any]:
|
||||
"""Full payload for the desktop learning panel.
|
||||
|
||||
Focus on what is profile-learned and actionable:
|
||||
- skills that are NOT base-installed and show real learning signal
|
||||
(agent-created or used),
|
||||
- memory chunks as first-class graph nodes connected to those learned skills.
|
||||
"""
|
||||
all_skills = build_skill_nodes(_skill_roots())
|
||||
learned_skills = {
|
||||
name: node
|
||||
for name, node in all_skills.items()
|
||||
if node.source != "base" and (node.created_by == "agent" or node.use_count > 0)
|
||||
}
|
||||
skill_edges = build_edges(learned_skills)
|
||||
memory_cards = _memory_cards()
|
||||
memory_edges = _memory_skill_edges(memory_cards, list(learned_skills.values()))
|
||||
|
||||
edges = skill_edges + memory_edges
|
||||
clusters: dict[str, int] = {}
|
||||
for node in learned_skills.values():
|
||||
clusters[node.category] = clusters.get(node.category, 0) + 1
|
||||
if memory_cards:
|
||||
clusters["memory"] = len(memory_cards)
|
||||
|
||||
graph_nodes = [
|
||||
{
|
||||
"id": n.name,
|
||||
"label": n.name,
|
||||
"kind": "skill",
|
||||
"timestamp": n.timestamp,
|
||||
"category": n.category,
|
||||
"useCount": n.use_count,
|
||||
"state": n.state,
|
||||
"createdBy": n.created_by,
|
||||
"pinned": n.pinned,
|
||||
}
|
||||
for n in learned_skills.values()
|
||||
]
|
||||
for i, card in enumerate(memory_cards):
|
||||
graph_nodes.append(
|
||||
{
|
||||
"id": f"memory:{card['source']}:{i}",
|
||||
"label": card["title"],
|
||||
"kind": "memory",
|
||||
"memorySource": card["source"],
|
||||
"timestamp": card.get("timestamp"),
|
||||
"category": "memory",
|
||||
"useCount": 0,
|
||||
"state": "active",
|
||||
"createdBy": "memory",
|
||||
"pinned": False,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"nodes": graph_nodes,
|
||||
"edges": [{"source": a, "target": b} for a, b in edges],
|
||||
"clusters": [
|
||||
{"category": c, "count": n}
|
||||
for c, n in sorted(clusters.items(), key=lambda kv: -kv[1])
|
||||
],
|
||||
"memory": memory_cards,
|
||||
"stats": {
|
||||
**density_stats(learned_skills, skill_edges),
|
||||
"memory_nodes": len(memory_cards),
|
||||
"memory_skill_edges": len(memory_edges),
|
||||
"learned_skills": len(learned_skills),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
nodes = build_skill_nodes(_skill_roots())
|
||||
print(json.dumps(density_stats(nodes, build_edges(nodes)), indent=2))
|
||||
@@ -1,659 +0,0 @@
|
||||
"""Terminal renderer for the learning timeline (learned skills + memories).
|
||||
|
||||
The desktop app (``apps/desktop/src/app/starmap``) paints a GPU radial
|
||||
constellation; a terminal can't, so this is a *rendition* of the same data as a
|
||||
timeline bar chart — date rows, proportional skill/memory bars colored by the
|
||||
day's dominant category, and a cumulative trajectory sparkline — plus per-slice
|
||||
bucket metadata the TUI walks as a tree. The age gradient and complementary
|
||||
memory ink are ported from the desktop source, not guessed.
|
||||
|
||||
Grids are emitted as style runs — ``[text, style, alpha, hex?]`` — so each
|
||||
consumer maps the semantic style + brightness onto its own palette; the
|
||||
optional 4th element overrides the base color (category heatmap). Pure,
|
||||
stdlib-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
# time-axis.ts LEAD_IN: the oldest node sits just off recency 0.
|
||||
LEAD_IN = 0.06
|
||||
|
||||
# constants.ts AGE_GRADIENT — old quiet, recent bright.
|
||||
AGE_OLD_INK = 0.42
|
||||
AGE_MID_INK = 0.74
|
||||
AGE_NEW_INK = 0.95
|
||||
AGE_MID = 0.52
|
||||
|
||||
# Style keys consumers map to base colors (brightness = the run alpha).
|
||||
STYLE_BG = "bg"
|
||||
STYLE_SKILL = "skill"
|
||||
STYLE_MEMORY = "memory"
|
||||
STYLE_LABEL = "label"
|
||||
STYLE_DIM = "dim"
|
||||
|
||||
# Legend glyphs mirror NODE_SHAPE (skill = circle, memory = diamond).
|
||||
SKILL_GLYPH = "●"
|
||||
MEMORY_GLYPH = "◆"
|
||||
_LABEL_KEYS = tuple("123456789abc")
|
||||
|
||||
Run = list # [text, style, alpha, hex?]
|
||||
Row = list # list[Run]
|
||||
Grid = list # list[Row]
|
||||
|
||||
|
||||
def _to_ts(value: Any) -> Optional[float]:
|
||||
try:
|
||||
return None if value is None else float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _clamp(v: float, lo: float, hi: float) -> float:
|
||||
return lo if v < lo else hi if v > hi else v
|
||||
|
||||
|
||||
def _smoothstep(p: float) -> float:
|
||||
p = _clamp(p, 0.0, 1.0)
|
||||
return p * p * (3 - 2 * p)
|
||||
|
||||
|
||||
def recency_ink(rec: float) -> float:
|
||||
"""Port of geometry.ts ``recencyInk`` — smoothstep age → ink alpha."""
|
||||
t = _clamp(rec, 0.0, 1.0)
|
||||
if t <= AGE_MID:
|
||||
return AGE_OLD_INK + (AGE_MID_INK - AGE_OLD_INK) * _smoothstep(t / AGE_MID)
|
||||
return AGE_MID_INK + (AGE_NEW_INK - AGE_MID_INK) * _smoothstep((t - AGE_MID) / (1 - AGE_MID))
|
||||
|
||||
|
||||
def format_date(ts: Optional[float]) -> str:
|
||||
if not ts:
|
||||
return "unknown"
|
||||
try:
|
||||
dt = datetime.fromtimestamp(float(ts), tz=timezone.utc)
|
||||
return f"{dt.day} {dt.strftime('%b %Y')}"
|
||||
except (ValueError, OSError, OverflowError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def compute_recency(nodes: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Port of time-axis.ts ``computeRecency`` (id → recency ratio, timed flag)."""
|
||||
known = [t for t in (_to_ts(n.get("timestamp")) for n in nodes) if t is not None]
|
||||
min_ts = min(known) if known else None
|
||||
max_ts = max(known) if known else None
|
||||
timed = min_ts is not None and max_ts is not None and max_ts > min_ts
|
||||
|
||||
ordered = sorted(
|
||||
nodes,
|
||||
key=lambda n: (
|
||||
_to_ts(n.get("timestamp")) if _to_ts(n.get("timestamp")) is not None else math.inf,
|
||||
str(n.get("id", "")),
|
||||
),
|
||||
)
|
||||
last = max(len(ordered) - 1, 1)
|
||||
ord_ratio = {str(n.get("id", "")): (i / last if len(ordered) > 1 else 0.0) for i, n in enumerate(ordered)}
|
||||
|
||||
rec: dict[str, float] = {}
|
||||
for n in nodes:
|
||||
nid = str(n.get("id", ""))
|
||||
ts = _to_ts(n.get("timestamp"))
|
||||
if timed and ts is not None and min_ts is not None and max_ts is not None:
|
||||
ratio = (ts - min_ts) / (max_ts - min_ts)
|
||||
else:
|
||||
ratio = ord_ratio.get(nid, 0.0)
|
||||
rec[nid] = LEAD_IN + (1 - LEAD_IN) * _clamp(ratio, 0.0, 1.0)
|
||||
|
||||
return {"rec": rec, "timed": timed, "minTs": min_ts, "maxTs": max_ts}
|
||||
|
||||
|
||||
def _date_at(rec: dict[str, Any], reveal: float) -> Optional[float]:
|
||||
if not rec.get("timed"):
|
||||
return None
|
||||
lo, hi = rec.get("minTs"), rec.get("maxTs")
|
||||
if lo is None or hi is None:
|
||||
return None
|
||||
return round(lo + _clamp(reveal, 0, 1) * (hi - lo))
|
||||
|
||||
|
||||
# ── Color: ported from color.ts so memory ink + age fade match the desktop ──
|
||||
|
||||
|
||||
def hex_to_rgb(s: str) -> tuple[int, int, int]:
|
||||
s = s.strip().lstrip("#")
|
||||
if len(s) == 3:
|
||||
s = "".join(c * 2 for c in s)
|
||||
try:
|
||||
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
|
||||
except (ValueError, IndexError):
|
||||
return 255, 215, 0
|
||||
|
||||
|
||||
def rgb_to_hex(c: tuple) -> str:
|
||||
return "#{:02X}{:02X}{:02X}".format(*(int(_clamp(v, 0, 255)) for v in c))
|
||||
|
||||
|
||||
def mix_rgb(a: tuple, b: tuple, t: float) -> tuple[int, int, int]:
|
||||
p = _clamp(t, 0.0, 1.0)
|
||||
return tuple(round(a[i] + (b[i] - a[i]) * p) for i in range(3)) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _rgb_to_hsl(c: tuple) -> tuple[float, float, float]:
|
||||
r, g, b = (x / 255 for x in c)
|
||||
mx, mn = max(r, g, b), min(r, g, b)
|
||||
light = (mx + mn) / 2
|
||||
d = mx - mn
|
||||
if not d:
|
||||
return 0.0, 0.0, light
|
||||
s = d / (2 - mx - mn) if light > 0.5 else d / (mx + mn)
|
||||
if mx == r:
|
||||
h = (g - b) / d + (6 if g < b else 0)
|
||||
elif mx == g:
|
||||
h = (b - r) / d + 2
|
||||
else:
|
||||
h = (r - g) / d + 4
|
||||
return h * 60, s, light
|
||||
|
||||
|
||||
def _hsl_to_rgb(h: float, s: float, light: float) -> tuple[int, int, int]:
|
||||
hue = ((h % 360) + 360) % 360
|
||||
c = (1 - abs(2 * light - 1)) * s
|
||||
x = c * (1 - abs(((hue / 60) % 2) - 1))
|
||||
m = light - c / 2
|
||||
if hue < 60:
|
||||
r, g, b = c, x, 0.0
|
||||
elif hue < 120:
|
||||
r, g, b = x, c, 0.0
|
||||
elif hue < 180:
|
||||
r, g, b = 0.0, c, x
|
||||
elif hue < 240:
|
||||
r, g, b = 0.0, x, c
|
||||
elif hue < 300:
|
||||
r, g, b = x, 0.0, c
|
||||
else:
|
||||
r, g, b = c, 0.0, x
|
||||
return round((r + m) * 255), round((g + m) * 255), round((b + m) * 255)
|
||||
|
||||
|
||||
def _complementary_ink(c: tuple) -> tuple[int, int, int]:
|
||||
h, s, light = _rgb_to_hsl(c)
|
||||
return _hsl_to_rgb(h + 165, max(s, 0.5), _clamp(light, 0.5, 0.7))
|
||||
|
||||
|
||||
def derive_palette(primary_hex: str, *, dark: bool = True) -> dict[str, str]:
|
||||
"""Port of color.ts ``computePalette`` (the bits a terminal needs)."""
|
||||
primary = hex_to_rgb(primary_hex)
|
||||
base = (255, 255, 255) if dark else (0, 0, 0)
|
||||
bg = (8, 8, 12) if dark else (250, 250, 250)
|
||||
return {
|
||||
"primary": primary_hex,
|
||||
# Memories are drillable → primary "clickable" ink; skills are dead-ends
|
||||
# → muted complement.
|
||||
"memory": rgb_to_hex(mix_rgb(primary, base, 0.12 if dark else 0.18)),
|
||||
"skill": rgb_to_hex(mix_rgb(_complementary_ink(primary), bg, 0.45)),
|
||||
"label": rgb_to_hex(mix_rgb(base, bg, 0.35)),
|
||||
"dim": rgb_to_hex(mix_rgb(base, bg, 0.7)),
|
||||
"bg": rgb_to_hex(bg),
|
||||
}
|
||||
|
||||
|
||||
def _node_score(node: dict[str, Any], rec: float) -> float:
|
||||
"""Pick which visible objects deserve map markers + label rows."""
|
||||
if node.get("kind") == "memory":
|
||||
return 3.5 + rec
|
||||
use = float(node.get("useCount", 0) or 0)
|
||||
return rec * 2 + math.sqrt(max(0.0, use)) + (2.0 if node.get("pinned") else 0.0)
|
||||
|
||||
|
||||
def _node_label(node: dict[str, Any]) -> str:
|
||||
text = str(node.get("label") or node.get("id") or "unknown").strip()
|
||||
return text if len(text) <= 26 else text[:23].rstrip() + "…"
|
||||
|
||||
|
||||
def _node_meta(node: dict[str, Any]) -> str:
|
||||
if node.get("kind") == "memory":
|
||||
source = "profile memory" if node.get("memorySource") == "profile" else "memory"
|
||||
return f"{source} · {format_date(_to_ts(node.get('timestamp')))}"
|
||||
bits = [str(node.get("category") or "skill"), format_date(_to_ts(node.get("timestamp")))]
|
||||
count = int(node.get("useCount", 0) or 0)
|
||||
if count:
|
||||
bits.append(f"x{count}")
|
||||
if node.get("pinned"):
|
||||
bits.append("pinned")
|
||||
return " · ".join(bits)
|
||||
|
||||
|
||||
# ── Timeline chart frame ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _ChartBucket:
|
||||
__slots__ = ("label", "ts", "skills", "memories", "nodes", "rec")
|
||||
|
||||
def __init__(self, label: str, ts: float):
|
||||
self.label = label
|
||||
self.ts = ts
|
||||
self.skills = 0
|
||||
self.memories = 0
|
||||
self.nodes: list[dict[str, Any]] = []
|
||||
self.rec = 1.0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self.skills + self.memories
|
||||
|
||||
|
||||
def _period_key(ts: float, granularity: str) -> tuple[int, ...]:
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
if granularity == "day":
|
||||
return (dt.year, dt.month, dt.day)
|
||||
if granularity == "month":
|
||||
return (dt.year, dt.month)
|
||||
return (dt.year,)
|
||||
|
||||
|
||||
def _period_label(ts: float, granularity: str) -> str:
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
if granularity == "day":
|
||||
return f"{dt.day} {dt.strftime('%b')}"
|
||||
if granularity == "month":
|
||||
return dt.strftime("%b %Y")
|
||||
return dt.strftime("%Y")
|
||||
|
||||
|
||||
def _build_chart_buckets(nodes: list[dict[str, Any]], rec: dict[str, Any], max_rows: int) -> list[_ChartBucket]:
|
||||
"""Timeline rows: finest date granularity that fits, oldest → newest."""
|
||||
if not nodes:
|
||||
return []
|
||||
if not rec["timed"]:
|
||||
ordered = sorted(nodes, key=lambda n: rec["rec"].get(str(n.get("id", "")), 0.0))
|
||||
n_bins = min(max_rows, max(1, len(ordered)))
|
||||
buckets = [_ChartBucket(f"#{i + 1}", float(i)) for i in range(n_bins)]
|
||||
for node in ordered:
|
||||
idx = int(_clamp(math.floor(rec["rec"].get(str(node.get("id", "")), 0.0) * n_bins), 0, n_bins - 1))
|
||||
b = buckets[idx]
|
||||
b.nodes.append(node)
|
||||
if node.get("kind") == "memory":
|
||||
b.memories += 1
|
||||
else:
|
||||
b.skills += 1
|
||||
return buckets
|
||||
|
||||
chosen: Optional[list[_ChartBucket]] = None
|
||||
for granularity in ("day", "month", "year"):
|
||||
groups: dict[tuple[int, ...], _ChartBucket] = {}
|
||||
for node in nodes:
|
||||
ts = _to_ts(node.get("timestamp"))
|
||||
if ts is None:
|
||||
continue
|
||||
key = _period_key(ts, granularity)
|
||||
bucket = groups.get(key)
|
||||
if bucket is None:
|
||||
bucket = _ChartBucket(_period_label(ts, granularity), ts)
|
||||
groups[key] = bucket
|
||||
bucket.nodes.append(node)
|
||||
if node.get("kind") == "memory":
|
||||
bucket.memories += 1
|
||||
else:
|
||||
bucket.skills += 1
|
||||
# For short spans, keep the useful day-by-day graph even when the caller
|
||||
# asked for fewer rows; terminal scrollback is better than collapsing a
|
||||
# month of activity into one unreadable bar.
|
||||
if len(groups) <= max_rows or (granularity == "day" and len(groups) <= 32):
|
||||
chosen = [groups[key] for key in sorted(groups)]
|
||||
break
|
||||
|
||||
if chosen is None:
|
||||
# If even yearly buckets overflow, fall back to even time bins.
|
||||
min_ts, max_ts = rec.get("minTs"), rec.get("maxTs")
|
||||
n_bins = max(1, max_rows)
|
||||
chosen = []
|
||||
for i in range(n_bins):
|
||||
ts = min_ts + (i / max(1, n_bins - 1)) * (max_ts - min_ts) if min_ts and max_ts else float(i)
|
||||
chosen.append(_ChartBucket(format_date(ts), ts))
|
||||
for node in nodes:
|
||||
r = rec["rec"].get(str(node.get("id", "")), 0.0)
|
||||
idx = int(_clamp(math.floor(r * n_bins), 0, n_bins - 1))
|
||||
b = chosen[idx]
|
||||
b.nodes.append(node)
|
||||
if node.get("kind") == "memory":
|
||||
b.memories += 1
|
||||
else:
|
||||
b.skills += 1
|
||||
|
||||
min_ts, max_ts = rec.get("minTs"), rec.get("maxTs")
|
||||
span = (max_ts - min_ts) if min_ts is not None and max_ts is not None and max_ts > min_ts else 0
|
||||
for bucket in chosen:
|
||||
bucket.rec = LEAD_IN + (1 - LEAD_IN) * ((bucket.ts - min_ts) / span) if span else 1.0
|
||||
return chosen
|
||||
|
||||
|
||||
def _bucket_label_node(bucket: _ChartBucket) -> Optional[dict[str, Any]]:
|
||||
if not bucket.nodes:
|
||||
return None
|
||||
return max(bucket.nodes, key=lambda node: _node_score(node, _to_ts(node.get("timestamp")) or bucket.ts))
|
||||
|
||||
|
||||
def _bucket_nodes(bucket: _ChartBucket, memory_lookup: Optional[dict[str, dict[str, Any]]] = None) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
# Chronological within the slice so the TUI tree reads oldest → newest.
|
||||
ordered = sorted(bucket.nodes, key=lambda n: _to_ts(n.get("timestamp")) or bucket.ts)
|
||||
for node in ordered:
|
||||
style = STYLE_MEMORY if node.get("kind") == "memory" else STYLE_SKILL
|
||||
raw_label = str(node.get("label") or node.get("id") or "unknown").strip()
|
||||
memory = (memory_lookup or {}).get(str(node.get("id", "")))
|
||||
out.append(
|
||||
{
|
||||
"id": str(node.get("id", "")),
|
||||
"glyph": MEMORY_GLYPH if node.get("kind") == "memory" else SKILL_GLYPH,
|
||||
"label": _node_label(node),
|
||||
"fullLabel": raw_label,
|
||||
"meta": _node_meta(node),
|
||||
"body": str(memory.get("body", "")) if memory else "",
|
||||
"style": style,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _bucket_rows(buckets: list[_ChartBucket], payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
cmap = category_color_map(payload)
|
||||
memory_lookup = {
|
||||
f"memory:{card.get('source')}:{idx}": card
|
||||
for idx, card in enumerate(payload.get("memory", []) or [])
|
||||
if isinstance(card, dict)
|
||||
}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for idx, bucket in enumerate(buckets):
|
||||
cat = _bucket_category(bucket)
|
||||
rows.append(
|
||||
{
|
||||
"index": idx,
|
||||
"label": bucket.label,
|
||||
"date": format_date(bucket.ts),
|
||||
"skills": bucket.skills,
|
||||
"memories": bucket.memories,
|
||||
"total": bucket.total,
|
||||
"category": cat,
|
||||
"color": cmap.get(cat) if cat else None,
|
||||
"nodes": _bucket_nodes(bucket, memory_lookup),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _category_counts(payload: dict[str, Any]) -> list[tuple[str, int]]:
|
||||
clusters = [
|
||||
(str(c.get("category")), int(c.get("count", 0)))
|
||||
for c in payload.get("clusters", []) or []
|
||||
if c.get("category") and c.get("category") != "memory"
|
||||
]
|
||||
if clusters:
|
||||
return clusters
|
||||
counts: dict[str, int] = {}
|
||||
for node in payload.get("nodes", []):
|
||||
if node.get("kind") == "memory":
|
||||
continue
|
||||
cat = str(node.get("category") or "skill")
|
||||
counts[cat] = counts.get(cat, 0) + 1
|
||||
return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
|
||||
|
||||
def category_color_map(payload: dict[str, Any]) -> dict[str, str]:
|
||||
"""Deterministic, evenly-spread hue per skill category (theme-independent)."""
|
||||
clusters = _category_counts(payload)
|
||||
n = max(1, len(clusters))
|
||||
# Golden-angle hue spacing so adjacent categories never collide in color.
|
||||
return {cat: rgb_to_hex(_hsl_to_rgb((i * 137.508) % 360, 0.55, 0.62)) for i, (cat, _c) in enumerate(clusters)}
|
||||
|
||||
|
||||
def category_legend(payload: dict[str, Any], limit: int = 4) -> list[dict[str, Any]]:
|
||||
cmap = category_color_map(payload)
|
||||
cats = _category_counts(payload)
|
||||
shown = cats[:limit]
|
||||
hidden = max(0, len(cats) - len(shown))
|
||||
return [
|
||||
{"glyph": "●", "color": cmap.get(cat, ""), "label": f"{cat} ({count})"}
|
||||
for cat, count in shown
|
||||
] + ([{"glyph": "·", "color": "", "label": f"+{hidden}"}] if hidden else [])
|
||||
|
||||
|
||||
def _bucket_category(bucket: _ChartBucket) -> Optional[str]:
|
||||
counts: dict[str, int] = {}
|
||||
for node in bucket.nodes:
|
||||
if node.get("kind") == "memory":
|
||||
continue
|
||||
cat = str(node.get("category") or "skill")
|
||||
counts[cat] = counts.get(cat, 0) + 1
|
||||
return max(counts, key=lambda k: counts[k]) if counts else None
|
||||
|
||||
|
||||
def _trajectory_row(buckets: list[_ChartBucket], width: int, reveal: float) -> Row:
|
||||
"""Cumulative learning curve as a compact star-path sparkline."""
|
||||
if not buckets:
|
||||
return []
|
||||
total = sum(b.total for b in buckets) or 1
|
||||
visible = int(_clamp(math.ceil(reveal * len(buckets)), 0, len(buckets)))
|
||||
acc = 0
|
||||
points: list[int] = []
|
||||
for b in buckets[:visible]:
|
||||
acc += b.total
|
||||
points.append(round((acc / total) * (width - 1)))
|
||||
cells = [" "] * width
|
||||
last = 0
|
||||
for p in points:
|
||||
for x in range(min(last, p), max(last, p) + 1):
|
||||
if 0 <= x < width and cells[x] == " ":
|
||||
cells[x] = "·"
|
||||
if 0 <= p < width:
|
||||
cells[p] = "✦"
|
||||
last = p
|
||||
return [["trajectory ", STYLE_LABEL, 0.55], ["".join(cells), STYLE_SKILL, 0.48]]
|
||||
|
||||
|
||||
def render_graph(payload: dict[str, Any], *, cols: int = 80, rows: int = 16, reveal: float = 1.0) -> dict[str, Any]:
|
||||
"""Render one timeline frame at ``reveal`` (0→1).
|
||||
|
||||
Date rows with proportional skill/memory bars colored by the day's dominant
|
||||
category, numbered markers tied to label rows, and a cumulative trajectory
|
||||
sparkline underneath.
|
||||
"""
|
||||
reveal = _clamp(reveal, 0.0, 1.0)
|
||||
cols = max(44, cols)
|
||||
rows = max(14, rows)
|
||||
nodes = list(payload.get("nodes", []))
|
||||
if not nodes:
|
||||
placeholder = [["no learning yet — keep using Hermes and it maps out here", STYLE_DIM, 0.7]]
|
||||
return {"grid": [placeholder], "date": "", "reveal": reveal, "visible": 0}
|
||||
|
||||
rec = compute_recency(nodes)
|
||||
cmap = category_color_map(payload)
|
||||
buckets = _build_chart_buckets(nodes, rec, max_rows=max(4, rows - 3))
|
||||
n_buckets = len(buckets)
|
||||
visible_bucket_count = int(_clamp(math.ceil(reveal * n_buckets), 0, n_buckets))
|
||||
max_total = max((b.total for b in buckets), default=1) or 1
|
||||
label_w = min(9, max(len(b.label) for b in buckets))
|
||||
bar_w = max(14, cols - label_w - 16)
|
||||
|
||||
grid: Grid = []
|
||||
labels: list[dict[str, Any]] = []
|
||||
visible = 0
|
||||
for i, bucket in enumerate(buckets):
|
||||
if i >= visible_bucket_count:
|
||||
grid.append([])
|
||||
continue
|
||||
visible += bucket.total
|
||||
ink = recency_ink(bucket.rec)
|
||||
bar_len = max(1, round((bucket.total / max_total) * bar_w)) if bucket.total else 0
|
||||
skill_len = round((bucket.skills / bucket.total) * bar_len) if bucket.total else 0
|
||||
if bucket.skills and skill_len == 0:
|
||||
skill_len = 1
|
||||
memory_len = bar_len - skill_len
|
||||
if bucket.memories and memory_len == 0 and bar_len > 1:
|
||||
memory_len = 1
|
||||
skill_len = bar_len - 1
|
||||
|
||||
node = _bucket_label_node(bucket)
|
||||
marker = ""
|
||||
if node and len(labels) < 6:
|
||||
marker = _LABEL_KEYS[len(labels)]
|
||||
style = STYLE_MEMORY if node.get("kind") == "memory" else STYLE_SKILL
|
||||
labels.append(
|
||||
{
|
||||
"key": marker,
|
||||
"glyph": MEMORY_GLYPH if node.get("kind") == "memory" else SKILL_GLYPH,
|
||||
"label": _node_label(node),
|
||||
"meta": _node_meta(node),
|
||||
"style": style,
|
||||
"alpha": round(ink, 3),
|
||||
}
|
||||
)
|
||||
|
||||
cat = _bucket_category(bucket)
|
||||
cat_hex = cmap.get(cat) if cat else None
|
||||
|
||||
row: Row = [[f"{bucket.label:>{label_w}} ", STYLE_LABEL, ink], ["│ ", STYLE_DIM, 0.55]]
|
||||
if marker:
|
||||
row.append([marker, STYLE_LABEL, 0.95])
|
||||
elif bucket.total:
|
||||
head_hex = cat_hex if bucket.skills else None
|
||||
row.append(["✦" if bucket.skills else "◆", STYLE_SKILL if bucket.skills else STYLE_MEMORY, ink, head_hex])
|
||||
if skill_len:
|
||||
# Bar colored by the day's dominant category — a learning heatmap.
|
||||
row.append(["━" * skill_len, STYLE_SKILL, ink, cat_hex])
|
||||
if memory_len:
|
||||
if memory_len == 1:
|
||||
mem_trail = "◆"
|
||||
else:
|
||||
mem_trail = "◆" + ("━" * (memory_len - 2)) + "◆"
|
||||
row.append([mem_trail, STYLE_MEMORY, max(0.65, ink)])
|
||||
if bar_len < bar_w:
|
||||
# Empty space keeps counts aligned; starmap texture lives in the
|
||||
# trajectory row below, where it reads as signal rather than noise.
|
||||
row.append([" " * (bar_w - bar_len), STYLE_BG, 1.0])
|
||||
row.append([" ", STYLE_BG, 1.0])
|
||||
row.append([str(bucket.skills), STYLE_SKILL, max(0.72, ink)])
|
||||
if bucket.memories:
|
||||
row.append(["+", STYLE_DIM, 0.6])
|
||||
row.append([str(bucket.memories), STYLE_MEMORY, max(0.72, ink)])
|
||||
if i == visible_bucket_count - 1:
|
||||
row.append([" ◀ now", STYLE_LABEL, 0.9])
|
||||
elif bucket.total == max_total and max_total > 1:
|
||||
row.append([" ☄ peak", STYLE_LABEL, 0.75])
|
||||
grid.append(row)
|
||||
|
||||
# Cumulative learning trajectory underneath the rows.
|
||||
grid.append([[(" " * (label_w + 2)), STYLE_BG, 1.0], *_trajectory_row(buckets, max(12, cols - label_w - 13), reveal)])
|
||||
|
||||
return {
|
||||
"grid": grid,
|
||||
"date": format_date(_date_at(rec, reveal)),
|
||||
"reveal": reveal,
|
||||
"visible": visible,
|
||||
"labels": labels,
|
||||
}
|
||||
|
||||
|
||||
# ── Trimmings ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_legend(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
nodes = payload.get("nodes", [])
|
||||
skills = sum(1 for n in nodes if n.get("kind") != "memory")
|
||||
memories = sum(1 for n in nodes if n.get("kind") == "memory")
|
||||
return [
|
||||
{"glyph": SKILL_GLYPH, "style": STYLE_SKILL, "label": f"skills ({skills})"},
|
||||
{"glyph": MEMORY_GLYPH, "style": STYLE_MEMORY, "label": f"memories ({memories})"},
|
||||
]
|
||||
|
||||
|
||||
def axis_labels(payload: dict[str, Any]) -> dict[str, str]:
|
||||
rec = compute_recency(list(payload.get("nodes", [])))
|
||||
if not rec["timed"]:
|
||||
return {"start": "oldest", "end": "now"}
|
||||
return {"start": format_date(rec.get("minTs")), "end": format_date(rec.get("maxTs"))}
|
||||
|
||||
|
||||
def _peak_day(payload: dict[str, Any]) -> Optional[str]:
|
||||
counts: dict[tuple[int, ...], int] = {}
|
||||
reps: dict[tuple[int, ...], float] = {}
|
||||
for node in payload.get("nodes", []):
|
||||
ts = _to_ts(node.get("timestamp"))
|
||||
if ts is None:
|
||||
continue
|
||||
key = _period_key(ts, "day")
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
reps[key] = ts
|
||||
if not counts:
|
||||
return None
|
||||
best = max(counts, key=lambda k: counts[k])
|
||||
return f"busiest day {_period_label(reps[best], 'day')} · {counts[best]} learned"
|
||||
|
||||
|
||||
def build_summary(payload: dict[str, Any]) -> list[str]:
|
||||
stats = payload.get("stats", {}) or {}
|
||||
lines: list[str] = []
|
||||
learned = stats.get("learned_skills", stats.get("nodes", 0))
|
||||
mem = stats.get("memory_nodes", 0)
|
||||
edges = stats.get("related_edges", 0)
|
||||
lines.append(f"{learned} learned skills · {mem} memories · {edges} skill links")
|
||||
extra = []
|
||||
if stats.get("memory_skill_edges"):
|
||||
extra.append(f"{stats['memory_skill_edges']} memory↔skill links")
|
||||
peak = _peak_day(payload)
|
||||
if peak:
|
||||
extra.append(peak)
|
||||
if extra:
|
||||
lines.append(" · ".join(extra))
|
||||
return lines
|
||||
|
||||
|
||||
def _merge_runs(cells: Iterable[Run]) -> Row:
|
||||
out: Row = []
|
||||
for run in cells:
|
||||
text, style, alpha = run[0], run[1], (run[2] if len(run) > 2 else 1.0)
|
||||
hex_override = run[3] if len(run) > 3 else None
|
||||
prev_hex = out[-1][3] if out and len(out[-1]) > 3 else None
|
||||
if out and out[-1][1] == style and abs(out[-1][2] - alpha) < 1e-6 and prev_hex == hex_override:
|
||||
out[-1][0] += text
|
||||
else:
|
||||
merged: Run = [text, style, alpha]
|
||||
if hex_override:
|
||||
merged.append(hex_override)
|
||||
out.append(merged)
|
||||
return out
|
||||
|
||||
|
||||
def render_frames(payload: dict[str, Any], *, cols: int = 80, rows: int = 16, frames: int = 48) -> dict[str, Any]:
|
||||
"""Pre-render a full play-through (reveal 0→1) plus static legend/summary."""
|
||||
frames = max(2, min(frames, 240))
|
||||
nodes = list(payload.get("nodes", []))
|
||||
rec = compute_recency(nodes)
|
||||
# Mirror render_graph's bucketing so the interactive row list lines up with
|
||||
# what the user sees.
|
||||
buckets = _build_chart_buckets(nodes, rec, max_rows=max(4, rows - 3)) if nodes else []
|
||||
out_frames = []
|
||||
for i in range(frames):
|
||||
reveal = i / (frames - 1)
|
||||
frame = render_graph(payload, cols=cols, rows=rows, reveal=reveal)
|
||||
out_frames.append(
|
||||
{
|
||||
"reveal": frame["reveal"],
|
||||
"date": frame["date"],
|
||||
"visible": frame["visible"],
|
||||
"grid": frame["grid"],
|
||||
"labels": frame.get("labels", []),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"frames": out_frames,
|
||||
"legend": build_legend(payload),
|
||||
"categories": category_legend(payload),
|
||||
"buckets": _bucket_rows(buckets, payload),
|
||||
"summary": build_summary(payload),
|
||||
"axis": axis_labels(payload),
|
||||
"count": len(payload.get("nodes", [])),
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
"""User-initiated edit/delete for journey nodes (learned skills + memories).
|
||||
|
||||
The journey graph (``agent.learning_graph``) gives every node a stable id:
|
||||
|
||||
- **skills** → the skill name (e.g. ``"debugging-hermes-desktop"``)
|
||||
- **memories** → ``memory:<source>:<index>`` where ``source`` is ``memory``
|
||||
(``MEMORY.md``) or ``profile`` (``USER.md``) and ``index`` is the node's
|
||||
position in the combined card list (``MEMORY.md`` cards first, then
|
||||
``USER.md``).
|
||||
|
||||
This module maps a node id back to its on-disk home and performs the mutation,
|
||||
shared by the CLI (``hermes journey delete|edit``), the TUI ``/journey`` overlay
|
||||
(gateway RPCs), and the desktop GUI (REST). Deleting a skill *archives* it
|
||||
(recoverable via ``hermes curator restore``); deleting a memory rewrites its
|
||||
file. Pure stdlib + existing skill/memory helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_MEMORY_FILES = {"memory": "MEMORY.md", "profile": "USER.md"}
|
||||
|
||||
|
||||
def parse_node_kind(node_id: str) -> str:
|
||||
return "memory" if node_id.startswith("memory:") else "skill"
|
||||
|
||||
|
||||
def _memories_dir() -> Path:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
return get_hermes_home() / "memories"
|
||||
|
||||
|
||||
def _parse_memory_id(node_id: str) -> tuple[str, int]:
|
||||
"""``memory:<source>:<index>`` → (source, global_index)."""
|
||||
parts = node_id.split(":", 2)
|
||||
if len(parts) != 3 or parts[0] != "memory" or parts[1] not in _MEMORY_FILES:
|
||||
raise ValueError(f"bad memory node id: {node_id!r}")
|
||||
try:
|
||||
return parts[1], int(parts[2])
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"bad memory node id: {node_id!r}") from exc
|
||||
|
||||
|
||||
def _memory_local_index(source: str, global_index: int) -> int:
|
||||
"""Global card index → position within the source's own file.
|
||||
|
||||
``_memory_cards`` emits all ``MEMORY.md`` cards before ``USER.md`` cards, so
|
||||
a profile card's local index is its global index minus the memory count.
|
||||
"""
|
||||
from agent.learning_graph import _memory_cards
|
||||
|
||||
cards = _memory_cards()
|
||||
if not 0 <= global_index < len(cards):
|
||||
raise IndexError(f"memory index {global_index} out of range")
|
||||
if cards[global_index].get("source") != source:
|
||||
raise ValueError("memory node id is stale — refresh the graph")
|
||||
if source == "memory":
|
||||
return global_index
|
||||
return global_index - sum(1 for c in cards if c.get("source") == "memory")
|
||||
|
||||
|
||||
def _locate_memory(source: str, gidx: int) -> tuple[Path, list[str], int]:
|
||||
"""Resolve a memory card to its file, all §-delimited entries, and local index.
|
||||
|
||||
Entries come from ``MemoryStore._read_file`` — the same parser the memory
|
||||
tool uses — so journey indices stay aligned with what the graph renders.
|
||||
"""
|
||||
from tools.memory_tool import MemoryStore
|
||||
|
||||
path = _memories_dir() / _MEMORY_FILES[source]
|
||||
if not path.exists():
|
||||
raise ValueError(f"{path.name} not found")
|
||||
chunks = MemoryStore._read_file(path)
|
||||
local = _memory_local_index(source, gidx)
|
||||
if not 0 <= local < len(chunks):
|
||||
raise ValueError("memory node id is stale — refresh the graph")
|
||||
return path, chunks, local
|
||||
|
||||
|
||||
# ── Inspect (edit prefill) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def node_detail(node_id: str) -> dict[str, Any]:
|
||||
"""Current content for an edit prefill. ``content`` is the full SKILL.md
|
||||
(skills) or the raw memory chunk (memories)."""
|
||||
try:
|
||||
return _node_detail(node_id)
|
||||
except (ValueError, IndexError) as exc:
|
||||
return {"ok": False, "message": str(exc)}
|
||||
|
||||
|
||||
def _node_detail(node_id: str) -> dict[str, Any]:
|
||||
if parse_node_kind(node_id) == "memory":
|
||||
source, gidx = _parse_memory_id(node_id)
|
||||
_, chunks, local = _locate_memory(source, gidx)
|
||||
body = chunks[local].strip()
|
||||
|
||||
return {"ok": True, "kind": "memory", "id": node_id, "label": body.splitlines()[0][:80], "content": body}
|
||||
|
||||
from tools.skill_manager_tool import _find_skill
|
||||
|
||||
found = _find_skill(node_id)
|
||||
if not found:
|
||||
return {"ok": False, "message": f"skill '{node_id}' not found"}
|
||||
skill_md = Path(found["path"]) / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
return {"ok": False, "message": f"SKILL.md missing for '{node_id}'"}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"kind": "skill",
|
||||
"id": node_id,
|
||||
"label": node_id,
|
||||
"content": skill_md.read_text(encoding="utf-8"),
|
||||
}
|
||||
|
||||
|
||||
# ── Delete ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def delete_node(node_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return _delete_memory(node_id) if parse_node_kind(node_id) == "memory" else _delete_skill(node_id)
|
||||
except (ValueError, IndexError) as exc:
|
||||
return {"ok": False, "message": str(exc)}
|
||||
|
||||
|
||||
def _delete_skill(name: str) -> dict[str, Any]:
|
||||
from tools import skill_usage
|
||||
|
||||
if skill_usage.get_record(name).get("pinned"):
|
||||
return {"ok": False, "message": f"'{name}' is pinned — unpin it first (hermes curator unpin {name})"}
|
||||
|
||||
ok, message = skill_usage.archive_skill(name)
|
||||
if ok:
|
||||
_clear_skill_cache()
|
||||
|
||||
return {"ok": ok, "message": f"archived '{name}' — restore with: hermes curator restore {name}" if ok else message}
|
||||
|
||||
|
||||
def _delete_memory(node_id: str) -> dict[str, Any]:
|
||||
source, gidx = _parse_memory_id(node_id)
|
||||
path, chunks, local = _locate_memory(source, gidx)
|
||||
|
||||
del chunks[local]
|
||||
_write_memory(path, chunks)
|
||||
|
||||
return {"ok": True, "message": f"deleted memory from {path.name}"}
|
||||
|
||||
|
||||
# ── Edit ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def edit_node(node_id: str, content: str) -> dict[str, Any]:
|
||||
try:
|
||||
return _edit_memory(node_id, content) if parse_node_kind(node_id) == "memory" else _edit_skill(node_id, content)
|
||||
except (ValueError, IndexError) as exc:
|
||||
return {"ok": False, "message": str(exc)}
|
||||
|
||||
|
||||
def _edit_skill(name: str, content: str) -> dict[str, Any]:
|
||||
from tools.skill_manager_tool import _edit_skill as _do_edit
|
||||
|
||||
result = _do_edit(name, content)
|
||||
if result.get("success"):
|
||||
_clear_skill_cache()
|
||||
|
||||
return {"ok": True, "message": f"updated '{name}'"}
|
||||
|
||||
return {"ok": False, "message": result.get("error", "edit failed")}
|
||||
|
||||
|
||||
def _edit_memory(node_id: str, content: str) -> dict[str, Any]:
|
||||
source, gidx = _parse_memory_id(node_id)
|
||||
body = content.strip()
|
||||
if not body:
|
||||
return {"ok": False, "message": "empty memory — use delete to remove it"}
|
||||
path, chunks, local = _locate_memory(source, gidx)
|
||||
|
||||
chunks[local] = body
|
||||
_write_memory(path, chunks)
|
||||
|
||||
return {"ok": True, "message": f"updated memory in {path.name}"}
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_memory(path: Path, chunks: list[str]) -> None:
|
||||
"""Atomic temp-file + rename via the memory tool, so a concurrent reader
|
||||
never sees a half-written file (and the §-join stays single-sourced)."""
|
||||
from tools.memory_tool import MemoryStore
|
||||
|
||||
MemoryStore._write_file(path, [c.strip() for c in chunks if c.strip()])
|
||||
|
||||
|
||||
def _clear_skill_cache() -> None:
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -20,17 +20,6 @@ _LM_VALID_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"}
|
||||
# Map them onto the OpenAI-compatible request vocabulary.
|
||||
_LM_EFFORT_ALIASES = {"off": "none", "on": "medium"}
|
||||
|
||||
# Hermes' generic effort ladder grew past LM Studio's vocabulary ("max",
|
||||
# "ultra"). Clamp the stronger generic levels onto LM Studio's ceiling: left
|
||||
# alone they miss _LM_VALID_EFFORTS, keep the initialized "medium" default and
|
||||
# are thereby conflated with unparseable input, so asking for more reasoning
|
||||
# yields less than "xhigh". Mirrors the ceiling clamp every other provider
|
||||
# applies (see agent/transports/codex.py).
|
||||
#
|
||||
# Deliberately separate from _LM_EFFORT_ALIASES: that mapping is also applied
|
||||
# to the model's published allowed_options, which must not be rewritten.
|
||||
_LM_EFFORT_CLAMP = {"max": "xhigh", "ultra": "xhigh"}
|
||||
|
||||
|
||||
def resolve_lmstudio_effort(
|
||||
reasoning_config: Optional[dict],
|
||||
@@ -50,7 +39,6 @@ def resolve_lmstudio_effort(
|
||||
else:
|
||||
raw = (reasoning_config.get("effort") or "").strip().lower()
|
||||
raw = _LM_EFFORT_ALIASES.get(raw, raw)
|
||||
raw = _LM_EFFORT_CLAMP.get(raw, raw)
|
||||
if raw in _LM_VALID_EFFORTS:
|
||||
effort = raw
|
||||
if allowed_options:
|
||||
|
||||
+71
-148
@@ -18,15 +18,9 @@ into it via :func:`agent.lsp.manager.LSPService.touch_file`.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- All per-document state lives in one :class:`_DocState` keyed by
|
||||
absolute path. Freshness is tracked with **document versions**,
|
||||
not timestamps: every didChange bumps ``version``, and each stored
|
||||
push/pull result is tagged with the version it describes. A
|
||||
result is fresh iff its tag >= the version being waited on, so a
|
||||
didChange implicitly invalidates everything older — no clearing,
|
||||
no clock comparisons, no race windows. This is what prevents
|
||||
"ghost diagnostics": a slow server's leftovers from the previous
|
||||
edit can never masquerade as a verdict on the current content.
|
||||
- Push diagnostics are stored per-URI in :attr:`_push_diagnostics` from
|
||||
``textDocument/publishDiagnostics`` notifications. Pull diagnostics
|
||||
go in :attr:`_pull_diagnostics`. The merged view dedupes by content.
|
||||
|
||||
- Whole-document sync. Even when the server advertises incremental
|
||||
sync, we send a single ``contentChanges`` entry replacing the
|
||||
@@ -51,7 +45,6 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
|
||||
from urllib.parse import quote, unquote
|
||||
@@ -131,40 +124,6 @@ def _end_position(text: str) -> Dict[str, int]:
|
||||
return {"line": last_line, "character": last_col}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DocState:
|
||||
"""Everything the client tracks for one open document.
|
||||
|
||||
``version`` is the LSP document version we last sent (didOpen=0,
|
||||
each didChange +1). It doubles as the freshness token: stored
|
||||
push/pull results are tagged with the version they describe
|
||||
(``push_version`` / ``pull_version``), and a result is *fresh*
|
||||
iff its tag has caught up to ``version``. Bumping the version on
|
||||
didChange therefore invalidates all older results implicitly —
|
||||
no store-clearing, no timestamps.
|
||||
|
||||
``push_version``/``pull_version`` start at -1 = "no data yet".
|
||||
Servers that echo a document version in publishDiagnostics get
|
||||
exact tagging; those that don't are credited with the current
|
||||
version at receipt time (a push observed after we sent the
|
||||
change describes the changed content or newer).
|
||||
"""
|
||||
|
||||
version: int = 0
|
||||
text: str = ""
|
||||
push: List[Dict[str, Any]] = field(default_factory=list)
|
||||
pull: List[Dict[str, Any]] = field(default_factory=list)
|
||||
push_version: int = -1
|
||||
pull_version: int = -1
|
||||
seed_seen: bool = False
|
||||
|
||||
def fresh_push(self, version: Optional[int] = None) -> bool:
|
||||
return self.push_version >= (self.version if version is None else version)
|
||||
|
||||
def fresh_pull(self, version: Optional[int] = None) -> bool:
|
||||
return self.pull_version >= (self.version if version is None else version)
|
||||
|
||||
|
||||
class LSPClient:
|
||||
"""Async LSP client tied to one server process and one workspace root.
|
||||
|
||||
@@ -227,10 +186,18 @@ class LSPClient:
|
||||
# is silently dropped by default.
|
||||
}
|
||||
|
||||
# Per-document state (version, text, diagnostic stores, and
|
||||
# their freshness tags), keyed by absolute file path (NOT URI).
|
||||
# See _DocState for the version-based freshness model.
|
||||
self._docs: Dict[str, _DocState] = {}
|
||||
# Tracked file state — required for didChange version bumps.
|
||||
self._files: Dict[str, Dict[str, Any]] = {}
|
||||
# Diagnostic stores, keyed by file path (NOT URI).
|
||||
self._push_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
|
||||
self._pull_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
|
||||
# Per-path "last published" time so wait-for-fresh logic works.
|
||||
self._published: Dict[str, float] = {}
|
||||
# Per-path version of the latest push (matches our didChange
|
||||
# version when the server respects it).
|
||||
self._published_version: Dict[str, int] = {}
|
||||
# First-push seen flag, for typescript-style seed-on-first-push.
|
||||
self._first_push_seen: Set[str] = set()
|
||||
# Capability registrations — only diagnostic ones are tracked.
|
||||
self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@@ -296,13 +263,6 @@ class LSPClient:
|
||||
cmd = self._win_wrap_cmd(cmd)
|
||||
|
||||
try:
|
||||
# start_new_session=True detaches the LSP server into its own
|
||||
# process group / session. Without this, the LSP server inherits
|
||||
# the gateway's pgid (= TUI parent PID). When mcp_tool's
|
||||
# _kill_orphaned_mcp_children races with LSP spawn and sweeps the
|
||||
# gateway's child set, it captures the LSP PID, records the
|
||||
# inherited pgid, and killpg() then kills the TUI parent itself.
|
||||
# See tui_gateway_crash.log "killpg → SIGTERM received" stacks.
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
cmd[0],
|
||||
*cmd[1:],
|
||||
@@ -311,7 +271,6 @@ class LSPClient:
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
cwd=self._cwd,
|
||||
start_new_session=True,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise LSPProtocolError(
|
||||
@@ -680,25 +639,25 @@ class LSPClient:
|
||||
if not isinstance(diagnostics, list):
|
||||
diagnostics = []
|
||||
version = params.get("version")
|
||||
loop_time = asyncio.get_event_loop().time()
|
||||
|
||||
doc = self._docs.setdefault(path, _DocState(version=-1))
|
||||
if self._seed_first_push and not doc.seed_seen:
|
||||
# First push: seed the store WITHOUT a freshness tag. It
|
||||
# arrives before the user-triggered didChange could've
|
||||
# produced fresh diagnostics, so it must never satisfy a
|
||||
# waiter — it's baseline data only.
|
||||
doc.seed_seen = True
|
||||
doc.push = diagnostics
|
||||
if self._seed_first_push and path not in self._first_push_seen:
|
||||
# First push: seed without firing the event so a waiter
|
||||
# doesn't resolve on the very first push (which arrives
|
||||
# before the user-triggered didChange could've produced
|
||||
# fresh diagnostics).
|
||||
self._first_push_seen.add(path)
|
||||
self._push_diagnostics[path] = diagnostics
|
||||
self._published[path] = loop_time
|
||||
if isinstance(version, int):
|
||||
self._published_version[path] = version
|
||||
return
|
||||
|
||||
doc.seed_seen = True
|
||||
doc.push = diagnostics
|
||||
# Tag with the echoed document version when the server provides
|
||||
# one; otherwise credit the current version — a push observed
|
||||
# after we sent the change describes the changed content (or
|
||||
# newer). Note doc.version is -1 for never-opened paths
|
||||
# (e.g. relatedDocuments spillover), keeping them unfresh.
|
||||
doc.push_version = version if isinstance(version, int) else doc.version
|
||||
self._push_diagnostics[path] = diagnostics
|
||||
self._published[path] = loop_time
|
||||
if isinstance(version, int):
|
||||
self._published_version[path] = version
|
||||
self._first_push_seen.add(path)
|
||||
# Bump the monotonic push counter and wake every waiter. We
|
||||
# keep the Event sticky-set so any wait already in progress
|
||||
# resolves; waiters re-check their predicate after waking and
|
||||
@@ -727,16 +686,16 @@ class LSPClient:
|
||||
raise LSPProtocolError(f"cannot read {abs_path}: {e}") from e
|
||||
|
||||
uri = file_uri(abs_path)
|
||||
doc = self._docs.get(abs_path)
|
||||
existing = self._files.get(abs_path)
|
||||
|
||||
if doc is not None and doc.version >= 0:
|
||||
if existing is not None:
|
||||
# Re-open: bump version, fire didChangeWatchedFiles + didChange.
|
||||
await self._send_notification(
|
||||
"workspace/didChangeWatchedFiles",
|
||||
{"changes": [{"uri": uri, "type": 2}]}, # 2 = CHANGED
|
||||
)
|
||||
new_version = doc.version + 1
|
||||
old_text = doc.text
|
||||
new_version = existing["version"] + 1
|
||||
old_text = existing["text"]
|
||||
content_changes: List[Dict[str, Any]]
|
||||
if self._sync_kind == 2:
|
||||
content_changes = [
|
||||
@@ -757,11 +716,7 @@ class LSPClient:
|
||||
"contentChanges": content_changes,
|
||||
},
|
||||
)
|
||||
# Bumping the version is the whole invalidation story:
|
||||
# every stored result tagged with an older version is now
|
||||
# stale by definition (see _DocState).
|
||||
doc.version = new_version
|
||||
doc.text = text
|
||||
self._files[abs_path] = {"version": new_version, "text": text}
|
||||
return new_version
|
||||
|
||||
# First open: didChangeWatchedFiles CREATED + didOpen.
|
||||
@@ -769,9 +724,12 @@ class LSPClient:
|
||||
"workspace/didChangeWatchedFiles",
|
||||
{"changes": [{"uri": uri, "type": 1}]}, # 1 = CREATED
|
||||
)
|
||||
# Fresh doc state — anything stashed under this path by a
|
||||
# pre-open push (relatedDocuments spillover etc.) is discarded.
|
||||
self._docs[abs_path] = _DocState(version=0, text=text)
|
||||
# Clear any stale push/pull entries — fresh open should start
|
||||
# from scratch.
|
||||
self._push_diagnostics.pop(abs_path, None)
|
||||
self._pull_diagnostics.pop(abs_path, None)
|
||||
self._published.pop(abs_path, None)
|
||||
self._published_version.pop(abs_path, None)
|
||||
await self._send_notification(
|
||||
"textDocument/didOpen",
|
||||
{
|
||||
@@ -783,6 +741,7 @@ class LSPClient:
|
||||
}
|
||||
},
|
||||
)
|
||||
self._files[abs_path] = {"version": 0, "text": text}
|
||||
return 0
|
||||
|
||||
async def save_file(self, path: str) -> None:
|
||||
@@ -802,19 +761,12 @@ class LSPClient:
|
||||
async def _pull_document_diagnostics(self, path: str) -> None:
|
||||
"""Send ``textDocument/diagnostic`` for one file.
|
||||
|
||||
Stores results into the doc's pull store, tagged with the
|
||||
document version captured at request send time. If a didChange
|
||||
races past the in-flight request, the version bump makes the
|
||||
stored result stale automatically — no explicit invalidation.
|
||||
Silently no-ops on errors (server may not support the pull
|
||||
endpoint).
|
||||
Stores results into :attr:`_pull_diagnostics`. Silently
|
||||
no-ops on errors (server may not support the pull endpoint).
|
||||
"""
|
||||
abs_path = os.path.abspath(path)
|
||||
doc = self._docs.get(abs_path)
|
||||
sent_version = doc.version if doc else -1
|
||||
try:
|
||||
params: Dict[str, Any] = {
|
||||
"textDocument": {"uri": file_uri(abs_path)}
|
||||
"textDocument": {"uri": file_uri(os.path.abspath(path))}
|
||||
}
|
||||
result = await self._send_request_with_retry(
|
||||
"textDocument/diagnostic",
|
||||
@@ -828,9 +780,7 @@ class LSPClient:
|
||||
return
|
||||
items = result.get("items")
|
||||
if isinstance(items, list):
|
||||
doc = self._docs.setdefault(abs_path, _DocState(version=-1))
|
||||
doc.pull = items
|
||||
doc.pull_version = sent_version
|
||||
self._pull_diagnostics[os.path.abspath(path)] = items
|
||||
related = result.get("relatedDocuments")
|
||||
if isinstance(related, dict):
|
||||
for uri, sub in related.items():
|
||||
@@ -838,11 +788,7 @@ class LSPClient:
|
||||
continue
|
||||
sub_items = sub.get("items")
|
||||
if isinstance(sub_items, list):
|
||||
rel = self._docs.setdefault(uri_to_path(uri), _DocState(version=-1))
|
||||
rel.pull = sub_items
|
||||
# Same send-anchored tagging: fresh only if that
|
||||
# doc hasn't changed since the request went out.
|
||||
rel.pull_version = rel.version
|
||||
self._pull_diagnostics[uri_to_path(uri)] = sub_items
|
||||
|
||||
async def wait_for_diagnostics(
|
||||
self,
|
||||
@@ -850,36 +796,22 @@ class LSPClient:
|
||||
version: int,
|
||||
*,
|
||||
mode: str = "document",
|
||||
timeout: Optional[float] = None,
|
||||
) -> bool:
|
||||
) -> None:
|
||||
"""Wait for the server to publish diagnostics for ``path`` at ``version``.
|
||||
|
||||
``mode`` is ``"document"`` (5s budget, document pulls) or
|
||||
``"full"`` (10s budget, also workspace pulls). ``timeout``
|
||||
overrides the mode's default budget when provided — this is
|
||||
how the user's ``lsp.wait_timeout`` config reaches the wait
|
||||
loop (slow servers like tsserver on big projects need more
|
||||
than the 5s default).
|
||||
|
||||
Returns ``True`` when *fresh* diagnostics arrived (a push at
|
||||
or after our didChange, or a pull answered after it) and
|
||||
``False`` on timeout. Callers must treat ``False`` as "no
|
||||
data", NOT as "no errors" — the diagnostic stores may still
|
||||
hold stale entries from the previous edit at that point.
|
||||
Best-effort — never throws if the server doesn't support pull
|
||||
diagnostics; we still get the push side.
|
||||
``"full"`` (10s budget, also workspace pulls). Best-effort —
|
||||
returns silently on timeout. Does NOT throw if the server
|
||||
doesn't support pull diagnostics; we still get the push side.
|
||||
"""
|
||||
if timeout is not None and timeout > 0:
|
||||
budget = timeout
|
||||
else:
|
||||
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
|
||||
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
|
||||
deadline = asyncio.get_event_loop().time() + budget
|
||||
abs_path = os.path.abspath(path)
|
||||
|
||||
while True:
|
||||
remaining = deadline - asyncio.get_event_loop().time()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
return
|
||||
|
||||
# Concurrent: document pull + push wait.
|
||||
pull_task = asyncio.create_task(self._pull_document_diagnostics(abs_path))
|
||||
@@ -898,24 +830,26 @@ class LSPClient:
|
||||
pass
|
||||
|
||||
# If we got a fresh push for our version, we're done.
|
||||
doc = self._docs.get(abs_path)
|
||||
if doc and doc.fresh_push(version):
|
||||
return True
|
||||
current_v = self._published_version.get(abs_path)
|
||||
if abs_path in self._published and (
|
||||
current_v is None or current_v >= version
|
||||
):
|
||||
return
|
||||
|
||||
# Pull may have answered for the current version — that's
|
||||
# also success.
|
||||
if doc and doc.fresh_pull(version):
|
||||
return True
|
||||
# Pull may have populated _pull_diagnostics — that's also
|
||||
# success.
|
||||
if abs_path in self._pull_diagnostics:
|
||||
return
|
||||
|
||||
# Loop until budget runs out.
|
||||
|
||||
async def _wait_for_fresh_push(self, path: str, version: int, timeout: float) -> None:
|
||||
"""Wait until a fresh publishDiagnostics arrives for ``path`` at ``version``+."""
|
||||
"""Wait until a publishDiagnostics arrives for ``path`` at ``version``+."""
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
baseline = self._push_counter
|
||||
while True:
|
||||
doc = self._docs.get(path)
|
||||
if doc and doc.fresh_push(version):
|
||||
current_v = self._published_version.get(path)
|
||||
if path in self._published and (current_v is None or current_v >= version):
|
||||
# Debounce — wait a tick in case more diagnostics arrive
|
||||
# immediately after. TS often emits in pairs. We
|
||||
# snapshot the counter so we wake on a *new* push, not
|
||||
@@ -946,28 +880,17 @@ class LSPClient:
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> List[Dict[str, Any]]:
|
||||
def diagnostics_for(self, path: str) -> List[Dict[str, Any]]:
|
||||
"""Return current merged + deduped diagnostics for one file.
|
||||
|
||||
Diagnostics from push and pull stores are concatenated and
|
||||
deduplicated by ``(severity, code, message, range)`` content
|
||||
key. Empty list if the server hasn't published anything.
|
||||
|
||||
With ``fresh_only=True``, a store only contributes when its
|
||||
version tag has caught up to the document's current version —
|
||||
stale leftovers from the previous edit cycle are excluded.
|
||||
This is what report paths should use: after an edit, "stale
|
||||
errors" and "no errors" must not be conflated.
|
||||
"""
|
||||
doc = self._docs.get(os.path.abspath(path))
|
||||
if doc is None:
|
||||
return []
|
||||
if fresh_only:
|
||||
return _dedupe(
|
||||
doc.push if doc.fresh_push() else [],
|
||||
doc.pull if doc.fresh_pull() else [],
|
||||
)
|
||||
return _dedupe(doc.push, doc.pull)
|
||||
abs_path = os.path.abspath(path)
|
||||
push = self._push_diagnostics.get(abs_path) or []
|
||||
pull = self._pull_diagnostics.get(abs_path) or []
|
||||
return _dedupe(push, pull)
|
||||
|
||||
|
||||
def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
|
||||
+7
-10
@@ -102,11 +102,6 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
|
||||
# Lua — manual (LuaLS is platform-specific binaries from GitHub
|
||||
# releases; complex enough that we punt to the user)
|
||||
"lua-language-server": {"strategy": "manual", "pkg": "", "bin": "lua-language-server"},
|
||||
# PowerShell — PowerShellEditorServices ships as a GitHub release
|
||||
# zip driven by a pwsh bootstrap script, not a single binary. We
|
||||
# require a manual bundle install and probe for the pwsh host so
|
||||
# `hermes lsp status` reports the host's presence.
|
||||
"powershell": {"strategy": "manual", "pkg": "", "bin": "pwsh"},
|
||||
}
|
||||
|
||||
|
||||
@@ -348,15 +343,17 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
||||
pip_target.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
logger.info("[install] pip install --target %s %s", pip_target, pkg)
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
proc = _pip_install(
|
||||
["--target", str(pip_target), "--quiet", pkg],
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"[install] pip install failed for %s: %s", pkg, (proc.stderr or "").strip()[:500]
|
||||
"[install] pip install failed for %s: %s", pkg, proc.stderr.strip()[:500]
|
||||
)
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
|
||||
+10
-41
@@ -292,10 +292,7 @@ class LSPService:
|
||||
if not self.enabled_for(file_path):
|
||||
return
|
||||
try:
|
||||
# Outer join budget must exceed the inner wait budget or a
|
||||
# slow-but-alive server gets falsely marked broken.
|
||||
t = max(8.0, self._wait_timeout + 3.0)
|
||||
diags = self._loop.run(self._snapshot_async(file_path), timeout=t)
|
||||
diags = self._loop.run(self._snapshot_async(file_path), timeout=8.0)
|
||||
self._delta_baseline[os.path.abspath(file_path)] = diags or []
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("baseline snapshot failed for %s: %s", file_path, e)
|
||||
@@ -344,7 +341,7 @@ class LSPService:
|
||||
|
||||
try:
|
||||
t = timeout if timeout is not None else self._wait_timeout + 2.0
|
||||
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t)
|
||||
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) or []
|
||||
except asyncio.TimeoutError as e:
|
||||
eventlog.log_timeout(server_id, file_path)
|
||||
logger.debug("LSP diagnostics timeout for %s: %s", file_path, e)
|
||||
@@ -356,17 +353,6 @@ class LSPService:
|
||||
self._mark_broken_for_file(file_path, e)
|
||||
return []
|
||||
|
||||
if diags is None:
|
||||
# The server is alive but never produced diagnostics for the
|
||||
# post-edit content within the wait budget (common for
|
||||
# tsserver on large projects). Report "no data" rather than
|
||||
# whatever stale state is in the stores — surfacing the
|
||||
# previous edit's errors as if they were current is the
|
||||
# ghost-diagnostics bug. The server is NOT marked broken:
|
||||
# slow is not dead, and the next edit may well succeed.
|
||||
eventlog.log_timeout(server_id, file_path, kind="fresh diagnostics")
|
||||
return []
|
||||
|
||||
abs_path = os.path.abspath(file_path)
|
||||
if delta:
|
||||
baseline = self._delta_baseline.get(abs_path) or []
|
||||
@@ -466,43 +452,26 @@ class LSPService:
|
||||
return []
|
||||
try:
|
||||
version = await client.open_file(file_path, language_id=language_id_for(file_path))
|
||||
fresh = await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("snapshot open/wait failed: %s", e)
|
||||
return []
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
if not fresh:
|
||||
# No fresh data for the pre-edit content — an empty baseline
|
||||
# is safe: worst case the delta filter removes less, never
|
||||
# more. Never seed the baseline from stale stores.
|
||||
return []
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
return list(client.diagnostics_for(file_path))
|
||||
|
||||
async def _open_and_wait_async(self, file_path: str) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Open + wait for FRESH diagnostics.
|
||||
|
||||
Returns the fresh diagnostic list, or ``None`` when the server
|
||||
never produced post-change data within the wait budget. The
|
||||
distinction matters: ``[]`` means "server checked the new
|
||||
content, it's clean", ``None`` means "no verdict" — the caller
|
||||
must not substitute stale data for either.
|
||||
"""
|
||||
async def _open_and_wait_async(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
client = await self._get_or_spawn(file_path)
|
||||
if client is None:
|
||||
return None
|
||||
return []
|
||||
try:
|
||||
version = await client.open_file(file_path, language_id=language_id_for(file_path))
|
||||
await client.save_file(file_path)
|
||||
fresh = await client.wait_for_diagnostics(
|
||||
file_path, version, mode=self._wait_mode, timeout=self._wait_timeout
|
||||
)
|
||||
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("open/wait failed for %s: %s", file_path, e)
|
||||
return None
|
||||
return []
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
if not fresh:
|
||||
return None
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
return list(client.diagnostics_for(file_path))
|
||||
|
||||
async def _current_diags_async(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
ws, gated = resolve_workspace_for_file(file_path)
|
||||
@@ -513,7 +482,7 @@ class LSPService:
|
||||
client = self._clients.get((srv.server_id, ws))
|
||||
if client is None:
|
||||
return []
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
return list(client.diagnostics_for(file_path))
|
||||
|
||||
async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]:
|
||||
srv = find_server_for_file(file_path)
|
||||
|
||||
@@ -91,7 +91,7 @@ async def read_message(reader: asyncio.StreamReader) -> Optional[dict]:
|
||||
header_bytes += len(line)
|
||||
if header_bytes > 8192:
|
||||
raise LSPProtocolError(
|
||||
"LSP header block exceeded 8 KiB without terminator"
|
||||
f"LSP header block exceeded 8 KiB without terminator"
|
||||
)
|
||||
line = line[:-2] # strip CRLF
|
||||
if not line:
|
||||
|
||||
+6
-58
@@ -8,7 +8,6 @@ OpenCode's ``lsp/diagnostic.ts`` and Claude Code's
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Severity-1 only by default — warnings/info/hints would flood the
|
||||
@@ -19,65 +18,18 @@ DEFAULT_SEVERITIES = frozenset({1}) # ERROR only
|
||||
MAX_PER_FILE = 20
|
||||
MAX_TOTAL_CHARS = 4000
|
||||
|
||||
# Per-field caps for diagnostic content sourced from the language server.
|
||||
# These bound the length of any single attacker-controlled identifier that
|
||||
# can ride into the model's tool output via an LSP diagnostic message.
|
||||
MAX_MESSAGE_CHARS = 300
|
||||
MAX_CODE_CHARS = 80
|
||||
MAX_SOURCE_CHARS = 80
|
||||
|
||||
|
||||
def _sanitize_field(value: Any, *, limit: int) -> str:
|
||||
"""Make a language-server field safe to embed in a tool-result block.
|
||||
|
||||
Diagnostic ``message``, ``code``, and ``source`` originate from a
|
||||
language server that has just parsed user-controlled source code, so
|
||||
they're untrusted from the agent's point of view. A hostile repo can
|
||||
place instruction-shaped text inside identifier names, type aliases,
|
||||
or import paths so the resulting diagnostic echoes that text back
|
||||
into the ``<diagnostics>`` block the model reads.
|
||||
|
||||
This helper:
|
||||
|
||||
* Collapses CR/LF so a raw newline can't synthesize a new line in the
|
||||
formatted block.
|
||||
* Drops non-printable ASCII control characters that have no business
|
||||
in a single-line summary.
|
||||
* Caps length per-field so a long identifier can't push past the
|
||||
block boundary.
|
||||
* HTML-escapes ``< > &`` so the result can't close ``<diagnostics>``
|
||||
early or open a new tag.
|
||||
|
||||
Returns ``""`` for ``None`` / empty so the surrounding format string
|
||||
naturally omits the part (mirrors the prior ``if code not in {None,
|
||||
""}`` check at call sites).
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
raw = str(value)
|
||||
# Collapse newlines so identifier text with raw \n can't fake new lines.
|
||||
raw = raw.replace("\r", " ").replace("\n", " ")
|
||||
# Drop ASCII control chars; keep regular spaces.
|
||||
raw = "".join(ch for ch in raw if ch == " " or ch.isprintable())
|
||||
raw = raw.strip()[:limit]
|
||||
return html.escape(raw, quote=False)
|
||||
|
||||
|
||||
def format_diagnostic(d: Dict[str, Any]) -> str:
|
||||
"""One-line representation of a single diagnostic.
|
||||
|
||||
``message``, ``code``, and ``source`` are sanitized before
|
||||
interpolation — see ``_sanitize_field``.
|
||||
"""
|
||||
"""One-line representation of a single diagnostic."""
|
||||
sev = SEVERITY_NAMES.get(d.get("severity") or 1, "ERROR")
|
||||
rng = d.get("range") or {}
|
||||
start = rng.get("start") or {}
|
||||
line = int(start.get("line", 0)) + 1
|
||||
col = int(start.get("character", 0)) + 1
|
||||
msg = _sanitize_field(d.get("message"), limit=MAX_MESSAGE_CHARS)
|
||||
code = _sanitize_field(d.get("code"), limit=MAX_CODE_CHARS)
|
||||
code_part = f" [{code}]" if code else ""
|
||||
source = _sanitize_field(d.get("source"), limit=MAX_SOURCE_CHARS)
|
||||
msg = str(d.get("message") or "").rstrip()
|
||||
code = d.get("code")
|
||||
code_part = f" [{code}]" if code not in {None, ""} else ""
|
||||
source = d.get("source")
|
||||
source_part = f" ({source})" if source else ""
|
||||
return f"{sev} [{line}:{col}] {msg}{code_part}{source_part}"
|
||||
|
||||
@@ -105,11 +57,7 @@ def report_for_file(
|
||||
body = "\n".join(lines)
|
||||
if extra > 0:
|
||||
body += f"\n... and {extra} more"
|
||||
# quote=True escapes both ``"`` and ``&`` so a crafted file name like
|
||||
# ``foo"><script`` can't break out of the ``file="..."`` attribute and
|
||||
# synthesize new tags inside the tool output.
|
||||
safe_path = html.escape(file_path, quote=True)
|
||||
return f"<diagnostics file=\"{safe_path}\">\n{body}\n</diagnostics>"
|
||||
return f"<diagnostics file=\"{file_path}\">\n{body}\n</diagnostics>"
|
||||
|
||||
|
||||
def truncate(s: str, *, limit: int = MAX_TOTAL_CHARS) -> str:
|
||||
|
||||
@@ -102,9 +102,6 @@ LANGUAGE_BY_EXT: Dict[str, str] = {
|
||||
".zig": "zig",
|
||||
".zon": "zig",
|
||||
".dockerfile": "dockerfile",
|
||||
".ps1": "powershell",
|
||||
".psm1": "powershell",
|
||||
".psd1": "powershell",
|
||||
}
|
||||
|
||||
|
||||
@@ -679,131 +676,6 @@ def _spawn_astro(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
|
||||
)
|
||||
|
||||
|
||||
_PSES_BUNDLE_WARNED = False
|
||||
|
||||
|
||||
def _find_pses_bundle(ctx: ServerContext) -> Optional[str]:
|
||||
"""Locate the PowerShellEditorServices module bundle directory.
|
||||
|
||||
PSES ships as a GitHub release zip (not an npm/go/pip package), so
|
||||
there's no auto-install recipe — the user downloads it and points us
|
||||
at the extracted bundle. Resolution order:
|
||||
|
||||
1. ``command`` override in config (``lsp.servers.powershell.command``) —
|
||||
the FIRST element is treated as the bundle path when it's a
|
||||
directory. This is the documented config knob.
|
||||
2. ``init_overrides["powershell"]["bundlePath"]``.
|
||||
3. ``PSES_BUNDLE_PATH`` env var.
|
||||
4. ``<HERMES_HOME>/lsp/PowerShellEditorServices`` staging dir (where a
|
||||
user-run unzip would naturally land).
|
||||
|
||||
Returns the bundle directory containing ``PowerShellEditorServices/``,
|
||||
or ``None`` when it can't be found.
|
||||
"""
|
||||
candidates: List[str] = []
|
||||
override = ctx.binary_overrides.get("powershell")
|
||||
if override and override[0]:
|
||||
candidates.append(override[0])
|
||||
init = ctx.init_overrides.get("powershell", {})
|
||||
if isinstance(init, dict) and init.get("bundlePath"):
|
||||
candidates.append(str(init["bundlePath"]))
|
||||
env_path = os.environ.get("PSES_BUNDLE_PATH")
|
||||
if env_path:
|
||||
candidates.append(env_path)
|
||||
home = os.environ.get("HERMES_HOME") or os.path.join(
|
||||
os.path.expanduser("~"), ".hermes"
|
||||
)
|
||||
candidates.append(os.path.join(home, "lsp", "PowerShellEditorServices"))
|
||||
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
continue
|
||||
# Accept either the bundle root or the inner module dir.
|
||||
start_script = os.path.join(
|
||||
cand, "PowerShellEditorServices", "Start-EditorServices.ps1"
|
||||
)
|
||||
if os.path.isfile(start_script):
|
||||
return cand
|
||||
inner = os.path.join(cand, "Start-EditorServices.ps1")
|
||||
if os.path.isfile(inner):
|
||||
return os.path.dirname(cand)
|
||||
return None
|
||||
|
||||
|
||||
def _spawn_powershell_es(root: str, ctx: ServerContext) -> Optional[SpawnSpec]:
|
||||
"""Spawn PowerShellEditorServices over stdio.
|
||||
|
||||
Unlike the single-binary servers, PSES is a PowerShell module driven
|
||||
by a bootstrap script. We need both a PowerShell host (``pwsh`` for
|
||||
PowerShell 7+, or Windows ``powershell``) and the PSES module bundle.
|
||||
The bundle is manual-install (release zip) — see ``_find_pses_bundle``.
|
||||
"""
|
||||
pwsh = _which("pwsh", "powershell")
|
||||
if pwsh is None:
|
||||
return None
|
||||
bundle = _find_pses_bundle(ctx)
|
||||
if bundle is None:
|
||||
global _PSES_BUNDLE_WARNED
|
||||
if not _PSES_BUNDLE_WARNED:
|
||||
_PSES_BUNDLE_WARNED = True
|
||||
logger.warning(
|
||||
"powershell: pwsh found but the PowerShellEditorServices "
|
||||
"bundle is missing. Download the release zip from "
|
||||
"https://github.com/PowerShell/PowerShellEditorServices/releases, "
|
||||
"extract it, and either set lsp.servers.powershell.command "
|
||||
"to the bundle path or unzip it to "
|
||||
"<HERMES_HOME>/lsp/PowerShellEditorServices."
|
||||
)
|
||||
return None
|
||||
start_script = os.path.join(
|
||||
bundle, "PowerShellEditorServices", "Start-EditorServices.ps1"
|
||||
)
|
||||
# Session details file: PSES writes connection info here on startup.
|
||||
session_path = os.path.join(
|
||||
hermes_lsp_session_dir(), f"pses-session-{os.getpid()}.json"
|
||||
)
|
||||
log_path = os.path.join(hermes_lsp_session_dir(), "pses.log")
|
||||
inner = (
|
||||
f"& '{start_script}' "
|
||||
f"-BundledModulesPath '{bundle}' "
|
||||
f"-LogPath '{log_path}' "
|
||||
f"-SessionDetailsPath '{session_path}' "
|
||||
f"-FeatureFlags @() -AdditionalModules @() "
|
||||
f"-HostName Hermes -HostProfileId hermes -HostVersion 1.0.0 "
|
||||
f"-Stdio -LogLevel Normal"
|
||||
)
|
||||
return SpawnSpec(
|
||||
command=[
|
||||
pwsh,
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
inner,
|
||||
],
|
||||
workspace_root=root,
|
||||
cwd=root,
|
||||
env=ctx.env_overrides.get("powershell", {}),
|
||||
initialization_options={
|
||||
k: v
|
||||
for k, v in ctx.init_overrides.get("powershell", {}).items()
|
||||
if k != "bundlePath"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def hermes_lsp_session_dir() -> str:
|
||||
"""Return (and create) the dir for PSES session/log scratch files."""
|
||||
home = os.environ.get("HERMES_HOME") or os.path.join(
|
||||
os.path.expanduser("~"), ".hermes"
|
||||
)
|
||||
d = os.path.join(home, "lsp", "pses")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _resolve_override(ctx: ServerContext, server_id: str) -> Optional[str]:
|
||||
"""User can pin a binary path in config."""
|
||||
override = ctx.binary_overrides.get(server_id)
|
||||
@@ -951,18 +823,6 @@ def _root_java(file_path: str, workspace: str) -> Optional[str]:
|
||||
)
|
||||
|
||||
|
||||
def _root_powershell(file_path: str, workspace: str) -> Optional[str]:
|
||||
# PowerShell projects rarely have a universal root marker. Use the
|
||||
# PSScriptAnalyzer settings file when present, otherwise fall back to
|
||||
# the git workspace root (nearest_root does exact-name matching only,
|
||||
# so no globs here).
|
||||
return _root_or_workspace(
|
||||
file_path,
|
||||
workspace,
|
||||
["PSScriptAnalyzerSettings.psd1"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the registry
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1152,13 +1012,6 @@ SERVERS: List[ServerDef] = [
|
||||
build_spawn=_spawn_jdtls,
|
||||
description="Java — Eclipse JDT Language Server",
|
||||
),
|
||||
ServerDef(
|
||||
server_id="powershell",
|
||||
extensions=(".ps1", ".psm1", ".psd1"),
|
||||
resolve_root=_root_powershell,
|
||||
build_spawn=_spawn_powershell_es,
|
||||
description="PowerShell — PowerShellEditorServices (manual bundle)",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -4,86 +4,45 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Sequence
|
||||
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
|
||||
def summarize_manual_compression(
|
||||
before_messages: Sequence[dict[str, Any]],
|
||||
after_messages: Sequence[dict[str, Any]],
|
||||
before_tokens: int,
|
||||
after_tokens: int,
|
||||
*,
|
||||
compression_state: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return consistent user-facing feedback for manual compression."""
|
||||
before_count = len(before_messages)
|
||||
after_count = len(after_messages)
|
||||
noop = list(after_messages) == list(before_messages)
|
||||
aborted = (
|
||||
compression_state is not None
|
||||
and getattr(compression_state, "_last_compress_aborted", False) is True
|
||||
)
|
||||
fallback_used = (
|
||||
compression_state is not None
|
||||
and getattr(compression_state, "_last_summary_fallback_used", False) is True
|
||||
)
|
||||
failure_reason = (
|
||||
getattr(compression_state, "_last_summary_error", None)
|
||||
if compression_state is not None
|
||||
else None
|
||||
)
|
||||
if not isinstance(failure_reason, str) or not failure_reason.strip():
|
||||
failure_reason = None
|
||||
|
||||
if aborted:
|
||||
headline = f"Compression aborted: {before_count} messages preserved"
|
||||
elif fallback_used:
|
||||
headline = (
|
||||
f"Compressed with fallback: {before_count} → {after_count} messages"
|
||||
)
|
||||
elif noop:
|
||||
if noop:
|
||||
headline = f"No changes from compression: {before_count} messages"
|
||||
if after_tokens == before_tokens:
|
||||
token_line = (
|
||||
f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
|
||||
)
|
||||
else:
|
||||
token_line = (
|
||||
f"Approx request size: ~{before_tokens:,} → "
|
||||
f"~{after_tokens:,} tokens"
|
||||
)
|
||||
else:
|
||||
headline = f"Compressed: {before_count} → {after_count} messages"
|
||||
|
||||
if noop and after_tokens == before_tokens:
|
||||
token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
|
||||
else:
|
||||
token_line = (
|
||||
f"Approx request size: ~{before_tokens:,} → "
|
||||
f"~{after_tokens:,} tokens"
|
||||
)
|
||||
|
||||
note = None
|
||||
if aborted:
|
||||
note = "Summary generation failed; no messages were removed."
|
||||
elif fallback_used:
|
||||
dropped_count = getattr(
|
||||
compression_state, "_last_summary_dropped_count", None
|
||||
)
|
||||
if not isinstance(dropped_count, int) or isinstance(dropped_count, bool):
|
||||
dropped_count = max(before_count - after_count, 0)
|
||||
note = (
|
||||
"Summary generation failed; Hermes used limited fallback context "
|
||||
f"and removed {dropped_count} message(s)."
|
||||
)
|
||||
elif not noop and after_count < before_count and after_tokens > before_tokens:
|
||||
if not noop and after_count < before_count and after_tokens > before_tokens:
|
||||
note = (
|
||||
"Note: fewer messages can still raise this estimate when "
|
||||
"compression rewrites the transcript into denser summaries."
|
||||
)
|
||||
|
||||
if failure_reason and (aborted or fallback_used):
|
||||
# This text crosses a user-facing UI boundary. Never let a disabled
|
||||
# global redaction preference expose credentials embedded in provider
|
||||
# exception text.
|
||||
safe_reason = redact_sensitive_text(failure_reason.strip(), force=True)
|
||||
note = f"{note} Reason: {safe_reason}"
|
||||
|
||||
return {
|
||||
"noop": noop,
|
||||
"aborted": aborted,
|
||||
"fallback_used": fallback_used,
|
||||
"headline": headline,
|
||||
"token_line": token_line,
|
||||
"note": note,
|
||||
|
||||
+57
-207
@@ -30,7 +30,7 @@ import logging
|
||||
import re
|
||||
import inspect
|
||||
import threading
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
@@ -44,7 +44,6 @@ logger = logging.getLogger(__name__)
|
||||
# teardown indefinitely — the worker threads are daemon, so anything still
|
||||
# running past this window dies with the interpreter.
|
||||
_SYNC_DRAIN_TIMEOUT_S = 5.0
|
||||
_EXTERNAL_PREFETCH_TIMEOUT_S = 8.0
|
||||
|
||||
|
||||
def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
|
||||
@@ -358,19 +357,10 @@ class MemoryManager:
|
||||
provider is allowed. Failures in one provider never block the other.
|
||||
"""
|
||||
|
||||
def __init__(self, *, external_prefetch_timeout: Optional[float] = None) -> None:
|
||||
def __init__(self) -> None:
|
||||
self._providers: List[MemoryProvider] = []
|
||||
self._tool_to_provider: Dict[str, MemoryProvider] = {}
|
||||
self._has_external: bool = False # True once a non-builtin provider is added
|
||||
self._external_prefetch_timeout = (
|
||||
_EXTERNAL_PREFETCH_TIMEOUT_S
|
||||
if external_prefetch_timeout is None
|
||||
else float(external_prefetch_timeout)
|
||||
)
|
||||
if self._external_prefetch_timeout <= 0:
|
||||
raise ValueError("external_prefetch_timeout must be positive")
|
||||
self._external_prefetch_threads: Dict[str, threading.Thread] = {}
|
||||
self._external_prefetch_lock = threading.Lock()
|
||||
# Background executor for end-of-turn sync/prefetch. Lazily created on
|
||||
# first use so the common builtin-only path spawns no extra threads.
|
||||
# A single worker serializes a provider's writes (turn N must land
|
||||
@@ -378,16 +368,6 @@ class MemoryManager:
|
||||
# _submit_background() and the sync_all/queue_prefetch_all rationale.
|
||||
self._sync_executor: Optional[ThreadPoolExecutor] = None
|
||||
self._sync_executor_lock = threading.Lock()
|
||||
# Futures are tracked by durability class so shutdown can give writes
|
||||
# a bounded FIFO drain, then explicitly report anything abandoned.
|
||||
self._background_futures: Dict[Future, str] = {}
|
||||
self._shutting_down = False
|
||||
self._shutdown_drain_state: Dict[str, Any] = {
|
||||
"status": "not_started",
|
||||
"abandoned_writes": 0,
|
||||
"abandoned_prefetches": 0,
|
||||
"active_tasks": 0,
|
||||
}
|
||||
|
||||
# -- Registration --------------------------------------------------------
|
||||
|
||||
@@ -524,7 +504,7 @@ class MemoryManager:
|
||||
parts = []
|
||||
for provider in self._providers:
|
||||
try:
|
||||
result = self._prefetch_provider(provider, clean_query, session_id=session_id)
|
||||
result = provider.prefetch(clean_query, session_id=session_id)
|
||||
if result and result.strip():
|
||||
parts.append(result)
|
||||
except Exception as e:
|
||||
@@ -534,56 +514,6 @@ class MemoryManager:
|
||||
)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def _prefetch_provider(
|
||||
self, provider: MemoryProvider, query: str, *, session_id: str = ""
|
||||
) -> str:
|
||||
if provider.name == "builtin":
|
||||
return provider.prefetch(query, session_id=session_id)
|
||||
|
||||
result_box: Dict[str, str] = {}
|
||||
error_box: Dict[str, Exception] = {}
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
result_box["value"] = provider.prefetch(query, session_id=session_id) or ""
|
||||
except Exception as exc: # pragma: no cover - re-raised by caller
|
||||
error_box["value"] = exc
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run,
|
||||
daemon=True,
|
||||
name=f"memory-prefetch-{provider.name}",
|
||||
)
|
||||
with self._external_prefetch_lock:
|
||||
existing = self._external_prefetch_threads.get(provider.name)
|
||||
if existing is not None:
|
||||
if existing.is_alive():
|
||||
logger.debug(
|
||||
"Memory provider '%s' prefetch is still running; skipping this turn",
|
||||
provider.name,
|
||||
)
|
||||
return ""
|
||||
self._external_prefetch_threads.pop(provider.name, None)
|
||||
self._external_prefetch_threads[provider.name] = thread
|
||||
thread.start()
|
||||
|
||||
thread.join(self._external_prefetch_timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning(
|
||||
"Memory provider '%s' prefetch timed out after %.1fs; skipping it until "
|
||||
"the stuck call returns",
|
||||
provider.name,
|
||||
self._external_prefetch_timeout,
|
||||
)
|
||||
return ""
|
||||
|
||||
with self._external_prefetch_lock:
|
||||
if self._external_prefetch_threads.get(provider.name) is thread:
|
||||
self._external_prefetch_threads.pop(provider.name, None)
|
||||
if error_box:
|
||||
raise error_box["value"]
|
||||
return result_box.get("value", "")
|
||||
|
||||
def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None:
|
||||
"""Queue background prefetch on all providers for the next turn.
|
||||
|
||||
@@ -609,7 +539,7 @@ class MemoryManager:
|
||||
provider.name, e,
|
||||
)
|
||||
|
||||
self._submit_background(_run, kind="prefetch")
|
||||
self._submit_background(_run)
|
||||
|
||||
# -- Sync ----------------------------------------------------------------
|
||||
|
||||
@@ -685,59 +615,43 @@ class MemoryManager:
|
||||
|
||||
# -- Background dispatch -------------------------------------------------
|
||||
|
||||
def _submit_background(self, fn, *, kind: str = "write") -> None:
|
||||
"""Queue ``fn`` on the serialized worker and track its durability class."""
|
||||
def _submit_background(self, fn) -> None:
|
||||
"""Run ``fn`` on the manager's background worker.
|
||||
|
||||
The executor is created lazily and shared across calls. If the
|
||||
executor can't be created or has already been shut down, ``fn``
|
||||
runs inline as a last-resort fallback — losing the async benefit
|
||||
but never losing the write itself. ``fn`` must do its own
|
||||
per-provider error handling; this wrapper only guards executor
|
||||
plumbing.
|
||||
"""
|
||||
executor = self._get_sync_executor()
|
||||
if executor is None:
|
||||
if self._shutting_down:
|
||||
logger.warning("Memory manager is shutting down; rejecting late %s task", kind)
|
||||
return
|
||||
# Creation failure outside shutdown: preserve the historical
|
||||
# fail-safe behavior and run the operation inline.
|
||||
# Executor unavailable (shut down / creation failed) — run
|
||||
# inline rather than drop the work. Slow, but correct.
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # pragma: no cover - fn guards internally
|
||||
logger.debug("Inline memory background task failed: %s", e)
|
||||
return
|
||||
try:
|
||||
# Make submit+tracking atomic with the shutdown snapshot. The
|
||||
# callback is attached after releasing the lock because an already
|
||||
# completed future invokes callbacks synchronously.
|
||||
with self._sync_executor_lock:
|
||||
if self._shutting_down:
|
||||
logger.warning("Memory manager is shutting down; rejecting late %s task", kind)
|
||||
return
|
||||
future = executor.submit(fn)
|
||||
self._background_futures[future] = kind
|
||||
future.add_done_callback(self._forget_background_future)
|
||||
executor.submit(fn)
|
||||
except RuntimeError:
|
||||
if self._shutting_down:
|
||||
logger.warning("Memory manager shut down during %s submission; task rejected", kind)
|
||||
return
|
||||
# Executor was shut down between the get and the submit
|
||||
# (teardown race). Fall back to inline.
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # pragma: no cover - fn guards internally
|
||||
logger.debug("Inline memory background task failed: %s", e)
|
||||
|
||||
def _forget_background_future(self, future: Future) -> None:
|
||||
with self._sync_executor_lock:
|
||||
self._background_futures.pop(future, None)
|
||||
|
||||
def _get_sync_executor(self) -> Optional[ThreadPoolExecutor]:
|
||||
"""Lazily create the single-worker background executor."""
|
||||
if self._shutting_down:
|
||||
return None
|
||||
if self._sync_executor is not None:
|
||||
return self._sync_executor
|
||||
with self._sync_executor_lock:
|
||||
if self._shutting_down:
|
||||
return None
|
||||
if self._sync_executor is None:
|
||||
try:
|
||||
# Daemon workers (see tools.daemon_pool): a provider wedged
|
||||
# on a network call must never block interpreter exit.
|
||||
from tools.daemon_pool import DaemonThreadPoolExecutor
|
||||
self._sync_executor = DaemonThreadPoolExecutor(
|
||||
self._sync_executor = ThreadPoolExecutor(
|
||||
max_workers=1,
|
||||
thread_name_prefix="mem-sync",
|
||||
)
|
||||
@@ -864,55 +778,6 @@ 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,
|
||||
@@ -1150,66 +1015,51 @@ class MemoryManager:
|
||||
provider.name, e,
|
||||
)
|
||||
|
||||
@property
|
||||
def shutdown_drain_state(self) -> Dict[str, Any]:
|
||||
"""Snapshot of the most recent bounded shutdown drain outcome."""
|
||||
with self._sync_executor_lock:
|
||||
return dict(self._shutdown_drain_state)
|
||||
|
||||
def _drain_sync_executor(self) -> None:
|
||||
"""Give queued FIFO work a bounded chance, then abandon explicitly."""
|
||||
"""Shut down the background executor, waiting briefly for drain.
|
||||
|
||||
Bounded by ``_SYNC_DRAIN_TIMEOUT_S``: a wedged provider must never
|
||||
hang process/session teardown. We stop accepting new work and
|
||||
cancel anything still queued, then wait at most the drain timeout
|
||||
for the currently-running task on a watcher thread. The worker is
|
||||
daemon, so an over-running task dies with the interpreter.
|
||||
"""
|
||||
with self._sync_executor_lock:
|
||||
self._shutting_down = True
|
||||
executor = self._sync_executor
|
||||
self._sync_executor = None
|
||||
tracked = dict(self._background_futures)
|
||||
self._shutdown_drain_state = {
|
||||
"status": "draining" if executor is not None else "drained",
|
||||
"abandoned_writes": 0,
|
||||
"abandoned_prefetches": 0,
|
||||
"active_tasks": sum(not future.done() for future in tracked),
|
||||
}
|
||||
if executor is None:
|
||||
return
|
||||
|
||||
# shutdown(wait=False) closes submission without touching the FIFO.
|
||||
# Waiting on the tracked futures lets the real single-worker executor
|
||||
# run every queued write/boundary task in order up to the deadline.
|
||||
executor.shutdown(wait=False, cancel_futures=False)
|
||||
_, pending = wait(tuple(tracked), timeout=_SYNC_DRAIN_TIMEOUT_S)
|
||||
if not pending:
|
||||
with self._sync_executor_lock:
|
||||
self._shutdown_drain_state.update(status="drained", active_tasks=0)
|
||||
try:
|
||||
# Stop accepting new work and drop anything still queued, but
|
||||
# do NOT block here — cancel_futures cancels not-yet-started
|
||||
# tasks; the in-flight one keeps running on its daemon thread.
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
except TypeError:
|
||||
# Older Python without cancel_futures kwarg.
|
||||
try:
|
||||
executor.shutdown(wait=False)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.debug("Memory sync executor shutdown failed: %s", e)
|
||||
return
|
||||
|
||||
abandoned_writes = 0
|
||||
abandoned_prefetches = 0
|
||||
active_tasks = 0
|
||||
for future in pending:
|
||||
kind = tracked[future]
|
||||
if future.cancel():
|
||||
if kind == "prefetch":
|
||||
abandoned_prefetches += 1
|
||||
else:
|
||||
abandoned_writes += 1
|
||||
else:
|
||||
active_tasks += 1
|
||||
|
||||
with self._sync_executor_lock:
|
||||
self._shutdown_drain_state.update(
|
||||
status="timed_out",
|
||||
abandoned_writes=abandoned_writes,
|
||||
abandoned_prefetches=abandoned_prefetches,
|
||||
active_tasks=active_tasks,
|
||||
)
|
||||
logger.warning(
|
||||
"Memory shutdown drain timed out after %.2fs; abandoning %d queued "
|
||||
"memory write(s) and %d queued prefetch(es); %d active task(s) remain detached",
|
||||
_SYNC_DRAIN_TIMEOUT_S,
|
||||
abandoned_writes,
|
||||
abandoned_prefetches,
|
||||
active_tasks,
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.debug("Memory sync executor shutdown failed: %s", e)
|
||||
return
|
||||
# Give an in-flight sync a bounded chance to finish on a watcher
|
||||
# thread so we don't block the caller past the drain timeout.
|
||||
drainer = threading.Thread(
|
||||
target=lambda: self._bounded_executor_wait(executor),
|
||||
daemon=True,
|
||||
name="mem-sync-drain",
|
||||
)
|
||||
drainer.start()
|
||||
drainer.join(timeout=_SYNC_DRAIN_TIMEOUT_S)
|
||||
|
||||
@staticmethod
|
||||
def _bounded_executor_wait(executor: ThreadPoolExecutor) -> None:
|
||||
try:
|
||||
executor.shutdown(wait=True)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.debug("Memory sync executor drain wait failed: %s", e)
|
||||
|
||||
def initialize_all(self, session_id: str, **kwargs) -> None:
|
||||
"""Initialize all providers.
|
||||
|
||||
+86
-736
File diff suppressed because it is too large
Load Diff
@@ -1,167 +0,0 @@
|
||||
"""Full MoA turn trace persistence (opt-in via config ``moa.save_traces``).
|
||||
|
||||
When enabled, every Mixture-of-Agents turn that actually runs the reference
|
||||
fan-out (a cache MISS in ``MoAChatCompletions.create``) appends one JSON line
|
||||
to ``<hermes_home>/moa-traces/<session_id>.jsonl``. The record is the TRUE
|
||||
FULL turn — the exact messages array each reference model received (system
|
||||
prompt + advisory view, not the truncated display preview), each reference's
|
||||
full output, and the exact messages array the aggregator received (including
|
||||
the injected reference-context guidance block) plus its output when available
|
||||
— so a run can be audited end-to-end offline: what every model saw, what every
|
||||
model said, and what it cost.
|
||||
|
||||
This is a side-channel trace. It is NOT the conversation ``messages`` table and
|
||||
never enters message history or replay — MoA references are advisory side-calls
|
||||
with their own system prompt, not conversation turns, so persisting them as
|
||||
message rows would corrupt role alternation / replay. Traces live in their own
|
||||
files, keyed by session id, and are safe to delete.
|
||||
|
||||
Cost model note: gated OFF by default. When off, the only overhead is the
|
||||
``_traces_enabled()`` config read (cheap) — no file I/O, no serialization.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _traces_enabled_and_dir() -> Optional[Path]:
|
||||
"""Return the trace directory if ``moa.save_traces`` is on, else None.
|
||||
|
||||
Reads config lazily per call (config is cheap to load and this only runs on
|
||||
a cache-MISS MoA turn, i.e. once per user turn, not per tool iteration).
|
||||
``moa.trace_dir`` overrides the default ``<hermes_home>/moa-traces/``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
moa_cfg = (load_config() or {}).get("moa") or {}
|
||||
except Exception: # pragma: no cover - defensive: never break a turn over tracing
|
||||
return None
|
||||
if not moa_cfg.get("save_traces"):
|
||||
return None
|
||||
override = moa_cfg.get("trace_dir")
|
||||
if override:
|
||||
base = Path(os.path.expandvars(os.path.expanduser(str(override))))
|
||||
else:
|
||||
base = get_hermes_home() / "moa-traces"
|
||||
return base
|
||||
|
||||
|
||||
def _sanitize_session_id(session_id: Optional[str]) -> str:
|
||||
"""Make a session id safe as a filename component."""
|
||||
if not session_id:
|
||||
return "unknown-session"
|
||||
return "".join(c if (c.isalnum() or c in "-_.") else "_" for c in str(session_id))
|
||||
|
||||
|
||||
def _slot_trace(acct: Any, label: str) -> dict[str, Any]:
|
||||
"""Render one reference's _RefAccounting into a full trace dict.
|
||||
|
||||
Includes the FULL input messages the reference received and its FULL
|
||||
output — not the truncated display preview.
|
||||
"""
|
||||
usage = getattr(acct, "usage", None)
|
||||
usage_dict: dict[str, Any] = {}
|
||||
if usage is not None:
|
||||
usage_dict = {
|
||||
"input_tokens": getattr(usage, "input_tokens", 0),
|
||||
"output_tokens": getattr(usage, "output_tokens", 0),
|
||||
"cache_read_tokens": getattr(usage, "cache_read_tokens", 0),
|
||||
"cache_write_tokens": getattr(usage, "cache_write_tokens", 0),
|
||||
"reasoning_tokens": getattr(usage, "reasoning_tokens", 0),
|
||||
}
|
||||
return {
|
||||
"label": label,
|
||||
"model": getattr(acct, "model", None),
|
||||
"provider": getattr(acct, "provider", None),
|
||||
"temperature": getattr(acct, "temperature", None),
|
||||
"input_messages": getattr(acct, "messages", None),
|
||||
"output": getattr(acct, "output", None),
|
||||
"usage": usage_dict,
|
||||
"cost_usd": getattr(acct, "cost_usd", None),
|
||||
"cost_status": getattr(acct, "cost_status", None),
|
||||
"cost_source": getattr(acct, "cost_source", None),
|
||||
}
|
||||
|
||||
|
||||
def save_moa_turn(
|
||||
*,
|
||||
session_id: Optional[str],
|
||||
preset_name: str,
|
||||
reference_outputs: list[tuple[str, str, Any]],
|
||||
aggregator_label: str,
|
||||
aggregator_model: Optional[str],
|
||||
aggregator_provider: Optional[str],
|
||||
aggregator_temperature: Any,
|
||||
aggregator_input_messages: Any,
|
||||
aggregator_output: Optional[str],
|
||||
aggregator_streamed: bool,
|
||||
) -> None:
|
||||
"""Append one full MoA turn record to the session's trace JSONL, if enabled.
|
||||
|
||||
Best-effort: any failure is logged at debug and swallowed — tracing must
|
||||
never break a live turn. Called once per turn on a reference cache MISS.
|
||||
|
||||
``aggregator_output`` is the aggregator's synthesized text. On the
|
||||
non-streaming path (eval / quiet-mode / subagents) it was captured inline
|
||||
at call time. On the streaming path it is captured after the fact from the
|
||||
caller's resolved assistant text (``aggregator_output_fallback`` in
|
||||
``consume_and_save_trace``) so the trace is self-contained either way; if
|
||||
that resolved text was unavailable, it falls back to None and the record
|
||||
points at the session store via ``output_location``.
|
||||
"""
|
||||
base = _traces_enabled_and_dir()
|
||||
if base is None:
|
||||
return
|
||||
try:
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
path = base / f"{_sanitize_session_id(session_id)}.jsonl"
|
||||
# output_location tells an offline reader where the acting text lives:
|
||||
# embedded here when we have it (both non-streaming inline capture and
|
||||
# streaming after-the-fact capture), else the session-db assistant row.
|
||||
_have_output = bool(aggregator_output)
|
||||
if not aggregator_streamed:
|
||||
_output_location = "inline"
|
||||
elif _have_output:
|
||||
_output_location = "inline_from_stream"
|
||||
else:
|
||||
_output_location = "assistant_message_in_session_db"
|
||||
record = {
|
||||
"ts": time.time(),
|
||||
"session_id": session_id,
|
||||
"preset": preset_name,
|
||||
"references": [
|
||||
_slot_trace(acct, label)
|
||||
for label, _text, acct in reference_outputs
|
||||
],
|
||||
"aggregator": {
|
||||
"label": aggregator_label,
|
||||
"model": aggregator_model,
|
||||
"provider": aggregator_provider,
|
||||
"temperature": aggregator_temperature,
|
||||
"input_messages": aggregator_input_messages,
|
||||
"output": aggregator_output,
|
||||
"streamed": aggregator_streamed,
|
||||
# Where the aggregator's acting output lives for this record.
|
||||
# "inline" — non-streaming inline capture
|
||||
# "inline_from_stream" — streamed, then captured from the
|
||||
# caller's resolved assistant text
|
||||
# "assistant_message_in_session_db" — streamed and the resolved
|
||||
# text was unavailable at flush time
|
||||
"output_location": _output_location,
|
||||
},
|
||||
}
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
|
||||
except Exception as exc: # pragma: no cover - tracing must never break a turn
|
||||
logger.debug("MoA trace write failed (session=%s): %s", session_id, exc)
|
||||
+141
-890
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user