Compare commits
168
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58362361dd | ||
|
|
0bbf325a8f | ||
|
|
0bbff1fc7e | ||
|
|
ae433634db | ||
|
|
9eb0bcd60f | ||
|
|
45e2f4fdcd | ||
|
|
30377e108c | ||
|
|
f02484feba | ||
|
|
eae3836eb6 | ||
|
|
3e7e9b24d4 | ||
|
|
ead38107a2 | ||
|
|
5035fa9029 | ||
|
|
5b2604df99 | ||
|
|
2f2e3616b4 | ||
|
|
bee13817f0 | ||
|
|
fbabf438a1 | ||
|
|
49e743985a | ||
|
|
ba3883cd18 | ||
|
|
be7c919bf9 | ||
|
|
733472952a | ||
|
|
e5b4cf7bea | ||
|
|
29c6985590 | ||
|
|
92a456f711 | ||
|
|
975b9f0a54 | ||
|
|
0d82060c74 | ||
|
|
ea49a79633 | ||
|
|
c17469cb19 | ||
|
|
febdddb41a | ||
|
|
aab2e99bae | ||
|
|
ad58dd51ac | ||
|
|
a688d2a1bd | ||
|
|
40699c3292 | ||
|
|
c1a70a5439 | ||
|
|
2cddc9c895 | ||
|
|
f79b109f4f | ||
|
|
ec05d2bc3e | ||
|
|
a376ca0081 | ||
|
|
8844e091c1 | ||
|
|
1227007aed | ||
|
|
497352bc4e | ||
|
|
f1d6f04362 | ||
|
|
dcc3216955 | ||
|
|
aca11c227e | ||
|
|
6cb88a0874 | ||
|
|
8fce54499f | ||
|
|
b0c99c12dd | ||
|
|
ddf7c7af81 | ||
|
|
d6a8d9dcab | ||
|
|
95715dcb03 | ||
|
|
80f8ffc74c | ||
|
|
c2b7669ad3 | ||
|
|
b770967263 | ||
|
|
61ee2dbfdb | ||
|
|
f795513782 | ||
|
|
8fe334b056 | ||
|
|
40d7c264f0 | ||
|
|
4eb0ff639b | ||
|
|
f3fe99863d | ||
|
|
a829e04d62 | ||
|
|
2a14e8957d | ||
|
|
bff78a34dc | ||
|
|
4e6d05c6a5 | ||
|
|
a1f51feb72 | ||
|
|
6c34088a17 | ||
|
|
fc2b8b3d31 | ||
|
|
3bc4a2ff78 | ||
|
|
ce19fdb7ce | ||
|
|
7f245b0035 | ||
|
|
7bbe7024c2 | ||
|
|
7433d5f0eb | ||
|
|
1436793051 | ||
|
|
08d89e7aba | ||
|
|
2c174bce24 | ||
|
|
5191c1c2ce | ||
|
|
0f3670ba79 | ||
|
|
288f7026e3 | ||
|
|
efbe1635dd | ||
|
|
a27d7e68cc | ||
|
|
13a1bd0f83 | ||
|
|
0e22bf6439 | ||
|
|
972a9885ee | ||
|
|
9459057d7f | ||
|
|
cf7d5932f8 | ||
|
|
04d4471d79 | ||
|
|
5105c3651a | ||
|
|
293c04fef6 | ||
|
|
10bad2faf1 | ||
|
|
2b4873f7fb | ||
|
|
723c2331bd | ||
|
|
b00060ce54 | ||
|
|
0428945b5b | ||
|
|
afc8615509 | ||
|
|
89bdb1e546 | ||
|
|
7b9dc7cd0a | ||
|
|
d76a58bd15 | ||
|
|
1f5eef8093 | ||
|
|
9f33d673e9 | ||
|
|
d842155da1 | ||
|
|
4936a49a0c | ||
|
|
85e6232a07 | ||
|
|
81e42335a1 | ||
|
|
526a1e24b5 | ||
|
|
1eb13744b4 | ||
|
|
49dd91d682 | ||
|
|
715b691723 | ||
|
|
9cbb91abd3 | ||
|
|
c8ad2ca997 | ||
|
|
10bd01972b | ||
|
|
12c84d6c77 | ||
|
|
ab26541b9a | ||
|
|
bb46bf8ce4 | ||
|
|
4b5ba112ad | ||
|
|
cdf30a7ac6 | ||
|
|
b0288ae9b6 | ||
|
|
630a4ef03c | ||
|
|
b4ba3f5e3b | ||
|
|
8f278403d1 | ||
|
|
1b16c48170 | ||
|
|
e986e3fc68 | ||
|
|
12682d96b9 | ||
|
|
8d5d36d793 | ||
|
|
7aaae7acd0 | ||
|
|
73d1357747 | ||
|
|
af1995a838 | ||
|
|
dc90ca4e17 | ||
|
|
af5b526472 | ||
|
|
b42c5bf652 | ||
|
|
a218a0f156 | ||
|
|
1106879147 | ||
|
|
6b76284c77 | ||
|
|
4026f526d5 | ||
|
|
9a2b976326 | ||
|
|
3581131e7d | ||
|
|
bf8effad02 | ||
|
|
817f392311 | ||
|
|
2b67e96aec | ||
|
|
abd69b8117 | ||
|
|
da28d5d113 | ||
|
|
1fa761f8de | ||
|
|
069bfd6545 | ||
|
|
1d584a301e | ||
|
|
57c2a55be4 | ||
|
|
0a865e5948 | ||
|
|
c8e5f34f24 | ||
|
|
7d11fa4e9e | ||
|
|
7c0605bf22 | ||
|
|
819def44c7 | ||
|
|
08890d77e6 | ||
|
|
425e777f54 | ||
|
|
7be22e37e1 | ||
|
|
28902dc890 | ||
|
|
63097ee0d7 | ||
|
|
6e2fd955ca | ||
|
|
78c11d99e3 | ||
|
|
957a8ffa88 | ||
|
|
cc14b74718 | ||
|
|
9b5f7b63c6 | ||
|
|
d146b85173 | ||
|
|
28bf8fb47d | ||
|
|
3380563d94 | ||
|
|
ad7436a5d9 | ||
|
|
fc46354580 | ||
|
|
1185dfd773 | ||
|
|
f82cb48120 | ||
|
|
4fd9397ae3 | ||
|
|
45f9099e51 | ||
|
|
4373e802a1 | ||
|
|
d206e1f51d |
@@ -11,8 +11,20 @@ on:
|
||||
- 'optional-skills/**'
|
||||
- '.github/workflows/deploy-site.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skills_index_run_id:
|
||||
description: 'Optional Build Skills Index run ID whose skills-index artifact should be deployed'
|
||||
required: false
|
||||
type: string
|
||||
rebuild_skills_index:
|
||||
description: 'Force a fresh multi-source crawl instead of reusing the latest healthy index'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
@@ -55,26 +67,81 @@ jobs:
|
||||
- name: Install PyYAML for skill extraction
|
||||
run: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
|
||||
- name: Build skills index (unified multi-source catalog)
|
||||
- name: Prepare skills index (unified multi-source catalog)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_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: |
|
||||
# Rebuild the unified catalog. The file is gitignored, so a fresh
|
||||
# checkout starts without it and we want the freshest crawl in
|
||||
# every deploy.
|
||||
# The unified external catalog is expensive to crawl and can burn
|
||||
# through the repository installation's GitHub API quota when several
|
||||
# docs deploys land close together. Normal docs deploys therefore
|
||||
# reuse the latest healthy catalog: first the artifact from a
|
||||
# scheduled skills-index run, then the currently live index. Only a
|
||||
# manual force rebuild does a fresh crawl here.
|
||||
#
|
||||
# This MUST be fatal. build_skills_index.py runs a health check and
|
||||
# exits non-zero WITHOUT writing the output file when a source
|
||||
# collapses (e.g. a GitHub API rate limit zeroes the github /
|
||||
# claude-marketplace / well-known taps all at once). Letting the
|
||||
# deploy continue would either (a) ship a degenerate index missing
|
||||
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
|
||||
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
|
||||
# local-only catalog. Failing here keeps the last good deployment
|
||||
# live (GitHub Pages serves the previous build) instead of
|
||||
# publishing a broken catalog. Re-run the workflow once the
|
||||
# transient rate limit clears.
|
||||
# If we do crawl, the build remains fatal. build_skills_index.py runs
|
||||
# the health check BEFORE writing and exits non-zero on source
|
||||
# collapse, keeping the last good Pages deployment live instead of
|
||||
# publishing a degenerate catalog.
|
||||
set -euo pipefail
|
||||
INDEX_PATH="website/static/api/skills-index.json"
|
||||
mkdir -p "$(dirname "$INDEX_PATH")"
|
||||
|
||||
validate_index() {
|
||||
python3 - "$INDEX_PATH" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
print(f"invalid skills index JSON: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
skills = data.get("skills")
|
||||
if not isinstance(skills, list) or len(skills) < 1500:
|
||||
count = len(skills) if isinstance(skills, list) else "missing"
|
||||
print(f"skills index too small: {count}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"skills index ready: {len(skills)} skills")
|
||||
PY
|
||||
}
|
||||
|
||||
if [ "$REBUILD_SKILLS_INDEX" = "true" ]; then
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLS_INDEX_RUN_ID" ]; then
|
||||
tmpdir="$(mktemp -d)"
|
||||
echo "Downloading skills-index artifact from run $SKILLS_INDEX_RUN_ID"
|
||||
if gh run download "$SKILLS_INDEX_RUN_ID" --name skills-index --dir "$tmpdir"; then
|
||||
candidate="$(find "$tmpdir" -name skills-index.json -type f | head -n 1 || true)"
|
||||
if [ -n "$candidate" ]; then
|
||||
cp "$candidate" "$INDEX_PATH"
|
||||
if validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "::warning::Could not use skills-index artifact from run $SKILLS_INDEX_RUN_ID; trying live index"
|
||||
fi
|
||||
|
||||
echo "Downloading currently live skills index"
|
||||
if curl -fsSL --retry 3 --retry-delay 5 \
|
||||
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json" \
|
||||
-o "$INDEX_PATH" && validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::Live skills index unavailable or unhealthy; falling back to a fresh crawl"
|
||||
rm -f "$INDEX_PATH"
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
name: Nix Lockfile Fix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'package-lock.json'
|
||||
- 'package.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'apps/desktop/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to fix (leave empty to run on the selected branch)'
|
||||
required: false
|
||||
type: string
|
||||
issue_comment:
|
||||
types: [edited]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-lockfile-fix-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json.
|
||||
# Runs fix-lockfiles and pushes the hash update commit directly to main
|
||||
# so Nix builds never stay broken.
|
||||
#
|
||||
# Safety invariants:
|
||||
# 1. The fix commit only touches nix/*.nix files, which are NOT in
|
||||
# the paths filter above, so this cannot re-trigger itself.
|
||||
# 2. An explicit file-whitelist check before commit aborts if
|
||||
# fix-lockfiles ever modifies unexpected files.
|
||||
# 3. Job-level concurrency with cancel-in-progress: true ensures
|
||||
# back-to-back pushes collapse to the newest; ref: main checkout
|
||||
# always operates on the latest branch state.
|
||||
# 4. Uses a GitHub App token (not GITHUB_TOKEN) so the fix commit
|
||||
# triggers downstream nix.yml verification.
|
||||
auto-fix-main:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
concurrency:
|
||||
group: auto-fix-main
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@7bfa3a4717ef143a604ee0a99d859b8886a96d00 # v1.9.3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
run: nix run .#fix-lockfiles -- --apply
|
||||
|
||||
- name: Commit & push
|
||||
if: steps.apply.outputs.changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure only nix/lib.nix (home of the single npmDepsHash) was
|
||||
# modified — prevents accidental self-triggering if fix-lockfiles
|
||||
# ever touches package files.
|
||||
unexpected="$(git diff --name-only | grep -Ev '^nix/lib\.nix$' || true)"
|
||||
if [ -n "$unexpected" ]; then
|
||||
echo "::error::Unexpected modified files: $unexpected"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Record the base SHA before committing — used to detect package
|
||||
# file changes if we need to rebase after a non-fast-forward push.
|
||||
BASE_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git add nix/lib.nix
|
||||
git commit -m "fix(nix): auto-refresh npm lockfile hashes" \
|
||||
-m "Source: $GITHUB_SHA" \
|
||||
-m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
||||
|
||||
# Retry push with rebase in case main advanced with an unrelated
|
||||
# commit during the nix build. Without this, a non-fast-forward
|
||||
# rejection silently loses the fix. If package files changed during
|
||||
# the rebase, abort — a fresh auto-fix run will handle the new state.
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin HEAD:main; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::Push attempt $attempt failed (non-fast-forward?), rebasing…"
|
||||
git fetch origin main
|
||||
|
||||
# If package files changed between our base and the new main,
|
||||
# our computed hashes are stale. Abort and let the next triggered
|
||||
# run recompute from the correct package-lock state.
|
||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||
'package-lock.json' 'package.json' \
|
||||
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
|
||||
if [ -n "$pkg_changed" ]; then
|
||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git rebase origin/main
|
||||
done
|
||||
echo "::error::Failed to push after 3 rebase attempts"
|
||||
exit 1
|
||||
|
||||
# ── PR fix (manual / checkbox) ─────────────────────────────────────
|
||||
# Existing behavior: run on manual dispatch OR when a task-list
|
||||
# checkbox in the sticky lockfile-check comment flips from [ ] to [x].
|
||||
fix:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request != null
|
||||
&& contains(github.event.comment.body, '[x] **Apply lockfile fix**')
|
||||
&& !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Authorize & resolve PR
|
||||
id: resolve
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
// 1. Verify the actor has write access — applies to both checkbox
|
||||
// clicks and manual dispatch.
|
||||
const { data: perm } =
|
||||
await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.actor,
|
||||
});
|
||||
if (!['admin', 'write', 'maintain'].includes(perm.permission)) {
|
||||
core.setFailed(
|
||||
`${context.actor} lacks write access (has: ${perm.permission})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Resolve which ref to check out.
|
||||
let prNumber = '';
|
||||
if (context.eventName === 'issue_comment') {
|
||||
prNumber = String(context.payload.issue.number);
|
||||
} else if (context.eventName === 'workflow_dispatch') {
|
||||
prNumber = context.payload.inputs.pr_number || '';
|
||||
}
|
||||
|
||||
if (!prNumber) {
|
||||
core.setOutput('ref', context.ref.replace(/^refs\/heads\//, ''));
|
||||
core.setOutput('repo', context.repo.repo);
|
||||
core.setOutput('owner', context.repo.owner);
|
||||
core.setOutput('pr', '');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: Number(prNumber),
|
||||
});
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
core.setOutput('repo', pr.head.repo.name);
|
||||
core.setOutput('owner', pr.head.repo.owner.login);
|
||||
core.setOutput('pr', String(pr.number));
|
||||
|
||||
# Wipe the sticky lockfile-check comment to a "running" state as soon
|
||||
# as the job is authorized, so the user sees their click was picked up
|
||||
# before the ~minute of nix build work.
|
||||
- name: Mark sticky as running
|
||||
if: steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### 🔄 Applying lockfile fix…
|
||||
|
||||
Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
run: nix run .#fix-lockfiles
|
||||
|
||||
- name: Commit & push
|
||||
if: steps.apply.outputs.changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git add nix/lib.nix
|
||||
git commit -m "fix(nix): refresh npm lockfile hashes"
|
||||
git push
|
||||
|
||||
- name: Update sticky (applied)
|
||||
if: steps.apply.outputs.changed == 'true' && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ✅ Lockfile fix applied
|
||||
|
||||
Pushed a commit refreshing the npm lockfile hashes — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- name: Update sticky (already current)
|
||||
if: steps.apply.outputs.changed == 'false' && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ✅ Lockfile hashes already current
|
||||
|
||||
Nothing to commit — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- name: Update sticky (failed)
|
||||
if: failure() && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ❌ Lockfile fix failed
|
||||
|
||||
See the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for logs.
|
||||
@@ -1,105 +0,0 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Resolve head SHA
|
||||
if: github.event_name == 'pull_request'
|
||||
id: sha
|
||||
shell: bash
|
||||
run: |
|
||||
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "full=$FULL" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check flake
|
||||
id: flake
|
||||
continue-on-error: true
|
||||
run: nix flake check --print-build-logs
|
||||
|
||||
# When the flake check fails, run a targeted diagnostic to see if
|
||||
# the failure is specifically a stale npm lockfile hash in one of the
|
||||
# known npm subpackages (tui / web). This avoids surfacing a generic
|
||||
# "build failed" message when the fix is a single known command.
|
||||
- name: Diagnose npm lockfile hashes
|
||||
id: hash_check
|
||||
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
env:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
run: nix run .#fix-lockfiles -- --check
|
||||
|
||||
# If fix-lockfiles itself crashes (infrastructure blip, cache throttle,
|
||||
# etc.) it won't set stale=true/false. Treat that as a distinct failure
|
||||
# mode rather than silently ignoring it.
|
||||
- name: Fail if hash check crashed without reporting
|
||||
if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false'
|
||||
run: |
|
||||
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
|
||||
exit 1
|
||||
|
||||
- name: Post sticky PR comment (stale hashes)
|
||||
if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request'
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
message: |
|
||||
### ⚠️ npm lockfile hash out of date
|
||||
|
||||
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
|
||||
|
||||
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
|
||||
|
||||
${{ steps.hash_check.outputs.report }}
|
||||
|
||||
#### Apply the fix
|
||||
|
||||
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
|
||||
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
|
||||
- Or locally: `nix run .#fix-lockfiles` and commit the diff
|
||||
|
||||
# Clear the sticky comment when either the flake check passed outright (no
|
||||
# hash check needed) or the hash check explicitly returned stale=false
|
||||
# (check failed for a non-hash reason).
|
||||
- name: Clear sticky PR comment (resolved)
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
(steps.hash_check.outputs.stale == 'false' ||
|
||||
steps.flake.outcome == 'success')
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
delete: true
|
||||
|
||||
- name: Final fail if flake check failed
|
||||
if: steps.flake.outcome == 'failure'
|
||||
run: |
|
||||
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
|
||||
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
|
||||
else
|
||||
echo "::error::Nix flake check failed. See logs above."
|
||||
fi
|
||||
exit 1
|
||||
@@ -53,4 +53,4 @@ jobs:
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
|
||||
|
||||
@@ -29,6 +29,8 @@ jobs:
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
# True when the curated MCP catalog / bundled MCP manifests changed.
|
||||
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -54,6 +56,14 @@ jobs:
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
MCP_CATALOG_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'optional-mcps/**' \
|
||||
'hermes_cli/mcp_catalog.py' || true)
|
||||
if [ -n "$MCP_CATALOG_FILES" ]; then
|
||||
echo "mcp_catalog=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "mcp_catalog=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
@@ -268,3 +278,50 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
mcp-catalog-review:
|
||||
name: MCP catalog security review
|
||||
needs: changes
|
||||
if: needs.changes.outputs.mcp_catalog == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require explicit MCP catalog review label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
|
||||
echo "MCP catalog review label present."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BODY="## ⚠️ MCP catalog security review required
|
||||
|
||||
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
|
||||
|
||||
mcp-catalog-review-gate:
|
||||
name: MCP catalog security review
|
||||
needs: changes
|
||||
if: always() && needs.changes.outputs.mcp_catalog != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No MCP catalog changes, skipping MCP catalog security review."
|
||||
|
||||
@@ -23,3 +23,20 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- 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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run --prefix apps/desktop build
|
||||
|
||||
+44
-6
@@ -78,7 +78,41 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
|
||||
| **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) |
|
||||
|
||||
### Clone and install
|
||||
### Install with the standard installer
|
||||
|
||||
For most contributors, the best development bootstrap is the same path users
|
||||
take: run the standard installer, then work inside the repository it cloned.
|
||||
The installer creates the Hermes venv, wires the `hermes` command, stamps the
|
||||
install method for `hermes update`, and clones the full git project into
|
||||
`$HERMES_HOME/hermes-agent` (usually `~/.hermes/hermes-agent`). That keeps your
|
||||
development environment on the same layout the CLI, updater, lazy dependency
|
||||
installer, gateway, and docs assume.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
|
||||
# Add dev/test extras on top of the standard install.
|
||||
uv pip install -e ".[all,dev]"
|
||||
|
||||
# Optional: browser tools / docs site dependencies.
|
||||
npm install
|
||||
```
|
||||
|
||||
After that, create branches and run tests from that checkout:
|
||||
|
||||
```bash
|
||||
git checkout -b fix/description
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
### Manual clone fallback
|
||||
|
||||
Use this only if you intentionally do not want Hermes' managed install layout
|
||||
(for example, a throwaway clone inside a container or CI job). If you install
|
||||
this way, make sure you run the `hermes` entrypoint from this venv; running the
|
||||
system `python3 -m hermes_cli.main` can pick up unrelated system Python
|
||||
packages.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
@@ -109,15 +143,19 @@ echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
|
||||
### Run
|
||||
|
||||
```bash
|
||||
# Symlink for global access
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
|
||||
|
||||
# Verify
|
||||
# The standard installer already put `hermes` on PATH.
|
||||
hermes doctor
|
||||
hermes chat -q "Hello"
|
||||
```
|
||||
|
||||
If you used the manual clone fallback, run `./hermes` from the checkout or
|
||||
symlink this clone's venv explicitly:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -181,16 +181,20 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration
|
||||
|
||||
We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process.
|
||||
|
||||
Quick start for contributors — clone and go with `setup-hermes.sh`:
|
||||
Quick start for contributors — use the standard installer, then work from the
|
||||
full git checkout it creates at `$HERMES_HOME/hermes-agent` (usually
|
||||
`~/.hermes/hermes-agent`). This matches the layout used by `hermes update`, the
|
||||
managed venv, lazy dependencies, gateway, and docs tooling.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes
|
||||
./hermes # auto-detects the venv, no need to `source` first
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
uv pip install -e ".[all,dev]"
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
Manual path (equivalent to the above):
|
||||
Manual clone fallback (for throwaway clones/CI where you intentionally do not
|
||||
want the managed install layout):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
+8
-6
@@ -164,16 +164,18 @@ hermes claw migrate --overwrite # 覆盖已有冲突
|
||||
|
||||
欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。
|
||||
|
||||
贡献者快速开始——克隆并使用 `setup-hermes.sh`:
|
||||
贡献者快速开始——使用标准安装器,然后在它创建的完整 git checkout 中开发:
|
||||
`$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这会匹配
|
||||
`hermes update`、托管 venv、lazy dependencies、gateway 和 docs tooling 使用的布局。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes
|
||||
./hermes # 自动检测 venv,无需先 source
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
uv pip install -e ".[all,dev]"
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
手动安装(等效于上述命令):
|
||||
手动克隆备用路径(用于一次性 clone / CI,或你明确不想使用 managed install layout 时):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
@@ -900,6 +900,9 @@ def init_agent(
|
||||
agent.api_key = client_kwargs.get("api_key", "")
|
||||
agent.base_url = client_kwargs.get("base_url", agent.base_url)
|
||||
try:
|
||||
from agent.ssl_guard import verify_ca_bundle_with_fallback
|
||||
|
||||
verify_ca_bundle_with_fallback()
|
||||
agent.client = agent._create_openai_client(client_kwargs, reason="agent_init", shared=True)
|
||||
if not agent.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with model: {agent.model}")
|
||||
|
||||
@@ -881,6 +881,8 @@ def try_recover_primary_transport(
|
||||
|
||||
def drop_thinking_only_and_merge_users(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
drop_codex_reasoning_items: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Drop thinking-only assistant turns; merge any adjacent user messages left behind.
|
||||
|
||||
@@ -902,7 +904,13 @@ def drop_thinking_only_and_merge_users(
|
||||
return messages
|
||||
|
||||
# Pass 1: drop thinking-only assistant turns.
|
||||
kept = [m for m in messages if not _ra().AIAgent._is_thinking_only_assistant(m)]
|
||||
kept = [
|
||||
m for m in messages
|
||||
if not _ra().AIAgent._is_thinking_only_assistant(
|
||||
m,
|
||||
drop_codex_reasoning_items=drop_codex_reasoning_items,
|
||||
)
|
||||
]
|
||||
dropped = len(messages) - len(kept)
|
||||
if dropped == 0:
|
||||
return messages
|
||||
@@ -1209,12 +1217,23 @@ def dump_api_request_debug(
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
|
||||
atomic_json_write(dump_file, dump_payload, default=str)
|
||||
|
||||
# Redact secrets before persisting/printing. This dump captures the
|
||||
# full request body (system prompt, tool defs, context-embedded
|
||||
# values), and this path fires unconditionally on API errors — so it
|
||||
# otherwise lands any context-embedded secret in cleartext on disk.
|
||||
# Run the serialized dump through the same scrubber used for logs/tool
|
||||
# output, then hand the resulting payload back to the shared atomic
|
||||
# JSON writer so request dumps keep the same write semantics as before.
|
||||
from agent.redact import redact_sensitive_text
|
||||
_serialized = json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)
|
||||
_redacted_payload = json.loads(redact_sensitive_text(_serialized, force=True))
|
||||
atomic_json_write(dump_file, _redacted_payload, default=str)
|
||||
|
||||
agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")
|
||||
|
||||
if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"):
|
||||
print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))
|
||||
print(json.dumps(_redacted_payload, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
return dump_file
|
||||
except Exception as dump_error:
|
||||
|
||||
@@ -751,6 +751,9 @@ def build_anthropic_client(
|
||||
from httpx import Timeout
|
||||
|
||||
normalized_base_url = _normalize_base_url_text(base_url)
|
||||
if normalized_base_url:
|
||||
import re as _re
|
||||
normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/"))
|
||||
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
|
||||
kwargs = {
|
||||
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0),
|
||||
|
||||
@@ -1144,7 +1144,8 @@ def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
normalized = (base_url or "").strip().lower().rstrip("/")
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized.endswith("/anthropic"):
|
||||
path = urlparse(normalized).path.rstrip("/")
|
||||
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
|
||||
return True
|
||||
hostname = base_url_hostname(normalized)
|
||||
if hostname == "api.anthropic.com":
|
||||
@@ -5004,7 +5005,7 @@ def _build_call_kwargs(
|
||||
|
||||
# Provider-specific extra_body
|
||||
merged_extra = dict(extra_body or {})
|
||||
if provider == "nous" or auxiliary_is_nous:
|
||||
if provider == "nous":
|
||||
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
|
||||
if merged_extra:
|
||||
kwargs["extra_body"] = merged_extra
|
||||
|
||||
@@ -58,17 +58,34 @@ _bedrock_runtime_client_cache: Dict[str, Any] = {}
|
||||
_bedrock_control_client_cache: Dict[str, Any] = {}
|
||||
|
||||
|
||||
_MIN_BOTO3_VERSION = (1, 34, 59)
|
||||
|
||||
|
||||
def _require_boto3():
|
||||
"""Import boto3, raising a clear error if not installed."""
|
||||
"""Import boto3, raising a clear error if not installed or too old."""
|
||||
try:
|
||||
import boto3
|
||||
return boto3
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"The 'boto3' package is required for the AWS Bedrock provider. "
|
||||
"Install it with: pip install boto3\n"
|
||||
"Or install Hermes with Bedrock support: pip install -e '.[bedrock]'"
|
||||
)
|
||||
# converse() / converse_stream() were added in boto3 1.34.59.
|
||||
# When Hermes is installed editable into system Python, the system boto3
|
||||
# (e.g. Ubuntu 24.04 ships 1.34.46) may take precedence over the venv
|
||||
# version pinned in pyproject.toml.
|
||||
try:
|
||||
version = tuple(int(x) for x in boto3.__version__.split(".")[:3])
|
||||
except (AttributeError, ValueError):
|
||||
return boto3 # can't parse — don't block on version check
|
||||
if version < _MIN_BOTO3_VERSION:
|
||||
raise RuntimeError(
|
||||
f"boto3 {boto3.__version__} does not support converse_stream "
|
||||
f"(minimum 1.34.59 required). Upgrade with: "
|
||||
f"pip install --upgrade boto3"
|
||||
)
|
||||
return boto3
|
||||
|
||||
|
||||
def _get_bedrock_runtime_client(region: str):
|
||||
@@ -935,11 +952,14 @@ def build_converse_kwargs(
|
||||
if system_prompt:
|
||||
kwargs["system"] = system_prompt
|
||||
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
if not _forbids_sampling_params(model):
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
|
||||
if stop_sequences:
|
||||
kwargs["inferenceConfig"]["stopSequences"] = stop_sequences
|
||||
|
||||
@@ -1081,6 +1081,7 @@ def _normalize_codex_response(
|
||||
message_items_raw: List[Dict[str, Any]] = []
|
||||
tool_calls: List[Any] = []
|
||||
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
|
||||
saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
|
||||
saw_commentary_phase = False
|
||||
saw_final_answer_phase = False
|
||||
saw_reasoning_item = False
|
||||
@@ -1095,6 +1096,7 @@ def _normalize_codex_response(
|
||||
|
||||
if item_status in {"queued", "in_progress", "incomplete"}:
|
||||
has_incomplete_items = True
|
||||
saw_streaming_or_item_incomplete = True
|
||||
|
||||
if item_type == "message":
|
||||
item_phase = getattr(item, "phase", None)
|
||||
@@ -1252,7 +1254,9 @@ def _normalize_codex_response(
|
||||
finish_reason = "tool_calls"
|
||||
elif leaked_tool_call_text:
|
||||
finish_reason = "incomplete"
|
||||
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
|
||||
elif saw_streaming_or_item_incomplete:
|
||||
finish_reason = "incomplete"
|
||||
elif (has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase:
|
||||
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
|
||||
|
||||
@@ -40,6 +40,16 @@ from agent.model_metadata import estimate_request_tokens_rough
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stable marker the gateway matches on to re-tag the auto-compaction lifecycle
|
||||
# status as ``kind="compacting"`` (tui_gateway/server.py::_status_update), so
|
||||
# drivers like the desktop app can show an explicit "Summarizing…" indicator
|
||||
# instead of the transcript appearing to silently reset. Keep the marker phrase
|
||||
# intact if you reword COMPACTION_STATUS.
|
||||
COMPACTION_STATUS_MARKER = "Compacting context"
|
||||
COMPACTION_STATUS = (
|
||||
f"🗜️ {COMPACTION_STATUS_MARKER} — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
@@ -324,9 +334,7 @@ def compress_context(
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model,
|
||||
focus_topic,
|
||||
)
|
||||
agent._emit_status(
|
||||
"🗜️ Compacting context — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
|
||||
# ── Compression lock ────────────────────────────────────────────────
|
||||
# Atomic, state.db-backed lock per session_id. Without this, two
|
||||
@@ -631,7 +639,11 @@ def compress_context(
|
||||
return compressed, new_system_prompt
|
||||
|
||||
|
||||
def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
def try_shrink_image_parts_in_messages(
|
||||
api_messages: list,
|
||||
*,
|
||||
max_dimension: int = 8000,
|
||||
) -> bool:
|
||||
"""Re-encode all native image parts at a smaller size to recover from
|
||||
image-too-large errors (Anthropic 5 MB, unknown other providers).
|
||||
|
||||
@@ -642,7 +654,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
Strategy: look for ``image_url`` / ``input_image`` parts carrying a
|
||||
``data:image/...;base64,...`` payload. For each one whose encoded
|
||||
size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
|
||||
ceiling with header overhead), write the base64 to a tempfile, call
|
||||
ceiling with header overhead) or whose longest side exceeds
|
||||
``max_dimension``, write the base64 to a tempfile, call
|
||||
``vision_tools._resize_image_for_vision`` to produce a smaller data
|
||||
URL, and substitute it in place.
|
||||
|
||||
@@ -664,10 +677,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# after a confirmed provider rejection, so the alternative is failure.
|
||||
target_bytes = 4 * 1024 * 1024
|
||||
# Anthropic enforces an 8000px per-side dimension cap independently of
|
||||
# the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far
|
||||
# over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions
|
||||
# even when the byte budget is fine.
|
||||
max_dimension = 8000
|
||||
# the 5 MB byte cap. In many-image requests, the provider can report a
|
||||
# lower cap (observed: 2000px). The caller passes that parsed ceiling
|
||||
# when the rejection includes it.
|
||||
changed_count = 0
|
||||
# Track parts that are over the target but could NOT be shrunk under it.
|
||||
# If any survive, retrying is pointless — the same oversized payload will
|
||||
@@ -684,9 +696,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# Check both byte size AND pixel dimensions.
|
||||
needs_shrink = len(url) > target_bytes # over byte budget
|
||||
if not needs_shrink:
|
||||
# Even if bytes are fine, check pixel dimensions against
|
||||
# Anthropic's 8000px cap. A tall image can be tiny in bytes
|
||||
# yet huge in pixels.
|
||||
# Even if bytes are fine, check pixel dimensions against the
|
||||
# provider's reported per-side cap. A screenshot can be tiny in
|
||||
# bytes yet too large in pixels.
|
||||
try:
|
||||
import base64 as _b64_dim
|
||||
header_d, _, data_d = url.partition(",")
|
||||
@@ -795,6 +807,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COMPACTION_STATUS",
|
||||
"COMPACTION_STATUS_MARKER",
|
||||
"check_compression_model_feasibility",
|
||||
"replay_compression_warning",
|
||||
"compress_context",
|
||||
|
||||
+183
-14
@@ -71,6 +71,35 @@ logger = logging.getLogger(__name__)
|
||||
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("
|
||||
|
||||
|
||||
def _image_error_max_dimension(error: Exception) -> Optional[int]:
|
||||
"""Extract a provider-reported image dimension ceiling, if present."""
|
||||
parts = []
|
||||
for value in (
|
||||
error,
|
||||
getattr(error, "message", None),
|
||||
getattr(error, "body", None),
|
||||
):
|
||||
if value:
|
||||
try:
|
||||
parts.append(str(value))
|
||||
except Exception:
|
||||
pass
|
||||
text = " ".join(parts).lower()
|
||||
if "image" not in text or "dimension" not in text or "max allowed size" not in text:
|
||||
return None
|
||||
|
||||
match = re.search(r"max allowed size(?:\s+for [^:]+)?:\s*(\d{3,5})\s*pixels?", text)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
max_dimension = int(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
if 512 <= max_dimension <= 8000:
|
||||
return max_dimension
|
||||
return None
|
||||
|
||||
|
||||
def _ollama_context_limit_error(agent: Any, request_tokens: int) -> Optional[str]:
|
||||
"""Return a user-facing error when Ollama is loaded with too little context."""
|
||||
if not getattr(agent, "tools", None):
|
||||
@@ -368,6 +397,42 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List
|
||||
)
|
||||
|
||||
|
||||
# Shared recovery hint appended to every content-policy refusal message. Both
|
||||
# the HTTP-200 refusal path (``finish_reason=content_filter``) and the
|
||||
# exception path (a provider moderation error classified as
|
||||
# ``content_policy_blocked``) end with the same actionable next steps, so they
|
||||
# share one trailer to keep the guidance from drifting between the two sites.
|
||||
_CONTENT_POLICY_RECOVERY_HINT = (
|
||||
"Try rephrasing the request, narrowing the context, or "
|
||||
"adding a fallback provider with `hermes fallback add`."
|
||||
)
|
||||
|
||||
|
||||
def _content_policy_blocked_result(
|
||||
messages: List[Dict],
|
||||
api_call_count: int,
|
||||
*,
|
||||
final_response: str,
|
||||
error_detail: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the terminal turn result for a content-policy block.
|
||||
|
||||
A content-policy refusal is deterministic for the unchanged prompt, so the
|
||||
turn ends here (no retry). Both the HTTP-200 refusal handler and the
|
||||
exception-path handler return the identical shape — a failed, non-completed
|
||||
turn carrying the user-facing message and a ``content_policy_blocked:``
|
||||
prefixed error — so they funnel through this one builder.
|
||||
"""
|
||||
return {
|
||||
"final_response": final_response,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"failed": True,
|
||||
"error": f"content_policy_blocked: {error_detail}",
|
||||
}
|
||||
|
||||
|
||||
def run_conversation(
|
||||
agent,
|
||||
user_message: str,
|
||||
@@ -707,7 +772,10 @@ def run_conversation(
|
||||
# a thinking-only turn. Runs on the per-call copy only — the
|
||||
# stored conversation history keeps the reasoning block for the
|
||||
# UI transcript and session persistence.
|
||||
api_messages = agent._drop_thinking_only_and_merge_users(api_messages)
|
||||
api_messages = agent._drop_thinking_only_and_merge_users(
|
||||
api_messages,
|
||||
drop_codex_reasoning_items=agent.api_mode != "codex_responses",
|
||||
)
|
||||
|
||||
# Normalize message whitespace and tool-call JSON for consistent
|
||||
# prefix matching. Ensures bit-perfect prefixes across turns,
|
||||
@@ -1316,6 +1384,106 @@ def run_conversation(
|
||||
)
|
||||
finish_reason = "length"
|
||||
|
||||
# ── Content-policy refusal (HTTP 200) ──────────────────
|
||||
# The model — or the provider's safety system — returned a
|
||||
# *successful* response whose stop/finish reason is a refusal:
|
||||
# Anthropic ``stop_reason="refusal"`` → ``content_filter``;
|
||||
# OpenAI / portal ``finish_reason="content_filter"`` or a
|
||||
# populated ``message.refusal`` (mapped in the chat_completions
|
||||
# transport); Bedrock ``guardrail_intervened``. The content is
|
||||
# typically empty, so without this branch the response falls
|
||||
# through to the empty-response / invalid-response retry loops
|
||||
# and is mis-surfaced as "rate limited" / "no content after
|
||||
# retries" — burning paid attempts reproducing a deterministic
|
||||
# refusal. Surface it clearly and stop. Mirrors the
|
||||
# exception-based ``content_policy_blocked`` recovery: try a
|
||||
# configured fallback once, otherwise return the refusal.
|
||||
if finish_reason == "content_filter":
|
||||
_refusal_transport = agent._get_transport()
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
_refusal_result = _refusal_transport.normalize_response(
|
||||
response, strip_tool_prefix=agent._is_anthropic_oauth
|
||||
)
|
||||
else:
|
||||
_refusal_result = _refusal_transport.normalize_response(response)
|
||||
_refusal_text = (getattr(_refusal_result, "content", None) or "").strip()
|
||||
# Some refusals carry the explanation only in the reasoning
|
||||
# channel; fall back to it so the user sees *something*.
|
||||
if not _refusal_text:
|
||||
_refusal_text = (agent._extract_reasoning(_refusal_result) or "").strip()
|
||||
|
||||
agent._invoke_api_request_error_hook(
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
api_call_count=api_call_count,
|
||||
api_start_time=api_start_time,
|
||||
api_kwargs=api_kwargs,
|
||||
error_type="ContentPolicyBlocked",
|
||||
error_message=_refusal_text or "model declined to respond (content_filter)",
|
||||
status_code=None,
|
||||
retry_count=retry_count,
|
||||
max_retries=max_retries,
|
||||
retryable=False,
|
||||
reason=FailoverReason.content_policy_blocked.value,
|
||||
)
|
||||
|
||||
if thinking_spinner:
|
||||
thinking_spinner.stop("")
|
||||
thinking_spinner = None
|
||||
if agent.thinking_callback:
|
||||
agent.thinking_callback("")
|
||||
|
||||
# Deterministic for the unchanged prompt — never retry.
|
||||
# Try a configured fallback once (a different model may not
|
||||
# refuse); otherwise surface the refusal terminally.
|
||||
if agent._has_pending_fallback():
|
||||
agent._buffer_status(
|
||||
"⚠️ Model declined to respond (safety refusal) — trying fallback..."
|
||||
)
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
_retry.primary_recovery_attempted = False
|
||||
continue
|
||||
|
||||
agent._flush_status_buffer()
|
||||
_refusal_log = (
|
||||
_refusal_text[:500] + "..."
|
||||
if len(_refusal_text) > 500
|
||||
else _refusal_text
|
||||
)
|
||||
logger.warning(
|
||||
"%sModel declined to respond (finish_reason=content_filter). "
|
||||
"model=%s provider=%s refusal=%s",
|
||||
agent.log_prefix, agent.model, agent.provider,
|
||||
_refusal_log or "(no text)",
|
||||
)
|
||||
agent._emit_status(
|
||||
"⚠️ The model declined to respond to this request (safety refusal)."
|
||||
)
|
||||
|
||||
_refusal_detail = (
|
||||
f"Model's explanation: {_refusal_text}"
|
||||
if _refusal_text
|
||||
else "The model returned no explanation."
|
||||
)
|
||||
_refusal_response = (
|
||||
"⚠️ The model declined to respond to this request "
|
||||
"(safety refusal — not a Hermes/gateway failure).\n\n"
|
||||
f"{_refusal_detail}\n\n"
|
||||
f"{_CONTENT_POLICY_RECOVERY_HINT}"
|
||||
)
|
||||
|
||||
agent._cleanup_task_resources(effective_task_id)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return _content_policy_blocked_result(
|
||||
messages,
|
||||
api_call_count,
|
||||
final_response=_refusal_response,
|
||||
error_detail=_refusal_text or "model declined (content_filter)",
|
||||
)
|
||||
|
||||
if finish_reason == "length":
|
||||
if getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID:
|
||||
agent._vprint(
|
||||
@@ -2067,7 +2235,11 @@ def run_conversation(
|
||||
and not _retry.image_shrink_retry_attempted
|
||||
):
|
||||
_retry.image_shrink_retry_attempted = True
|
||||
if agent._try_shrink_image_parts_in_messages(api_messages):
|
||||
image_max_dimension = _image_error_max_dimension(api_error) or 8000
|
||||
if agent._try_shrink_image_parts_in_messages(
|
||||
api_messages,
|
||||
max_dimension=image_max_dimension,
|
||||
):
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
|
||||
f"shrank and retrying...",
|
||||
@@ -3083,20 +3255,17 @@ def run_conversation(
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
_summary = agent._summarize_api_error(api_error)
|
||||
_policy_response = (
|
||||
f"⚠️ The model provider's safety filter blocked this request "
|
||||
f"(not a Hermes/gateway failure).\n\n"
|
||||
"⚠️ The model provider's safety filter blocked this request "
|
||||
"(not a Hermes/gateway failure).\n\n"
|
||||
f"Provider message: {_summary}\n\n"
|
||||
f"Try rephrasing the request, narrowing the context, or "
|
||||
f"adding a fallback provider with `hermes fallback add`."
|
||||
f"{_CONTENT_POLICY_RECOVERY_HINT}"
|
||||
)
|
||||
return _content_policy_blocked_result(
|
||||
messages,
|
||||
api_call_count,
|
||||
final_response=_policy_response,
|
||||
error_detail=_summary,
|
||||
)
|
||||
return {
|
||||
"final_response": _policy_response,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"failed": True,
|
||||
"error": f"content_policy_blocked: {_summary}",
|
||||
}
|
||||
return {
|
||||
"final_response": None,
|
||||
"messages": messages,
|
||||
|
||||
@@ -70,16 +70,6 @@ def _resolve_args() -> list[str]:
|
||||
|
||||
def _resolve_home_dir() -> str:
|
||||
"""Return a stable HOME for child ACP processes."""
|
||||
|
||||
try:
|
||||
from hermes_constants import get_subprocess_home
|
||||
|
||||
profile_home = get_subprocess_home()
|
||||
if profile_home:
|
||||
return profile_home
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
home = os.environ.get("HOME", "").strip()
|
||||
if home:
|
||||
return home
|
||||
@@ -105,7 +95,10 @@ def _resolve_home_dir() -> str:
|
||||
|
||||
def _build_subprocess_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = _resolve_home_dir()
|
||||
home = _resolve_home_dir()
|
||||
env["HOME"] = home
|
||||
from hermes_constants import apply_subprocess_home_env
|
||||
apply_subprocess_home_env(env)
|
||||
return env
|
||||
|
||||
|
||||
|
||||
@@ -454,16 +454,16 @@ def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]:
|
||||
report["attempted"] = True # we tried but there was nothing to do
|
||||
return report
|
||||
|
||||
# Load and rewrite the live jobs under the scheduler's lock.
|
||||
# Load and rewrite the live jobs under the scheduler's cross-process lock.
|
||||
try:
|
||||
from cron.jobs import load_jobs, save_jobs, _jobs_file_lock
|
||||
from cron.jobs import load_jobs, save_jobs, _jobs_lock
|
||||
except ImportError as e:
|
||||
report["error"] = f"cron module unavailable: {e}"
|
||||
return report
|
||||
|
||||
report["attempted"] = True
|
||||
try:
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
live_jobs = load_jobs()
|
||||
changed = False
|
||||
|
||||
|
||||
+45
-20
@@ -12,6 +12,7 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import unified_diff
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from utils import safe_json_loads
|
||||
from agent.tool_result_classification import file_mutation_result_landed
|
||||
@@ -168,6 +169,27 @@ def _oneline(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _truncate_preview(text: str, max_len: int | None) -> str:
|
||||
if max_len and max_len > 0 and len(text) > max_len:
|
||||
if max_len <= 3:
|
||||
return "." * max_len
|
||||
return text[:max_len - 3] + "..."
|
||||
return text
|
||||
|
||||
|
||||
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
|
||||
if not isinstance(tasks, list):
|
||||
return 0, []
|
||||
goals: list[str] = []
|
||||
for task in tasks:
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
raw_goal = task.get("goal")
|
||||
goal = "?" if raw_goal is None else _oneline(str(raw_goal))
|
||||
goals.append(_truncate_preview(goal or "?", per_goal_len))
|
||||
return len(goals), goals
|
||||
|
||||
|
||||
def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
|
||||
"""Build a short preview of a tool call's primary argument for display.
|
||||
|
||||
@@ -191,6 +213,22 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
"clarify": "question", "skill_manage": "name",
|
||||
}
|
||||
|
||||
# delegate_task: show goal (single) or individual task goals (batch)
|
||||
if tool_name == "delegate_task":
|
||||
tasks = args.get("tasks")
|
||||
if tasks and isinstance(tasks, list):
|
||||
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=40)
|
||||
preview = (
|
||||
f"{task_count} tasks: " + " | ".join(goals)
|
||||
if goals else f"{len(tasks)} parallel tasks"
|
||||
)
|
||||
return _truncate_preview(preview, max_len)
|
||||
goal = args.get("goal", "")
|
||||
if goal is None:
|
||||
return None
|
||||
preview = _oneline(str(goal))
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "process":
|
||||
action = args.get("action", "")
|
||||
sid = args.get("session_id", "")
|
||||
@@ -858,20 +896,6 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
|
||||
return False, ""
|
||||
|
||||
|
||||
def _used_free_parallel(result: str | None) -> bool:
|
||||
"""True when a web result came from Parallel's free Search MCP.
|
||||
|
||||
Only the keyless Parallel path tags its result with ``provider="parallel"``;
|
||||
the paid REST path and every other provider omit it. Used to label the tool
|
||||
line "Parallel search" / "Parallel fetch" exactly when the free MCP served
|
||||
the call.
|
||||
"""
|
||||
if not isinstance(result, str) or '"provider"' not in result:
|
||||
return False
|
||||
data = safe_json_loads(result)
|
||||
return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel"
|
||||
|
||||
|
||||
def get_cute_tool_message(
|
||||
tool_name: str, args: dict, duration: float, result: str | None = None,
|
||||
) -> str:
|
||||
@@ -909,17 +933,15 @@ def get_cute_tool_message(
|
||||
return f"{line}{failure_suffix}"
|
||||
|
||||
if tool_name == "web_search":
|
||||
verb = "Parallel search" if _used_free_parallel(result) else "search"
|
||||
return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}")
|
||||
return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}")
|
||||
if tool_name == "web_extract":
|
||||
verb = "Parallel fetch" if _used_free_parallel(result) else "fetch"
|
||||
urls = args.get("urls", [])
|
||||
if urls:
|
||||
url = urls[0] if isinstance(urls, list) else str(urls)
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
|
||||
return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 {verb:<9} pages {dur}")
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||
if tool_name == "process":
|
||||
@@ -1035,7 +1057,10 @@ def get_cute_tool_message(
|
||||
if tool_name == "delegate_task":
|
||||
tasks = args.get("tasks")
|
||||
if tasks and isinstance(tasks, list):
|
||||
return _wrap(f"┊ 🔀 delegate {len(tasks)} parallel tasks {dur}")
|
||||
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=30)
|
||||
detail = " | ".join(goals) if goals else "parallel"
|
||||
count_label = task_count or len(tasks)
|
||||
return _wrap(f"┊ 🔀 delegate {count_label}x: {_trunc(detail, 35)} {dur}")
|
||||
return _wrap(f"┊ 🔀 delegate {_trunc(args.get('goal', ''), 35)} {dur}")
|
||||
|
||||
preview = build_tool_preview(tool_name, args) or ""
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
class SSLConfigurationError(Exception):
|
||||
"""Raised when SSL/TLS certificate bundle configuration fails."""
|
||||
pass
|
||||
@@ -46,11 +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"),
|
||||
os.path.join(home, ".bashrc"),
|
||||
os.path.join(home, ".zshrc"),
|
||||
os.path.join(home, ".profile"),
|
||||
os.path.join(home, ".bash_profile"),
|
||||
os.path.join(home, ".zprofile"),
|
||||
os.path.join(home, ".netrc"),
|
||||
os.path.join(home, ".pgpass"),
|
||||
os.path.join(home, ".npmrc"),
|
||||
@@ -104,12 +99,6 @@ def is_write_denied(path: str) -> bool:
|
||||
if resolved.startswith(prefix):
|
||||
return True
|
||||
|
||||
# Hermes control-plane files: block both the ACTIVE profile's view
|
||||
# (hermes_home) AND the global root view. Without the root pass, a
|
||||
# profile-mode session leaves <root>/auth.json + <root>/config.yaml
|
||||
# writable — letting a prompt-injected write_file overwrite the global
|
||||
# files that every profile inherits from (same shape as #15981).
|
||||
control_file_names = ("auth.json", "config.yaml", "webhook_subscriptions.json")
|
||||
mcp_tokens_dir_name = "mcp-tokens"
|
||||
|
||||
hermes_dirs = []
|
||||
@@ -122,12 +111,6 @@ def is_write_denied(path: str) -> bool:
|
||||
continue
|
||||
|
||||
for base_real in hermes_dirs:
|
||||
for name in control_file_names:
|
||||
try:
|
||||
if resolved == os.path.realpath(os.path.join(base_real, name)):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
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):
|
||||
|
||||
@@ -41,6 +41,16 @@ DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
|
||||
|
||||
|
||||
def bare_gemini_model_id(model: str) -> str:
|
||||
"""Strip Gemini's own provider prefix from an aggregator-style model id."""
|
||||
name = (model or "").strip()
|
||||
lowered = name.lower()
|
||||
for prefix in ("google/", "gemini/"):
|
||||
if lowered.startswith(prefix):
|
||||
return name[len(prefix):].strip() or name
|
||||
return name
|
||||
|
||||
|
||||
def is_native_gemini_base_url(base_url: str) -> bool:
|
||||
"""Return True when the endpoint speaks Gemini's native REST API."""
|
||||
normalized = str(base_url or "").strip().rstrip("/").lower()
|
||||
@@ -914,6 +924,7 @@ class GeminiNativeClient:
|
||||
thinking_config=thinking_config,
|
||||
)
|
||||
|
||||
model = bare_gemini_model_id(model)
|
||||
if stream:
|
||||
return self._stream_completion(model=model, request=request, timeout=timeout)
|
||||
|
||||
|
||||
+82
-3
@@ -5,6 +5,7 @@ and run_agent.py for pre-flight context checks.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -16,7 +17,7 @@ from urllib.parse import urlparse
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
from utils import atomic_json_write, base_url_host_matches, base_url_hostname
|
||||
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
@@ -111,6 +112,57 @@ _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||||
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
|
||||
_ENDPOINT_MODEL_CACHE_TTL = 300
|
||||
|
||||
|
||||
def _get_model_metadata_cache_path() -> Path:
|
||||
"""Return path to the OpenRouter model metadata disk cache."""
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "cache" / "openrouter_model_metadata.json"
|
||||
|
||||
|
||||
def _model_metadata_disk_cache_age_seconds() -> Optional[float]:
|
||||
"""Return disk-cache age in seconds, or None if freshness is unknown."""
|
||||
try:
|
||||
cache_path = _get_model_metadata_cache_path()
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
age = time.time() - cache_path.stat().st_mtime
|
||||
if age < 0:
|
||||
return None
|
||||
return age
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _load_model_metadata_disk_cache() -> Dict[str, Dict[str, Any]]:
|
||||
"""Load processed OpenRouter metadata cache from disk."""
|
||||
try:
|
||||
cache_path = _get_model_metadata_cache_path()
|
||||
with cache_path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {
|
||||
str(key): value
|
||||
for key, value in data.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load OpenRouter model metadata disk cache: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _save_model_metadata_disk_cache(data: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Save processed OpenRouter metadata cache to disk atomically."""
|
||||
try:
|
||||
atomic_json_write(
|
||||
_get_model_metadata_cache_path(),
|
||||
data,
|
||||
indent=0,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to save OpenRouter model metadata disk cache: %s", e)
|
||||
|
||||
# Descending tiers for context length probing when the model is unknown.
|
||||
# We start at 256K (covers GPT-5.x, many current large-context models) and
|
||||
# step down on context-length errors until one works. Tier[0] is also the
|
||||
@@ -209,7 +261,13 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# https://platform.minimax.io/docs/api-reference/text-chat-openai
|
||||
"minimax-m3": 1000000,
|
||||
"minimax": 204800,
|
||||
# GLM
|
||||
# GLM — GLM-5.2 ships with a 1M context window (verified empirically:
|
||||
# needle-in-a-haystack retrieval at 789K prompt tokens succeeded with
|
||||
# zero errors on api.z.ai/api/coding/paas/v4). Older GLM models
|
||||
# (5, 5.1, 5-turbo) are ~202K. Longest-key-first substring matching
|
||||
# ensures "glm-5.2" resolves to 1M while older variants still hit the
|
||||
# generic 202K fallback.
|
||||
"glm-5.2": 1_048_576,
|
||||
"glm": 202752,
|
||||
# xAI Grok — xAI /v1/models does not return context_length metadata,
|
||||
# so these hardcoded fallbacks prevent Hermes from probing-down to
|
||||
@@ -627,6 +685,15 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
|
||||
if not force_refresh and _model_metadata_cache and (time.time() - _model_metadata_cache_time) < _MODEL_CACHE_TTL:
|
||||
return _model_metadata_cache
|
||||
|
||||
if not force_refresh:
|
||||
disk_age = _model_metadata_disk_cache_age_seconds()
|
||||
if disk_age is not None and disk_age < _MODEL_CACHE_TTL:
|
||||
disk_cache = _load_model_metadata_disk_cache()
|
||||
if disk_cache:
|
||||
_model_metadata_cache = disk_cache
|
||||
_model_metadata_cache_time = time.time() - disk_age
|
||||
return _model_metadata_cache
|
||||
|
||||
try:
|
||||
response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify())
|
||||
response.raise_for_status()
|
||||
@@ -648,12 +715,24 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
|
||||
|
||||
_model_metadata_cache = cache
|
||||
_model_metadata_cache_time = time.time()
|
||||
_save_model_metadata_disk_cache(cache)
|
||||
logger.debug("Fetched metadata for %s models from OpenRouter", len(cache))
|
||||
return cache
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch model metadata from OpenRouter: {e}")
|
||||
return _model_metadata_cache or {}
|
||||
if _model_metadata_cache:
|
||||
return _model_metadata_cache
|
||||
disk_cache = _load_model_metadata_disk_cache()
|
||||
if disk_cache:
|
||||
_model_metadata_cache = disk_cache
|
||||
disk_age = _model_metadata_disk_cache_age_seconds()
|
||||
if disk_age is not None:
|
||||
_model_metadata_cache_time = time.time() - min(disk_age, _MODEL_CACHE_TTL)
|
||||
else:
|
||||
_model_metadata_cache_time = time.time() - _MODEL_CACHE_TTL + 1
|
||||
return _model_metadata_cache
|
||||
return {}
|
||||
|
||||
|
||||
def fetch_endpoint_model_metadata(
|
||||
|
||||
+14
-8
@@ -511,13 +511,19 @@ PLATFORM_HINTS = {
|
||||
"Standard Markdown is automatically converted to Telegram formatting. "
|
||||
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
|
||||
"`inline code`, ```code blocks```, [links](url), and ## headers. "
|
||||
"Telegram supports rich Markdown, so when it improves clarity you may "
|
||||
"use headings, tables (pipe `| col | col |` syntax), task lists "
|
||||
"(`- [ ]` / `- [x]`), nested blockquotes, collapsible details, "
|
||||
"footnotes/references, math/formulas (`$...$`, `$$...$$`), underline, "
|
||||
"subscript/superscript, marked (highlighted) text, and anchors. Prefer "
|
||||
"real Markdown tables and task lists over hand-built bullet substitutes "
|
||||
"when presenting structured data. "
|
||||
"Telegram now supports rich Markdown, so lean into it: whenever it "
|
||||
"makes the answer clearer or easier to scan, actively reach for real "
|
||||
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
|
||||
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
|
||||
"collapsible details, footnotes/references, math/formulas (`$...$`, "
|
||||
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
|
||||
"text, and anchors. Default to structured formatting over dense "
|
||||
"paragraphs for any comparison, set of steps, key/value summary, or "
|
||||
"tabular data. Prefer real Markdown tables and task lists over "
|
||||
"hand-built bullet substitutes when presenting structured data; these "
|
||||
"degrade gracefully (tables become readable bullet groups) when rich "
|
||||
"rendering is unavailable, but advanced constructs like math and "
|
||||
"collapsible details may render as plain source text in that case. "
|
||||
"You can send media files natively: to deliver a file to the user, "
|
||||
"include MEDIA:/absolute/path/to/file in your response. Images "
|
||||
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
|
||||
@@ -1158,7 +1164,7 @@ def build_skills_system_prompt(
|
||||
or get_session_env("HERMES_SESSION_PLATFORM")
|
||||
or ""
|
||||
)
|
||||
disabled = get_disabled_skill_names()
|
||||
disabled = get_disabled_skill_names(_platform_hint or None)
|
||||
cache_key = (
|
||||
str(skills_dir.resolve()),
|
||||
tuple(str(d) for d in external_dirs),
|
||||
|
||||
@@ -104,6 +104,7 @@ _PREFIX_PATTERNS = [
|
||||
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
|
||||
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
|
||||
r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key
|
||||
r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token
|
||||
]
|
||||
|
||||
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
|
||||
|
||||
+56
-27
@@ -272,27 +272,65 @@ def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool:
|
||||
# ── Disabled skills ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_RAW_CONFIG_CACHE: Dict[Tuple[str, int, int], Dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _raw_config_cache_clear() -> None:
|
||||
"""Test hook — drop the shared raw config cache."""
|
||||
_RAW_CONFIG_CACHE.clear()
|
||||
|
||||
|
||||
def _load_raw_config() -> Dict[str, Any]:
|
||||
"""Read config.yaml with a shared mtime+size keyed cache.
|
||||
|
||||
This module intentionally avoids importing ``hermes_cli.config`` on the
|
||||
skill prompt/build path. A tiny local cache gives the same repeated-read
|
||||
win without pulling the heavier CLI config stack into startup.
|
||||
"""
|
||||
config_path = get_config_path()
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
try:
|
||||
stat = config_path.stat()
|
||||
cache_key = (str(config_path), stat.st_mtime_ns, stat.st_size)
|
||||
except OSError:
|
||||
cache_key = None
|
||||
|
||||
if cache_key is not None:
|
||||
cached = _RAW_CONFIG_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.debug("Could not read skill config %s: %s", config_path, e)
|
||||
return {}
|
||||
if not isinstance(parsed, dict):
|
||||
return {}
|
||||
|
||||
if cache_key is not None:
|
||||
_RAW_CONFIG_CACHE.clear()
|
||||
_RAW_CONFIG_CACHE[cache_key] = parsed
|
||||
return parsed
|
||||
|
||||
|
||||
def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
|
||||
"""Read disabled skill names from config.yaml.
|
||||
|
||||
Args:
|
||||
platform: Explicit platform name (e.g. ``"telegram"``). When
|
||||
*None*, resolves from ``HERMES_PLATFORM`` or
|
||||
``HERMES_SESSION_PLATFORM`` env vars. Falls back to the
|
||||
global disabled list when no platform is determined.
|
||||
``HERMES_SESSION_PLATFORM`` env vars. Returns the global
|
||||
disabled list, unioned with the platform-specific list when a
|
||||
platform is resolved (a globally-disabled skill stays disabled
|
||||
on every platform).
|
||||
|
||||
Reads the config file directly (no CLI config imports) to stay
|
||||
lightweight.
|
||||
"""
|
||||
config_path = get_config_path()
|
||||
if not config_path.exists():
|
||||
return set()
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.debug("Could not read skill config %s: %s", config_path, e)
|
||||
return set()
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = _load_raw_config()
|
||||
if not parsed:
|
||||
return set()
|
||||
|
||||
skills_cfg = parsed.get("skills")
|
||||
@@ -305,13 +343,14 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
|
||||
or os.getenv("HERMES_PLATFORM")
|
||||
or get_session_env("HERMES_SESSION_PLATFORM")
|
||||
)
|
||||
global_disabled = _normalize_string_set(skills_cfg.get("disabled"))
|
||||
if resolved_platform:
|
||||
platform_disabled = (skills_cfg.get("platform_disabled") or {}).get(
|
||||
resolved_platform
|
||||
)
|
||||
if platform_disabled is not None:
|
||||
return _normalize_string_set(platform_disabled)
|
||||
return _normalize_string_set(skills_cfg.get("disabled"))
|
||||
return global_disabled | _normalize_string_set(platform_disabled)
|
||||
return global_disabled
|
||||
|
||||
|
||||
def _normalize_string_set(values) -> Set[str]:
|
||||
@@ -336,6 +375,7 @@ _EXTERNAL_DIRS_CACHE: Dict[Tuple[str, int], List[Path]] = {}
|
||||
def _external_dirs_cache_clear() -> None:
|
||||
"""Test hook — drop the in-process cache."""
|
||||
_EXTERNAL_DIRS_CACHE.clear()
|
||||
_raw_config_cache_clear()
|
||||
|
||||
|
||||
def get_external_skills_dirs() -> List[Path]:
|
||||
@@ -368,11 +408,8 @@ def get_external_skills_dirs() -> List[Path]:
|
||||
# Return a copy so callers can't mutate the cached list.
|
||||
return list(cached)
|
||||
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = _load_raw_config()
|
||||
if not parsed:
|
||||
return []
|
||||
|
||||
skills_cfg = parsed.get("skills")
|
||||
@@ -584,15 +621,7 @@ def resolve_skill_config_values(
|
||||
current values (or the declared default if the key isn't set).
|
||||
Path values are expanded via ``os.path.expanduser``.
|
||||
"""
|
||||
config_path = get_config_path()
|
||||
config: Dict[str, Any] = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
if isinstance(parsed, dict):
|
||||
config = parsed
|
||||
except Exception:
|
||||
pass
|
||||
config = _load_raw_config()
|
||||
|
||||
resolved: Dict[str, Any] = {}
|
||||
for var in config_vars:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Preventive SSL CA certificate checks for Hermes Agent.
|
||||
|
||||
This module catches broken CA bundle paths before OpenAI/httpx turns them into
|
||||
opaque ``FileNotFoundError: [Errno 2] No such file or directory`` failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
from agent.errors import SSLConfigurationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CA_BUNDLE_ENV_VARS = (
|
||||
"HERMES_CA_BUNDLE",
|
||||
"SSL_CERT_FILE",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"CURL_CA_BUNDLE",
|
||||
)
|
||||
|
||||
_SKIP_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _skip_ssl_guard_enabled() -> bool:
|
||||
return os.getenv("HERMES_SKIP_SSL_GUARD", "").strip().lower() in _SKIP_VALUES
|
||||
|
||||
|
||||
def _repair_hint() -> str:
|
||||
return (
|
||||
"Repair: python -m pip install --force-reinstall certifi openai httpx\n"
|
||||
"If you configured a custom corporate CA bundle, fix or unset the "
|
||||
"broken CA bundle environment variable."
|
||||
)
|
||||
|
||||
|
||||
def _ssl_err(message: str) -> SSLConfigurationError:
|
||||
"""Create a consistent, user-actionable SSL configuration error."""
|
||||
return SSLConfigurationError(f"{message}\n{_repair_hint()}")
|
||||
|
||||
|
||||
def _validate_bundle_path(label: str, value: str, *, require_substantial: bool = False) -> None:
|
||||
path = Path(value).expanduser()
|
||||
if not path.exists():
|
||||
raise _ssl_err(f"{label} points to a missing CA bundle: {value}")
|
||||
if not path.is_file():
|
||||
raise _ssl_err(f"{label} does not point to a CA bundle file: {value}")
|
||||
if require_substantial and path.stat().st_size < 1024:
|
||||
raise _ssl_err(f"{label} at {value} appears corrupted (too small)")
|
||||
try:
|
||||
ctx = ssl.create_default_context(cafile=str(path))
|
||||
except Exception as exc:
|
||||
raise _ssl_err(f"{label} CA bundle at {value} cannot be loaded: {exc}") from exc
|
||||
if not ctx.get_ca_certs():
|
||||
raise _ssl_err(f"{label} CA bundle at {value} did not load any certificates")
|
||||
|
||||
|
||||
def verify_ca_bundle() -> None:
|
||||
"""Verify configured and bundled CA certificates are present and loadable.
|
||||
|
||||
Raises:
|
||||
SSLConfigurationError: If an explicit CA-bundle environment variable
|
||||
points at a bad path, or if certifi's bundled ``cacert.pem`` is
|
||||
missing/corrupt.
|
||||
"""
|
||||
if _skip_ssl_guard_enabled():
|
||||
logger.debug("SSL CA bundle guard skipped via HERMES_SKIP_SSL_GUARD")
|
||||
return
|
||||
|
||||
for env_var in _CA_BUNDLE_ENV_VARS:
|
||||
value = os.getenv(env_var)
|
||||
if value:
|
||||
_validate_bundle_path(env_var, value)
|
||||
|
||||
try:
|
||||
import certifi
|
||||
except Exception as exc:
|
||||
raise _ssl_err(f"certifi is not importable: {exc}") from exc
|
||||
|
||||
ca_bundle = str(certifi.where())
|
||||
_validate_bundle_path("certifi", ca_bundle, require_substantial=True)
|
||||
|
||||
|
||||
def verify_ca_bundle_with_fallback() -> None:
|
||||
"""Backward-compatible wrapper for older call sites.
|
||||
|
||||
The old PR name mentioned a platform fallback, but allowing startup with a
|
||||
broken certifi bundle still leaves httpx/OpenAI and requests call sites
|
||||
failing later. Keep the wrapper name but enforce the same check.
|
||||
"""
|
||||
verify_ca_bundle()
|
||||
@@ -186,10 +186,21 @@ class AnthropicTransport(ProviderTransport):
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Anthropic response structure is valid.
|
||||
|
||||
An empty content list is legitimate when ``stop_reason == "end_turn"``
|
||||
— the model's canonical way of signalling "nothing more to add" after
|
||||
a tool turn that already delivered the user-facing text. Treating it
|
||||
as invalid falsely retries a completed response.
|
||||
An empty content list is legitimate for terminal stop reasons that
|
||||
carry no text payload:
|
||||
|
||||
- ``end_turn`` — the model's canonical "nothing more to add" after a
|
||||
tool turn that already delivered the user-facing text.
|
||||
- ``refusal`` — the model declined to respond (Claude 4.5+). The
|
||||
Messages API returns an empty ``content`` list with this stop
|
||||
reason. Treating it as invalid sends a deterministic refusal into
|
||||
the invalid-response retry loop, which reproduces the refusal on
|
||||
every attempt and surfaces a misleading "rate limited / invalid
|
||||
response" error instead of the refusal. ``normalize_response`` maps
|
||||
``refusal`` → ``content_filter`` so the agent loop's refusal handler
|
||||
can surface it.
|
||||
|
||||
Treating either as invalid falsely retries a completed response.
|
||||
"""
|
||||
if response is None:
|
||||
return False
|
||||
@@ -197,7 +208,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
if not isinstance(content_blocks, list):
|
||||
return False
|
||||
if not content_blocks:
|
||||
return getattr(response, "stop_reason", None) == "end_turn"
|
||||
return getattr(response, "stop_reason", None) in {"end_turn", "refusal"}
|
||||
return True
|
||||
|
||||
def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
|
||||
|
||||
@@ -531,6 +531,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
supports_reasoning=params.get("supports_reasoning", False),
|
||||
qwen_session_metadata=params.get("qwen_session_metadata"),
|
||||
model=model,
|
||||
base_url=params.get("base_url"),
|
||||
ollama_num_ctx=params.get("ollama_num_ctx"),
|
||||
session_id=params.get("session_id"),
|
||||
)
|
||||
@@ -664,8 +665,42 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if rd:
|
||||
provider_data["reasoning_details"] = rd
|
||||
|
||||
# OpenAI structured-refusal field. When a model declines, the SDK
|
||||
# populates ``message.refusal`` with the explanation and leaves
|
||||
# ``content`` empty. OpenAI-compatible proxies that front Anthropic /
|
||||
# Bedrock (e.g. Nous Portal) surface a Claude refusal this way — or via
|
||||
# ``finish_reason="content_filter"`` — instead of the native
|
||||
# ``stop_reason="refusal"``. Without capturing it the refusal looks
|
||||
# like an empty response, so the agent loop retries a deterministic
|
||||
# refusal three times and gives up with "no content after retries".
|
||||
# Promote it to content + a ``content_filter`` finish reason so the
|
||||
# loop's refusal handler surfaces it clearly and stops. ``refusal`` is
|
||||
# ``None`` for normal responses, so this is a no-op in the common case.
|
||||
content = msg.content
|
||||
refusal = getattr(msg, "refusal", None)
|
||||
if refusal is None and hasattr(msg, "model_extra"):
|
||||
_msg_extra = getattr(msg, "model_extra", None) or {}
|
||||
if isinstance(_msg_extra, dict):
|
||||
refusal = _msg_extra.get("refusal")
|
||||
if isinstance(refusal, str) and refusal.strip():
|
||||
# Record the refusal explanation regardless — it's useful provider
|
||||
# metadata even when the model also returned a usable payload.
|
||||
provider_data["refusal"] = refusal
|
||||
_has_text = isinstance(content, str) and content.strip()
|
||||
_has_tool_calls = bool(tool_calls)
|
||||
# Only promote to a terminal ``content_filter`` when the refusal is
|
||||
# the *sole* payload — no visible text and no tool calls. A response
|
||||
# that carries real content (or tool calls) alongside a refusal note
|
||||
# is a normal, usable turn: surfacing it as a failed safety refusal
|
||||
# would discard the model's actual work. In the empty-payload case,
|
||||
# adopt the refusal as content so the loop has something to show.
|
||||
if not _has_text and not _has_tool_calls:
|
||||
content = refusal
|
||||
if finish_reason in (None, "stop"):
|
||||
finish_reason = "content_filter"
|
||||
|
||||
return NormalizedResponse(
|
||||
content=msg.content,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
reasoning=reasoning,
|
||||
|
||||
@@ -218,22 +218,10 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
kwargs.pop("timeout", None)
|
||||
|
||||
if is_codex_backend:
|
||||
prompt_cache_key = kwargs.get("prompt_cache_key")
|
||||
cache_scope_id = str(prompt_cache_key or session_id or "").strip()
|
||||
if cache_scope_id:
|
||||
existing_extra_headers = kwargs.get("extra_headers")
|
||||
merged_extra_headers: Dict[str, str] = {}
|
||||
if isinstance(existing_extra_headers, dict):
|
||||
merged_extra_headers.update(
|
||||
{
|
||||
str(key): str(value)
|
||||
for key, value in existing_extra_headers.items()
|
||||
if key and value is not None
|
||||
}
|
||||
)
|
||||
merged_extra_headers["session_id"] = cache_scope_id
|
||||
merged_extra_headers["x-client-request-id"] = cache_scope_id
|
||||
kwargs["extra_headers"] = merged_extra_headers
|
||||
# chatgpt.com/backend-api/codex rejects body-level
|
||||
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
|
||||
# this backend must not be sent through the Responses payload.
|
||||
kwargs.pop("extra_headers", None)
|
||||
|
||||
max_tokens = params.get("max_tokens")
|
||||
if max_tokens is not None and not is_codex_backend:
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
@@ -40,8 +40,8 @@
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -67,6 +67,16 @@ function buildDesktopBackendPath({
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
|
||||
if (!hermesHome) return hermesHome
|
||||
const resolved = pathModule.resolve(String(hermesHome))
|
||||
const parent = pathModule.dirname(resolved)
|
||||
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
|
||||
return pathModule.dirname(parent)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function buildDesktopBackendEnv({
|
||||
hermesHome,
|
||||
pythonPathEntries = [],
|
||||
@@ -97,5 +107,6 @@ module.exports = {
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
} = require('./backend-env.cjs')
|
||||
|
||||
@@ -66,6 +67,21 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () =
|
||||
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
|
||||
})
|
||||
|
||||
test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => {
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }),
|
||||
'/Users/test/.hermes'
|
||||
)
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
|
||||
'C:\\Users\\test\\AppData\\Local\\hermes'
|
||||
)
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }),
|
||||
'/Users/test/.hermes'
|
||||
)
|
||||
})
|
||||
|
||||
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
|
||||
|
||||
@@ -38,7 +38,7 @@ const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { waitForDashboardPort } = require('./backend-ready.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv } = require('./backend-env.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||
@@ -240,7 +240,7 @@ if (INSTALL_STAMP) {
|
||||
// HERMES_HOME beneath the throwaway userData dir so a fresh-install run never
|
||||
// touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes.
|
||||
function resolveHermesHome() {
|
||||
if (process.env.HERMES_HOME) return path.resolve(process.env.HERMES_HOME)
|
||||
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
|
||||
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
|
||||
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
|
||||
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
|
||||
@@ -5609,11 +5609,30 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
|
||||
ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) return false
|
||||
new Notification({
|
||||
// Action buttons render only on signed macOS builds; elsewhere they're dropped
|
||||
// and the body click still works.
|
||||
const actions = Array.isArray(payload?.actions) ? payload.actions : []
|
||||
const notification = new Notification({
|
||||
title: payload?.title || 'Hermes',
|
||||
body: payload?.body || '',
|
||||
silent: Boolean(payload?.silent)
|
||||
}).show()
|
||||
silent: Boolean(payload?.silent),
|
||||
actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') }))
|
||||
})
|
||||
notification.on('click', () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
focusWindow(mainWindow)
|
||||
if (payload?.sessionId) {
|
||||
mainWindow.webContents.send('hermes:focus-session', payload.sessionId)
|
||||
}
|
||||
})
|
||||
notification.on('action', (_actionEvent, index) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const action = actions[index]
|
||||
if (action?.id) {
|
||||
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id })
|
||||
}
|
||||
})
|
||||
notification.show()
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -94,6 +94,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
ipcRenderer.on('hermes:window-state-changed', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
|
||||
},
|
||||
onFocusSession: callback => {
|
||||
const listener = (_event, sessionId) => callback(sessionId)
|
||||
ipcRenderer.on('hermes:focus-session', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
|
||||
},
|
||||
onNotificationAction: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:notification-action', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
|
||||
},
|
||||
onPreviewFileChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:preview-file-changed', listener)
|
||||
|
||||
@@ -85,6 +85,8 @@ import {
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
@@ -135,6 +137,12 @@ function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
|
||||
return 'command'
|
||||
}
|
||||
|
||||
/** A `/` query is at its arg stage once it's past the command name. */
|
||||
const slashArgStage = (query: string) => query.includes(' ')
|
||||
|
||||
/** The `/command` token of a slash query (`personality x` → `/personality`). */
|
||||
const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}`
|
||||
|
||||
interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
@@ -532,48 +540,6 @@ export function ChatBar({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
if (onAttachImageBlob) {
|
||||
triggerHaptic('selection')
|
||||
|
||||
for (const blob of imageBlobs) {
|
||||
void onAttachImageBlob(blob)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText)) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
document.execCommand('insertText', false, pastedText)
|
||||
const nextDraft = composerPlainText(event.currentTarget)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
@@ -610,7 +576,15 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
const found = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
// The arg-stage popover is only useful for commands with an options screen.
|
||||
// For a no-arg command it would dead-end on "No matches", so drop it — the
|
||||
// directive is already complete.
|
||||
const detected =
|
||||
found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query))
|
||||
? null
|
||||
: found
|
||||
|
||||
setTrigger(detected)
|
||||
|
||||
@@ -650,6 +624,46 @@ export function ChatBar({
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
if (onAttachImageBlob) {
|
||||
triggerHaptic('selection')
|
||||
|
||||
for (const blob of imageBlobs) {
|
||||
void onAttachImageBlob(blob)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText)) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
insertPlainTextAtCaret(event.currentTarget, pastedText)
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
@@ -665,6 +679,12 @@ export function ChatBar({
|
||||
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
|
||||
// Suppress the "No matches" empty state once a slash command is past its name:
|
||||
// a no-arg command has nothing to offer, and a fully-typed arg commits on
|
||||
// Space/Tab — neither should dead-end on a popover.
|
||||
const argStageEmpty =
|
||||
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
|
||||
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
@@ -675,6 +695,25 @@ export function ChatBar({
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
// Commit the literally-typed `/command arg` as a directive chip — used when
|
||||
// the completion list is empty because the arg is already fully typed (the
|
||||
// backend completer drops exact matches). Reuses the chip path via a
|
||||
// synthetic item whose serialized form is the verbatim text.
|
||||
const commitTypedSlashDirective = () => {
|
||||
if (trigger?.kind !== '/') {
|
||||
return
|
||||
}
|
||||
|
||||
const text = `/${trigger.query.trimEnd()}`
|
||||
|
||||
replaceTriggerWithChip({
|
||||
id: text,
|
||||
type: 'slash',
|
||||
label: text.slice(1),
|
||||
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
|
||||
})
|
||||
}
|
||||
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -793,6 +832,18 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
|
||||
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
|
||||
if (
|
||||
(event.key === 'Backspace' || event.key === 'Delete') &&
|
||||
deleteSelectionInEditor(event.currentTarget)
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
|
||||
// reserved for the global command palette.
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
@@ -822,7 +873,15 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
// Enter / Tab / Space all accept the highlighted item: a no-arg command
|
||||
// commits its directive chip, an arg-taking command expands to its
|
||||
// options step, and an arg option commits the full `/cmd arg` chip. Space
|
||||
// is slash-only (an `@` mention takes a literal space) and gated to a
|
||||
// non-empty query so a bare `/ ` still types a space.
|
||||
const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim())
|
||||
const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace
|
||||
|
||||
if (accept) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
@@ -843,6 +902,24 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
// Arg stage with nothing left to suggest — a fully-typed arg the backend
|
||||
// completer no longer echoes (it drops the exact match), e.g.
|
||||
// `/personality creative`. Space/Tab still commit what's typed as a single
|
||||
// directive chip; Enter falls through to submit (send it as-is).
|
||||
if (
|
||||
trigger?.kind === '/' &&
|
||||
!triggerItems.length &&
|
||||
(event.key === ' ' || event.key === 'Tab') &&
|
||||
slashArgStage(trigger.query) &&
|
||||
trigger.query.trim()
|
||||
) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
commitTypedSlashDirective()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
|
||||
// place) then sent-message history. The history ring is derived from live
|
||||
// session messages each press — single source of truth, no mirror.
|
||||
@@ -1765,7 +1842,7 @@ export function ChatBar({
|
||||
ref={composerRef}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
{trigger && (
|
||||
{trigger && !argStageEmpty && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
|
||||
@@ -3,12 +3,24 @@ import { describe, expect, it } from 'vitest'
|
||||
import { insertInlineRefsIntoEditor } from './inline-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
const caretIn = (editor: HTMLElement) => {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()!
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
describe('renderComposerContents', () => {
|
||||
it('renders refs and raw text without interpreting user text as HTML', () => {
|
||||
const editor = document.createElement('div')
|
||||
@@ -59,3 +71,64 @@ describe('insertInlineRefsIntoEditor', () => {
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertPlainTextAtCaret', () => {
|
||||
it('inserts multiline text as text nodes + br', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'one\ntwo\nthree')
|
||||
|
||||
expect(editor.querySelectorAll('br').length).toBe(2)
|
||||
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('replaces the selected span', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'abXYef'
|
||||
document.body.append(editor)
|
||||
|
||||
const text = editor.firstChild!
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(text, 2)
|
||||
range.setEnd(text, 4)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'cd')
|
||||
|
||||
expect(composerPlainText(editor)).toBe('abcdef')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSelectionInEditor', () => {
|
||||
it('clears a non-collapsed range and leaves a collapsed caret', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'hello world'
|
||||
document.body.append(editor)
|
||||
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
expect(deleteSelectionInEditor(editor)).toBe(true)
|
||||
expect(composerPlainText(editor)).toBe('')
|
||||
expect(selection.getRangeAt(0).collapsed).toBe(true)
|
||||
expect(deleteSelectionInEditor(editor)).toBe(false)
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -132,6 +132,63 @@ export function renderComposerContents(target: HTMLElement, text: string) {
|
||||
appendComposerContents(target, text)
|
||||
}
|
||||
|
||||
/** Caret range when the selection lives inside `editor`; else null. */
|
||||
function composerSelectionRange(editor: HTMLElement) {
|
||||
const selection = window.getSelection()
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
|
||||
|
||||
if (!selection || !range || !editor.contains(range.commonAncestorContainer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { range, selection }
|
||||
}
|
||||
|
||||
/** Insert plain text at the caret (replacing any selection). Pastes use this
|
||||
* instead of `execCommand('insertText')` — Chromium's editing pipeline is
|
||||
* ~O(n²) on large multiline blobs. */
|
||||
export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
appendTextWithBreaks(fragment, text)
|
||||
|
||||
const tail = fragment.lastChild
|
||||
|
||||
if (hit) {
|
||||
hit.range.deleteContents()
|
||||
hit.range.insertNode(fragment)
|
||||
} else {
|
||||
editor.append(fragment)
|
||||
}
|
||||
|
||||
if (tail) {
|
||||
const caret = document.createRange()
|
||||
caret.setStartAfter(tail)
|
||||
caret.collapse(true)
|
||||
const selection = hit?.selection ?? window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(caret)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/
|
||||
* line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */
|
||||
export function deleteSelectionInEditor(editor: HTMLElement) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
|
||||
if (!hit || hit.range.collapsed) {
|
||||
return false
|
||||
}
|
||||
|
||||
hit.range.deleteContents()
|
||||
hit.range.collapse(true)
|
||||
hit.selection.removeAllRanges()
|
||||
hit.selection.addRange(hit.range)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
|
||||
export function composerHtml(text: string) {
|
||||
let cursor = 0
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { onScrollToBottomRequest, resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll'
|
||||
|
||||
import { ScrollToBottomButton } from './scroll-to-bottom-button'
|
||||
|
||||
function pendingApproval() {
|
||||
$activeSessionId.set('sess-1')
|
||||
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearAllPrompts()
|
||||
resetThreadScroll()
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
// `getByRole('button')` excludes aria-hidden nodes, so "queryByRole null" is the
|
||||
// control's hidden (parked-at-bottom) state.
|
||||
describe('ScrollToBottomButton', () => {
|
||||
it('stays hidden while parked at the bottom', () => {
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('is a plain jump-to-bottom control when scrolled up with no approval', () => {
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeTruthy()
|
||||
expect(screen.queryByText('Approval needed')).toBeNull()
|
||||
})
|
||||
|
||||
it('morphs into the approval pill when scrolled up with a pending approval', () => {
|
||||
pendingApproval()
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Approval needed' })).toBeTruthy()
|
||||
expect(screen.getByText('Approval needed')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not morph while a pending approval is still in view (at bottom)', () => {
|
||||
pendingApproval()
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
// Parked at bottom → control hidden, so it can't claim "approval needed".
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('re-arms sticky-bottom on click', () => {
|
||||
const handler = vi.fn()
|
||||
const stop = onScrollToBottomRequest(handler)
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
stop()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $approvalRequest } from '@/store/prompts'
|
||||
import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll'
|
||||
|
||||
/**
|
||||
@@ -15,6 +16,13 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
|
||||
* / background cards. Visible only while the user has scrolled meaningfully
|
||||
* away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
|
||||
*
|
||||
* When the turn is BLOCKED on an approval, this same control morphs into an
|
||||
* "Approval needed" pill — the only response surface is the inline Run/Reject
|
||||
* bar on the parked tool row, which is always the bottom-most content, so the
|
||||
* existing scroll-to-bottom action lands the user right on it. One control, no
|
||||
* collision, no second scroll path (native scrollIntoView would scroll
|
||||
* overflow:hidden ancestors that can't scroll back and wreck the layout).
|
||||
*
|
||||
* Enter/exit motion lives in styles.css under `.thread-jump-button` — a
|
||||
* directional scale (contract in from 1.1, contract out to 0.9) keyed off
|
||||
* `data-state`. `idle` (never-shown) stays silent so it can't flash on mount;
|
||||
@@ -23,6 +31,11 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
|
||||
export function ScrollToBottomButton() {
|
||||
const { t } = useI18n()
|
||||
const visible = useStore($threadJumpButtonVisible)
|
||||
const request = useStore($approvalRequest)
|
||||
// Scrolled away while an approval is pending → the inline Run/Reject bar is
|
||||
// below the fold. Relabel so the user knows the session needs them, not just
|
||||
// that there's more to read.
|
||||
const approval = visible && Boolean(request)
|
||||
const hasShownRef = useRef(false)
|
||||
|
||||
if (visible) {
|
||||
@@ -30,15 +43,17 @@ export function ScrollToBottomButton() {
|
||||
}
|
||||
|
||||
const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
|
||||
const label = approval ? t.assistant.approval.jumpToApproval : t.assistant.thread.scrollToBottom
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-hidden={!visible}
|
||||
aria-label={t.assistant.thread.scrollToBottom}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'thread-jump-button absolute left-1/2 z-20 grid size-8 place-items-center rounded-full',
|
||||
'border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
|
||||
'backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
|
||||
'thread-jump-button absolute left-1/2 z-20 grid place-items-center backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
|
||||
approval
|
||||
? 'h-8 grid-flow-col gap-1.5 rounded-full border border-primary/40 bg-(--composer-fill) px-3 text-primary hover:bg-primary/10'
|
||||
: 'size-8 rounded-full border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
|
||||
!visible && 'pointer-events-none'
|
||||
)}
|
||||
data-state={state}
|
||||
@@ -52,7 +67,8 @@ export function ScrollToBottomButton() {
|
||||
tabIndex={visible ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="arrow-down" size="1rem" />
|
||||
<Codicon name="arrow-down" size={approval ? '0.875rem' : '1rem'} />
|
||||
{approval && <span className="text-xs font-medium">{label}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ export function ProfileRail() {
|
||||
selectProfile(name)
|
||||
}}
|
||||
open={createOpen}
|
||||
profiles={profiles}
|
||||
/>
|
||||
|
||||
<RenameProfileDialog
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { writeClipboardText } from '@/components/ui/copy-button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -49,26 +49,17 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
const r = t.sidebar.row
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const pinItem: ItemSpec = {
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
}
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
{
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'copy',
|
||||
label: r.copyId,
|
||||
onSelect: event => {
|
||||
event.preventDefault()
|
||||
triggerHaptic('selection')
|
||||
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
|
||||
}
|
||||
},
|
||||
...(canOpenSessionWindow()
|
||||
? [
|
||||
{
|
||||
@@ -122,13 +113,28 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
}
|
||||
]
|
||||
|
||||
const renderItems = (Item: MenuItem) =>
|
||||
items.map(({ className, disabled, icon, label, onSelect, variant }) => (
|
||||
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
<span>{label}</span>
|
||||
</Item>
|
||||
))
|
||||
const renderMenuItem = (Item: MenuItem, { className, disabled, icon, label, onSelect, variant }: ItemSpec) => (
|
||||
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
<span>{label}</span>
|
||||
</Item>
|
||||
)
|
||||
|
||||
const renderItems = (Item: MenuItem) => (
|
||||
<>
|
||||
{renderMenuItem(Item, pinItem)}
|
||||
<CopyButton
|
||||
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
|
||||
disabled={!sessionId}
|
||||
errorMessage={r.copyIdFailed}
|
||||
key={r.copyId}
|
||||
label={r.copyId}
|
||||
onCopyError={err => notifyError(err, r.copyIdFailed)}
|
||||
text={sessionId}
|
||||
/>
|
||||
{items.map(spec => renderMenuItem(Item, spec))}
|
||||
</>
|
||||
)
|
||||
|
||||
const renameDialog = (
|
||||
<RenameSessionDialog
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { respondToApprovalAction } from '../store/native-notifications'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
@@ -269,6 +270,26 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Notification click: the main process already focused the window; jump to its session.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
|
||||
if (sessionId) {
|
||||
navigate(sessionRoute(sessionId))
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [navigate])
|
||||
|
||||
// Notification action button (Approve/Reject) — resolve in place, no navigation.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
|
||||
void respondToApprovalAction(sessionId ?? null, actionId)
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
|
||||
// Build the equivalent /blueprint slash command from the payload and drop
|
||||
// it into the composer — the user reviews/edits, then sends; the agent (or
|
||||
|
||||
@@ -527,7 +527,7 @@ const PLATFORM_INTRO: Record<string, string> = {
|
||||
wecom_callback:
|
||||
'Set up a WeCom self-built app, expose its callback URL, and provide the corp ID, secret, agent ID, and AES key.',
|
||||
weixin:
|
||||
'Sign in to the WeChat Official Account platform, copy the AppID and Token, and point the message callback URL at Hermes.',
|
||||
'Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent\'s iLink Bot API and saves the credentials.',
|
||||
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
|
||||
api_server:
|
||||
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
|
||||
|
||||
@@ -2,14 +2,15 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import { ActionStatus } from '@/components/ui/action-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createProfile, updateProfileSoul } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
||||
@@ -23,16 +24,18 @@ export function isValidProfileName(name: string): boolean {
|
||||
export function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
open
|
||||
open,
|
||||
profiles = []
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated?: (name: string) => Promise<void> | void
|
||||
open: boolean
|
||||
profiles?: ProfileInfo[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
|
||||
const [soul, setSoul] = useState('')
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
@@ -43,7 +46,7 @@ export function CreateProfileDialog({
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setCloneFrom('default')
|
||||
setSoul('')
|
||||
setError(null)
|
||||
setStatus('idle')
|
||||
@@ -66,7 +69,7 @@ export function CreateProfileDialog({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
await createProfile({ name: trimmed, clone_from: cloneFrom })
|
||||
|
||||
if (soul.trim()) {
|
||||
await updateProfileSoul(trimmed, soul)
|
||||
@@ -107,17 +110,25 @@ export function CreateProfileDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
|
||||
<Checkbox
|
||||
checked={cloneFromDefault}
|
||||
className="mt-0.5 shrink-0"
|
||||
onCheckedChange={checked => setCloneFromDefault(checked === true)}
|
||||
/>
|
||||
<span className="grid gap-0.5 leading-snug">
|
||||
<span className="text-sm font-medium">{p.cloneFromDefault}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
|
||||
{p.cloneFrom}
|
||||
</label>
|
||||
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
|
||||
{profiles.map(profile => (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-soul">
|
||||
@@ -127,7 +138,7 @@ export function CreateProfileDialog({
|
||||
className="min-h-28 font-mono text-xs leading-5"
|
||||
id="new-profile-soul"
|
||||
onChange={event => setSoul(event.target.value)}
|
||||
placeholder={p.soulPlaceholder(cloneFromDefault ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||
value={soul}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
createProfile,
|
||||
@@ -82,14 +83,14 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
}, [profiles, selectedName])
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (name: string, cloneFromDefault: boolean) => {
|
||||
async (name: string, cloneFrom: null | string) => {
|
||||
const trimmed = name.trim()
|
||||
|
||||
if (!isValidProfileName(trimmed)) {
|
||||
throw new Error(p.nameHint)
|
||||
}
|
||||
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
await createProfile({ name: trimmed, clone_from: cloneFrom })
|
||||
notify({ kind: 'success', title: p.created, message: trimmed })
|
||||
setSelectedName(trimmed)
|
||||
await refresh()
|
||||
@@ -180,8 +181,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
|
||||
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
|
||||
open={createOpen}
|
||||
profiles={profiles ?? []}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
@@ -453,16 +455,18 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreate,
|
||||
open
|
||||
open,
|
||||
profiles
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
|
||||
onCreate: (name: string, cloneFrom: null | string) => Promise<void>
|
||||
open: boolean
|
||||
profiles: ProfileInfo[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
@@ -472,7 +476,7 @@ function CreateProfileDialog({
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setCloneFrom('default')
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
}, [open])
|
||||
@@ -493,7 +497,7 @@ function CreateProfileDialog({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onCreate(trimmed, cloneFromDefault)
|
||||
await onCreate(trimmed, cloneFrom)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : p.failedCreate)
|
||||
@@ -528,18 +532,25 @@ function CreateProfileDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
|
||||
<input
|
||||
checked={cloneFromDefault}
|
||||
className="size-4 accent-primary"
|
||||
onChange={event => setCloneFromDefault(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{p.cloneFromDefault}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
|
||||
{p.cloneFrom}
|
||||
</label>
|
||||
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
|
||||
{profiles.map(profile => (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
|
||||
import { translateNow } from '@/i18n'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
appendReasoningPart,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
upsertToolPart
|
||||
} from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { playCompletionSound } from '@/lib/completion-sound'
|
||||
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
||||
import {
|
||||
dedupeGeneratedImageEchoesInParts,
|
||||
@@ -25,8 +27,10 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { parseTodos } from '@/lib/todos'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
@@ -330,6 +334,8 @@ export function useMessageStream({
|
||||
const flushHandleRef = useRef<number | null>(null)
|
||||
const lastFlushAtRef = useRef<number>(0)
|
||||
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
|
||||
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
||||
const compactedTurnRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const flushQueuedDeltas = useCallback(
|
||||
(sessionId?: string) => {
|
||||
@@ -636,18 +642,22 @@ export function useMessageStream({
|
||||
|
||||
void refreshSessions().catch(() => undefined)
|
||||
|
||||
if (compactedTurnRef.current.delete(sessionId)) {
|
||||
shouldHydrate = false
|
||||
}
|
||||
|
||||
if (shouldHydrate) {
|
||||
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
|
||||
}
|
||||
|
||||
if (document.hidden && sessionId === activeSessionIdRef.current) {
|
||||
void window.hermesDesktop?.notify({
|
||||
title: 'Hermes finished',
|
||||
body: text.slice(0, 140) || 'The response is ready.'
|
||||
})
|
||||
}
|
||||
dispatchNativeNotification({
|
||||
body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'),
|
||||
kind: 'turnDone',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.turnDoneTitle')
|
||||
})
|
||||
},
|
||||
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
[hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
)
|
||||
|
||||
const failAssistantMessage = useCallback(
|
||||
@@ -822,6 +832,8 @@ export function useMessageStream({
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
nativeSubagentSessionsRef.current.delete(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
@@ -867,12 +879,11 @@ export function useMessageStream({
|
||||
// session so a background turn finishing can't wipe the active chat's
|
||||
// prompt, and vice versa.
|
||||
clearAllPrompts(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamDone')
|
||||
}
|
||||
playCompletionSound()
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText)
|
||||
@@ -903,10 +914,7 @@ export function useMessageStream({
|
||||
|
||||
// terminal/process tool calls are the only things that spawn or reap
|
||||
// background processes — sync the composer status stack right after.
|
||||
if (
|
||||
!sessionInterrupted(sessionId) &&
|
||||
(payload?.name === 'terminal' || payload?.name === 'process')
|
||||
) {
|
||||
if (!sessionInterrupted(sessionId) && (payload?.name === 'terminal' || payload?.name === 'process')) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -958,6 +966,13 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: question,
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'approval.request') {
|
||||
// Dangerous-command / execute_code approval. The Python side is blocked
|
||||
@@ -966,17 +981,31 @@ export function useMessageStream({
|
||||
// Park it per-session (like clarify) so a *background* profile's turn can
|
||||
// raise it and wait — the sidebar flags "needs input" and the inline bar
|
||||
// surfaces once the user focuses that chat.
|
||||
const command = typeof payload?.command === 'string' ? payload.command : ''
|
||||
const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command'
|
||||
|
||||
setApprovalRequest({
|
||||
// false only when a tirith warning forbids it; backend omits the field otherwise.
|
||||
allowPermanent: payload?.allow_permanent !== false,
|
||||
command: typeof payload?.command === 'string' ? payload.command : '',
|
||||
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
|
||||
command,
|
||||
description,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
actions: [
|
||||
{ id: 'approve', text: translateNow('notifications.native.approveAction') },
|
||||
{ id: 'reject', text: translateNow('notifications.native.rejectAction') }
|
||||
],
|
||||
body: command || description,
|
||||
kind: 'approval',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.approvalTitle')
|
||||
})
|
||||
} else if (event.type === 'sudo.request') {
|
||||
// Sudo password capture (tools/terminal_tool.py). Blocked on
|
||||
// sudo.respond {request_id, password}.
|
||||
@@ -988,6 +1017,13 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: translateNow('notifications.native.inputBody'),
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'secret.request') {
|
||||
// Skill credential capture (tools/skills_tool.py). Blocked on
|
||||
@@ -995,16 +1031,26 @@ export function useMessageStream({
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
const envVar = typeof payload?.env_var === 'string' ? payload.env_var : ''
|
||||
const promptText = typeof payload?.prompt === 'string' ? payload.prompt : ''
|
||||
|
||||
setSecretRequest({
|
||||
requestId,
|
||||
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
|
||||
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
|
||||
envVar,
|
||||
prompt: promptText,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: promptText || envVar || translateNow('notifications.native.inputBody'),
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'terminal.read.request') {
|
||||
// read_terminal tool: serialize the renderer's xterm buffer and answer
|
||||
@@ -1022,9 +1068,12 @@ export function useMessageStream({
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'status.update') {
|
||||
// The gateway's notification poller announces background process
|
||||
// completions / watch matches here — re-sync the status stack.
|
||||
if (sessionId && payload?.kind === 'process') {
|
||||
if (sessionId && payload?.kind === 'compacting') {
|
||||
setSessionCompacting(sessionId, true)
|
||||
compactedTurnRef.current.add(sessionId)
|
||||
} else if (sessionId && payload?.kind === 'process') {
|
||||
// The gateway's notification poller announces background process
|
||||
// completions / watch matches here — re-sync the status stack.
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
@@ -1036,8 +1085,17 @@ export function useMessageStream({
|
||||
// the failed turn (same intent as the message.complete clear).
|
||||
if (sessionId) {
|
||||
clearAllPrompts(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: errorMessage,
|
||||
kind: 'turnError',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.turnErrorTitle')
|
||||
})
|
||||
|
||||
if (looksLikeProviderSetup) {
|
||||
requestDesktopOnboarding(errorMessage)
|
||||
} else if (isActiveEvent) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -20,6 +20,7 @@ import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { NotificationsSettings } from './notifications-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
@@ -30,6 +31,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'notifications',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
@@ -101,6 +103,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'notifications'}
|
||||
icon={Bell}
|
||||
label={t.settings.nav.notifications}
|
||||
onClick={() => setActiveView('notifications')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'providers'}
|
||||
@@ -225,6 +233,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : activeView === 'notifications' ? (
|
||||
<NotificationsSettings />
|
||||
) : (
|
||||
<SessionsSettings />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Bell, Play } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound'
|
||||
import {
|
||||
$nativeNotifyPrefs,
|
||||
NATIVE_NOTIFICATION_KINDS,
|
||||
sendTestNativeNotification,
|
||||
setNativeNotifyEnabled,
|
||||
setNativeNotifyKind
|
||||
} from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
|
||||
|
||||
function Caption({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <p className={cn(CAPTION, className)}>{children}</p>
|
||||
}
|
||||
|
||||
function ToggleRow(props: {
|
||||
checked: boolean
|
||||
description: string
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onChange: (on: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch
|
||||
aria-label={props.label}
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
onCheckedChange={on => {
|
||||
triggerHaptic('selection')
|
||||
props.onChange(on)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={props.description}
|
||||
title={props.label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationsSettings() {
|
||||
const { t } = useI18n()
|
||||
const prefs = useStore($nativeNotifyPrefs)
|
||||
const completionSoundVariantId = useStore($completionSoundVariantId)
|
||||
const copy = t.settings.notifications
|
||||
|
||||
const runTest = async () => {
|
||||
triggerHaptic('open')
|
||||
const ok = await sendTestNativeNotification(copy.testTitle, copy.testBody)
|
||||
notify({ kind: ok ? 'info' : 'error', message: ok ? copy.testSent : copy.testUnsupported })
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading icon={Bell} title={copy.title} />
|
||||
<Caption className="mb-2 leading-(--conversation-caption-line-height)">{copy.intro}</Caption>
|
||||
|
||||
<ToggleRow
|
||||
checked={prefs.enabled}
|
||||
description={copy.enableAllDesc}
|
||||
label={copy.enableAll}
|
||||
onChange={setNativeNotifyEnabled}
|
||||
/>
|
||||
|
||||
<div className="my-1 h-px bg-border/30" />
|
||||
|
||||
{NATIVE_NOTIFICATION_KINDS.map(kind => (
|
||||
<ToggleRow
|
||||
checked={prefs.enabled && prefs.kinds[kind]}
|
||||
description={copy.kinds[kind].description}
|
||||
disabled={!prefs.enabled}
|
||||
key={kind}
|
||||
label={copy.kinds[kind].label}
|
||||
onChange={on => setNativeNotifyKind(kind, on)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="my-1 h-px bg-border/30" />
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Select
|
||||
onValueChange={value => {
|
||||
const variantId = Number.parseInt(value, 10)
|
||||
|
||||
setCompletionSoundVariantId(variantId)
|
||||
previewCompletionSound(variantId)
|
||||
triggerHaptic('selection')
|
||||
}}
|
||||
value={String(completionSoundVariantId)}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-56', CONTROL_TEXT)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{COMPLETION_SOUND_VARIANTS.map(variant => (
|
||||
<SelectItem key={variant.id} value={String(variant.id)}>
|
||||
{variant.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
className="gap-1.5"
|
||||
onClick={() => {
|
||||
previewCompletionSound()
|
||||
triggerHaptic('crisp')
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{copy.completionSoundPreview}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={copy.completionSoundDesc}
|
||||
title={copy.completionSoundTitle}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Button className="self-start" onClick={() => void runTest()} size="sm" type="button" variant="outline">
|
||||
<Bell />
|
||||
{copy.test}
|
||||
</Button>
|
||||
<Caption>{copy.focusedHint}</Caption>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { OAuthProvider } from '@/types/hermes'
|
||||
|
||||
const listOAuthProviders = vi.fn()
|
||||
const disconnectOAuthProvider = vi.fn()
|
||||
const getEnvVars = vi.fn()
|
||||
const startManualProviderOAuth = vi.fn()
|
||||
const onboarding = atom({ manual: false })
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
disconnectOAuthProvider: (providerId: string) => disconnectOAuthProvider(providerId),
|
||||
getEnvVars: () => getEnvVars(),
|
||||
listOAuthProviders: () => listOAuthProviders()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/onboarding', () => ({
|
||||
$desktopOnboarding: onboarding,
|
||||
startManualProviderOAuth: (providerId: string) => startManualProviderOAuth(providerId)
|
||||
}))
|
||||
|
||||
function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> = {}): OAuthProvider {
|
||||
return {
|
||||
cli_command: `hermes auth add ${id}`,
|
||||
disconnectable: true,
|
||||
docs_url: '',
|
||||
flow: 'device_code',
|
||||
id,
|
||||
name: id === 'nous' ? 'Nous Portal' : 'MiniMax',
|
||||
status: {
|
||||
logged_in: loggedIn
|
||||
},
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onboarding.set({ manual: false })
|
||||
getEnvVars.mockResolvedValue({})
|
||||
disconnectOAuthProvider.mockResolvedValue({ ok: true, provider: 'nous' })
|
||||
listOAuthProviders.mockResolvedValue({
|
||||
providers: [provider('nous', true), provider('minimax-oauth', false)]
|
||||
})
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderProvidersSettings() {
|
||||
const { ProvidersSettings } = await import('./providers-settings')
|
||||
|
||||
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />)
|
||||
}
|
||||
|
||||
describe('ProvidersSettings', () => {
|
||||
it('disconnects a connected provider account and refreshes the accounts list', async () => {
|
||||
await renderProvidersSettings()
|
||||
|
||||
const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' })
|
||||
fireEvent.click(remove)
|
||||
|
||||
await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous'))
|
||||
expect(listOAuthProviders).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps provider selection separate from account removal', async () => {
|
||||
await renderProvidersSettings()
|
||||
|
||||
fireEvent.click(await screen.findByText('Nous Portal'))
|
||||
|
||||
expect(startManualProviderOAuth).toHaveBeenCalledWith('nous')
|
||||
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not offer removal for externally managed providers', async () => {
|
||||
listOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
provider('qwen-oauth', true, {
|
||||
cli_command: 'hermes auth add qwen-oauth',
|
||||
disconnect_hint: 'Use `hermes auth add qwen-oauth` or that provider\'s CLI to remove it.',
|
||||
disconnectable: false,
|
||||
flow: 'external',
|
||||
name: 'Qwen (via Qwen CLI)'
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
await renderProvidersSettings()
|
||||
|
||||
expect(await screen.findByText('Qwen Code')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
|
||||
expect(screen.getByText(/managed outside Hermes/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
FEATURED_ID,
|
||||
FeaturedProviderRow,
|
||||
KeyProviderRow,
|
||||
ProviderRow,
|
||||
providerTitle,
|
||||
sortProviders
|
||||
} from '@/components/desktop-onboarding-overlay'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { listOAuthProviders } from '@/hermes'
|
||||
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown, KeyRound } from '@/lib/icons'
|
||||
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
||||
|
||||
@@ -85,7 +87,17 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
|
||||
// Selecting a provider hands off to the shared onboarding overlay, which runs
|
||||
// that provider's real sign-in flow; the key affordances open the API-key
|
||||
// catalog below.
|
||||
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
|
||||
function OAuthPicker({
|
||||
disconnecting,
|
||||
onDisconnect,
|
||||
onWantApiKey,
|
||||
providers
|
||||
}: {
|
||||
disconnecting: null | string
|
||||
onDisconnect: (provider: OAuthProvider) => void
|
||||
onWantApiKey: () => void
|
||||
providers: OAuthProvider[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.providers
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
@@ -97,7 +109,7 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
|
||||
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
|
||||
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null
|
||||
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
|
||||
// Keep connected accounts grouped and always visible; only the unconnected
|
||||
// providers hide behind the disclosure, so the page leads with what's set up.
|
||||
@@ -130,7 +142,13 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
{p.connected}
|
||||
</p>
|
||||
{connected.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
<ConnectedProviderRow
|
||||
disconnecting={disconnecting === p.id}
|
||||
key={p.id}
|
||||
onDisconnect={onDisconnect}
|
||||
onSelect={select}
|
||||
provider={p}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
@@ -158,6 +176,63 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectedProviderRow({
|
||||
disconnecting,
|
||||
onDisconnect,
|
||||
onSelect,
|
||||
provider
|
||||
}: {
|
||||
disconnecting: boolean
|
||||
onDisconnect: (provider: OAuthProvider) => void
|
||||
onSelect: (provider: OAuthProvider) => void
|
||||
provider: OAuthProvider
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const title = providerTitle(provider)
|
||||
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
||||
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
|
||||
|
||||
const disconnectHint = provider.flow === 'external'
|
||||
? t.settings.providers.removeExternal(title, provider.cli_command)
|
||||
: t.settings.providers.removeKeyManaged(title)
|
||||
|
||||
return (
|
||||
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
|
||||
<button className="min-w-0 px-3 py-2.5 text-left" onClick={() => onSelect(provider)} type="button">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Check className="size-3" />
|
||||
{t.settings.providers.connected}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||
{!canDisconnect && (
|
||||
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
|
||||
{disconnectHint}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
{canDisconnect && (
|
||||
<Button
|
||||
aria-label={`${t.common.remove} ${title}`}
|
||||
disabled={disconnecting}
|
||||
onClick={() => onDisconnect(provider)}
|
||||
size="icon-xs"
|
||||
title={`${t.common.remove} ${title}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoProviderKeys() {
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -173,20 +248,26 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||
const [disconnecting, setDisconnecting] = useState<null | string>(null)
|
||||
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
|
||||
// re-read connection state when the user finishes (or dismisses) a sign-in
|
||||
// they launched from this page — otherwise the cards keep their stale status.
|
||||
const onboardingActive = useStore($desktopOnboarding).manual
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingActive) {
|
||||
return
|
||||
}
|
||||
const refreshOAuthProviders = useCallback(async () => {
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
const { providers } = await listOAuthProviders()
|
||||
setOauthProviders(providers)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
void (async () => {
|
||||
if (onboardingActive) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { providers } = await listOAuthProviders()
|
||||
|
||||
@@ -201,6 +282,26 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
return () => void (cancelled = true)
|
||||
}, [onboardingActive])
|
||||
|
||||
async function handleDisconnect(provider: OAuthProvider) {
|
||||
const name = providerTitle(provider)
|
||||
|
||||
if (!window.confirm(t.settings.providers.removeConfirm(name))) {
|
||||
return
|
||||
}
|
||||
|
||||
setDisconnecting(provider.id)
|
||||
|
||||
try {
|
||||
await disconnectOAuthProvider(provider.id)
|
||||
notify({ durationMs: 3_000, kind: 'success', title: t.settings.providers.removedTitle, message: t.settings.providers.removedMessage(name) })
|
||||
await refreshOAuthProviders().catch(() => undefined)
|
||||
} catch (err) {
|
||||
notifyError(err, t.settings.providers.failedRemove(name))
|
||||
} finally {
|
||||
setDisconnecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label={t.settings.providers.loading} />
|
||||
}
|
||||
@@ -237,7 +338,12 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
|
||||
<OAuthPicker
|
||||
disconnecting={disconnecting}
|
||||
onDisconnect={provider => void handleDisconnect(provider)}
|
||||
onWantApiKey={() => onViewChange('keys')}
|
||||
providers={oauthProviders}
|
||||
/>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,15 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
|
||||
export type SettingsView =
|
||||
| 'about'
|
||||
| 'gateway'
|
||||
| 'keys'
|
||||
| 'mcp'
|
||||
| 'notifications'
|
||||
| 'providers'
|
||||
| 'sessions'
|
||||
| `config:${string}`
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState, type ComponentProps } from 'react'
|
||||
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -36,14 +36,30 @@ function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
}
|
||||
|
||||
// Choice and "Other" rows share a layout; only color/hover differs.
|
||||
const OPTION_ROW_CLASS = 'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
|
||||
const OPTION_ROW_CLASS = 'flex w-full items-start gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
|
||||
|
||||
const CLARIFY_SHELL_CLASS =
|
||||
'relative mb-3 mt-2 rounded-[0.5rem] border border-border/70 bg-card/40 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
|
||||
|
||||
function ClarifyShell({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div className={cn(CLARIFY_SHELL_CLASS, className)} data-slot="clarify-inline" {...props}>
|
||||
<span aria-hidden className="arc-border" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioDot({ selected }: { selected: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
|
||||
'mt-0.5 grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
|
||||
selected ? 'border-primary' : 'border-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
@@ -99,9 +115,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Race: tool.start fires a tick before clarify.request, so request_id
|
||||
// arrives slightly after the tool block mounts. Show the question (from
|
||||
// args) but disable submit until we have the request id from the gateway.
|
||||
// arrives slightly after the tool block mounts. Hold the whole panel on a
|
||||
// spinner until the gateway request is wired — showing disabled choices or
|
||||
// a "loading question" stub is worse than a brief wait.
|
||||
const ready = Boolean(matchingRequest?.requestId)
|
||||
const loading = !ready && !submitting
|
||||
|
||||
const respond = useCallback(
|
||||
async (answer: string) => {
|
||||
@@ -138,7 +156,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
const handleTextareaKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
const trimmed = draft.trim()
|
||||
|
||||
@@ -162,12 +184,20 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
[draft, respond]
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ClarifyShell
|
||||
aria-label={copy.loadingQuestion}
|
||||
className="grid min-h-24 place-items-center px-3 py-6"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 aria-hidden className="size-5 animate-spin text-muted-foreground/80" />
|
||||
</ClarifyShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mb-3 mt-2 grid gap-6 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]"
|
||||
data-slot="clarify-inline"
|
||||
>
|
||||
<span aria-hidden className="arc-border" />
|
||||
<ClarifyShell className="grid gap-6 px-3 py-2.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -175,9 +205,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
|
||||
{question || <em className="font-normal text-muted-foreground/70">{copy.loadingQuestion}</em>}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">{question}</span>
|
||||
</div>
|
||||
|
||||
{!typing && hasChoices && (
|
||||
@@ -190,7 +218,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
selectedChoice === choice && 'bg-accent/60'
|
||||
)}
|
||||
data-choice
|
||||
disabled={!ready || submitting}
|
||||
disabled={submitting}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => {
|
||||
setSelectedChoice(choice)
|
||||
@@ -200,7 +228,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
>
|
||||
<RadioDot selected={selectedChoice === choice} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
{selectedChoice === choice && <Check aria-hidden className="size-4 shrink-0 text-primary" />}
|
||||
{selectedChoice === choice && <Check aria-hidden className="mt-0.5 size-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
@@ -231,8 +259,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-muted-foreground/85">
|
||||
<KbdCombo combo="mod+enter" size="sm" />
|
||||
{copy.shortcutSuffix}
|
||||
<KbdCombo combo="enter" size="sm" />
|
||||
<KbdCombo combo="shift+enter" size="sm" />
|
||||
{t.composer.hotkeyDescs['composer.sendNewline']}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasChoices && (
|
||||
@@ -249,16 +278,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
{copy.back}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Button disabled={submitting} onClick={() => void respond('')} size="sm" type="button" variant="ghost">
|
||||
{copy.skip}
|
||||
</Button>
|
||||
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
|
||||
<Button disabled={submitting || !draft.trim()} size="sm" type="submit">
|
||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.send}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -270,7 +293,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="-mr-2"
|
||||
disabled={!ready || submitting}
|
||||
disabled={submitting}
|
||||
onClick={() => void respond('')}
|
||||
size="xs"
|
||||
type="button"
|
||||
@@ -280,6 +303,6 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ClarifyShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { $compactionActive } from '@/store/compaction'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $connection } from '@/store/session'
|
||||
@@ -273,10 +274,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
return pickPrimaryPreviewTarget(extractPreviewTargets(completedText))
|
||||
}, [completedText])
|
||||
|
||||
const getMessageText = useCallback(
|
||||
() => messageContentText(messageRuntime.getState().content),
|
||||
[messageRuntime]
|
||||
)
|
||||
const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime])
|
||||
|
||||
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
|
||||
|
||||
@@ -339,13 +337,25 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
|
||||
</div>
|
||||
)
|
||||
|
||||
// Fixed label while auto-compaction runs — decoupled from backend status text.
|
||||
const COMPACTION_LABEL = 'Summarizing thread'
|
||||
|
||||
const CompactionHint: FC = () => (
|
||||
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
|
||||
)
|
||||
|
||||
const ResponseLoadingIndicator: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const elapsed = useElapsedSeconds()
|
||||
const compacting = useStore($compactionActive)
|
||||
|
||||
return (
|
||||
<StatusRow data-slot="aui_response-loading" label={t.assistant.thread.loadingResponse}>
|
||||
<StatusRow
|
||||
data-slot="aui_response-loading"
|
||||
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
|
||||
>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
@@ -380,6 +390,7 @@ const StreamStallIndicator: FC = () => {
|
||||
})
|
||||
|
||||
const [stalled, setStalled] = useState(false)
|
||||
const compacting = useStore($compactionActive)
|
||||
|
||||
useEffect(() => {
|
||||
setStalled(false)
|
||||
@@ -388,15 +399,21 @@ const StreamStallIndicator: FC = () => {
|
||||
return () => window.clearTimeout(id)
|
||||
}, [activity])
|
||||
|
||||
const elapsed = useElapsedSeconds(stalled)
|
||||
const active = stalled || compacting
|
||||
const elapsed = useElapsedSeconds(active)
|
||||
|
||||
if (!stalled) {
|
||||
if (!active) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusRow className="mt-1.5" data-slot="aui_stream-stall" label="Hermes is thinking">
|
||||
<StatusRow
|
||||
className="mt-1.5"
|
||||
data-slot="aui_stream-stall"
|
||||
label={compacting ? COMPACTION_LABEL : 'Hermes is thinking'}
|
||||
>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
@@ -571,10 +588,7 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te
|
||||
|
||||
return (
|
||||
<MarkdownTextContent
|
||||
containerClassName={cn(
|
||||
'text-xs leading-snug text-muted-foreground/85',
|
||||
isRunning && 'shimmer text-muted-foreground/55'
|
||||
)}
|
||||
containerClassName="text-xs leading-snug text-muted-foreground/85"
|
||||
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
|
||||
isRunning={isRunning}
|
||||
text={displayText}
|
||||
|
||||
@@ -180,7 +180,7 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
|
||||
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
|
||||
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
||||
export const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
||||
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
|
||||
|
||||
export const sortProviders = (providers: OAuthProvider[]) =>
|
||||
|
||||
@@ -80,9 +80,12 @@ const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
|
||||
// Offset shadow lifting the revealed panel off the content (same both sides;
|
||||
// the mirror axis is offset-x, which is 0). Same color on light + dark.
|
||||
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
|
||||
// Edge trigger strip, inset past the OS window-resize grab area.
|
||||
// Edge trigger strip, inset past the OS window-resize grab area AND the
|
||||
// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the
|
||||
// neighboring scroller's edge, so any overlap makes the scrollbar reveal the
|
||||
// pane on hover and swallow its clicks (#44140).
|
||||
const HOVER_REVEAL_TRIGGER_WIDTH = 14
|
||||
const HOVER_REVEAL_EDGE_GUTTER = 6
|
||||
const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)'
|
||||
|
||||
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
|
||||
// from the keyboard, since its store-open toggle is a no-op while collapsed.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ContextMenuItem } from '@/components/ui/context-menu'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -9,7 +10,7 @@ import { Check, Copy, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CopyPayload = string | (() => Promise<string> | string)
|
||||
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'tool-row'
|
||||
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'context-menu-item' | 'tool-row'
|
||||
type CopyStatus = 'copied' | 'error' | 'idle'
|
||||
const COPIED_RESET_MS = 1_500
|
||||
|
||||
@@ -159,9 +160,11 @@ export function CopyButton({
|
||||
status === 'copied' ? t.common.copied : status === 'error' ? resolvedErrorMessage : (title ?? resolvedLabel)
|
||||
const ariaLabel = status === 'idle' ? resolvedLabel : feedbackLabel
|
||||
|
||||
if (appearance === 'menu-item') {
|
||||
if (appearance === 'menu-item' || appearance === 'context-menu-item') {
|
||||
const MenuItem = appearance === 'menu-item' ? DropdownMenuItem : ContextMenuItem
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<MenuItem
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onSelect={event => {
|
||||
@@ -170,7 +173,7 @@ export function CopyButton({
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+5
@@ -88,6 +88,8 @@ declare global {
|
||||
) => () => void
|
||||
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
|
||||
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
|
||||
onFocusSession?: (callback: (sessionId: string) => void) => () => void
|
||||
onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
onPowerResume?: (callback: () => void) => () => void
|
||||
@@ -413,6 +415,9 @@ export interface HermesNotification {
|
||||
title?: string
|
||||
body?: string
|
||||
silent?: boolean
|
||||
kind?: string
|
||||
sessionId?: string
|
||||
actions?: { id: string; text: string }[]
|
||||
}
|
||||
|
||||
export interface HermesPreviewTarget {
|
||||
|
||||
@@ -393,6 +393,14 @@ export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; provider: string }>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse> {
|
||||
return window.hermesDesktop.api<OAuthStartResponse>({
|
||||
...profileScoped(),
|
||||
|
||||
@@ -131,6 +131,18 @@ export const en: Translations = {
|
||||
transcriptionUnavailable: 'Voice transcription is not available yet.',
|
||||
tryRecordingAgain: 'Try recording again.',
|
||||
unavailable: 'Voice unavailable'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: 'Approval needed',
|
||||
approveAction: 'Approve',
|
||||
rejectAction: 'Reject',
|
||||
inputTitle: 'Input needed',
|
||||
inputBody: 'Hermes is waiting for your response.',
|
||||
turnDoneTitle: 'Hermes finished',
|
||||
turnDoneBody: 'The response is ready.',
|
||||
turnErrorTitle: 'Turn failed',
|
||||
backgroundDoneTitle: 'Background task finished',
|
||||
backgroundFailedTitle: 'Background task failed'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -263,7 +275,46 @@ export const en: Translations = {
|
||||
keysSettings: 'Settings',
|
||||
mcp: 'MCP',
|
||||
archivedChats: 'Archived Chats',
|
||||
about: 'About'
|
||||
about: 'About',
|
||||
notifications: 'Notifications'
|
||||
},
|
||||
notifications: {
|
||||
title: 'Notifications',
|
||||
intro:
|
||||
'Native desktop notifications, separate from in-app toasts. These are device-local — each computer keeps its own settings.',
|
||||
enableAll: 'Enable notifications',
|
||||
enableAllDesc: 'Master switch. Turn this off to silence every notification below.',
|
||||
focusedHint: 'Completion alerts only fire while Hermes is in the background.',
|
||||
kinds: {
|
||||
approval: {
|
||||
label: 'Approval needed',
|
||||
description: 'A command is waiting for you to approve or reject it.'
|
||||
},
|
||||
input: {
|
||||
label: 'Input needed',
|
||||
description: 'Hermes asked a question or needs a password or secret.'
|
||||
},
|
||||
turnDone: {
|
||||
label: 'Response ready',
|
||||
description: 'A turn finished while Hermes was in the background.'
|
||||
},
|
||||
turnError: {
|
||||
label: 'Turn failed',
|
||||
description: 'A turn ended with an error.'
|
||||
},
|
||||
backgroundDone: {
|
||||
label: 'Background task finished',
|
||||
description: 'A backgrounded terminal command completed.'
|
||||
}
|
||||
},
|
||||
test: 'Send test notification',
|
||||
testTitle: 'Hermes',
|
||||
testBody: 'Notifications are working.',
|
||||
testSent: 'Test sent. If nothing appears, check your OS notification permissions and Focus/Do Not Disturb.',
|
||||
testUnsupported: 'This system does not support native notifications.',
|
||||
completionSoundTitle: 'Completion Sound',
|
||||
completionSoundDesc: 'Plays when an agent turn finishes. Pick a preset and preview it here.',
|
||||
completionSoundPreview: 'Preview'
|
||||
},
|
||||
sections: {
|
||||
model: 'Model',
|
||||
@@ -513,6 +564,12 @@ export const en: Translations = {
|
||||
collapse: 'Collapse',
|
||||
connectAnother: 'Connect another provider',
|
||||
otherProviders: 'Other providers',
|
||||
removeConfirm: provider => `Remove ${provider}?`,
|
||||
removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`,
|
||||
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
|
||||
removedTitle: 'Account removed',
|
||||
removedMessage: provider => `${provider} was removed.`,
|
||||
failedRemove: provider => `Could not remove ${provider}`,
|
||||
noProviderKeys: 'No provider API keys available.',
|
||||
loading: 'Loading providers...'
|
||||
},
|
||||
@@ -903,6 +960,9 @@ export const en: Translations = {
|
||||
deleting: 'Deleting...',
|
||||
createDesc: 'Profiles are independent Hermes environments: separate config, skills, and SOUL.md.',
|
||||
nameLabel: 'Name',
|
||||
cloneFrom: 'Clone from',
|
||||
cloneFromNone: 'None (blank)',
|
||||
cloneFromDesc: 'Copies config, skills, and SOUL.md from the selected source profile.',
|
||||
cloneFromDefault: 'Clone from default',
|
||||
cloneFromDefaultDesc: 'Copy config, skills, and SOUL.md from your default profile.',
|
||||
invalidName: hint => `Invalid name. ${hint}`,
|
||||
@@ -1691,6 +1751,7 @@ export const en: Translations = {
|
||||
moreOptions: 'More approval options',
|
||||
allowSession: 'Allow this session',
|
||||
alwaysAllowMenu: 'Always allow…',
|
||||
jumpToApproval: 'Approval needed',
|
||||
reject: 'Reject',
|
||||
alwaysTitle: 'Always allow this command?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -132,6 +132,18 @@ export const ja = defineLocale({
|
||||
transcriptionUnavailable: '音声文字起こしはまだ利用できません。',
|
||||
tryRecordingAgain: 'もう一度録音してください。',
|
||||
unavailable: '音声は利用できません'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: '承認が必要です',
|
||||
approveAction: '承認',
|
||||
rejectAction: '拒否',
|
||||
inputTitle: '入力が必要です',
|
||||
inputBody: 'Hermes が応答を待っています。',
|
||||
turnDoneTitle: 'Hermes が完了しました',
|
||||
turnDoneBody: '応答の準備ができました。',
|
||||
turnErrorTitle: 'ターンが失敗しました',
|
||||
backgroundDoneTitle: 'バックグラウンドタスクが完了しました',
|
||||
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -177,7 +189,47 @@ export const ja = defineLocale({
|
||||
keysSettings: '設定',
|
||||
mcp: 'MCP',
|
||||
archivedChats: 'アーカイブ済みチャット',
|
||||
about: '情報'
|
||||
about: '情報',
|
||||
notifications: '通知'
|
||||
},
|
||||
notifications: {
|
||||
title: '通知',
|
||||
intro:
|
||||
'アプリ内トーストとは別の、ネイティブのデスクトップ通知です。設定は端末ごとに保存されます。',
|
||||
enableAll: '通知を有効にする',
|
||||
enableAllDesc: 'マスタースイッチ。オフにすると以下のすべての通知を無効にします。',
|
||||
focusedHint: '完了通知は Hermes がバックグラウンドにあるときのみ表示されます。',
|
||||
kinds: {
|
||||
approval: {
|
||||
label: '承認が必要',
|
||||
description: 'コマンドが承認または拒否を待っています。'
|
||||
},
|
||||
input: {
|
||||
label: '入力が必要',
|
||||
description: 'Hermes が質問したか、パスワードやシークレットを必要としています。'
|
||||
},
|
||||
turnDone: {
|
||||
label: '応答完了',
|
||||
description: 'Hermes がバックグラウンドのときにターンが完了しました。'
|
||||
},
|
||||
turnError: {
|
||||
label: 'ターン失敗',
|
||||
description: 'ターンがエラーで終了しました。'
|
||||
},
|
||||
backgroundDone: {
|
||||
label: 'バックグラウンドタスク完了',
|
||||
description: 'バックグラウンドのターミナルコマンドが完了しました。'
|
||||
}
|
||||
},
|
||||
test: 'テスト通知を送信',
|
||||
testTitle: 'Hermes',
|
||||
testBody: '通知は正常に動作しています。',
|
||||
testSent:
|
||||
'テストを送信しました。表示されない場合は、OS の通知許可と集中モード/おやすみモードを確認してください。',
|
||||
testUnsupported: 'このシステムはネイティブ通知に対応していません。',
|
||||
completionSoundTitle: '完了サウンド',
|
||||
completionSoundDesc: 'エージェントのターン終了時に再生されます。プリセットを選んでここで試聴できます。',
|
||||
completionSoundPreview: '試聴'
|
||||
},
|
||||
sections: {
|
||||
model: 'モデル',
|
||||
@@ -642,6 +694,12 @@ export const ja = defineLocale({
|
||||
collapse: '折りたたむ',
|
||||
connectAnother: '別のプロバイダーを接続',
|
||||
otherProviders: 'その他のプロバイダー',
|
||||
removeConfirm: provider => `${provider} を削除しますか?`,
|
||||
removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`,
|
||||
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
|
||||
removedTitle: 'アカウントを削除しました',
|
||||
removedMessage: provider => `${provider} を削除しました。`,
|
||||
failedRemove: provider => `${provider} を削除できませんでした`,
|
||||
noProviderKeys: '利用可能なプロバイダー API キーがありません。',
|
||||
loading: 'プロバイダーを読み込み中...'
|
||||
},
|
||||
@@ -1041,6 +1099,9 @@ export const ja = defineLocale({
|
||||
deleting: '削除中...',
|
||||
createDesc: 'プロファイルは独立した Hermes 環境です:設定、スキル、SOUL.md が別々になります。',
|
||||
nameLabel: '名前',
|
||||
cloneFrom: '複製元',
|
||||
cloneFromNone: 'なし(空)',
|
||||
cloneFromDesc: '選択したプロファイルから設定、スキル、SOUL.md をコピーします。',
|
||||
cloneFromDefault: 'デフォルトプロファイルから設定を複製',
|
||||
cloneFromDefaultDesc: 'デフォルトプロファイルから設定、スキル、SOUL.md をコピーします。',
|
||||
invalidName: hint => `無効なプロファイル名。${hint}`,
|
||||
@@ -1831,6 +1892,7 @@ export const ja = defineLocale({
|
||||
moreOptions: 'その他の承認オプション',
|
||||
allowSession: 'このセッションで許可',
|
||||
alwaysAllowMenu: '常に許可…',
|
||||
jumpToApproval: '承認が必要',
|
||||
reject: '拒否',
|
||||
alwaysTitle: 'このコマンドを常に許可しますか?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -143,6 +143,20 @@ export interface Translations {
|
||||
tryRecordingAgain: string
|
||||
unavailable: string
|
||||
}
|
||||
// Native OS notification copy (titles + generic fallback bodies). Dynamic
|
||||
// bodies (the agent's reply, a command, an error) are passed through raw.
|
||||
native: {
|
||||
approvalTitle: string
|
||||
approveAction: string
|
||||
rejectAction: string
|
||||
inputTitle: string
|
||||
inputBody: string
|
||||
turnDoneTitle: string
|
||||
turnDoneBody: string
|
||||
turnErrorTitle: string
|
||||
backgroundDoneTitle: string
|
||||
backgroundFailedTitle: string
|
||||
}
|
||||
}
|
||||
|
||||
titlebar: {
|
||||
@@ -202,6 +216,26 @@ export interface Translations {
|
||||
mcp: string
|
||||
archivedChats: string
|
||||
about: string
|
||||
notifications: string
|
||||
}
|
||||
notifications: {
|
||||
title: string
|
||||
intro: string
|
||||
enableAll: string
|
||||
enableAllDesc: string
|
||||
focusedHint: string
|
||||
kinds: Record<
|
||||
'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError',
|
||||
{ label: string; description: string }
|
||||
>
|
||||
test: string
|
||||
testTitle: string
|
||||
testBody: string
|
||||
testSent: string
|
||||
testUnsupported: string
|
||||
completionSoundTitle: string
|
||||
completionSoundDesc: string
|
||||
completionSoundPreview: string
|
||||
}
|
||||
sections: Record<string, string>
|
||||
searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string>
|
||||
@@ -413,6 +447,12 @@ export interface Translations {
|
||||
collapse: string
|
||||
connectAnother: string
|
||||
otherProviders: string
|
||||
removeConfirm: (provider: string) => string
|
||||
removeExternal: (provider: string, command: string) => string
|
||||
removeKeyManaged: (provider: string) => string
|
||||
removedTitle: string
|
||||
removedMessage: (provider: string) => string
|
||||
failedRemove: (provider: string) => string
|
||||
noProviderKeys: string
|
||||
loading: string
|
||||
}
|
||||
@@ -695,6 +735,9 @@ export interface Translations {
|
||||
deleting: string
|
||||
createDesc: string
|
||||
nameLabel: string
|
||||
cloneFrom: string
|
||||
cloneFromNone: string
|
||||
cloneFromDesc: string
|
||||
cloneFromDefault: string
|
||||
cloneFromDefaultDesc: string
|
||||
invalidName: (hint: string) => string
|
||||
@@ -1350,6 +1393,7 @@ export interface Translations {
|
||||
moreOptions: string
|
||||
allowSession: string
|
||||
alwaysAllowMenu: string
|
||||
jumpToApproval: string
|
||||
reject: string
|
||||
alwaysTitle: string
|
||||
alwaysDescription: (pattern: string) => string
|
||||
|
||||
@@ -127,6 +127,18 @@ export const zhHant = defineLocale({
|
||||
transcriptionUnavailable: '語音轉寫暫不可用。',
|
||||
tryRecordingAgain: '請再錄製一次。',
|
||||
unavailable: '語音不可用'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: '需要核准',
|
||||
approveAction: '核准',
|
||||
rejectAction: '拒絕',
|
||||
inputTitle: '需要輸入',
|
||||
inputBody: 'Hermes 正在等待你的回應。',
|
||||
turnDoneTitle: 'Hermes 已完成',
|
||||
turnDoneBody: '回覆已就緒。',
|
||||
turnErrorTitle: '本輪失敗',
|
||||
backgroundDoneTitle: '背景工作已完成',
|
||||
backgroundFailedTitle: '背景工作失敗'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -172,7 +184,45 @@ export const zhHant = defineLocale({
|
||||
keysSettings: '設定',
|
||||
mcp: 'MCP',
|
||||
archivedChats: '已封存聊天',
|
||||
about: '關於'
|
||||
about: '關於',
|
||||
notifications: '通知'
|
||||
},
|
||||
notifications: {
|
||||
title: '通知',
|
||||
intro: '原生桌面通知,與應用程式內提示不同。設定會依裝置保存,每台電腦各自獨立。',
|
||||
enableAll: '啟用通知',
|
||||
enableAllDesc: '總開關。關閉後會靜音下方所有通知。',
|
||||
focusedHint: '完成提醒僅在 Hermes 位於背景時觸發。',
|
||||
kinds: {
|
||||
approval: {
|
||||
label: '需要核准',
|
||||
description: '有指令正在等待你核准或拒絕。'
|
||||
},
|
||||
input: {
|
||||
label: '需要輸入',
|
||||
description: 'Hermes 提出了問題,或需要密碼或密鑰。'
|
||||
},
|
||||
turnDone: {
|
||||
label: '回覆就緒',
|
||||
description: 'Hermes 在背景時完成了一輪對話。'
|
||||
},
|
||||
turnError: {
|
||||
label: '本輪失敗',
|
||||
description: '本輪以錯誤結束。'
|
||||
},
|
||||
backgroundDone: {
|
||||
label: '背景工作完成',
|
||||
description: '背景終端機指令已完成。'
|
||||
}
|
||||
},
|
||||
test: '傳送測試通知',
|
||||
testTitle: 'Hermes',
|
||||
testBody: '通知運作正常。',
|
||||
testSent: '測試已傳送。若沒有出現,請檢查系統通知權限與專注模式/勿擾模式。',
|
||||
testUnsupported: '此系統不支援原生通知。',
|
||||
completionSoundTitle: '完成提示音',
|
||||
completionSoundDesc: '代理回合結束時播放。可在此選擇預設並預覽。',
|
||||
completionSoundPreview: '預覽'
|
||||
},
|
||||
sections: {
|
||||
model: '模型',
|
||||
@@ -621,6 +671,12 @@ export const zhHant = defineLocale({
|
||||
collapse: '收合',
|
||||
connectAnother: '連結其他提供方',
|
||||
otherProviders: '其他提供方',
|
||||
removeConfirm: provider => `移除 ${provider}?`,
|
||||
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`,
|
||||
removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`,
|
||||
removedTitle: '帳號已移除',
|
||||
removedMessage: provider => `${provider} 已移除。`,
|
||||
failedRemove: provider => `無法移除 ${provider}`,
|
||||
noProviderKeys: '沒有可用的提供方 API 金鑰。',
|
||||
loading: '正在載入提供方...'
|
||||
},
|
||||
@@ -999,6 +1055,9 @@ export const zhHant = defineLocale({
|
||||
deleting: '刪除中…',
|
||||
createDesc: '設定檔是獨立的 Hermes 環境:各自擁有獨立的設定、技能和 SOUL.md。',
|
||||
nameLabel: '名稱',
|
||||
cloneFrom: '複製來源',
|
||||
cloneFromNone: '無(空白)',
|
||||
cloneFromDesc: '從選取的來源設定檔複製設定、技能和 SOUL.md。',
|
||||
cloneFromDefault: '從預設設定檔複製設定',
|
||||
cloneFromDefaultDesc: '從您的預設設定檔複製設定、技能和 SOUL.md。',
|
||||
invalidName: hint => `設定檔名稱無效。${hint}`,
|
||||
@@ -1775,6 +1834,7 @@ export const zhHant = defineLocale({
|
||||
moreOptions: '更多核准選項',
|
||||
allowSession: '允許本工作階段',
|
||||
alwaysAllowMenu: '一律允許…',
|
||||
jumpToApproval: '需要核准',
|
||||
reject: '拒絕',
|
||||
alwaysTitle: '一律允許此指令?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -127,6 +127,18 @@ export const zh: Translations = {
|
||||
transcriptionUnavailable: '语音转写暂不可用。',
|
||||
tryRecordingAgain: '请再录一次。',
|
||||
unavailable: '语音不可用'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: '需要批准',
|
||||
approveAction: '批准',
|
||||
rejectAction: '拒绝',
|
||||
inputTitle: '需要输入',
|
||||
inputBody: 'Hermes 正在等待你的回应。',
|
||||
turnDoneTitle: 'Hermes 已完成',
|
||||
turnDoneBody: '回复已就绪。',
|
||||
turnErrorTitle: '本轮失败',
|
||||
backgroundDoneTitle: '后台任务已完成',
|
||||
backgroundFailedTitle: '后台任务失败'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -259,7 +271,45 @@ export const zh: Translations = {
|
||||
keysSettings: '设置',
|
||||
mcp: 'MCP',
|
||||
archivedChats: '已归档对话',
|
||||
about: '关于'
|
||||
about: '关于',
|
||||
notifications: '通知'
|
||||
},
|
||||
notifications: {
|
||||
title: '通知',
|
||||
intro: '原生桌面通知,区别于应用内提示。设置按设备保存,每台电脑各自独立。',
|
||||
enableAll: '启用通知',
|
||||
enableAllDesc: '总开关。关闭后将静音下方所有通知。',
|
||||
focusedHint: '完成提醒仅在 Hermes 处于后台时触发。',
|
||||
kinds: {
|
||||
approval: {
|
||||
label: '需要批准',
|
||||
description: '有命令正在等待你批准或拒绝。'
|
||||
},
|
||||
input: {
|
||||
label: '需要输入',
|
||||
description: 'Hermes 提出了问题,或需要密码或密钥。'
|
||||
},
|
||||
turnDone: {
|
||||
label: '回复就绪',
|
||||
description: 'Hermes 在后台时完成了一轮对话。'
|
||||
},
|
||||
turnError: {
|
||||
label: '本轮失败',
|
||||
description: '本轮以错误结束。'
|
||||
},
|
||||
backgroundDone: {
|
||||
label: '后台任务完成',
|
||||
description: '后台终端命令已完成。'
|
||||
}
|
||||
},
|
||||
test: '发送测试通知',
|
||||
testTitle: 'Hermes',
|
||||
testBody: '通知工作正常。',
|
||||
testSent: '测试已发送。如果没有出现,请检查系统通知权限和专注模式/勿扰模式。',
|
||||
testUnsupported: '此系统不支持原生通知。',
|
||||
completionSoundTitle: '完成提示音',
|
||||
completionSoundDesc: '智能体回合结束时播放。可在此选择预设并预览。',
|
||||
completionSoundPreview: '预览'
|
||||
},
|
||||
sections: {
|
||||
model: '模型',
|
||||
@@ -708,6 +758,12 @@ export const zh: Translations = {
|
||||
collapse: '收起',
|
||||
connectAnother: '连接其他提供方',
|
||||
otherProviders: '其他提供方',
|
||||
removeConfirm: provider => `移除 ${provider}?`,
|
||||
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。请使用 ${command} 移除。`,
|
||||
removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`,
|
||||
removedTitle: '账号已移除',
|
||||
removedMessage: provider => `${provider} 已移除。`,
|
||||
failedRemove: provider => `无法移除 ${provider}`,
|
||||
noProviderKeys: '没有可用的提供方 API 密钥。',
|
||||
loading: '正在加载提供方...'
|
||||
},
|
||||
@@ -1037,7 +1093,8 @@ export const zh: Translations = {
|
||||
feishu: '创建飞书 / Lark 应用,配置机器人能力,复制 App ID、App secret 和事件加密密钥。',
|
||||
wecom: '在企业微信中添加群机器人,复制其 webhook key 作为 WECOM_BOT_ID。仅可发送——双向请用企业微信 (应用) 选项。',
|
||||
wecom_callback: '设置一个企业微信自建应用,暴露其回调 URL,并提供 corp ID、secret、agent ID 和 AES key。',
|
||||
weixin: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
|
||||
weixin:
|
||||
'运行 `hermes gateway setup`,选择 Weixin,然后使用个人微信账号扫描并确认二维码。Hermes 会通过腾讯 iLink Bot API 连接并保存凭据。',
|
||||
qqbot: '在 QQ 开放平台 (q.qq.com) 注册一个应用,复制 App ID 和 Client Secret。',
|
||||
api_server:
|
||||
'把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
|
||||
@@ -1092,6 +1149,9 @@ export const zh: Translations = {
|
||||
deleting: '删除中…',
|
||||
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
|
||||
nameLabel: '名称',
|
||||
cloneFrom: '克隆来源',
|
||||
cloneFromNone: '无(空白)',
|
||||
cloneFromDesc: '从选中的来源配置档案复制配置、技能和 SOUL.md。',
|
||||
cloneFromDefault: '从默认档案克隆',
|
||||
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
|
||||
invalidName: hint => `名称无效。${hint}`,
|
||||
@@ -1871,6 +1931,7 @@ export const zh: Translations = {
|
||||
moreOptions: '更多审批选项',
|
||||
allowSession: '允许本会话',
|
||||
alwaysAllowMenu: '始终允许…',
|
||||
jumpToApproval: '需要审批',
|
||||
reject: '拒绝',
|
||||
alwaysTitle: '始终允许此命令?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
// Completion sound bank for agent turn-end cues.
|
||||
// Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1.
|
||||
|
||||
import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound'
|
||||
import { $hapticsMuted } from '@/store/haptics'
|
||||
|
||||
type OscType = OscillatorType
|
||||
|
||||
let ctx: AudioContext | null = null
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (!ctx) {
|
||||
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
|
||||
if (!Ctor) {
|
||||
return null
|
||||
}
|
||||
|
||||
ctx = new Ctor()
|
||||
}
|
||||
|
||||
// Autoplay policies can leave the context suspended until a gesture; a
|
||||
// resume() here recovers it once the user has interacted with the window.
|
||||
if (ctx.state === 'suspended') {
|
||||
void ctx.resume().catch(() => undefined)
|
||||
}
|
||||
|
||||
return ctx
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// One enveloped oscillator voice → master. Linear attack into an exponential
|
||||
// decay keeps the tail smooth and avoids the click you get ramping to zero.
|
||||
function voice(ac: AudioContext, master: GainNode, t0: number, spec: ToneSpec) {
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const peak = spec.gain ?? 0.5
|
||||
const attack = spec.attack ?? 0.006
|
||||
const end = start + spec.dur
|
||||
|
||||
osc.type = spec.type ?? 'sine'
|
||||
osc.frequency.setValueAtTime(spec.freq, start)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), start + attack)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(start)
|
||||
osc.stop(end + 0.02)
|
||||
}
|
||||
|
||||
// Soft pluck: brief triangle strike with an upward glide into the bloom.
|
||||
function pluckVoice(ac: AudioContext, master: GainNode, t0: number, spec: PluckSpec) {
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const attack = spec.attack ?? 0.004
|
||||
const glide = spec.glide ?? 0.16
|
||||
const end = start + spec.decay
|
||||
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.setValueAtTime(spec.freqFrom, start)
|
||||
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + glide)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(start)
|
||||
osc.stop(end + 0.02)
|
||||
}
|
||||
|
||||
// Slow-swell harmonic bloom — the dreamy tail after the pluck.
|
||||
function bloomVoice(ac: AudioContext, master: GainNode, t0: number, spec: BloomSpec) {
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const hold = spec.hold ?? 0.08
|
||||
const end = start + spec.attack + hold + spec.decay
|
||||
|
||||
osc.type = spec.type ?? 'sine'
|
||||
osc.frequency.setValueAtTime(spec.freq, start)
|
||||
|
||||
if (spec.freqTo) {
|
||||
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, start + spec.attack + hold * 0.6)
|
||||
}
|
||||
|
||||
osc.detune.setValueAtTime(spec.detune ?? 0, start)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack)
|
||||
env.gain.setValueAtTime(Math.max(spec.gain, 0.0002), start + spec.attack + hold)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(start)
|
||||
osc.stop(end + 0.02)
|
||||
}
|
||||
|
||||
// One-shot white-noise source of a given length, the raw material for the
|
||||
// bandpassed air/whoosh gestures below.
|
||||
function noiseSource(ac: AudioContext, seconds: number): AudioBufferSourceNode {
|
||||
const length = Math.floor(ac.sampleRate * seconds)
|
||||
const buffer = ac.createBuffer(1, length, ac.sampleRate)
|
||||
const data = buffer.getChannelData(0)
|
||||
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
data[i] = Math.random() * 2 - 1
|
||||
}
|
||||
|
||||
const source = ac.createBufferSource()
|
||||
source.buffer = buffer
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
// A whisper of bandpassed noise for PS5-menu airiness.
|
||||
function airPuff(ac: AudioContext, master: GainNode, t0: number, spec: AirPuffSpec) {
|
||||
const source = noiseSource(ac, 0.12)
|
||||
const filter = ac.createBiquadFilter()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const end = start + spec.decay
|
||||
|
||||
filter.type = 'bandpass'
|
||||
filter.frequency.setValueAtTime(spec.freq, start)
|
||||
filter.Q.setValueAtTime(spec.q ?? 1.2, start)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.018)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
source.connect(filter)
|
||||
filter.connect(env)
|
||||
env.connect(master)
|
||||
source.start(start)
|
||||
source.stop(end + 0.02)
|
||||
}
|
||||
|
||||
// Filtered noise sweep — soft send / whoosh gestures.
|
||||
function whooshVoice(ac: AudioContext, master: GainNode, t0: number, spec: WhooshSpec) {
|
||||
const source = noiseSource(ac, 0.4)
|
||||
const filter = ac.createBiquadFilter()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const end = start + spec.decay
|
||||
|
||||
filter.type = 'bandpass'
|
||||
filter.frequency.setValueAtTime(spec.freqFrom, start)
|
||||
filter.frequency.exponentialRampToValueAtTime(spec.freqTo, end)
|
||||
filter.Q.setValueAtTime(spec.q ?? 0.8, start)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + 0.03)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
source.connect(filter)
|
||||
filter.connect(env)
|
||||
env.connect(master)
|
||||
source.start(start)
|
||||
source.stop(end + 0.02)
|
||||
}
|
||||
|
||||
// Pitch-sweep chirp — modem / sci-fi gestures.
|
||||
function sweepVoice(ac: AudioContext, master: GainNode, t0: number, spec: SweepSpec) {
|
||||
const osc = ac.createOscillator()
|
||||
const env = ac.createGain()
|
||||
const start = t0 + (spec.start ?? 0)
|
||||
const attack = spec.attack ?? 0.003
|
||||
const end = start + spec.decay
|
||||
|
||||
osc.type = spec.type ?? 'triangle'
|
||||
osc.frequency.setValueAtTime(spec.freqFrom, start)
|
||||
osc.frequency.exponentialRampToValueAtTime(spec.freqTo, end - 0.02)
|
||||
|
||||
env.gain.setValueAtTime(0.0001, start)
|
||||
env.gain.exponentialRampToValueAtTime(Math.max(spec.gain, 0.0002), start + attack)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, end)
|
||||
|
||||
osc.connect(env)
|
||||
env.connect(master)
|
||||
osc.start(start)
|
||||
osc.stop(end + 0.02)
|
||||
}
|
||||
|
||||
let reverbImpulse: AudioBuffer | null = null
|
||||
|
||||
// Subtle wet send so the chimes sit in a room rather than a tin can. The impulse
|
||||
// is generated once and cached; each play gets a fresh, disposable convolver.
|
||||
function makeReverb(ac: AudioContext): ConvolverNode {
|
||||
if (!reverbImpulse) {
|
||||
const seconds = 1.6
|
||||
const length = Math.floor(ac.sampleRate * seconds)
|
||||
reverbImpulse = ac.createBuffer(2, length, ac.sampleRate)
|
||||
|
||||
for (let channel = 0; channel < 2; channel += 1) {
|
||||
const data = reverbImpulse.getChannelData(channel)
|
||||
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
// White noise with a steep exponential decay → smooth, short tail.
|
||||
data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2.6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const convolver = ac.createConvolver()
|
||||
convolver.buffer = reverbImpulse
|
||||
|
||||
return convolver
|
||||
}
|
||||
|
||||
export interface CompletionSoundVariant {
|
||||
id: number
|
||||
name: string
|
||||
// `master` is warm (runs through low-pass + room tail).
|
||||
play: (ac: AudioContext, master: GainNode, t0: number) => void
|
||||
}
|
||||
|
||||
// Note frequencies (equal temperament). Everything lives in a low-mid register
|
||||
// (C3–C5) so the chimes feel warm and "appy" rather than bright and arcade-y.
|
||||
const A2 = 110
|
||||
const A3 = 220
|
||||
const A4 = 440
|
||||
const A5 = 880
|
||||
const B5 = 987.77
|
||||
const C3 = 130.81
|
||||
const C4 = 261.63
|
||||
const E4 = 329.63
|
||||
const E5 = 659.25
|
||||
const E6 = 1318.51
|
||||
const G4 = 392
|
||||
const G5 = 783.99
|
||||
const C5 = 523.25
|
||||
const C6 = 1046.5
|
||||
|
||||
export const COMPLETION_SOUND_VARIANTS: readonly CompletionSoundVariant[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Two-note comfort',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: E4, dur: 0.22, gain: 0.05, attack: 0.03, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.08, { freq: C4, dur: 0.52, gain: 0.07, attack: 0.08, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.08, { freq: C3, dur: 0.46, gain: 0.02, attack: 0.1, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Glass ping',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: C6, dur: 0.55, gain: 0.032, attack: 0.002, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.01, { freq: E5, dur: 0.42, gain: 0.018, attack: 0.004, type: 'sine' })
|
||||
airPuff(ac, master, t0, { freq: 3200, gain: 0.004, decay: 0.1, q: 1.4 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Soft marimba',
|
||||
play: (ac, master, t0) => {
|
||||
pluckVoice(ac, master, t0, { freqFrom: E5, freqTo: G5, gain: 0.03, decay: 0.14, glide: 0.08 })
|
||||
bloomVoice(ac, master, t0 + 0.04, { freq: C5, gain: 0.028, attack: 0.08, hold: 0.04, decay: 0.62 })
|
||||
bloomVoice(ac, master, t0 + 0.06, { freq: G4, gain: 0.014, attack: 0.12, hold: 0.06, decay: 0.55 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Tri-tone message',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: C6, dur: 0.14, gain: 0.045, attack: 0.004, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.1, { freq: A5, dur: 0.16, gain: 0.04, attack: 0.004, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.2, { freq: G5, dur: 0.22, gain: 0.035, attack: 0.006, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Airy whoosh',
|
||||
play: (ac, master, t0) => {
|
||||
whooshVoice(ac, master, t0, { freqFrom: 4200, freqTo: 900, gain: 0.022, decay: 0.28, q: 0.7 })
|
||||
voice(ac, master, t0 + 0.12, { freq: A5, dur: 0.35, gain: 0.02, attack: 0.02, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Discovery cluster',
|
||||
play: (ac, master, t0) => {
|
||||
const clusterDetunes = [-14, -5, 0, 7, 12]
|
||||
|
||||
clusterDetunes.forEach((detune, i) => {
|
||||
bloomVoice(ac, master, t0 + i * 0.03, {
|
||||
freq: A3,
|
||||
gain: 0.012,
|
||||
attack: 0.38,
|
||||
hold: 0.12,
|
||||
decay: 1.05,
|
||||
detune
|
||||
})
|
||||
})
|
||||
bloomVoice(ac, master, t0 + 0.1, { freq: E4, gain: 0.008, attack: 0.45, hold: 0.08, decay: 0.9, detune: 3 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Systems online',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: C5, dur: 0.16, gain: 0.04, attack: 0.006, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.09, { freq: G5, dur: 0.28, gain: 0.042, attack: 0.008, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.09, { freq: C4, dur: 0.24, gain: 0.012, attack: 0.01, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'IBM terminal',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: B5, dur: 0.12, gain: 0.038, attack: 0.002, type: 'square' })
|
||||
voice(ac, master, t0 + 0.14, { freq: E5, dur: 0.1, gain: 0.028, attack: 0.002, type: 'square' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Modem chirp',
|
||||
play: (ac, master, t0) => {
|
||||
sweepVoice(ac, master, t0, { freqFrom: 320, freqTo: 2200, gain: 0.024, decay: 0.16, type: 'triangle' })
|
||||
sweepVoice(ac, master, t0 + 0.1, { freqFrom: 480, freqTo: 1400, gain: 0.014, decay: 0.12, type: 'sine' })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Wind chimes',
|
||||
play: (ac, master, t0) => {
|
||||
const chimes = [G5, C6, E5, A5]
|
||||
|
||||
chimes.forEach((frequency, i) => {
|
||||
voice(ac, master, t0 + i * 0.13, {
|
||||
freq: frequency,
|
||||
dur: 0.72,
|
||||
gain: 0.028 - i * 0.003,
|
||||
attack: 0.003,
|
||||
type: 'sine'
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Singing bowl',
|
||||
play: (ac, master, t0) => {
|
||||
bloomVoice(ac, master, t0, { freq: A3, gain: 0.022, attack: 0.58, hold: 0.16, decay: 1.35 })
|
||||
bloomVoice(ac, master, t0 + 0.08, { freq: E4, gain: 0.01, attack: 0.62, hold: 0.12, decay: 1.2, detune: 4 })
|
||||
bloomVoice(ac, master, t0 + 0.14, { freq: A4, gain: 0.006, attack: 0.68, hold: 0.08, decay: 1.05, detune: -3 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'Harp lift',
|
||||
play: (ac, master, t0) => {
|
||||
const notes = [C5, E5, G5, C6]
|
||||
|
||||
notes.forEach((frequency, i) => {
|
||||
voice(ac, master, t0 + i * 0.075, {
|
||||
freq: frequency,
|
||||
dur: 0.38,
|
||||
gain: 0.034 - i * 0.004,
|
||||
attack: 0.012,
|
||||
type: 'sine'
|
||||
})
|
||||
})
|
||||
|
||||
bloomVoice(ac, master, t0 + 0.2, { freq: C4, gain: 0.01, attack: 0.18, hold: 0.06, decay: 0.7 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: 'Sonar ping',
|
||||
play: (ac, master, t0) => {
|
||||
voice(ac, master, t0, { freq: A2, dur: 0.95, gain: 0.036, attack: 0.008, type: 'sine' })
|
||||
voice(ac, master, t0 + 0.42, { freq: A3, dur: 0.55, gain: 0.014, attack: 0.01, type: 'sine' })
|
||||
airPuff(ac, master, t0, { freq: 600, gain: 0.005, decay: 0.2, q: 0.5 })
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: 'Music box',
|
||||
play: (ac, master, t0) => {
|
||||
const notes = [E6, C6, G5, E5]
|
||||
|
||||
notes.forEach((frequency, i) => {
|
||||
pluckVoice(ac, master, t0 + i * 0.09, {
|
||||
freqFrom: frequency,
|
||||
freqTo: frequency * 0.998,
|
||||
gain: 0.02 - i * 0.002,
|
||||
decay: 0.2,
|
||||
glide: 0.06
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
] as const
|
||||
|
||||
function playVariant(variantId: number) {
|
||||
const variant = COMPLETION_SOUND_VARIANTS.find(v => v.id === variantId)
|
||||
|
||||
if (!variant) {
|
||||
return
|
||||
}
|
||||
|
||||
const ac = getCtx()
|
||||
|
||||
if (!ac) {
|
||||
return
|
||||
}
|
||||
|
||||
// Signal path: voices → master → low-pass → (dry + reverb send) → out.
|
||||
const master = ac.createGain()
|
||||
const tone = ac.createBiquadFilter()
|
||||
tone.type = 'lowpass'
|
||||
tone.frequency.setValueAtTime(3800, ac.currentTime)
|
||||
tone.Q.setValueAtTime(0.32, ac.currentTime)
|
||||
master.gain.setValueAtTime(0.48, ac.currentTime)
|
||||
master.connect(tone)
|
||||
|
||||
const dry = ac.createGain()
|
||||
dry.gain.setValueAtTime(0.88, ac.currentTime)
|
||||
tone.connect(dry)
|
||||
dry.connect(ac.destination)
|
||||
|
||||
const reverb = makeReverb(ac)
|
||||
const wet = ac.createGain()
|
||||
wet.gain.setValueAtTime(0.34, ac.currentTime)
|
||||
tone.connect(reverb)
|
||||
reverb.connect(wet)
|
||||
wet.connect(ac.destination)
|
||||
|
||||
variant.play(ac, master, ac.currentTime + 0.01)
|
||||
}
|
||||
|
||||
// Audition the selected variant from settings. Bypasses the haptics mute toggle so
|
||||
// sound design can be compared even when turn-end cues are silenced.
|
||||
export function previewCompletionSound(variantId?: number) {
|
||||
playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get()))
|
||||
}
|
||||
|
||||
// Plays the selected completion cue on any `message.complete`.
|
||||
export function playCompletionSound() {
|
||||
if ($hapticsMuted.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
playVariant($completionSoundVariantId.get())
|
||||
}
|
||||
|
||||
interface AirPuffSpec {
|
||||
decay: number
|
||||
freq: number
|
||||
gain: number
|
||||
q?: number
|
||||
start?: number
|
||||
}
|
||||
|
||||
interface BloomSpec {
|
||||
attack: number
|
||||
decay: number
|
||||
detune?: number
|
||||
freq: number
|
||||
freqTo?: number
|
||||
gain: number
|
||||
hold?: number
|
||||
start?: number
|
||||
type?: OscType
|
||||
}
|
||||
|
||||
interface PluckSpec {
|
||||
attack?: number
|
||||
decay: number
|
||||
freqFrom: number
|
||||
freqTo: number
|
||||
gain: number
|
||||
glide?: number
|
||||
start?: number
|
||||
}
|
||||
|
||||
interface SweepSpec {
|
||||
attack?: number
|
||||
decay: number
|
||||
freqFrom: number
|
||||
freqTo: number
|
||||
gain: number
|
||||
start?: number
|
||||
type?: OscType
|
||||
}
|
||||
|
||||
interface ToneSpec {
|
||||
attack?: number
|
||||
dur: number
|
||||
freq: number
|
||||
gain?: number
|
||||
start?: number
|
||||
type?: OscType
|
||||
}
|
||||
|
||||
interface WhooshSpec {
|
||||
decay: number
|
||||
freqFrom: number
|
||||
freqTo: number
|
||||
gain: number
|
||||
q?: number
|
||||
start?: number
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconAt as AtSign,
|
||||
IconWaveSine as AudioLines,
|
||||
IconChartBar as BarChart3,
|
||||
IconBell as Bell,
|
||||
IconBrain as Brain,
|
||||
IconBug as Bug,
|
||||
IconCheck as Check,
|
||||
@@ -110,6 +111,7 @@ export {
|
||||
AtSign,
|
||||
AudioLines,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Brain,
|
||||
Bug,
|
||||
Check,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $compactingSessions, $compactionActive, setSessionCompacting } from './compaction'
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
describe('compaction store', () => {
|
||||
beforeEach(() => {
|
||||
$compactingSessions.set({})
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$compactingSessions.set({})
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
it('tracks compaction per session independently', () => {
|
||||
setSessionCompacting('session-a', true)
|
||||
setSessionCompacting('session-b', true)
|
||||
|
||||
expect($compactingSessions.get()).toEqual({ 'session-a': true, 'session-b': true })
|
||||
})
|
||||
|
||||
it('exposes only the active session via the focus-scoped view', () => {
|
||||
setSessionCompacting('session-a', true)
|
||||
|
||||
expect($compactionActive.get()).toBe(false)
|
||||
|
||||
$activeSessionId.set('session-a')
|
||||
expect($compactionActive.get()).toBe(true)
|
||||
|
||||
$activeSessionId.set('session-b')
|
||||
expect($compactionActive.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('clears a session without disturbing the others', () => {
|
||||
setSessionCompacting('session-a', true)
|
||||
setSessionCompacting('session-b', true)
|
||||
|
||||
setSessionCompacting('session-a', false)
|
||||
|
||||
expect($compactingSessions.get()).toEqual({ 'session-b': true })
|
||||
})
|
||||
|
||||
it('is a no-op when clearing an unknown session', () => {
|
||||
setSessionCompacting('session-a', true)
|
||||
const before = $compactingSessions.get()
|
||||
|
||||
setSessionCompacting('session-missing', false)
|
||||
|
||||
expect($compactingSessions.get()).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
// Per-session flag while auto-compaction runs mid-turn. Without it the
|
||||
// transcript looks like it reset; per-session so a background chat can't
|
||||
// clobber the foreground view.
|
||||
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
|
||||
|
||||
export const $compactingSessions = atom<Record<string, true>>({})
|
||||
|
||||
export const $compactionActive = computed(
|
||||
[$compactingSessions, $activeSessionId],
|
||||
(sessions, activeId) => keyFor(activeId) in sessions
|
||||
)
|
||||
|
||||
export function setSessionCompacting(sessionId: string | null | undefined, active: boolean): void {
|
||||
const key = keyFor(sessionId)
|
||||
const sessions = $compactingSessions.get()
|
||||
|
||||
if (active) {
|
||||
if (key in sessions) {
|
||||
return
|
||||
}
|
||||
|
||||
$compactingSessions.set({ ...sessions, [key]: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!(key in sessions)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...sessions }
|
||||
delete next[key]
|
||||
$compactingSessions.set(next)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.completionSoundVariantId'
|
||||
|
||||
export const DEFAULT_COMPLETION_SOUND_VARIANT_ID = 1
|
||||
|
||||
// Range mirrors COMPLETION_SOUND_VARIANTS in lib/completion-sound.ts. Validating
|
||||
// by range (not membership) keeps this store free of a dependency on the lib,
|
||||
// which imports the atom back — a membership check would close that cycle.
|
||||
const VARIANT_COUNT = 14
|
||||
|
||||
export function resolveCompletionSoundVariantId(variantId: number): number {
|
||||
return Number.isInteger(variantId) && variantId >= 1 && variantId <= VARIANT_COUNT
|
||||
? variantId
|
||||
: DEFAULT_COMPLETION_SOUND_VARIANT_ID
|
||||
}
|
||||
|
||||
function load(): number {
|
||||
const stored = storedString(STORAGE_KEY)
|
||||
|
||||
return stored ? resolveCompletionSoundVariantId(Number.parseInt(stored, 10)) : DEFAULT_COMPLETION_SOUND_VARIANT_ID
|
||||
}
|
||||
|
||||
export const $completionSoundVariantId = atom(load())
|
||||
|
||||
$completionSoundVariantId.subscribe(id => persistString(STORAGE_KEY, String(id)))
|
||||
|
||||
export function setCompletionSoundVariantId(variantId: number) {
|
||||
$completionSoundVariantId.set(resolveCompletionSoundVariantId(variantId))
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { translateNow } from '@/i18n'
|
||||
import type { TodoItem, TodoStatus } from '@/lib/todos'
|
||||
|
||||
import { $gateway } from './gateway'
|
||||
import { dispatchNativeNotification } from './native-notifications'
|
||||
import { $subagentsBySession, type SubagentProgress } from './subagents'
|
||||
import { $todosBySession } from './todos'
|
||||
|
||||
@@ -161,6 +163,24 @@ export function reconcileBackgroundProcesses(sid: string, procs: GatewayProcessE
|
||||
|
||||
const prev = $backgroundStatusBySession.get()[sid] ?? []
|
||||
|
||||
// running → exited since the last snapshot = a background process just finished.
|
||||
const prevState = new Map(prev.map(item => [item.id, item.state]))
|
||||
|
||||
for (const [id, item] of fresh) {
|
||||
if (item.state !== 'running' && prevState.get(id) === 'running') {
|
||||
dispatchNativeNotification({
|
||||
body: item.title,
|
||||
kind: 'backgroundDone',
|
||||
sessionId: sid,
|
||||
title: translateNow(
|
||||
item.state === 'failed'
|
||||
? 'notifications.native.backgroundFailedTitle'
|
||||
: 'notifications.native.backgroundDoneTitle'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const kept = prev.flatMap(old => {
|
||||
const next = fresh.get(old.id)
|
||||
fresh.delete(old.id)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $gateway } from './gateway'
|
||||
import {
|
||||
dispatchNativeNotification,
|
||||
NATIVE_NOTIFICATION_KINDS,
|
||||
respondToApprovalAction,
|
||||
sendTestNativeNotification,
|
||||
setNativeNotifyEnabled,
|
||||
setNativeNotifyKind
|
||||
} from './native-notifications'
|
||||
import { $approvalRequest, setApprovalRequest } from './prompts'
|
||||
import { $activeSessionId, setActiveSessionId } from './session'
|
||||
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
const initialHermesDesktop = desktopWindow.hermesDesktop
|
||||
|
||||
const notify = vi.fn().mockResolvedValue(true)
|
||||
|
||||
function setWindowState({ focused = true, hidden = false }: { focused?: boolean; hidden?: boolean }) {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
|
||||
Object.defineProperty(document, 'hasFocus', { configurable: true, value: () => focused })
|
||||
}
|
||||
|
||||
let counter = 0
|
||||
|
||||
// Unique session id per call dodges the per-(kind,session) throttle so each
|
||||
// assertion starts clean.
|
||||
function freshSession(): string {
|
||||
counter += 1
|
||||
|
||||
return `session-${counter}`
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
notify.mockClear()
|
||||
desktopWindow.hermesDesktop = { notify } as unknown as Window['hermesDesktop']
|
||||
setNativeNotifyEnabled(true)
|
||||
|
||||
for (const kind of NATIVE_NOTIFICATION_KINDS) {
|
||||
setNativeNotifyKind(kind, true)
|
||||
}
|
||||
|
||||
setActiveSessionId(null)
|
||||
setWindowState({ focused: false, hidden: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (initialHermesDesktop) {
|
||||
desktopWindow.hermesDesktop = initialHermesDesktop
|
||||
} else {
|
||||
delete desktopWindow.hermesDesktop
|
||||
}
|
||||
})
|
||||
|
||||
describe('dispatchNativeNotification focus gating', () => {
|
||||
it('fires a completion notification for the active session when the window is hidden', () => {
|
||||
const sessionId = freshSession()
|
||||
setActiveSessionId(sessionId)
|
||||
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
|
||||
expect(notify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fires a completion notification when the window is visible but unfocused (alt-tab)', () => {
|
||||
const sessionId = freshSession()
|
||||
setActiveSessionId(sessionId)
|
||||
setWindowState({ focused: false, hidden: false })
|
||||
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
|
||||
expect(notify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('suppresses a completion notification when the window is focused', () => {
|
||||
const sessionId = freshSession()
|
||||
setActiveSessionId(sessionId)
|
||||
setWindowState({ focused: true, hidden: false })
|
||||
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses a completion notification for a non-active background session (no gateway spam)', () => {
|
||||
setActiveSessionId('on-screen')
|
||||
dispatchNativeNotification({ kind: 'turnDone', sessionId: 'busy-bot-session', title: 'done' })
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fires an attention notification for an off-screen session even when focused', () => {
|
||||
setWindowState({ focused: true, hidden: false })
|
||||
setActiveSessionId('on-screen')
|
||||
dispatchNativeNotification({ kind: 'approval', sessionId: 'background', title: 'approve' })
|
||||
expect(notify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('suppresses an attention notification for the active session when focused', () => {
|
||||
setWindowState({ focused: true, hidden: false })
|
||||
setActiveSessionId('on-screen')
|
||||
dispatchNativeNotification({ kind: 'approval', sessionId: 'on-screen', title: 'approve' })
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatchNativeNotification preferences', () => {
|
||||
it('suppresses everything when the master switch is off', () => {
|
||||
setNativeNotifyEnabled(false)
|
||||
dispatchNativeNotification({ kind: 'approval', sessionId: freshSession(), title: 'approve' })
|
||||
dispatchNativeNotification({ kind: 'turnDone', sessionId: freshSession(), title: 'done' })
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses only the disabled kind', () => {
|
||||
const sessionId = freshSession()
|
||||
setActiveSessionId(sessionId)
|
||||
setNativeNotifyKind('turnDone', false)
|
||||
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
|
||||
expect(notify).not.toHaveBeenCalled()
|
||||
|
||||
dispatchNativeNotification({ kind: 'turnError', sessionId, title: 'boom' })
|
||||
expect(notify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards kind and sessionId to the bridge', () => {
|
||||
setActiveSessionId('abc')
|
||||
dispatchNativeNotification({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: 'hi', kind: 'turnError', sessionId: 'abc', title: 'boom' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispatchNativeNotification throttle', () => {
|
||||
it('collapses duplicate kind+session within the throttle window', () => {
|
||||
const sessionId = freshSession()
|
||||
setActiveSessionId(sessionId)
|
||||
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done' })
|
||||
dispatchNativeNotification({ kind: 'turnDone', sessionId, title: 'done again' })
|
||||
expect(notify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendTestNativeNotification', () => {
|
||||
it('fires regardless of focus or active session', () => {
|
||||
setWindowState({ focused: true, hidden: false })
|
||||
setActiveSessionId('on-screen')
|
||||
sendTestNativeNotification('Hermes', 'works')
|
||||
expect(notify).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('$activeSessionId wiring', () => {
|
||||
it('reflects the setter used for gating', () => {
|
||||
setActiveSessionId('xyz')
|
||||
expect($activeSessionId.get()).toBe('xyz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('respondToApprovalAction', () => {
|
||||
const request = vi.fn().mockResolvedValue({ resolved: true })
|
||||
|
||||
beforeEach(() => {
|
||||
request.mockClear()
|
||||
$gateway.set({ request } as unknown as ReturnType<typeof $gateway.get>)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$gateway.set(null)
|
||||
})
|
||||
|
||||
it('approves via approval.respond {choice: "once"} and clears the prompt', async () => {
|
||||
setActiveSessionId('bg')
|
||||
setApprovalRequest({ command: 'rm -rf /', description: 'dangerous', sessionId: 'bg' })
|
||||
|
||||
await respondToApprovalAction('bg', 'approve')
|
||||
|
||||
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'bg' })
|
||||
expect($approvalRequest.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects via approval.respond {choice: "deny"}', async () => {
|
||||
await respondToApprovalAction('bg', 'reject')
|
||||
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'bg' })
|
||||
})
|
||||
|
||||
it('ignores unknown action ids', async () => {
|
||||
await respondToApprovalAction('bg', 'snooze')
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops without a gateway', async () => {
|
||||
$gateway.set(null)
|
||||
await respondToApprovalAction('bg', 'approve')
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
|
||||
import { $gateway } from './gateway'
|
||||
import { clearApprovalRequest } from './prompts'
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
// Native OS notifications (Electron `Notification`), separate from the in-app
|
||||
// toast feed in `notifications.ts`. Each kind toggles independently.
|
||||
export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'input' | 'turnDone' | 'turnError'
|
||||
|
||||
export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [
|
||||
'approval',
|
||||
'input',
|
||||
'turnDone',
|
||||
'turnError',
|
||||
'backgroundDone'
|
||||
]
|
||||
|
||||
// Blocking prompts — surface even while focused if they're for another session.
|
||||
const ATTENTION_KINDS = new Set<NativeNotificationKind>(['approval', 'input'])
|
||||
|
||||
export interface NativeNotificationPrefs {
|
||||
enabled: boolean
|
||||
kinds: Record<NativeNotificationKind, boolean>
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'hermes:native-notifications'
|
||||
|
||||
const DEFAULT_PREFS: NativeNotificationPrefs = {
|
||||
enabled: true,
|
||||
kinds: { approval: true, backgroundDone: true, input: true, turnDone: true, turnError: true }
|
||||
}
|
||||
|
||||
function readPrefs(): NativeNotificationPrefs {
|
||||
const raw = storedString(STORAGE_KEY)
|
||||
|
||||
if (!raw) {
|
||||
return DEFAULT_PREFS
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<NativeNotificationPrefs>
|
||||
const kinds = { ...DEFAULT_PREFS.kinds }
|
||||
|
||||
for (const kind of NATIVE_NOTIFICATION_KINDS) {
|
||||
const value = parsed.kinds?.[kind]
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
kinds[kind] = value
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_PREFS.enabled,
|
||||
kinds
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_PREFS
|
||||
}
|
||||
}
|
||||
|
||||
export const $nativeNotifyPrefs = atom<NativeNotificationPrefs>(readPrefs())
|
||||
|
||||
function writePrefs(next: NativeNotificationPrefs) {
|
||||
$nativeNotifyPrefs.set(next)
|
||||
persistString(STORAGE_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
export function setNativeNotifyEnabled(enabled: boolean) {
|
||||
writePrefs({ ...$nativeNotifyPrefs.get(), enabled })
|
||||
}
|
||||
|
||||
export function setNativeNotifyKind(kind: NativeNotificationKind, on: boolean) {
|
||||
const prev = $nativeNotifyPrefs.get()
|
||||
writePrefs({ ...prev, kinds: { ...prev.kinds, [kind]: on } })
|
||||
}
|
||||
|
||||
// De-dupe replayed events for the same kind+session. Self-evicting: entries
|
||||
// older than the window are pruned on every dispatch, so the map can't grow.
|
||||
const THROTTLE_MS = 1000
|
||||
const lastFiredAt = new Map<string, number>()
|
||||
|
||||
function throttled(key: string, now: number): boolean {
|
||||
for (const [k, at] of lastFiredAt) {
|
||||
if (now - at >= THROTTLE_MS) {
|
||||
lastFiredAt.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
if (lastFiredAt.has(key)) {
|
||||
return true
|
||||
}
|
||||
|
||||
lastFiredAt.set(key, now)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// "Backgrounded" = the user isn't on Hermes. `document.hidden` only flips when
|
||||
// minimized/occluded; an alt-tabbed window is visible-but-unfocused, so we also
|
||||
// check `document.hasFocus()`.
|
||||
function isBackgrounded(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (document.hidden) {
|
||||
return true
|
||||
}
|
||||
|
||||
return typeof document.hasFocus === 'function' && !document.hasFocus()
|
||||
}
|
||||
|
||||
function shouldFire(kind: NativeNotificationKind, sessionId?: null | string): boolean {
|
||||
// Attention kinds break through for an off-screen session even while focused.
|
||||
if (ATTENTION_KINDS.has(kind)) {
|
||||
return isBackgrounded() || (Boolean(sessionId) && sessionId !== $activeSessionId.get())
|
||||
}
|
||||
|
||||
// Completion kinds: only the active session, only while away — so a busy
|
||||
// gateway (messaging, kanban, cron) can't spam a toast per background session.
|
||||
return isBackgrounded() && Boolean(sessionId) && sessionId === $activeSessionId.get()
|
||||
}
|
||||
|
||||
export interface NativeNotificationAction {
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface NativeNotificationInput {
|
||||
kind: NativeNotificationKind
|
||||
title: string
|
||||
body?: string
|
||||
sessionId?: null | string
|
||||
silent?: boolean
|
||||
actions?: NativeNotificationAction[]
|
||||
}
|
||||
|
||||
export function dispatchNativeNotification(input: NativeNotificationInput): void {
|
||||
const prefs = $nativeNotifyPrefs.get()
|
||||
|
||||
if (!prefs.enabled || !prefs.kinds[input.kind]) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!shouldFire(input.kind, input.sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (throttled(`${input.kind}:${input.sessionId ?? ''}`, Date.now())) {
|
||||
return
|
||||
}
|
||||
|
||||
void window.hermesDesktop?.notify({
|
||||
actions: input.actions,
|
||||
body: input.body,
|
||||
kind: input.kind,
|
||||
sessionId: input.sessionId ?? undefined,
|
||||
silent: input.silent,
|
||||
title: input.title
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve a pending approval from a notification button, mirroring the in-app
|
||||
// Run/Reject bar. Keyed by session id — a background approval has no local guard.
|
||||
export async function respondToApprovalAction(sessionId: null | string, actionId: string): Promise<void> {
|
||||
const choice = actionId === 'approve' ? 'once' : actionId === 'reject' ? 'deny' : null
|
||||
|
||||
if (!choice) {
|
||||
return
|
||||
}
|
||||
|
||||
const gateway = $gateway.get()
|
||||
|
||||
if (!gateway) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await gateway.request('approval.respond', { choice, session_id: sessionId ?? undefined })
|
||||
clearApprovalRequest(sessionId)
|
||||
} catch {
|
||||
// Leave the prompt parked so the user can still resolve it in-app.
|
||||
}
|
||||
}
|
||||
|
||||
// Settings "send test" — bypasses gating. Returns whether the OS accepted it so
|
||||
// the panel can flag a silent permission failure instead of looking dead.
|
||||
export async function sendTestNativeNotification(title: string, body: string): Promise<boolean> {
|
||||
const bridge = window.hermesDesktop
|
||||
|
||||
if (!bridge?.notify) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return await bridge.notify({ body, kind: 'turnDone', title })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { atom } from 'nanostores'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
|
||||
// Keep profile.ts's side-effecting imports inert: the gateway socket layer and
|
||||
// the REST query client must not run for real in a unit test.
|
||||
const ensureGatewayForProfile = vi.fn(async () => undefined)
|
||||
const $gateway = atom<unknown>({ id: 'live-socket' })
|
||||
|
||||
vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForProfile }))
|
||||
vi.mock('@/hermes', () => ({
|
||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||
setApiRequestProfile: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
|
||||
|
||||
const { $activeGatewayProfile, ensureGatewayProfile } = await import('./profile')
|
||||
const { $connection } = await import('./session')
|
||||
|
||||
const remoteConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
|
||||
({ baseUrl: 'https://hermes-roy.tail.ts.net', mode: 'remote', profile: 'vps-remote', ...over }) as HermesConnection
|
||||
|
||||
const localConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
|
||||
({ baseUrl: '', mode: 'local', profile: 'default', ...over }) as HermesConnection
|
||||
|
||||
const getConnection = vi.fn<(profile?: string | null) => Promise<HermesConnection>>()
|
||||
|
||||
beforeEach(() => {
|
||||
getConnection.mockReset()
|
||||
ensureGatewayForProfile.mockClear()
|
||||
$gateway.set({ id: 'live-socket' })
|
||||
$activeGatewayProfile.set('default')
|
||||
$connection.set(localConn())
|
||||
vi.stubGlobal('window', { hermesDesktop: { getConnection } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
$connection.set(null)
|
||||
})
|
||||
|
||||
describe('ensureGatewayProfile → $connection sync (#46651)', () => {
|
||||
it('refreshes $connection to the remote descriptor when activating a remote pool profile', async () => {
|
||||
// Regression: the primary window backend is local, so $connection.mode is
|
||||
// "local". Activating the remote profile must flip it to "remote" — without
|
||||
// this, image attach uses path-based image.attach against the remote
|
||||
// gateway ("image not found: C:\\…") instead of image.attach_bytes.
|
||||
getConnection.mockResolvedValue(remoteConn())
|
||||
|
||||
await ensureGatewayProfile('vps-remote')
|
||||
|
||||
expect(ensureGatewayForProfile).toHaveBeenCalledWith('vps-remote')
|
||||
expect(getConnection).toHaveBeenCalledWith('vps-remote')
|
||||
expect($connection.get()?.mode).toBe('remote')
|
||||
expect($connection.get()?.profile).toBe('vps-remote')
|
||||
})
|
||||
|
||||
it('resyncs $connection back to local when returning to the default profile', async () => {
|
||||
$activeGatewayProfile.set('vps-remote')
|
||||
$connection.set(remoteConn())
|
||||
getConnection.mockResolvedValue(localConn())
|
||||
|
||||
await ensureGatewayProfile('default')
|
||||
|
||||
expect(getConnection).toHaveBeenCalledWith('default')
|
||||
expect($connection.get()?.mode).toBe('local')
|
||||
})
|
||||
|
||||
it('leaves the prior connection intact when the descriptor fetch fails', async () => {
|
||||
getConnection.mockRejectedValue(new Error('backend unreachable'))
|
||||
|
||||
await ensureGatewayProfile('vps-remote')
|
||||
|
||||
// Best-effort: boot/reconnect resyncs later; we must not null it out here.
|
||||
expect($connection.get()?.mode).toBe('local')
|
||||
})
|
||||
|
||||
it('does not churn $connection when the target is already the active profile', async () => {
|
||||
$activeGatewayProfile.set('vps-remote')
|
||||
$connection.set(remoteConn())
|
||||
|
||||
await ensureGatewayProfile('vps-remote')
|
||||
|
||||
expect(getConnection).not.toHaveBeenCalled()
|
||||
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
|
||||
expect($connection.get()?.mode).toBe('remote')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
storedStringRecord
|
||||
} from '@/lib/storage'
|
||||
import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
|
||||
import { setConnection } from '@/store/session'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
// Canonical key for a profile: trimmed, empty → "default". Used everywhere we
|
||||
@@ -178,6 +179,32 @@ export const $gatewaySwapTarget = atom<string | null>(null)
|
||||
|
||||
let gatewaySwitch: Promise<void> | null = null
|
||||
|
||||
// Keep the renderer's $connection (mode / baseUrl / profile) in lockstep with
|
||||
// the profile the live gateway is now on. $connection seeds from the PRIMARY
|
||||
// (window) backend at boot and otherwise only refreshes on a sleep/wake
|
||||
// reconnect — so activating a *background* profile left $connection describing
|
||||
// the primary, with the wrong `mode` for everything that branches on
|
||||
// local-vs-remote. Headline symptom: with a local primary and a remote pool
|
||||
// profile active, image attachments went out via the path-based `image.attach`
|
||||
// instead of `image.attach_bytes`, handing the remote gateway a client-only
|
||||
// path it can't resolve ("image not found: C:\…"), while the /api/fs/* file
|
||||
// browser and /api/media fetches targeted the wrong machine (#46651).
|
||||
// Best-effort: a failed descriptor fetch leaves the prior connection intact for
|
||||
// boot/reconnect to resync.
|
||||
async function syncConnectionToActiveProfile(profile: string): Promise<void> {
|
||||
const getConnection = window.hermesDesktop?.getConnection
|
||||
|
||||
if (!getConnection) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setConnection(await getConnection(profile))
|
||||
} catch {
|
||||
// Leave the prior connection in place; boot/reconnect resyncs it later.
|
||||
}
|
||||
}
|
||||
|
||||
// Make `profile`'s backend the active gateway, lazily opening its socket if it
|
||||
// isn't live yet. Unlike the old single-socket swap, background profiles keep
|
||||
// their sockets — so their sessions keep streaming concurrently. A null/empty
|
||||
@@ -218,6 +245,9 @@ export async function ensureGatewayProfile(profile: string | null | undefined):
|
||||
// the active gateway at it — without closing the profile you came from.
|
||||
await ensureGatewayForProfile(target)
|
||||
$activeGatewayProfile.set(target)
|
||||
// The active backend just changed; resync $connection so remote-aware
|
||||
// paths (image.attach_bytes vs image.attach, /api/fs/*, /api/media) follow.
|
||||
await syncConnectionToActiveProfile(target)
|
||||
})()
|
||||
|
||||
try {
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface OAuthProviderStatus {
|
||||
|
||||
export interface OAuthProvider {
|
||||
cli_command: string
|
||||
disconnect_hint?: null | string
|
||||
disconnectable?: boolean
|
||||
docs_url: string
|
||||
flow: 'device_code' | 'external' | 'loopback' | 'pkce'
|
||||
id: string
|
||||
@@ -470,7 +472,7 @@ export interface CronJobUpdates {
|
||||
|
||||
export interface ProfileCreatePayload {
|
||||
clone_all?: boolean
|
||||
clone_from?: string
|
||||
clone_from?: null | string
|
||||
clone_from_default?: boolean
|
||||
name: string
|
||||
no_skills?: boolean
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@@ -182,6 +182,11 @@ terminal:
|
||||
backend: "local"
|
||||
cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
|
||||
timeout: 180
|
||||
# HOME policy for tool subprocesses:
|
||||
# auto - default: host uses your real HOME; containers use HERMES_HOME/home
|
||||
# real - force your real OS-user HOME
|
||||
# profile - force HERMES_HOME/home for strict per-profile CLI config isolation
|
||||
home_mode: "auto"
|
||||
docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace.
|
||||
lifetime_seconds: 300
|
||||
# sudo_password: "hunter2" # Optional: pipe a sudo password via sudo -S. SECURITY WARNING: plaintext.
|
||||
@@ -719,6 +724,7 @@ platform_toolsets:
|
||||
# # allowed_chats: ["-1001234567890"]
|
||||
# extra:
|
||||
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
|
||||
# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
|
||||
#
|
||||
# Discord-specific settings (config.yaml top-level, not under platforms:):
|
||||
#
|
||||
|
||||
@@ -394,7 +394,7 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"terminal": {
|
||||
"env_type": "local",
|
||||
"cwd": ".", # "." is resolved to os.getcwd() at runtime
|
||||
"timeout": 60,
|
||||
"home_mode": "auto",
|
||||
"lifetime_seconds": 300,
|
||||
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||||
"docker_forward_env": [],
|
||||
@@ -589,6 +589,7 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"env_type": "TERMINAL_ENV",
|
||||
"cwd": "TERMINAL_CWD",
|
||||
"timeout": "TERMINAL_TIMEOUT",
|
||||
"home_mode": "TERMINAL_HOME_MODE",
|
||||
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
|
||||
"docker_image": "TERMINAL_DOCKER_IMAGE",
|
||||
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
|
||||
@@ -5782,14 +5783,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
if not self._session_db:
|
||||
return []
|
||||
try:
|
||||
sessions = self._session_db.list_sessions_rich(
|
||||
from hermes_cli.session_listing import query_session_listing
|
||||
|
||||
return query_session_listing(
|
||||
self._session_db,
|
||||
source="cli",
|
||||
exclude_sources=["tool"],
|
||||
current_session_id=self.session_id,
|
||||
include_all_sources=False,
|
||||
include_unnamed=True,
|
||||
limit=limit,
|
||||
exclude_sources=["tool"],
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
return [s for s in sessions if s.get("id") != self.session_id]
|
||||
|
||||
def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> bool:
|
||||
"""Render recent sessions inline from the active chat TUI.
|
||||
|
||||
+153
-61
@@ -5,6 +5,7 @@ Jobs are stored in ~/.hermes/cron/jobs.json
|
||||
Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
@@ -14,6 +15,19 @@ import threading
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
|
||||
# Cross-process advisory file locking for jobs.json critical sections.
|
||||
# fcntl is Unix-only; on Windows fall back to msvcrt. Either may be absent,
|
||||
# in which case _jobs_lock() degrades to in-process locking only (the old
|
||||
# behaviour) rather than failing.
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-Unix
|
||||
fcntl = None
|
||||
try:
|
||||
import msvcrt
|
||||
except ImportError: # pragma: no cover - non-Windows
|
||||
msvcrt = None
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
@@ -41,10 +55,79 @@ JOBS_FILE = CRON_DIR / "jobs.json"
|
||||
# In-process lock protecting load_jobs→modify→save_jobs cycles.
|
||||
# Required when tick() runs jobs in parallel threads — without this,
|
||||
# concurrent mark_job_run / advance_next_run calls can clobber each other.
|
||||
_jobs_file_lock = threading.Lock()
|
||||
_jobs_file_lock = threading.RLock()
|
||||
_jobs_lock_state = threading.local()
|
||||
OUTPUT_DIR = CRON_DIR / "output"
|
||||
ONESHOT_GRACE_SECONDS = 120
|
||||
|
||||
|
||||
def _jobs_lock_file() -> Path:
|
||||
"""Return the advisory lock path for the current cron directory."""
|
||||
return CRON_DIR / ".jobs.lock"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _jobs_lock():
|
||||
"""Serialize a load_jobs→modify→save_jobs critical section.
|
||||
|
||||
Combines the in-process threading lock (cheap mutual exclusion between
|
||||
the gateway's parallel tick threads) with a cross-process advisory file
|
||||
lock on ``<cron dir>/.jobs.lock`` (mutual exclusion between the gateway process
|
||||
and standalone ``hermes`` CLI invocations, which previously shared no lock
|
||||
at all — a `cron pause` could be silently clobbered by a concurrent
|
||||
gateway write, leaving a "paused" job still firing).
|
||||
|
||||
The flock is blocking, but every critical section that uses it is short
|
||||
(field updates only — no agent execution), so contention resolves in
|
||||
milliseconds. If neither fcntl nor msvcrt is available the manager still
|
||||
provides in-process locking, matching the historical behaviour.
|
||||
|
||||
Nested calls in the same thread reuse the held lock so legacy callers that
|
||||
invoke save_jobs() inside a broader mutation section don't deadlock or try
|
||||
to reacquire the advisory file lock.
|
||||
"""
|
||||
depth = getattr(_jobs_lock_state, "depth", 0)
|
||||
if depth:
|
||||
_jobs_lock_state.depth = depth + 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_jobs_lock_state.depth -= 1
|
||||
return
|
||||
|
||||
with _jobs_file_lock:
|
||||
_jobs_lock_state.depth = 1
|
||||
lock_fd = None
|
||||
try:
|
||||
try:
|
||||
ensure_dirs()
|
||||
lock_fd = open(_jobs_lock_file(), "a+", encoding="utf-8")
|
||||
lock_fd.seek(0)
|
||||
if fcntl is not None:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
elif msvcrt is not None:
|
||||
getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_LOCK"), 1)
|
||||
except (OSError, IOError) as e:
|
||||
# Never let a locking failure take down cron writes — fall back to
|
||||
# in-process-only protection (still held via _jobs_file_lock).
|
||||
logger.warning("jobs.json cross-process lock unavailable (%s); "
|
||||
"proceeding with in-process lock only", e)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if lock_fd is not None:
|
||||
try:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
elif msvcrt is not None:
|
||||
getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_UNLCK"), 1)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
finally:
|
||||
lock_fd.close()
|
||||
finally:
|
||||
_jobs_lock_state.depth = 0
|
||||
|
||||
# Fields on a cron job that must never change after creation. ``id`` is used
|
||||
# as a filesystem path component under ``OUTPUT_DIR``; allowing it to be
|
||||
# updated lets an unsafe value (``../escape``, absolute path, nested) leak
|
||||
@@ -468,8 +551,8 @@ def load_jobs() -> List[Dict[str, Any]]:
|
||||
)
|
||||
|
||||
|
||||
def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage."""
|
||||
def _save_jobs_unlocked(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage. Caller must hold _jobs_lock()."""
|
||||
ensure_dirs()
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_')
|
||||
try:
|
||||
@@ -487,6 +570,12 @@ def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
raise
|
||||
|
||||
|
||||
def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage."""
|
||||
with _jobs_lock():
|
||||
_save_jobs_unlocked(jobs)
|
||||
|
||||
|
||||
def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:
|
||||
"""Normalize and validate a cron job workdir.
|
||||
|
||||
@@ -670,9 +759,10 @@ def create_job(
|
||||
"workdir": normalized_workdir,
|
||||
}
|
||||
|
||||
jobs = load_jobs()
|
||||
jobs.append(job)
|
||||
save_jobs(jobs)
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
jobs.append(job)
|
||||
save_jobs(jobs)
|
||||
|
||||
return job
|
||||
|
||||
@@ -743,49 +833,50 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
|
||||
f"Cron job field(s) cannot be updated: {', '.join(sorted(bad_fields))}"
|
||||
)
|
||||
|
||||
jobs = load_jobs()
|
||||
for i, job in enumerate(jobs):
|
||||
if job["id"] != job_id:
|
||||
continue
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
for i, job in enumerate(jobs):
|
||||
if job["id"] != job_id:
|
||||
continue
|
||||
|
||||
# Validate / normalize workdir if present in updates. Empty string or
|
||||
# None both mean "clear the field" (restore old behaviour).
|
||||
if "workdir" in updates:
|
||||
_wd = updates["workdir"]
|
||||
if _wd in {None, "", False}:
|
||||
updates["workdir"] = None
|
||||
else:
|
||||
updates["workdir"] = _normalize_workdir(_wd)
|
||||
# Validate / normalize workdir if present in updates. Empty string
|
||||
# or None both mean "clear the field" (restore old behaviour).
|
||||
if "workdir" in updates:
|
||||
_wd = updates["workdir"]
|
||||
if _wd in {None, "", False}:
|
||||
updates["workdir"] = None
|
||||
else:
|
||||
updates["workdir"] = _normalize_workdir(_wd)
|
||||
|
||||
updated = _apply_skill_fields({**job, **updates})
|
||||
schedule_changed = "schedule" in updates
|
||||
updated = _apply_skill_fields({**job, **updates})
|
||||
schedule_changed = "schedule" in updates
|
||||
|
||||
if "skills" in updates or "skill" in updates:
|
||||
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
|
||||
updated["skills"] = normalized_skills
|
||||
updated["skill"] = normalized_skills[0] if normalized_skills else None
|
||||
if "skills" in updates or "skill" in updates:
|
||||
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
|
||||
updated["skills"] = normalized_skills
|
||||
updated["skill"] = normalized_skills[0] if normalized_skills else None
|
||||
|
||||
if schedule_changed:
|
||||
updated_schedule = updated["schedule"]
|
||||
# The API may pass schedule as a raw string (e.g. "every 10m")
|
||||
# instead of a pre-parsed dict. Normalize it the same way
|
||||
# create_job() does so downstream code can call .get() safely.
|
||||
if isinstance(updated_schedule, str):
|
||||
updated_schedule = parse_schedule(updated_schedule)
|
||||
updated["schedule"] = updated_schedule
|
||||
updated["schedule_display"] = updates.get(
|
||||
"schedule_display",
|
||||
updated_schedule.get("display", updated.get("schedule_display")),
|
||||
)
|
||||
if updated.get("state") != "paused":
|
||||
updated["next_run_at"] = compute_next_run(updated_schedule)
|
||||
if schedule_changed:
|
||||
updated_schedule = updated["schedule"]
|
||||
# The API may pass schedule as a raw string (e.g. "every 10m")
|
||||
# instead of a pre-parsed dict. Normalize it the same way
|
||||
# create_job() does so downstream code can call .get() safely.
|
||||
if isinstance(updated_schedule, str):
|
||||
updated_schedule = parse_schedule(updated_schedule)
|
||||
updated["schedule"] = updated_schedule
|
||||
updated["schedule_display"] = updates.get(
|
||||
"schedule_display",
|
||||
updated_schedule.get("display", updated.get("schedule_display")),
|
||||
)
|
||||
if updated.get("state") != "paused":
|
||||
updated["next_run_at"] = compute_next_run(updated_schedule)
|
||||
|
||||
if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
|
||||
updated["next_run_at"] = compute_next_run(updated["schedule"])
|
||||
if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
|
||||
updated["next_run_at"] = compute_next_run(updated["schedule"])
|
||||
|
||||
jobs[i] = updated
|
||||
save_jobs(jobs)
|
||||
return _normalize_job_record(jobs[i])
|
||||
jobs[i] = updated
|
||||
save_jobs(jobs)
|
||||
return _normalize_job_record(jobs[i])
|
||||
return None
|
||||
|
||||
|
||||
@@ -847,19 +938,20 @@ def remove_job(job_id: str) -> bool:
|
||||
if not job:
|
||||
return False
|
||||
canonical_id = job["id"]
|
||||
jobs = load_jobs()
|
||||
original_len = len(jobs)
|
||||
jobs = [j for j in jobs if j["id"] != canonical_id]
|
||||
if len(jobs) < original_len:
|
||||
# Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g.
|
||||
# left over from before the create-time guard) fails closed without
|
||||
# half-applying the removal.
|
||||
job_output_dir = _job_output_dir(canonical_id)
|
||||
save_jobs(jobs)
|
||||
# Clean up output directory to prevent orphaned dirs accumulating
|
||||
if job_output_dir.exists():
|
||||
shutil.rmtree(job_output_dir)
|
||||
return True
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
original_len = len(jobs)
|
||||
jobs = [j for j in jobs if j["id"] != canonical_id]
|
||||
if len(jobs) < original_len:
|
||||
# Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g.
|
||||
# left over from before the create-time guard) fails closed without
|
||||
# half-applying the removal.
|
||||
job_output_dir = _job_output_dir(canonical_id)
|
||||
save_jobs(jobs)
|
||||
# Clean up output directory to prevent orphaned dirs accumulating
|
||||
if job_output_dir.exists():
|
||||
shutil.rmtree(job_output_dir)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -874,7 +966,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,
|
||||
``delivery_error`` is tracked separately from the agent error — a job
|
||||
can succeed (agent produced output) but fail delivery (platform down).
|
||||
"""
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
for i, job in enumerate(jobs):
|
||||
if job["id"] == job_id:
|
||||
@@ -948,7 +1040,7 @@ def advance_next_run(job_id: str) -> bool:
|
||||
|
||||
Returns True if next_run_at was advanced, False otherwise.
|
||||
"""
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
for job in jobs:
|
||||
if job["id"] == job_id:
|
||||
@@ -973,12 +1065,12 @@ def get_due_jobs() -> List[Dict[str, Any]]:
|
||||
the job is fast-forwarded to the next future run instead of firing
|
||||
immediately. This prevents a burst of missed jobs on gateway restart.
|
||||
"""
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
return _get_due_jobs_locked()
|
||||
|
||||
|
||||
def _get_due_jobs_locked() -> List[Dict[str, Any]]:
|
||||
"""Inner implementation of get_due_jobs(); must be called with _jobs_file_lock held."""
|
||||
"""Inner implementation of get_due_jobs(); must be called with _jobs_lock held."""
|
||||
now = _hermes_now()
|
||||
raw_jobs = load_jobs()
|
||||
jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)]
|
||||
@@ -1158,7 +1250,7 @@ def rewrite_skill_refs(
|
||||
if not consolidated and not pruned_set:
|
||||
return {"rewrites": [], "jobs_updated": 0, "jobs_scanned": 0}
|
||||
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
rewrites: List[Dict[str, Any]] = []
|
||||
changed = False
|
||||
|
||||
@@ -316,6 +316,7 @@ as_hermes mkdir -p \
|
||||
"$HERMES_HOME/cron" \
|
||||
"$HERMES_HOME/sessions" \
|
||||
"$HERMES_HOME/logs" \
|
||||
"$HERMES_HOME/logs/gateways" \
|
||||
"$HERMES_HOME/hooks" \
|
||||
"$HERMES_HOME/memories" \
|
||||
"$HERMES_HOME/skills" \
|
||||
|
||||
@@ -85,6 +85,8 @@ Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite:
|
||||
```python
|
||||
class ProfileCreate(BaseModel):
|
||||
name: str
|
||||
clone_from: Optional[str] = None
|
||||
# Backward compatibility for older dashboard/desktop clients.
|
||||
clone_from_default: bool = False
|
||||
clone_all: bool = False
|
||||
no_skills: bool = False
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# RCA: SSL CA cert bundle corruption after `hermes update`
|
||||
|
||||
**Status:** resolved by `fix(ssl): surface broken CA bundles before provider calls`
|
||||
**Severity:** P2 — degrades the agent into opaque provider/client failures until the user repairs deps or CA configuration.
|
||||
|
||||
## Summary
|
||||
|
||||
A partial `hermes update`, interrupted venv repair, or stale CA-bundle environment variable can leave Python TLS configuration pointing at a missing, empty, or unloadable CA bundle. The first outbound HTTPS client creation or request can then fail with a raw `FileNotFoundError: [Errno 2] No such file or directory` or a low-level SSL error that does not name the broken CA path.
|
||||
|
||||
## Root cause
|
||||
|
||||
Hermes uses OpenAI/httpx and requests-based clients for provider calls, model metadata, gateway delivery, and web tools. Those clients inherit CA bundle settings from:
|
||||
|
||||
- `HERMES_CA_BUNDLE`
|
||||
- `SSL_CERT_FILE`
|
||||
- `REQUESTS_CA_BUNDLE`
|
||||
- `CURL_CA_BUNDLE`
|
||||
- the bundled `certifi` package's `cacert.pem`
|
||||
|
||||
When the venv is partially refreshed, or when one of those env vars points at a file that no longer exists, provider client construction can fail before Hermes has enough context to produce a useful message.
|
||||
|
||||
## Fix
|
||||
|
||||
`agent/ssl_guard.py` validates CA bundle configuration before the OpenAI-compatible provider client is created in `agent/agent_init.py`. It:
|
||||
|
||||
1. Checks explicit CA bundle env vars and reports the exact broken variable/path,
|
||||
2. Verifies `certifi` is importable,
|
||||
3. Verifies `certifi.where()` points at an existing file of plausible size,
|
||||
4. Builds an `ssl.SSLContext` from each checked bundle,
|
||||
5. Raises a typed `SSLConfigurationError` with a repair hint before httpx/OpenAI can raise a raw low-level error.
|
||||
|
||||
`hermes_cli doctor` exposes the same check under `SSL / CA Certificates`, so users can diagnose the problem without starting a model session.
|
||||
|
||||
## Recovery
|
||||
|
||||
When the guard fires during agent init, the user sees a message like:
|
||||
|
||||
```text
|
||||
Failed to initialize OpenAI client: SSL_CERT_FILE points to a missing CA bundle: C:\path\to\missing\cacert.pem
|
||||
Repair: python -m pip install --force-reinstall certifi openai httpx
|
||||
If you configured a custom corporate CA bundle, fix or unset the broken CA bundle environment variable.
|
||||
```
|
||||
|
||||
For a normal corrupted Hermes venv, reinstall the affected client dependencies:
|
||||
|
||||
```bash
|
||||
python -m pip install --force-reinstall certifi openai httpx
|
||||
```
|
||||
|
||||
For a custom/corporate CA setup, fix the env var so it points at a real PEM bundle, or unset it if Hermes should use the bundled `certifi` store.
|
||||
|
||||
## Environment escape hatch
|
||||
|
||||
Set `HERMES_SKIP_SSL_GUARD=1` to bypass the preflight check. This is intended only for sandboxed or managed-trust environments where the Python CA path looks unusual but downstream clients are known to work.
|
||||
+126
-27
@@ -37,10 +37,12 @@ class GatewayAuthorizationMixin:
|
||||
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
|
||||
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
|
||||
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
|
||||
message is dispatched to the gateway, so a message that reaches
|
||||
``_is_user_authorized`` has already been authorized by the adapter.
|
||||
Defaults to ``False`` when the adapter is unknown or doesn't expose
|
||||
the flag.
|
||||
message is dispatched to the gateway. The flag alone is NOT "already
|
||||
authorized": these adapters default to ``open``, which forwards every
|
||||
sender, so ``_is_user_authorized`` only trusts the adapter when its
|
||||
effective policy for the chat type is an actual ``allowlist`` restriction
|
||||
(see that method). Defaults to ``False`` when the adapter is unknown or
|
||||
doesn't expose the flag.
|
||||
"""
|
||||
if not platform:
|
||||
return False
|
||||
@@ -65,10 +67,11 @@ class GatewayAuthorizationMixin:
|
||||
env var is not always bridged back into ``config.extra``) — and falls
|
||||
back to ``config.extra`` for bare runners built without a live adapter.
|
||||
|
||||
Used by ``_is_user_authorized`` to carve ``dm_policy: pairing`` out of
|
||||
the adapter-trust shortcut: in pairing mode the adapter forwards the DM
|
||||
so the gateway can run its pairing handshake, so "reached the gateway"
|
||||
must not be read as "authorized".
|
||||
Used by ``_is_user_authorized`` to decide whether an own-policy adapter
|
||||
actually restricted DM senders to a configured allowlist (trustworthy)
|
||||
or merely forwarded everyone under ``dm_policy: open`` / for a pairing
|
||||
handshake (not authorization). "Reached the gateway" only carries an
|
||||
authorization signal in the ``allowlist`` case.
|
||||
"""
|
||||
if not platform:
|
||||
return ""
|
||||
@@ -87,6 +90,89 @@ class GatewayAuthorizationMixin:
|
||||
policy = extra.get("dm_policy")
|
||||
return str(policy or "").strip().lower()
|
||||
|
||||
def _adapter_group_policy(self, platform: Optional[Platform]) -> str:
|
||||
"""Best-effort read of an own-policy adapter's effective group policy.
|
||||
|
||||
Mirror of ``_adapter_dm_policy`` for group / forum / channel traffic:
|
||||
returns the lowercased ``group_policy`` (``"open"`` / ``"allowlist"`` /
|
||||
``"disabled"``) for *platform*, or ``""`` when unknown. Prefers the live
|
||||
adapter's resolved ``_group_policy`` and falls back to ``config.extra``
|
||||
for bare runners built without a live adapter.
|
||||
|
||||
Used by ``_is_user_authorized`` to decide whether an own-policy adapter
|
||||
restricted group senders to a configured allowlist (trustworthy) or
|
||||
forwarded the whole channel under ``group_policy: open`` (not
|
||||
authorization).
|
||||
"""
|
||||
if not platform:
|
||||
return ""
|
||||
adapters = getattr(self, "adapters", None) or {}
|
||||
adapter = adapters.get(platform)
|
||||
policy = getattr(adapter, "_group_policy", None) if adapter is not None else None
|
||||
if policy is None:
|
||||
config = getattr(self, "config", None)
|
||||
platform_cfg = (
|
||||
config.platforms.get(platform)
|
||||
if config is not None and hasattr(config, "platforms")
|
||||
else None
|
||||
)
|
||||
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
|
||||
if isinstance(extra, dict):
|
||||
policy = extra.get("group_policy")
|
||||
return str(policy or "").strip().lower()
|
||||
|
||||
def _adapter_group_has_sender_allowlist(
|
||||
self,
|
||||
platform: Optional[Platform],
|
||||
chat_id: Optional[str],
|
||||
) -> bool:
|
||||
"""Whether a per-group sender allowlist gated this group message.
|
||||
|
||||
WeCom supports ``groups.<group_id>.allow_from`` on top of the top-level
|
||||
``group_policy``. A group may be open at the chat level while still
|
||||
restricting which senders inside that group can invoke Hermes. If such a
|
||||
message reached the gateway, the adapter already checked that sender
|
||||
allowlist, so it is a trustworthy intake decision rather than the
|
||||
fail-open ``group_policy: open`` case.
|
||||
"""
|
||||
if not platform or not chat_id:
|
||||
return False
|
||||
adapters = getattr(self, "adapters", None) or {}
|
||||
adapter = adapters.get(platform)
|
||||
groups = getattr(adapter, "_groups", None) if adapter is not None else None
|
||||
if groups is None:
|
||||
config = getattr(self, "config", None)
|
||||
platform_cfg = (
|
||||
config.platforms.get(platform)
|
||||
if config is not None and hasattr(config, "platforms")
|
||||
else None
|
||||
)
|
||||
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
|
||||
if isinstance(extra, dict):
|
||||
groups = extra.get("groups")
|
||||
if not isinstance(groups, dict):
|
||||
return False
|
||||
|
||||
chat_id_str = str(chat_id)
|
||||
group_cfg = groups.get(chat_id_str)
|
||||
if not isinstance(group_cfg, dict):
|
||||
lowered = chat_id_str.lower()
|
||||
for key, value in groups.items():
|
||||
if isinstance(key, str) and key.lower() == lowered and isinstance(value, dict):
|
||||
group_cfg = value
|
||||
break
|
||||
if not isinstance(group_cfg, dict):
|
||||
group_cfg = groups.get("*")
|
||||
if not isinstance(group_cfg, dict):
|
||||
return False
|
||||
|
||||
sender_allow = group_cfg.get("allow_from") or group_cfg.get("allowFrom")
|
||||
if isinstance(sender_allow, str):
|
||||
return bool(sender_allow.strip())
|
||||
if isinstance(sender_allow, (list, tuple, set)):
|
||||
return any(str(item).strip() for item in sender_allow)
|
||||
return False
|
||||
|
||||
def _is_user_authorized(self, source: SessionSource) -> bool:
|
||||
"""
|
||||
Check if a user is authorized to use the bot.
|
||||
@@ -237,27 +323,40 @@ class GatewayAuthorizationMixin:
|
||||
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
|
||||
|
||||
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
|
||||
# No env allowlists configured. Adapters that own their own
|
||||
# No env allowlist configured. Adapters that own their own
|
||||
# config-driven access policy (dm_policy / group_policy /
|
||||
# allow_from / group_allow_from) already gated this message at
|
||||
# intake — it would not have reached the gateway otherwise — so
|
||||
# honor that decision instead of falling through to the
|
||||
# env-only default-deny below, which would silently break
|
||||
# `dm_policy: open` and config-only allowlists. (#34515)
|
||||
# allow_from / group_allow_from) gate access at intake, so for those
|
||||
# platforms we can honor the adapter's decision instead of the
|
||||
# env-only default-deny below -- but ONLY when that decision was an
|
||||
# actual allowlist restriction.
|
||||
#
|
||||
# The adapters default dm_policy / group_policy to "open", which
|
||||
# forwards EVERY sender. Reading "reached the gateway" as
|
||||
# authorization in that case would admit the whole external network
|
||||
# with no operator-configured allowlist -- the fail-open SECURITY.md
|
||||
# §2.6 forbids ("an allowlist is required for every enabled
|
||||
# network-exposed adapter ... code paths that fail open when no
|
||||
# allowlist is configured are code bugs"). "disabled" never
|
||||
# forwards, and "pairing" forwards unpaired DMs only so the gateway
|
||||
# can run its pairing handshake (the pairing-store check above
|
||||
# already denied this sender). So trust the adapter only when its
|
||||
# effective policy for THIS chat type is "allowlist"; for "open" /
|
||||
# "pairing" / anything else, fall through to default-deny, where
|
||||
# GATEWAY_ALLOW_ALL_USERS, the per-platform {PLATFORM}_ALLOW_ALL_USERS
|
||||
# flag (checked above), and the pairing flow remain the explicit
|
||||
# opt-ins to broader access. (#34515 follow-up: trusting "open" was a
|
||||
# fail-open.)
|
||||
if self._adapter_enforces_own_access_policy(source.platform):
|
||||
# Exception: `dm_policy: pairing` does NOT authorize at intake.
|
||||
# The adapter forwards the DM precisely so the gateway can run
|
||||
# its pairing handshake (issue a code, consult the pairing
|
||||
# store). The pairing-store approval check above already ran and
|
||||
# returned False for this sender, so blanket-trusting the
|
||||
# adapter here would silently turn pairing mode into open
|
||||
# access. Fall through to default-deny so the unpaired sender is
|
||||
# offered a pairing code instead. (Pairing is DM-only; group
|
||||
# traffic keeps the adapter-trust path.)
|
||||
if not (
|
||||
source.chat_type == "dm"
|
||||
and self._adapter_dm_policy(source.platform) == "pairing"
|
||||
):
|
||||
if source.chat_type in {"group", "forum", "channel"}:
|
||||
effective_policy = self._adapter_group_policy(source.platform)
|
||||
if self._adapter_group_has_sender_allowlist(
|
||||
source.platform,
|
||||
source.chat_id,
|
||||
):
|
||||
return True
|
||||
else:
|
||||
effective_policy = self._adapter_dm_policy(source.platform)
|
||||
if effective_policy == "allowlist":
|
||||
return True
|
||||
# No allowlists configured -- check global allow-all flag
|
||||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
|
||||
@@ -17,6 +17,57 @@ from utils import atomic_json_write
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DIRECTORY_PATH = get_hermes_home() / "channel_directory.json"
|
||||
# User-maintained friendly-name overlay. The directory is fully regenerated
|
||||
# from live adapters + session data on a timer, so hand-edits to
|
||||
# channel_directory.json don't survive. Aliases declared here are re-applied
|
||||
# on every build AND every load, giving durable human-friendly names (and
|
||||
# letting you pre-name a chat before it has produced any traffic).
|
||||
# Format: {"<platform>": {"<chat_id>": "<friendly name>", ...}, ...}
|
||||
CHANNEL_ALIASES_PATH = get_hermes_home() / "channel_aliases.json"
|
||||
|
||||
|
||||
def _load_channel_aliases() -> Dict[str, Dict[str, str]]:
|
||||
if not CHANNEL_ALIASES_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(CHANNEL_ALIASES_PATH, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _apply_channel_aliases(platforms: Dict[str, Any]) -> None:
|
||||
"""Overlay friendly names onto directory entries by chat_id.
|
||||
|
||||
Renames matching entries in place; injects a placeholder entry for an
|
||||
aliased id that hasn't been discovered yet (so a freshly-created group is
|
||||
addressable by name before its first message). Mutates *platforms*.
|
||||
"""
|
||||
aliases = _load_channel_aliases()
|
||||
for plat_name, id_map in aliases.items():
|
||||
if not isinstance(id_map, dict):
|
||||
continue
|
||||
entries = platforms.setdefault(plat_name, [])
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for chat_id, friendly in id_map.items():
|
||||
if not isinstance(friendly, str) or not friendly.strip():
|
||||
continue
|
||||
chat_id = str(chat_id)
|
||||
friendly = friendly.strip()
|
||||
matched = False
|
||||
for e in entries:
|
||||
if isinstance(e, dict) and e.get("id") == chat_id:
|
||||
e["name"] = friendly
|
||||
matched = True
|
||||
if not matched:
|
||||
entries.append({
|
||||
"id": chat_id,
|
||||
"name": friendly,
|
||||
"type": "group" if str(chat_id).endswith("@g.us") else "dm",
|
||||
"thread_id": None,
|
||||
})
|
||||
|
||||
|
||||
def _normalize_channel_query(value: str) -> str:
|
||||
@@ -96,6 +147,9 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Overlay user-maintained friendly names before persisting.
|
||||
_apply_channel_aliases(platforms)
|
||||
|
||||
directory = {
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"platforms": platforms,
|
||||
@@ -247,12 +301,20 @@ def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
|
||||
def load_directory() -> Dict[str, Any]:
|
||||
"""Load the cached channel directory from disk."""
|
||||
if not DIRECTORY_PATH.exists():
|
||||
return {"updated_at": None, "platforms": {}}
|
||||
base = {"updated_at": None, "platforms": {}}
|
||||
_apply_channel_aliases(base["platforms"])
|
||||
return base
|
||||
try:
|
||||
with open(DIRECTORY_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
# Re-apply aliases on read so friendly names take effect immediately,
|
||||
# even between timed rebuilds and for brand-new alias entries.
|
||||
_apply_channel_aliases(data.setdefault("platforms", {}))
|
||||
return data
|
||||
except Exception:
|
||||
return {"updated_at": None, "platforms": {}}
|
||||
base = {"updated_at": None, "platforms": {}}
|
||||
_apply_channel_aliases(base["platforms"])
|
||||
return base
|
||||
|
||||
|
||||
def lookup_channel_type(platform_name: str, chat_id: str) -> Optional[str]:
|
||||
|
||||
+4
-4
@@ -417,9 +417,9 @@ class StreamingConfig:
|
||||
# if the original preview has been visible for at least this many
|
||||
# seconds, so the platform's visible timestamp reflects completion
|
||||
# time instead of the preview creation time. Currently applied to
|
||||
# Telegram only (other platforms ignore the setting). Default 60s
|
||||
# matches the OpenClaw rollout. Set to 0 to disable.
|
||||
fresh_final_after_seconds: float = 60.0
|
||||
# Telegram only (other platforms ignore the setting). Default 0 disables
|
||||
# the fresh-message replacement path; set >0 to opt in.
|
||||
fresh_final_after_seconds: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -446,7 +446,7 @@ class StreamingConfig:
|
||||
),
|
||||
cursor=data.get("cursor", DEFAULT_STREAMING_CURSOR),
|
||||
fresh_final_after_seconds=_coerce_float(
|
||||
data.get("fresh_final_after_seconds"), 60.0
|
||||
data.get("fresh_final_after_seconds"), 0.0
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -156,18 +156,23 @@ def _normalize_chat_content(
|
||||
|
||||
if isinstance(content, list):
|
||||
parts: List[str] = []
|
||||
total_len = 0
|
||||
items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
if item:
|
||||
parts.append(item[:MAX_NORMALIZED_TEXT_LENGTH])
|
||||
part = item[:MAX_NORMALIZED_TEXT_LENGTH]
|
||||
parts.append(part)
|
||||
total_len += len(part)
|
||||
elif isinstance(item, dict):
|
||||
item_type = str(item.get("type") or "").strip().lower()
|
||||
if item_type in {"text", "input_text", "output_text"}:
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
try:
|
||||
parts.append(str(text)[:MAX_NORMALIZED_TEXT_LENGTH])
|
||||
part = str(text)[:MAX_NORMALIZED_TEXT_LENGTH]
|
||||
parts.append(part)
|
||||
total_len += len(part)
|
||||
except Exception:
|
||||
pass
|
||||
# Silently skip image_url / other non-text parts
|
||||
@@ -175,8 +180,9 @@ def _normalize_chat_content(
|
||||
nested = _normalize_chat_content(item, _max_depth=_max_depth, _depth=_depth + 1)
|
||||
if nested:
|
||||
parts.append(nested)
|
||||
total_len += len(nested)
|
||||
# Check accumulated size
|
||||
if sum(len(p) for p in parts) >= MAX_NORMALIZED_TEXT_LENGTH:
|
||||
if total_len >= MAX_NORMALIZED_TEXT_LENGTH:
|
||||
break
|
||||
result = "\n".join(parts)
|
||||
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
|
||||
|
||||
@@ -1128,8 +1128,11 @@ SUPPORTED_DOCUMENT_TYPES = {
|
||||
".ini": "text/plain",
|
||||
".cfg": "text/plain",
|
||||
".zip": "application/zip",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".ts": "text/plain",
|
||||
".py": "text/plain",
|
||||
@@ -1913,16 +1916,21 @@ class BasePlatformAdapter(ABC):
|
||||
enforce it at intake: a message is dropped inside the adapter and never
|
||||
reaches the gateway unless it already passed that policy.
|
||||
|
||||
The gateway's env-based allowlist check runs *after* the adapter, so for
|
||||
these platforms a message arriving at ``_is_user_authorized`` has, by
|
||||
definition, already been authorized by the adapter. Without this flag the
|
||||
gateway would then deny it again (no env allowlist → default deny),
|
||||
silently breaking ``dm_policy: open`` and config-only allowlists.
|
||||
The gateway's env-based allowlist check runs *after* the adapter. When
|
||||
no env allowlist is configured, the gateway consults this flag so it can
|
||||
honor a config-only ``dm_policy: allowlist`` / ``allow_from`` (which the
|
||||
adapter already enforced) instead of double-denying it. Crucially, the
|
||||
flag alone is NOT "already authorized": these adapters default
|
||||
``dm_policy`` / ``group_policy`` to ``"open"``, which forwards every
|
||||
sender, so the gateway trusts the adapter only when its effective policy
|
||||
for the chat type is an actual ``"allowlist"`` restriction — never for
|
||||
``"open"`` (that would be the network-exposed fail-open SECURITY.md §2.6
|
||||
forbids). Open access still requires an explicit
|
||||
``{PLATFORM}_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS`` opt-in.
|
||||
|
||||
Adapters that own their access policy override this to return ``True``.
|
||||
The gateway treats that as "already authorized at intake" and skips the
|
||||
env-allowlist default-deny. Adapters that delegate access control to the
|
||||
gateway leave it ``False`` (the default).
|
||||
Adapters that delegate access control to the gateway leave it ``False``
|
||||
(the default).
|
||||
"""
|
||||
return False
|
||||
|
||||
@@ -1945,6 +1953,46 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def prefers_fresh_final_streaming(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Whether the stream consumer should finalize a streamed reply by
|
||||
sending a *fresh* final message (and deleting the preview) instead of
|
||||
final-editing the preview.
|
||||
|
||||
Some adapters can send richer final messages than their current edit
|
||||
implementation supports. Telegram is the motivating case: Hermes sends
|
||||
final replies through ``sendRichMessage`` but still finalizes streamed
|
||||
previews through its existing MarkdownV2 edit path until Bot API 10.1's
|
||||
``rich_message`` edit parameter is wired directly. Such adapters
|
||||
override this to ask the consumer to re-deliver the completed answer as
|
||||
a new rich message and best-effort delete the stale preview, so the
|
||||
final rendering matches the rich send path.
|
||||
|
||||
Default implementation returns False — legacy platforms keep the
|
||||
edit-in-place finalization path.
|
||||
"""
|
||||
return False
|
||||
|
||||
def streaming_overflow_limit(self) -> Optional[int]:
|
||||
"""Max single-message length (in this adapter's ``message_len_fn``
|
||||
units) the stream consumer may accumulate before it splits, when the
|
||||
adapter can deliver a larger message than its legacy per-message limit.
|
||||
|
||||
Telegram Bot API 10.1 Rich Messages accept up to 32,768 chars in a
|
||||
single ``sendRichMessage`` / ``sendRichMessageDraft``, far above the
|
||||
4,096 MarkdownV2 limit. Adapters with such a richer send/draft path
|
||||
override this so the consumer doesn't fragment a reply that fits one
|
||||
rich message; the live edit preview is still bound by the platform's
|
||||
edit limit, but the finalized reply (and DM draft preview) is delivered
|
||||
whole.
|
||||
|
||||
Return ``None`` (default) to use ``MAX_MESSAGE_LENGTH``.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def send_draft(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
||||
+108
-10
@@ -22,6 +22,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
import uuid
|
||||
from email.header import decode_header
|
||||
@@ -62,6 +63,63 @@ _AUTOMATED_HEADERS = {
|
||||
# Gmail-safe max length per email body
|
||||
MAX_MESSAGE_LENGTH = 50_000
|
||||
|
||||
SMTP_CONNECT_TIMEOUT = 30
|
||||
|
||||
|
||||
def _create_ipv4_connection(
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float,
|
||||
source_address: Any = None,
|
||||
) -> socket.socket:
|
||||
"""Create a TCP connection using only IPv4 addresses.
|
||||
|
||||
This mirrors ``socket.create_connection`` but constrains DNS resolution to
|
||||
``AF_INET``. It avoids mutating process-global socket functions, which
|
||||
matters because email sends run in executor threads.
|
||||
"""
|
||||
last_error: OSError | None = None
|
||||
for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo(
|
||||
host, port, socket.AF_INET, socket.SOCK_STREAM
|
||||
):
|
||||
sock = socket.socket(family, socktype, proto)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sockaddr)
|
||||
return sock
|
||||
except OSError as exc:
|
||||
last_error = exc
|
||||
sock.close()
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise OSError(f"No IPv4 address found for {host}:{port}")
|
||||
|
||||
|
||||
class _IPv4SMTP(smtplib.SMTP):
|
||||
def _get_socket(self, host, port, timeout): # type: ignore[override]
|
||||
return _create_ipv4_connection(
|
||||
host,
|
||||
port,
|
||||
timeout,
|
||||
source_address=self.source_address,
|
||||
)
|
||||
|
||||
|
||||
class _IPv4SMTP_SSL(smtplib.SMTP_SSL):
|
||||
def _get_socket(self, host, port, timeout): # type: ignore[override]
|
||||
raw_sock = _create_ipv4_connection(
|
||||
host,
|
||||
port,
|
||||
timeout,
|
||||
source_address=self.source_address,
|
||||
)
|
||||
return self.context.wrap_socket(
|
||||
raw_sock,
|
||||
server_hostname=getattr(self, "_host", host),
|
||||
)
|
||||
|
||||
# Supported image extensions for inline detection
|
||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
@@ -293,6 +351,48 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
# Fallback: just clear old entries if sort fails
|
||||
self._seen_uids = set(list(self._seen_uids)[-self._seen_uids_max // 2:])
|
||||
|
||||
def _connect_smtp(self) -> smtplib.SMTP:
|
||||
"""Create an SMTP connection, selecting the correct protocol for the port.
|
||||
|
||||
Port 465 uses implicit TLS (``SMTP_SSL``). All other ports use
|
||||
``SMTP`` + ``STARTTLS``.
|
||||
|
||||
When the host resolves to an IPv6 address that is unreachable
|
||||
(common on networks without IPv6 routing), the default connection can
|
||||
hang until the socket timeout expires. We retry connection-level
|
||||
failures through an IPv4-only socket path, without mutating global
|
||||
resolver state. TLS verification errors are not retried.
|
||||
|
||||
Returns a connected SMTP object with TLS established — callers
|
||||
can proceed directly to ``login()``.
|
||||
"""
|
||||
ctx = ssl.create_default_context()
|
||||
host = self._smtp_host
|
||||
port = self._smtp_port
|
||||
|
||||
def _connect(*, ipv4_only: bool = False) -> smtplib.SMTP:
|
||||
"""Attempt one SMTP connection."""
|
||||
smtp_cls = _IPv4SMTP if ipv4_only else smtplib.SMTP
|
||||
smtp_ssl_cls = _IPv4SMTP_SSL if ipv4_only else smtplib.SMTP_SSL
|
||||
if port == 465:
|
||||
return smtp_ssl_cls(host, port, timeout=SMTP_CONNECT_TIMEOUT, context=ctx)
|
||||
smtp = smtp_cls(host, port, timeout=SMTP_CONNECT_TIMEOUT)
|
||||
try:
|
||||
smtp.starttls(context=ctx)
|
||||
except Exception:
|
||||
smtp.close()
|
||||
raise
|
||||
return smtp
|
||||
|
||||
try:
|
||||
return _connect()
|
||||
except (socket.timeout, TimeoutError, ConnectionError, OSError) as exc:
|
||||
if isinstance(exc, ssl.SSLError):
|
||||
raise
|
||||
# Connection-level failure (may be unreachable IPv6).
|
||||
# Retry with IPv4 only.
|
||||
return _connect(ipv4_only=True)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to the IMAP server and start polling for new messages."""
|
||||
try:
|
||||
@@ -316,10 +416,11 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
|
||||
try:
|
||||
# Test SMTP connection
|
||||
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.quit()
|
||||
smtp = self._connect_smtp()
|
||||
try:
|
||||
smtp.login(self._address, self._password)
|
||||
finally:
|
||||
smtp.quit()
|
||||
logger.info("[Email] SMTP connection test passed.")
|
||||
except Exception as e:
|
||||
logger.error("[Email] SMTP connection failed: %s", e)
|
||||
@@ -555,9 +656,8 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
|
||||
smtp = self._connect_smtp()
|
||||
try:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
@@ -677,9 +777,8 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
logger.warning("[Email] Failed to attach %s: %s", file_path, e)
|
||||
|
||||
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
|
||||
smtp = self._connect_smtp()
|
||||
try:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
@@ -756,9 +855,8 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
part.add_header("Content-Disposition", f"attachment; filename={fname}")
|
||||
msg.attach(part)
|
||||
|
||||
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
|
||||
smtp = self._connect_smtp()
|
||||
try:
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self._address, self._password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
|
||||
@@ -209,7 +209,7 @@ class _MatrixHtmlSanitizer(HTMLParser):
|
||||
_ALLOWED_TAGS = {
|
||||
"a", "b", "blockquote", "br", "code", "del", "em", "h1", "h2", "h3",
|
||||
"h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "s", "strike",
|
||||
"strong", "ul",
|
||||
"strong", "table", "tbody", "td", "th", "thead", "tr", "ul",
|
||||
}
|
||||
_VOID_TAGS = {"br", "hr"}
|
||||
|
||||
|
||||
+152
-19
@@ -349,8 +349,11 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
supports_code_blocks = True # Telegram MarkdownV2 renders fenced code blocks
|
||||
# Bot API 10.1 Rich Messages cap the raw markdown/html text at 32,768
|
||||
# UTF-8 bytes. Content above this is sent via the legacy chunking path.
|
||||
RICH_MESSAGE_MAX_BYTES = 32768
|
||||
# UTF-8 characters. Content above this is sent via the legacy chunking path.
|
||||
RICH_MESSAGE_MAX_CHARS = 32768
|
||||
# Backwards-compatible alias for tests/external callers that referenced the
|
||||
# initial implementation name. The API limit is character-based, not bytes.
|
||||
RICH_MESSAGE_MAX_BYTES = RICH_MESSAGE_MAX_CHARS
|
||||
# Threshold for detecting Telegram client-side message splits.
|
||||
# When a chunk is near this limit, a continuation is almost certain.
|
||||
_SPLIT_THRESHOLD = 4000
|
||||
@@ -416,8 +419,11 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._mention_patterns = self._compile_mention_patterns()
|
||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
|
||||
# Bot API 10.1 Rich Messages: send final replies via sendRichMessage
|
||||
# with the raw agent markdown so tables/task lists/etc. render natively.
|
||||
# Bot API 10.1 Rich Messages: when explicitly enabled, send final
|
||||
# replies via sendRichMessage with the raw agent markdown so
|
||||
# tables/task lists/etc. render natively. Disabled by default because
|
||||
# several Telegram clients accept but render rich messages poorly.
|
||||
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
|
||||
# Latched off after a capability failure on sendRichMessage /
|
||||
# sendRichMessageDraft (e.g. older python-telegram-bot without the
|
||||
# endpoint) so later sends skip the doomed rich attempt entirely.
|
||||
@@ -920,19 +926,20 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# the RAW agent markdown so richer constructs (tables, task lists,
|
||||
# collapsible details, math, ...) render natively. The legacy MarkdownV2
|
||||
# send() path stays as the fallback for unsupported/oversized content and
|
||||
# older PTB/clients. Streaming edits/drafts are intentionally untouched —
|
||||
# Telegram exposes no rich-edit method.
|
||||
# older PTB/clients. Streaming edits stay on Hermes' existing MarkdownV2
|
||||
# edit path for now; finalization can re-send as rich and delete the stale
|
||||
# preview until rich_message edit support is wired directly.
|
||||
# ------------------------------------------------------------------
|
||||
def _content_fits_rich_limits(self, content: str) -> bool:
|
||||
"""Cheap pre-check for the one hard rich limit we can count locally.
|
||||
|
||||
Only the 32,768 UTF-8 byte text cap is enforced here. Other Bot API
|
||||
Only the 32,768 UTF-8 character text cap is enforced here. Other Bot API
|
||||
rich limits (500 blocks, 16 nesting levels, 20 table columns, ...) are
|
||||
not pre-counted; if exceeded Telegram returns a BadRequest, which
|
||||
:meth:`_is_rich_fallback_error` classifies as permanent so the send
|
||||
degrades to the legacy chunking path.
|
||||
"""
|
||||
return len(content.encode("utf-8")) <= self.RICH_MESSAGE_MAX_BYTES
|
||||
return len(content) <= self.RICH_MESSAGE_MAX_CHARS
|
||||
|
||||
def _bot_supports_rich(self) -> bool:
|
||||
"""True when the bound bot can issue raw ``sendRichMessage`` calls.
|
||||
@@ -946,15 +953,79 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None))
|
||||
|
||||
def _should_attempt_rich(self, content: str) -> bool:
|
||||
_RICH_DETAILS_RE = re.compile(r"<details\b[^>]*>.*?</details>", re.IGNORECASE | re.DOTALL)
|
||||
_RICH_MATH_IN_DETAILS_RE = re.compile(
|
||||
r"(\$\$.*?\$\$|"
|
||||
r"\\\[.*?\\\]|"
|
||||
r"\\\(.*?\\\)|"
|
||||
r"\\(?:sum|frac|alpha|beta|gamma|delta|theta|lambda|mu|pi|sigma|"
|
||||
r"int|prod|sqrt|lim|infty|begin\{(?:equation|align|matrix|cases)\}))",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
def _has_telegram_desktop_details_math_crash_shape(self, content: str) -> bool:
|
||||
"""Return True for rich-message details+math content that crashes TDesktop.
|
||||
|
||||
Telegram Desktop 6.9.1 can crash while rendering Bot API 10.1 rich
|
||||
messages containing math inside a collapsible details block
|
||||
(telegramdesktop/tdesktop#30808). The Bot API accepts the payload, so
|
||||
Hermes must skip rich delivery up front and use the legacy MarkdownV2
|
||||
path until affected Desktop clients age out.
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
for details_block in self._RICH_DETAILS_RE.findall(content):
|
||||
if self._RICH_MATH_IN_DETAILS_RE.search(details_block):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _should_attempt_rich(
|
||||
self, content: str, metadata: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
return bool(
|
||||
not getattr(self, "_rich_send_disabled", False)
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and not (metadata or {}).get("expect_edits")
|
||||
and content
|
||||
and content.strip()
|
||||
and not self._has_telegram_desktop_details_math_crash_shape(content)
|
||||
and self._content_fits_rich_limits(content)
|
||||
and self._bot_supports_rich()
|
||||
)
|
||||
|
||||
def prefers_fresh_final_streaming(
|
||||
self, content: str, metadata: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
"""Whether to replace a streamed preview with a fresh rich final.
|
||||
|
||||
Keep this disabled for Telegram. The fresh-final path briefly shows two
|
||||
copies of the final answer, then deletes the streaming preview after the
|
||||
rich send succeeds. That is especially visible on clients that support
|
||||
rich messages well, and it looks like duplicate delivery at the end of
|
||||
every streamed turn. Until Telegram rich edits are wired directly, final
|
||||
streamed replies should edit the existing preview in place.
|
||||
"""
|
||||
return False
|
||||
|
||||
def streaming_overflow_limit(self) -> Optional[int]:
|
||||
"""Allow the stream consumer to accumulate up to the rich-message cap
|
||||
before splitting, so a reply that fits one ``sendRichMessage`` /
|
||||
``sendRichMessageDraft`` isn't fragmented at the 4,096 MarkdownV2 limit.
|
||||
|
||||
Gated on the same rich capability as the send path (minus the
|
||||
content-length check — raising that cap is the whole point): rich not
|
||||
latched off and the bot exposes an async ``do_api_request``. Returns
|
||||
``None`` (→ legacy 4,096 limit) when rich isn't available, so non-rich
|
||||
streams split exactly as before.
|
||||
"""
|
||||
if (
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and self._bot_supports_rich()
|
||||
):
|
||||
return self.RICH_MESSAGE_MAX_CHARS
|
||||
return None
|
||||
|
||||
def _rich_message_payload(
|
||||
self, content: str, *, skip_entity_detection: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
@@ -976,16 +1047,19 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
rejections (BadRequest from a parser/limit issue) are NOT capability
|
||||
errors: the next message may be fine.
|
||||
"""
|
||||
name = exc.__class__.__name__.lower()
|
||||
if name in {"endpointnotfound", "invalidtoken"}:
|
||||
return True
|
||||
if isinstance(exc, (AttributeError, TypeError, NotImplementedError)):
|
||||
return True
|
||||
if getattr(exc, "error_code", None) == 404:
|
||||
return True
|
||||
s = str(exc).lower()
|
||||
if ("method" in s and "not found" in s) or "no such method" in s:
|
||||
if ("method" in s or "endpoint" in s) and (
|
||||
"not found" in s or "does not exist" in s
|
||||
):
|
||||
return True
|
||||
if "unsupported" in s or "not implemented" in s:
|
||||
return True
|
||||
return False
|
||||
return "no such method" in s
|
||||
|
||||
def _is_rich_fallback_error(self, exc: Exception) -> bool:
|
||||
"""True ⇒ permanent/capability error ⇒ safe to fall back to legacy.
|
||||
@@ -997,7 +1071,10 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
if self._is_bad_request_error(exc):
|
||||
return True
|
||||
return self._is_rich_capability_error(exc)
|
||||
if self._is_rich_capability_error(exc):
|
||||
return True
|
||||
s = str(exc).lower()
|
||||
return "unsupported" in s or "not implemented" in s
|
||||
|
||||
def _compute_single_send_routing(
|
||||
self,
|
||||
@@ -1070,6 +1147,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# which must not be sent as a stray field on the raw endpoint.
|
||||
payload.update({k: v for k, v in thread_kwargs.items() if v is not None})
|
||||
payload.update(self._notification_kwargs(metadata))
|
||||
if getattr(self, "_disable_link_previews", False):
|
||||
payload["link_preview_options"] = {"is_disabled": True}
|
||||
if reply_to_id is not None:
|
||||
# Spec: sendRichMessage takes reply_parameters (ReplyParameters
|
||||
# object), NOT the legacy reply_to_message_id scalar. Unknown
|
||||
@@ -1078,8 +1157,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
payload["reply_parameters"] = {"message_id": reply_to_id}
|
||||
|
||||
try:
|
||||
# Take the raw Bot API result (dict under real PTB). Passing
|
||||
# return_type=Message would make PTB deserialize a Bot API 10.1
|
||||
# response shape it does not fully model yet; a post-delivery parse
|
||||
# error must not be mistaken for a sendable failure.
|
||||
msg = await self._bot.do_api_request(
|
||||
"sendRichMessage", api_kwargs=payload, return_type=Message
|
||||
"sendRichMessage", api_kwargs=payload
|
||||
)
|
||||
except Exception as exc:
|
||||
if self._is_rich_fallback_error(exc):
|
||||
@@ -1116,7 +1199,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if isinstance(msg, dict):
|
||||
message_id = msg.get("message_id")
|
||||
if message_id is None:
|
||||
message_id = msg.get("result", {}).get("message_id")
|
||||
message_id = (msg.get("result") or {}).get("message_id")
|
||||
else:
|
||||
message_id = getattr(msg, "message_id", None)
|
||||
return SendResult(
|
||||
@@ -1126,10 +1209,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
def _should_attempt_rich_draft(self, content: str) -> bool:
|
||||
return bool(
|
||||
not getattr(self, "_rich_send_disabled", False)
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and not getattr(self, "_rich_draft_disabled", False)
|
||||
and content
|
||||
and content.strip()
|
||||
and not self._has_telegram_desktop_details_math_crash_shape(content)
|
||||
and self._content_fits_rich_limits(content)
|
||||
and self._bot_supports_rich()
|
||||
)
|
||||
@@ -2146,7 +2231,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# through to the legacy MarkdownV2 path on permanent/capability
|
||||
# errors or DM-topic routing skips; returns directly on success or
|
||||
# on a transient failure (which must NOT be legacy-resent).
|
||||
if self._should_attempt_rich(content):
|
||||
if self._should_attempt_rich(content, metadata=metadata):
|
||||
rich_result = await self._try_send_rich(chat_id, content, reply_to, metadata)
|
||||
if rich_result is not None:
|
||||
if rich_result.success:
|
||||
@@ -5468,6 +5553,52 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
event.text = self._append_observed_note(event.text, cached.context_note())
|
||||
logger.info("[Telegram] Cached observed group %s at %s", cached.kind, cached.path)
|
||||
|
||||
async def _cache_replied_media(self, msg: Any, event: MessageEvent) -> None:
|
||||
"""Cache media from the message this turn replies to, if any."""
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
|
||||
reply_msg = getattr(msg, "reply_to_message", None)
|
||||
if reply_msg is None:
|
||||
return
|
||||
source, filename, mime, kind = self._observed_media_source(reply_msg)
|
||||
if source is None:
|
||||
return
|
||||
|
||||
max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)
|
||||
file_size = getattr(source, "file_size", None)
|
||||
try:
|
||||
size = int(file_size or 0)
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
if not (0 < size <= max_bytes):
|
||||
return
|
||||
|
||||
try:
|
||||
file_obj = await source.get_file()
|
||||
data = bytes(await file_obj.download_as_bytearray())
|
||||
if not filename:
|
||||
filename = os.path.basename(getattr(file_obj, "file_path", "") or "")
|
||||
cached = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind)
|
||||
except Exception as exc:
|
||||
logger.warning("[Telegram] Failed to cache replied-to media: %s", exc, exc_info=True)
|
||||
return
|
||||
|
||||
if cached is None:
|
||||
return
|
||||
|
||||
event.media_urls.append(cached.path)
|
||||
event.media_types.append(cached.media_type)
|
||||
if len(event.media_urls) == 1:
|
||||
if cached.kind == "image":
|
||||
event.message_type = MessageType.PHOTO
|
||||
elif cached.kind == "video":
|
||||
event.message_type = MessageType.VIDEO
|
||||
event.text = self._append_observed_note(
|
||||
event.text,
|
||||
f"[Replied-to {cached.kind} '{cached.display_name}' saved at: {cached.path}]",
|
||||
)
|
||||
logger.info("[Telegram] Cached replied-to %s at %s", cached.kind, cached.path)
|
||||
|
||||
def _observed_media_source(self, msg: Message):
|
||||
"""Return (telegram_file_source, filename, mime, default_kind) or Nones."""
|
||||
if msg.photo:
|
||||
@@ -5657,6 +5788,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
event = self._build_message_event(msg, MessageType.TEXT, update_id=update.update_id)
|
||||
event.text = self._clean_bot_trigger_text(event.text)
|
||||
await self._cache_replied_media(msg, event)
|
||||
event = self._apply_telegram_group_observe_attribution(event)
|
||||
self._enqueue_text_event(event)
|
||||
|
||||
@@ -5671,6 +5803,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
event = self._build_message_event(msg, MessageType.COMMAND, update_id=update.update_id)
|
||||
event.text = self._clean_bot_trigger_text(event.text)
|
||||
await self._cache_replied_media(msg, event)
|
||||
event = self._apply_telegram_group_observe_attribution(event)
|
||||
await self.handle_message(event)
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from collections import deque
|
||||
from typing import Any, Deque, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
@@ -67,6 +68,7 @@ DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8644
|
||||
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
|
||||
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
|
||||
_RATE_WINDOW_SECONDS = 60.0
|
||||
|
||||
# Hostnames/IP literals that only serve connections originating on the same
|
||||
# machine. Anything else is treated as a public bind for safety-rail purposes.
|
||||
@@ -122,6 +124,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
# back to the "log" deliver type.
|
||||
self._delivery_info: Dict[str, dict] = {}
|
||||
self._delivery_info_created: Dict[str, float] = {}
|
||||
self._delivery_info_order: Deque[tuple[float, str]] = deque()
|
||||
|
||||
# Reference to gateway runner for cross-platform delivery (set externally)
|
||||
self.gateway_runner = None
|
||||
@@ -130,9 +133,10 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
# Prevents duplicate agent runs when webhook providers retry.
|
||||
self._seen_deliveries: Dict[str, float] = {}
|
||||
self._idempotency_ttl: int = 3600 # 1 hour
|
||||
self._seen_deliveries_next_prune_at: float = 0.0
|
||||
|
||||
# Rate limiting: per-route timestamps in a fixed window.
|
||||
self._rate_counts: Dict[str, List[float]] = {}
|
||||
self._rate_counts: Dict[str, Deque[float]] = {}
|
||||
self._rate_limit: int = int(config.extra.get("rate_limit", 30)) # per minute
|
||||
|
||||
# Body size limit (auth-before-body pattern)
|
||||
@@ -271,15 +275,57 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
on each POST so the dict size is bounded by ``rate_limit * TTL``
|
||||
even if many webhooks fire and never receive a final response.
|
||||
"""
|
||||
if len(self._delivery_info_order) < len(self._delivery_info_created):
|
||||
self._delivery_info_order = deque(
|
||||
(created_at, key)
|
||||
for key, created_at in sorted(
|
||||
self._delivery_info_created.items(), key=lambda item: item[1]
|
||||
)
|
||||
)
|
||||
cutoff = now - self._idempotency_ttl
|
||||
stale = [
|
||||
k
|
||||
for k, t in self._delivery_info_created.items()
|
||||
if t < cutoff
|
||||
]
|
||||
while self._delivery_info_order and self._delivery_info_order[0][0] < cutoff:
|
||||
created_at, key = self._delivery_info_order.popleft()
|
||||
if self._delivery_info_created.get(key) != created_at:
|
||||
continue
|
||||
self._delivery_info.pop(key, None)
|
||||
self._delivery_info_created.pop(key, None)
|
||||
|
||||
def _prune_seen_deliveries(self, now: float) -> None:
|
||||
"""Occasionally prune expired delivery IDs without scanning every POST."""
|
||||
if now < self._seen_deliveries_next_prune_at:
|
||||
return
|
||||
cutoff = now - self._idempotency_ttl
|
||||
stale = [k for k, t in self._seen_deliveries.items() if t < cutoff]
|
||||
for k in stale:
|
||||
self._delivery_info.pop(k, None)
|
||||
self._delivery_info_created.pop(k, None)
|
||||
self._seen_deliveries.pop(k, None)
|
||||
self._seen_deliveries_next_prune_at = now + min(60.0, max(1.0, self._idempotency_ttl / 10))
|
||||
|
||||
def _record_rate_limit_hit(self, route_name: str, now: float) -> bool:
|
||||
"""Return True if route is still within limit after recording this hit."""
|
||||
window = self._rate_counts.get(route_name)
|
||||
if not isinstance(window, deque):
|
||||
new_window: Deque[float] = deque(window or ())
|
||||
self._rate_counts[route_name] = new_window
|
||||
window = new_window
|
||||
cutoff = now - _RATE_WINDOW_SECONDS
|
||||
while window and window[0] < cutoff:
|
||||
window.popleft()
|
||||
if len(window) >= self._rate_limit:
|
||||
return False
|
||||
window.append(now)
|
||||
return True
|
||||
|
||||
def _record_delivery_id(self, delivery_id: str, now: float) -> bool:
|
||||
"""Return True when this delivery should be processed."""
|
||||
seen_at = self._seen_deliveries.get(delivery_id)
|
||||
if seen_at is not None and now - seen_at < self._idempotency_ttl:
|
||||
return False
|
||||
if seen_at is not None:
|
||||
self._seen_deliveries.pop(delivery_id, None)
|
||||
self._seen_deliveries[delivery_id] = now
|
||||
if len(self._seen_deliveries) > max(self._rate_limit * 2, 128):
|
||||
self._prune_seen_deliveries(now)
|
||||
return True
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
return {"name": chat_id, "type": "webhook"}
|
||||
@@ -413,13 +459,10 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
|
||||
# ── Rate limiting (after auth) ───────────────────────────
|
||||
now = time.time()
|
||||
window = self._rate_counts.setdefault(route_name, [])
|
||||
window[:] = [t for t in window if now - t < 60]
|
||||
if len(window) >= self._rate_limit:
|
||||
if not self._record_rate_limit_hit(route_name, now):
|
||||
return web.json_response(
|
||||
{"error": "Rate limit exceeded"}, status=429
|
||||
)
|
||||
window.append(now)
|
||||
|
||||
# Parse payload
|
||||
try:
|
||||
@@ -504,13 +547,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
# ── Idempotency ─────────────────────────────────────────
|
||||
# Skip duplicate deliveries (webhook retries).
|
||||
now = time.time()
|
||||
# Prune expired entries
|
||||
self._seen_deliveries = {
|
||||
k: v
|
||||
for k, v in self._seen_deliveries.items()
|
||||
if now - v < self._idempotency_ttl
|
||||
}
|
||||
if delivery_id in self._seen_deliveries:
|
||||
if not self._record_delivery_id(delivery_id, now):
|
||||
logger.info(
|
||||
"[webhook] Skipping duplicate delivery %s", delivery_id
|
||||
)
|
||||
@@ -518,7 +555,6 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
{"status": "duplicate", "delivery_id": delivery_id},
|
||||
status=200,
|
||||
)
|
||||
self._seen_deliveries[delivery_id] = now
|
||||
|
||||
# ── Direct delivery mode (deliver_only) ─────────────────
|
||||
# Skip the agent entirely — the rendered prompt IS the message we
|
||||
@@ -594,6 +630,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
}
|
||||
self._delivery_info[session_chat_id] = deliver_config
|
||||
self._delivery_info_created[session_chat_id] = now
|
||||
self._delivery_info_order.append((now, session_chat_id))
|
||||
self._prune_delivery_info(now)
|
||||
|
||||
# Build source and event
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Gateway response filtering helpers.
|
||||
|
||||
These helpers operate at the gateway boundary: they decide whether a completed
|
||||
agent turn should be delivered to the chat, not what should be persisted in the
|
||||
conversation history.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Canonical model-emitted control token for intentional silence.
|
||||
SILENT_REPLY_TOKEN = "NO_REPLY"
|
||||
|
||||
# Exact whole-response markers that mean "the agent intentionally chose not to
|
||||
# reply". Keep this list small and explicit; arbitrary empty output remains an
|
||||
# error/empty-response path, not silence.
|
||||
LIVE_GATEWAY_SILENT_MARKERS = frozenset({
|
||||
"[SILENT]",
|
||||
"SILENT",
|
||||
"NO_REPLY",
|
||||
"NO REPLY",
|
||||
})
|
||||
|
||||
|
||||
def _canonical_silence_candidate(text: str) -> str:
|
||||
return " ".join(text.strip().upper().split())
|
||||
|
||||
|
||||
def is_intentional_silence_response(response: Any) -> bool:
|
||||
"""Return True only when ``response`` is exactly a silence marker.
|
||||
|
||||
Substantive prose that merely mentions ``NO_REPLY`` or ``[SILENT]`` must be
|
||||
delivered normally. A blank response is also not silence; blank output is
|
||||
handled by the empty-response failure path.
|
||||
"""
|
||||
if not isinstance(response, str):
|
||||
return False
|
||||
stripped = response.strip()
|
||||
if not stripped:
|
||||
return False
|
||||
if len(stripped) > 64:
|
||||
return False
|
||||
return _canonical_silence_candidate(stripped) in LIVE_GATEWAY_SILENT_MARKERS
|
||||
|
||||
|
||||
def is_intentional_silence_agent_result(agent_result: dict | None, response: Any) -> bool:
|
||||
"""Silence markers suppress delivery only for successful agent turns."""
|
||||
if not isinstance(agent_result, dict):
|
||||
return False
|
||||
if agent_result.get("failed"):
|
||||
return False
|
||||
return is_intentional_silence_response(response)
|
||||
+414
-37
@@ -677,6 +677,15 @@ def _build_gateway_agent_history(
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in {"timestamp", "observed"}}
|
||||
agent_history.append(clean_msg)
|
||||
elif content:
|
||||
# Strip gateway-injected auto-continue notes that were persisted
|
||||
# as part of user messages during interrupted turns. Keep the
|
||||
# user's real text after the note, but never replay the recovery
|
||||
# instruction itself — that is what caused infinite re-execution
|
||||
# loops for interrupted long-running tools.
|
||||
if role == "user":
|
||||
content = _strip_auto_continue_noise(content)
|
||||
if not content:
|
||||
continue
|
||||
# Simple text message - just need role and content.
|
||||
if msg.get("mirror"):
|
||||
mirror_src = msg.get("mirror_source", "another session")
|
||||
@@ -684,6 +693,10 @@ def _build_gateway_agent_history(
|
||||
entry = _build_replay_entry(role, content, msg)
|
||||
agent_history.append(entry)
|
||||
|
||||
# Strip interrupted tool-call tails so the LLM doesn't re-execute
|
||||
# tools that were killed mid-flight.
|
||||
agent_history = _strip_interrupted_tool_tails(agent_history)
|
||||
|
||||
observed_context = "\n".join(observed_group_context).strip() or None
|
||||
return agent_history, observed_context
|
||||
|
||||
@@ -749,6 +762,99 @@ _AUTO_APPEND_MEDIA_TOOL_NAMES = {
|
||||
"image_generate",
|
||||
}
|
||||
|
||||
# ---- helpers: detect interrupted tool tails & auto-continue noise ----------
|
||||
|
||||
def _is_interrupted_tool_result(content: Any) -> bool:
|
||||
"""Return True if a tool result indicates the tool was interrupted."""
|
||||
if not isinstance(content, str):
|
||||
return False
|
||||
lowered = content.lower()
|
||||
if "[command interrupted]" in lowered:
|
||||
return True
|
||||
if "exit_code" in lowered and ("130" in lowered or "-1" in lowered):
|
||||
return "interrupt" in lowered
|
||||
return False
|
||||
|
||||
|
||||
def _strip_interrupted_tool_tails(
|
||||
agent_history: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Strip interrupted assistant→tool sequences from replay history.
|
||||
|
||||
Older interrupted gateway turns can be followed by a queued real user
|
||||
message, so the interrupted assistant/tool block is not necessarily the
|
||||
final tail by the time we rebuild replay history. Remove any contiguous
|
||||
assistant(tool_calls) + tool-result block that contains an interrupted tool
|
||||
result, while preserving successful tool-call sequences intact.
|
||||
"""
|
||||
if not agent_history:
|
||||
return agent_history
|
||||
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
i = 0
|
||||
n = len(agent_history)
|
||||
while i < n:
|
||||
msg = agent_history[i]
|
||||
if msg.get("role") == "assistant" and "tool_calls" in msg:
|
||||
j = i + 1
|
||||
tool_results: List[Dict[str, Any]] = []
|
||||
while j < n and agent_history[j].get("role") == "tool":
|
||||
tool_results.append(agent_history[j])
|
||||
j += 1
|
||||
if tool_results and any(
|
||||
_is_interrupted_tool_result(m.get("content", ""))
|
||||
for m in tool_results
|
||||
):
|
||||
logger.debug(
|
||||
"Stripping interrupted assistant→tool replay block "
|
||||
"(indices %d–%d, tool_results=%d)",
|
||||
i, j - 1, len(tool_results),
|
||||
)
|
||||
i = j
|
||||
continue
|
||||
if msg.get("role") == "tool" and _is_interrupted_tool_result(msg.get("content", "")):
|
||||
logger.debug("Stripping orphan interrupted tool result from replay history")
|
||||
i += 1
|
||||
continue
|
||||
cleaned.append(msg)
|
||||
i += 1
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
_AUTO_CONTINUE_NOTE_PREFIX = "[System note: Your previous turn"
|
||||
_AUTO_CONTINUE_FALLBACK_PREFIX = "[System note: A new message"
|
||||
|
||||
|
||||
def _is_auto_continue_noise(content: Any) -> bool:
|
||||
"""Return True if this user-message content is a gateway-injected
|
||||
auto-continue note that should NOT be replayed as a real user turn."""
|
||||
if not isinstance(content, str):
|
||||
return False
|
||||
return (
|
||||
content.startswith(_AUTO_CONTINUE_NOTE_PREFIX)
|
||||
or content.startswith(_AUTO_CONTINUE_FALLBACK_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def _strip_auto_continue_noise(content: Any) -> Any:
|
||||
"""Remove persisted gateway auto-continue note prefix from user text.
|
||||
|
||||
Older gateway builds prepended the recovery note directly to the user
|
||||
message, so the transcript row can contain both the synthetic note and
|
||||
the user's real question. Strip one or more leading synthetic notes while
|
||||
preserving any real text that follows.
|
||||
"""
|
||||
if not _is_auto_continue_noise(content):
|
||||
return content
|
||||
text = str(content)
|
||||
while _is_auto_continue_noise(text):
|
||||
end = text.find("]")
|
||||
if end < 0:
|
||||
return ""
|
||||
text = text[end + 1 :].lstrip()
|
||||
return text
|
||||
|
||||
# Tools in this set return their deliverable artifact as a JSON payload with a
|
||||
# local-file path field rather than a literal ``MEDIA:`` tag (e.g. image_generate
|
||||
# returns ``{"success": true, "image": "/abs/path.png"}``). The auto-append path
|
||||
@@ -1021,6 +1127,7 @@ if _config_path.exists():
|
||||
"backend": "TERMINAL_ENV",
|
||||
"cwd": "TERMINAL_CWD",
|
||||
"timeout": "TERMINAL_TIMEOUT",
|
||||
"home_mode": "TERMINAL_HOME_MODE",
|
||||
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
|
||||
"docker_image": "TERMINAL_DOCKER_IMAGE",
|
||||
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
|
||||
@@ -2000,6 +2107,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_stop_task: Optional[asyncio.Task] = None
|
||||
_session_model_overrides: Dict[str, Dict[str, str]] = {}
|
||||
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
|
||||
_startup_restore_in_progress: bool = False
|
||||
|
||||
def __init__(self, config: Optional[GatewayConfig] = None):
|
||||
global _gateway_runner_ref
|
||||
@@ -2080,6 +2188,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
self._pending_native_image_paths_by_session: Dict[str, List[str]] = {}
|
||||
self._busy_ack_ts: Dict[str, float] = {} # last busy-ack timestamp per session (debounce)
|
||||
self._session_run_generation: Dict[str, int] = {}
|
||||
# Startup restore gate: while restart-interrupted sessions are being
|
||||
# auto-resumed, real inbound messages are queued instead of competing
|
||||
# with the synthetic resume turns for the same session. The queued
|
||||
# events drain only after all startup resume tasks have finished.
|
||||
self._startup_restore_in_progress = False
|
||||
self._startup_restore_queue: List[MessageEvent] = []
|
||||
self._startup_restore_tasks: List[asyncio.Task] = []
|
||||
# LRU cache of live SessionSources keyed by session_key. Used by
|
||||
# fallback routing paths (shutdown notifications, synthetic
|
||||
# background-process events) when the persisted origin is missing
|
||||
@@ -4495,6 +4610,94 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
{"restart_timeout", "shutdown_timeout", "restart_interrupted"}
|
||||
)
|
||||
|
||||
async def _run_startup_resume_event(
|
||||
self,
|
||||
adapter: BasePlatformAdapter,
|
||||
event: MessageEvent,
|
||||
session_key: str,
|
||||
) -> None:
|
||||
"""Dispatch one synthetic startup resume and wait for its agent turn.
|
||||
|
||||
``BasePlatformAdapter.handle_message()`` returns after it installs the
|
||||
adapter-level guard and spawns the background processing task. Startup
|
||||
restore needs a stronger boundary: inbound messages must stay queued
|
||||
until the resumed agent turn itself has finished, otherwise a user
|
||||
message can race the restore turn immediately after ``handle_message``
|
||||
returns.
|
||||
"""
|
||||
try:
|
||||
await adapter.handle_message(event)
|
||||
session_tasks = getattr(adapter, "_session_tasks", {})
|
||||
task = session_tasks.get(session_key) if isinstance(session_tasks, dict) else None
|
||||
if task is not None:
|
||||
await asyncio.shield(task)
|
||||
finally:
|
||||
# _schedule_resume_pending_sessions pre-claims the runner slot
|
||||
# before spawning this task. If adapter.handle_message raises
|
||||
# before _handle_message takes ownership, release that pre-claim;
|
||||
# otherwise the real run's normal cleanup owns the slot.
|
||||
if self._running_agents.get(session_key) is _AGENT_PENDING_SENTINEL:
|
||||
self._release_running_agent_state(session_key)
|
||||
|
||||
def _queue_startup_restore_event(self, event: MessageEvent) -> None:
|
||||
queue = getattr(self, "_startup_restore_queue", None)
|
||||
if queue is None:
|
||||
queue = []
|
||||
self._startup_restore_queue = queue
|
||||
queue.append(event)
|
||||
try:
|
||||
source = event.source
|
||||
logger.info(
|
||||
"Queued inbound message during gateway startup restore: platform=%s chat=%s",
|
||||
source.platform.value if source and source.platform else "unknown",
|
||||
source.chat_id if source else "unknown",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _drain_startup_restore_queue(self) -> int:
|
||||
"""Replay inbound messages queued while startup auto-resume ran."""
|
||||
drained = 0
|
||||
queue = getattr(self, "_startup_restore_queue", None)
|
||||
if queue is None:
|
||||
return 0
|
||||
while queue:
|
||||
event = queue.pop(0)
|
||||
source = getattr(event, "source", None)
|
||||
adapter = self.adapters.get(source.platform) if source is not None else None
|
||||
if adapter is None:
|
||||
logger.debug(
|
||||
"Dropping startup-restore queued message: adapter unavailable for %s",
|
||||
getattr(getattr(source, "platform", None), "value", None),
|
||||
)
|
||||
continue
|
||||
# Mark this replay so _handle_message does not queue it again while
|
||||
# the restore gate remains closed for any fresh inbound arrivals.
|
||||
try:
|
||||
setattr(event, "_hermes_startup_restore_replay", True)
|
||||
except Exception:
|
||||
pass
|
||||
await adapter.handle_message(event)
|
||||
drained += 1
|
||||
return drained
|
||||
|
||||
async def _finish_startup_restore(self) -> None:
|
||||
"""Wait for startup auto-resume, then release and drain inbound queue."""
|
||||
tasks = list(getattr(self, "_startup_restore_tasks", []) or [])
|
||||
if tasks:
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.debug(
|
||||
"startup auto-resume task failed",
|
||||
exc_info=(type(result), result, result.__traceback__),
|
||||
)
|
||||
self._startup_restore_tasks = []
|
||||
drained = await self._drain_startup_restore_queue()
|
||||
self._startup_restore_in_progress = False
|
||||
if drained:
|
||||
logger.info("Drained %d inbound message(s) queued during startup restore", drained)
|
||||
|
||||
def _schedule_resume_pending_sessions(self, platform=None) -> int:
|
||||
"""Auto-continue fresh restart-interrupted sessions after startup.
|
||||
|
||||
@@ -4556,6 +4759,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
continue
|
||||
|
||||
# Claim the session slot *before* spawning the task so that an
|
||||
# inbound message arriving between task creation and the task's
|
||||
# first await (where _process_message_background sets the real
|
||||
# sentinel) sees the slot as occupied and queues behind it
|
||||
# instead of spinning up a duplicate AIAgent (#45456).
|
||||
self._running_agents[entry.session_key] = _AGENT_PENDING_SENTINEL
|
||||
self._running_agents_ts[entry.session_key] = time.time()
|
||||
|
||||
# Empty-text internal event — the _is_resume_pending branch in
|
||||
# _handle_message_with_agent prepends the proper reason-aware
|
||||
# system note before the turn runs.
|
||||
@@ -4565,9 +4776,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
source=source,
|
||||
internal=True,
|
||||
)
|
||||
task = asyncio.create_task(adapter.handle_message(event))
|
||||
task = asyncio.create_task(
|
||||
self._run_startup_resume_event(adapter, event, entry.session_key)
|
||||
)
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
if getattr(self, "_startup_restore_in_progress", False):
|
||||
tasks = getattr(self, "_startup_restore_tasks", None)
|
||||
if tasks is None:
|
||||
tasks = []
|
||||
self._startup_restore_tasks = tasks
|
||||
tasks.append(task)
|
||||
scheduled += 1
|
||||
|
||||
if scheduled:
|
||||
@@ -4830,6 +5049,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
except Exception as e:
|
||||
logger.debug("Stuck-loop detection failed: %s", e)
|
||||
|
||||
# Serialize startup restore against inbound dispatch. Platform
|
||||
# adapters can begin receiving messages as soon as they connect, but
|
||||
# restart-interrupted sessions are not auto-resumed until all startup
|
||||
# wiring below completes. Queue inbound messages until the resume
|
||||
# pass runs and every synthetic resume turn has finished.
|
||||
self._startup_restore_in_progress = True
|
||||
self._startup_restore_queue = []
|
||||
self._startup_restore_tasks = []
|
||||
|
||||
connected_count = 0
|
||||
enabled_platform_count = 0
|
||||
startup_nonretryable_errors: list[str] = []
|
||||
@@ -4964,6 +5192,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
except Exception:
|
||||
pass
|
||||
self._request_clean_exit(reason)
|
||||
self._startup_restore_in_progress = False
|
||||
return True
|
||||
if enabled_platform_count > 0:
|
||||
if startup_retryable_errors:
|
||||
@@ -5074,6 +5303,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# by the normal successful-turn path, so a failed auto-resume remains
|
||||
# visible for manual recovery on the next user message.
|
||||
self._schedule_resume_pending_sessions()
|
||||
await self._finish_startup_restore()
|
||||
|
||||
# Drain any recovered process watchers (from crash recovery checkpoint)
|
||||
try:
|
||||
@@ -6376,6 +6606,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"""
|
||||
source = event.source
|
||||
|
||||
if (
|
||||
getattr(self, "_startup_restore_in_progress", False)
|
||||
and not getattr(event, "internal", False)
|
||||
and not getattr(event, "_hermes_startup_restore_replay", False)
|
||||
):
|
||||
self._queue_startup_restore_event(event)
|
||||
return None
|
||||
|
||||
# Internal events (e.g. background-process completion notifications)
|
||||
# are system-generated and must skip user authorization.
|
||||
is_internal = bool(getattr(event, "internal", False))
|
||||
@@ -7316,6 +7554,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "resume":
|
||||
return await self._handle_resume_command(event)
|
||||
|
||||
if canonical == "sessions":
|
||||
return await self._handle_sessions_command(event)
|
||||
|
||||
if canonical == "branch":
|
||||
return await self._handle_branch_command(event)
|
||||
|
||||
@@ -8608,13 +8849,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
return None
|
||||
|
||||
response = agent_result.get("final_response") or ""
|
||||
try:
|
||||
from gateway.response_filters import is_intentional_silence_agent_result
|
||||
_intentional_silence = is_intentional_silence_agent_result(
|
||||
agent_result, response,
|
||||
)
|
||||
except Exception:
|
||||
_intentional_silence = False
|
||||
|
||||
# Convert the agent's internal "(empty)" sentinel into a
|
||||
# user-friendly message. "(empty)" means the model failed to
|
||||
# produce visible content after exhausting all retries (nudge,
|
||||
# prefill, empty-retry, fallback). Sending the raw sentinel
|
||||
# looks like a bug; a short explanation is more helpful.
|
||||
if response == "(empty)":
|
||||
if response == "(empty)" and not _intentional_silence:
|
||||
response = (
|
||||
"⚠️ The model returned no response after processing tool "
|
||||
"results. This can happen with some models — try again or "
|
||||
@@ -8630,6 +8878,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_response_time, _api_calls, _resp_len,
|
||||
)
|
||||
|
||||
# Re-baseline the cached agent's message_count snapshot now that
|
||||
# this turn has completed and the agent has flushed its rows to
|
||||
# the SessionDB. The cross-process coherence guard (#45966)
|
||||
# snapshots the count at agent-BUILD time (before this turn's own
|
||||
# writes) and never refreshes it on reuse — so without this, this
|
||||
# process's own turn would grow the count and the next turn would
|
||||
# see a mismatch and rebuild the agent every turn, destroying
|
||||
# prompt caching. Refreshing here makes the guard fire only on a
|
||||
# DIFFERENT process's writes. Uses the (possibly compaction-
|
||||
# updated) live session_id. Fail-safe inside the helper.
|
||||
self._refresh_agent_cache_message_count(
|
||||
session_key, session_entry.session_id
|
||||
)
|
||||
|
||||
# Successful turn — clear any stuck-loop counter for this session.
|
||||
# This ensures the counter only accumulates across CONSECUTIVE
|
||||
# restarts where the session was active (never completed).
|
||||
@@ -8650,10 +8912,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
# Normalize empty responses: surface errors, partial failures, and
|
||||
# the case where agent did work but returned no text. Fix for #18765.
|
||||
response = _normalize_empty_agent_response(
|
||||
agent_result, response, history_len=len(history),
|
||||
)
|
||||
response = _sanitize_gateway_final_response(source.platform, response)
|
||||
if not _intentional_silence:
|
||||
response = _normalize_empty_agent_response(
|
||||
agent_result, response, history_len=len(history),
|
||||
)
|
||||
response = _sanitize_gateway_final_response(source.platform, response)
|
||||
|
||||
# Ordering contract: the agent thread already updated the contextvar
|
||||
# in conversation_compression.py; propagate to SessionEntry + _save().
|
||||
@@ -8677,7 +8940,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
except Exception:
|
||||
_show_reasoning_effective = getattr(self, "_show_reasoning", False)
|
||||
if _show_reasoning_effective and response:
|
||||
if _show_reasoning_effective and response and not _intentional_silence:
|
||||
last_reasoning = agent_result.get("last_reasoning")
|
||||
if last_reasoning:
|
||||
# Collapse long reasoning to keep messages readable
|
||||
@@ -8707,7 +8970,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
except Exception as _footer_err:
|
||||
logger.debug("runtime_footer build failed: %s", _footer_err)
|
||||
_footer_line = ""
|
||||
if _footer_line and response and not agent_result.get("already_sent"):
|
||||
if _footer_line and response and not agent_result.get("already_sent") and not _intentional_silence:
|
||||
response = f"{response}\n\n{_footer_line}"
|
||||
|
||||
# Emit agent:end hook
|
||||
@@ -8941,6 +9204,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
last_prompt_tokens=agent_result.get("last_prompt_tokens", 0),
|
||||
)
|
||||
|
||||
# Intentional silence is a delivery decision, not a transcript
|
||||
# mutation. The agent's [SILENT]/NO_REPLY assistant turn above is
|
||||
# still persisted in session history so later turns keep normal
|
||||
# user/assistant alternation; only the outbound chat delivery is
|
||||
# suppressed.
|
||||
if _intentional_silence:
|
||||
logger.info(
|
||||
"Suppressing intentional silence marker for session %s",
|
||||
session_entry.session_id,
|
||||
)
|
||||
response = ""
|
||||
|
||||
# Auto voice reply: send TTS audio before the text response
|
||||
_already_sent = bool(agent_result.get("already_sent"))
|
||||
if self._should_send_voice_reply(event, response, agent_messages, already_sent=_already_sent):
|
||||
@@ -12046,7 +12321,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if session.exited:
|
||||
# --- Agent-triggered completion: inject synthetic message ---
|
||||
# Skip if the agent already consumed the result via wait/poll/log
|
||||
from tools.process_registry import process_registry as _pr_check
|
||||
from tools.process_registry import format_process_notification, process_registry as _pr_check
|
||||
if agent_notify and not _pr_check.is_completion_consumed(session_id):
|
||||
from tools.ansi_strip import strip_ansi
|
||||
_raw = strip_ansi(session.output_buffer) if session.output_buffer else ""
|
||||
@@ -12062,12 +12337,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_out = f"[… output truncated — showing last {len(_tail)} chars]\n{_tail}"
|
||||
else:
|
||||
_out = _raw
|
||||
synth_text = (
|
||||
f"[IMPORTANT: Background process {session_id} completed "
|
||||
f"(exit code {session.exit_code}).\n"
|
||||
f"Command: {session.command}\n"
|
||||
f"Output:\n{_out}]"
|
||||
)
|
||||
synth_text = format_process_notification({
|
||||
"type": "completion",
|
||||
"session_id": session_id,
|
||||
"command": session.command,
|
||||
"exit_code": session.exit_code,
|
||||
"completion_reason": getattr(session, "completion_reason", "exited"),
|
||||
"termination_source": getattr(session, "termination_source", ""),
|
||||
"output": _out,
|
||||
})
|
||||
if not synth_text:
|
||||
break
|
||||
source = self._build_process_event_source({
|
||||
"session_id": session_id,
|
||||
"session_key": session_key,
|
||||
@@ -12526,6 +12806,57 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if release_running_state:
|
||||
self._release_running_agent_state(session_key)
|
||||
|
||||
def _refresh_agent_cache_message_count(
|
||||
self, session_key: str, session_id: Optional[str]
|
||||
) -> None:
|
||||
"""Re-baseline a cached agent's stored message_count after THIS turn.
|
||||
|
||||
The cross-process coherence guard (#45966) compares the session's
|
||||
on-disk ``message_count`` against the count snapshotted next to the
|
||||
cached agent, and rebuilds the agent on a mismatch. But the snapshot
|
||||
is taken at agent-BUILD time — before this turn writes its own user +
|
||||
assistant (+ tool) rows — and the cache entry is never rewritten on a
|
||||
reuse. So without this re-baseline, THIS process's own turn would
|
||||
grow ``message_count`` and the very next turn would see a mismatch
|
||||
and rebuild the agent — every turn, for every conversation — silently
|
||||
destroying the per-conversation prompt caching the cache exists to
|
||||
protect.
|
||||
|
||||
Call this once a turn has completed and the agent has flushed its
|
||||
rows to the SessionDB. It snapshots the now-current count (which
|
||||
includes this process's own writes) so the guard only fires when a
|
||||
DIFFERENT process changes the transcript out from under us. The
|
||||
``_sig`` is left untouched; only the count element is refreshed, and
|
||||
only when the same agent is still cached (no rebuild/eviction raced
|
||||
in between). Fail-safe: any DB error leaves the snapshot as-is, which
|
||||
at worst costs one unnecessary rebuild on the next turn.
|
||||
"""
|
||||
if self._session_db is None or not session_id:
|
||||
return
|
||||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
_cache = getattr(self, "_agent_cache", None)
|
||||
if not _cache_lock or _cache is None:
|
||||
return
|
||||
try:
|
||||
_sess_row = self._session_db.get_session(session_id)
|
||||
_live = _sess_row.get("message_count", 0) if _sess_row else None
|
||||
except Exception:
|
||||
return
|
||||
if _live is None:
|
||||
return
|
||||
with _cache_lock:
|
||||
cached = _cache.get(session_key)
|
||||
# Only re-baseline a live 3-tuple entry; skip pending sentinels,
|
||||
# legacy 2-tuples (they intentionally opt out of the guard), and
|
||||
# the case where the entry was evicted/rebuilt mid-turn.
|
||||
if (
|
||||
isinstance(cached, tuple)
|
||||
and len(cached) > 2
|
||||
and cached[0] is not _AGENT_PENDING_SENTINEL
|
||||
):
|
||||
if cached[2] != _live:
|
||||
_cache[session_key] = (cached[0], cached[1], _live)
|
||||
|
||||
def _evict_cached_agent(self, session_key: str) -> None:
|
||||
"""Remove a cached agent for a session (called on /new, /model, etc).
|
||||
|
||||
@@ -14049,20 +14380,57 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
agent = None
|
||||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
_cache = getattr(self, "_agent_cache", None)
|
||||
|
||||
# Detect cross-process writes: when another process (e.g. hermes
|
||||
# dashboard) appends to the same session in the shared SessionDB,
|
||||
# the cached agent's in-memory transcript becomes stale. Compare
|
||||
# the session's current message_count against the count recorded
|
||||
# when the agent was cached; on mismatch, invalidate the cache
|
||||
# so a fresh agent re-reads from disk. (#45966)
|
||||
_current_msg_count = None
|
||||
if self._session_db is not None and session_id:
|
||||
try:
|
||||
_sess_row = self._session_db.get_session(session_id)
|
||||
if _sess_row:
|
||||
_current_msg_count = _sess_row.get("message_count", 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _cache_lock and _cache is not None:
|
||||
with _cache_lock:
|
||||
cached = _cache.get(session_key)
|
||||
if cached and cached[1] == _sig:
|
||||
agent = cached[0]
|
||||
# Refresh LRU order so the cap enforcement evicts
|
||||
# truly-oldest entries, not the one we just used.
|
||||
if hasattr(_cache, "move_to_end"):
|
||||
try:
|
||||
_cache.move_to_end(session_key)
|
||||
except KeyError:
|
||||
pass
|
||||
self._init_cached_agent_for_turn(agent, _interrupt_depth)
|
||||
logger.debug("Reusing cached agent for session %s", session_key)
|
||||
# cached[2] is the message_count at cache time;
|
||||
# stale when a second process appended rows.
|
||||
_cached_mc = cached[2] if len(cached) > 2 else None
|
||||
if (
|
||||
_cached_mc is not None
|
||||
and _current_msg_count is not None
|
||||
and _current_msg_count != _cached_mc
|
||||
):
|
||||
# Cross-process write detected — discard stale
|
||||
# agent so it rebuilds from fresh DB transcript.
|
||||
logger.info(
|
||||
"Agent cache invalidated for session %s: "
|
||||
"message_count changed (%s -> %s), "
|
||||
"possible cross-process write",
|
||||
session_key, _cached_mc, _current_msg_count,
|
||||
)
|
||||
evicted = self._agent_cache.pop(session_key, None)
|
||||
_ev_agent = evicted[0] if isinstance(evicted, tuple) and evicted else None
|
||||
if _ev_agent and _ev_agent is not _AGENT_PENDING_SENTINEL:
|
||||
self._cleanup_agent_resources(_ev_agent)
|
||||
else:
|
||||
agent = cached[0]
|
||||
# Refresh LRU order so the cap enforcement evicts
|
||||
# truly-oldest entries, not the one we just used.
|
||||
if hasattr(_cache, "move_to_end"):
|
||||
try:
|
||||
_cache.move_to_end(session_key)
|
||||
except KeyError:
|
||||
pass
|
||||
self._init_cached_agent_for_turn(agent, _interrupt_depth)
|
||||
logger.debug("Reusing cached agent for session %s", session_key)
|
||||
|
||||
if agent is None:
|
||||
# Config changed or first message — create fresh agent
|
||||
@@ -14100,7 +14468,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
if _cache_lock and _cache is not None:
|
||||
with _cache_lock:
|
||||
_cache[session_key] = (agent, _sig)
|
||||
_cache[session_key] = (agent, _sig, _current_msg_count)
|
||||
self._enforce_agent_cache_cap()
|
||||
logger.debug("Created new agent for session %s (sig=%s)", session_key, _sig)
|
||||
|
||||
@@ -14414,6 +14782,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
except Exception as _e:
|
||||
logger.error("Failed to send approval request: %s", _e)
|
||||
|
||||
# Keep real user text separate from API-only recovery guidance. If
|
||||
# an auto-continue note is prepended below, persist the original
|
||||
# message so stale guidance never replays as user-authored text.
|
||||
_persist_user_message_override: Optional[Any] = None
|
||||
|
||||
# Prepend pending model switch note so the model knows about the switch
|
||||
_pending_notes = getattr(self, '_pending_model_notes', {})
|
||||
_msn = _pending_notes.pop(session_key, None) if session_key else None
|
||||
@@ -14474,21 +14847,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if _reason == "shutdown_timeout"
|
||||
else "a gateway interruption"
|
||||
)
|
||||
_persist_user_message_override = message
|
||||
message = (
|
||||
f"[System note: Your previous turn in this session was interrupted "
|
||||
f"by {_reason_phrase}. The conversation history below is intact. "
|
||||
f"If it contains unfinished tool result(s), process them first and "
|
||||
f"summarize what was accomplished, then address the user's new "
|
||||
f"message below.]\n\n"
|
||||
f"[System note: A new message has arrived. The previous turn "
|
||||
f"was interrupted by {_reason_phrase}. "
|
||||
f"Address the user's NEW message below FIRST. "
|
||||
f"Do NOT re-execute old tool calls — skip any unfinished "
|
||||
f"work from the conversation history and focus on what the "
|
||||
f"user is asking now.]\n\n"
|
||||
+ message
|
||||
)
|
||||
elif _has_fresh_tool_tail:
|
||||
_persist_user_message_override = message
|
||||
message = (
|
||||
"[System note: Your previous turn was interrupted before you could "
|
||||
"process the last tool result(s). The conversation history contains "
|
||||
"tool outputs you haven't responded to yet. Please finish processing "
|
||||
"those results and summarize what was accomplished, then address the "
|
||||
"user's new message below.]\n\n"
|
||||
"[System note: A new message has arrived. The conversation "
|
||||
"history contains pending tool outputs from an interrupted turn. "
|
||||
"IGNORE those pending results. Address the user's NEW message "
|
||||
"below FIRST. Do NOT re-execute old tool calls from the history.]\n\n"
|
||||
+ message
|
||||
)
|
||||
|
||||
@@ -14546,7 +14921,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"conversation_history": agent_history,
|
||||
"task_id": session_id,
|
||||
}
|
||||
if observed_group_context:
|
||||
if _persist_user_message_override is not None:
|
||||
_conversation_kwargs["persist_user_message"] = _persist_user_message_override
|
||||
elif observed_group_context:
|
||||
_conversation_kwargs["persist_user_message"] = message
|
||||
result = agent.run_conversation(_api_run_message, **_conversation_kwargs)
|
||||
finally:
|
||||
|
||||
+150
-8
@@ -394,20 +394,35 @@ class GatewaySlashCommandsMixin:
|
||||
|
||||
async def _handle_status_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /status command."""
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model
|
||||
|
||||
source = event.source
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
|
||||
connected_platforms = [p.value for p in self.adapters.keys()]
|
||||
|
||||
# Check if there's an active agent
|
||||
# Check if there's an active agent. Keep the sentinel distinct: a
|
||||
# starting/pending run should not be treated as a fully usable agent for
|
||||
# model/context display, but it still occupies the session slot.
|
||||
session_key = session_entry.session_key
|
||||
is_running = session_key in self._running_agents
|
||||
agent = self._running_agents.get(session_key)
|
||||
is_running = agent is not None and agent is not _AGENT_PENDING_SENTINEL
|
||||
|
||||
# Count pending /queue follow-ups (slot + overflow).
|
||||
adapter = self.adapters.get(source.platform) if source else None
|
||||
queue_depth = self._queue_depth(session_key, adapter=adapter)
|
||||
|
||||
def _clean_str(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) and value.strip() else ""
|
||||
|
||||
def _int_value(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
title = None
|
||||
session_row: dict[str, Any] = {}
|
||||
# Pull token totals from the SQLite session DB rather than the
|
||||
# in-memory SessionStore. The agent's per-turn token deltas are
|
||||
# persisted into sessions_db (run_agent.py), not into SessionEntry,
|
||||
@@ -422,17 +437,92 @@ class GatewaySlashCommandsMixin:
|
||||
title = None
|
||||
try:
|
||||
row = self._session_db.get_session(session_entry.session_id)
|
||||
if row:
|
||||
if isinstance(row, dict):
|
||||
session_row = row
|
||||
db_total_tokens = (
|
||||
(row.get("input_tokens") or 0)
|
||||
+ (row.get("output_tokens") or 0)
|
||||
+ (row.get("cache_read_tokens") or 0)
|
||||
+ (row.get("cache_write_tokens") or 0)
|
||||
+ (row.get("reasoning_tokens") or 0)
|
||||
_int_value(row.get("input_tokens"))
|
||||
+ _int_value(row.get("output_tokens"))
|
||||
+ _int_value(row.get("cache_read_tokens"))
|
||||
+ _int_value(row.get("cache_write_tokens"))
|
||||
+ _int_value(row.get("reasoning_tokens"))
|
||||
)
|
||||
except Exception:
|
||||
db_total_tokens = 0
|
||||
|
||||
# Resolve model/context for cockpit-style status. Prefer the live or
|
||||
# cached agent because it carries the actual runtime route and context
|
||||
# compressor. Fall back to persisted SessionDB metadata plus the
|
||||
# SessionStore's last_prompt_tokens so /status remains useful between
|
||||
# turns without making billing/account calls.
|
||||
status_agent = agent if is_running else None
|
||||
if status_agent is None:
|
||||
cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
cache = getattr(self, "_agent_cache", None)
|
||||
if cache_lock is not None and cache is not None:
|
||||
try:
|
||||
with cache_lock:
|
||||
cached = cache.get(session_key)
|
||||
if cached:
|
||||
status_agent = cached[0]
|
||||
except Exception:
|
||||
status_agent = None
|
||||
|
||||
model_name = ""
|
||||
provider_name = ""
|
||||
base_url = ""
|
||||
context_used = 0
|
||||
context_total = 0
|
||||
if status_agent is not None and status_agent is not _AGENT_PENDING_SENTINEL:
|
||||
model_name = _clean_str(getattr(status_agent, "model", ""))
|
||||
provider_name = _clean_str(getattr(status_agent, "provider", ""))
|
||||
base_url = _clean_str(getattr(status_agent, "base_url", ""))
|
||||
ctx = getattr(status_agent, "context_compressor", None)
|
||||
if ctx is not None:
|
||||
context_used = _int_value(getattr(ctx, "last_prompt_tokens", 0))
|
||||
context_total = _int_value(getattr(ctx, "context_length", 0))
|
||||
|
||||
model_name = model_name or _clean_str(session_row.get("model"))
|
||||
provider_name = provider_name or _clean_str(session_row.get("billing_provider"))
|
||||
base_url = base_url or _clean_str(session_row.get("billing_base_url"))
|
||||
context_used = context_used or _int_value(getattr(session_entry, "last_prompt_tokens", 0))
|
||||
|
||||
user_config: dict[str, Any] = {}
|
||||
if not model_name or not provider_name or not context_total:
|
||||
try:
|
||||
user_config = _load_gateway_config()
|
||||
except Exception:
|
||||
user_config = {}
|
||||
if not model_name:
|
||||
model_name = _resolve_gateway_model(user_config)
|
||||
if not provider_name:
|
||||
model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
|
||||
if isinstance(model_cfg, dict):
|
||||
provider_name = _clean_str(model_cfg.get("provider"))
|
||||
if not context_total:
|
||||
model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
|
||||
configured_context = model_cfg.get("context_length") if isinstance(model_cfg, dict) else None
|
||||
if isinstance(configured_context, int) and configured_context > 0:
|
||||
context_total = configured_context
|
||||
|
||||
model_line = ""
|
||||
if model_name:
|
||||
if provider_name:
|
||||
model_line = t("gateway.status.model_provider", model=model_name, provider=provider_name)
|
||||
else:
|
||||
model_line = t("gateway.status.model", model=model_name)
|
||||
|
||||
context_line = ""
|
||||
if context_total:
|
||||
pct = min(100, round((context_used / context_total) * 100)) if context_total else 0
|
||||
context_line = t(
|
||||
"gateway.status.context",
|
||||
used=f"{context_used:,}",
|
||||
total=f"{context_total:,}",
|
||||
pct=f"{pct}",
|
||||
)
|
||||
elif context_used:
|
||||
context_line = t("gateway.status.context_used", used=f"{context_used:,}")
|
||||
|
||||
lines = [
|
||||
t("gateway.status.header"),
|
||||
"",
|
||||
@@ -443,6 +533,12 @@ class GatewaySlashCommandsMixin:
|
||||
lines.extend([
|
||||
t("gateway.status.created", timestamp=session_entry.created_at.strftime('%Y-%m-%d %H:%M')),
|
||||
t("gateway.status.last_activity", timestamp=session_entry.updated_at.strftime('%Y-%m-%d %H:%M')),
|
||||
])
|
||||
if model_line:
|
||||
lines.append(model_line)
|
||||
if context_line:
|
||||
lines.append(context_line)
|
||||
lines.extend([
|
||||
t("gateway.status.tokens", tokens=f"{db_total_tokens:,}"),
|
||||
t("gateway.status.agent_running", state=t("gateway.status.state_yes") if is_running else t("gateway.status.state_no")),
|
||||
])
|
||||
@@ -2845,6 +2941,52 @@ class GatewaySlashCommandsMixin:
|
||||
return t("gateway.resume.resumed_one", title=title, count=msg_count)
|
||||
return t("gateway.resume.resumed_many", title=title, count=msg_count)
|
||||
|
||||
async def _handle_sessions_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /sessions — list previous sessions for gateway chats."""
|
||||
if not self._session_db:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
|
||||
|
||||
from hermes_cli.session_listing import (
|
||||
format_gateway_session_listing,
|
||||
parse_session_listing_args,
|
||||
query_session_listing,
|
||||
)
|
||||
|
||||
source = event.source
|
||||
raw_args = event.get_command_args().strip()
|
||||
try:
|
||||
include_all, include_unnamed, target = parse_session_listing_args(raw_args)
|
||||
except ValueError as exc:
|
||||
return t("gateway.resume.parse_error", error=exc)
|
||||
|
||||
if target:
|
||||
resume_event = dataclasses.replace(event, text=f"/resume {target}")
|
||||
return await self._handle_resume_command(resume_event)
|
||||
|
||||
current_entry = self.session_store.get_or_create_session(source)
|
||||
rows = query_session_listing(
|
||||
self._session_db,
|
||||
source=source.platform.value if source.platform else None,
|
||||
current_session_id=current_entry.session_id,
|
||||
include_all_sources=include_all,
|
||||
include_unnamed=include_unnamed,
|
||||
limit=10,
|
||||
exclude_sources=["tool"],
|
||||
)
|
||||
if source.platform == Platform.MATRIX and not include_all:
|
||||
rows = [
|
||||
row for row in rows
|
||||
if self._same_matrix_room(
|
||||
source, self._gateway_session_origin_for_id(str(row.get("id") or ""))
|
||||
)
|
||||
]
|
||||
return format_gateway_session_listing(
|
||||
rows,
|
||||
include_source=include_all,
|
||||
title="Sessions" if include_unnamed else "Named Sessions",
|
||||
)
|
||||
|
||||
async def _handle_branch_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /branch [name] — fork the current session into a new independent copy.
|
||||
|
||||
|
||||
@@ -643,6 +643,21 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str,
|
||||
live_cmdline = _read_process_cmdline(existing_pid)
|
||||
if live_cmdline is not None or not _record_looks_like_gateway(existing):
|
||||
stale = True
|
||||
# Secondary defence against boot-time PID+start_time collisions:
|
||||
# systemd spawns core services deterministically, so an unrelated
|
||||
# process (e.g. cron) can land on the exact same PID and jiffy
|
||||
# count as a previous gateway. If both start_times are known and
|
||||
# match but the live process is not a gateway, and we can confirm
|
||||
# that by reading its cmdline, the lock is stale.
|
||||
if (
|
||||
not stale
|
||||
and existing.get("start_time") is not None
|
||||
and current_start is not None
|
||||
and not _looks_like_gateway_process(existing_pid)
|
||||
):
|
||||
live_cmdline = _read_process_cmdline(existing_pid)
|
||||
if live_cmdline is not None:
|
||||
stale = True
|
||||
# Check if process is stopped (Ctrl+Z / SIGTSTP) — stopped
|
||||
# processes still appear alive to _pid_exists but are not
|
||||
# actually running. Treat them as stale so --replace works.
|
||||
|
||||
+152
-22
@@ -143,6 +143,13 @@ class GatewayStreamConsumer:
|
||||
# timestamps would be stale by completion time. Ported from
|
||||
# openclaw/openclaw#72038.
|
||||
self._message_created_ts: Optional[float] = None
|
||||
# Every real preview message id the consumer has put on screen during
|
||||
# this response (first send + any continuation messages from oversized
|
||||
# edits/sends). The fresh-final path deletes all of them when it
|
||||
# re-delivers the completed answer as a single (rich) message, so a
|
||||
# reply that was split across the platform's edit limit while streaming
|
||||
# doesn't leave stale fragments above the final message.
|
||||
self._preview_message_ids: "set[str]" = set()
|
||||
self._already_sent = False
|
||||
self._edit_supported = True # Disabled when progressive edits are no longer usable
|
||||
self._last_edit_time = 0.0
|
||||
@@ -420,7 +427,10 @@ class GatewayStreamConsumer:
|
||||
if isinstance(self.adapter, _BasePlatformAdapter)
|
||||
else len
|
||||
)
|
||||
_raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
|
||||
# Rich-capable adapters (Telegram rich messages) raise this above the
|
||||
# legacy per-message limit so a reply that fits one rich send/draft
|
||||
# isn't fragmented at 4096 while streaming. See _raw_message_limit.
|
||||
_raw_limit = self._raw_message_limit()
|
||||
_safe_limit = max(500, _raw_limit - _len_fn(self.cfg.cursor) - 100)
|
||||
|
||||
# Resolve native draft streaming once per run. When enabled the
|
||||
@@ -589,9 +599,20 @@ class GatewayStreamConsumer:
|
||||
if self._accumulated:
|
||||
if self._fallback_final_send:
|
||||
await self._send_fallback_final(self._accumulated)
|
||||
elif current_update_visible and (
|
||||
not self._adapter_requires_finalize
|
||||
or self._last_edit_overflowed
|
||||
elif self._final_response_sent:
|
||||
# A finalize=True tick above already delivered the
|
||||
# final answer via the adapter's fresh-final path
|
||||
# (_try_fresh_final sent a fresh rich message and
|
||||
# deleted the preview). Running a second finalize
|
||||
# edit here would duplicate the message / re-delete,
|
||||
# so just record delivery and stop.
|
||||
self._final_content_delivered = True
|
||||
elif (
|
||||
current_update_visible
|
||||
and (
|
||||
not self._adapter_requires_finalize
|
||||
or self._last_edit_overflowed
|
||||
)
|
||||
):
|
||||
# Mid-stream edit above already delivered the
|
||||
# final accumulated content. Skip the redundant
|
||||
@@ -614,6 +635,15 @@ class GatewayStreamConsumer:
|
||||
)
|
||||
if self._final_response_sent:
|
||||
self._final_content_delivered = True
|
||||
elif self._fallback_final_send:
|
||||
# The final edit attempt itself may be the one
|
||||
# that exhausts flood-control strikes and
|
||||
# promotes the consumer into fallback mode. Do
|
||||
# not return to the gateway with a full-response
|
||||
# fallback still pending; send only the unsent
|
||||
# tail here so the normal gateway send path does
|
||||
# not duplicate the visible prefix.
|
||||
await self._send_fallback_final(self._accumulated)
|
||||
elif not self._already_sent:
|
||||
self._final_response_sent = await self._send_or_edit(self._accumulated)
|
||||
if self._final_response_sent:
|
||||
@@ -729,6 +759,9 @@ class GatewayStreamConsumer:
|
||||
return reply_to_id
|
||||
try:
|
||||
meta = dict(self.metadata) if self.metadata else {}
|
||||
# This chunk becomes the next edit target — adapters that support
|
||||
# rich final sends (Telegram) must keep it on the editable path.
|
||||
meta["expect_edits"] = True
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
@@ -737,6 +770,7 @@ class GatewayStreamConsumer:
|
||||
)
|
||||
if result.success and result.message_id:
|
||||
self._message_id = str(result.message_id)
|
||||
self._track_preview_ids_from_result(result)
|
||||
self._already_sent = True
|
||||
self._last_sent_text = text
|
||||
# Fresh content bubble — close off any stale tool bubble
|
||||
@@ -1114,6 +1148,76 @@ class GatewayStreamConsumer:
|
||||
age = time.monotonic() - self._message_created_ts
|
||||
return age >= threshold
|
||||
|
||||
def _raw_message_limit(self) -> int:
|
||||
"""Per-message length budget (in the adapter's ``message_len_fn`` units)
|
||||
before the consumer splits an overflowing reply.
|
||||
|
||||
Adapters with a richer send/draft path (e.g. Telegram rich messages)
|
||||
can raise this above ``MAX_MESSAGE_LENGTH`` via
|
||||
``streaming_overflow_limit`` so a reply that fits one rich message isn't
|
||||
fragmented at the legacy edit limit. Falls back to
|
||||
``MAX_MESSAGE_LENGTH`` (4096 default) for everyone else.
|
||||
"""
|
||||
base = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
|
||||
# isinstance gate: MagicMock adapters return mock objects (truthy, not
|
||||
# ints) for arbitrary attribute access — keep them on the base limit.
|
||||
if isinstance(self.adapter, _BasePlatformAdapter):
|
||||
try:
|
||||
cap = self.adapter.streaming_overflow_limit()
|
||||
except Exception as e:
|
||||
logger.debug("streaming_overflow_limit check failed: %s", e)
|
||||
cap = None
|
||||
if isinstance(cap, int) and cap > base:
|
||||
return cap
|
||||
return base
|
||||
|
||||
def _track_preview_id(self, message_id: Optional[str]) -> None:
|
||||
"""Record a real preview message id for fresh-final cleanup."""
|
||||
if message_id and message_id != "__no_edit__":
|
||||
self._preview_message_ids.add(str(message_id))
|
||||
|
||||
def _track_preview_ids_from_result(self, result: Any) -> None:
|
||||
"""Record every message id a send/edit result exposes: the primary id
|
||||
plus any continuation ids from an oversized split
|
||||
(``continuation_message_ids`` or ``raw_response['message_ids']``)."""
|
||||
self._track_preview_id(getattr(result, "message_id", None))
|
||||
for mid in (getattr(result, "continuation_message_ids", None) or ()):
|
||||
self._track_preview_id(mid)
|
||||
raw = getattr(result, "raw_response", None) or {}
|
||||
if isinstance(raw, dict):
|
||||
for mid in (raw.get("message_ids") or ()):
|
||||
self._track_preview_id(mid)
|
||||
|
||||
def _adapter_prefers_fresh_final(self, text: str) -> bool:
|
||||
"""Return True when the adapter would rather finalize a streamed reply
|
||||
by sending a fresh message and deleting the preview than by editing the
|
||||
preview in place — e.g. Telegram, whose ``sendRichMessage`` send path
|
||||
currently renders richer markdown than Hermes' MarkdownV2 edit path.
|
||||
|
||||
Returns False when there is no real preview to replace (no message id,
|
||||
or the ``__no_edit__`` sentinel), when the adapter doesn't expose the
|
||||
hook, or on any error (the consumer then keeps the edit-in-place path).
|
||||
"""
|
||||
if not self._message_id or self._message_id == "__no_edit__":
|
||||
return False
|
||||
fn = getattr(self.adapter, "prefers_fresh_final_streaming", None)
|
||||
if fn is None:
|
||||
return False
|
||||
try:
|
||||
try:
|
||||
result = fn(text, metadata=self.metadata)
|
||||
except TypeError:
|
||||
# Adapter / test double whose hook doesn't accept the metadata
|
||||
# keyword — fall back to the positional-only form.
|
||||
result = fn(text)
|
||||
except Exception as e:
|
||||
logger.debug("prefers_fresh_final_streaming check failed: %s", e)
|
||||
return False
|
||||
# ``is True`` (not ``bool(...)``) so a MagicMock adapter's auto-child
|
||||
# method — truthy by default in tests — does not wrongly enable the
|
||||
# fresh-final path. Mirrors the REQUIRES_EDIT_FINALIZE gate in __init__.
|
||||
return result is True
|
||||
|
||||
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
|
||||
"""Send ``text`` as a brand-new message (best-effort delete the old
|
||||
preview) so the platform's visible timestamp reflects completion
|
||||
@@ -1127,7 +1231,13 @@ class GatewayStreamConsumer:
|
||||
|
||||
Ported from openclaw/openclaw#72038.
|
||||
"""
|
||||
old_message_id = self._message_id
|
||||
# Every preview message the user has seen for this response: the
|
||||
# current one plus any continuation fragments tracked while streaming
|
||||
# (an oversized reply split across the platform's edit limit). All of
|
||||
# them are replaced by the single fresh message below.
|
||||
stale_ids = set(self._preview_message_ids)
|
||||
if self._message_id and self._message_id != "__no_edit__":
|
||||
stale_ids.add(self._message_id)
|
||||
try:
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
@@ -1139,25 +1249,29 @@ class GatewayStreamConsumer:
|
||||
return False
|
||||
if not getattr(result, "success", False):
|
||||
return False
|
||||
# Successful fresh send — try to delete the stale preview so the
|
||||
# user doesn't see the old edit-stuck message underneath. Cleanup
|
||||
# is best-effort; platforms that don't implement ``delete_message``
|
||||
# just leave the preview behind (still an acceptable outcome —
|
||||
# the visible final timestamp is the important part).
|
||||
if old_message_id and old_message_id != "__no_edit__":
|
||||
delete_fn = getattr(self.adapter, "delete_message", None)
|
||||
if delete_fn is not None:
|
||||
try:
|
||||
await delete_fn(self.chat_id, old_message_id)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Fresh-final preview cleanup failed (%s): %s",
|
||||
old_message_id, e,
|
||||
)
|
||||
# Adopt the new message id as the current message so subsequent
|
||||
# callers (e.g. overflow split loops, finalize retries) see a
|
||||
# consistent state.
|
||||
new_message_id = getattr(result, "message_id", None)
|
||||
# Successful fresh send — try to delete the stale preview(s) so the
|
||||
# user doesn't see the old edit-stuck message(s) underneath. Cleanup
|
||||
# is best-effort; platforms that don't implement ``delete_message``
|
||||
# just leave the preview behind (still an acceptable outcome — the
|
||||
# visible final timestamp is the important part). Never delete the
|
||||
# message we just sent.
|
||||
delete_fn = getattr(self.adapter, "delete_message", None)
|
||||
if delete_fn is not None:
|
||||
for stale_id in stale_ids:
|
||||
if not stale_id or stale_id == "__no_edit__" or stale_id == new_message_id:
|
||||
continue
|
||||
try:
|
||||
await delete_fn(self.chat_id, stale_id)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Fresh-final preview cleanup failed (%s): %s",
|
||||
stale_id, e,
|
||||
)
|
||||
self._preview_message_ids = set()
|
||||
if new_message_id:
|
||||
self._message_id = new_message_id
|
||||
self._message_created_ts = time.monotonic()
|
||||
@@ -1267,9 +1381,19 @@ class GatewayStreamConsumer:
|
||||
# old preview follows. Ported from
|
||||
# openclaw/openclaw#72038. Gated by config so the
|
||||
# legacy edit-in-place path stays the default.
|
||||
#
|
||||
# Adapters can also opt in regardless of the time threshold
|
||||
# via prefers_fresh_final_streaming (e.g. Telegram, whose
|
||||
# send path renders richer markdown than its edit path):
|
||||
# finalizing through edit would visibly downgrade a rich
|
||||
# preview, so re-deliver as a fresh message + delete the
|
||||
# preview instead.
|
||||
if (
|
||||
finalize
|
||||
and self._should_send_fresh_final()
|
||||
and (
|
||||
self._should_send_fresh_final()
|
||||
or self._adapter_prefers_fresh_final(text)
|
||||
)
|
||||
and await self._try_fresh_final(
|
||||
text, is_turn_final=is_turn_final,
|
||||
)
|
||||
@@ -1283,6 +1407,9 @@ class GatewayStreamConsumer:
|
||||
)
|
||||
if result.success:
|
||||
self._already_sent = True
|
||||
# Record any continuation fragments an oversized edit
|
||||
# split off, so fresh-final can clean them all up.
|
||||
self._track_preview_ids_from_result(result)
|
||||
# Adapter may have split-and-delivered an oversized
|
||||
# edit across the original message + N continuations.
|
||||
# When that happens, ``message_id`` is the LAST visible
|
||||
@@ -1405,7 +1532,7 @@ class GatewayStreamConsumer:
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
reply_to=self._initial_reply_to_id,
|
||||
metadata=self.metadata,
|
||||
metadata={**(self.metadata or {}), "expect_edits": True},
|
||||
)
|
||||
if result.success:
|
||||
if result.message_id:
|
||||
@@ -1414,6 +1541,9 @@ class GatewayStreamConsumer:
|
||||
# the user so fresh-final logic can detect stale
|
||||
# preview timestamps on long-running responses.
|
||||
self._message_created_ts = time.monotonic()
|
||||
# Record this (and any continuation fragments from an
|
||||
# oversized first send) for fresh-final cleanup.
|
||||
self._track_preview_ids_from_result(result)
|
||||
else:
|
||||
self._edit_supported = False
|
||||
self._already_sent = True
|
||||
|
||||
+130
-6
@@ -103,7 +103,12 @@ XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:acces
|
||||
XAI_OAUTH_REDIRECT_HOST = "127.0.0.1"
|
||||
XAI_OAUTH_REDIRECT_PORT = 56121
|
||||
XAI_OAUTH_REDIRECT_PATH = "/callback"
|
||||
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||
# xAI/Grok OAuth access tokens are intentionally short-lived (about 6h in
|
||||
# current SuperGrok flows). A two-minute refresh window is too narrow for
|
||||
# gateway/cron workloads that may only touch the provider every 30 minutes,
|
||||
# leaving brief but noisy credential-expiry gaps. Refresh up to one hour
|
||||
# early so ordinary runtime calls keep the token warm without user reauth.
|
||||
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 3600
|
||||
QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
|
||||
QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"
|
||||
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||
@@ -616,8 +621,8 @@ ZAI_ENDPOINTS = [
|
||||
# (id, base_url, probe_models, label)
|
||||
("global", "https://api.z.ai/api/paas/v4", ["glm-5"], "Global"),
|
||||
("cn", "https://open.bigmodel.cn/api/paas/v4", ["glm-5"], "China"),
|
||||
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
|
||||
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
|
||||
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
|
||||
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
|
||||
]
|
||||
|
||||
|
||||
@@ -1079,8 +1084,13 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
|
||||
return {"version": AUTH_STORE_VERSION, "providers": {}}
|
||||
|
||||
|
||||
def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
|
||||
auth_file = _auth_file_path()
|
||||
def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = None) -> Path:
|
||||
# target_path=None preserves the existing contract (write the active
|
||||
# store at _auth_file_path()). An explicit path lets callers persist a
|
||||
# specific store — e.g. the global-root write-through for rotating xAI
|
||||
# OAuth grants (#43589) — reusing this function's atomic O_EXCL + 0o600
|
||||
# write so the root auth.json gets the same TOCTOU-safe treatment.
|
||||
auth_file = target_path if target_path is not None else _auth_file_path()
|
||||
auth_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Tighten parent dir to 0o700 so siblings can't traverse to creds.
|
||||
# No-op on Windows (POSIX mode bits not enforced); ignore failures.
|
||||
@@ -3886,13 +3896,64 @@ def _pool_codex_access_token() -> str:
|
||||
# xAI Grok OAuth — tokens stored in ~/.hermes/auth.json
|
||||
# =============================================================================
|
||||
|
||||
def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Return usable xAI OAuth state from provider state or credential pool."""
|
||||
state = _load_provider_state(auth_store, "xai-oauth")
|
||||
tokens = state.get("tokens") if isinstance(state, dict) else None
|
||||
if isinstance(tokens, dict):
|
||||
access_token = str(tokens.get("access_token", "") or "").strip()
|
||||
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
|
||||
if access_token and refresh_token:
|
||||
return state
|
||||
|
||||
credential_pool = auth_store.get("credential_pool")
|
||||
entries = (
|
||||
credential_pool.get("xai-oauth")
|
||||
if isinstance(credential_pool, dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(entries, list):
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
access_token = str(entry.get("access_token", "") or "").strip()
|
||||
refresh_token = str(entry.get("refresh_token", "") or "").strip()
|
||||
if not access_token or not refresh_token:
|
||||
continue
|
||||
merged = dict(state or {})
|
||||
merged["tokens"] = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": str(entry.get("token_type") or "Bearer"),
|
||||
}
|
||||
if entry.get("last_refresh"):
|
||||
merged["last_refresh"] = entry.get("last_refresh")
|
||||
merged.setdefault("auth_mode", "oauth_pkce")
|
||||
return merged
|
||||
|
||||
return state if isinstance(state, dict) else None
|
||||
|
||||
|
||||
def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool:
|
||||
tokens = state.get("tokens") if isinstance(state, dict) else None
|
||||
return (
|
||||
isinstance(tokens, dict)
|
||||
and bool(str(tokens.get("access_token", "") or "").strip())
|
||||
and bool(str(tokens.get("refresh_token", "") or "").strip())
|
||||
)
|
||||
|
||||
|
||||
def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
||||
if _lock:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
else:
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, "xai-oauth")
|
||||
state = _xai_oauth_state_from_store(auth_store)
|
||||
if not _xai_oauth_state_has_usable_tokens(state):
|
||||
global_state = _xai_oauth_state_from_store(_load_global_auth_store())
|
||||
if _xai_oauth_state_has_usable_tokens(global_state):
|
||||
state = global_state
|
||||
if not state:
|
||||
raise AuthError(
|
||||
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
|
||||
@@ -3932,6 +3993,62 @@ def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _profile_has_own_xai_oauth_state(auth_store: Dict[str, Any]) -> bool:
|
||||
"""True when this store has its OWN ``providers.xai-oauth`` block.
|
||||
|
||||
Distinguishes a profile that genuinely shadows the root xAI grant from
|
||||
one that only *reads* root via ``_load_provider_state``'s fallback. Only
|
||||
the latter needs the refresh write-through below.
|
||||
"""
|
||||
providers = auth_store.get("providers")
|
||||
return isinstance(providers, dict) and isinstance(providers.get("xai-oauth"), dict)
|
||||
|
||||
|
||||
def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None:
|
||||
"""Persist a rotated xAI OAuth ``state`` into the global-root auth.json.
|
||||
|
||||
Best-effort write-through for the multi-profile rotation hazard (#43589):
|
||||
xAI rotates the refresh_token on every refresh, so when a profile session
|
||||
refreshes a grant it resolved from the root fallback, the rotated chain
|
||||
must land back in root. Otherwise root keeps a now-revoked refresh token
|
||||
and every other profile reading the stale root grant dies with
|
||||
``invalid_grant`` once its access token expires.
|
||||
|
||||
Only updates ``providers.xai-oauth`` in the root store; never touches the
|
||||
profile store (the caller already saved that). Swallows all errors — a
|
||||
failed write-through degrades to the pre-existing behavior (root stale),
|
||||
it must never break the profile's own successful save.
|
||||
"""
|
||||
global_path = _global_auth_file_path()
|
||||
if global_path is None:
|
||||
# Classic mode (profile == root); the profile save already hit root.
|
||||
return
|
||||
# Seat belt: under pytest, refuse to write the real user's
|
||||
# ~/.hermes/auth.json even when HERMES_HOME points at a profile path
|
||||
# (mirrors the read-side guard in _load_global_auth_store). Uses the
|
||||
# unmodified HOME env, not Path.home() which fixtures may monkeypatch.
|
||||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
real_home_env = os.environ.get("HOME", "")
|
||||
if real_home_env:
|
||||
real_root = Path(real_home_env) / ".hermes" / "auth.json"
|
||||
try:
|
||||
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
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, "xai-oauth", dict(state), set_active=False)
|
||||
_save_auth_store(global_store, global_path)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.debug("xAI OAuth: write-through to global root failed: %s", exc)
|
||||
|
||||
|
||||
def _save_xai_oauth_tokens(
|
||||
tokens: Dict[str, Any],
|
||||
*,
|
||||
@@ -3943,6 +4060,11 @@ def _save_xai_oauth_tokens(
|
||||
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
# A profile that lacks its own xai-oauth block is reading the root
|
||||
# grant through _load_provider_state's fallback. When such a profile
|
||||
# refreshes the (rotating) grant, we must write the rotated chain back
|
||||
# to root too, or root is left holding a revoked refresh token (#43589).
|
||||
write_through_to_root = not _profile_has_own_xai_oauth_state(auth_store)
|
||||
state = _load_provider_state(auth_store, "xai-oauth") or {}
|
||||
state["tokens"] = tokens
|
||||
state["last_refresh"] = last_refresh
|
||||
@@ -3953,6 +4075,8 @@ def _save_xai_oauth_tokens(
|
||||
state["redirect_uri"] = redirect_uri
|
||||
_save_provider_state(auth_store, "xai-oauth", state)
|
||||
_save_auth_store(auth_store)
|
||||
if write_through_to_root:
|
||||
_write_through_xai_oauth_to_global_root(state)
|
||||
|
||||
|
||||
def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:
|
||||
|
||||
@@ -510,6 +510,7 @@ _QUICK_STATE_FILES = (
|
||||
"cron/jobs.json",
|
||||
"gateway_state.json",
|
||||
"channel_directory.json",
|
||||
"channel_aliases.json",
|
||||
"processes.json",
|
||||
# Pairing stores (generic + per-platform JSONs outside state.db)
|
||||
"pairing", # legacy location (gateway/pairing.py)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user