Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f93a5c03be | |||
| 61b502853f | |||
| 5385b7573d | |||
| bc7b84f575 | |||
| f3d958f482 | |||
| dff5dc34ce | |||
| 4532182bda | |||
| dbbd5512d5 |
@@ -244,15 +244,6 @@ BROWSERBASE_PROXIES=true
|
||||
# Uses custom Chromium build to avoid bot detection altogether
|
||||
BROWSERBASE_ADVANCED_STEALTH=false
|
||||
|
||||
# Browser engine for local mode (default: auto = Chrome)
|
||||
# "auto" — use Chrome (don't pass --engine flag)
|
||||
# "lightpanda" — use Lightpanda (1.3-5.8x faster navigation, no screenshots)
|
||||
# "chrome" — explicitly request Chrome
|
||||
# Requires agent-browser v0.25.3+. Lightpanda commands that fail or return
|
||||
# empty results are automatically retried with Chrome.
|
||||
# Also configurable via browser.engine in config.yaml.
|
||||
# AGENT_BROWSER_ENGINE=auto
|
||||
|
||||
# Browser session timeout in seconds (default: 300)
|
||||
# Sessions are cleaned up after this duration of inactivity
|
||||
BROWSER_SESSION_TIMEOUT=300
|
||||
@@ -423,24 +414,3 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# TEAMS_HOME_CHANNEL= # Default channel/chat ID for cron delivery
|
||||
# TEAMS_HOME_CHANNEL_NAME= # Display name for the home channel
|
||||
# TEAMS_PORT=3978 # Webhook listen port (Bot Framework default)
|
||||
|
||||
# =============================================================================
|
||||
# GOOGLE CHAT INTEGRATION
|
||||
# =============================================================================
|
||||
# Connects via Cloud Pub/Sub pull subscription (no public URL required).
|
||||
# Setup walkthrough: website/docs/user-guide/messaging/google_chat.md.
|
||||
# 1. Create a GCP project, enable the Google Chat API and Cloud Pub/Sub.
|
||||
# 2. Create a Service Account with roles/pubsub.subscriber on the
|
||||
# subscription (NOT project-wide); download the JSON key.
|
||||
# 3. Configure your Chat app at console.cloud.google.com/apis/credentials
|
||||
# → Google Chat API → Configuration → Cloud Pub/Sub topic.
|
||||
# 4. (Optional, for native attachment delivery) Each user runs
|
||||
# `/setup-files` once in their own DM after Pub/Sub is wired up.
|
||||
#
|
||||
# GOOGLE_CHAT_PROJECT_ID= # GCP project hosting the topic (or set GOOGLE_CLOUD_PROJECT)
|
||||
# GOOGLE_CHAT_SUBSCRIPTION_NAME= # Full path: projects/<id>/subscriptions/<name>
|
||||
# GOOGLE_CHAT_SERVICE_ACCOUNT_JSON= # Path to SA JSON (or set GOOGLE_APPLICATION_CREDENTIALS)
|
||||
# GOOGLE_CHAT_ALLOWED_USERS= # Comma-separated emails allowed to talk to the bot
|
||||
# GOOGLE_CHAT_ALLOW_ALL_USERS=false # Set true to skip the allowlist
|
||||
# GOOGLE_CHAT_HOME_CHANNEL= # Default space (spaces/XXXX) for cron delivery
|
||||
# GOOGLE_CHAT_HOME_CHANNEL_NAME= # Display name for the home channel
|
||||
|
||||
@@ -16,13 +16,9 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Top-level concurrency: do NOT cancel in-flight builds when a new push lands.
|
||||
# Every commit deserves its own SHA-tagged image in the registry, and we guard
|
||||
# the :latest tag in a separate job below (with its own concurrency group) so
|
||||
# a slow run can't clobber :latest with older bits.
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -30,18 +26,11 @@ jobs:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
outputs:
|
||||
pushed_sha_tag: ${{ steps.mark_pushed.outputs.pushed }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
# Fetch enough history to run `git merge-base --is-ancestor` in the
|
||||
# move-latest job. That job reuses this checkout via its own
|
||||
# actions/checkout call, but commits reachable from main up to ~1000
|
||||
# back are plenty for any realistic race window.
|
||||
fetch-depth: 1000
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
@@ -65,31 +54,19 @@ jobs:
|
||||
|
||||
- name: Test image starts
|
||||
run: |
|
||||
mkdir -p /tmp/hermes-test
|
||||
sudo chown -R 10000:10000 /tmp/hermes-test
|
||||
# The image runs as the hermes user (UID 10000). GitHub Actions
|
||||
# creates /tmp/hermes-test root-owned by default, which hermes
|
||||
# can't write to — chown it to match the in-container UID before
|
||||
# bind-mounting. Real users doing `docker run -v ~/.hermes:...`
|
||||
# with their own UID hit the same issue and have their own
|
||||
# remediations (HERMES_UID env var, or chown locally).
|
||||
mkdir -p /tmp/hermes-test
|
||||
sudo chown -R 10000:10000 /tmp/hermes-test
|
||||
docker run --rm \
|
||||
-v /tmp/hermes-test:/opt/data \
|
||||
--entrypoint /opt/hermes/docker/entrypoint.sh \
|
||||
nousresearch/hermes-agent:test --help
|
||||
|
||||
- name: Test dashboard subcommand
|
||||
run: |
|
||||
mkdir -p /tmp/hermes-test
|
||||
sudo chown -R 10000:10000 /tmp/hermes-test
|
||||
# Verify the dashboard subcommand is included in the Docker image.
|
||||
# This prevents regressions like #9153 where the dashboard command
|
||||
# was present in source but missing from the published image.
|
||||
docker run --rm \
|
||||
-v /tmp/hermes-test:/opt/data \
|
||||
--entrypoint /opt/hermes/docker/entrypoint.sh \
|
||||
nousresearch/hermes-agent:test dashboard --help
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
@@ -97,12 +74,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Always push a per-commit SHA tag on main. This is race-free because
|
||||
# every commit has a unique SHA — concurrent runs can't clobber each
|
||||
# other here. We also embed the git SHA as an OCI label so the
|
||||
# move-latest job (below) can read it back off the registry's `:latest`.
|
||||
- name: Push multi-arch image with SHA tag (main branch)
|
||||
id: push_sha
|
||||
- name: Push multi-arch image (main branch)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
@@ -110,17 +82,10 @@ jobs:
|
||||
file: Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: nousresearch/hermes-agent:sha-${{ github.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
tags: nousresearch/hermes-agent:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Mark SHA tag pushed
|
||||
id: mark_pushed
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: echo "pushed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Push multi-arch image (release)
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
@@ -132,119 +97,3 @@ jobs:
|
||||
tags: nousresearch/hermes-agent:${{ github.event.release.tag_name }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Second job: moves `:latest` to point at the SHA tag the first job pushed.
|
||||
#
|
||||
# Has its own concurrency group with `cancel-in-progress: true`, which
|
||||
# gives us the serialization we need: if a newer push arrives while an
|
||||
# older run is mid-way through this job, the older run is cancelled
|
||||
# before it can clobber `:latest`. Combined with the ancestor check
|
||||
# below, this means `:latest` only ever moves forward in git history.
|
||||
move-latest:
|
||||
if: |
|
||||
github.repository == 'NousResearch/hermes-agent'
|
||||
&& github.event_name == 'push'
|
||||
&& github.ref == 'refs/heads/main'
|
||||
&& needs.build-and-push.outputs.pushed_sha_tag == 'true'
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: docker-move-latest-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 1000
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Read the git revision label off the current `:latest` manifest, then
|
||||
# use `git merge-base --is-ancestor` to check whether our commit is a
|
||||
# descendant of it. If `:latest` doesn't exist yet, or its label is
|
||||
# missing, we treat that as "safe to publish". If another run already
|
||||
# advanced `:latest` past us (or diverged), we skip and leave it alone.
|
||||
- name: Decide whether to move :latest
|
||||
id: latest_check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=nousresearch/hermes-agent
|
||||
|
||||
# Pull the JSON for the linux/amd64 sub-manifest's config and extract
|
||||
# the OCI revision label with jq — Go template field access can't
|
||||
# handle dots in map keys, so using json+jq is the robust route.
|
||||
image_json=$(
|
||||
docker buildx imagetools inspect "${image}:latest" \
|
||||
--format '{{ json (index .Image "linux/amd64") }}' \
|
||||
2>/dev/null || true
|
||||
)
|
||||
|
||||
if [ -z "${image_json}" ]; then
|
||||
echo "No existing :latest (or inspect failed) — safe to publish."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_sha=$(
|
||||
printf '%s' "${image_json}" \
|
||||
| jq -r '.config.Labels."org.opencontainers.image.revision" // ""'
|
||||
)
|
||||
|
||||
if [ -z "${current_sha}" ]; then
|
||||
echo "Registry :latest has no revision label — safe to publish."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Registry :latest is at ${current_sha}"
|
||||
echo "This run is at ${GITHUB_SHA}"
|
||||
|
||||
if [ "${current_sha}" = "${GITHUB_SHA}" ]; then
|
||||
echo ":latest already points at our SHA — nothing to do."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Make sure we have the :latest commit locally for merge-base.
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
git fetch --no-tags --prune origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
|| true
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
echo "Registry :latest points at an unknown commit (${current_sha}); refusing to overwrite."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Our SHA must be a descendant of the current :latest to be safe.
|
||||
if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then
|
||||
echo "Our commit is a descendant of :latest — safe to advance."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Another run advanced :latest past us (or diverged) — leaving it alone."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Retag the already-pushed SHA manifest as :latest. This is a registry-
|
||||
# side operation — no rebuild, no layer re-push — so it's quick and
|
||||
# atomic per-tag. The ancestor check above plus the cancel-in-progress
|
||||
# concurrency on this job together guarantee we only ever move :latest
|
||||
# forward in git history.
|
||||
- name: Move :latest to this SHA
|
||||
if: steps.latest_check.outputs.push_latest == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=nousresearch/hermes-agent
|
||||
docker buildx imagetools create \
|
||||
--tag "${image}:latest" \
|
||||
"${image}:sha-${GITHUB_SHA}"
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
name: Lint (ruff + ty)
|
||||
|
||||
# Surface ruff and ty diagnostics as a diff vs the target branch.
|
||||
# This check is advisory only ATM it always exits zero and never blocks merge.
|
||||
# It posts a Markdown summary to the workflow run and, for pull requests,
|
||||
# comments the same summary on the PR.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "website/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "website/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed to post/update PR comments
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint-diff:
|
||||
name: ruff + ty diff
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0 # need full history for merge-base + worktree
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Install ruff + ty
|
||||
run: |
|
||||
uv tool install ruff
|
||||
uv tool install ty
|
||||
|
||||
- name: Determine base ref
|
||||
id: base
|
||||
run: |
|
||||
# For PRs, diff against the merge base with the target branch.
|
||||
# For pushes to main, diff against the previous commit on main.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
||||
BASE_REF="origin/${{ github.base_ref }}"
|
||||
else
|
||||
BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD)
|
||||
BASE_REF="HEAD~1"
|
||||
fi
|
||||
echo "sha=${BASE_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=${BASE_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "Base SHA: ${BASE_SHA}"
|
||||
echo "Base ref: ${BASE_REF}"
|
||||
|
||||
- name: Run ruff + ty on HEAD
|
||||
run: |
|
||||
mkdir -p .lint-reports/head
|
||||
ruff check --output-format json --exit-zero \
|
||||
> .lint-reports/head/ruff.json || true
|
||||
ty check --output-format gitlab --exit-zero \
|
||||
> .lint-reports/head/ty.json || true
|
||||
echo "HEAD ruff: $(wc -c < .lint-reports/head/ruff.json) bytes"
|
||||
echo "HEAD ty: $(wc -c < .lint-reports/head/ty.json) bytes"
|
||||
|
||||
- name: Run ruff + ty on base (via git worktree)
|
||||
run: |
|
||||
mkdir -p .lint-reports/base
|
||||
# Use a worktree so we don't clobber the main checkout. If the basex
|
||||
# SHA is identical to HEAD (e.g. first commit), skip and leave the
|
||||
# base reports empty — the diff script handles missing files.
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
BASE_SHA="${{ steps.base.outputs.sha }}"
|
||||
if [ "$BASE_SHA" = "$HEAD_SHA" ]; then
|
||||
echo "Base SHA == HEAD SHA, skipping base scan."
|
||||
echo '[]' > .lint-reports/base/ruff.json
|
||||
echo '[]' > .lint-reports/base/ty.json
|
||||
else
|
||||
git worktree add --detach /tmp/lint-base "$BASE_SHA"
|
||||
(
|
||||
cd /tmp/lint-base
|
||||
ruff check --output-format json --exit-zero \
|
||||
> "$GITHUB_WORKSPACE/.lint-reports/base/ruff.json" || true
|
||||
ty check --output-format gitlab --exit-zero \
|
||||
> "$GITHUB_WORKSPACE/.lint-reports/base/ty.json" || true
|
||||
)
|
||||
git worktree remove --force /tmp/lint-base
|
||||
fi
|
||||
echo "base ruff: $(wc -c < .lint-reports/base/ruff.json) bytes"
|
||||
echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes"
|
||||
|
||||
- name: Generate diff summary
|
||||
run: |
|
||||
python scripts/lint_diff.py \
|
||||
--base-ruff .lint-reports/base/ruff.json \
|
||||
--head-ruff .lint-reports/head/ruff.json \
|
||||
--base-ty .lint-reports/base/ty.json \
|
||||
--head-ty .lint-reports/head/ty.json \
|
||||
--base-ref "${{ steps.base.outputs.ref }}" \
|
||||
--head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
|
||||
--output .lint-reports/summary.md
|
||||
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload reports as artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: lint-reports
|
||||
path: .lint-reports/
|
||||
retention-days: 14
|
||||
|
||||
- name: Post / update PR comment
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const body = fs.readFileSync('.lint-reports/summary.md', 'utf8');
|
||||
const marker = '<!-- lint-diff-summary -->';
|
||||
const fullBody = marker + '\n' + body;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: fullBody,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: fullBody,
|
||||
});
|
||||
}
|
||||
@@ -42,7 +42,6 @@ hermes-agent/
|
||||
├── plugins/ # Plugin system (see "Plugins" section below)
|
||||
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
|
||||
│ ├── context_engine/ # Context-engine plugins
|
||||
│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
|
||||
│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
|
||||
│ ├── hermes-achievements/ # Gamified achievement tracking
|
||||
│ ├── observability/ # Metrics / traces / logs plugin
|
||||
@@ -513,31 +512,6 @@ generic plugin surface (new hook, new ctx method) — never hardcode
|
||||
plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
|
||||
honcho argparse from `main.py` for exactly this reason.
|
||||
|
||||
### Model-provider plugins (`plugins/model-providers/<name>/`)
|
||||
|
||||
Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …)
|
||||
ships as a plugin here. Each plugin's `__init__.py` calls
|
||||
`providers.register_provider(ProviderProfile(...))` at module load.
|
||||
`providers/__init__.py._discover_providers()` is a **lazy, separate
|
||||
discovery system** — scanned on first `get_provider_profile()` or
|
||||
`list_providers()` call, NOT by the general PluginManager.
|
||||
|
||||
Scan order:
|
||||
1. Bundled: `<repo>/plugins/model-providers/<name>/`
|
||||
2. User: `$HERMES_HOME/plugins/model-providers/<name>/`
|
||||
3. Legacy: `<repo>/providers/<name>.py` (back-compat)
|
||||
|
||||
User plugins of the same name override bundled ones — `register_provider()`
|
||||
is last-writer-wins. This lets third parties swap out any built-in
|
||||
profile without a repo patch.
|
||||
|
||||
The general PluginManager records `kind: model-provider` manifests but does
|
||||
NOT import them (would double-instantiate `ProviderProfile`). Plugins
|
||||
without an explicit `kind:` get auto-coerced via a source-text heuristic
|
||||
(`register_provider` + `ProviderProfile` in `__init__.py`).
|
||||
|
||||
Full authoring guide: `website/docs/developer-guide/model-provider-plugin.md`.
|
||||
|
||||
### Dashboard / context-engine / image-gen plugin directories
|
||||
|
||||
`plugins/context_engine/`, `plugins/image_gen/`, `plugins/example-dashboard/`,
|
||||
|
||||
+9
-16
@@ -106,11 +106,6 @@ hermes chat -q "Hello"
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
# Preferred — matches CI (hermetic env, 4 xdist workers); see AGENTS.md
|
||||
scripts/run_tests.sh
|
||||
|
||||
# Alternative (activate the venv first). The wrapper is still recommended
|
||||
# for parity with GitHub Actions before you open a PR:
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
@@ -291,18 +286,16 @@ registry.register(
|
||||
)
|
||||
```
|
||||
|
||||
**Wire into a toolset (required):** Built-in tools are auto-discovered: any
|
||||
`tools/*.py` file that contains a top-level `registry.register(...)` call is
|
||||
imported by `discover_builtin_tools()` in `tools/registry.py` when `model_tools`
|
||||
loads. There is **no** manual import list in `model_tools.py` to maintain.
|
||||
Then add the import to `model_tools.py` in the `_modules` list:
|
||||
|
||||
You must still add the tool name to the appropriate list in `toolsets.py`
|
||||
(for example `_HERMES_CORE_TOOLS` or a dedicated toolset); otherwise the tool
|
||||
registers but is never exposed to the agent. If you introduce a new toolset,
|
||||
add it in `toolsets.py` and wire it into the relevant platform presets.
|
||||
```python
|
||||
_modules = [
|
||||
# ... existing modules ...
|
||||
"tools.my_tool",
|
||||
]
|
||||
```
|
||||
|
||||
See `AGENTS.md` (section **Adding New Tools**) for profile-aware paths and
|
||||
plugin vs core guidance.
|
||||
If it's a new toolset, add it to `toolsets.py` and to the relevant platform presets.
|
||||
|
||||
---
|
||||
|
||||
@@ -602,7 +595,7 @@ refactor/description # Code restructuring
|
||||
|
||||
### Before submitting
|
||||
|
||||
1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI) or `pytest tests/ -v` with the project venv activated
|
||||
1. **Run tests**: `pytest tests/ -v`
|
||||
2. **Test manually**: Run `hermes` and exercise the code path you changed
|
||||
3. **Check cross-platform impact**: If you touch file I/O, process management, or terminal handling, consider macOS, Linux, and WSL2
|
||||
4. **Keep PRs focused**: One logical change per PR. Don't mix a bug fix with a refactor with a new feature.
|
||||
|
||||
+1
-7
@@ -66,14 +66,8 @@ RUN cd web && npm run build && \
|
||||
# ---------- Permissions ----------
|
||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||
# The venv needs to be traversable too.
|
||||
# node_modules trees additionally need to be writable by the hermes user
|
||||
# so the runtime `npm install` triggered by _tui_need_npm_install() in
|
||||
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
|
||||
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
|
||||
# not chowned here.
|
||||
USER root
|
||||
RUN chmod -R a+rX /opt/hermes && \
|
||||
chown -R hermes:hermes /opt/hermes/ui-tui /opt/hermes/node_modules
|
||||
RUN chmod -R a+rX /opt/hermes
|
||||
# Start as root so the entrypoint can usermod/groupmod + gosu.
|
||||
# If HERMES_UID is unset, the entrypoint drops to the default hermes user (10000).
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
||||
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
|
||||
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
|
||||
</p>
|
||||
|
||||
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
|
||||
@@ -22,7 +21,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
|
||||
<tr><td><b>A closed learning loop</b></td><td>Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. <a href="https://github.com/plastic-labs/honcho">Honcho</a> dialectic user modeling. Compatible with the <a href="https://agentskills.io">agentskills.io</a> open standard.</td></tr>
|
||||
<tr><td><b>Scheduled automations</b></td><td>Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended.</td></tr>
|
||||
<tr><td><b>Delegates and parallelizes</b></td><td>Spawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns.</td></tr>
|
||||
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Seven terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster.</td></tr>
|
||||
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Six terminal backends — local, Docker, SSH, Daytona, Singularity, and Modal. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster.</td></tr>
|
||||
<tr><td><b>Research-ready</b></td><td>Batch trajectory generation, Atropos RL environments, trajectory compression for training the next generation of tool-calling models.</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -155,13 +154,13 @@ Manual path (equivalent to the above):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
uv venv venv --python 3.11
|
||||
source venv/bin/activate
|
||||
uv pip install -e ".[all,dev]"
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
> **RL Training (optional):** The RL/Atropos integration (`environments/`) — see [`CONTRIBUTING.md`](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#development-setup) for the full setup.
|
||||
> **RL Training (optional):** The RL/Atropos integration (`environments/`) ships via the `atroposlib` and `tinker` dependencies pulled in by `.[all,dev]` — no submodule setup required.
|
||||
|
||||
---
|
||||
|
||||
|
||||
-186
@@ -1,186 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="assets/banner.png" alt="Hermes Agent" width="100%">
|
||||
</p>
|
||||
|
||||
# Hermes Agent ☤
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
|
||||
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
||||
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
|
||||
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=for-the-badge" alt="English"></a>
|
||||
</p>
|
||||
|
||||
**由 [Nous Research](https://nousresearch.com) 构建的自进化 AI 代理。** 它是唯一内置学习闭环的智能代理——从经验中创建技能,在使用中改进技能,主动持久化知识,搜索过往对话,并在跨会话中逐步构建对你的深度理解。可以在 $5 的 VPS 上运行,也可以在 GPU 集群上运行,或者使用几乎零成本的 Serverless 基础设施。它不绑定你的笔记本——你可以在 Telegram 上与它对话,而它在云端 VM 上工作。
|
||||
|
||||
支持任意模型——[Nous Portal](https://portal.nousresearch.com)、[OpenRouter](https://openrouter.ai)(200+ 模型)、[NVIDIA NIM](https://build.nvidia.com)(Nemotron)、[小米 MiMo](https://platform.xiaomimimo.com)、[z.ai/GLM](https://z.ai)、[Kimi/Moonshot](https://platform.moonshot.ai)、[MiniMax](https://www.minimax.io)、[Hugging Face](https://huggingface.co)、OpenAI,或自定义端点。使用 `hermes model` 即可切换——无需改代码,无锁定。
|
||||
|
||||
<table>
|
||||
<tr><td><b>真正的终端界面</b></td><td>完整的 TUI,支持多行编辑、斜杠命令自动补全、对话历史、中断重定向和流式工具输出。</td></tr>
|
||||
<tr><td><b>随你所在</b></td><td>Telegram、Discord、Slack、WhatsApp、Signal 和 CLI——全部从单个网关进程运行。语音备忘录转写、跨平台对话连续性。</td></tr>
|
||||
<tr><td><b>闭环学习</b></td><td>代理管理记忆并定期自我提醒。复杂任务后自动创建技能。技能在使用中自我改进。FTS5 会话搜索配合 LLM 摘要实现跨会话回溯。<a href="https://github.com/plastic-labs/honcho">Honcho</a> 辩证式用户建模。兼容 <a href="https://agentskills.io">agentskills.io</a> 开放标准。</td></tr>
|
||||
<tr><td><b>定时自动化</b></td><td>内置 cron 调度器,支持向任何平台投递。日报、夜间备份、周审计——全部用自然语言描述,无人值守运行。</td></tr>
|
||||
<tr><td><b>委派与并行</b></td><td>生成隔离子代理处理并行工作流。编写 Python 脚本通过 RPC 调用工具,将多步管道压缩为零上下文开销的轮次。</td></tr>
|
||||
<tr><td><b>随处运行</b></td><td>六种终端后端——本地、Docker、SSH、Daytona、Singularity 和 Modal。Daytona 和 Modal 提供 Serverless 持久化——代理环境空闲时休眠、按需唤醒,空闲期间几乎零成本。$5 VPS 或 GPU 集群都能跑。</td></tr>
|
||||
<tr><td><b>研究就绪</b></td><td>批量轨迹生成、Atropos RL 环境、轨迹压缩——用于训练下一代工具调用模型。</td></tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 快速安装
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。
|
||||
|
||||
> **Android / Termux:** 已测试的手动安装路径请参考 [Termux 指南](https://hermes-agent.nousresearch.com/docs/getting-started/termux)。在 Termux 上,Hermes 会安装精选的 `.[termux]` 扩展,因为完整的 `.[all]` 扩展会拉取 Android 不兼容的语音依赖。
|
||||
>
|
||||
> **Windows:** 原生 Windows 不受支持。请安装 [WSL2](https://learn.microsoft.com/zh-cn/windows/wsl/install) 并运行上述命令。
|
||||
|
||||
安装后:
|
||||
|
||||
```bash
|
||||
source ~/.bashrc # 重新加载 shell(或: source ~/.zshrc)
|
||||
hermes # 开始对话!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速入门
|
||||
|
||||
```bash
|
||||
hermes # 交互式 CLI — 开始对话
|
||||
hermes model # 选择 LLM 提供商和模型
|
||||
hermes tools # 配置启用的工具
|
||||
hermes config set # 设置单个配置项
|
||||
hermes gateway # 启动消息网关(Telegram、Discord 等)
|
||||
hermes setup # 运行完整设置向导(一次性配置所有内容)
|
||||
hermes claw migrate # 从 OpenClaw 迁移(如果来自 OpenClaw)
|
||||
hermes update # 更新到最新版本
|
||||
hermes doctor # 诊断问题
|
||||
```
|
||||
|
||||
📖 **[完整文档 →](https://hermes-agent.nousresearch.com/docs/)**
|
||||
|
||||
## CLI 与消息平台 快速对照
|
||||
|
||||
Hermes 有两种入口:用 `hermes` 启动终端 UI,或运行网关从 Telegram、Discord、Slack、WhatsApp、Signal 或 Email 与之对话。进入对话后,许多斜杠命令在两种界面中通用。
|
||||
|
||||
| 操作 | CLI | 消息平台 |
|
||||
|------|-----|----------|
|
||||
| 开始对话 | `hermes` | 运行 `hermes gateway setup` + `hermes gateway start`,然后给机器人发消息 |
|
||||
| 开始新对话 | `/new` 或 `/reset` | `/new` 或 `/reset` |
|
||||
| 更换模型 | `/model [provider:model]` | `/model [provider:model]` |
|
||||
| 设置人格 | `/personality [name]` | `/personality [name]` |
|
||||
| 重试或撤销上一轮 | `/retry`、`/undo` | `/retry`、`/undo` |
|
||||
| 压缩上下文 / 查看用量 | `/compress`、`/usage`、`/insights [--days N]` | `/compress`、`/usage`、`/insights [days]` |
|
||||
| 浏览技能 | `/skills` 或 `/<skill-name>` | `/skills` 或 `/<skill-name>` |
|
||||
| 中断当前工作 | `Ctrl+C` 或发送新消息 | `/stop` 或发送新消息 |
|
||||
| 平台特定状态 | `/platforms` | `/status`、`/sethome` |
|
||||
|
||||
完整命令列表请参阅 [CLI 指南](https://hermes-agent.nousresearch.com/docs/user-guide/cli) 和 [消息网关指南](https://hermes-agent.nousresearch.com/docs/user-guide/messaging)。
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
所有文档位于 **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)**:
|
||||
|
||||
| 章节 | 内容 |
|
||||
|------|------|
|
||||
| [快速开始](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | 安装 → 设置 → 2 分钟内开始首次对话 |
|
||||
| [CLI 使用](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | 命令、快捷键、人格、会话 |
|
||||
| [配置](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | 配置文件、提供商、模型、所有选项 |
|
||||
| [消息网关](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram、Discord、Slack、WhatsApp、Signal、Home Assistant |
|
||||
| [安全](https://hermes-agent.nousresearch.com/docs/user-guide/security) | 命令审批、DM 配对、容器隔离 |
|
||||
| [工具与工具集](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40+ 工具、工具集系统、终端后端 |
|
||||
| [技能系统](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | 过程记忆、技能中心、创建技能 |
|
||||
| [记忆](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | 持久记忆、用户画像、最佳实践 |
|
||||
| [MCP 集成](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | 连接任意 MCP 服务器扩展能力 |
|
||||
| [定时调度](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | 定时任务与平台投递 |
|
||||
| [上下文文件](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | 影响每次对话的项目上下文 |
|
||||
| [架构](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | 项目结构、代理循环、关键类 |
|
||||
| [贡献](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | 开发设置、PR 流程、代码风格 |
|
||||
| [CLI 参考](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | 所有命令和标志 |
|
||||
| [环境变量](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | 完整环境变量参考 |
|
||||
|
||||
---
|
||||
|
||||
## 从 OpenClaw 迁移
|
||||
|
||||
如果你来自 OpenClaw,Hermes 可以自动导入你的设置、记忆、技能和 API 密钥。
|
||||
|
||||
**首次安装时:** 安装向导(`hermes setup`)会自动检测 `~/.openclaw` 并在配置开始前提供迁移选项。
|
||||
|
||||
**安装后任意时间:**
|
||||
|
||||
```bash
|
||||
hermes claw migrate # 交互式迁移(完整预设)
|
||||
hermes claw migrate --dry-run # 预览将要迁移的内容
|
||||
hermes claw migrate --preset user-data # 仅迁移用户数据,不含密钥
|
||||
hermes claw migrate --overwrite # 覆盖已有冲突
|
||||
```
|
||||
|
||||
导入内容:
|
||||
- **SOUL.md** — 人格文件
|
||||
- **记忆** — MEMORY.md 和 USER.md 条目
|
||||
- **技能** — 用户创建的技能 → `~/.hermes/skills/openclaw-imports/`
|
||||
- **命令白名单** — 审批模式
|
||||
- **消息设置** — 平台配置、允许用户、工作目录
|
||||
- **API 密钥** — 白名单中的密钥(Telegram、OpenRouter、OpenAI、Anthropic、ElevenLabs)
|
||||
- **TTS 资产** — 工作区音频文件
|
||||
- **工作区指令** — AGENTS.md(使用 `--workspace-target`)
|
||||
|
||||
使用 `hermes claw migrate --help` 查看所有选项,或使用 `openclaw-migration` 技能进行交互式代理引导迁移(含干运行预览)。
|
||||
|
||||
---
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。
|
||||
|
||||
贡献者快速开始——克隆并使用 `setup-hermes.sh`:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
手动安装(等效于上述命令):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
uv venv venv --python 3.11
|
||||
source venv/bin/activate
|
||||
uv pip install -e ".[all,dev]"
|
||||
python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
> **RL 训练(可选):** 如需参与 RL/Tinker-Atropos 集成开发:
|
||||
> ```bash
|
||||
> git submodule update --init tinker-atropos
|
||||
> uv pip install -e "./tinker-atropos"
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## 社区
|
||||
|
||||
- 💬 [Discord](https://discord.gg/NousResearch)
|
||||
- 📚 [技能中心](https://agentskills.io)
|
||||
- 🐛 [问题反馈](https://github.com/NousResearch/hermes-agent/issues)
|
||||
- 💡 [讨论区](https://github.com/NousResearch/hermes-agent/discussions)
|
||||
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — 社区微信桥接:在同一微信账号上运行 Hermes Agent 和 OpenClaw。
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT — 详见 [LICENSE](LICENSE)。
|
||||
|
||||
由 [Nous Research](https://nousresearch.com) 构建。
|
||||
@@ -1,641 +0,0 @@
|
||||
# Hermes Agent v0.13.0 (v2026.5.7)
|
||||
|
||||
**Release Date:** May 7, 2026
|
||||
**Since v0.12.0:** 864 commits · 588 merged PRs · 829 files changed · 128,366 insertions · 282 issues closed (13 P0, 36 P1) · 295 community contributors (including co-authors)
|
||||
|
||||
> The Tenacity Release — Hermes Agent now finishes what it starts. Kanban ships as a durable multi-agent board (heartbeat, reclaim, zombie detection, auto-block on incomplete exit, per-task retries, hallucination recovery). `/goal` keeps the agent locked on a target across turns (Ralph loop). Checkpoints v2 rewrites state persistence with real pruning. Gateway auto-resumes interrupted sessions after restart. Cron grows a `no_agent` watchdog mode. A security wave closes 8 P0s — redaction is now ON by default, Discord role-allowlists are guild-scoped, WhatsApp rejects strangers by default, and TOCTOU windows close across auth.json and MCP OAuth. Google Chat becomes the 20th platform. Providers become a pluggable surface. Seven i18n locales ship.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Multi-agent Kanban — delegate to an AI team that actually finishes** — Spin up a durable board, drop tasks on it, and let multiple Hermes workers pick them up, hand off, and close them out. Heartbeats, reclaim, zombie detection, retry budgets, and a hallucination gate keep the team honest. One install, many kanbans. ([#17805](https://github.com/NousResearch/hermes-agent/pull/17805), [#19653](https://github.com/NousResearch/hermes-agent/pull/19653), [#20232](https://github.com/NousResearch/hermes-agent/pull/20232), [#20332](https://github.com/NousResearch/hermes-agent/pull/20332), [#21330](https://github.com/NousResearch/hermes-agent/pull/21330), [#21183](https://github.com/NousResearch/hermes-agent/pull/21183), [#21214](https://github.com/NousResearch/hermes-agent/pull/21214))
|
||||
|
||||
- **`/goal` — the agent doesn't forget what you asked it to do** — Lock the agent onto a target and it stays on task across turns. The Ralph loop as a first-class primitive. ([#18262](https://github.com/NousResearch/hermes-agent/pull/18262), [#18275](https://github.com/NousResearch/hermes-agent/pull/18275), [#21287](https://github.com/NousResearch/hermes-agent/pull/21287))
|
||||
|
||||
- **Show it a video** — new `video_analyze` tool for native video understanding on Gemini and compatible multimodal models. (@alt-glitch) ([#19301](https://github.com/NousResearch/hermes-agent/pull/19301))
|
||||
|
||||
- **Clone a voice** — xAI Custom Voices lands as a TTS provider with voice cloning support. (@alt-glitch) ([#18776](https://github.com/NousResearch/hermes-agent/pull/18776))
|
||||
|
||||
- **Hermes speaks your language** — static gateway + CLI messages translate to 7 locales: Chinese, Japanese, German, Spanish, French, Ukrainian, and Turkish. Docs site gains a Chinese (zh-Hans) locale. ([#20231](https://github.com/NousResearch/hermes-agent/pull/20231), [#20329](https://github.com/NousResearch/hermes-agent/pull/20329), [#20467](https://github.com/NousResearch/hermes-agent/pull/20467), [#20474](https://github.com/NousResearch/hermes-agent/pull/20474), [#20430](https://github.com/NousResearch/hermes-agent/pull/20430), [#20431](https://github.com/NousResearch/hermes-agent/pull/20431))
|
||||
|
||||
- **Google Chat — the 20th messaging platform** — plus a generic platform-plugin hooks surface so third-party adapters drop in without touching core (IRC and Teams migrated). ([#21306](https://github.com/NousResearch/hermes-agent/pull/21306), [#21331](https://github.com/NousResearch/hermes-agent/pull/21331))
|
||||
|
||||
- **Sessions survive restarts** — gateway bounces mid-agent, `/update` restarts, source-file reloads — conversations auto-resume when the gateway comes back. ([#21192](https://github.com/NousResearch/hermes-agent/pull/21192))
|
||||
|
||||
- **Security wave — 8 P0 closures** — redaction ON by default, Discord role-allowlists guild-scoped (CVSS 8.1 cross-guild DM bypass closed), WhatsApp rejects strangers by default, TOCTOU windows closed across `auth.json` and MCP OAuth, browser enforces cloud-metadata SSRF floor, cron prompt-injection scans assembled skill content, `hermes debug share` redacts at upload. ([#21193](https://github.com/NousResearch/hermes-agent/pull/21193), [#21241](https://github.com/NousResearch/hermes-agent/pull/21241), [#21291](https://github.com/NousResearch/hermes-agent/pull/21291), [#21176](https://github.com/NousResearch/hermes-agent/pull/21176), [#21194](https://github.com/NousResearch/hermes-agent/pull/21194), [#21228](https://github.com/NousResearch/hermes-agent/pull/21228), [#21350](https://github.com/NousResearch/hermes-agent/pull/21350), [#19318](https://github.com/NousResearch/hermes-agent/pull/19318))
|
||||
|
||||
- **Checkpoints v2** — state persistence rewritten. Real pruning, disk guardrails, no more orphan shadow repos. ([#20709](https://github.com/NousResearch/hermes-agent/pull/20709))
|
||||
|
||||
- **The agent lints its own writes** — post-write delta lint on `write_file` + `patch`. Python, JSON, YAML, TOML. Syntax errors surface immediately instead of shipping downstream. ([#20191](https://github.com/NousResearch/hermes-agent/pull/20191))
|
||||
|
||||
- **`no_agent` cron mode — script-only watchdog** — cron jobs can now skip the agent entirely and just run a script. Empty stdout is silent, non-empty gets delivered verbatim. ([#19709](https://github.com/NousResearch/hermes-agent/pull/19709))
|
||||
|
||||
- **Platform allowlists everywhere** — `allowed_channels` / `allowed_chats` / `allowed_rooms` config across Slack, Telegram, Mattermost, Matrix, and DingTalk. ([#21251](https://github.com/NousResearch/hermes-agent/pull/21251))
|
||||
|
||||
- **Providers are now plugins** — `ProviderProfile` ABC + `plugins/model-providers/`. Drop in third-party providers without touching core. ([#20324](https://github.com/NousResearch/hermes-agent/pull/20324))
|
||||
|
||||
- **API server — long-term memory per session** — `X-Hermes-Session-Key` header gives memory providers a stable session identifier. ([#20199](https://github.com/NousResearch/hermes-agent/pull/20199))
|
||||
|
||||
- **MCP levels up** — SSE transport with OAuth forwarding, stale-pipe retries, image results surface as MEDIA tags instead of getting dropped, keepalive on long-lived lifecycle waits. ([#21227](https://github.com/NousResearch/hermes-agent/pull/21227), [#21323](https://github.com/NousResearch/hermes-agent/pull/21323), [#21289](https://github.com/NousResearch/hermes-agent/pull/21289), [#21328](https://github.com/NousResearch/hermes-agent/pull/21328), [#20209](https://github.com/NousResearch/hermes-agent/pull/20209))
|
||||
|
||||
- **Curator grows subcommands** — `hermes curator archive`, `prune`, `list-archived`. Manual `hermes curator run` is synchronous now — you see results without polling. ([#20200](https://github.com/NousResearch/hermes-agent/pull/20200), [#21236](https://github.com/NousResearch/hermes-agent/pull/21236), [#21216](https://github.com/NousResearch/hermes-agent/pull/21216))
|
||||
|
||||
- **ACP — `/steer` and `/queue`** — direct the in-flight agent or queue follow-ups from Zed, VS Code, or JetBrains. Plus atomic session persistence and reasoning-metadata preservation across restarts. (@HenkDz) ([#18114](https://github.com/NousResearch/hermes-agent/pull/18114), [#20279](https://github.com/NousResearch/hermes-agent/pull/20279), [#20296](https://github.com/NousResearch/hermes-agent/pull/20296), [#20433](https://github.com/NousResearch/hermes-agent/pull/20433))
|
||||
|
||||
- **TUI glow-up** — `/model` picker matches `hermes model` with inline auth (@austinpickett), collapsible startup banner sections (@kshitijk4poor), context-compression counter in the status bar. ([#18117](https://github.com/NousResearch/hermes-agent/pull/18117), [#20625](https://github.com/NousResearch/hermes-agent/pull/20625), [#21218](https://github.com/NousResearch/hermes-agent/pull/21218))
|
||||
|
||||
- **Dashboard grows up** — Plugins page (manage, enable/disable, auth status) (@austinpickett), Profiles management page (@vincez-hms-coder), sortable analytics tables, reverse-proxy support via `X-Forwarded-Prefix`, new `default-large` 18px theme. ([#18095](https://github.com/NousResearch/hermes-agent/pull/18095), [#16419](https://github.com/NousResearch/hermes-agent/pull/16419), [#18192](https://github.com/NousResearch/hermes-agent/pull/18192), [#21296](https://github.com/NousResearch/hermes-agent/pull/21296), [#20820](https://github.com/NousResearch/hermes-agent/pull/20820))
|
||||
|
||||
- **SearXNG + split web tools** — SearXNG ships as a native search-only backend; web tools now let you pick different backends per capability (search vs extract vs browse). (@kshitijk4poor) ([#20823](https://github.com/NousResearch/hermes-agent/pull/20823), [#20061](https://github.com/NousResearch/hermes-agent/pull/20061), [#20841](https://github.com/NousResearch/hermes-agent/pull/20841))
|
||||
|
||||
- **OpenRouter response caching** — explicit cache control for models that expose it. (@kshitijk4poor) ([#19132](https://github.com/NousResearch/hermes-agent/pull/19132))
|
||||
|
||||
- **`[[as_document]]` — skill media-routing directive** — skills can force the gateway to deliver output as a document on platforms that support it. ([#21210](https://github.com/NousResearch/hermes-agent/pull/21210))
|
||||
|
||||
- **`transform_llm_output` plugin hook** — new lifecycle hook that lets plugins reshape or filter LLM output before it hits the conversation. Useful for context-window reducers and content filters. ([#21235](https://github.com/NousResearch/hermes-agent/pull/21235))
|
||||
|
||||
- **Nous OAuth persists across profiles** — shared token store: sign in once, every profile inherits the session. ([#19712](https://github.com/NousResearch/hermes-agent/pull/19712))
|
||||
|
||||
- **QQBot — native approval keyboards** — feature parity with Telegram / Discord approval UX. Chunked upload, quoted attachments. ([#21342](https://github.com/NousResearch/hermes-agent/pull/21342), [#21353](https://github.com/NousResearch/hermes-agent/pull/21353))
|
||||
|
||||
- **6 new optional skills** — Shopify (Admin + Storefront GraphQL), here.now, shop-app personal shopping assistant, Anthropic financial-services bundle, kanban-video-orchestrator (@SHL0MS), searxng-search (@kshitijk4poor). ([#18116](https://github.com/NousResearch/hermes-agent/pull/18116), [#18170](https://github.com/NousResearch/hermes-agent/pull/18170), [#20702](https://github.com/NousResearch/hermes-agent/pull/20702), [#21180](https://github.com/NousResearch/hermes-agent/pull/21180), [#19281](https://github.com/NousResearch/hermes-agent/pull/19281), [#20841](https://github.com/NousResearch/hermes-agent/pull/20841))
|
||||
|
||||
- **New models** — `deepseek/deepseek-v4-pro`, `x-ai/grok-4.3`, `openrouter/owl-alpha` (free), `tencent/hy3-preview` (@Contentment003111), Arcee Trinity Large Thinking temperature + compression overrides. ([#20495](https://github.com/NousResearch/hermes-agent/pull/20495), [#20497](https://github.com/NousResearch/hermes-agent/pull/20497), [#18071](https://github.com/NousResearch/hermes-agent/pull/18071), [#21077](https://github.com/NousResearch/hermes-agent/pull/21077), [#20473](https://github.com/NousResearch/hermes-agent/pull/20473))
|
||||
|
||||
- **100 fresh CLI startup tips** — the random tip banner gets 100 new entries covering cron, kanban, curator, plugins, and lesser-known flags. ([#20168](https://github.com/NousResearch/hermes-agent/pull/20168))
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Multi-Agent Kanban (Durable)
|
||||
|
||||
### New — durable multi-profile collaboration board
|
||||
- **`feat(kanban): durable multi-profile collaboration board`** — post-revert reimplementation, multi-profile by design ([#17805](https://github.com/NousResearch/hermes-agent/pull/17805))
|
||||
- **Multi-project boards** — one install, many kanbans ([#19653](https://github.com/NousResearch/hermes-agent/pull/19653), [#19679](https://github.com/NousResearch/hermes-agent/pull/19679))
|
||||
- **Share board, workspaces, and worker logs across profiles** ([#19378](https://github.com/NousResearch/hermes-agent/pull/19378))
|
||||
- **Hallucination gate + recovery UX for worker-created-card claims** (closes #20017) ([#20232](https://github.com/NousResearch/hermes-agent/pull/20232))
|
||||
- **Generic diagnostics engine for task distress signals** ([#20332](https://github.com/NousResearch/hermes-agent/pull/20332))
|
||||
- **Per-task `max_retries` override** (supersedes #20972) ([#21330](https://github.com/NousResearch/hermes-agent/pull/21330))
|
||||
- **Multiline textarea for inline-create title** (salvage of #20970) ([#21243](https://github.com/NousResearch/hermes-agent/pull/21243))
|
||||
|
||||
### Kanban Dashboard
|
||||
- **Workspace kind + path inputs in inline create form** ([#19679](https://github.com/NousResearch/hermes-agent/pull/19679))
|
||||
- **Per-platform home-channel notification toggles** ([#19864](https://github.com/NousResearch/hermes-agent/pull/19864))
|
||||
- **Sharper home-channel toggle contrast + drop → running action** ([#19916](https://github.com/NousResearch/hermes-agent/pull/19916))
|
||||
- Fix: reject direct status transition to 'running' via dashboard API (salvage of #19554) ([#19705](https://github.com/NousResearch/hermes-agent/pull/19705))
|
||||
- Fix: dashboard board pin authoritative over server current file (#20879) ([#21230](https://github.com/NousResearch/hermes-agent/pull/21230))
|
||||
- Fix: treat dashboard event-stream cancellation as normal shutdown (#20790) ([#21222](https://github.com/NousResearch/hermes-agent/pull/21222))
|
||||
- Fix: filter dashboard board by selected tenant (#19817) ([#21349](https://github.com/NousResearch/hermes-agent/pull/21349))
|
||||
- Fix: code/pre styling theme-immune across all themes (#21086) ([#21247](https://github.com/NousResearch/hermes-agent/pull/21247))
|
||||
- Fix: reset `<code>` background inside dashboard board ([#20687](https://github.com/NousResearch/hermes-agent/pull/20687))
|
||||
- Fix: preserve dashboard completion summaries + add kanban edit (salvages #20016) ([#20195](https://github.com/NousResearch/hermes-agent/pull/20195))
|
||||
- Fix: avoid fragile failure-column renames (salvage #20848) (@kshitijk4poor) ([#20855](https://github.com/NousResearch/hermes-agent/pull/20855))
|
||||
|
||||
### Worker lifecycle + reliability
|
||||
- **Heartbeat + reclaim + zombie + retry-cap fixes** (#21147, #21141, #21169, #20881) ([#21183](https://github.com/NousResearch/hermes-agent/pull/21183))
|
||||
- **Auto-block workers that exit without completing + shutdown race** (#20894) ([#21214](https://github.com/NousResearch/hermes-agent/pull/21214))
|
||||
- **Detect darwin zombie workers** (salvages #20023) ([#20188](https://github.com/NousResearch/hermes-agent/pull/20188))
|
||||
- **Unify failure counter across spawn/timeout/crash outcomes** ([#20410](https://github.com/NousResearch/hermes-agent/pull/20410))
|
||||
- **Enforce worker task-ownership on destructive tool calls** ([#19713](https://github.com/NousResearch/hermes-agent/pull/19713))
|
||||
- **Drop worker identity claim from KANBAN_GUIDANCE** ([#19427](https://github.com/NousResearch/hermes-agent/pull/19427))
|
||||
- Fix: skip dispatch for tasks assigned to non-profile lanes (salvages #20105, #20134) ([#20165](https://github.com/NousResearch/hermes-agent/pull/20165))
|
||||
- Fix: include default profile in on-disk assignee enumeration (salvages #20123) ([#20170](https://github.com/NousResearch/hermes-agent/pull/20170))
|
||||
- Fix: ignore stale current board pointers (salvages #20063) ([#20183](https://github.com/NousResearch/hermes-agent/pull/20183))
|
||||
- Fix: profile discovery ignores HERMES_HOME in custom-root deployments (@jackey8616) ([#19020](https://github.com/NousResearch/hermes-agent/pull/19020))
|
||||
- Fix: allow orchestrator profiles to see kanban tools via toolsets config ([#19606](https://github.com/NousResearch/hermes-agent/pull/19606))
|
||||
|
||||
### Batch salvages
|
||||
- Tier-1 batch — metadata test, max_spawn config, run-id lifecycle guard (salvages #19522 #19556 #19829) ([#20440](https://github.com/NousResearch/hermes-agent/pull/20440))
|
||||
- Tier-2 batch — doctor, started_at, parent-guard, latest_summary, selects, linked-children ([#20448](https://github.com/NousResearch/hermes-agent/pull/20448))
|
||||
|
||||
### Documentation
|
||||
- Backfill multi-board refs in reference docs ([#19704](https://github.com/NousResearch/hermes-agent/pull/19704))
|
||||
- Document `/kanban` slash command ([#19584](https://github.com/NousResearch/hermes-agent/pull/19584))
|
||||
- Document recommended handoff evidence metadata (salvage #19512) ([#20415](https://github.com/NousResearch/hermes-agent/pull/20415))
|
||||
- Fix orchestrator + worker skill setup instructions (@helix4u) ([#20958](https://github.com/NousResearch/hermes-agent/pull/20958), [#20960](https://github.com/NousResearch/hermes-agent/pull/20960))
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Persistent Goals, Checkpoints & Session Durability
|
||||
|
||||
### `/goal` — persistent cross-turn goals (Ralph loop)
|
||||
- **`feat: /goal — persistent cross-turn goals`** ([#18262](https://github.com/NousResearch/hermes-agent/pull/18262))
|
||||
- **Docs page — Persistent Goals (/goal)** ([#18275](https://github.com/NousResearch/hermes-agent/pull/18275))
|
||||
- Fix: honor configured goal turn budget (salvage #19423) ([#21287](https://github.com/NousResearch/hermes-agent/pull/21287))
|
||||
|
||||
### Checkpoints v2
|
||||
- **Single-store rewrite with real pruning + disk guardrails** ([#20709](https://github.com/NousResearch/hermes-agent/pull/20709))
|
||||
|
||||
### Session durability
|
||||
- **Auto-resume interrupted sessions after gateway restart** (salvage #20888) ([#21192](https://github.com/NousResearch/hermes-agent/pull/21192))
|
||||
- **Preserve pending update prompts across restarts** ([#20160](https://github.com/NousResearch/hermes-agent/pull/20160))
|
||||
- **Preserve home-channel thread targets across restart notifications** (salvage #18440) ([#19271](https://github.com/NousResearch/hermes-agent/pull/19271))
|
||||
- **Preserve thread routing from cached live session sources** ([#21206](https://github.com/NousResearch/hermes-agent/pull/21206))
|
||||
- **Preserve assistant metadata when branching sessions** ([#18222](https://github.com/NousResearch/hermes-agent/pull/18222))
|
||||
- **Preserve thread routing for /update progress and prompts** ([#18193](https://github.com/NousResearch/hermes-agent/pull/18193))
|
||||
- **Preserve document type when merging queued events** ([#18215](https://github.com/NousResearch/hermes-agent/pull/18215))
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Security & Reliability
|
||||
|
||||
### Security hardening (8 P0 closures)
|
||||
- **Enable secret redaction by default** (#17691, #20785) ([#21193](https://github.com/NousResearch/hermes-agent/pull/21193))
|
||||
- **Discord — scope `DISCORD_ALLOWED_ROLES` to originating guild** (#12136, CVSS 8.1) ([#21241](https://github.com/NousResearch/hermes-agent/pull/21241))
|
||||
- **WhatsApp — reject strangers by default, never respond in self-chat** (#8389) ([#21291](https://github.com/NousResearch/hermes-agent/pull/21291))
|
||||
- **MCP OAuth — close TOCTOU window when saving credentials** ([#21176](https://github.com/NousResearch/hermes-agent/pull/21176))
|
||||
- **`hermes_cli/auth.py` — close TOCTOU window in credential writers** ([#21194](https://github.com/NousResearch/hermes-agent/pull/21194))
|
||||
- **Browser — enforce cloud-metadata SSRF floor in hybrid routing** (#16234) ([#21228](https://github.com/NousResearch/hermes-agent/pull/21228))
|
||||
- **`hermes debug share` — redact log content at upload time** (@GodsBoy) ([#19318](https://github.com/NousResearch/hermes-agent/pull/19318))
|
||||
- **Cron — scan assembled prompt including skill content for prompt injection** (#3968) ([#21350](https://github.com/NousResearch/hermes-agent/pull/21350))
|
||||
- **Restore .env/auth.json/state.db with 0600 perms** ([#19699](https://github.com/NousResearch/hermes-agent/pull/19699))
|
||||
- **SRI integrity for dashboard plugin scripts** (salvage #19389) ([#21277](https://github.com/NousResearch/hermes-agent/pull/21277))
|
||||
- **Bind Meet node server to localhost, restrict token file to owner read** ([#19597](https://github.com/NousResearch/hermes-agent/pull/19597))
|
||||
- **Extend sensitive-write target to cover shell RC and credential files** ([#19282](https://github.com/NousResearch/hermes-agent/pull/19282))
|
||||
- **Harden YOLO mode env parsing against quoted-bool strings** ([#18214](https://github.com/NousResearch/hermes-agent/pull/18214))
|
||||
- **OSV-Scanner CI + Dependabot for github-actions only** ([#20037](https://github.com/NousResearch/hermes-agent/pull/20037))
|
||||
|
||||
### Reliability — critical bug closures
|
||||
- **CLI crash on startup — `Invalid key 'c-S-c'`** (P0, prompt_toolkit doesn't support Shift modifier) ([#19895](https://github.com/NousResearch/hermes-agent/pull/19895), [#19919](https://github.com/NousResearch/hermes-agent/pull/19919))
|
||||
- **CLOSE_WAIT fd leak audit** — httpx keepalive + WhatsApp aiohttp leak + Feishu hygiene (#18451) ([#18766](https://github.com/NousResearch/hermes-agent/pull/18766))
|
||||
- **Gateway creates AIAgent with empty OpenRouter API key when OPENROUTER_API_KEY is missing** (#20982) — fallback providers correctly honored
|
||||
- **Background review + curator protected from overwriting bundled/hub skills** (#20273) ([#20194](https://github.com/NousResearch/hermes-agent/pull/20194))
|
||||
- **TUI compression continuation — ghost sessions with incomplete metadata** (#20001)
|
||||
- **`hermes mcp add` silently launches chat instead of registering MCP server** (#19785) ([#21204](https://github.com/NousResearch/hermes-agent/pull/21204))
|
||||
- **Background review agent runtime propagation** — provider/model/credentials now actually inherit from parent
|
||||
- **Inbound document host paths translated to container paths for Docker backend** (salvage #19048) ([#21184](https://github.com/NousResearch/hermes-agent/pull/21184))
|
||||
- **Matrix gateway race between auto-redaction and message delivery with high-speed models** (#19075)
|
||||
- **`/new` during active agent session never sends response on Telegram** (#18912)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Messaging Platforms (Gateway)
|
||||
|
||||
### New platform
|
||||
- **Google Chat — 20th platform** + generic `env_enablement_fn` / `cron_deliver_env_var` platform-plugin hooks (IRC + Teams migrated) ([#21306](https://github.com/NousResearch/hermes-agent/pull/21306), [#21331](https://github.com/NousResearch/hermes-agent/pull/21331))
|
||||
|
||||
### Cross-platform
|
||||
- **`allowed_{channels,chats,rooms}` whitelist** — Slack (salvage #7401), Telegram, Mattermost, Matrix, DingTalk ([#21251](https://github.com/NousResearch/hermes-agent/pull/21251))
|
||||
- **Per-platform `gateway_restart_notification` flag** ([#20892](https://github.com/NousResearch/hermes-agent/pull/20892))
|
||||
- **`busy_ack_enabled` config — suppress ack messages** ([#18194](https://github.com/NousResearch/hermes-agent/pull/18194))
|
||||
- **Auto-delete slash-command system notices after TTL** ([#18266](https://github.com/NousResearch/hermes-agent/pull/18266))
|
||||
- **Opt-in cleanup of temporary progress bubbles** ([#21186](https://github.com/NousResearch/hermes-agent/pull/21186))
|
||||
- **`[[as_document]]` directive — skill media routing** (salvage #19069) ([#21210](https://github.com/NousResearch/hermes-agent/pull/21210))
|
||||
- **`hermes gateway list` — cross-profile status** (salvage #19129) ([#21225](https://github.com/NousResearch/hermes-agent/pull/21225))
|
||||
- **Auto-resume interrupted sessions after restart** (salvage #20888) ([#21192](https://github.com/NousResearch/hermes-agent/pull/21192))
|
||||
- **Atomic restart markers + Windows runtime-lock offset** (#17842) ([#18179](https://github.com/NousResearch/hermes-agent/pull/18179))
|
||||
- Fix: `config.yaml` wins over `.env` for agent/display/timezone settings ([#18764](https://github.com/NousResearch/hermes-agent/pull/18764))
|
||||
- Fix: auto-restart when source files change out from under us (#17648) ([#18409](https://github.com/NousResearch/hermes-agent/pull/18409))
|
||||
- Fix: use git HEAD SHA for stale-code check, not file mtimes ([#19740](https://github.com/NousResearch/hermes-agent/pull/19740))
|
||||
- Fix: shutdown + restart hygiene — drain timeout, false-fatal, success log ([#18761](https://github.com/NousResearch/hermes-agent/pull/18761))
|
||||
- Fix: preserve max_turns after env reload (salvage #19183) ([#21240](https://github.com/NousResearch/hermes-agent/pull/21240))
|
||||
- Fix: exclude ancestor PIDs from gateway process scan ([#19586](https://github.com/NousResearch/hermes-agent/pull/19586))
|
||||
- Fix: move quick-command alias dispatch before built-ins ([#19588](https://github.com/NousResearch/hermes-agent/pull/19588))
|
||||
- Fix: show other profiles in 'gateway status' to prevent confusion ([#19582](https://github.com/NousResearch/hermes-agent/pull/19582))
|
||||
- Fix: include external_dirs skills in Telegram/Discord slash commands (salvage #8790) ([#18741](https://github.com/NousResearch/hermes-agent/pull/18741))
|
||||
- Fix: match disabled/optional skills by frontmatter slug, not dir name ([#18753](https://github.com/NousResearch/hermes-agent/pull/18753))
|
||||
- Fix: read /status token totals from SessionDB (#17158) ([#18206](https://github.com/NousResearch/hermes-agent/pull/18206))
|
||||
- Fix: snapshot callback generation after agent binds it, not before ([#18219](https://github.com/NousResearch/hermes-agent/pull/18219))
|
||||
- Fix: re-inject topic-bound skill after /new or /reset ([#18205](https://github.com/NousResearch/hermes-agent/pull/18205))
|
||||
- Fix: isolate pending native image paths by session ([#18202](https://github.com/NousResearch/hermes-agent/pull/18202))
|
||||
- Fix: clear queued reload skills notes on new/resume/branch ([#19431](https://github.com/NousResearch/hermes-agent/pull/19431))
|
||||
- Fix: hide required-arg commands from Telegram menu ([#19400](https://github.com/NousResearch/hermes-agent/pull/19400))
|
||||
- Fix: bridge top-level `require_mention` to Telegram config ([#19429](https://github.com/NousResearch/hermes-agent/pull/19429))
|
||||
- Fix: suppress duplicate voice transcripts ([#19428](https://github.com/NousResearch/hermes-agent/pull/19428))
|
||||
- Fix: show friendly error when service is not installed ([#19707](https://github.com/NousResearch/hermes-agent/pull/19707))
|
||||
- Fix: read context_length from custom_providers in session info header ([#19708](https://github.com/NousResearch/hermes-agent/pull/19708))
|
||||
- Fix: preserve WSL interop PATH in systemd units ([#19867](https://github.com/NousResearch/hermes-agent/pull/19867))
|
||||
- Fix: handle planned service stops (salvage #19876) ([#19936](https://github.com/NousResearch/hermes-agent/pull/19936))
|
||||
- Fix: keep DoH-confirmed Telegram IPs that match system DNS (salvage #17043) ([#20175](https://github.com/NousResearch/hermes-agent/pull/20175))
|
||||
- Fix: load `reply_to_mode` from config.yaml for Discord + Telegram (salvage #17117) ([#20171](https://github.com/NousResearch/hermes-agent/pull/20171))
|
||||
- Fix: tolerate malformed HERMES_HUMAN_DELAY_* env vars (salvage #16933) ([#20217](https://github.com/NousResearch/hermes-agent/pull/20217))
|
||||
- Fix: deterministic thread eviction preserves newest entries (salvage #13639) ([#20285](https://github.com/NousResearch/hermes-agent/pull/20285))
|
||||
- Fix: don't dead-end setup wizard when only system-scope unit is installed ([#20905](https://github.com/NousResearch/hermes-agent/pull/20905))
|
||||
- Fix: wait for systemd restart readiness + harden Discord slash-command sync ([#20949](https://github.com/NousResearch/hermes-agent/pull/20949))
|
||||
- Fix: avoid duplicated Responses history (salvage #18995) ([#21185](https://github.com/NousResearch/hermes-agent/pull/21185))
|
||||
- Fix: surface bootstrap failures to stderr (salvage #21157) ([#21278](https://github.com/NousResearch/hermes-agent/pull/21278))
|
||||
- Fix: log agent task failures instead of silently losing usage data (salvage #21159) ([#21274](https://github.com/NousResearch/hermes-agent/pull/21274))
|
||||
- Fix: log runtime-status write failures with rate-limiting (salvage #21158) ([#21285](https://github.com/NousResearch/hermes-agent/pull/21285))
|
||||
- Fix: reset-failed before every fallback restart so the gateway can't get stranded ([#21371](https://github.com/NousResearch/hermes-agent/pull/21371))
|
||||
- Fix: Telegram — preserve `thread_id=1` for forum General typing indicator ([#21390](https://github.com/NousResearch/hermes-agent/pull/21390))
|
||||
- Fix: batch critical fixes — session resume, /new race, HA WebSocket scheme (@kshitijk4poor) ([#19182](https://github.com/NousResearch/hermes-agent/pull/19182))
|
||||
|
||||
### Telegram
|
||||
- **DM user-managed multi-session topics** (salvage of #19185) ([#19206](https://github.com/NousResearch/hermes-agent/pull/19206))
|
||||
|
||||
### Discord
|
||||
- **Message deletion action** (salvage #19052) ([#21197](https://github.com/NousResearch/hermes-agent/pull/21197))
|
||||
- Fix: allow `free_response_channels` to override `DISCORD_IGNORE_NO_MENTION` ([#19629](https://github.com/NousResearch/hermes-agent/pull/19629))
|
||||
|
||||
### Slack
|
||||
- Fix: ephemeral slash-command ack, private notice delivery, format_message fixes (@kshitijk4poor) ([#18198](https://github.com/NousResearch/hermes-agent/pull/18198))
|
||||
|
||||
### WhatsApp
|
||||
- Fix: load WhatsApp home channel from env overrides ([#18190](https://github.com/NousResearch/hermes-agent/pull/18190))
|
||||
|
||||
### Feishu
|
||||
- **Operator-configurable bot admission and mention policy** ([#18208](https://github.com/NousResearch/hermes-agent/pull/18208))
|
||||
- Fix: force text mode for markdown tables (salvage of #13723 by @WuTianyi123) ([#20275](https://github.com/NousResearch/hermes-agent/pull/20275))
|
||||
|
||||
### Matrix + Email
|
||||
- Fix: `/sethome` on Matrix and Email now persists across restarts ([#18272](https://github.com/NousResearch/hermes-agent/pull/18272))
|
||||
|
||||
### Teams
|
||||
- **Docs + feat: sidebar + threading with group-chat fallback** ([#20042](https://github.com/NousResearch/hermes-agent/pull/20042))
|
||||
|
||||
### Weixin
|
||||
- Fix: deduplicate Weixin messages by content fingerprint ([#19742](https://github.com/NousResearch/hermes-agent/pull/19742))
|
||||
|
||||
### QQBot
|
||||
- **Port SDK improvements in-tree — chunked upload, approval keyboards, quoted attachments** ([#21342](https://github.com/NousResearch/hermes-agent/pull/21342))
|
||||
- **Wire native tool-approval UX via inline keyboards** ([#21353](https://github.com/NousResearch/hermes-agent/pull/21353))
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Agent & Architecture
|
||||
|
||||
### Provider & Model Support
|
||||
|
||||
#### Pluggable providers
|
||||
- **ProviderProfile ABC + `plugins/model-providers/`** — inference providers are now a pluggable surface (salvage of #14424) ([#20324](https://github.com/NousResearch/hermes-agent/pull/20324))
|
||||
- **`list_picker_providers`** — credential-filtered picker (salvage #13561) ([#20298](https://github.com/NousResearch/hermes-agent/pull/20298))
|
||||
- **Remove `/provider` alias for `/model`** ([#20358](https://github.com/NousResearch/hermes-agent/pull/20358))
|
||||
- **Shared Hermes dotenv loader across CLI + plugins** (salvage #13660) ([#20281](https://github.com/NousResearch/hermes-agent/pull/20281))
|
||||
- **Nous OAuth persisted across profiles via shared token store** ([#19712](https://github.com/NousResearch/hermes-agent/pull/19712))
|
||||
|
||||
#### New models
|
||||
- `deepseek/deepseek-v4-pro` added to OpenRouter + Nous Portal ([#20495](https://github.com/NousResearch/hermes-agent/pull/20495))
|
||||
- `x-ai/grok-4.3` added to OpenRouter + Nous Portal ([#20497](https://github.com/NousResearch/hermes-agent/pull/20497))
|
||||
- `openrouter/owl-alpha` (free tier) added to curated OpenRouter list ([#18071](https://github.com/NousResearch/hermes-agent/pull/18071))
|
||||
- `tencent/hy3-preview` paid route on OpenRouter (@Contentment003111) ([#21077](https://github.com/NousResearch/hermes-agent/pull/21077))
|
||||
- Arcee Trinity Large Thinking — temperature + compression overrides ([#20473](https://github.com/NousResearch/hermes-agent/pull/20473))
|
||||
- Rename `x-ai/grok-4.20-beta` to `x-ai/grok-4.20` ([#19640](https://github.com/NousResearch/hermes-agent/pull/19640))
|
||||
- Demote Vercel AI Gateway to bottom of provider picker ([#18112](https://github.com/NousResearch/hermes-agent/pull/18112))
|
||||
|
||||
#### Provider configuration
|
||||
- **OpenRouter — response caching support** (@kshitijk4poor) ([#19132](https://github.com/NousResearch/hermes-agent/pull/19132))
|
||||
- **`image_gen.model` from config.yaml honored** (salvage #19376) ([#21273](https://github.com/NousResearch/hermes-agent/pull/21273))
|
||||
- Fix: honor runtime default model during delegate provider resolution (@johnncenae) ([#17587](https://github.com/NousResearch/hermes-agent/pull/17587))
|
||||
- Fix: avoid Bedrock credential probe in provider picker (@helix4u) ([#18998](https://github.com/NousResearch/hermes-agent/pull/18998))
|
||||
- Fix: drop stale env-var override of persisted provider for cron ([#19627](https://github.com/NousResearch/hermes-agent/pull/19627))
|
||||
- Fix: auxiliary curator api_key/base_url into runtime resolution ([#19421](https://github.com/NousResearch/hermes-agent/pull/19421))
|
||||
|
||||
### Agent Loop & Conversation
|
||||
- **`video_analyze` — native video understanding tool** (@alt-glitch) ([#19301](https://github.com/NousResearch/hermes-agent/pull/19301))
|
||||
- **Show context compression count in status bar** (CLI + TUI) ([#21218](https://github.com/NousResearch/hermes-agent/pull/21218))
|
||||
- **Isolate `get_tool_definitions` quiet_mode cache + dedup LCM injection** (#17335) ([#17889](https://github.com/NousResearch/hermes-agent/pull/17889))
|
||||
- Fix: warning-first tool-call loop guardrails ([#18227](https://github.com/NousResearch/hermes-agent/pull/18227))
|
||||
- Fix: break permanent empty-response loop from orphan tool-tail ([#21385](https://github.com/NousResearch/hermes-agent/pull/21385))
|
||||
- Fix: propagate ContextVars to concurrent tool worker threads (salvage #16660) ([#18123](https://github.com/NousResearch/hermes-agent/pull/18123))
|
||||
- Fix: surface self-improvement review summaries across CLI, TUI, and gateway ([#18073](https://github.com/NousResearch/hermes-agent/pull/18073))
|
||||
- Fix: serialize concurrent `hermes_tools` RPC calls from `execute_code` ([#17894](https://github.com/NousResearch/hermes-agent/pull/17894), [#17902](https://github.com/NousResearch/hermes-agent/pull/17902))
|
||||
- Fix: include system prompt + tool schemas in token estimates for compression ([#18265](https://github.com/NousResearch/hermes-agent/pull/18265))
|
||||
|
||||
### Compression
|
||||
- Fix: skip non-string tool content in dedup pass to prevent AttributeError ([#19398](https://github.com/NousResearch/hermes-agent/pull/19398))
|
||||
- Fix: reset `_summary_failure_cooldown_until` on session reset ([#19622](https://github.com/NousResearch/hermes-agent/pull/19622))
|
||||
- Fix: trigger fallback on timeout errors alongside model-unavailable errors ([#19665](https://github.com/NousResearch/hermes-agent/pull/19665))
|
||||
- Fix: `_prune_old_tool_results` boundary direction ([#19725](https://github.com/NousResearch/hermes-agent/pull/19725))
|
||||
- Fix: soften summary prompt for content filters (salvage #19456) ([#21302](https://github.com/NousResearch/hermes-agent/pull/21302))
|
||||
|
||||
### Delegate
|
||||
- Fix: inherit parent fallback_chain in `_build_child_agent` ([#19601](https://github.com/NousResearch/hermes-agent/pull/19601))
|
||||
- Fix: guard `_load_config()` against `delegation: null` in config.yaml ([#19662](https://github.com/NousResearch/hermes-agent/pull/19662))
|
||||
- Fix: inherit parent api_key when `delegation.base_url` set without `delegation.api_key` ([#19741](https://github.com/NousResearch/hermes-agent/pull/19741))
|
||||
- Fix: expand composite toolsets before intersection (salvage #19455) ([#21300](https://github.com/NousResearch/hermes-agent/pull/21300))
|
||||
- Fix: correct ACP docs — Claude Code CLI has no --acp flag (salvage #19058) ([#21201](https://github.com/NousResearch/hermes-agent/pull/21201))
|
||||
|
||||
### Session & Memory
|
||||
- **Hindsight — probe API for `update_mode='append'` to dedupe across processes** (@nicoloboschi) ([#20222](https://github.com/NousResearch/hermes-agent/pull/20222))
|
||||
|
||||
### Curator
|
||||
- **`hermes curator archive` and `prune` subcommands** ([#20200](https://github.com/NousResearch/hermes-agent/pull/20200))
|
||||
- **`hermes curator list-archived`** (#20651) ([#21236](https://github.com/NousResearch/hermes-agent/pull/21236))
|
||||
- **Synchronous manual `hermes curator run`** (#20555) ([#21216](https://github.com/NousResearch/hermes-agent/pull/21216))
|
||||
- Fix: preserve `last_report_path` in state ([#18169](https://github.com/NousResearch/hermes-agent/pull/18169))
|
||||
- Fix: rewrite cron job skill refs after consolidation ([#18253](https://github.com/NousResearch/hermes-agent/pull/18253))
|
||||
- Fix: defer first run + `--dry-run` preview (#18373) ([#18389](https://github.com/NousResearch/hermes-agent/pull/18389))
|
||||
- Fix: authoritative `absorbed_into` on delete + restore cron skill links on rollback (#18671) ([#18731](https://github.com/NousResearch/hermes-agent/pull/18731))
|
||||
- Fix: prevent false-positive consolidation from substring matching ([#19573](https://github.com/NousResearch/hermes-agent/pull/19573))
|
||||
- Fix: only mark agent-created for background-review sediment ([#19621](https://github.com/NousResearch/hermes-agent/pull/19621))
|
||||
- Fix: protect hub skills by frontmatter name ([#20194](https://github.com/NousResearch/hermes-agent/pull/20194))
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tool System
|
||||
|
||||
### File tools
|
||||
- **Post-write delta lint on `write_file` + `patch`** — in-proc linters for Python, JSON, YAML, TOML ([#20191](https://github.com/NousResearch/hermes-agent/pull/20191))
|
||||
|
||||
### Cron
|
||||
- **`no_agent` mode — script-only cron jobs (watchdog pattern)** ([#19709](https://github.com/NousResearch/hermes-agent/pull/19709))
|
||||
- **`context_from` chaining docs** (salvage #15724) ([#20394](https://github.com/NousResearch/hermes-agent/pull/20394))
|
||||
- Fix: treat non-dict origin as missing instead of crashing tick ([#19283](https://github.com/NousResearch/hermes-agent/pull/19283))
|
||||
- Fix: bump skill usage when cron jobs load skills ([#19433](https://github.com/NousResearch/hermes-agent/pull/19433))
|
||||
- Fix: recover null `next_run_at` jobs ([#19576](https://github.com/NousResearch/hermes-agent/pull/19576))
|
||||
- Fix: skip AI call when prerun script produces no output ([#19628](https://github.com/NousResearch/hermes-agent/pull/19628))
|
||||
- Fix: expand config.yaml refs during job execution ([#19872](https://github.com/NousResearch/hermes-agent/pull/19872))
|
||||
- Fix: serialize `get_due_jobs` writes to prevent parallel state corruption ([#19874](https://github.com/NousResearch/hermes-agent/pull/19874))
|
||||
- Fix: initialize MCP servers before constructing the cron AIAgent ([#21354](https://github.com/NousResearch/hermes-agent/pull/21354))
|
||||
|
||||
### MCP
|
||||
- **SSE transport support** (salvage #19135) ([#21227](https://github.com/NousResearch/hermes-agent/pull/21227))
|
||||
- **Forward OAuth auth + bump `sse_read_timeout` on SSE transport** ([#21323](https://github.com/NousResearch/hermes-agent/pull/21323))
|
||||
- **Retry stale pipe transport failures as session-expired** ([#21289](https://github.com/NousResearch/hermes-agent/pull/21289))
|
||||
- **Surface image tool results as MEDIA tags instead of dropping them** ([#21328](https://github.com/NousResearch/hermes-agent/pull/21328))
|
||||
- **Periodic keepalive to `_wait_for_lifecycle_event`** (salvage #17016) ([#20209](https://github.com/NousResearch/hermes-agent/pull/20209))
|
||||
- Fix: reconnect on terminated sessions ([#19380](https://github.com/NousResearch/hermes-agent/pull/19380))
|
||||
- Fix: decouple AnyUrl import from mcp dependency ([#19695](https://github.com/NousResearch/hermes-agent/pull/19695))
|
||||
- Fix: `mcp add --command` gets distinct argparse dest ([#21204](https://github.com/NousResearch/hermes-agent/pull/21204))
|
||||
- Fix: clear stale thread interrupt before MCP discovery ([#21276](https://github.com/NousResearch/hermes-agent/pull/21276))
|
||||
- Fix: report configured timeout in MCP call errors ([#21281](https://github.com/NousResearch/hermes-agent/pull/21281))
|
||||
- Fix: include exception type in error messages when str(exc) is empty (salvage #19425) ([#21292](https://github.com/NousResearch/hermes-agent/pull/21292))
|
||||
- Fix: re-raise CancelledError explicitly in `MCPServerTask.run` ([#21318](https://github.com/NousResearch/hermes-agent/pull/21318))
|
||||
- Fix: coerce numeric tool args defensively in `mcp_serve` ([#21329](https://github.com/NousResearch/hermes-agent/pull/21329))
|
||||
- Fix: gate utility stubs on server-advertised capabilities ([#21347](https://github.com/NousResearch/hermes-agent/pull/21347))
|
||||
|
||||
### Browser
|
||||
- Fix: allow explicit CDP override without local agent-browser ([#19670](https://github.com/NousResearch/hermes-agent/pull/19670))
|
||||
- Fix: inject `--no-sandbox` for root + AppArmor userns restrictions ([#19747](https://github.com/NousResearch/hermes-agent/pull/19747))
|
||||
- Fix: tighten Lightpanda fallback edge cases (@kshitijk4poor) ([#20672](https://github.com/NousResearch/hermes-agent/pull/20672))
|
||||
|
||||
### Web tools
|
||||
- **Per-capability backend selection — search/extract split** (@kshitijk4poor) ([#20061](https://github.com/NousResearch/hermes-agent/pull/20061))
|
||||
- **SearXNG native search-only backend** (@kshitijk4poor) ([#20823](https://github.com/NousResearch/hermes-agent/pull/20823))
|
||||
|
||||
### Approval / Tool gating
|
||||
- Fix: wake blocked gateway approvals on session cleanup ([#18171](https://github.com/NousResearch/hermes-agent/pull/18171))
|
||||
- Fix: harden YOLO mode env parsing against quoted-bool strings ([#18214](https://github.com/NousResearch/hermes-agent/pull/18214))
|
||||
- Fix: extend sensitive write target to cover shell RC and credential files ([#19282](https://github.com/NousResearch/hermes-agent/pull/19282))
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Plugin System
|
||||
|
||||
- **`transform_llm_output` plugin hook** (salvage of #20813) ([#21235](https://github.com/NousResearch/hermes-agent/pull/21235))
|
||||
- **Document `env_enablement_fn` + `cron_deliver_env_var` platform-plugin hooks** ([#21331](https://github.com/NousResearch/hermes-agent/pull/21331))
|
||||
- **Pluggable surfaces coverage — model-provider guide, full plugin map, opt-in fix** ([#20749](https://github.com/NousResearch/hermes-agent/pull/20749))
|
||||
- **Plugin-authoring gaps — image-gen provider guide + publishing a skill tap** ([#20800](https://github.com/NousResearch/hermes-agent/pull/20800))
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Skills Ecosystem
|
||||
|
||||
### New optional skills
|
||||
- **Shopify** — Admin + Storefront GraphQL optional skill ([#18116](https://github.com/NousResearch/hermes-agent/pull/18116))
|
||||
- **here.now** — optional skill ([#18170](https://github.com/NousResearch/hermes-agent/pull/18170))
|
||||
- **shop-app** — personal shopping assistant (optional) ([#20702](https://github.com/NousResearch/hermes-agent/pull/20702))
|
||||
- **Anthropic financial-services bundle** — ported as optional finance skills ([#21180](https://github.com/NousResearch/hermes-agent/pull/21180))
|
||||
- **kanban-video-orchestrator** — creative optional skill (@SHL0MS) ([#19281](https://github.com/NousResearch/hermes-agent/pull/19281))
|
||||
- **searxng-search** — optional skill + Web Search + Extract docs page (@kshitijk4poor) ([#20841](https://github.com/NousResearch/hermes-agent/pull/20841), [#20844](https://github.com/NousResearch/hermes-agent/pull/20844))
|
||||
|
||||
### Skill UX
|
||||
- **Linear skill — add Documents support + Python helper script** ([#20752](https://github.com/NousResearch/hermes-agent/pull/20752))
|
||||
- **Modernize Obsidian skill to use file tools** (salvage #19332) ([#20413](https://github.com/NousResearch/hermes-agent/pull/20413))
|
||||
- **Default custom tool creation to plugins** (@kshitijk4poor) ([#19755](https://github.com/NousResearch/hermes-agent/pull/19755))
|
||||
- **skill_commands cache — rescan on platform scope changes** (salvage #14570 by @LeonSGP43) ([#18739](https://github.com/NousResearch/hermes-agent/pull/18739))
|
||||
- **Skills — additional rescan paths in skill_commands cache** (salvage #19042) ([#21181](https://github.com/NousResearch/hermes-agent/pull/21181))
|
||||
- Fix: regression tests for non-dict metadata in `extract_skill_conditions` ([#18213](https://github.com/NousResearch/hermes-agent/pull/18213))
|
||||
- Docs: explain restoring bundled skills (salvage #19254) ([#20404](https://github.com/NousResearch/hermes-agent/pull/20404))
|
||||
- Docs: document `hermes skills reset` subcommand (salvage #11544) ([#20395](https://github.com/NousResearch/hermes-agent/pull/20395))
|
||||
- Docs: himalaya v1.2.0 `folder.aliases` syntax ([#19882](https://github.com/NousResearch/hermes-agent/pull/19882))
|
||||
- Point agent at `hermes-agent` skill + docs site sync ([#20390](https://github.com/NousResearch/hermes-agent/pull/20390))
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
|
||||
### CLI
|
||||
- **`/new` accepts optional session name argument** (salvage of #19555) ([#19637](https://github.com/NousResearch/hermes-agent/pull/19637))
|
||||
- **100 new CLI startup tips** ([#20168](https://github.com/NousResearch/hermes-agent/pull/20168))
|
||||
- **`display.language` — static message translation** (zh/ja/de/es) ([#20231](https://github.com/NousResearch/hermes-agent/pull/20231))
|
||||
- **French (fr) locale** (@Foolafroos) ([#20329](https://github.com/NousResearch/hermes-agent/pull/20329))
|
||||
- **Ukrainian (uk) locale** ([#20467](https://github.com/NousResearch/hermes-agent/pull/20467))
|
||||
- **Turkish (tr) locale** ([#20474](https://github.com/NousResearch/hermes-agent/pull/20474))
|
||||
- Fix: recover classic CLI output after resize (@helix4u) ([#20444](https://github.com/NousResearch/hermes-agent/pull/20444))
|
||||
- Fix: complete absolute paths as paths (@helix4u) ([#19930](https://github.com/NousResearch/hermes-agent/pull/19930))
|
||||
- Fix: resolve lazy session creation regressions (#18370 fallout) (@alt-glitch) ([#20363](https://github.com/NousResearch/hermes-agent/pull/20363))
|
||||
- Fix: local backend CLI always uses launch directory (@alt-glitch) ([#19334](https://github.com/NousResearch/hermes-agent/pull/19334))
|
||||
- Refactor: drop dead c-S-c key binding (follow-up to #19895) ([#19919](https://github.com/NousResearch/hermes-agent/pull/19919))
|
||||
|
||||
### TUI (Ink)
|
||||
- **`/model` picker overhaul to match `hermes model` with inline auth** (@austinpickett) ([#18117](https://github.com/NousResearch/hermes-agent/pull/18117))
|
||||
- **Collapsible sections in startup banner** — skills, system prompt, MCP (@kshitijk4poor) ([#20625](https://github.com/NousResearch/hermes-agent/pull/20625))
|
||||
- **Show context compression count in status bar** ([#21218](https://github.com/NousResearch/hermes-agent/pull/21218))
|
||||
- Perf: reduce overlay render churn with focused selectors (@OutThisLife) ([#20393](https://github.com/NousResearch/hermes-agent/pull/20393))
|
||||
- Fix: restore voice push-to-talk parity (salvage of #16189 by @Montbra) (@OutThisLife) ([#20897](https://github.com/NousResearch/hermes-agent/pull/20897))
|
||||
- Fix: kanban button (@austinpickett) ([#18358](https://github.com/NousResearch/hermes-agent/pull/18358))
|
||||
|
||||
### Dashboard
|
||||
- **Plugins page — manage, enable/disable, auth status** (@austinpickett) ([#18095](https://github.com/NousResearch/hermes-agent/pull/18095))
|
||||
- **Profiles management page** (@vincez-hms-coder) ([#16419](https://github.com/NousResearch/hermes-agent/pull/16419))
|
||||
- **Interactive column sorting in analytics tables** ([#18192](https://github.com/NousResearch/hermes-agent/pull/18192))
|
||||
- **`default-large` built-in theme with 18px base size** ([#20820](https://github.com/NousResearch/hermes-agent/pull/20820))
|
||||
- **Support serving under URL prefix via `X-Forwarded-Prefix`** (salvage #19450) ([#21296](https://github.com/NousResearch/hermes-agent/pull/21296))
|
||||
- **Launch dashboard as side-process via `HERMES_DASHBOARD=1` in Docker** (@benbarclay) ([#19540](https://github.com/NousResearch/hermes-agent/pull/19540))
|
||||
- Fix: dashboard theme layout shift (@AllardQuek) ([#17232](https://github.com/NousResearch/hermes-agent/pull/17232))
|
||||
- Fix: gateway model picker current context (@helix4u) ([#20513](https://github.com/NousResearch/hermes-agent/pull/20513))
|
||||
|
||||
### Update + setup
|
||||
- **`hermes update --yes/-y` to skip interactive prompts** ([#18261](https://github.com/NousResearch/hermes-agent/pull/18261))
|
||||
- **Restart manual profile gateways after update** ([#18178](https://github.com/NousResearch/hermes-agent/pull/18178))
|
||||
|
||||
### Profiles
|
||||
- **`--no-skills` flag for empty profile creation** ([#20986](https://github.com/NousResearch/hermes-agent/pull/20986))
|
||||
|
||||
---
|
||||
|
||||
## 🎵 Voice, Image & Media
|
||||
|
||||
- **xAI Custom Voices — voice cloning** (@alt-glitch) ([#18776](https://github.com/NousResearch/hermes-agent/pull/18776))
|
||||
- **Achievements — share card render on unlocked badges** ([#19657](https://github.com/NousResearch/hermes-agent/pull/19657))
|
||||
- **Refresh systemd unit on gateway boot (not just start/restart)** (@alt-glitch) ([#19684](https://github.com/NousResearch/hermes-agent/pull/19684))
|
||||
|
||||
---
|
||||
|
||||
## 🔗 API Server & Remote Access
|
||||
|
||||
- **`X-Hermes-Session-Key` header for long-term memory scoping** (closes #20060) ([#20199](https://github.com/NousResearch/hermes-agent/pull/20199))
|
||||
|
||||
---
|
||||
|
||||
## 🧰 ACP Adapter (VS Code / Zed / JetBrains)
|
||||
|
||||
- **`/steer` and `/queue` slash commands** (@HenkDz) ([#18114](https://github.com/NousResearch/hermes-agent/pull/18114))
|
||||
- Fix: translate Windows cwd for WSL sessions (salvage #18128) ([#18233](https://github.com/NousResearch/hermes-agent/pull/18233))
|
||||
- Fix: run `/steer` as a regular prompt on idle sessions ([#18258](https://github.com/NousResearch/hermes-agent/pull/18258))
|
||||
- Fix: route Zed thoughts to reasoning + polish tool/context rendering ([#19139](https://github.com/NousResearch/hermes-agent/pull/19139))
|
||||
- Fix: atomic session persistence via `replace_messages` (salvage #13675) ([#20279](https://github.com/NousResearch/hermes-agent/pull/20279))
|
||||
- Fix: preserve assistant reasoning metadata in session persistence (salvage #13575) ([#20296](https://github.com/NousResearch/hermes-agent/pull/20296))
|
||||
- Docs: update VS Code setup for ACP Client extension (salvage #12495) ([#20433](https://github.com/NousResearch/hermes-agent/pull/20433))
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
- **Launch dashboard as side-process via `HERMES_DASHBOARD=1`** (@benbarclay) ([#19540](https://github.com/NousResearch/hermes-agent/pull/19540))
|
||||
- **Refuse root gateway runs in official image** (salvage #19215) ([#21250](https://github.com/NousResearch/hermes-agent/pull/21250))
|
||||
- **Chown runtime `node_modules` trees to hermes user** (salvage #19303) ([#21267](https://github.com/NousResearch/hermes-agent/pull/21267))
|
||||
- Fix: exclude compose/profile runtime state from build context ([#19626](https://github.com/NousResearch/hermes-agent/pull/19626))
|
||||
- CI: don't cancel overlapping builds, guard `:latest` (@ethernet8023) ([#20890](https://github.com/NousResearch/hermes-agent/pull/20890))
|
||||
- Test: align Dockerfile contract tests with simplified TUI flow (salvage #19024) ([#21174](https://github.com/NousResearch/hermes-agent/pull/21174))
|
||||
- Docs: connect to local inference servers (vLLM, Ollama) (salvage #12335) ([#20407](https://github.com/NousResearch/hermes-agent/pull/20407))
|
||||
- Docs: document `API_SERVER_*` env vars (salvage #11758) ([#20409](https://github.com/NousResearch/hermes-agent/pull/20409))
|
||||
- Docs: clarify Docker terminal backend is a single persistent container ([#20003](https://github.com/NousResearch/hermes-agent/pull/20003))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Notable Bug Fixes
|
||||
|
||||
### Agent
|
||||
- Fix: recover lazy session creation regressions (#18370 fallout) (@alt-glitch) ([#20363](https://github.com/NousResearch/hermes-agent/pull/20363))
|
||||
- Fix: propagate ContextVars to concurrent tool worker threads (salvage #16660) ([#18123](https://github.com/NousResearch/hermes-agent/pull/18123))
|
||||
- Fix: warning-first tool-call loop guardrails ([#18227](https://github.com/NousResearch/hermes-agent/pull/18227))
|
||||
- Fix: surface self-improvement review summaries across CLI, TUI, and gateway ([#18073](https://github.com/NousResearch/hermes-agent/pull/18073))
|
||||
|
||||
### Gateway streaming
|
||||
- Fix: harden StreamingConfig bool and numeric coercion (@simbam99) ([#16463](https://github.com/NousResearch/hermes-agent/pull/16463))
|
||||
|
||||
### Model
|
||||
- Fix: avoid Bedrock credential probe in provider picker (@helix4u) ([#18998](https://github.com/NousResearch/hermes-agent/pull/18998))
|
||||
|
||||
### Doctor
|
||||
- Fix: check global agent-browser when local install not found ([#19671](https://github.com/NousResearch/hermes-agent/pull/19671))
|
||||
- Test: kimi-coding-cn provider validation regression ([#19734](https://github.com/NousResearch/hermes-agent/pull/19734))
|
||||
|
||||
### Update
|
||||
- Fix: patch `isatty` on real streams to fix xdist-flaky `--yes` tests (salvage #19026) ([#21175](https://github.com/NousResearch/hermes-agent/pull/21175))
|
||||
- Fix: teach restart-mocks about the post-update survivor sweep (salvage #19031) ([#21177](https://github.com/NousResearch/hermes-agent/pull/21177))
|
||||
|
||||
### Auth
|
||||
- Fix: acp preserve assistant reasoning metadata ([#20296](https://github.com/NousResearch/hermes-agent/pull/20296))
|
||||
|
||||
### Redact
|
||||
- Fix: add `code_file` param to skip false-positive ENV/JSON patterns ([#19715](https://github.com/NousResearch/hermes-agent/pull/19715))
|
||||
|
||||
### Email
|
||||
- Fix: quoted-relative file-drop paths + Date header on tool email path ([#19646](https://github.com/NousResearch/hermes-agent/pull/19646))
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
- **ACP — accept prompt persistence kwargs in MCP E2E mocks** (@stephenschoettler) ([#18047](https://github.com/NousResearch/hermes-agent/pull/18047))
|
||||
- **Toolsets — include kanban in expected post-#17805 toolset assertions** (@briandevans) ([#18122](https://github.com/NousResearch/hermes-agent/pull/18122))
|
||||
- **Agent — cover max-iterations summary message sanitization** ([#19580](https://github.com/NousResearch/hermes-agent/pull/19580))
|
||||
- **run_agent — `-inf` and `nan` regression coverage for `_coerce_number`** ([#19703](https://github.com/NousResearch/hermes-agent/pull/19703))
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Major docs additions
|
||||
- **`llms.txt` + `llms-full.txt` — agent-friendly ingestion** ([#18276](https://github.com/NousResearch/hermes-agent/pull/18276))
|
||||
- **User Stories and Use Cases collage page** ([#18282](https://github.com/NousResearch/hermes-agent/pull/18282))
|
||||
- **Persistent Goals (/goal) feature page** ([#18275](https://github.com/NousResearch/hermes-agent/pull/18275))
|
||||
- **Windows (WSL2) guide expansion** — filesystem, networking, services, pitfalls ([#20748](https://github.com/NousResearch/hermes-agent/pull/20748))
|
||||
- **Chinese (zh-CN) README translation** (salvage #13508) ([#20431](https://github.com/NousResearch/hermes-agent/pull/20431))
|
||||
- **zh-Hans Docusaurus locale** + Tool Gateway / image-gen / WSL quickstart translations (salvage #11728) ([#20430](https://github.com/NousResearch/hermes-agent/pull/20430))
|
||||
- **Tool Gateway docs restructure** — lead with what it does, config moved to bottom ([#20827](https://github.com/NousResearch/hermes-agent/pull/20827))
|
||||
- **Quickstart — Onchain AI Garage Hermes tutorials playlist** ([#20192](https://github.com/NousResearch/hermes-agent/pull/20192))
|
||||
- **Open WebUI bootstrap script** (salvage #9566) ([#20427](https://github.com/NousResearch/hermes-agent/pull/20427))
|
||||
- **Local Ollama setup guide** (salvage #5842) ([#20426](https://github.com/NousResearch/hermes-agent/pull/20426))
|
||||
- **Google Gemini guide** (salvage #17450) ([#20401](https://github.com/NousResearch/hermes-agent/pull/20401))
|
||||
- **Custom model aliases for /model command** ([#20475](https://github.com/NousResearch/hermes-agent/pull/20475))
|
||||
- **Together/Groq/Perplexity cookbook via `custom_providers`** (salvage #15214) ([#20400](https://github.com/NousResearch/hermes-agent/pull/20400))
|
||||
- **Doubao speech integration examples** (TTS + STT) (salvage #18065) ([#20418](https://github.com/NousResearch/hermes-agent/pull/20418))
|
||||
- **WSL-to-Windows Chrome MCP bridge** (salvage #8313) ([#20428](https://github.com/NousResearch/hermes-agent/pull/20428))
|
||||
- **Hermes skills docs sync** — slash commands + durable-systems section ([#20390](https://github.com/NousResearch/hermes-agent/pull/20390))
|
||||
- **AGENTS.md — curator/cron/delegation/toolsets + fix plugin tree** ([#20226](https://github.com/NousResearch/hermes-agent/pull/20226))
|
||||
- **Bedrock quickstart entry + fallback comment + deployment link** (salvage #11093) ([#20397](https://github.com/NousResearch/hermes-agent/pull/20397))
|
||||
|
||||
### Docs polish
|
||||
- Collapse exploding skills tree to a single Skills node ([#18259](https://github.com/NousResearch/hermes-agent/pull/18259))
|
||||
- Clarify `session_search` auxiliary model docs ([#19593](https://github.com/NousResearch/hermes-agent/pull/19593))
|
||||
- Open WebUI Quick Setup gap fill ([#19654](https://github.com/NousResearch/hermes-agent/pull/19654))
|
||||
- Default custom tool creation to plugins (@kshitijk4poor) ([#19755](https://github.com/NousResearch/hermes-agent/pull/19755))
|
||||
- Clarify Telegram group chat troubleshooting (salvage #18672) ([#20416](https://github.com/NousResearch/hermes-agent/pull/20416))
|
||||
- Codex OAuth auth prerequisite clarification (salvage #18688) ([#20417](https://github.com/NousResearch/hermes-agent/pull/20417))
|
||||
- Discord Server Members Intent + SSRC-mapping drift + /voice join slash Choice (salvage #11350) ([#20411](https://github.com/NousResearch/hermes-agent/pull/20411))
|
||||
- Document `ctx.dispatch_tool()` (salvage #10955) ([#20391](https://github.com/NousResearch/hermes-agent/pull/20391))
|
||||
- Document `hermes webhook subscribe --deliver-only` (salvage #12612) ([#20392](https://github.com/NousResearch/hermes-agent/pull/20392))
|
||||
- Document `hermes import` reference (salvage #14711) ([#20396](https://github.com/NousResearch/hermes-agent/pull/20396))
|
||||
- Document per-provider TTS `max_text_length` caps (salvage #13825) ([#20389](https://github.com/NousResearch/hermes-agent/pull/20389))
|
||||
- Clarify supported prompt customization surfaces (salvage #19987) ([#20383](https://github.com/NousResearch/hermes-agent/pull/20383))
|
||||
- Correct `web_extract` summarizer timeout comment (salvage #20051) ([#20381](https://github.com/NousResearch/hermes-agent/pull/20381))
|
||||
- Fix fallback provider config paths (salvage #20033) ([#20382](https://github.com/NousResearch/hermes-agent/pull/20382))
|
||||
- Fix misleading RL install-extras claim (salvage #19080) ([#21213](https://github.com/NousResearch/hermes-agent/pull/21213))
|
||||
- Clarify API server tool execution locality (salvage #19117) ([#21223](https://github.com/NousResearch/hermes-agent/pull/21223))
|
||||
- Prefer `.venv` to match AGENTS.md and scripts/run_tests.sh (@xxxigm) ([#21334](https://github.com/NousResearch/hermes-agent/pull/21334))
|
||||
- Align tool discovery + test runner with AGENTS.md (@xxxigm) ([#20791](https://github.com/NousResearch/hermes-agent/pull/20791))
|
||||
- Align terminal-backend count and naming across docs and code (salvage #19044) ([#20402](https://github.com/NousResearch/hermes-agent/pull/20402))
|
||||
- Refresh stale platform counts (salvage #19053) ([#20403](https://github.com/NousResearch/hermes-agent/pull/20403))
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- **@teknium1** — salvage, triage, review, feature work, and release management
|
||||
|
||||
### Top Community Contributors
|
||||
|
||||
- **@kshitijk4poor** (21 PRs) — SearXNG native search backend, per-capability backend selection, collapsible TUI startup banner, Slack ephemeral ack + format fixes, Lightpanda fallback hardening, searxng-search optional skill + Web Search + Extract docs, default custom tool creation to plugins, kanban failure-column fix
|
||||
- **@alt-glitch** (13 PRs) — video_analyze tool, xAI Custom Voices (voice cloning), local-backend CLI launch-directory fix, lazy-session creation regression recovery, systemd unit refresh on gateway boot
|
||||
- **@OutThisLife** (9 PRs) — TUI perf — overlay render churn reduction, voice push-to-talk parity restoration (salvaging @Montbra)
|
||||
- **@helix4u** (6 PRs) — Classic CLI output recovery after resize, absolute-path TUI completion, gateway model picker current-context fix, Bedrock credential probe avoidance, kanban docs fixes
|
||||
- **@ethernet8023** (3 PRs) — Docker CI — don't cancel overlapping builds, :latest guard
|
||||
- **@benbarclay** (3 PRs) — Docker — launch dashboard as side-process via HERMES_DASHBOARD=1
|
||||
- **@austinpickett** (3 PRs) — Dashboard Plugins page, TUI /model picker overhaul with inline auth, kanban button fix
|
||||
- **@sprmn24** (2 PRs) — Contributor (2 PRs)
|
||||
- **@asheriif** (2 PRs) — Contributor (2 PRs)
|
||||
- **@xxxigm** (2 PRs) — Contributing docs — .venv preference and test runner alignment with AGENTS.md
|
||||
- **@stephenschoettler** (1 PR) — ACP — MCP E2E mock kwargs
|
||||
- **@vincez-hms-coder** (1 PR) — Dashboard — Profiles management page
|
||||
- **@cdanis** (1 PR) — Contributor
|
||||
- **@briandevans** (1 PR) — Toolsets test — kanban assertions post-#17805
|
||||
- **@heyitsaamir** (1 PR) — Contributor
|
||||
|
||||
### All Contributors
|
||||
|
||||
Thanks to everyone who contributed to v0.13.0 — commits, co-authored work, and salvaged PRs. 295 contributors in one week.
|
||||
|
||||
@0oAstro, @0xDevNinja, @0xharryriddle, @0xKingBack, @0xsir0000, @0xyg3n, @0z1-ghb, @abhinav11082001-stack,
|
||||
@acc001k, @acesjohnny, @adamludwin, @adybag14-cyber, @agentlinker, @agilejava, @ai-ag2026, @AJV20,
|
||||
@alanxchen85, @albert748, @AllardQuek, @alt-glitch, @altmazza0-star, @ambition0802, @amitgaur, @amroessam,
|
||||
@andrewhosf, @Asce66, @asheriif, @ashermorse, @asimons81, @Aslaaen, @Asunfly, @atongrun, @austinpickett,
|
||||
@banditburai, @barteqpl, @Bartok9, @Beandon13, @beardthelion, @beibi9966, @benbarclay, @binhnt92, @bjianhang,
|
||||
@BlackJulySnow, @bobashopcashier, @bogerman1, @Bongulielmi, @Brecht-H, @briandevans, @brooklynnicholson,
|
||||
@c3115644151, @camaragon, @CashWilliams, @CCClelo, @cdanis, @CES4751, @cg2aigc, @changchun989, @ChanlerDev,
|
||||
@CharlieKerfoot, @chengoak, @chenyunbo411, @chinadbo, @CIRWEL, @cixuuz, @cmcgrabby-hue, @colorcross,
|
||||
@Contentment003111, @CoreyNoDream, @counterposition, @curiouscleo, @DaniuXie, @deep-name, @dengtaoyuan450-a11y,
|
||||
@discodirector, @donramon77, @dpaluy, @ee-blog, @ehz0ah, @el-analista, @elmatadorgh, @EmelyanenkoK,
|
||||
@Emidomenge, @emozilla, @Es1la, @EthanGuo-coder, @etherman-os, @ethernet8023, @EvilDrag0n, @exxmen, @Fearvox,
|
||||
@Feranmi10, @firefly, @flobo3, @fmercurio, @Foolafroos, @formulahendry, @franksong2702, @ggnnggez, @GinWU05,
|
||||
@giwaov, @glesperance, @gnanirahulnutakki, @GodsBoy, @Gosuj, @Grey0202, @guillaumemeyer, @Gutslabs, @h0tp-ftw,
|
||||
@haidao1919, @halmisen, @happy5318, @hedirman, @helix4u, @hendrixfreire, @HenkDz, @hex-clawd, @heyitsaamir,
|
||||
@hharry11, @Hinotoi-agent, @holynn-q, @hrkzogw, @Hypn0sis, @Hypnus-Yuan, @ideathinklab01-source, @IMHaoyan,
|
||||
@Interstellar-code, @ishardo, @jacdevos, @jackey8616, @JanCong, @jasonoutland, @jatingodnani, @JayGwod,
|
||||
@jethac, @JezzaHehn, @JiaDe-Wu, @jjjojoj, @jkausel-ai, @John-tip, @johnncenae, @jrusso1020, @jslizar,
|
||||
@JTroyerOvermatch, @julysir, @Junass1, @JustinUssuri, @Kailigithub, @keepcalmqqf, @kiala9, @konsisumer,
|
||||
@kowenhaoai, @Krionex, @kshitijk4poor, @kyan12, @leavrcn, @leon7609, @LeonSGP43, @leprincep35700, @lhysdl,
|
||||
@likejudy, @lisanhu, @liu-collab, @liuguangyong93, @liuhao1024, @LucianoSP, @luoyuctl, @luyao618, @M3RCUR2Y,
|
||||
@maciekczech, @Magicray1217, @magicray1217, @MaHaoHao-ch, @malaiwah, @manateelazycat, @masonjames, @megastary,
|
||||
@memosr, @MichaelWDanko, @mikeyobrien, @millerc79, @Mind-Dragon, @mioimotoai-lgtm, @misery-hl, @molvikar,
|
||||
@momowind, @Montbra, @MottledShadow, @mrbob-git, @mrcharlesiv, @mrcoferland, @ms-alan, @mwnickerson,
|
||||
@nazirulhafiy, @nftpoetrist, @nicoloboschi, @nightq, @nikolay-bratanov, @NikolayGusev-astra, @nocturnum91,
|
||||
@noOne-list, @nouseman666, @novax635, @npmisantosh, @nudiltoys-cmyk, @olisikh, @oluwadareab12, @Oxidane-bot,
|
||||
@pama0227, @pander, @pasevin, @paul-tian, @pdonizete, @perlowja, @pingchesu, @PratikRai0101, @priveperfumes,
|
||||
@probepark, @QifengKuang, @quocanh261997, @qWaitCrypto, @qxxaa, @r266-tech, @rames-jusso, @revaraver,
|
||||
@Ricardo-M-L, @rob-maron, @Roy-oss1, @rxdxxxx, @SandroHub013, @Sanjays2402, @Sertug17, @shashwatgokhe,
|
||||
@shellybotmoyer, @SHL0MS, @SimbaKingjoe, @simbam99, @simplenamebox-ops, @socrates1024, @sonic-netizen,
|
||||
@sprmn24, @steezkelly, @stephen0110, @stephenschoettler, @stevenchanin, @stevenchouai, @stormhierta,
|
||||
@subtract0, @suncokret12, @swithek, @taeng0204, @TakeshiSawaguchi, @tangyuanjc, @TheEpTic, @thelumiereguy,
|
||||
@Tkander1715, @tmdgusya, @Tranquil-Flow, @TruaShamu, @UgwujaGeorge, @valda, @vincez-hms-coder, @VinVC,
|
||||
@vominh1919, @wabrent, @WadydX, @wanazhar, @WanderWang, @warabe1122, @web-dev0521, @WideLee, @willy-scr,
|
||||
@wmagev, @WuTianyi123, @wxst, @wysie, @Wysie, @xsfX20, @xxxigm, @xyiy001, @YanzhongSu, @ygd58, @Yoimex,
|
||||
@yuehei, @Yukipukii1, @yuqianma, @YX234, @zeejaytan, @zhanggttry, @zhao0112, @zng8418, @zons-zhaozhy, @Zyproth
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.4.30...v2026.5.7](https://github.com/NousResearch/hermes-agent/compare/v2026.4.30...v2026.5.7)
|
||||
+2
-288
@@ -3,16 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict, deque
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any, Deque, Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import acp
|
||||
from acp.schema import (
|
||||
@@ -21,7 +18,6 @@ from acp.schema import (
|
||||
AuthenticateResponse,
|
||||
AvailableCommand,
|
||||
AvailableCommandsUpdate,
|
||||
BlobResourceContents,
|
||||
ClientCapabilities,
|
||||
EmbeddedResourceContentBlock,
|
||||
ForkSessionResponse,
|
||||
@@ -50,7 +46,6 @@ from acp.schema import (
|
||||
SessionResumeCapabilities,
|
||||
SessionInfo,
|
||||
TextContentBlock,
|
||||
TextResourceContents,
|
||||
UnstructuredCommandInput,
|
||||
Usage,
|
||||
UsageUpdate,
|
||||
@@ -88,272 +83,6 @@ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="acp-agent")
|
||||
# does not expose a client-side limit, so this is a fixed cap that clients
|
||||
# paginate against using `cursor` / `next_cursor`.
|
||||
_LIST_SESSIONS_PAGE_SIZE = 50
|
||||
_MAX_ACP_RESOURCE_BYTES = 512 * 1024
|
||||
_TEXT_RESOURCE_MIME_PREFIXES = ("text/",)
|
||||
_TEXT_RESOURCE_MIME_TYPES = {
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/typescript",
|
||||
"application/xml",
|
||||
"application/x-yaml",
|
||||
"application/yaml",
|
||||
"application/toml",
|
||||
"application/sql",
|
||||
}
|
||||
|
||||
|
||||
def _resource_display_name(uri: str, name: str | None = None, title: str | None = None) -> str:
|
||||
"""Human-readable attachment name for prompt context."""
|
||||
raw_name = (name or "").strip()
|
||||
raw_title = (title or "").strip()
|
||||
if raw_title and raw_name and raw_title != raw_name:
|
||||
return f"{raw_title} ({raw_name})"
|
||||
if raw_title:
|
||||
return raw_title
|
||||
if raw_name:
|
||||
return raw_name
|
||||
parsed = urlparse(uri)
|
||||
candidate = parsed.path if parsed.scheme else uri
|
||||
return Path(unquote(candidate)).name or uri or "resource"
|
||||
|
||||
|
||||
def _is_text_resource(mime_type: str | None) -> bool:
|
||||
mime = (mime_type or "").split(";", 1)[0].strip().lower()
|
||||
if not mime:
|
||||
return False
|
||||
return mime.startswith(_TEXT_RESOURCE_MIME_PREFIXES) or mime in _TEXT_RESOURCE_MIME_TYPES
|
||||
|
||||
|
||||
def _is_image_resource(mime_type: str | None) -> bool:
|
||||
mime = (mime_type or "").split(";", 1)[0].strip().lower()
|
||||
return mime.startswith("image/")
|
||||
|
||||
|
||||
def _guess_image_mime_from_path(path: Path) -> str | None:
|
||||
suffix = path.suffix.lower()
|
||||
return {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
}.get(suffix)
|
||||
|
||||
|
||||
def _image_data_url(data: bytes, mime_type: str) -> str:
|
||||
return f"data:{mime_type};base64,{base64.b64encode(data).decode('ascii')}"
|
||||
|
||||
|
||||
def _path_from_file_uri(uri: str) -> Path | None:
|
||||
"""Convert local file URIs/paths from ACP clients into a readable Path.
|
||||
|
||||
Zed may send POSIX file URIs from Linux/WSL workspaces or Windows-ish paths
|
||||
when launched through wsl.exe. Translate the common Windows drive form to
|
||||
/mnt/<drive>/... so Hermes running in WSL can read it.
|
||||
"""
|
||||
raw = (uri or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme and parsed.scheme != "file":
|
||||
return None
|
||||
|
||||
if parsed.scheme == "file":
|
||||
if parsed.netloc and parsed.netloc not in {"", "localhost"}:
|
||||
return None
|
||||
path_text = unquote(parsed.path or "")
|
||||
else:
|
||||
path_text = unquote(raw)
|
||||
|
||||
# file:///C:/Users/... or C:\Users\...
|
||||
if len(path_text) >= 3 and path_text[0] == "/" and path_text[2] == ":" and path_text[1].isalpha():
|
||||
drive = path_text[1].lower()
|
||||
rest = path_text[3:].lstrip("/\\").replace("\\", "/")
|
||||
return Path("/mnt") / drive / rest
|
||||
if len(path_text) >= 2 and path_text[1] == ":" and path_text[0].isalpha():
|
||||
drive = path_text[0].lower()
|
||||
rest = path_text[2:].lstrip("/\\").replace("\\", "/")
|
||||
return Path("/mnt") / drive / rest
|
||||
|
||||
return Path(path_text)
|
||||
|
||||
|
||||
def _decode_text_bytes(data: bytes, mime_type: str | None) -> str | None:
|
||||
"""Decode resource bytes if they are probably text; return None for binary."""
|
||||
if b"\x00" in data and not _is_text_resource(mime_type):
|
||||
return None
|
||||
for encoding in ("utf-8-sig", "utf-8", "latin-1"):
|
||||
try:
|
||||
return data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _format_resource_text(
|
||||
*,
|
||||
uri: str,
|
||||
body: str,
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
note: str | None = None,
|
||||
) -> str:
|
||||
display = _resource_display_name(uri, name=name, title=title)
|
||||
header = f"[Attached file: {display}]"
|
||||
if note:
|
||||
header += f" ({note})"
|
||||
return f"{header}\nURI: {uri}\n\n{body}"
|
||||
|
||||
|
||||
def _resource_link_to_parts(block: ResourceContentBlock) -> list[dict[str, Any]]:
|
||||
"""Convert an ACP resource_link block to OpenAI content parts.
|
||||
|
||||
Returns a list of {"type": "text", ...} and/or {"type": "image_url", ...}
|
||||
parts. Image resources produce an image_url part with a small text header
|
||||
so the model knows which attachment it is. Non-image resources return a
|
||||
single text part with the inlined file body (or a binary-omit note).
|
||||
"""
|
||||
uri = str(getattr(block, "uri", "") or "").strip()
|
||||
if not uri:
|
||||
return []
|
||||
|
||||
name = str(getattr(block, "name", "") or "").strip() or None
|
||||
title = str(getattr(block, "title", "") or "").strip() or None
|
||||
mime_type = str(getattr(block, "mime_type", "") or "").strip() or None
|
||||
path = _path_from_file_uri(uri)
|
||||
|
||||
if path is None:
|
||||
return [{
|
||||
"type": "text",
|
||||
"text": _format_resource_text(
|
||||
uri=uri,
|
||||
name=name,
|
||||
title=title,
|
||||
body="[Resource link only; Hermes cannot read non-file ACP resource URIs directly.]",
|
||||
),
|
||||
}]
|
||||
|
||||
# Image files: emit a short text header + image_url data URL so vision
|
||||
# models can see the attachment instead of a "binary omitted" note.
|
||||
image_mime = mime_type if _is_image_resource(mime_type) else _guess_image_mime_from_path(path)
|
||||
if image_mime and _is_image_resource(image_mime):
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
if size > _MAX_ACP_RESOURCE_BYTES:
|
||||
return [{
|
||||
"type": "text",
|
||||
"text": _format_resource_text(
|
||||
uri=uri,
|
||||
name=name,
|
||||
title=title,
|
||||
body=f"[Image too large to inline: {size} bytes, cap={_MAX_ACP_RESOURCE_BYTES}]",
|
||||
),
|
||||
}]
|
||||
with path.open("rb") as fh:
|
||||
data = fh.read()
|
||||
except OSError as exc:
|
||||
logger.warning("ACP image resource read failed: %s", uri, exc_info=True)
|
||||
return [{
|
||||
"type": "text",
|
||||
"text": _format_resource_text(
|
||||
uri=uri,
|
||||
name=name,
|
||||
title=title,
|
||||
body=f"[Could not read attached image: {exc}]",
|
||||
),
|
||||
}]
|
||||
display = _resource_display_name(uri, name=name, title=title)
|
||||
return [
|
||||
{"type": "text", "text": f"[Attached image: {display}]\nURI: {uri}"},
|
||||
{"type": "image_url", "image_url": {"url": _image_data_url(data, image_mime)}},
|
||||
]
|
||||
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
read_size = min(size, _MAX_ACP_RESOURCE_BYTES)
|
||||
with path.open("rb") as fh:
|
||||
data = fh.read(read_size)
|
||||
text = _decode_text_bytes(data, mime_type)
|
||||
if text is None:
|
||||
return [{
|
||||
"type": "text",
|
||||
"text": _format_resource_text(
|
||||
uri=uri,
|
||||
name=name,
|
||||
title=title,
|
||||
body=f"[Binary file omitted: {size} bytes, mime={mime_type or 'unknown'}]",
|
||||
),
|
||||
}]
|
||||
note = None
|
||||
if size > _MAX_ACP_RESOURCE_BYTES:
|
||||
note = f"truncated to {_MAX_ACP_RESOURCE_BYTES} of {size} bytes"
|
||||
return [{
|
||||
"type": "text",
|
||||
"text": _format_resource_text(uri=uri, name=name, title=title, body=text, note=note),
|
||||
}]
|
||||
except OSError as exc:
|
||||
logger.warning("ACP resource read failed: %s", uri, exc_info=True)
|
||||
return [{
|
||||
"type": "text",
|
||||
"text": _format_resource_text(
|
||||
uri=uri,
|
||||
name=name,
|
||||
title=title,
|
||||
body=f"[Could not read attached file: {exc}]",
|
||||
),
|
||||
}]
|
||||
|
||||
|
||||
def _embedded_resource_to_parts(block: EmbeddedResourceContentBlock) -> list[dict[str, Any]]:
|
||||
resource = getattr(block, "resource", None)
|
||||
if resource is None:
|
||||
return []
|
||||
|
||||
uri = str(getattr(resource, "uri", "") or "").strip()
|
||||
mime_type = str(getattr(resource, "mime_type", "") or "").strip() or None
|
||||
|
||||
if isinstance(resource, TextResourceContents):
|
||||
return [{"type": "text", "text": _format_resource_text(uri=uri, body=resource.text)}]
|
||||
|
||||
if isinstance(resource, BlobResourceContents):
|
||||
blob = resource.blob or ""
|
||||
try:
|
||||
data = base64.b64decode(blob, validate=True)
|
||||
except Exception:
|
||||
data = blob.encode("utf-8", errors="replace")
|
||||
|
||||
# Image blobs go through as image_url so vision models can see them.
|
||||
if _is_image_resource(mime_type):
|
||||
if len(data) > _MAX_ACP_RESOURCE_BYTES:
|
||||
return [{
|
||||
"type": "text",
|
||||
"text": _format_resource_text(
|
||||
uri=uri,
|
||||
body=f"[Embedded image too large to inline: {len(data)} bytes, cap={_MAX_ACP_RESOURCE_BYTES}]",
|
||||
),
|
||||
}]
|
||||
display = _resource_display_name(uri)
|
||||
return [
|
||||
{"type": "text", "text": f"[Attached image: {display}]" + (f"\nURI: {uri}" if uri else "")},
|
||||
{"type": "image_url", "image_url": {"url": _image_data_url(data, mime_type or "image/png")}},
|
||||
]
|
||||
|
||||
text = _decode_text_bytes(data[:_MAX_ACP_RESOURCE_BYTES], mime_type)
|
||||
if text is None:
|
||||
body = f"[Binary embedded file omitted: {len(data)} bytes, mime={mime_type or 'unknown'}]"
|
||||
else:
|
||||
body = text
|
||||
if len(data) > _MAX_ACP_RESOURCE_BYTES:
|
||||
body += f"\n\n[Truncated to {_MAX_ACP_RESOURCE_BYTES} of {len(data)} bytes]"
|
||||
return [{"type": "text", "text": _format_resource_text(uri=uri, body=body)}]
|
||||
|
||||
text = getattr(resource, "text", None)
|
||||
if text:
|
||||
return [{"type": "text", "text": _format_resource_text(uri=uri, body=str(text))}]
|
||||
return []
|
||||
|
||||
|
||||
def _extract_text(
|
||||
@@ -415,20 +144,6 @@ def _content_blocks_to_openai_user_content(
|
||||
if image_part is not None:
|
||||
parts.append(image_part)
|
||||
continue
|
||||
if isinstance(block, ResourceContentBlock):
|
||||
resource_parts = _resource_link_to_parts(block)
|
||||
for part in resource_parts:
|
||||
parts.append(part)
|
||||
if part.get("type") == "text":
|
||||
text_parts.append(part["text"])
|
||||
continue
|
||||
if isinstance(block, EmbeddedResourceContentBlock):
|
||||
resource_parts = _embedded_resource_to_parts(block)
|
||||
for part in resource_parts:
|
||||
parts.append(part)
|
||||
if part.get("type") == "text":
|
||||
text_parts.append(part["text"])
|
||||
continue
|
||||
|
||||
if not parts:
|
||||
return _extract_text(prompt)
|
||||
@@ -1088,7 +803,6 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
user_text = _extract_text(prompt).strip()
|
||||
user_content = _content_blocks_to_openai_user_content(prompt)
|
||||
text_only_prompt = all(isinstance(block, TextContentBlock) for block in prompt)
|
||||
has_content = bool(user_text) or (
|
||||
isinstance(user_content, list) and bool(user_content)
|
||||
)
|
||||
@@ -1107,7 +821,7 @@ class HermesACPAgent(acp.Agent):
|
||||
# silently append to state.queued_prompts and respond with
|
||||
# "No active turn — queued for the next turn", which looks like
|
||||
# /queue even though the user never typed /queue.
|
||||
if text_only_prompt and isinstance(user_content, str) and user_text.startswith("/steer"):
|
||||
if isinstance(user_content, str) and user_text.startswith("/steer"):
|
||||
steer_text = user_text.split(maxsplit=1)[1].strip() if len(user_text.split(maxsplit=1)) > 1 else ""
|
||||
interrupted_prompt = ""
|
||||
rewrite_idle = False
|
||||
@@ -1132,7 +846,7 @@ class HermesACPAgent(acp.Agent):
|
||||
# Slash commands are text-only; if the client included images/resources,
|
||||
# send the whole multimodal prompt to the agent instead of treating it as
|
||||
# an ACP command.
|
||||
if text_only_prompt and isinstance(user_content, str) and user_text.startswith("/"):
|
||||
if isinstance(user_content, str) and user_text.startswith("/"):
|
||||
response_text = self._handle_slash_command(user_text, state)
|
||||
if response_text is not None:
|
||||
if self._conn:
|
||||
|
||||
+32
-35
@@ -231,30 +231,33 @@ def _supports_fast_mode(model: str) -> bool:
|
||||
return any(v in model for v in _FAST_MODE_SUPPORTED_SUBSTRINGS)
|
||||
|
||||
|
||||
# Beta headers for enhanced features that are safe on ordinary/native Anthropic
|
||||
# requests. As of Opus 4.7 (2026-04-16), these are GA on Claude 4.6+ — the
|
||||
# Beta headers for enhanced features (sent with ALL auth types).
|
||||
# As of Opus 4.7 (2026-04-16), the first two are GA on Claude 4.6+ — the
|
||||
# beta headers are still accepted (harmless no-op) but not required. Kept
|
||||
# here so older Claude (4.5, 4.1) + compatible endpoints that still gate on
|
||||
# the headers continue to get the enhanced features.
|
||||
# here so older Claude (4.5, 4.1) + third-party Anthropic-compat endpoints
|
||||
# that still gate on the headers continue to get the enhanced features.
|
||||
#
|
||||
# Do NOT include ``context-1m-2025-08-07`` here. Anthropic returns HTTP 400
|
||||
# ("long context beta is not yet available for this subscription") for
|
||||
# accounts without the long-context beta, which breaks normal short auxiliary
|
||||
# calls like title generation/session summarization.
|
||||
# ``context-1m-2025-08-07`` unlocks the 1M context window on Claude Opus 4.6/4.7
|
||||
# and Sonnet 4.6 when served via AWS Bedrock or Azure AI Foundry. 1M is GA on
|
||||
# native Anthropic (api.anthropic.com) for Opus 4.6+, but Bedrock/Azure still
|
||||
# gate it behind this beta header as of 2026-04 — without it Bedrock caps Opus
|
||||
# at 200K even though model_metadata.py advertises 1M. The header is a harmless
|
||||
# no-op on endpoints where 1M is GA.
|
||||
#
|
||||
# ``context-1m-2025-08-07`` is still required to unlock the 1M context window
|
||||
# on Claude Opus 4.6/4.7 and Sonnet 4.6 when served via AWS Bedrock or Azure
|
||||
# AI Foundry. Add it only for those endpoint-specific paths below.
|
||||
# Migration guide: remove these if you no longer support ≤4.5 models or once
|
||||
# Bedrock/Azure promote 1M to GA.
|
||||
_COMMON_BETAS = [
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
"context-1m-2025-08-07",
|
||||
]
|
||||
# MiniMax's Anthropic-compatible endpoints fail tool-use requests when
|
||||
# the fine-grained tool streaming beta is present. Omit it so tool calls
|
||||
# fall back to the provider's default response path.
|
||||
_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"
|
||||
# 1M context beta. Native Anthropic does not get this by default because some
|
||||
# subscriptions reject it, but Bedrock/Azure still need it for 1M context.
|
||||
# 1M context beta — see comment on _COMMON_BETAS above. Stripped for
|
||||
# Bearer-auth (MiniMax) endpoints since they host their own models and
|
||||
# unknown Anthropic beta headers risk request rejection.
|
||||
_CONTEXT_1M_BETA = "context-1m-2025-08-07"
|
||||
|
||||
# Fast mode beta — enables the ``speed: "fast"`` request parameter for
|
||||
@@ -473,14 +476,6 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
return normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic"))
|
||||
|
||||
|
||||
def _base_url_needs_context_1m_beta(base_url: str | None) -> bool:
|
||||
"""Return True for endpoints that still gate 1M context behind a beta."""
|
||||
normalized = _normalize_base_url_text(base_url).lower()
|
||||
if not normalized:
|
||||
return False
|
||||
return "azure.com" in normalized
|
||||
|
||||
|
||||
def _common_betas_for_base_url(
|
||||
base_url: str | None,
|
||||
*,
|
||||
@@ -490,25 +485,27 @@ def _common_betas_for_base_url(
|
||||
|
||||
MiniMax's Anthropic-compatible endpoints (Bearer-auth) reject requests
|
||||
that include Anthropic's ``fine-grained-tool-streaming`` beta — every
|
||||
tool-use message triggers a connection error.
|
||||
tool-use message triggers a connection error. Strip that beta for
|
||||
Bearer-auth endpoints while keeping all other betas intact.
|
||||
|
||||
The ``context-1m-2025-08-07`` beta is not sent to native Anthropic by
|
||||
default because some subscriptions reject it. Add it only for endpoint
|
||||
families that still require it for 1M context, currently Azure AI Foundry.
|
||||
Bedrock uses its own client helper below and opts in explicitly.
|
||||
The ``context-1m-2025-08-07`` beta is also stripped for Bearer-auth
|
||||
endpoints — MiniMax hosts its own models, not Claude, so the header is
|
||||
irrelevant at best and risks request rejection at worst.
|
||||
|
||||
``drop_context_1m_beta=True`` strips the 1M-context beta from any path that
|
||||
would otherwise include it after a subscription/endpoint rejects the beta.
|
||||
``drop_context_1m_beta=True`` additionally strips the 1M-context beta on
|
||||
otherwise-unrelated endpoints. The OAuth retry path flips this flag after
|
||||
a subscription rejects the beta with
|
||||
"The long context beta is not yet available for this subscription" so
|
||||
subsequent requests in the same session don't repeat the probe. See the
|
||||
reactive recovery loop in ``run_agent.py`` and issue-comment history on
|
||||
PR #17680 for the full rationale.
|
||||
"""
|
||||
betas = list(_COMMON_BETAS)
|
||||
if _base_url_needs_context_1m_beta(base_url) and not drop_context_1m_beta:
|
||||
betas.append(_CONTEXT_1M_BETA)
|
||||
if _requires_bearer_auth(base_url):
|
||||
_stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA}
|
||||
return [b for b in betas if b not in _stripped]
|
||||
return [b for b in _COMMON_BETAS if b not in _stripped]
|
||||
if drop_context_1m_beta:
|
||||
return [b for b in betas if b != _CONTEXT_1M_BETA]
|
||||
return betas
|
||||
return [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]
|
||||
return _COMMON_BETAS
|
||||
|
||||
|
||||
def build_anthropic_client(
|
||||
@@ -645,7 +642,7 @@ def build_anthropic_bedrock_client(region: str):
|
||||
return _anthropic_sdk.AnthropicBedrock(
|
||||
aws_region=region,
|
||||
timeout=Timeout(timeout=900.0, connect=10.0),
|
||||
default_headers={"anthropic-beta": ",".join([*_COMMON_BETAS, _CONTEXT_1M_BETA])},
|
||||
default_headers={"anthropic-beta": ",".join(_COMMON_BETAS)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
+6
-150
@@ -196,12 +196,6 @@ def _is_kimi_model(model: Optional[str]) -> bool:
|
||||
return bare.startswith("kimi-") or bare == "kimi"
|
||||
|
||||
|
||||
def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
|
||||
"""True for Arcee Trinity Large Thinking (direct or via OpenRouter)."""
|
||||
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
|
||||
return bare == "trinity-large-thinking"
|
||||
|
||||
|
||||
def _fixed_temperature_for_model(
|
||||
model: Optional[str],
|
||||
base_url: Optional[str] = None,
|
||||
@@ -219,23 +213,6 @@ def _fixed_temperature_for_model(
|
||||
if _is_kimi_model(model):
|
||||
logger.debug("Omitting temperature for Kimi model %r (server-managed)", model)
|
||||
return OMIT_TEMPERATURE
|
||||
if _is_arcee_trinity_thinking(model):
|
||||
return 0.5
|
||||
return None
|
||||
|
||||
|
||||
def _compression_threshold_for_model(model: Optional[str]) -> Optional[float]:
|
||||
"""Return a context-compression threshold override for specific models.
|
||||
|
||||
The threshold is the fraction of the model's context window that must be
|
||||
consumed before Hermes triggers summarization. Higher values delay
|
||||
compression and preserve more raw context.
|
||||
|
||||
Returns a float in (0, 1] to override the global ``compression.threshold``
|
||||
config value, or ``None`` to leave the user's config value unchanged.
|
||||
"""
|
||||
if _is_arcee_trinity_thinking(model):
|
||||
return 0.75
|
||||
return None
|
||||
|
||||
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
|
||||
@@ -455,12 +432,6 @@ def _to_openai_base_url(base_url: str) -> str:
|
||||
"""
|
||||
url = str(base_url or "").strip().rstrip("/")
|
||||
if url.endswith("/anthropic"):
|
||||
# ZAI (open.bigmodel.cn) uses /api/anthropic for Anthropic wire
|
||||
# but /api/paas/v4 for OpenAI wire — the generic /v1 rewrite is wrong.
|
||||
if "open.bigmodel.cn" in url or "bigmodel" in url:
|
||||
rewritten = url[: -len("/anthropic")] + "/paas/v4"
|
||||
logger.debug("Auxiliary client: rewrote ZAI base URL %s → %s", url, rewritten)
|
||||
return rewritten
|
||||
rewritten = url[: -len("/anthropic")] + "/v1"
|
||||
logger.debug("Auxiliary client: rewrote base URL %s → %s", url, rewritten)
|
||||
return rewritten
|
||||
@@ -602,14 +573,6 @@ class _CodexCompletionsAdapter:
|
||||
"store": False,
|
||||
}
|
||||
|
||||
# Preserve the chat.completions timeout contract. This adapter is used
|
||||
# by auxiliary calls such as context compression; if the timeout is not
|
||||
# forwarded and enforced, a Codex Responses stream can sit behind a
|
||||
# dead-looking CLI until the user force-interrupts the whole session.
|
||||
timeout = kwargs.get("timeout")
|
||||
if timeout is not None:
|
||||
resp_kwargs["timeout"] = timeout
|
||||
|
||||
# Note: the Codex endpoint (chatgpt.com/backend-api/codex) does NOT
|
||||
# support max_output_tokens or temperature — omit to avoid 400 errors.
|
||||
|
||||
@@ -667,37 +630,6 @@ class _CodexCompletionsAdapter:
|
||||
text_parts: List[str] = []
|
||||
tool_calls_raw: List[Any] = []
|
||||
usage = None
|
||||
total_timeout = timeout if isinstance(timeout, (int, float)) and timeout > 0 else None
|
||||
deadline = time.monotonic() + float(total_timeout) if total_timeout else None
|
||||
timed_out = threading.Event()
|
||||
timeout_timer: Optional[threading.Timer] = None
|
||||
|
||||
def _timeout_message() -> str:
|
||||
return f"Codex auxiliary Responses stream exceeded {float(total_timeout):.1f}s total timeout"
|
||||
|
||||
def _close_client_on_timeout() -> None:
|
||||
timed_out.set()
|
||||
close = getattr(self._client, "close", None)
|
||||
if callable(close):
|
||||
try:
|
||||
close()
|
||||
except Exception:
|
||||
logger.debug("Codex auxiliary: client close during timeout failed", exc_info=True)
|
||||
|
||||
def _check_cancelled() -> None:
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
timed_out.set()
|
||||
raise TimeoutError(_timeout_message())
|
||||
try:
|
||||
from tools.interrupt import is_interrupted
|
||||
if is_interrupted():
|
||||
raise InterruptedError("Codex auxiliary Responses stream interrupted")
|
||||
except InterruptedError:
|
||||
raise
|
||||
except Exception:
|
||||
# Interrupt state is a best-effort UX hook; never make it a
|
||||
# new failure mode for auxiliary calls.
|
||||
pass
|
||||
|
||||
try:
|
||||
# Collect output items and text deltas during streaming —
|
||||
@@ -706,14 +638,8 @@ class _CodexCompletionsAdapter:
|
||||
collected_output_items: List[Any] = []
|
||||
collected_text_deltas: List[str] = []
|
||||
has_function_calls = False
|
||||
if total_timeout:
|
||||
timeout_timer = threading.Timer(float(total_timeout), _close_client_on_timeout)
|
||||
timeout_timer.daemon = True
|
||||
timeout_timer.start()
|
||||
_check_cancelled()
|
||||
with self._client.responses.stream(**resp_kwargs) as stream:
|
||||
for _event in stream:
|
||||
_check_cancelled()
|
||||
_etype = getattr(_event, "type", "")
|
||||
if _etype == "response.output_item.done":
|
||||
_done = getattr(_event, "item", None)
|
||||
@@ -725,7 +651,6 @@ class _CodexCompletionsAdapter:
|
||||
collected_text_deltas.append(_delta)
|
||||
elif "function_call" in _etype:
|
||||
has_function_calls = True
|
||||
_check_cancelled()
|
||||
final = stream.get_final_response()
|
||||
|
||||
# Backfill empty output from collected stream events
|
||||
@@ -785,13 +710,8 @@ class _CodexCompletionsAdapter:
|
||||
total_tokens=getattr(resp_usage, "total_tokens", 0),
|
||||
)
|
||||
except Exception as exc:
|
||||
if timed_out.is_set():
|
||||
raise TimeoutError(_timeout_message()) from exc
|
||||
logger.debug("Codex auxiliary Responses API call failed: %s", exc)
|
||||
raise
|
||||
finally:
|
||||
if timeout_timer is not None:
|
||||
timeout_timer.cancel()
|
||||
|
||||
content = "".join(text_parts).strip() or None
|
||||
|
||||
@@ -885,14 +805,7 @@ class _AnthropicCompletionsAdapter:
|
||||
model = kwargs.get("model", self._model)
|
||||
tools = kwargs.get("tools")
|
||||
tool_choice = kwargs.get("tool_choice")
|
||||
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
|
||||
# models (glm-4v-flash etc.) with error code 1210. When the caller
|
||||
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
|
||||
_skip_mt = kwargs.pop("_skip_zai_max_tokens", False)
|
||||
if _skip_mt:
|
||||
max_tokens = None
|
||||
else:
|
||||
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
|
||||
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
|
||||
temperature = kwargs.get("temperature")
|
||||
|
||||
normalized_tool_choice = None
|
||||
@@ -2899,33 +2812,6 @@ def resolve_vision_provider_client(
|
||||
)
|
||||
return _finalize(requested, sync_client, default_model)
|
||||
|
||||
# ZAI vision models must use the OpenAI-compatible endpoint, not the
|
||||
# Anthropic-compatible one (which may be the main-runtime default).
|
||||
# The Anthropic wire rejects max_tokens on multimodal calls (error 1210),
|
||||
# while the OpenAI wire handles it correctly.
|
||||
if requested == "zai" and not resolved_base_url:
|
||||
zai_openai_urls = [
|
||||
"https://open.bigmodel.cn/api/paas/v4",
|
||||
"https://api.z.ai/api/paas/v4",
|
||||
]
|
||||
for _zai_url in zai_openai_urls:
|
||||
client, final_model = _get_cached_client(
|
||||
requested, resolved_model, async_mode,
|
||||
base_url=_zai_url,
|
||||
api_key=resolved_api_key or None,
|
||||
api_mode="chat_completions",
|
||||
is_vision=True,
|
||||
)
|
||||
if client is not None:
|
||||
return _finalize(requested, client, final_model)
|
||||
# Fallback: try without explicit base_url (old behavior)
|
||||
client, final_model = _get_cached_client(requested, resolved_model, async_mode,
|
||||
api_mode=resolved_api_mode,
|
||||
is_vision=True)
|
||||
if client is None:
|
||||
return requested, None, None
|
||||
return requested, client, final_model
|
||||
|
||||
client, final_model = _get_cached_client(requested, resolved_model, async_mode,
|
||||
api_mode=resolved_api_mode,
|
||||
is_vision=True)
|
||||
@@ -2953,11 +2839,10 @@ def auxiliary_max_tokens_param(value: int) -> dict:
|
||||
"""
|
||||
custom_base = _current_custom_base_url()
|
||||
or_key = os.getenv("OPENROUTER_API_KEY")
|
||||
# Use max_completion_tokens for direct OpenAI-compatible providers that reject
|
||||
# max_tokens on newer GPT-4o/o-series/GPT-5-style models.
|
||||
# Only use max_completion_tokens for direct OpenAI custom endpoints
|
||||
if (not or_key
|
||||
and _read_nous_auth() is None
|
||||
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
|
||||
and base_url_hostname(custom_base) == "api.openai.com"):
|
||||
return {"max_completion_tokens": value}
|
||||
return {"max_tokens": value}
|
||||
|
||||
@@ -3485,16 +3370,7 @@ def _build_call_kwargs(
|
||||
if max_tokens is not None:
|
||||
# Codex adapter handles max_tokens internally; OpenRouter/Nous use max_tokens.
|
||||
# Direct OpenAI api.openai.com with newer models needs max_completion_tokens.
|
||||
# ZAI vision models (glm-4v-flash, glm-4v-plus, etc.) reject max_tokens with
|
||||
# error code 1210 ("API 调用参数有误") on multimodal requests — skip it.
|
||||
_model_lower = (model or "").lower()
|
||||
_skip_max_tokens = (
|
||||
provider == "zai"
|
||||
and ("4v" in _model_lower or "5v" in _model_lower or "-v" in _model_lower)
|
||||
)
|
||||
if _skip_max_tokens:
|
||||
pass # ZAI vision models do not accept max_tokens
|
||||
elif provider == "custom":
|
||||
if provider == "custom":
|
||||
custom_base = base_url or _current_custom_base_url()
|
||||
if base_url_hostname(custom_base) == "api.openai.com":
|
||||
kwargs["max_completion_tokens"] = max_tokens
|
||||
@@ -3725,23 +3601,13 @@ def call_llm(
|
||||
kwargs = retry_kwargs
|
||||
|
||||
err_str = str(first_err)
|
||||
# ZAI vision models (glm-4v-flash etc.) return error code 1210
|
||||
# ("API 调用参数有误") when max_tokens is passed on multimodal
|
||||
# calls. The error message does NOT contain "max_tokens" so the
|
||||
# generic retry below never fires. Detect the ZAI-specific error
|
||||
# and strip max_tokens before retrying.
|
||||
_is_zai_param_error = (
|
||||
"1210" in err_str
|
||||
and "bigmodel" in str(getattr(client, "base_url", ""))
|
||||
)
|
||||
if max_tokens is not None and (
|
||||
"max_tokens" in err_str
|
||||
or "unsupported_parameter" in err_str
|
||||
or _is_unsupported_parameter_error(first_err, "max_tokens")
|
||||
or _is_zai_param_error
|
||||
):
|
||||
kwargs.pop("max_tokens", None)
|
||||
kwargs.pop("max_completion_tokens", None)
|
||||
kwargs["max_completion_tokens"] = max_tokens
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
@@ -4041,23 +3907,13 @@ async def async_call_llm(
|
||||
kwargs = retry_kwargs
|
||||
|
||||
err_str = str(first_err)
|
||||
# ZAI vision models (glm-4v-flash etc.) return error code 1210
|
||||
# ("API 调用参数有误") when max_tokens is passed on multimodal
|
||||
# calls. The error message does NOT contain "max_tokens" so the
|
||||
# generic retry below never fires. Detect the ZAI-specific error
|
||||
# and strip max_tokens before retrying.
|
||||
_is_zai_param_error = (
|
||||
"1210" in err_str
|
||||
and "bigmodel" in str(getattr(client, "base_url", ""))
|
||||
)
|
||||
if max_tokens is not None and (
|
||||
"max_tokens" in err_str
|
||||
or "unsupported_parameter" in err_str
|
||||
or _is_unsupported_parameter_error(first_err, "max_tokens")
|
||||
or _is_zai_param_error
|
||||
):
|
||||
kwargs.pop("max_tokens", None)
|
||||
kwargs.pop("max_completion_tokens", None)
|
||||
kwargs["max_completion_tokens"] = max_tokens
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
|
||||
@@ -631,18 +631,11 @@ def normalize_converse_response(response: Dict) -> SimpleNamespace:
|
||||
stop_reason = response.get("stopReason", "end_turn")
|
||||
|
||||
text_parts = []
|
||||
reasoning_parts = []
|
||||
tool_calls = []
|
||||
|
||||
for block in content_blocks:
|
||||
if "text" in block:
|
||||
text_parts.append(block["text"])
|
||||
elif "reasoningContent" in block:
|
||||
reasoning = block["reasoningContent"]
|
||||
if isinstance(reasoning, dict):
|
||||
thinking_text = reasoning.get("text", "")
|
||||
if thinking_text:
|
||||
reasoning_parts.append(str(thinking_text))
|
||||
elif "toolUse" in block:
|
||||
tu = block["toolUse"]
|
||||
tool_calls.append(SimpleNamespace(
|
||||
@@ -659,7 +652,6 @@ def normalize_converse_response(response: Dict) -> SimpleNamespace:
|
||||
role="assistant",
|
||||
content="\n".join(text_parts) if text_parts else None,
|
||||
tool_calls=tool_calls if tool_calls else None,
|
||||
reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None,
|
||||
)
|
||||
|
||||
# Build usage stats
|
||||
@@ -740,7 +732,6 @@ def stream_converse_with_callbacks(
|
||||
``normalize_converse_response()``.
|
||||
"""
|
||||
text_parts: List[str] = []
|
||||
reasoning_parts: List[str] = []
|
||||
tool_calls: List[SimpleNamespace] = []
|
||||
current_tool: Optional[Dict] = None
|
||||
current_text_buffer: List[str] = []
|
||||
@@ -786,10 +777,8 @@ def stream_converse_with_callbacks(
|
||||
reasoning = delta["reasoningContent"]
|
||||
if isinstance(reasoning, dict):
|
||||
thinking_text = reasoning.get("text", "")
|
||||
if thinking_text:
|
||||
reasoning_parts.append(str(thinking_text))
|
||||
if on_reasoning_delta:
|
||||
on_reasoning_delta(thinking_text)
|
||||
if thinking_text and on_reasoning_delta:
|
||||
on_reasoning_delta(thinking_text)
|
||||
|
||||
elif "contentBlockStop" in event:
|
||||
if current_tool is not None:
|
||||
@@ -828,7 +817,6 @@ def stream_converse_with_callbacks(
|
||||
role="assistant",
|
||||
content="\n".join(text_parts) if text_parts else None,
|
||||
tool_calls=tool_calls if tool_calls else None,
|
||||
reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None,
|
||||
)
|
||||
|
||||
usage = SimpleNamespace(
|
||||
|
||||
+13
-14
@@ -6,7 +6,8 @@ protecting head and tail context.
|
||||
|
||||
Improvements over v2:
|
||||
- Structured summary template with Resolved/Pending question tracking
|
||||
- Filter-safe summarizer preamble that treats prior turns as source material
|
||||
- Summarizer preamble: "Do not respond to any questions" (from OpenCode)
|
||||
- Handoff framing: "different assistant" (from Codex) to create separation
|
||||
- "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
|
||||
- Clear separator when summary merges into tail message
|
||||
- Iterative summary updates (preserves info across multiple compactions)
|
||||
@@ -42,9 +43,6 @@ SUMMARY_PREFIX = (
|
||||
"they were already addressed. "
|
||||
"Your current task is identified in the '## Active Task' section of the "
|
||||
"summary — resume exactly from there. "
|
||||
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
|
||||
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
|
||||
"memory content due to this compaction note. "
|
||||
"Respond ONLY to the latest user message "
|
||||
"that appears AFTER this summary. The current session state (files, "
|
||||
"config, etc.) may reflect work described here — avoid repeating it:"
|
||||
@@ -754,14 +752,15 @@ class ContextCompressor(ContextEngine):
|
||||
content_to_summarize = self._serialize_for_summary(turns_to_summarize)
|
||||
|
||||
# Preamble shared by both first-compaction and iterative-update prompts.
|
||||
# Keep the wording deliberately plain: Azure/OpenAI-compatible content
|
||||
# filters have flagged stronger "injection" / "do not respond" framing.
|
||||
# Inspired by OpenCode's "do not respond to any questions" instruction
|
||||
# and Codex's "another language model" framing.
|
||||
_summarizer_preamble = (
|
||||
"You are a summarization agent creating a context checkpoint. "
|
||||
"Treat the conversation turns below as source material for a "
|
||||
"compact record of prior work. "
|
||||
"Produce only the structured summary; do not add a greeting, "
|
||||
"preamble, or prefix. "
|
||||
"Your output will be injected as reference material for a DIFFERENT "
|
||||
"assistant that continues the conversation. "
|
||||
"Do NOT respond to any questions or requests in the conversation — "
|
||||
"only output the structured summary. "
|
||||
"Do NOT include any preamble, greeting, or prefix. "
|
||||
"Write the summary in the same language the user was using in the "
|
||||
"conversation — do not translate or switch to English. "
|
||||
"NEVER include API keys, tokens, passwords, secrets, credentials, "
|
||||
@@ -775,7 +774,7 @@ class ContextCompressor(ContextEngine):
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or
|
||||
task assignment verbatim — the exact words they used. If multiple tasks
|
||||
were requested and only some are done, list only the ones NOT yet completed.
|
||||
Continuation should pick up exactly here. Example:
|
||||
The next assistant must pick up exactly here. Example:
|
||||
"User asked: 'Now refactor the auth module to use JWT instead of sessions'"
|
||||
If no outstanding task exists, write "None."]
|
||||
|
||||
@@ -812,7 +811,7 @@ Be specific with file paths, commands, line numbers, and results.]
|
||||
[Important technical decisions and WHY they were made]
|
||||
|
||||
## Resolved Questions
|
||||
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
|
||||
[Questions the user asked that were ALREADY answered — include the answer so the next assistant does not re-answer them]
|
||||
|
||||
## Pending User Asks
|
||||
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
|
||||
@@ -849,7 +848,7 @@ Update the summary using this exact structure. PRESERVE all existing information
|
||||
# First compaction: summarize from scratch
|
||||
prompt = f"""{_summarizer_preamble}
|
||||
|
||||
Create a structured checkpoint summary for the conversation after earlier turns are compacted. The summary should preserve enough detail for continuity without re-reading the original turns.
|
||||
Create a structured handoff summary for a different assistant that will continue this conversation after earlier turns are compacted. The next assistant should be able to understand what happened without re-reading the original turns.
|
||||
|
||||
TURNS TO SUMMARIZE:
|
||||
{content_to_summarize}
|
||||
@@ -1374,7 +1373,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
msg = messages[i].copy()
|
||||
if i == 0 and msg.get("role") == "system":
|
||||
existing = msg.get("content")
|
||||
_compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work. Your persistent memory (MEMORY.md, USER.md) remains fully authoritative regardless of compaction.]"
|
||||
_compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]"
|
||||
if _compression_note not in _content_text_for_contains(existing):
|
||||
msg["content"] = _append_text_to_content(
|
||||
existing,
|
||||
|
||||
@@ -477,8 +477,8 @@ class CopilotACPClient:
|
||||
proc.stdin.write(json.dumps(payload) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
deadline = time.time() + timeout_seconds
|
||||
while time.time() < deadline:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
try:
|
||||
|
||||
@@ -68,10 +68,8 @@ SUPPORTED_POOL_STRATEGIES = {
|
||||
}
|
||||
|
||||
# Cooldown before retrying an exhausted credential.
|
||||
# Transient 401 auth failures cool down briefly so single-key setups can recover.
|
||||
# 429 (rate-limited), 402 (billing/quota), and other failures cool down after 1 hour.
|
||||
# 429 (rate-limited) and 402 (billing/quota) both cool down after 1 hour.
|
||||
# Provider-supplied reset_at timestamps override these defaults.
|
||||
EXHAUSTED_TTL_401_SECONDS = 5 * 60 # 5 minutes
|
||||
EXHAUSTED_TTL_429_SECONDS = 60 * 60 # 1 hour
|
||||
EXHAUSTED_TTL_DEFAULT_SECONDS = 60 * 60 # 1 hour
|
||||
|
||||
@@ -192,8 +190,6 @@ def _is_manual_source(source: str) -> bool:
|
||||
|
||||
def _exhausted_ttl(error_code: Optional[int]) -> int:
|
||||
"""Return cooldown seconds based on the HTTP status that caused exhaustion."""
|
||||
if error_code == 401:
|
||||
return EXHAUSTED_TTL_401_SECONDS
|
||||
if error_code == 429:
|
||||
return EXHAUSTED_TTL_429_SECONDS
|
||||
return EXHAUSTED_TTL_DEFAULT_SECONDS
|
||||
@@ -309,29 +305,14 @@ def _iter_custom_providers(config: Optional[dict] = None):
|
||||
yield _normalize_custom_pool_name(name), entry
|
||||
|
||||
|
||||
def get_custom_provider_pool_key(base_url: str, provider_name: Optional[str] = None) -> Optional[str]:
|
||||
def get_custom_provider_pool_key(base_url: str) -> Optional[str]:
|
||||
"""Look up the custom_providers list in config.yaml and return 'custom:<name>' for a matching base_url.
|
||||
|
||||
When provider_name is given, prefer matching by name first (solving the case where
|
||||
multiple custom providers share the same base_url but have different API keys).
|
||||
Falls back to base_url matching when no name match is found.
|
||||
|
||||
Returns None if no match is found.
|
||||
"""
|
||||
if not base_url:
|
||||
return None
|
||||
normalized_url = base_url.strip().rstrip("/")
|
||||
|
||||
# When a provider name is given, try to match by name first.
|
||||
# This fixes the P1 bug where two custom providers sharing the same
|
||||
# base_url always resolve to the first one's credentials.
|
||||
if provider_name:
|
||||
normalized_name = _normalize_custom_pool_name(provider_name)
|
||||
for norm_name, entry in _iter_custom_providers():
|
||||
if norm_name == normalized_name:
|
||||
return f"{CUSTOM_POOL_PREFIX}{norm_name}"
|
||||
|
||||
# Fall back to base_url matching (original behavior)
|
||||
for norm_name, entry in _iter_custom_providers():
|
||||
entry_url = str(entry.get("base_url") or "").strip().rstrip("/")
|
||||
if entry_url and entry_url == normalized_url:
|
||||
|
||||
+2
-4
@@ -852,15 +852,13 @@ def get_cute_tool_message(
|
||||
s = str(s)
|
||||
if _tool_preview_max_len == 0:
|
||||
return s # no limit
|
||||
limit = _tool_preview_max_len
|
||||
return (s[:limit-3] + "...") if len(s) > limit else s
|
||||
return (s[:n-3] + "...") if len(s) > n else s
|
||||
|
||||
def _path(p, n=35):
|
||||
p = str(p)
|
||||
if _tool_preview_max_len == 0:
|
||||
return p # no limit
|
||||
limit = _tool_preview_max_len
|
||||
return ("..." + p[-(limit-3):]) if len(p) > limit else p
|
||||
return ("..." + p[-(n-3):]) if len(p) > n else p
|
||||
|
||||
def _wrap(line: str) -> str:
|
||||
"""Apply skin tool prefix and failure suffix."""
|
||||
|
||||
+2
-5
@@ -25,7 +25,7 @@ Language resolution order:
|
||||
3. ``display.language`` from config.yaml
|
||||
4. ``"en"`` (baseline)
|
||||
|
||||
Supported languages: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.
|
||||
Supported languages: en, zh, ja, de, es. Unknown values fall back to en.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -39,7 +39,7 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es", "fr", "tr", "uk")
|
||||
SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es")
|
||||
DEFAULT_LANGUAGE = "en"
|
||||
|
||||
# Accept a few natural aliases so users who type "chinese" / "zh-CN" / "jp"
|
||||
@@ -50,9 +50,6 @@ _LANGUAGE_ALIASES: dict[str, str] = {
|
||||
"japanese": "ja", "jp": "ja", "ja-jp": "ja",
|
||||
"german": "de", "deutsch": "de", "de-de": "de",
|
||||
"spanish": "es", "español": "es", "espanol": "es", "es-es": "es", "es-mx": "es",
|
||||
"french": "fr", "français": "fr", "france": "fr", "fr-fr": "fr", "fr-be": "fr", "fr-ca": "fr", "fr-ch": "fr",
|
||||
"ukrainian": "uk", "ukrainisch": "uk", "українська": "uk", "uk-ua": "uk", "ua": "uk",
|
||||
"turkish": "tr", "türkçe": "tr", "tr-tr": "tr",
|
||||
}
|
||||
|
||||
_catalog_cache: dict[str, dict[str, str]] = {}
|
||||
|
||||
+13
-78
@@ -144,51 +144,7 @@ def decide_image_input_mode(
|
||||
# it fires, which is cheaper than permanent quality loss.
|
||||
|
||||
|
||||
def _sniff_mime_from_bytes(raw: bytes) -> Optional[str]:
|
||||
"""Detect image MIME from magic bytes. Returns None if unrecognised.
|
||||
|
||||
Filename-based detection (``mimetypes.guess_type``) is unreliable when
|
||||
upstream platforms lie about content-type. Discord, for example, can
|
||||
serve a PNG with ``content_type=image/webp`` for proxied/animated
|
||||
stickers, custom emoji previews, or images uploaded via certain bots.
|
||||
Anthropic strictly validates that declared media_type matches the
|
||||
actual bytes and returns HTTP 400 on mismatch, so we sniff to be safe.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
# PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if raw.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "image/png"
|
||||
# JPEG: FF D8 FF
|
||||
if raw.startswith(b"\xff\xd8\xff"):
|
||||
return "image/jpeg"
|
||||
# GIF87a / GIF89a
|
||||
if raw[:6] in (b"GIF87a", b"GIF89a"):
|
||||
return "image/gif"
|
||||
# WEBP: "RIFF" .... "WEBP"
|
||||
if len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
|
||||
return "image/webp"
|
||||
# BMP: "BM"
|
||||
if raw.startswith(b"BM"):
|
||||
return "image/bmp"
|
||||
# HEIC/HEIF: ftypheic / ftypheix / ftypmif1 / ftypmsf1 etc.
|
||||
if len(raw) >= 12 and raw[4:8] == b"ftyp" and raw[8:12] in (
|
||||
b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim", b"heis",
|
||||
):
|
||||
return "image/heic"
|
||||
return None
|
||||
|
||||
|
||||
def _guess_mime(path: Path, raw: Optional[bytes] = None) -> str:
|
||||
"""Return image MIME type for *path*.
|
||||
|
||||
If *raw* bytes are provided, magic-byte sniffing wins (authoritative).
|
||||
Otherwise we fall back to ``mimetypes`` then suffix-based defaults.
|
||||
"""
|
||||
if raw is not None:
|
||||
sniffed = _sniff_mime_from_bytes(raw)
|
||||
if sniffed:
|
||||
return sniffed
|
||||
def _guess_mime(path: Path) -> str:
|
||||
mime, _ = mimetypes.guess_type(str(path))
|
||||
if mime and mime.startswith("image/"):
|
||||
return mime
|
||||
@@ -222,7 +178,7 @@ def _file_to_data_url(path: Path) -> Optional[str]:
|
||||
except Exception as exc:
|
||||
logger.warning("image_routing: failed to read %s — %s", path, exc)
|
||||
return None
|
||||
mime = _guess_mime(path, raw=raw)
|
||||
mime = _guess_mime(path)
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
return f"data:{mime};base64,{b64}"
|
||||
|
||||
@@ -234,30 +190,24 @@ def build_native_content_parts(
|
||||
"""Build an OpenAI-style ``content`` list for a user turn.
|
||||
|
||||
Shape:
|
||||
[{"type": "text", "text": "...\\n\\n[Image attached at: /local/path]"},
|
||||
[{"type": "text", "text": "..."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}},
|
||||
...]
|
||||
|
||||
The local path of each successfully attached image is appended to the
|
||||
text part as ``[Image attached at: <path>]``. The model still sees the
|
||||
pixels via the ``image_url`` part (full native vision); the path note
|
||||
just gives it a string handle so MCP/skill tools that take an image
|
||||
path or URL argument can be invoked on the same image without an
|
||||
extra round-trip. This parallels the text-mode hint produced by
|
||||
``Runner._enrich_message_with_vision`` (``vision_analyze using image_url:
|
||||
<path>``) so behaviour is consistent across both image input modes.
|
||||
|
||||
Images are attached at their native size. If a provider rejects the
|
||||
request because an image is too large (e.g. Anthropic's 5 MB per-image
|
||||
ceiling), the agent's retry loop transparently shrinks and retries
|
||||
once — see ``run_agent._try_shrink_image_parts_in_messages``.
|
||||
|
||||
Returns (content_parts, skipped_paths). Skipped paths are files that
|
||||
couldn't be read from disk and are NOT advertised in the path hints.
|
||||
couldn't be read from disk.
|
||||
"""
|
||||
parts: List[Dict[str, Any]] = []
|
||||
skipped: List[str] = []
|
||||
image_parts: List[Dict[str, Any]] = []
|
||||
attached_paths: List[str] = []
|
||||
|
||||
text = (user_text or "").strip()
|
||||
if text:
|
||||
parts.append({"type": "text", "text": text})
|
||||
|
||||
for raw_path in image_paths:
|
||||
p = Path(raw_path)
|
||||
@@ -268,30 +218,15 @@ def build_native_content_parts(
|
||||
if not data_url:
|
||||
skipped.append(str(raw_path))
|
||||
continue
|
||||
image_parts.append({
|
||||
parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": data_url},
|
||||
})
|
||||
attached_paths.append(str(raw_path))
|
||||
|
||||
text = (user_text or "").strip()
|
||||
# If the text was empty, add a neutral prompt so the turn isn't just images.
|
||||
if not text and any(p.get("type") == "image_url" for p in parts):
|
||||
parts.insert(0, {"type": "text", "text": "What do you see in this image?"})
|
||||
|
||||
# If at least one image attached, build a single text part that combines
|
||||
# the user's caption (or a neutral default) with one path hint per image.
|
||||
if attached_paths:
|
||||
base_text = text or "What do you see in this image?"
|
||||
path_hints = "\n".join(
|
||||
f"[Image attached at: {p}]" for p in attached_paths
|
||||
)
|
||||
combined_text = f"{base_text}\n\n{path_hints}"
|
||||
parts: List[Dict[str, Any]] = [{"type": "text", "text": combined_text}]
|
||||
parts.extend(image_parts)
|
||||
return parts, skipped
|
||||
|
||||
# No images successfully attached — fall back to plain text-only behaviour.
|
||||
parts = []
|
||||
if text:
|
||||
parts.append({"type": "text", "text": text})
|
||||
return parts, skipped
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ _INTERNAL_CONTEXT_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_INTERNAL_NOTE_RE = re.compile(
|
||||
r'\[System note:\s*The following is recalled memory context,\s*NOT new user input\.\s*Treat as (?:informational background data|authoritative reference data[^\]]*)\.\]\s*',
|
||||
r'\[System note:\s*The following is recalled memory context,\s*NOT new user input\.\s*Treat as informational background data\.\]\s*',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -180,8 +180,7 @@ def build_memory_context_block(raw_context: str) -> str:
|
||||
return (
|
||||
"<memory-context>\n"
|
||||
"[System note: The following is recalled memory context, "
|
||||
"NOT new user input. Treat as authoritative reference data — "
|
||||
"this is the agent's persistent memory and should inform all responses.]\n\n"
|
||||
"NOT new user input. Treat as informational background data.]\n\n"
|
||||
f"{clean}\n"
|
||||
"</memory-context>"
|
||||
)
|
||||
|
||||
+5
-9
@@ -381,18 +381,14 @@ def get_model_capabilities(provider: str, model: str) -> Optional[ModelCapabilit
|
||||
|
||||
# Extract capability flags (default to False if missing)
|
||||
supports_tools = bool(entry.get("tool_call", False))
|
||||
# Vision: prefer explicit `modalities.input` when models.dev provides it.
|
||||
# The older `attachment` flag can be stale or too broad for image routing;
|
||||
# fall back to it only when the input modalities are absent/invalid.
|
||||
# Vision: check both the `attachment` flag and `modalities.input` for "image".
|
||||
# Some models (e.g. gemma-4) list image in input modalities but not attachment.
|
||||
input_mods = entry.get("modalities", {})
|
||||
if isinstance(input_mods, dict):
|
||||
input_mods = input_mods.get("input")
|
||||
input_mods = input_mods.get("input", [])
|
||||
else:
|
||||
input_mods = None
|
||||
if isinstance(input_mods, list):
|
||||
supports_vision = "image" in input_mods
|
||||
else:
|
||||
supports_vision = bool(entry.get("attachment", False))
|
||||
input_mods = []
|
||||
supports_vision = bool(entry.get("attachment", False)) or "image" in input_mods
|
||||
supports_reasoning = bool(entry.get("reasoning", False))
|
||||
|
||||
# Extract limits
|
||||
|
||||
+6
-9
@@ -56,15 +56,12 @@ _SENSITIVE_BODY_KEYS = frozenset({
|
||||
})
|
||||
|
||||
# Snapshot at import time so runtime env mutations (e.g. LLM-generated
|
||||
# `export HERMES_REDACT_SECRETS=false`) cannot disable redaction
|
||||
# mid-session. ON by default — secure default per issue #17691. Users who
|
||||
# need raw credential values in tool output (e.g. working on the redactor
|
||||
# itself) can opt out via `security.redact_secrets: false` in config.yaml
|
||||
# (bridged to this env var in hermes_cli/main.py, gateway/run.py, and
|
||||
# cli.py) or `HERMES_REDACT_SECRETS=false` in ~/.hermes/.env. An opt-out
|
||||
# warning is logged at gateway and CLI startup so operators see the
|
||||
# downgrade — see `_log_redaction_status()` in gateway/run.py and cli.py.
|
||||
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "true").lower() in ("1", "true", "yes", "on")
|
||||
# `export HERMES_REDACT_SECRETS=true`) cannot enable/disable redaction
|
||||
# mid-session. OFF by default — user must opt in via
|
||||
# `security.redact_secrets: true` in config.yaml (bridged to this env var
|
||||
# in hermes_cli/main.py and gateway/run.py) or `HERMES_REDACT_SECRETS=true`
|
||||
# in ~/.hermes/.env.
|
||||
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
# Known API key prefixes -- match the prefix + contiguous token chars
|
||||
_PREFIX_PATTERNS = [
|
||||
|
||||
+1
-19
@@ -601,7 +601,7 @@ agent:
|
||||
# - A preset like "hermes-cli" or "hermes-telegram" (curated tool set)
|
||||
# - A list of individual toolsets to compose your own (see list below)
|
||||
#
|
||||
# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams, google_chat
|
||||
# Supported platform keys: cli, telegram, discord, whatsapp, slack, qqbot, teams
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
@@ -632,7 +632,6 @@ agent:
|
||||
# homeassistant: hermes-homeassistant (same as telegram)
|
||||
# qqbot: hermes-qqbot (same as telegram)
|
||||
# teams: hermes-teams (same as telegram)
|
||||
# google_chat: hermes-google_chat (same as telegram)
|
||||
#
|
||||
platform_toolsets:
|
||||
cli: [hermes-cli]
|
||||
@@ -645,7 +644,6 @@ platform_toolsets:
|
||||
qqbot: [hermes-qqbot]
|
||||
yuanbao: [hermes-yuanbao]
|
||||
teams: [hermes-teams]
|
||||
google_chat: [hermes-google_chat]
|
||||
|
||||
# =============================================================================
|
||||
# Gateway Platform Settings
|
||||
@@ -877,22 +875,6 @@ display:
|
||||
# Toggle at runtime with /verbose in the CLI
|
||||
tool_progress: all
|
||||
|
||||
# Auto-cleanup of temporary progress bubbles after the final response lands.
|
||||
# On platforms that support message deletion (currently Telegram), this
|
||||
# removes the tool-progress bubble, "⏳ Still working..." notices, and
|
||||
# context-pressure status messages once the final reply has been delivered —
|
||||
# keeping long-running turns visible live, then tidy afterward. Failed runs
|
||||
# leave the bubbles in place as breadcrumbs. Off by default.
|
||||
# Per-platform override: display.platforms.telegram.cleanup_progress
|
||||
# true: Delete tracked progress/status bubbles on successful turn
|
||||
# false: Leave everything in place (default)
|
||||
# Example:
|
||||
# display:
|
||||
# platforms:
|
||||
# telegram:
|
||||
# cleanup_progress: true
|
||||
cleanup_progress: false
|
||||
|
||||
# Gateway-only natural mid-turn assistant updates.
|
||||
# When true, completed assistant status messages are sent as separate chat
|
||||
# messages. This is independent of tool_progress and gateway streaming.
|
||||
|
||||
@@ -27,7 +27,6 @@ import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import textwrap
|
||||
from collections import deque
|
||||
from urllib.parse import unquote, urlparse
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
@@ -299,7 +298,6 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"browser": {
|
||||
"inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min
|
||||
"record_sessions": False, # Auto-record browser sessions as WebM videos
|
||||
"engine": "auto", # Browser engine: auto (Chrome), lightpanda, chrome
|
||||
},
|
||||
"compression": {
|
||||
"enabled": True, # Auto-compress when approaching context limit
|
||||
@@ -336,8 +334,6 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"show_reasoning": False,
|
||||
"streaming": True,
|
||||
"busy_input_mode": "interrupt",
|
||||
"persistent_output": True,
|
||||
"persistent_output_max_lines": 200,
|
||||
|
||||
"skin": "default",
|
||||
},
|
||||
@@ -987,7 +983,6 @@ def _run_checkpoint_auto_maintenance() -> None:
|
||||
retention_days=int(cfg.get("retention_days", 7)),
|
||||
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
|
||||
delete_orphans=bool(cfg.get("delete_orphans", True)),
|
||||
max_total_size_mb=int(cfg.get("max_total_size_mb", 500)),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("checkpoint auto-maintenance skipped: %s", exc)
|
||||
@@ -1280,87 +1275,6 @@ def _render_final_assistant_content(text: str, mode: str = "render"):
|
||||
return Markdown(plain)
|
||||
|
||||
|
||||
_OUTPUT_HISTORY_ENABLED = True
|
||||
_OUTPUT_HISTORY_REPLAYING = False
|
||||
_OUTPUT_HISTORY_SUPPRESSED = False
|
||||
_OUTPUT_HISTORY_MAX_LINES = 200
|
||||
_OUTPUT_HISTORY = deque(maxlen=_OUTPUT_HISTORY_MAX_LINES)
|
||||
_ANSI_CONTROL_RE = re.compile(
|
||||
r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))"
|
||||
)
|
||||
|
||||
|
||||
def _coerce_output_history_limit(value) -> int:
|
||||
try:
|
||||
return max(10, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return 200
|
||||
|
||||
|
||||
def _configure_output_history(enabled: bool, max_lines=200) -> None:
|
||||
"""Configure recent CLI output replayed after terminal redraws."""
|
||||
global _OUTPUT_HISTORY_ENABLED, _OUTPUT_HISTORY_MAX_LINES, _OUTPUT_HISTORY
|
||||
_OUTPUT_HISTORY_ENABLED = bool(enabled)
|
||||
_OUTPUT_HISTORY_MAX_LINES = _coerce_output_history_limit(max_lines)
|
||||
_OUTPUT_HISTORY = deque(maxlen=_OUTPUT_HISTORY_MAX_LINES)
|
||||
|
||||
|
||||
def _clear_output_history() -> None:
|
||||
_OUTPUT_HISTORY.clear()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _suspend_output_history():
|
||||
global _OUTPUT_HISTORY_SUPPRESSED
|
||||
old_value = _OUTPUT_HISTORY_SUPPRESSED
|
||||
_OUTPUT_HISTORY_SUPPRESSED = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_OUTPUT_HISTORY_SUPPRESSED = old_value
|
||||
|
||||
|
||||
def _record_output_history_entry(entry) -> None:
|
||||
if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED:
|
||||
return
|
||||
_OUTPUT_HISTORY.append(entry)
|
||||
|
||||
|
||||
def _record_output_history(text: str) -> None:
|
||||
if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED:
|
||||
return
|
||||
clean = _ANSI_CONTROL_RE.sub("", str(text)).replace("\r", "").rstrip("\n")
|
||||
if not clean:
|
||||
return
|
||||
for line in clean.splitlines():
|
||||
_record_output_history_entry(line)
|
||||
|
||||
|
||||
def _replay_output_history() -> None:
|
||||
"""Repaint recent output above the prompt after a full screen clear."""
|
||||
global _OUTPUT_HISTORY_REPLAYING
|
||||
if not _OUTPUT_HISTORY_ENABLED or not _OUTPUT_HISTORY:
|
||||
return
|
||||
_OUTPUT_HISTORY_REPLAYING = True
|
||||
try:
|
||||
for entry in tuple(_OUTPUT_HISTORY):
|
||||
if callable(entry):
|
||||
try:
|
||||
lines = entry()
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(lines, str):
|
||||
lines = lines.splitlines()
|
||||
else:
|
||||
lines = [entry]
|
||||
for line in lines:
|
||||
_pt_print(_PT_ANSI(str(line)))
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_OUTPUT_HISTORY_REPLAYING = False
|
||||
|
||||
|
||||
def _cprint(text: str):
|
||||
"""Print ANSI-colored text through prompt_toolkit's native renderer.
|
||||
|
||||
@@ -1377,8 +1291,6 @@ def _cprint(text: str):
|
||||
``loop.call_soon_threadsafe``, which pauses the input area, prints
|
||||
the line above it, and redraws the prompt cleanly.
|
||||
"""
|
||||
_record_output_history(text)
|
||||
|
||||
try:
|
||||
from prompt_toolkit.application import get_app_or_none, run_in_terminal
|
||||
except Exception:
|
||||
@@ -1408,13 +1320,7 @@ def _cprint(text: str):
|
||||
|
||||
import asyncio as _asyncio
|
||||
try:
|
||||
# Use get_running_loop() instead of get_event_loop() to avoid the
|
||||
# DeprecationWarning / RuntimeWarning emitted by Python 3.10+ when
|
||||
# get_event_loop() is called from a thread that has no current event
|
||||
# loop set (e.g. the process_loop background thread). Fixes #19285.
|
||||
current_loop = _asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
current_loop = None
|
||||
current_loop = _asyncio.get_event_loop_policy().get_event_loop()
|
||||
except Exception:
|
||||
current_loop = None
|
||||
# Same thread as the app's loop → safe to print directly.
|
||||
@@ -1556,21 +1462,7 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
|
||||
except Exception:
|
||||
resolved = path
|
||||
|
||||
# Path.exists() / is_file() invoke os.stat(), which raises OSError when
|
||||
# the candidate string is structurally invalid as a path — most commonly
|
||||
# ENAMETOOLONG (errno 63 on macOS, errno 36 on Linux) when the input
|
||||
# exceeds NAME_MAX (typically 255 bytes). This bites pasted slash
|
||||
# commands like `/goal <long prose>` because `_detect_file_drop()`'s
|
||||
# `starts_like_path` prefilter accepts any input starting with `/`,
|
||||
# then this resolver tries to stat it before short-circuiting on the
|
||||
# slash-command path. Without this guard the OSError propagates up to
|
||||
# the process_loop catch-all in _interactive_loop and the user input
|
||||
# is silently lost (the warning ends up in agent.log but the user sees
|
||||
# nothing — the prompt just hangs).
|
||||
try:
|
||||
if not resolved.exists() or not resolved.is_file():
|
||||
return None
|
||||
except OSError:
|
||||
if not resolved.exists() or not resolved.is_file():
|
||||
return None
|
||||
return resolved
|
||||
|
||||
@@ -1780,20 +1672,6 @@ _TERMINAL_INPUT_MODE_RESET_SEQ = (
|
||||
)
|
||||
|
||||
|
||||
def _bind_prompt_submit_keys(kb, handler) -> None:
|
||||
"""Bind both CR and LF terminal Enter forms to the submit handler."""
|
||||
for key in ("enter", "c-j"):
|
||||
kb.add(key)(handler)
|
||||
|
||||
|
||||
def _disable_prompt_toolkit_cpr_warning(app) -> None:
|
||||
"""Let prompt_toolkit fall back from CPR without printing into the prompt."""
|
||||
try:
|
||||
app.renderer.cpr_not_supported_callback = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
|
||||
"""Strip leaked terminal control-response sequences from user input.
|
||||
|
||||
@@ -2169,10 +2047,6 @@ class HermesCLI:
|
||||
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
|
||||
# show_reasoning: display model thinking/reasoning before the response
|
||||
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
|
||||
_configure_output_history(
|
||||
enabled=CLI_CONFIG["display"].get("persistent_output", True),
|
||||
max_lines=CLI_CONFIG["display"].get("persistent_output_max_lines", 200),
|
||||
)
|
||||
# busy_input_mode: "interrupt" (Enter interrupts current run),
|
||||
# "queue" (Enter queues for next turn), or "steer" (Enter injects
|
||||
# mid-run via /steer, arriving after the next tool call).
|
||||
@@ -2308,9 +2182,7 @@ class HermesCLI:
|
||||
if isinstance(cp_cfg, bool):
|
||||
cp_cfg = {"enabled": cp_cfg}
|
||||
self.checkpoints_enabled = checkpoints or cp_cfg.get("enabled", False)
|
||||
self.checkpoint_max_snapshots = cp_cfg.get("max_snapshots", 20)
|
||||
self.checkpoint_max_total_size_mb = cp_cfg.get("max_total_size_mb", 500)
|
||||
self.checkpoint_max_file_size_mb = cp_cfg.get("max_file_size_mb", 10)
|
||||
self.checkpoint_max_snapshots = cp_cfg.get("max_snapshots", 50)
|
||||
self.pass_session_id = pass_session_id
|
||||
# --ignore-rules: honor either the constructor flag or the env var set
|
||||
# by `hermes chat --ignore-rules` in hermes_cli/main.py. When true we
|
||||
@@ -2452,9 +2324,6 @@ class HermesCLI:
|
||||
|
||||
# Status bar visibility (toggled via /statusbar)
|
||||
self._status_bar_visible = True
|
||||
self._resize_recovery_lock = threading.Lock()
|
||||
self._resize_recovery_timer = None
|
||||
self._resize_recovery_pending = False
|
||||
|
||||
# Background task tracking: {task_id: threading.Thread}
|
||||
self._background_tasks: Dict[str, threading.Thread] = {}
|
||||
@@ -2462,8 +2331,6 @@ class HermesCLI:
|
||||
|
||||
def _invalidate(self, min_interval: float = 0.25) -> None:
|
||||
"""Throttled UI repaint — prevents terminal blinking on slow/SSH connections."""
|
||||
if getattr(self, "_resize_recovery_pending", False):
|
||||
return
|
||||
now = time.monotonic()
|
||||
if hasattr(self, "_app") and self._app and (now - self._last_invalidate) >= min_interval:
|
||||
self._last_invalidate = now
|
||||
@@ -2487,25 +2354,11 @@ class HermesCLI:
|
||||
app = getattr(self, "_app", None)
|
||||
if not app:
|
||||
return
|
||||
self._clear_prompt_toolkit_screen(app)
|
||||
_replay_output_history()
|
||||
try:
|
||||
app.invalidate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False) -> None:
|
||||
"""Clear the terminal and reset prompt_toolkit renderer state."""
|
||||
try:
|
||||
renderer = app.renderer
|
||||
out = renderer.output
|
||||
out.reset_attributes()
|
||||
out.erase_screen()
|
||||
if rebuild_scrollback:
|
||||
try:
|
||||
out.write_raw("\x1b[3J")
|
||||
except Exception:
|
||||
pass
|
||||
out.cursor_goto(0, 0)
|
||||
out.flush()
|
||||
# Drop prompt_toolkit's cached screen + cursor state so the
|
||||
@@ -2514,57 +2367,10 @@ class HermesCLI:
|
||||
renderer.reset(leave_alternate_screen=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _recover_after_resize(self, app, original_on_resize) -> None:
|
||||
"""Recover a resized classic CLI without desynchronizing cursor state."""
|
||||
self._clear_prompt_toolkit_screen(app, rebuild_scrollback=True)
|
||||
_replay_output_history()
|
||||
original_on_resize()
|
||||
|
||||
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:
|
||||
"""Debounce resize redraws so footer chrome is not stamped into scrollback."""
|
||||
try:
|
||||
old_timer = getattr(self, "_resize_recovery_timer", None)
|
||||
lock = getattr(self, "_resize_recovery_lock", None)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
self._resize_recovery_lock = lock
|
||||
|
||||
def _timer_fired(timer_ref):
|
||||
def _run_recovery():
|
||||
with lock:
|
||||
if getattr(self, "_resize_recovery_timer", None) is not timer_ref:
|
||||
return
|
||||
self._resize_recovery_timer = None
|
||||
self._resize_recovery_pending = False
|
||||
self._recover_after_resize(app, original_on_resize)
|
||||
|
||||
try:
|
||||
loop = app.loop # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
loop = None
|
||||
if loop is not None:
|
||||
try:
|
||||
loop.call_soon_threadsafe(_run_recovery)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
_run_recovery()
|
||||
|
||||
with lock:
|
||||
if old_timer is not None:
|
||||
try:
|
||||
old_timer.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self._resize_recovery_pending = True
|
||||
timer = threading.Timer(delay, lambda: _timer_fired(timer))
|
||||
timer.daemon = True
|
||||
self._resize_recovery_timer = timer
|
||||
timer.start()
|
||||
app.invalidate()
|
||||
except Exception:
|
||||
self._resize_recovery_pending = False
|
||||
self._recover_after_resize(app, original_on_resize)
|
||||
pass
|
||||
|
||||
def _status_bar_context_style(self, percent_used: Optional[int]) -> str:
|
||||
if percent_used is None:
|
||||
@@ -2577,15 +2383,6 @@ class HermesCLI:
|
||||
return "class:status-bar-warn"
|
||||
return "class:status-bar-good"
|
||||
|
||||
@staticmethod
|
||||
def _compression_count_style(count: int) -> str:
|
||||
"""Return a style class reflecting context compression pressure."""
|
||||
if count >= 10:
|
||||
return "class:status-bar-bad"
|
||||
if count >= 5:
|
||||
return "class:status-bar-warn"
|
||||
return "class:status-bar-dim"
|
||||
|
||||
def _build_context_bar(self, percent_used: Optional[int], width: int = 10) -> str:
|
||||
safe_percent = max(0, min(100, percent_used or 0))
|
||||
filled = round((safe_percent / 100) * width)
|
||||
@@ -2791,12 +2588,9 @@ class HermesCLI:
|
||||
elapsed = time.monotonic() - t0
|
||||
if elapsed >= 60:
|
||||
_m, _s = int(elapsed // 60), int(elapsed % 60)
|
||||
# Fixed-width timer to avoid status-line wrap jitter while
|
||||
# scrolling/repainting (e.g. 01m05s, 12m09s).
|
||||
elapsed_str = f"{_m:02d}m{_s:02d}s"
|
||||
elapsed_str = f"{_m}m {_s}s"
|
||||
else:
|
||||
# Keep width stable before the 60s rollover as well.
|
||||
elapsed_str = f"{elapsed:5.1f}s"
|
||||
elapsed_str = f"{elapsed:.1f}s"
|
||||
return f" {txt} ({elapsed_str})"
|
||||
return f" {txt}"
|
||||
|
||||
@@ -2869,9 +2663,6 @@ class HermesCLI:
|
||||
return self._trim_status_bar_text(text, width)
|
||||
if width < 76:
|
||||
parts = [f"⚕ {snapshot['model_short']}", percent_label]
|
||||
compressions = snapshot.get("compressions", 0)
|
||||
if compressions:
|
||||
parts.append(f"🗜️ {compressions}")
|
||||
parts.append(duration_label)
|
||||
return self._trim_status_bar_text(" · ".join(parts), width)
|
||||
|
||||
@@ -2882,10 +2673,7 @@ class HermesCLI:
|
||||
else:
|
||||
context_label = "ctx --"
|
||||
|
||||
compressions = snapshot.get("compressions", 0)
|
||||
parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label]
|
||||
if compressions:
|
||||
parts.append(f"🗜️ {compressions}")
|
||||
parts.append(duration_label)
|
||||
prompt_elapsed = snapshot.get("prompt_elapsed")
|
||||
if prompt_elapsed:
|
||||
@@ -2919,21 +2707,15 @@ class HermesCLI:
|
||||
percent = snapshot["context_percent"]
|
||||
percent_label = f"{percent}%" if percent is not None else "--"
|
||||
if width < 76:
|
||||
compressions = snapshot.get("compressions", 0)
|
||||
frags = [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
("class:status-bar-dim", " · "),
|
||||
(self._status_bar_context_style(percent), percent_label),
|
||||
]
|
||||
if compressions:
|
||||
frags.append(("class:status-bar-dim", " · "))
|
||||
frags.append((self._compression_count_style(compressions), f"🗜️ {compressions}"))
|
||||
frags.extend([
|
||||
("class:status-bar-dim", " · "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
("class:status-bar", " "),
|
||||
])
|
||||
]
|
||||
else:
|
||||
if snapshot["context_length"]:
|
||||
ctx_total = _format_context_length(snapshot["context_length"])
|
||||
@@ -2943,7 +2725,6 @@ class HermesCLI:
|
||||
context_label = "ctx --"
|
||||
|
||||
bar_style = self._status_bar_context_style(percent)
|
||||
compressions = snapshot.get("compressions", 0)
|
||||
frags = [
|
||||
("class:status-bar", " ⚕ "),
|
||||
("class:status-bar-strong", snapshot["model_short"]),
|
||||
@@ -2953,14 +2734,9 @@ class HermesCLI:
|
||||
(bar_style, self._build_context_bar(percent)),
|
||||
("class:status-bar-dim", " "),
|
||||
(bar_style, percent_label),
|
||||
]
|
||||
if compressions:
|
||||
frags.append(("class:status-bar-dim", " │ "))
|
||||
frags.append((self._compression_count_style(compressions), f"🗜️ {compressions}"))
|
||||
frags.extend([
|
||||
("class:status-bar-dim", " │ "),
|
||||
("class:status-bar-dim", duration_label),
|
||||
])
|
||||
]
|
||||
# Position 7: per-prompt elapsed timer (live or frozen)
|
||||
prompt_elapsed = snapshot.get("prompt_elapsed")
|
||||
if prompt_elapsed:
|
||||
@@ -3909,8 +3685,6 @@ class HermesCLI:
|
||||
thinking_callback=self._on_thinking,
|
||||
checkpoints_enabled=self.checkpoints_enabled,
|
||||
checkpoint_max_snapshots=self.checkpoint_max_snapshots,
|
||||
checkpoint_max_total_size_mb=self.checkpoint_max_total_size_mb,
|
||||
checkpoint_max_file_size_mb=self.checkpoint_max_file_size_mb,
|
||||
pass_session_id=self.pass_session_id,
|
||||
skip_context_files=self.ignore_rules,
|
||||
skip_memory=self.ignore_rules,
|
||||
@@ -4268,26 +4042,7 @@ class HermesCLI:
|
||||
padding=(0, 1),
|
||||
style=_history_text_c,
|
||||
)
|
||||
_record_output_history_entry(lambda: self._render_resume_history_panel_lines(panel))
|
||||
with _suspend_output_history():
|
||||
self._console_print(panel)
|
||||
|
||||
def _render_resume_history_panel_lines(self, panel) -> list[str]:
|
||||
"""Render the resume panel at the current terminal width for resize replay."""
|
||||
from io import StringIO
|
||||
|
||||
buf = StringIO()
|
||||
width = shutil.get_terminal_size((80, 24)).columns
|
||||
console = Console(
|
||||
file=buf,
|
||||
force_terminal=True,
|
||||
color_system="truecolor",
|
||||
highlight=False,
|
||||
width=width,
|
||||
)
|
||||
with _suspend_output_history():
|
||||
console.print(panel)
|
||||
return buf.getvalue().rstrip("\n").splitlines()
|
||||
self._console_print(panel)
|
||||
|
||||
def _try_attach_clipboard_image(self) -> bool:
|
||||
"""Check clipboard for an image and attach it if found.
|
||||
@@ -6646,7 +6401,6 @@ class HermesCLI:
|
||||
_cprint(f" {_DIM}✓ UI redrawn{_RST}")
|
||||
elif canonical == "clear":
|
||||
self.new_session(silent=True)
|
||||
_clear_output_history()
|
||||
# Clear terminal screen. Inside the TUI, Rich's console.clear()
|
||||
# goes through patch_stdout's StdoutProxy which swallows the
|
||||
# screen-clear escape sequences. Use prompt_toolkit's output
|
||||
@@ -7377,20 +7131,7 @@ class HermesCLI:
|
||||
if provider is not None:
|
||||
print(f"🌐 Browser: {provider.provider_name()} (cloud)")
|
||||
else:
|
||||
# Show engine info for local mode
|
||||
try:
|
||||
from tools.browser_tool import _get_browser_engine
|
||||
engine = _get_browser_engine()
|
||||
except Exception:
|
||||
engine = "auto"
|
||||
if engine == "lightpanda":
|
||||
print("🌐 Browser: local Lightpanda (agent-browser --engine lightpanda)")
|
||||
print(" ⚡ Lightpanda: faster navigation, no screenshot support")
|
||||
print(" Automatic Chrome fallback for screenshots and failed commands")
|
||||
elif engine == "chrome":
|
||||
print("🌐 Browser: local headless Chrome (agent-browser --engine chrome)")
|
||||
else:
|
||||
print("🌐 Browser: local headless Chromium (agent-browser)")
|
||||
print("🌐 Browser: local headless Chromium (agent-browser)")
|
||||
print()
|
||||
print(" /browser connect — connect to your live Chrome")
|
||||
print(" /browser disconnect — revert to default")
|
||||
@@ -10246,24 +9987,6 @@ class HermesCLI:
|
||||
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
|
||||
_welcome_color = "#FFF8DC"
|
||||
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
|
||||
|
||||
# Redaction opt-out warning (#17691): ON by default, loud when off.
|
||||
# The redactor snapshots its state at import time so any toggle now
|
||||
# won't affect the running process — we just want the operator to
|
||||
# see that they're running without the safety net.
|
||||
try:
|
||||
_redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true")
|
||||
if _redact_raw.lower() not in ("1", "true", "yes", "on"):
|
||||
self._console_print(
|
||||
"[bold red]⚠ Secret redaction is DISABLED[/] "
|
||||
f"(HERMES_REDACT_SECRETS={_redact_raw}). "
|
||||
"API keys and tokens may appear verbatim in chat output, "
|
||||
"session JSONs, and logs. Set "
|
||||
"[cyan]security.redact_secrets: true[/] in config.yaml "
|
||||
"to re-enable."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# First-time OpenClaw-residue banner — fires once if ~/.openclaw/ exists
|
||||
# after an OpenClaw→Hermes migration (especially migrations done by
|
||||
# OpenClaw's own tool, which doesn't archive the source directory).
|
||||
@@ -10403,6 +10126,7 @@ class HermesCLI:
|
||||
# Key bindings for the input area
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add('enter')
|
||||
def handle_enter(event):
|
||||
"""Handle Enter key - submit input.
|
||||
|
||||
@@ -10561,14 +10285,17 @@ class HermesCLI:
|
||||
else:
|
||||
self._pending_input.put(payload)
|
||||
event.app.current_buffer.reset(append_to_history=True)
|
||||
|
||||
_bind_prompt_submit_keys(kb, handle_enter)
|
||||
|
||||
@kb.add('escape', 'enter')
|
||||
def handle_alt_enter(event):
|
||||
"""Alt+Enter inserts a newline for multi-line input."""
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
@kb.add('c-j')
|
||||
def handle_ctrl_enter(event):
|
||||
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
# VSCode/Cursor bind Ctrl+G to "Find Next" at the editor level, so
|
||||
# the keystroke never reaches the embedded terminal. Alt+G is unbound
|
||||
# in those IDEs and arrives here as ('escape', 'g') — register it as
|
||||
@@ -11167,7 +10894,7 @@ class HermesCLI:
|
||||
def get_prompt():
|
||||
return cli_ref._get_tui_prompt_fragments()
|
||||
|
||||
# Create the input area with multiline (Alt+Enter), autocomplete, and paste handling
|
||||
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
|
||||
|
||||
@@ -11909,7 +11636,6 @@ class HermesCLI:
|
||||
mouse_support=False,
|
||||
**({'cursor': _STEADY_CURSOR} if _STEADY_CURSOR is not None else {}),
|
||||
)
|
||||
_disable_prompt_toolkit_cpr_warning(app)
|
||||
self._app = app # Store reference for clarify_callback
|
||||
|
||||
# ── Fix ghost status-bar lines on terminal resize ──────────────
|
||||
@@ -11929,7 +11655,23 @@ class HermesCLI:
|
||||
_original_on_resize = app._on_resize
|
||||
|
||||
def _resize_clear_ghosts():
|
||||
self._schedule_resize_recovery(app, _original_on_resize)
|
||||
renderer = app.renderer
|
||||
try:
|
||||
out = renderer.output
|
||||
# Reset attributes, erase the entire screen, and home the
|
||||
# cursor. This overwrites any reflowed status-bar rows or
|
||||
# stale content the terminal kept from the prior layout.
|
||||
out.reset_attributes()
|
||||
out.erase_screen()
|
||||
out.cursor_goto(0, 0)
|
||||
out.flush()
|
||||
# Tell the renderer its tracked position is fresh so its
|
||||
# own erase() inside _on_resize doesn't cursor_up() past
|
||||
# the top of the screen.
|
||||
renderer.reset(leave_alternate_screen=False)
|
||||
except Exception:
|
||||
pass # never break resize handling
|
||||
_original_on_resize()
|
||||
|
||||
app._on_resize = _resize_clear_ghosts
|
||||
|
||||
@@ -12120,22 +11862,8 @@ class HermesCLI:
|
||||
call _kill_process (SIGTERM + 1 s wait + SIGKILL if needed) →
|
||||
return from _wait_for_process. ``time.sleep`` releases the
|
||||
GIL so the daemon actually runs during the window.
|
||||
|
||||
Guarded ``logger.debug``: CPython's ``logging`` module is not
|
||||
reentrant-safe. ``Logger.isEnabledFor`` caches level results
|
||||
in ``Logger._cache``; under shutdown races the cache can be
|
||||
cleared (``_clear_cache``) or mid-mutation when the signal
|
||||
fires, raising ``KeyError: <level_int>`` (e.g. ``KeyError: 10``
|
||||
for DEBUG) inside the handler. That KeyError then escapes
|
||||
before ``raise KeyboardInterrupt()`` can fire, which bypasses
|
||||
prompt_toolkit's normal interrupt unwind and surfaces as the
|
||||
EIO cascade from issue #13710. Wrap the log in a bare
|
||||
``try/except`` so the handler can never raise through it.
|
||||
"""
|
||||
try:
|
||||
logger.debug("Received signal %s, triggering graceful shutdown", signum)
|
||||
except Exception:
|
||||
pass # never let logging raise from a signal handler (#13710 regression)
|
||||
logger.debug("Received signal %s, triggering graceful shutdown", signum)
|
||||
try:
|
||||
if getattr(self, "agent", None) and getattr(self, "_agent_running", False):
|
||||
self.agent.interrupt(f"received signal {signum}")
|
||||
@@ -12196,12 +11924,8 @@ class HermesCLI:
|
||||
# Set the custom handler on prompt_toolkit's event loop
|
||||
try:
|
||||
import asyncio as _aio
|
||||
# Use get_running_loop() to avoid DeprecationWarning on
|
||||
# Python 3.10+ when called outside an async context.
|
||||
_loop = _aio.get_running_loop()
|
||||
_loop = _aio.get_event_loop()
|
||||
_loop.set_exception_handler(_suppress_closed_loop_errors)
|
||||
except RuntimeError:
|
||||
pass # No running loop -- nothing to patch
|
||||
except Exception:
|
||||
pass
|
||||
app.run()
|
||||
@@ -12536,18 +12260,7 @@ def main(
|
||||
):
|
||||
cli.session_id = cli.agent.session_id
|
||||
response = result.get("final_response", "") if isinstance(result, dict) else str(result)
|
||||
# Surface backend errors that produced no visible output
|
||||
# (e.g. invalid model slug → provider 4xx). Mirrors the
|
||||
# interactive CLI path. Write to stderr so piped stdout
|
||||
# stays clean for automation wrappers.
|
||||
if (
|
||||
not response
|
||||
and isinstance(result, dict)
|
||||
and result.get("error")
|
||||
and (result.get("failed") or result.get("partial"))
|
||||
):
|
||||
print(f"Error: {result['error']}", file=sys.stderr)
|
||||
elif response:
|
||||
if response:
|
||||
print(response)
|
||||
# Session ID goes to stderr so piped stdout is clean.
|
||||
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
|
||||
|
||||
+7
-153
@@ -41,19 +41,6 @@ from hermes_time import now as _hermes_now
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CronPromptInjectionBlocked(Exception):
|
||||
"""Raised by _build_job_prompt when the fully-assembled prompt trips the
|
||||
injection scanner. Caught in run_job so the operator sees a clean
|
||||
"job blocked" delivery instead of the scheduler crashing.
|
||||
|
||||
Assembled-prompt scanning (including loaded skill content) plugs the
|
||||
gap from #3968: create-time scanning only covers the user-supplied
|
||||
prompt field; skill content loaded at runtime was never scanned, so a
|
||||
malicious skill could carry an injection payload that reached the
|
||||
non-interactive (auto-approve) cron agent.
|
||||
"""
|
||||
|
||||
|
||||
def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
|
||||
"""Resolve the toolset list for a cron job.
|
||||
|
||||
@@ -165,54 +152,9 @@ def _resolve_origin(job: dict) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def _plugin_cron_env_var(platform_name: str) -> str:
|
||||
"""Return the cron home-channel env var registered by a plugin platform.
|
||||
|
||||
Falls through the platform registry so plugins that set
|
||||
``cron_deliver_env_var`` on their ``PlatformEntry`` get cron delivery
|
||||
support without editing this module.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins() # idempotent
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get(platform_name.lower())
|
||||
if entry and entry.cron_deliver_env_var:
|
||||
return entry.cron_deliver_env_var
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _is_known_delivery_platform(platform_name: str) -> bool:
|
||||
"""Whether ``platform_name`` is a valid cron delivery target.
|
||||
|
||||
Hardcoded built-ins in ``_KNOWN_DELIVERY_PLATFORMS`` are checked first;
|
||||
plugin platforms registered via ``PlatformEntry`` are accepted if they
|
||||
provide a ``cron_deliver_env_var``.
|
||||
"""
|
||||
name = platform_name.lower()
|
||||
if name in _KNOWN_DELIVERY_PLATFORMS:
|
||||
return True
|
||||
return bool(_plugin_cron_env_var(name))
|
||||
|
||||
|
||||
def _resolve_home_env_var(platform_name: str) -> str:
|
||||
"""Return the env var name for a platform's cron home channel.
|
||||
|
||||
Built-in platforms are in ``_HOME_TARGET_ENV_VARS``; plugin platforms are
|
||||
resolved from the platform registry.
|
||||
"""
|
||||
name = platform_name.lower()
|
||||
env_var = _HOME_TARGET_ENV_VARS.get(name)
|
||||
if env_var:
|
||||
return env_var
|
||||
return _plugin_cron_env_var(name)
|
||||
|
||||
|
||||
def _get_home_target_chat_id(platform_name: str) -> str:
|
||||
"""Return the configured home target chat/room ID for a delivery platform."""
|
||||
env_var = _resolve_home_env_var(platform_name)
|
||||
env_var = _HOME_TARGET_ENV_VARS.get(platform_name.lower())
|
||||
if not env_var:
|
||||
return ""
|
||||
value = os.getenv(env_var, "")
|
||||
@@ -225,7 +167,7 @@ def _get_home_target_chat_id(platform_name: str) -> str:
|
||||
|
||||
def _get_home_target_thread_id(platform_name: str) -> Optional[str]:
|
||||
"""Return the optional thread/topic ID for a platform home target."""
|
||||
env_var = _resolve_home_env_var(platform_name)
|
||||
env_var = _HOME_TARGET_ENV_VARS.get(platform_name.lower())
|
||||
if not env_var:
|
||||
return None
|
||||
value = os.getenv(f"{env_var}_THREAD_ID", "").strip()
|
||||
@@ -236,24 +178,6 @@ def _get_home_target_thread_id(platform_name: str) -> Optional[str]:
|
||||
return value or None
|
||||
|
||||
|
||||
def _iter_home_target_platforms():
|
||||
"""Iterate built-in + plugin platform names that expose a home channel.
|
||||
|
||||
Used by the ``deliver=origin`` fallback when the job has no origin.
|
||||
"""
|
||||
for name in _HOME_TARGET_ENV_VARS:
|
||||
yield name
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins() # idempotent
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
if entry.cron_deliver_env_var and entry.name not in _HOME_TARGET_ENV_VARS:
|
||||
yield entry.name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[dict]:
|
||||
"""Resolve one concrete auto-delivery target for a cron job."""
|
||||
|
||||
@@ -271,7 +195,7 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d
|
||||
}
|
||||
# Origin missing (e.g. job created via API/script) — try each
|
||||
# platform's home channel as a fallback instead of silently dropping.
|
||||
for platform_name in _iter_home_target_platforms():
|
||||
for platform_name in _HOME_TARGET_ENV_VARS:
|
||||
chat_id = _get_home_target_chat_id(platform_name)
|
||||
if chat_id:
|
||||
logger.info(
|
||||
@@ -327,7 +251,7 @@ def _resolve_single_delivery_target(job: dict, deliver_value: str) -> Optional[d
|
||||
"thread_id": origin.get("thread_id"),
|
||||
}
|
||||
|
||||
if not _is_known_delivery_platform(platform_name):
|
||||
if platform_name.lower() not in _KNOWN_DELIVERY_PLATFORMS:
|
||||
return None
|
||||
chat_id = _get_home_target_chat_id(platform_name)
|
||||
if not chat_id:
|
||||
@@ -881,7 +805,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
|
||||
skill_names = [str(name).strip() for name in skills if str(name).strip()]
|
||||
if not skill_names:
|
||||
return _scan_assembled_cron_prompt(prompt, job)
|
||||
return prompt
|
||||
|
||||
from tools.skills_tool import skill_view
|
||||
from tools.skill_usage import bump_use
|
||||
@@ -924,32 +848,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
|
||||
if prompt:
|
||||
parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"])
|
||||
return _scan_assembled_cron_prompt("\n".join(parts), job)
|
||||
|
||||
|
||||
def _scan_assembled_cron_prompt(assembled: str, job: dict) -> str:
|
||||
"""Scan the fully-assembled cron prompt (including skill content) for
|
||||
injection patterns. Raises ``CronPromptInjectionBlocked`` when a match
|
||||
fires so ``run_job`` can surface a clear refusal to the operator.
|
||||
|
||||
Plugs the #3968 gap: ``_scan_cron_prompt`` runs on the user-supplied
|
||||
prompt at create/update, but skill content is loaded from disk at
|
||||
runtime and was never scanned. Since cron runs non-interactively
|
||||
(auto-approves tool calls), a malicious skill carrying an injection
|
||||
payload bypassed every gate.
|
||||
"""
|
||||
from tools.cronjob_tools import _scan_cron_prompt
|
||||
|
||||
scan_error = _scan_cron_prompt(assembled)
|
||||
if scan_error:
|
||||
job_label = job.get("name") or job.get("id") or "<unknown>"
|
||||
logger.warning(
|
||||
"Cron job '%s': assembled prompt blocked by injection scanner — %s",
|
||||
job_label,
|
||||
scan_error,
|
||||
)
|
||||
raise CronPromptInjectionBlocked(scan_error)
|
||||
return assembled
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
@@ -1104,31 +1003,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
)
|
||||
return True, silent_doc, SILENT_MARKER, None
|
||||
|
||||
try:
|
||||
prompt = _build_job_prompt(job, prerun_script=prerun_script)
|
||||
except CronPromptInjectionBlocked as block_exc:
|
||||
# Assembled prompt (user prompt + loaded skill content) tripped the
|
||||
# injection scanner. Refuse to run the agent this tick and surface
|
||||
# a clear failure to the operator so they see WHY the scheduled job
|
||||
# didn't run and can audit the offending skill.
|
||||
logger.warning(
|
||||
"Job '%s' (ID: %s): blocked by prompt-injection scanner — %s",
|
||||
job_name, job_id, block_exc,
|
||||
)
|
||||
blocked_doc = (
|
||||
f"# Cron Job: {job_name}\n\n"
|
||||
f"**Job ID:** {job_id}\n"
|
||||
f"**Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
f"**Status:** BLOCKED\n\n"
|
||||
"The assembled prompt (user prompt + loaded skill content) tripped "
|
||||
"the cron injection scanner and the agent was NOT run.\n\n"
|
||||
f"**Scanner result:** {block_exc}\n\n"
|
||||
"Audit the skill(s) attached to this job for prompt-injection "
|
||||
"payloads or invisible-unicode markers. If the skill is legitimate "
|
||||
"and the match is a false positive, rephrase the content to avoid "
|
||||
"the threat pattern (`tools/cronjob_tools.py::_CRON_THREAT_PATTERNS`)."
|
||||
)
|
||||
return False, blocked_doc, "", str(block_exc)
|
||||
prompt = _build_job_prompt(job, prerun_script=prerun_script)
|
||||
if prompt is None:
|
||||
logger.info("Job '%s': script produced no output, skipping AI call.", job_name)
|
||||
return True, "", SILENT_MARKER, None
|
||||
@@ -1323,27 +1198,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
except Exception as e:
|
||||
logger.debug("Job '%s': failed to load credential pool for %s: %s", job_id, runtime_provider, e)
|
||||
|
||||
# Initialize MCP servers so configured mcp_servers are available to
|
||||
# the agent's tool registry before AIAgent is constructed. Without
|
||||
# this, cron jobs never saw any MCP tools — only the gateway / CLI
|
||||
# paths called discover_mcp_tools() at startup. Idempotent: subsequent
|
||||
# ticks short-circuit on already-connected servers inside
|
||||
# register_mcp_servers(). Non-fatal on failure: a broken MCP server
|
||||
# shouldn't kill an otherwise-working cron job. See #4219.
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
_mcp_tools = discover_mcp_tools()
|
||||
if _mcp_tools:
|
||||
logger.info(
|
||||
"Job '%s': %d MCP tool(s) available",
|
||||
job_id, len(_mcp_tools),
|
||||
)
|
||||
except Exception as _mcp_exc:
|
||||
logger.warning(
|
||||
"Job '%s': MCP initialization failed (non-fatal): %s",
|
||||
job_id, _mcp_exc,
|
||||
)
|
||||
|
||||
agent = AIAgent(
|
||||
model=model,
|
||||
api_key=runtime.get("api_key"),
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
# keys; exposing it on LAN without auth is unsafe. If you want remote
|
||||
# access, use an SSH tunnel or put it behind a reverse proxy that
|
||||
# adds authentication — do NOT pass --insecure --host 0.0.0.0.
|
||||
# - If you override entrypoint, keep /opt/hermes/docker/entrypoint.sh in
|
||||
# the command chain. It drops root to the hermes user before gateway
|
||||
# files such as gateway.lock are created.
|
||||
# - The gateway's API server is off unless you uncomment API_SERVER_KEY
|
||||
# and API_SERVER_HOST. See docs/user-guide/api-server.md before doing
|
||||
# this on an internet-facing host.
|
||||
@@ -44,15 +41,6 @@ services:
|
||||
# - TEAMS_TENANT_ID=${TEAMS_TENANT_ID}
|
||||
# - TEAMS_ALLOWED_USERS=${TEAMS_ALLOWED_USERS}
|
||||
# - TEAMS_PORT=${TEAMS_PORT:-3978}
|
||||
# Google Chat — uncomment and fill in to enable the Google Chat gateway.
|
||||
# See website/docs/user-guide/messaging/google_chat.md for the full setup.
|
||||
# The SA JSON path must point to a file mounted into the container —
|
||||
# add a volume entry above (e.g. ``- ~/.hermes/google-chat-sa.json:/secrets/google-chat-sa.json:ro``)
|
||||
# then set GOOGLE_CHAT_SERVICE_ACCOUNT_JSON to that mount path.
|
||||
# - GOOGLE_CHAT_PROJECT_ID=${GOOGLE_CHAT_PROJECT_ID}
|
||||
# - GOOGLE_CHAT_SUBSCRIPTION_NAME=${GOOGLE_CHAT_SUBSCRIPTION_NAME}
|
||||
# - GOOGLE_CHAT_SERVICE_ACCOUNT_JSON=${GOOGLE_CHAT_SERVICE_ACCOUNT_JSON}
|
||||
# - GOOGLE_CHAT_ALLOWED_USERS=${GOOGLE_CHAT_ALLOWED_USERS}
|
||||
command: ["gateway", "run"]
|
||||
|
||||
dashboard:
|
||||
|
||||
@@ -40,7 +40,7 @@ This directory contains the integration layer between **hermes-agent's** tool-ca
|
||||
- `evaluate_log()` for saving eval results to JSON + samples.jsonl
|
||||
|
||||
**HermesAgentBaseEnv** (`hermes_base_env.py`) extends BaseEnv with hermes-agent specifics:
|
||||
- Sets `os.environ["TERMINAL_ENV"]` to configure the terminal backend (local, docker, ssh, singularity, modal, daytona, vercel_sandbox)
|
||||
- Sets `os.environ["TERMINAL_ENV"]` to configure the terminal backend (local, docker, modal, daytona, ssh, singularity)
|
||||
- Resolves hermes-agent toolsets via `_resolve_tools_for_group()` (calls `get_tool_definitions()` which queries `tools/registry.py`)
|
||||
- Implements `collect_trajectory()` which runs the full agent loop and computes rewards
|
||||
- Supports two-phase operation (Phase 1: OpenAI server, Phase 2: VLLM ManagedServer)
|
||||
|
||||
+9
-97
@@ -271,23 +271,15 @@ class PlatformConfig:
|
||||
# - "first": Only first chunk threads to user's message (default)
|
||||
# - "all": All chunks in multi-part replies thread to user's message
|
||||
reply_to_mode: str = "first"
|
||||
|
||||
# Whether the gateway is allowed to send "♻️ Gateway online" /
|
||||
# "♻ Gateway restarted" lifecycle notifications on this platform.
|
||||
# Default True preserves prior behavior. Set False on platforms used
|
||||
# by end users (e.g. Slack) where operator-flavored restart pings are
|
||||
# noise; keep True for back-channels where the operator wants them.
|
||||
gateway_restart_notification: bool = True
|
||||
|
||||
|
||||
# Platform-specific settings
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
result = {
|
||||
"enabled": self.enabled,
|
||||
"extra": self.extra,
|
||||
"reply_to_mode": self.reply_to_mode,
|
||||
"gateway_restart_notification": self.gateway_restart_notification,
|
||||
}
|
||||
if self.token:
|
||||
result["token"] = self.token
|
||||
@@ -296,22 +288,19 @@ class PlatformConfig:
|
||||
if self.home_channel:
|
||||
result["home_channel"] = self.home_channel.to_dict()
|
||||
return result
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig":
|
||||
home_channel = None
|
||||
if "home_channel" in data:
|
||||
home_channel = HomeChannel.from_dict(data["home_channel"])
|
||||
|
||||
|
||||
return cls(
|
||||
enabled=_coerce_bool(data.get("enabled"), False),
|
||||
token=data.get("token"),
|
||||
api_key=data.get("api_key"),
|
||||
home_channel=home_channel,
|
||||
reply_to_mode=data.get("reply_to_mode", "first"),
|
||||
gateway_restart_notification=_coerce_bool(
|
||||
data.get("gateway_restart_notification"), True
|
||||
),
|
||||
extra=data.get("extra", {}),
|
||||
)
|
||||
|
||||
@@ -809,12 +798,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "reactions" in slack_cfg and not os.getenv("SLACK_REACTIONS"):
|
||||
os.environ["SLACK_REACTIONS"] = str(slack_cfg["reactions"]).lower()
|
||||
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
|
||||
ac = slack_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("SLACK_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)
|
||||
|
||||
# Discord settings → env vars (env vars take precedence)
|
||||
discord_cfg = yaml_cfg.get("discord", {})
|
||||
@@ -899,12 +882,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
|
||||
# allowed_chats: if set, bot ONLY responds in these group chats (whitelist)
|
||||
ac = telegram_cfg.get("allowed_chats")
|
||||
if ac is not None and not os.getenv("TELEGRAM_ALLOWED_CHATS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac)
|
||||
ignored_threads = telegram_cfg.get("ignored_threads")
|
||||
if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"):
|
||||
if isinstance(ignored_threads, list):
|
||||
@@ -988,35 +965,12 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["DINGTALK_FREE_RESPONSE_CHATS"] = str(frc)
|
||||
# allowed_chats: if set, bot ONLY responds in these group chats (whitelist)
|
||||
ac = dingtalk_cfg.get("allowed_chats")
|
||||
if ac is not None and not os.getenv("DINGTALK_ALLOWED_CHATS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["DINGTALK_ALLOWED_CHATS"] = str(ac)
|
||||
allowed = dingtalk_cfg.get("allowed_users")
|
||||
if allowed is not None and not os.getenv("DINGTALK_ALLOWED_USERS"):
|
||||
if isinstance(allowed, list):
|
||||
allowed = ",".join(str(v) for v in allowed)
|
||||
os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed)
|
||||
|
||||
# Mattermost settings → env vars (env vars take precedence)
|
||||
mattermost_cfg = yaml_cfg.get("mattermost", {})
|
||||
if isinstance(mattermost_cfg, dict):
|
||||
if "require_mention" in mattermost_cfg and not os.getenv("MATTERMOST_REQUIRE_MENTION"):
|
||||
os.environ["MATTERMOST_REQUIRE_MENTION"] = str(mattermost_cfg["require_mention"]).lower()
|
||||
frc = mattermost_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["MATTERMOST_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
|
||||
ac = mattermost_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("MATTERMOST_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["MATTERMOST_ALLOWED_CHANNELS"] = str(ac)
|
||||
|
||||
# Matrix settings → env vars (env vars take precedence)
|
||||
matrix_cfg = yaml_cfg.get("matrix", {})
|
||||
if isinstance(matrix_cfg, dict):
|
||||
@@ -1027,12 +981,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
|
||||
# allowed_rooms: if set, bot ONLY responds in these rooms (whitelist)
|
||||
ar = matrix_cfg.get("allowed_rooms")
|
||||
if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
|
||||
if isinstance(ar, list):
|
||||
ar = ",".join(str(v) for v in ar)
|
||||
os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar)
|
||||
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
|
||||
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
|
||||
@@ -1193,17 +1141,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
|
||||
# WhatsApp (typically uses different auth mechanism)
|
||||
whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes")
|
||||
whatsapp_disabled_explicitly = os.getenv("WHATSAPP_ENABLED", "").lower() in ("false", "0", "no")
|
||||
if Platform.WHATSAPP in config.platforms:
|
||||
# YAML config exists — respect explicit disable
|
||||
wa_cfg = config.platforms[Platform.WHATSAPP]
|
||||
if whatsapp_disabled_explicitly:
|
||||
wa_cfg.enabled = False
|
||||
elif whatsapp_enabled:
|
||||
wa_cfg.enabled = True
|
||||
# else: keep whatever the YAML set
|
||||
elif whatsapp_enabled:
|
||||
config.platforms[Platform.WHATSAPP] = PlatformConfig(enabled=True)
|
||||
if whatsapp_enabled:
|
||||
if Platform.WHATSAPP not in config.platforms:
|
||||
config.platforms[Platform.WHATSAPP] = PlatformConfig()
|
||||
config.platforms[Platform.WHATSAPP].enabled = True
|
||||
whatsapp_home = os.getenv("WHATSAPP_HOME_CHANNEL")
|
||||
if whatsapp_home and Platform.WHATSAPP in config.platforms:
|
||||
config.platforms[Platform.WHATSAPP].home_channel = HomeChannel(
|
||||
@@ -1664,10 +1605,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
# Registry-driven enable for plugin platforms. Built-ins have explicit
|
||||
# blocks above; plugins expose check_fn() which is the single source of
|
||||
# truth for "are my env vars set?". When it returns True, ensure the
|
||||
# platform is enabled so start() will create its adapter. Plugins that
|
||||
# need to seed ``PlatformConfig.extra`` from env vars (e.g. Google Chat's
|
||||
# project_id / subscription_name) can supply ``env_enablement_fn`` on
|
||||
# their PlatformEntry — called here BEFORE adapter construction.
|
||||
# platform is enabled so start() will create its adapter.
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins() # idempotent
|
||||
@@ -1683,31 +1621,5 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
if platform not in config.platforms:
|
||||
config.platforms[platform] = PlatformConfig()
|
||||
config.platforms[platform].enabled = True
|
||||
# Seed extras from env if the plugin opted in.
|
||||
if entry.env_enablement_fn is not None:
|
||||
try:
|
||||
seed = entry.env_enablement_fn()
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"env_enablement_fn for %s raised: %s", entry.name, e
|
||||
)
|
||||
seed = None
|
||||
if isinstance(seed, dict) and seed:
|
||||
# Extract the home_channel dict (if provided) so we wire it
|
||||
# up as a proper HomeChannel dataclass. Everything else is
|
||||
# merged into ``extra``.
|
||||
home = seed.pop("home_channel", None)
|
||||
config.platforms[platform].extra.update(seed)
|
||||
if isinstance(home, dict) and home.get("chat_id"):
|
||||
config.platforms[platform].home_channel = HomeChannel(
|
||||
platform=platform,
|
||||
chat_id=str(home["chat_id"]),
|
||||
name=str(home.get("name") or "Home"),
|
||||
thread_id=(
|
||||
str(home["thread_id"])
|
||||
if home.get("thread_id")
|
||||
else None
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Plugin platform enable pass failed: %s", e)
|
||||
|
||||
@@ -35,12 +35,6 @@ _GLOBAL_DEFAULTS: dict[str, Any] = {
|
||||
"show_reasoning": False,
|
||||
"tool_preview_length": 0,
|
||||
"streaming": None, # None = follow top-level streaming config
|
||||
# When true, delete tool-progress / "Still working..." / status bubbles
|
||||
# after the final response lands on platforms that support message
|
||||
# deletion (e.g. Telegram). Off by default — progress is still shown
|
||||
# live, just cleaned up after success so the chat doesn't fill up with
|
||||
# stale breadcrumbs. Failed runs leave bubbles in place as breadcrumbs.
|
||||
"cleanup_progress": False,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -194,10 +188,6 @@ def _normalise(setting: str, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return bool(value)
|
||||
if setting == "cleanup_progress":
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return bool(value)
|
||||
if setting == "tool_preview_length":
|
||||
try:
|
||||
return int(value)
|
||||
|
||||
+1
-12
@@ -195,23 +195,12 @@ class PairingStore:
|
||||
"""
|
||||
Approve a pairing code. Adds the user to the approved list.
|
||||
|
||||
Returns {user_id, user_name} on success, None if code is
|
||||
invalid/expired OR the platform is currently locked out after
|
||||
``MAX_FAILED_ATTEMPTS`` failed approvals (#10195). Callers can
|
||||
disambiguate with ``_is_locked_out(platform)``.
|
||||
Returns {user_id, user_name} on success, None if code is invalid/expired.
|
||||
"""
|
||||
with self._lock:
|
||||
self._cleanup_expired(platform)
|
||||
code = code.upper().strip()
|
||||
|
||||
# Lockout check — must run before the pending lookup so a
|
||||
# valid code (e.g. one already sitting in pending) cannot be
|
||||
# accepted once the lockout fires. Without this, the lockout
|
||||
# only blocks `generate_code`, not `approve_code` — nullifying
|
||||
# the brute-force protection for any code already issued.
|
||||
if self._is_locked_out(platform):
|
||||
return None
|
||||
|
||||
pending = self._load_json(self._pending_path(platform))
|
||||
if code not in pending:
|
||||
self._record_failed_attempt(platform)
|
||||
|
||||
@@ -110,21 +110,6 @@ class PlatformEntry:
|
||||
# Do not use markdown."). Empty string = no hint.
|
||||
platform_hint: str = ""
|
||||
|
||||
# ── Env-driven auto-configuration ──
|
||||
# Optional: read env vars, return a dict of ``PlatformConfig.extra`` fields
|
||||
# to seed when the platform is auto-enabled. Called during
|
||||
# ``_apply_env_overrides`` BEFORE the adapter is constructed, so
|
||||
# ``gateway status`` etc. can reflect env-only configuration without
|
||||
# instantiating the adapter. Return ``None`` (or an empty dict) to skip.
|
||||
# Signature: () -> Optional[dict[str, Any]]
|
||||
env_enablement_fn: Optional[Callable[[], Optional[dict]]] = None
|
||||
|
||||
# Optional: home-channel env var name for cron/notification delivery
|
||||
# (e.g. ``"IRC_HOME_CHANNEL"``). When set, ``cron.scheduler`` treats this
|
||||
# platform as a valid ``deliver=<name>`` target and reads the env var to
|
||||
# resolve the default chat/room ID. Empty = no cron home-channel support.
|
||||
cron_deliver_env_var: str = ""
|
||||
|
||||
|
||||
class PlatformRegistry:
|
||||
"""Central registry of platform adapters.
|
||||
|
||||
@@ -4,34 +4,18 @@ There are two ways to add a platform to the Hermes gateway:
|
||||
|
||||
## Plugin Path (Recommended for Community/Third-Party)
|
||||
|
||||
Create a plugin directory in `~/.hermes/plugins/` (or under `plugins/platforms/`
|
||||
for bundled plugins) with a `plugin.yaml` and `adapter.py`. The adapter
|
||||
inherits from `BasePlatformAdapter` and registers via
|
||||
`ctx.register_platform()` in the `register(ctx)` entry point. This requires
|
||||
**zero changes to core Hermes code**.
|
||||
Create a plugin directory in `~/.hermes/plugins/` with a `PLUGIN.yaml` and
|
||||
`adapter.py`. The adapter inherits from `BasePlatformAdapter` and registers
|
||||
via `ctx.register_platform()` in the `register(ctx)` entry point. This
|
||||
requires **zero changes to core Hermes code**.
|
||||
|
||||
The plugin system automatically handles: adapter creation, config parsing,
|
||||
user authorization, cron delivery, send_message routing, system prompt hints,
|
||||
status display, gateway setup, and more.
|
||||
|
||||
**Three optional hooks cover the edges most adapters need:**
|
||||
|
||||
- `env_enablement_fn: () -> Optional[dict]` — seeds `PlatformConfig.extra`
|
||||
(and an optional `home_channel` dict) from env vars BEFORE the adapter is
|
||||
constructed. Without this, env-only setups don't surface in
|
||||
`hermes gateway status` or `get_connected_platforms()` until the SDK
|
||||
instantiates.
|
||||
- `cron_deliver_env_var: str` — name of the `*_HOME_CHANNEL` env var. When
|
||||
set, `deliver=<name>` cron jobs route to this var without editing
|
||||
`cron/scheduler.py`'s hardcoded sets.
|
||||
- `plugin.yaml` `requires_env` / `optional_env` rich-dict entries —
|
||||
auto-populate `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` so the setup
|
||||
wizard surfaces proper descriptions, prompts, password flags, and URLs.
|
||||
|
||||
See `plugins/platforms/irc/`, `plugins/platforms/teams/`, and
|
||||
`plugins/platforms/google_chat/` for complete working examples, and
|
||||
See `plugins/platforms/irc/` for a complete reference implementation, and
|
||||
`website/docs/developer-guide/adding-platform-adapters.md` for the full
|
||||
plugin guide with code examples and hook documentation.
|
||||
plugin guide with code examples.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+29
-206
@@ -56,7 +56,7 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8642
|
||||
MAX_STORED_RESPONSES = 100
|
||||
MAX_REQUEST_BYTES = 10_000_000 # 10 MB — accommodates long agent conversations with tool calls
|
||||
MAX_REQUEST_BYTES = 1_000_000 # 1 MB default limit for POST bodies
|
||||
CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0
|
||||
MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts
|
||||
MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array
|
||||
@@ -917,16 +917,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"type": "bearer",
|
||||
"required": bool(self._api_key),
|
||||
},
|
||||
"runtime": {
|
||||
"mode": "server_agent",
|
||||
"tool_execution": "server",
|
||||
"split_runtime": False,
|
||||
"description": (
|
||||
"The API server creates a server-side Hermes AIAgent; "
|
||||
"tools execute on the API-server host unless a future "
|
||||
"explicit split-runtime mode is enabled."
|
||||
),
|
||||
},
|
||||
"features": {
|
||||
"chat_completions": True,
|
||||
"chat_completions_streaming": True,
|
||||
@@ -1326,8 +1316,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
result, agent_usage = await agent_task
|
||||
usage = agent_usage or usage
|
||||
except Exception as exc:
|
||||
logger.warning("Agent task %s failed, usage data lost: %s", completion_id, exc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Finish chunk
|
||||
finish_chunk = {
|
||||
@@ -1359,22 +1349,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
logger.info("SSE client disconnected; interrupted agent task %s", completion_id)
|
||||
except Exception as _exc:
|
||||
# Agent crashed mid-stream. Try to emit an error chunk
|
||||
# so the client gets a proper response instead of a
|
||||
# TransferEncodingError from incomplete chunked encoding.
|
||||
import traceback as _tb
|
||||
logger.error("Agent crashed mid-stream for %s: %s", completion_id, _tb.format_exc()[:300])
|
||||
try:
|
||||
error_chunk = {
|
||||
"id": completion_id, "object": "chat.completion.chunk",
|
||||
"created": created, "model": model,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "error"}],
|
||||
}
|
||||
await response.write(f"data: {json.dumps(error_chunk)}\n\n".encode())
|
||||
await response.write(b"data: [DONE]\n\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
@@ -1695,54 +1669,20 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
async def _dispatch(it) -> None:
|
||||
"""Route a queue item to the correct SSE emitter.
|
||||
|
||||
Plain strings are text deltas — they are batched (50ms)
|
||||
to reduce Open WebUI re-render storms. Tagged tuples
|
||||
with ``__tool_started__`` / ``__tool_completed__``
|
||||
prefixes are tool lifecycle events and flush the buffer
|
||||
before emitting.
|
||||
Plain strings are text deltas. Tagged tuples with
|
||||
``__tool_started__`` / ``__tool_completed__`` prefixes
|
||||
are tool lifecycle events.
|
||||
"""
|
||||
nonlocal _batch_timer
|
||||
if isinstance(it, tuple) and len(it) == 2 and isinstance(it[0], str):
|
||||
tag, payload = it
|
||||
# Flush batched text before tool events
|
||||
if _batch_buf:
|
||||
await _flush_batch()
|
||||
if tag == "__tool_started__":
|
||||
await _emit_tool_started(payload)
|
||||
elif tag == "__tool_completed__":
|
||||
await _emit_tool_completed(payload)
|
||||
# Unknown tags are silently ignored (forward-compat).
|
||||
elif isinstance(it, str):
|
||||
# Batch text deltas — append to buffer, flush on timer
|
||||
_batch_buf.append(it)
|
||||
if _batch_timer is None:
|
||||
_batch_timer = asyncio.create_task(_batch_flush_after(0.05))
|
||||
# Other types are silently dropped.
|
||||
|
||||
# ── Batching state ──
|
||||
_batch_buf: List[str] = []
|
||||
_batch_timer: Optional[asyncio.Task] = None
|
||||
_batch_lock = asyncio.Lock()
|
||||
|
||||
async def _batch_flush_after(delay: float) -> None:
|
||||
"""Wait delay seconds, then flush accumulated text deltas."""
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
# Clear timer reference BEFORE flush so new deltas
|
||||
# can start a fresh timer while we emit
|
||||
nonlocal _batch_buf, _batch_timer
|
||||
_batch_timer = None
|
||||
await _flush_batch()
|
||||
|
||||
async def _flush_batch() -> None:
|
||||
"""Emit a single SSE delta for all accumulated text."""
|
||||
nonlocal _batch_buf
|
||||
async with _batch_lock:
|
||||
if _batch_buf:
|
||||
combined = "".join(_batch_buf)
|
||||
_batch_buf = []
|
||||
await _emit_text_delta(combined)
|
||||
await _emit_text_delta(it)
|
||||
# Other types (non-string, non-tuple) are silently dropped.
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
@@ -1767,21 +1707,11 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
continue
|
||||
|
||||
if item is None: # EOS sentinel
|
||||
# Cancel pending timer and flush remaining batched text
|
||||
if _batch_timer and not _batch_timer.done():
|
||||
_batch_timer.cancel()
|
||||
_batch_timer = None
|
||||
if _batch_buf:
|
||||
await _flush_batch()
|
||||
break
|
||||
|
||||
await _dispatch(item)
|
||||
last_activity = time.monotonic()
|
||||
|
||||
# Flush any final batched text before processing result
|
||||
if _batch_buf:
|
||||
await _flush_batch()
|
||||
|
||||
# Pick up agent result + usage from the completed task
|
||||
try:
|
||||
result, agent_usage = await agent_task
|
||||
@@ -1832,31 +1762,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
# payload still see the assistant text. This mirrors the
|
||||
# shape produced by _extract_output_items in the batch path.
|
||||
final_items: List[Dict[str, Any]] = list(emitted_items)
|
||||
|
||||
# Trim large content from tool call arguments to keep the
|
||||
# response.completed event under ~100KB. Clients already
|
||||
# received full details via incremental events.
|
||||
for _item in final_items:
|
||||
if _item.get("type") == "function_call":
|
||||
try:
|
||||
_args = json.loads(_item.get("arguments", "{}")) if isinstance(_item.get("arguments"), str) else _item.get("arguments", {})
|
||||
if isinstance(_args, dict):
|
||||
for _k in ("content", "query", "pattern", "old_string", "new_string"):
|
||||
if isinstance(_args.get(_k), str) and len(_args[_k]) > 500:
|
||||
_args[_k] = "[" + str(len(_args[_k])) + " chars — truncated for response.completed]"
|
||||
_item["arguments"] = json.dumps(_args)
|
||||
except Exception:
|
||||
pass
|
||||
elif _item.get("type") == "function_call_output":
|
||||
_output = _item.get("output", [])
|
||||
if isinstance(_output, list) and _output:
|
||||
_first = _output[0]
|
||||
if isinstance(_first, dict) and _first.get("type") == "input_text":
|
||||
_text = _first.get("text", "")
|
||||
if len(_text) > 1000:
|
||||
_first["text"] = _text[:500] + "...[" + str(len(_text) - 500) + " more chars]"
|
||||
_item["output"] = [_first]
|
||||
|
||||
final_items.append({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
@@ -1898,12 +1803,12 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"output_tokens": usage.get("output_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
}
|
||||
full_history = self._build_response_conversation_history(
|
||||
conversation_history,
|
||||
user_message,
|
||||
result,
|
||||
final_response_text,
|
||||
)
|
||||
full_history = list(conversation_history)
|
||||
full_history.append({"role": "user", "content": user_message})
|
||||
if isinstance(result, dict) and result.get("messages"):
|
||||
full_history.extend(result["messages"])
|
||||
else:
|
||||
full_history.append({"role": "assistant", "content": final_response_text})
|
||||
_persist_response_snapshot(
|
||||
completed_env,
|
||||
conversation_history_snapshot=full_history,
|
||||
@@ -1947,30 +1852,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
agent_task.cancel()
|
||||
logger.info("SSE task cancelled; persisted incomplete snapshot for %s", response_id)
|
||||
raise
|
||||
except Exception as _exc:
|
||||
# Agent crashed with an unhandled error (e.g. model API error like
|
||||
# BadRequestError, AuthenticationError). Emit a response.failed
|
||||
# event and properly terminate the SSE stream so the client doesn't
|
||||
# get a TransferEncodingError from incomplete chunked encoding.
|
||||
import traceback as _tb
|
||||
_persist_incomplete_if_needed()
|
||||
agent_error = _tb.format_exc()
|
||||
try:
|
||||
failed_env = _envelope("failed")
|
||||
failed_env["output"] = list(emitted_items)
|
||||
failed_env["error"] = {"message": str(_exc)[:500], "type": "server_error"}
|
||||
failed_env["usage"] = {
|
||||
"input_tokens": usage.get("input_tokens", 0),
|
||||
"output_tokens": usage.get("output_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
}
|
||||
await _write_event("response.failed", {
|
||||
"type": "response.failed",
|
||||
"response": failed_env,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
logger.error("Agent crashed mid-stream for %s: %s", response_id, str(agent_error)[:300])
|
||||
|
||||
return response
|
||||
|
||||
@@ -2202,22 +2083,17 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
# Build the full conversation history for storage
|
||||
# (includes tool calls from the agent run)
|
||||
full_history = self._build_response_conversation_history(
|
||||
conversation_history,
|
||||
user_message,
|
||||
result,
|
||||
final_response,
|
||||
)
|
||||
full_history = list(conversation_history)
|
||||
full_history.append({"role": "user", "content": user_message})
|
||||
# Add agent's internal messages if available
|
||||
agent_messages = result.get("messages", [])
|
||||
if agent_messages:
|
||||
full_history.extend(agent_messages)
|
||||
else:
|
||||
full_history.append({"role": "assistant", "content": final_response})
|
||||
|
||||
# Build output items from the current turn only. AIAgent returns a
|
||||
# full transcript in result["messages"], while older/mocked paths may
|
||||
# return only the current turn suffix.
|
||||
output_start_index = self._response_messages_turn_start_index(
|
||||
conversation_history,
|
||||
user_message,
|
||||
result,
|
||||
)
|
||||
output_items = self._extract_output_items(result, start_index=output_start_index)
|
||||
# Build output items (includes tool calls + final message)
|
||||
output_items = self._extract_output_items(result)
|
||||
|
||||
response_data = {
|
||||
"id": response_id,
|
||||
@@ -2509,70 +2385,17 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _build_response_conversation_history(
|
||||
conversation_history: List[Dict[str, Any]],
|
||||
user_message: Any,
|
||||
result: Dict[str, Any],
|
||||
final_response: Any,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Build the stored Responses transcript without duplicating history."""
|
||||
prior = list(conversation_history)
|
||||
current_user = {"role": "user", "content": user_message}
|
||||
agent_messages = result.get("messages") if isinstance(result, dict) else None
|
||||
|
||||
if isinstance(agent_messages, list) and agent_messages:
|
||||
turn_start = APIServerAdapter._response_messages_turn_start_index(
|
||||
conversation_history,
|
||||
user_message,
|
||||
result,
|
||||
)
|
||||
if turn_start:
|
||||
return list(agent_messages)
|
||||
|
||||
full_history = prior
|
||||
full_history.append(current_user)
|
||||
full_history.extend(agent_messages)
|
||||
return full_history
|
||||
|
||||
full_history = prior
|
||||
full_history.append(current_user)
|
||||
full_history.append({"role": "assistant", "content": final_response})
|
||||
return full_history
|
||||
|
||||
@staticmethod
|
||||
def _response_messages_turn_start_index(
|
||||
conversation_history: List[Dict[str, Any]],
|
||||
user_message: Any,
|
||||
result: Dict[str, Any],
|
||||
) -> int:
|
||||
"""Detect transcript-shaped result["messages"] and return turn start."""
|
||||
agent_messages = result.get("messages") if isinstance(result, dict) else None
|
||||
if not isinstance(agent_messages, list) or not agent_messages:
|
||||
return 0
|
||||
|
||||
prior = list(conversation_history)
|
||||
current_user = {"role": "user", "content": user_message}
|
||||
expected_prefix = prior + [current_user]
|
||||
if agent_messages[:len(expected_prefix)] == expected_prefix:
|
||||
return len(expected_prefix)
|
||||
if prior and agent_messages[:len(prior)] == prior:
|
||||
return len(prior)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
|
||||
def _extract_output_items(result: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Build the output item array from the agent's messages.
|
||||
Build the full output item array from the agent's messages.
|
||||
|
||||
Walks *result["messages"]* starting at *start_index* and emits:
|
||||
Walks *result["messages"]* and emits:
|
||||
- ``function_call`` items for each tool_call on assistant messages
|
||||
- ``function_call_output`` items for each tool-role message
|
||||
- a final ``message`` item with the assistant's text reply
|
||||
"""
|
||||
items: List[Dict[str, Any]] = []
|
||||
messages = result.get("messages", [])
|
||||
if start_index > 0:
|
||||
messages = messages[start_index:]
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role")
|
||||
@@ -3112,7 +2935,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
try:
|
||||
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
|
||||
self._app = web.Application(middlewares=mws, client_max_size=MAX_REQUEST_BYTES)
|
||||
self._app = web.Application(middlewares=mws)
|
||||
self._app["api_server_adapter"] = self
|
||||
self._app.router.add_get("/health", self._handle_health)
|
||||
self._app.router.add_get("/health/detailed", self._handle_health_detailed)
|
||||
|
||||
+26
-114
@@ -1304,52 +1304,37 @@ class BasePlatformAdapter(ABC):
|
||||
self._fatal_error_code = None
|
||||
self._fatal_error_message = None
|
||||
self._fatal_error_retryable = True
|
||||
self._write_runtime_status_safe("connected", platform_state="connected", error_code=None, error_message=None)
|
||||
try:
|
||||
from gateway.status import write_runtime_status
|
||||
write_runtime_status(platform=self.platform.value, platform_state="connected", error_code=None, error_message=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _mark_disconnected(self) -> None:
|
||||
self._running = False
|
||||
if self.has_fatal_error:
|
||||
return
|
||||
self._write_runtime_status_safe("disconnected", platform_state="disconnected", error_code=None, error_message=None)
|
||||
try:
|
||||
from gateway.status import write_runtime_status
|
||||
write_runtime_status(platform=self.platform.value, platform_state="disconnected", error_code=None, error_message=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _set_fatal_error(self, code: str, message: str, *, retryable: bool) -> None:
|
||||
self._running = False
|
||||
self._fatal_error_code = code
|
||||
self._fatal_error_message = message
|
||||
self._fatal_error_retryable = retryable
|
||||
self._write_runtime_status_safe("fatal", platform_state="fatal", error_code=code, error_message=message)
|
||||
|
||||
def _write_runtime_status_safe(self, context: str, **kwargs) -> None:
|
||||
"""Write runtime status; log first failure per context at warning, rest at debug.
|
||||
|
||||
Status writes can fail on permissions, ENOSPC, missing status dir, etc.
|
||||
A persistently failing status dir used to be silent (``except: pass``).
|
||||
Logging every failure would spam the log on reconnect loops, so this
|
||||
surfaces the first failure per (platform, context) at warning level and
|
||||
downgrades subsequent failures to debug.
|
||||
"""
|
||||
try:
|
||||
from gateway.status import write_runtime_status
|
||||
write_runtime_status(platform=self.platform.value, **kwargs)
|
||||
except Exception as exc:
|
||||
# Use getattr so object.__new__(...) test harnesses that skip __init__
|
||||
# don't blow up on attribute access.
|
||||
logged = getattr(self, "_status_write_logged", None)
|
||||
if logged is None:
|
||||
logged = set()
|
||||
try:
|
||||
self._status_write_logged = logged
|
||||
except Exception:
|
||||
pass
|
||||
key = (self.platform.value, context)
|
||||
if key not in logged:
|
||||
logger.warning(
|
||||
"Failed to write runtime status (%s) for %s: %s (further failures at debug level)",
|
||||
context, self.platform.value, exc,
|
||||
)
|
||||
logged.add(key)
|
||||
else:
|
||||
logger.debug("Failed to write runtime status (%s) for %s: %s", context, self.platform.value, exc)
|
||||
write_runtime_status(
|
||||
platform=self.platform.value,
|
||||
platform_state="fatal",
|
||||
error_code=code,
|
||||
error_message=message,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _notify_fatal_error(self) -> None:
|
||||
handler = self._fatal_error_handler
|
||||
@@ -1889,38 +1874,23 @@ class BasePlatformAdapter(ABC):
|
||||
def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
|
||||
"""
|
||||
Extract MEDIA:<path> tags and [[audio_as_voice]] directives from response text.
|
||||
|
||||
|
||||
The TTS tool returns responses like:
|
||||
[[audio_as_voice]]
|
||||
MEDIA:/path/to/audio.ogg
|
||||
|
||||
Skills that produce large/lossless images (e.g. info-graph, where a
|
||||
rendered JPG is 1-2 MB but Telegram's sendPhoto recompresses to
|
||||
~200 KB at 1280px) can use ``[[as_document]]`` to request unmodified
|
||||
delivery via sendDocument instead of sendPhoto/sendMediaGroup. The
|
||||
directive is detected at the dispatch sites (which have access to the
|
||||
original response); this method just strips it so it never leaks into
|
||||
user-visible text. Per-file granularity is intentionally not exposed —
|
||||
when an agent emits ``[[as_document]]`` once, every image path in the
|
||||
same response is delivered as a document, mirroring the all-or-nothing
|
||||
scope of ``[[audio_as_voice]]``.
|
||||
|
||||
|
||||
Args:
|
||||
content: The response text to scan.
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (list of (path, is_voice) pairs, cleaned content with tags removed).
|
||||
"""
|
||||
media = []
|
||||
cleaned = content
|
||||
|
||||
|
||||
# Check for [[audio_as_voice]] directive
|
||||
has_voice_tag = "[[audio_as_voice]]" in content
|
||||
cleaned = cleaned.replace("[[audio_as_voice]]", "")
|
||||
# Strip [[as_document]] directive — callers inspect the original
|
||||
# ``content`` for it (so they can still react to it); here we just
|
||||
# keep it out of the user-visible cleaned text.
|
||||
cleaned = cleaned.replace("[[as_document]]", "")
|
||||
|
||||
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
|
||||
# and quoted/backticked paths for LLM-formatted outputs.
|
||||
@@ -2126,52 +2096,9 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
``generation`` lets callers tie the callback to a specific gateway run
|
||||
generation so stale runs cannot clear callbacks owned by a fresher run.
|
||||
|
||||
If a callback for the same ``session_key`` (and generation, when set)
|
||||
is already registered, the new callback is chained — both fire, in
|
||||
registration order, with per-callback exception isolation. This lets
|
||||
independent features (background-review release + temporary-bubble
|
||||
cleanup) coexist without clobbering each other. Stale-generation
|
||||
callers never overwrite a fresher generation's slot.
|
||||
"""
|
||||
if not session_key or not callable(callback):
|
||||
return
|
||||
|
||||
existing = self._post_delivery_callbacks.get(session_key)
|
||||
if existing is not None:
|
||||
if isinstance(existing, tuple) and len(existing) == 2:
|
||||
existing_gen, existing_cb = existing
|
||||
else:
|
||||
existing_gen, existing_cb = None, existing
|
||||
# Stale-generation registrations never overwrite a fresher slot.
|
||||
if (
|
||||
existing_gen is not None
|
||||
and generation is not None
|
||||
and int(generation) < int(existing_gen)
|
||||
):
|
||||
return
|
||||
# Same-or-newer generation: chain with the existing callback so
|
||||
# both fire in registration order.
|
||||
if callable(existing_cb) and (
|
||||
existing_gen is None
|
||||
or generation is None
|
||||
or int(existing_gen) == int(generation)
|
||||
):
|
||||
_prev = existing_cb
|
||||
_new = callback
|
||||
|
||||
def _chained() -> None:
|
||||
try:
|
||||
_prev()
|
||||
except Exception:
|
||||
logger.debug("Post-delivery callback failed", exc_info=True)
|
||||
try:
|
||||
_new()
|
||||
except Exception:
|
||||
logger.debug("Post-delivery callback failed", exc_info=True)
|
||||
|
||||
callback = _chained
|
||||
|
||||
if generation is None:
|
||||
self._post_delivery_callbacks[session_key] = callback
|
||||
else:
|
||||
@@ -2845,21 +2772,13 @@ class BasePlatformAdapter(ABC):
|
||||
if not response:
|
||||
logger.debug("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id)
|
||||
if response:
|
||||
# Capture [[as_document]] before extract_media strips it, so the
|
||||
# dispatch partition below can route image-extension files
|
||||
# through send_document instead of send_multiple_images. Used
|
||||
# by skills that produce large/lossless images (e.g. info-graph)
|
||||
# where Telegram's sendPhoto recompression destroys legibility.
|
||||
force_document_attachments = "[[as_document]]" in response
|
||||
|
||||
# Extract MEDIA:<path> tags (from TTS tool) before other processing
|
||||
media_files, response = self.extract_media(response)
|
||||
|
||||
|
||||
# Extract image URLs and send them as native platform attachments
|
||||
images, text_content = self.extract_images(response)
|
||||
# Strip any remaining internal directives from message body (fixes #1561)
|
||||
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
|
||||
text_content = text_content.replace("[[as_document]]", "").strip()
|
||||
text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
|
||||
if images:
|
||||
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
|
||||
@@ -2961,26 +2880,19 @@ class BasePlatformAdapter(ABC):
|
||||
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||
|
||||
# Partition images out of media_files + local_files so they
|
||||
# can be sent as a single batch (Signal RPC). When
|
||||
# ``[[as_document]]`` was set on the original response, image
|
||||
# files skip the photo path and route to send_document below
|
||||
# so they're delivered with original bytes (no Telegram
|
||||
# sendPhoto recompression).
|
||||
# can be sent as a single batch (Signal RPC)
|
||||
from urllib.parse import quote as _quote
|
||||
_image_paths: list = []
|
||||
_non_image_media: list = []
|
||||
for media_path, is_voice in media_files:
|
||||
_ext = Path(media_path).suffix.lower()
|
||||
if (_ext in _IMAGE_EXTS
|
||||
and not is_voice
|
||||
and not force_document_attachments):
|
||||
if _ext in _IMAGE_EXTS and not is_voice:
|
||||
_image_paths.append(media_path)
|
||||
else:
|
||||
_non_image_media.append((media_path, is_voice))
|
||||
_non_image_local: list = []
|
||||
for file_path in local_files:
|
||||
if (Path(file_path).suffix.lower() in _IMAGE_EXTS
|
||||
and not force_document_attachments):
|
||||
if Path(file_path).suffix.lower() in _IMAGE_EXTS:
|
||||
_image_paths.append(file_path)
|
||||
else:
|
||||
_non_image_local.append(file_path)
|
||||
|
||||
@@ -365,20 +365,6 @@ class DingTalkAdapter(BasePlatformAdapter):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||||
|
||||
def _dingtalk_allowed_chats(self) -> Set[str]:
|
||||
"""Return the whitelist of group chat IDs the bot will respond in.
|
||||
|
||||
When non-empty, group messages from chats NOT in this set are silently
|
||||
ignored — even if the bot is @mentioned. DMs are never filtered.
|
||||
Empty set means no restriction (fully backward compatible).
|
||||
"""
|
||||
raw = self.config.extra.get("allowed_chats") if self.config.extra else None
|
||||
if raw is None:
|
||||
raw = os.getenv("DINGTALK_ALLOWED_CHATS", "")
|
||||
if isinstance(raw, list):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||||
|
||||
def _compile_mention_patterns(self) -> List[re.Pattern]:
|
||||
"""Compile optional regex wake-word patterns for group triggers."""
|
||||
patterns = self.config.extra.get("mention_patterns") if self.config.extra else None
|
||||
@@ -457,21 +443,13 @@ class DingTalkAdapter(BasePlatformAdapter):
|
||||
|
||||
DMs remain unrestricted (subject to ``allowed_users`` which is enforced
|
||||
earlier). Group messages are accepted when:
|
||||
- the chat passes the ``allowed_chats`` whitelist (when set)
|
||||
- the chat is explicitly allowlisted in ``free_response_chats``
|
||||
- ``require_mention`` is disabled
|
||||
- the bot is @mentioned (``is_in_at_list``)
|
||||
- the text matches a configured regex wake-word pattern
|
||||
|
||||
When ``allowed_chats`` is non-empty, it acts as a hard gate — messages
|
||||
from any group chat not in the list are ignored regardless of the
|
||||
other rules.
|
||||
"""
|
||||
if not is_group:
|
||||
return True
|
||||
allowed = self._dingtalk_allowed_chats()
|
||||
if allowed and chat_id and chat_id not in allowed:
|
||||
return False
|
||||
if chat_id and chat_id in self._dingtalk_free_response_chats():
|
||||
return True
|
||||
if not self._dingtalk_require_mention():
|
||||
|
||||
+44
-355
@@ -10,8 +10,6 @@ Uses discord.py library for:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
@@ -26,10 +24,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080}
|
||||
_DISCORD_COMMAND_SYNC_POLICIES = {"safe", "bulk", "off"}
|
||||
_DISCORD_COMMAND_SYNC_STATE_SUBDIR = "gateway"
|
||||
_DISCORD_COMMAND_SYNC_STATE_FILENAME = "discord_command_sync_state.json"
|
||||
_DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5
|
||||
_DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0
|
||||
|
||||
try:
|
||||
import discord
|
||||
@@ -51,7 +45,6 @@ from gateway.config import Platform, PlatformConfig
|
||||
import re
|
||||
|
||||
from gateway.platforms.helpers import MessageDeduplicator, ThreadParticipationTracker
|
||||
from utils import atomic_json_write
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
@@ -477,34 +470,6 @@ class VoiceReceiver:
|
||||
pass
|
||||
|
||||
|
||||
def _read_dm_role_auth_guild() -> Optional[int]:
|
||||
"""Return the guild ID opted-in for DM role-based auth, or None.
|
||||
|
||||
Reads ``discord.dm_role_auth_guild`` from config.yaml. This is
|
||||
deliberately a config.yaml-only setting (not an env var): per repo
|
||||
policy, ``~/.hermes/.env`` is for secrets only, and this is a
|
||||
behavioral setting. Guild IDs aren't secrets.
|
||||
|
||||
Accepts ints or numeric strings in the config. Anything else
|
||||
(empty, malformed, None) returns None, which keeps the secure
|
||||
default (DM role-auth disabled).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
cfg = read_raw_config() or {}
|
||||
discord_cfg = cfg.get("discord", {}) or {}
|
||||
raw = discord_cfg.get("dm_role_auth_guild")
|
||||
except Exception:
|
||||
return None
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
guild_id = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return guild_id if guild_id > 0 else None
|
||||
|
||||
|
||||
class DiscordAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
Discord bot adapter.
|
||||
@@ -729,17 +694,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# human-user allowlist below (bots aren't in it).
|
||||
else:
|
||||
# Non-bot: enforce the configured user/role allowlists.
|
||||
# Pass guild + is_dm so role checks are scoped to the
|
||||
# originating guild (prevents cross-guild DM bypass, see
|
||||
# _is_allowed_user docstring).
|
||||
_msg_guild = getattr(message, "guild", None)
|
||||
_is_dm = isinstance(message.channel, discord.DMChannel) or _msg_guild is None
|
||||
if not self._is_allowed_user(
|
||||
str(message.author.id),
|
||||
message.author,
|
||||
guild=_msg_guild,
|
||||
is_dm=_is_dm,
|
||||
):
|
||||
if not self._is_allowed_user(str(message.author.id), message.author):
|
||||
return
|
||||
|
||||
# Multi-agent filtering: if the message mentions specific bots
|
||||
@@ -870,167 +825,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
|
||||
logger.info("[%s] Disconnected", self.name)
|
||||
|
||||
def _command_sync_state_path(self) -> _Path:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
directory = get_hermes_home() / _DISCORD_COMMAND_SYNC_STATE_SUBDIR
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return directory / _DISCORD_COMMAND_SYNC_STATE_FILENAME
|
||||
|
||||
def _read_command_sync_state(self) -> dict:
|
||||
try:
|
||||
path = self._command_sync_state_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def _write_command_sync_state(self, state: dict) -> None:
|
||||
atomic_json_write(
|
||||
self._command_sync_state_path(),
|
||||
state,
|
||||
indent=None,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
def _command_sync_state_key(self, app_id: Any) -> str:
|
||||
return str(app_id or "unknown")
|
||||
|
||||
def _desired_command_sync_fingerprint(self) -> str:
|
||||
tree = self._client.tree if self._client else None
|
||||
desired = []
|
||||
if tree is not None:
|
||||
desired = [
|
||||
self._canonicalize_app_command_payload(command.to_dict(tree))
|
||||
for command in tree.get_commands()
|
||||
]
|
||||
desired.sort(key=lambda item: (item.get("type", 1), item.get("name", "")))
|
||||
payload = json.dumps(desired, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
def _command_sync_skip_reason(self, app_id: Any, fingerprint: str) -> Optional[str]:
|
||||
entry = self._read_command_sync_state().get(self._command_sync_state_key(app_id))
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
now = time.time()
|
||||
retry_after_until = float(entry.get("retry_after_until") or 0)
|
||||
if retry_after_until > now:
|
||||
remaining = max(1, int(retry_after_until - now))
|
||||
return f"Discord asked us to wait before syncing slash commands; retry in {remaining}s"
|
||||
if entry.get("fingerprint") == fingerprint and entry.get("last_success_at"):
|
||||
return "same slash-command fingerprint already synced"
|
||||
return None
|
||||
|
||||
def _record_command_sync_attempt(self, app_id: Any, fingerprint: str) -> None:
|
||||
state = self._read_command_sync_state()
|
||||
state[self._command_sync_state_key(app_id)] = {
|
||||
**(
|
||||
state.get(self._command_sync_state_key(app_id))
|
||||
if isinstance(state.get(self._command_sync_state_key(app_id)), dict)
|
||||
else {}
|
||||
),
|
||||
"fingerprint": fingerprint,
|
||||
"last_attempt_at": time.time(),
|
||||
}
|
||||
self._write_command_sync_state(state)
|
||||
|
||||
def _record_command_sync_rate_limit(self, app_id: Any, fingerprint: str, retry_after: float) -> None:
|
||||
retry_after = max(1.0, float(retry_after))
|
||||
state = self._read_command_sync_state()
|
||||
state[self._command_sync_state_key(app_id)] = {
|
||||
**(
|
||||
state.get(self._command_sync_state_key(app_id))
|
||||
if isinstance(state.get(self._command_sync_state_key(app_id)), dict)
|
||||
else {}
|
||||
),
|
||||
"fingerprint": fingerprint,
|
||||
"last_attempt_at": time.time(),
|
||||
"retry_after_until": time.time() + retry_after,
|
||||
"retry_after": retry_after,
|
||||
}
|
||||
self._write_command_sync_state(state)
|
||||
|
||||
def _record_command_sync_success(self, app_id: Any, fingerprint: str, summary: dict) -> None:
|
||||
state = self._read_command_sync_state()
|
||||
state[self._command_sync_state_key(app_id)] = {
|
||||
"fingerprint": fingerprint,
|
||||
"last_attempt_at": time.time(),
|
||||
"last_success_at": time.time(),
|
||||
"summary": summary,
|
||||
}
|
||||
self._write_command_sync_state(state)
|
||||
|
||||
@staticmethod
|
||||
def _extract_discord_retry_after(exc: BaseException) -> Optional[float]:
|
||||
value = getattr(exc, "retry_after", None)
|
||||
if value is not None:
|
||||
try:
|
||||
return max(1.0, float(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
response = getattr(exc, "response", None)
|
||||
headers = getattr(response, "headers", None)
|
||||
if headers:
|
||||
for key in ("Retry-After", "X-RateLimit-Reset-After"):
|
||||
try:
|
||||
raw = headers.get(key)
|
||||
except Exception:
|
||||
raw = None
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
return max(1.0, float(raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_discord_rate_limit(exc: BaseException) -> bool:
|
||||
"""True only for exceptions that look like Discord 429 rate limits.
|
||||
|
||||
Narrower than ``hasattr(exc, 'retry_after')``: discord.py's own
|
||||
``RateLimited`` exception and any HTTPException with status 429
|
||||
qualify. This prevents suppressing unrelated failures that happen
|
||||
to expose a ``retry_after`` attribute."""
|
||||
# discord.py emits RateLimited / HTTPException subclasses for 429s.
|
||||
# Guard with isinstance-of-class so a mocked ``discord`` module
|
||||
# (where attrs are MagicMocks, not types) doesn't trip isinstance.
|
||||
if DISCORD_AVAILABLE and discord is not None:
|
||||
for attr_name in ("RateLimited", "HTTPException"):
|
||||
cls = getattr(discord, attr_name, None)
|
||||
if not isinstance(cls, type):
|
||||
continue
|
||||
if isinstance(exc, cls):
|
||||
if attr_name == "RateLimited":
|
||||
return True
|
||||
status = getattr(exc, "status", None)
|
||||
if status == 429:
|
||||
return True
|
||||
# Fallback duck-type: something named like a rate-limit with a
|
||||
# numeric retry_after. Covers mocked clients in tests and exotic
|
||||
# transports, without swallowing arbitrary exceptions.
|
||||
name = type(exc).__name__.lower()
|
||||
if ("ratelimit" in name or "rate_limit" in name) and getattr(exc, "retry_after", None) is not None:
|
||||
return True
|
||||
response = getattr(exc, "response", None)
|
||||
status = getattr(response, "status", None) or getattr(response, "status_code", None)
|
||||
if status == 429:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _command_sync_mutation_interval_seconds(self) -> float:
|
||||
return _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS
|
||||
|
||||
async def _sleep_between_command_sync_mutations(self) -> None:
|
||||
interval = self._command_sync_mutation_interval_seconds()
|
||||
if interval > 0:
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
async def _run_post_connect_initialization(self) -> None:
|
||||
"""Finish non-critical startup work after Discord is connected."""
|
||||
if not self._client:
|
||||
@@ -1046,46 +840,14 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
logger.info("[%s] Synced %d slash command(s) via bulk tree sync", self.name, len(synced))
|
||||
return
|
||||
|
||||
app_id = getattr(self._client, "application_id", None) or getattr(getattr(self._client, "user", None), "id", None)
|
||||
fingerprint = self._desired_command_sync_fingerprint()
|
||||
skip_reason = self._command_sync_skip_reason(app_id, fingerprint)
|
||||
if skip_reason:
|
||||
logger.info("[%s] Skipping Discord slash command sync: %s", self.name, skip_reason)
|
||||
return
|
||||
self._record_command_sync_attempt(app_id, fingerprint)
|
||||
|
||||
http = getattr(self._client, "http", None)
|
||||
has_ratelimit_timeout = http is not None and hasattr(http, "max_ratelimit_timeout")
|
||||
previous_ratelimit_timeout = getattr(http, "max_ratelimit_timeout", None) if has_ratelimit_timeout else None
|
||||
if has_ratelimit_timeout:
|
||||
http.max_ratelimit_timeout = _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS
|
||||
|
||||
try:
|
||||
# Discord's per-app command-management bucket is small, and
|
||||
# discord.py can otherwise sit inside one long retry sleep
|
||||
# before surfacing the 429. Keep the whole sync bounded and
|
||||
# persist Discord's retry-after when it refuses the batch.
|
||||
summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=600)
|
||||
except Exception as e:
|
||||
if not self._is_discord_rate_limit(e):
|
||||
raise
|
||||
retry_after = self._extract_discord_retry_after(e)
|
||||
if retry_after is None:
|
||||
# Rate-limited but no retry-after signal — back off for a
|
||||
# conservative default so we don't slam the bucket again.
|
||||
retry_after = _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS
|
||||
self._record_command_sync_rate_limit(app_id, fingerprint, retry_after)
|
||||
logger.warning(
|
||||
"[%s] Discord rate-limited slash command sync; retrying after %.0fs",
|
||||
self.name,
|
||||
retry_after,
|
||||
)
|
||||
return
|
||||
finally:
|
||||
if has_ratelimit_timeout:
|
||||
http.max_ratelimit_timeout = previous_ratelimit_timeout
|
||||
|
||||
self._record_command_sync_success(app_id, fingerprint, summary)
|
||||
# Discord's per-app command-management bucket is ~5 writes / 20 s,
|
||||
# so a mass-prune-plus-upsert reconcile (e.g. 77 orphans + 30
|
||||
# desired = 107 writes) takes several minutes of forced waits.
|
||||
# A flat 30 s budget blew up reliably under bucket pressure and
|
||||
# left slash commands broken for ~60 min until the bucket fully
|
||||
# recovered. Use a wide ceiling; the cap still guards against a
|
||||
# true hang. (#16713)
|
||||
summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=600)
|
||||
logger.info(
|
||||
"[%s] Safely reconciled %d slash command(s): unchanged=%d updated=%d recreated=%d created=%d deleted=%d",
|
||||
self.name,
|
||||
@@ -1247,20 +1009,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
created = 0
|
||||
deleted = 0
|
||||
http = self._client.http
|
||||
mutation_count = 0
|
||||
|
||||
async def mutate(call, *args):
|
||||
nonlocal mutation_count
|
||||
if mutation_count:
|
||||
await self._sleep_between_command_sync_mutations()
|
||||
result = await call(*args)
|
||||
mutation_count += 1
|
||||
return result
|
||||
|
||||
for key, desired in desired_by_key.items():
|
||||
current = existing_by_key.pop(key, None)
|
||||
if current is None:
|
||||
await mutate(http.upsert_global_command, app_id, desired)
|
||||
await http.upsert_global_command(app_id, desired)
|
||||
created += 1
|
||||
continue
|
||||
|
||||
@@ -1272,16 +1025,16 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
continue
|
||||
|
||||
if self._patchable_app_command_payload(current_existing_payload) == self._patchable_app_command_payload(desired):
|
||||
await mutate(http.delete_global_command, app_id, current.id)
|
||||
await mutate(http.upsert_global_command, app_id, desired)
|
||||
await http.delete_global_command(app_id, current.id)
|
||||
await http.upsert_global_command(app_id, desired)
|
||||
recreated += 1
|
||||
continue
|
||||
|
||||
await mutate(http.edit_global_command, app_id, current.id, desired)
|
||||
await http.edit_global_command(app_id, current.id, desired)
|
||||
updated += 1
|
||||
|
||||
for current in existing_by_key.values():
|
||||
await mutate(http.delete_global_command, app_id, current.id)
|
||||
await http.delete_global_command(app_id, current.id)
|
||||
deleted += 1
|
||||
|
||||
return {
|
||||
@@ -2101,16 +1854,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
pass
|
||||
|
||||
completed = receiver.check_silence()
|
||||
# Voice inputs always originate from a specific guild
|
||||
# (guild_id is in scope). Pass it so role checks are
|
||||
# guild-scoped and not cross-guild.
|
||||
_vc_guild = self._client.get_guild(guild_id) if self._client is not None else None
|
||||
for user_id, pcm_data in completed:
|
||||
if not self._is_allowed_user(
|
||||
str(user_id),
|
||||
guild=_vc_guild,
|
||||
is_dm=False,
|
||||
):
|
||||
if not self._is_allowed_user(str(user_id)):
|
||||
continue
|
||||
await self._process_voice_input(guild_id, user_id, pcm_data)
|
||||
except asyncio.CancelledError:
|
||||
@@ -2153,32 +1898,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _is_allowed_user(
|
||||
self,
|
||||
user_id: str,
|
||||
author=None,
|
||||
*,
|
||||
guild=None,
|
||||
is_dm: bool = False,
|
||||
) -> bool:
|
||||
def _is_allowed_user(self, user_id: str, author=None) -> bool:
|
||||
"""Check if user is allowed via DISCORD_ALLOWED_USERS or DISCORD_ALLOWED_ROLES.
|
||||
|
||||
Uses OR semantics: if the user matches EITHER allowlist, they're allowed.
|
||||
If both allowlists are empty, everyone is allowed (backwards compatible).
|
||||
|
||||
Role checks are **scoped to the guild the message originated from**.
|
||||
For DMs (no guild context), role-based auth is disabled by default and
|
||||
only user-ID allowlist applies. Set ``discord.dm_role_auth_guild``
|
||||
in config.yaml to a specific guild ID to opt-in: role membership in
|
||||
that one guild will authorize DMs. This prevents cross-guild
|
||||
privilege escalation where a user with the configured role in any
|
||||
shared public server could DM the bot and pass the allowlist.
|
||||
|
||||
Args:
|
||||
user_id: Author ID as a string.
|
||||
author: Optional Member/User object for in-guild role lookup.
|
||||
guild: The guild the message arrived in (None for DMs).
|
||||
is_dm: True if the message came from a DM channel.
|
||||
When author is a Member, checks .roles directly; otherwise falls back
|
||||
to scanning the bot's mutual guilds for a Member record.
|
||||
"""
|
||||
# ``getattr`` fallbacks here guard against test fixtures that build
|
||||
# an adapter via ``object.__new__(DiscordAdapter)`` and skip __init__
|
||||
@@ -2189,54 +1915,31 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
has_roles = bool(allowed_roles)
|
||||
if not has_users and not has_roles:
|
||||
return True
|
||||
# Check user ID allowlist (works for both DMs and guild messages)
|
||||
# Check user ID allowlist
|
||||
if has_users and user_id in allowed_users:
|
||||
return True
|
||||
# Role allowlist is only consulted when configured.
|
||||
if not has_roles:
|
||||
return False
|
||||
|
||||
# DM path: roles require explicit opt-in via
|
||||
# ``discord.dm_role_auth_guild`` in config.yaml. Without this, a
|
||||
# user with the configured role in ANY mutual guild could DM the
|
||||
# bot and bypass the allowlist (cross-guild leakage).
|
||||
if is_dm or guild is None:
|
||||
dm_guild_id = _read_dm_role_auth_guild()
|
||||
if dm_guild_id is None:
|
||||
return False
|
||||
if self._client is None:
|
||||
return False
|
||||
dm_guild = self._client.get_guild(dm_guild_id)
|
||||
if dm_guild is None:
|
||||
return False
|
||||
try:
|
||||
uid_int = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
m = dm_guild.get_member(uid_int)
|
||||
if m is None:
|
||||
return False
|
||||
m_roles = getattr(m, "roles", None) or []
|
||||
return any(getattr(r, "id", None) in allowed_roles for r in m_roles)
|
||||
|
||||
# Guild path: role check is scoped to THIS guild only.
|
||||
# 1) Prefer the direct Member object passed in (correct guild by construction).
|
||||
direct_roles = getattr(author, "roles", None) if author is not None else None
|
||||
author_guild = getattr(author, "guild", None)
|
||||
if direct_roles and (author_guild is None or author_guild.id == guild.id):
|
||||
if any(getattr(r, "id", None) in allowed_roles for r in direct_roles):
|
||||
return True
|
||||
# 2) Fallback: resolve the Member in the message's guild only — NEVER
|
||||
# scan other mutual guilds (that is the cross-guild bypass bug).
|
||||
try:
|
||||
uid_int = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
m = guild.get_member(uid_int)
|
||||
if m is None:
|
||||
return False
|
||||
m_roles = getattr(m, "roles", None) or []
|
||||
return any(getattr(r, "id", None) in allowed_roles for r in m_roles)
|
||||
# Check role allowlist
|
||||
if has_roles:
|
||||
# Try direct role check from Member object
|
||||
direct_roles = getattr(author, "roles", None) if author is not None else None
|
||||
if direct_roles:
|
||||
if any(getattr(r, "id", None) in allowed_roles for r in direct_roles):
|
||||
return True
|
||||
# Fallback: scan mutual guilds for member's roles
|
||||
if self._client is not None:
|
||||
try:
|
||||
uid_int = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
uid_int = None
|
||||
if uid_int is not None:
|
||||
for guild in self._client.guilds:
|
||||
m = guild.get_member(uid_int)
|
||||
if m is None:
|
||||
continue
|
||||
m_roles = getattr(m, "roles", None) or []
|
||||
if any(getattr(r, "id", None) in allowed_roles for r in m_roles):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── Slash command authorization ─────────────────────────────────────
|
||||
# Slash commands (``_run_simple_slash`` and ``_handle_thread_create_slash``)
|
||||
@@ -2333,16 +2036,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
return (True, None)
|
||||
|
||||
user_id = str(user.id)
|
||||
# Pass guild + is_dm so role check is scoped to the originating
|
||||
# guild and cross-guild DM bypass (#12136) can't land via the
|
||||
# slash surface either.
|
||||
interaction_guild = getattr(interaction, "guild", None)
|
||||
if not self._is_allowed_user(
|
||||
user_id,
|
||||
author=user,
|
||||
guild=interaction_guild,
|
||||
is_dm=in_dm,
|
||||
):
|
||||
if not self._is_allowed_user(user_id, author=user):
|
||||
return (
|
||||
False,
|
||||
"user not in DISCORD_ALLOWED_USERS / DISCORD_ALLOWED_ROLES",
|
||||
@@ -2960,14 +2654,9 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
await self._run_simple_slash(interaction, "/reload-skills")
|
||||
|
||||
@tree.command(name="voice", description="Toggle voice reply mode")
|
||||
@discord.app_commands.describe(mode="Voice mode: join, channel, leave, on, tts, off, or status")
|
||||
@discord.app_commands.describe(mode="Voice mode: on, off, tts, channel, leave, or status")
|
||||
@discord.app_commands.choices(mode=[
|
||||
# `join` and `channel` both route to _handle_voice_channel_join in
|
||||
# gateway/run.py — expose both in the slash UI so autocomplete
|
||||
# matches what the docs advertise and what the runner accepts when
|
||||
# the command is typed as plain text.
|
||||
discord.app_commands.Choice(name="join — join your voice channel", value="join"),
|
||||
discord.app_commands.Choice(name="channel — join your voice channel (alias)", value="channel"),
|
||||
discord.app_commands.Choice(name="channel — join your voice channel", value="channel"),
|
||||
discord.app_commands.Choice(name="leave — leave voice channel", value="leave"),
|
||||
discord.app_commands.Choice(name="on — voice reply to voice messages", value="on"),
|
||||
discord.app_commands.Choice(name="tts — voice reply to all messages", value="tts"),
|
||||
|
||||
@@ -4089,18 +4089,15 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str],
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
) -> Any:
|
||||
effective_reply_to = reply_to
|
||||
if not effective_reply_to and metadata and metadata.get("thread_id"):
|
||||
effective_reply_to = metadata.get("reply_to_message_id")
|
||||
reply_in_thread = bool((metadata or {}).get("thread_id"))
|
||||
if effective_reply_to:
|
||||
if reply_to:
|
||||
body = self._build_reply_message_body(
|
||||
content=payload,
|
||||
msg_type=msg_type,
|
||||
reply_in_thread=reply_in_thread,
|
||||
uuid_value=str(uuid.uuid4()),
|
||||
)
|
||||
request = self._build_reply_message_request(effective_reply_to, body)
|
||||
request = self._build_reply_message_request(reply_to, body)
|
||||
return await asyncio.to_thread(self._client.im.v1.message.reply, request)
|
||||
|
||||
body = self._build_create_message_body(
|
||||
@@ -4591,12 +4588,12 @@ def _poll_registration(
|
||||
Returns dict with app_id, app_secret, domain, open_id on success.
|
||||
Returns None on failure.
|
||||
"""
|
||||
deadline = time.monotonic() + expire_in
|
||||
deadline = time.time() + expire_in
|
||||
current_domain = domain
|
||||
domain_switched = False
|
||||
poll_count = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
while time.time() < deadline:
|
||||
base_url = _accounts_base_url(current_domain)
|
||||
try:
|
||||
res = _post_registration(base_url, {
|
||||
|
||||
+12
-87
@@ -17,8 +17,7 @@ Environment variables:
|
||||
MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions
|
||||
(eyes/checkmark/cross). Default: true
|
||||
MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true)
|
||||
MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement (alias of matrix.free_response_rooms)
|
||||
MATRIX_ALLOWED_ROOMS Comma-separated room IDs; if set, bot ONLY responds in these rooms (whitelist, DMs exempt; alias of matrix.allowed_rooms)
|
||||
MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement
|
||||
MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true)
|
||||
MATRIX_DM_AUTO_THREAD Auto-create threads for DM messages (default: false)
|
||||
MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation
|
||||
@@ -344,29 +343,10 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._require_mention: bool = os.getenv(
|
||||
"MATRIX_REQUIRE_MENTION", "true"
|
||||
).lower() not in ("false", "0", "no")
|
||||
free_rooms_raw = config.extra.get("free_response_rooms")
|
||||
if free_rooms_raw is None:
|
||||
free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "")
|
||||
if isinstance(free_rooms_raw, list):
|
||||
self._free_rooms: Set[str] = {
|
||||
str(r).strip() for r in free_rooms_raw if str(r).strip()
|
||||
}
|
||||
else:
|
||||
self._free_rooms: Set[str] = {
|
||||
r.strip() for r in str(free_rooms_raw).split(",") if r.strip()
|
||||
}
|
||||
# If non-empty, bot ONLY responds in these rooms (whitelist); DMs exempt.
|
||||
allowed_rooms_raw = config.extra.get("allowed_rooms")
|
||||
if allowed_rooms_raw is None:
|
||||
allowed_rooms_raw = os.getenv("MATRIX_ALLOWED_ROOMS", "")
|
||||
if isinstance(allowed_rooms_raw, list):
|
||||
self._allowed_rooms: Set[str] = {
|
||||
str(r).strip() for r in allowed_rooms_raw if str(r).strip()
|
||||
}
|
||||
else:
|
||||
self._allowed_rooms: Set[str] = {
|
||||
r.strip() for r in str(allowed_rooms_raw).split(",") if r.strip()
|
||||
}
|
||||
free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "")
|
||||
self._free_rooms: Set[str] = {
|
||||
r.strip() for r in free_rooms_raw.split(",") if r.strip()
|
||||
}
|
||||
self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
@@ -384,12 +364,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"MATRIX_REACTIONS", "true"
|
||||
).lower() not in ("false", "0", "no")
|
||||
self._pending_reactions: dict[tuple[str, str], str] = {}
|
||||
# Delay before redacting reactions so Matrix homeservers have time to
|
||||
# deliver the final message event without tripping "missing event"
|
||||
# errors in some clients. 5s is empirically safe; not user-tunable —
|
||||
# if that changes, add a config.yaml entry rather than an env var.
|
||||
self._reaction_redaction_delay_seconds = 5.0
|
||||
self._reaction_redaction_tasks: Set[asyncio.Task] = set()
|
||||
|
||||
# Proxy support — resolve once at init, reuse for all HTTP traffic.
|
||||
self._proxy_url: str | None = resolve_proxy_url(platform_env_var="MATRIX_PROXY")
|
||||
@@ -877,14 +851,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
redaction_tasks = list(self._reaction_redaction_tasks)
|
||||
for task in redaction_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
if redaction_tasks:
|
||||
await asyncio.gather(*redaction_tasks, return_exceptions=True)
|
||||
self._reaction_redaction_tasks.clear()
|
||||
|
||||
# Close the SQLite crypto store database.
|
||||
if hasattr(self, "_crypto_db") and self._crypto_db:
|
||||
try:
|
||||
@@ -1593,18 +1559,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
|
||||
# Require-mention gating.
|
||||
if not is_dm:
|
||||
# allowed_rooms check (whitelist — must pass before other gating).
|
||||
# When set, messages from rooms NOT in this whitelist are silently
|
||||
# ignored, even if @mentioned. DMs are already excluded above.
|
||||
if self._allowed_rooms and room_id not in self._allowed_rooms:
|
||||
logger.debug(
|
||||
"Matrix: ignoring message %s in %s — room not in "
|
||||
"MATRIX_ALLOWED_ROOMS whitelist",
|
||||
event_id,
|
||||
room_id,
|
||||
)
|
||||
return None
|
||||
|
||||
is_free_room = room_id in self._free_rooms
|
||||
in_bot_thread = bool(thread_id and thread_id in self._threads)
|
||||
if self._require_mention and not is_free_room and not in_bot_thread:
|
||||
@@ -1975,35 +1929,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"""Remove a reaction by redacting its event."""
|
||||
return await self.redact_message(room_id, reaction_event_id, reason)
|
||||
|
||||
def _schedule_reaction_redaction(
|
||||
self,
|
||||
room_id: str,
|
||||
reaction_event_id: str,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""Redact a reaction after a short delay so message delivery settles."""
|
||||
|
||||
async def _redact_later() -> None:
|
||||
try:
|
||||
if self._reaction_redaction_delay_seconds:
|
||||
await asyncio.sleep(self._reaction_redaction_delay_seconds)
|
||||
if not await self._redact_reaction(room_id, reaction_event_id, reason):
|
||||
logger.debug(
|
||||
"Matrix: failed to redact reaction %s", reaction_event_id
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Matrix: delayed reaction redaction failed for %s: %s",
|
||||
reaction_event_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(_redact_later())
|
||||
self._reaction_redaction_tasks.add(task)
|
||||
task.add_done_callback(self._reaction_redaction_tasks.discard)
|
||||
|
||||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||
"""Add eyes reaction when the agent starts processing a message."""
|
||||
if not self._reactions_enabled:
|
||||
@@ -2032,11 +1957,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
reaction_key = (room_id, msg_id)
|
||||
if reaction_key in self._pending_reactions:
|
||||
eyes_event_id = self._pending_reactions.pop(reaction_key)
|
||||
self._schedule_reaction_redaction(
|
||||
room_id,
|
||||
eyes_event_id,
|
||||
"processing complete",
|
||||
)
|
||||
if not await self._redact_reaction(room_id, eyes_event_id):
|
||||
logger.debug("Matrix: failed to redact eyes reaction %s", eyes_event_id)
|
||||
await self._send_reaction(
|
||||
room_id,
|
||||
msg_id,
|
||||
@@ -2115,8 +2037,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
) -> None:
|
||||
"""Redact the bot's seed ✅/❎ reactions, leaving only the user's reaction."""
|
||||
for emoji, evt_id in prompt.bot_reaction_events.items():
|
||||
self._schedule_reaction_redaction(room_id, evt_id, "approval resolved")
|
||||
logger.debug("Matrix: scheduled bot reaction redaction %s (%s)", emoji, evt_id)
|
||||
try:
|
||||
await self.redact_message(room_id, evt_id, "approval resolved")
|
||||
logger.debug("Matrix: redacted bot reaction %s (%s)", emoji, evt_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: failed to redact bot reaction %s: %s", emoji, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text message aggregation (handles Matrix client-side splits)
|
||||
|
||||
@@ -706,30 +706,10 @@ class MattermostAdapter(BasePlatformAdapter):
|
||||
message_text = post.get("message", "")
|
||||
|
||||
# Mention-gating for non-DM channels.
|
||||
# Config (config.yaml `mattermost.*` with env-var fallback):
|
||||
# require_mention / MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true)
|
||||
# free_response_channels / MATTERMOST_FREE_RESPONSE_CHANNELS: Channel IDs where bot responds without mention
|
||||
# allowed_channels / MATTERMOST_ALLOWED_CHANNELS: If set, bot ONLY responds in these channels (whitelist)
|
||||
# Config (env vars):
|
||||
# MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true)
|
||||
# MATTERMOST_FREE_RESPONSE_CHANNELS: Channel IDs where bot responds without mention
|
||||
if channel_type_raw != "D":
|
||||
# allowed_channels check (whitelist — must pass before other gating).
|
||||
# When set, messages from channels NOT in this list are silently
|
||||
# ignored, even if @mentioned. DMs are already excluded above.
|
||||
allowed_raw = self.config.extra.get("allowed_channels") if self.config.extra else None
|
||||
if allowed_raw is None:
|
||||
allowed_raw = os.getenv("MATTERMOST_ALLOWED_CHANNELS", "")
|
||||
if isinstance(allowed_raw, list):
|
||||
allowed_channels = {str(c).strip() for c in allowed_raw if str(c).strip()}
|
||||
else:
|
||||
allowed_channels = {
|
||||
c.strip() for c in str(allowed_raw).split(",") if c.strip()
|
||||
}
|
||||
if allowed_channels and channel_id not in allowed_channels:
|
||||
logger.debug(
|
||||
"Mattermost: ignoring message in non-allowed channel: %s",
|
||||
channel_id,
|
||||
)
|
||||
return
|
||||
|
||||
require_mention = os.getenv(
|
||||
"MATTERMOST_REQUIRE_MENTION", "true"
|
||||
).lower() not in ("false", "0", "no")
|
||||
|
||||
@@ -34,27 +34,6 @@ from .crypto import decrypt_secret, generate_bind_key # noqa: F401
|
||||
# -- Utils -----------------------------------------------------------------
|
||||
from .utils import build_user_agent, get_api_headers, coerce_list # noqa: F401
|
||||
|
||||
# -- Chunked upload --------------------------------------------------------
|
||||
from .chunked_upload import ( # noqa: F401
|
||||
ChunkedUploader,
|
||||
UploadDailyLimitExceededError,
|
||||
UploadFileTooLargeError,
|
||||
)
|
||||
|
||||
# -- Inline keyboards ------------------------------------------------------
|
||||
from .keyboards import ( # noqa: F401
|
||||
ApprovalRequest,
|
||||
ApprovalSender,
|
||||
InlineKeyboard,
|
||||
InteractionEvent,
|
||||
build_approval_keyboard,
|
||||
build_approval_text,
|
||||
build_update_prompt_keyboard,
|
||||
parse_approval_button_data,
|
||||
parse_interaction_event,
|
||||
parse_update_prompt_button_data,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# adapter
|
||||
"QQAdapter",
|
||||
@@ -73,19 +52,4 @@ __all__ = [
|
||||
"build_user_agent",
|
||||
"get_api_headers",
|
||||
"coerce_list",
|
||||
# chunked upload
|
||||
"ChunkedUploader",
|
||||
"UploadDailyLimitExceededError",
|
||||
"UploadFileTooLargeError",
|
||||
# keyboards
|
||||
"ApprovalRequest",
|
||||
"ApprovalSender",
|
||||
"InlineKeyboard",
|
||||
"InteractionEvent",
|
||||
"build_approval_keyboard",
|
||||
"build_approval_text",
|
||||
"build_update_prompt_keyboard",
|
||||
"parse_approval_button_data",
|
||||
"parse_interaction_event",
|
||||
"parse_update_prompt_button_data",
|
||||
]
|
||||
|
||||
@@ -41,7 +41,7 @@ import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
@@ -119,22 +119,6 @@ from gateway.platforms.qqbot.utils import (
|
||||
coerce_list as _coerce_list_impl,
|
||||
build_user_agent,
|
||||
)
|
||||
from gateway.platforms.qqbot.chunked_upload import (
|
||||
ChunkedUploader,
|
||||
UploadDailyLimitExceededError,
|
||||
UploadFileTooLargeError,
|
||||
)
|
||||
from gateway.platforms.qqbot.keyboards import (
|
||||
ApprovalRequest,
|
||||
ApprovalSender,
|
||||
InlineKeyboard,
|
||||
InteractionEvent,
|
||||
build_approval_keyboard,
|
||||
build_update_prompt_keyboard,
|
||||
parse_approval_button_data,
|
||||
parse_interaction_event,
|
||||
parse_update_prompt_button_data,
|
||||
)
|
||||
|
||||
|
||||
def check_qq_requirements() -> bool:
|
||||
@@ -224,22 +208,6 @@ class QQAdapter(BasePlatformAdapter):
|
||||
# Upload cache: content_hash -> {file_info, file_uuid, expires_at}
|
||||
self._upload_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Inline-keyboard interaction routing. The callback (if set) is invoked
|
||||
# for every INTERACTION_CREATE event after the adapter has already
|
||||
# ACKed it. Callers (gateway wiring for approvals / update prompts)
|
||||
# register via set_interaction_callback().
|
||||
self._interaction_callback: Optional[
|
||||
Callable[[InteractionEvent], Awaitable[None]]
|
||||
] = None
|
||||
|
||||
# Default interaction dispatcher: routes approval-button clicks to
|
||||
# tools.approval.resolve_gateway_approval() and update-prompt clicks
|
||||
# to ~/.hermes/.update_response. Set here so the cross-adapter gateway
|
||||
# contract (send_exec_approval / send_update_prompt) works out of the
|
||||
# box; callers can override with set_interaction_callback(None) or
|
||||
# register a custom handler.
|
||||
self._interaction_callback = self._default_interaction_dispatch
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Properties
|
||||
# ------------------------------------------------------------------
|
||||
@@ -791,8 +759,6 @@ class QQAdapter(BasePlatformAdapter):
|
||||
"GUILD_AT_MESSAGE_CREATE",
|
||||
):
|
||||
asyncio.create_task(self._on_message(t, d))
|
||||
elif t == "INTERACTION_CREATE":
|
||||
self._create_task(self._on_interaction(d))
|
||||
else:
|
||||
logger.debug("[%s] Unhandled dispatch: %s", self._log_tag, t)
|
||||
return
|
||||
@@ -866,206 +832,6 @@ class QQAdapter(BasePlatformAdapter):
|
||||
elif event_type == "DIRECT_MESSAGE_CREATE":
|
||||
await self._handle_dm_message(d, msg_id, content, author, timestamp)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inline-keyboard interactions (INTERACTION_CREATE)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_interaction_callback(
|
||||
self,
|
||||
callback: Optional[Callable[[InteractionEvent], Awaitable[None]]],
|
||||
) -> None:
|
||||
"""Register (or clear) the interaction callback.
|
||||
|
||||
Invoked once per ``INTERACTION_CREATE`` event *after* the adapter has
|
||||
ACKed the interaction. The callback is responsible for routing the
|
||||
button click to the right subsystem (approval resolver, update-prompt
|
||||
resolver, etc.) based on the ``button_data`` payload.
|
||||
"""
|
||||
self._interaction_callback = callback
|
||||
|
||||
async def _on_interaction(self, d: Any) -> None:
|
||||
"""Handle an ``INTERACTION_CREATE`` event.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Parse the raw payload into an :class:`InteractionEvent`.
|
||||
2. ACK the interaction (``PUT /interactions/{id}``) so the client
|
||||
stops showing a loading indicator on the button.
|
||||
3. Dispatch to the registered interaction callback, if any.
|
||||
"""
|
||||
if not isinstance(d, dict):
|
||||
return
|
||||
try:
|
||||
event = parse_interaction_event(d)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] Failed to parse INTERACTION_CREATE: %s", self._log_tag, exc
|
||||
)
|
||||
return
|
||||
|
||||
if not event.id:
|
||||
logger.warning(
|
||||
"[%s] INTERACTION_CREATE missing id, skipping ACK", self._log_tag
|
||||
)
|
||||
return
|
||||
|
||||
# ACK the interaction promptly — per the QQ docs the client will show
|
||||
# an error icon on the button if we don't respond quickly.
|
||||
try:
|
||||
await self._acknowledge_interaction(event.id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] Failed to ACK interaction %s: %s",
|
||||
self._log_tag, event.id, exc,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[%s] Interaction: scene=%s button_data=%r operator=%s",
|
||||
self._log_tag, event.scene, event.button_data, event.operator_openid,
|
||||
)
|
||||
|
||||
callback = self._interaction_callback
|
||||
if callback is None:
|
||||
logger.debug(
|
||||
"[%s] No interaction callback registered; dropping button "
|
||||
"click %r",
|
||||
self._log_tag, event.button_data,
|
||||
)
|
||||
return
|
||||
try:
|
||||
await callback(event)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[%s] Interaction callback raised: %s",
|
||||
self._log_tag, exc, exc_info=True,
|
||||
)
|
||||
|
||||
async def _acknowledge_interaction(
|
||||
self,
|
||||
interaction_id: str,
|
||||
code: int = 0,
|
||||
) -> None:
|
||||
"""ACK a button interaction via ``PUT /interactions/{id}``.
|
||||
|
||||
:param interaction_id: The ``id`` field from the
|
||||
``INTERACTION_CREATE`` event.
|
||||
:param code: Response code (``0`` = success).
|
||||
"""
|
||||
if not self._http_client:
|
||||
raise RuntimeError("HTTP client not initialized — not connected?")
|
||||
token = await self._ensure_token()
|
||||
headers = {
|
||||
"Authorization": f"QQBot {token}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": build_user_agent(),
|
||||
}
|
||||
resp = await self._http_client.put(
|
||||
f"{API_BASE}/interactions/{interaction_id}",
|
||||
headers=headers,
|
||||
json={"code": code},
|
||||
timeout=DEFAULT_API_TIMEOUT,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(
|
||||
f"Interaction ACK failed [{resp.status_code}]: "
|
||||
f"{resp.text[:200]}"
|
||||
)
|
||||
|
||||
# Mapping from QQ keyboard button decisions → the ``choice`` vocabulary
|
||||
# accepted by ``tools.approval.resolve_gateway_approval``. QQ's 3-button
|
||||
# layout (mobile-space constraint) collapses "session" and "always" into
|
||||
# a single "always" button; users wanting session-only approval can fall
|
||||
# back to the ``/approve session`` text command.
|
||||
_APPROVAL_BUTTON_TO_CHOICE = {
|
||||
"allow-once": "once",
|
||||
"allow-always": "always",
|
||||
"deny": "deny",
|
||||
}
|
||||
|
||||
async def _default_interaction_dispatch(
|
||||
self,
|
||||
event: InteractionEvent,
|
||||
) -> None:
|
||||
"""Route ``INTERACTION_CREATE`` button clicks to the right subsystem.
|
||||
|
||||
- ``approve:<session_key>:<decision>`` →
|
||||
:func:`tools.approval.resolve_gateway_approval`
|
||||
(unblocks the agent thread waiting on a dangerous-command approval).
|
||||
- ``update_prompt:<answer>`` →
|
||||
writes the answer to ``~/.hermes/.update_response`` for the
|
||||
detached ``hermes update --gateway`` process to consume.
|
||||
- Anything else is logged at DEBUG and ignored.
|
||||
|
||||
Installed as the adapter's default interaction callback in
|
||||
``__init__``. Callers can replace via
|
||||
:meth:`set_interaction_callback` to route clicks elsewhere (or pass
|
||||
``None`` to drop them entirely).
|
||||
"""
|
||||
button_data = event.button_data
|
||||
if not button_data:
|
||||
return
|
||||
|
||||
approval = parse_approval_button_data(button_data)
|
||||
if approval is not None:
|
||||
session_key, decision = approval
|
||||
choice = self._APPROVAL_BUTTON_TO_CHOICE.get(decision)
|
||||
if choice is None:
|
||||
logger.warning(
|
||||
"[%s] Unknown approval decision %r (session=%s)",
|
||||
self._log_tag, decision, session_key,
|
||||
)
|
||||
return
|
||||
try:
|
||||
# Import lazily to keep the adapter importable in tests that
|
||||
# don't exercise the approval subsystem.
|
||||
from tools.approval import resolve_gateway_approval
|
||||
count = resolve_gateway_approval(session_key, choice)
|
||||
logger.info(
|
||||
"[%s] Button resolved %d approval(s) for session %s "
|
||||
"(choice=%s, operator=%s)",
|
||||
self._log_tag, count, session_key, choice,
|
||||
event.operator_openid,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[%s] resolve_gateway_approval failed for session %s: %s",
|
||||
self._log_tag, session_key, exc,
|
||||
)
|
||||
return
|
||||
|
||||
update_answer = parse_update_prompt_button_data(button_data)
|
||||
if update_answer is not None:
|
||||
self._write_update_response(update_answer, event.operator_openid)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"[%s] Unrecognised button_data %r from interaction %s",
|
||||
self._log_tag, button_data, event.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _write_update_response(answer: str, operator: str = "") -> None:
|
||||
"""Atomically write the update-prompt answer to ``.update_response``.
|
||||
|
||||
Mirrors the Discord / Telegram / Feishu adapters: the detached
|
||||
``hermes update --gateway`` watcher polls this file for a ``y``/``n``
|
||||
response to its interactive prompts (stash-restore, config migration).
|
||||
Writes via ``tmp + rename`` so a partial write can't fool the reader.
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
home = get_hermes_home()
|
||||
response_path = home / ".update_response"
|
||||
tmp = response_path.with_suffix(".tmp")
|
||||
tmp.write_text(answer)
|
||||
tmp.replace(response_path)
|
||||
logger.info(
|
||||
"QQ update prompt answered %r by %s",
|
||||
answer, operator or "(unknown)",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to write update response: %s", exc)
|
||||
|
||||
async def _handle_c2c_message(
|
||||
self,
|
||||
d: Dict[str, Any],
|
||||
@@ -1134,13 +900,6 @@ class QQAdapter(BasePlatformAdapter):
|
||||
len(voice_transcripts),
|
||||
)
|
||||
|
||||
# Merge any quoted-message context (message_type=103 → msg_elements[0]).
|
||||
quoted = await self._process_quoted_context(d)
|
||||
text = self._merge_quote_into(text, quoted["quote_block"])
|
||||
if quoted["image_urls"]:
|
||||
image_urls = image_urls + quoted["image_urls"]
|
||||
image_media_types = image_media_types + quoted["image_media_types"]
|
||||
|
||||
if not text.strip() and not image_urls:
|
||||
return
|
||||
|
||||
@@ -1199,13 +958,6 @@ class QQAdapter(BasePlatformAdapter):
|
||||
else attachment_info
|
||||
)
|
||||
|
||||
# Merge any quoted-message context (message_type=103 → msg_elements[0]).
|
||||
quoted = await self._process_quoted_context(d)
|
||||
text = self._merge_quote_into(text, quoted["quote_block"])
|
||||
if quoted["image_urls"]:
|
||||
image_urls = image_urls + quoted["image_urls"]
|
||||
image_media_types = image_media_types + quoted["image_media_types"]
|
||||
|
||||
if not text.strip() and not image_urls:
|
||||
return
|
||||
|
||||
@@ -1273,13 +1025,6 @@ class QQAdapter(BasePlatformAdapter):
|
||||
else attachment_info
|
||||
)
|
||||
|
||||
# Merge any quoted-message context (message_type=103 → msg_elements[0]).
|
||||
quoted = await self._process_quoted_context(d)
|
||||
text = self._merge_quote_into(text, quoted["quote_block"])
|
||||
if quoted["image_urls"]:
|
||||
image_urls = image_urls + quoted["image_urls"]
|
||||
image_media_types = image_media_types + quoted["image_media_types"]
|
||||
|
||||
if not text.strip() and not image_urls:
|
||||
return
|
||||
|
||||
@@ -1344,13 +1089,6 @@ class QQAdapter(BasePlatformAdapter):
|
||||
else attachment_info
|
||||
)
|
||||
|
||||
# Merge any quoted-message context (message_type=103 → msg_elements[0]).
|
||||
quoted = await self._process_quoted_context(d)
|
||||
text = self._merge_quote_into(text, quoted["quote_block"])
|
||||
if quoted["image_urls"]:
|
||||
image_urls = image_urls + quoted["image_urls"]
|
||||
image_media_types = image_media_types + quoted["image_media_types"]
|
||||
|
||||
if not text.strip() and not image_urls:
|
||||
return
|
||||
|
||||
@@ -1371,113 +1109,6 @@ class QQAdapter(BasePlatformAdapter):
|
||||
)
|
||||
await self.handle_message(event)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Quoted-message handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _process_quoted_context(
|
||||
self,
|
||||
d: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Process the quoted message a user is replying to.
|
||||
|
||||
When a user replies while quoting another message, the platform sets
|
||||
``message_type = 103`` and pushes the referenced message's content and
|
||||
attachments inside ``msg_elements[0]``. The old adapter ignored
|
||||
``msg_elements`` entirely, so:
|
||||
|
||||
- Quoted text was surfaced only when the user typed something of
|
||||
their own — bare quote-replies showed nothing.
|
||||
- Quoted attachments (images, voice, files) were never downloaded
|
||||
or described.
|
||||
- Quoted voice messages specifically produced no transcript, so the
|
||||
LLM had no way to see what the user was referring to.
|
||||
|
||||
This method parses ``msg_elements`` and runs the quoted attachments
|
||||
through the same :meth:`_process_attachments` pipeline as the main
|
||||
message body, so quoted voice messages get STT transcripts and
|
||||
quoted images are cached identically.
|
||||
|
||||
:param d: Raw inbound message dict (from the WS dispatch payload).
|
||||
:returns: Dict with keys:
|
||||
|
||||
- ``quote_block``: string to prepend to the user's text body
|
||||
(empty when there's nothing quoted).
|
||||
- ``image_urls``: list of cached quoted-image paths.
|
||||
- ``image_media_types``: parallel list of image MIME types.
|
||||
"""
|
||||
empty = {
|
||||
"quote_block": "",
|
||||
"image_urls": [],
|
||||
"image_media_types": [],
|
||||
}
|
||||
# Short-circuit: only message_type 103 indicates a quote.
|
||||
try:
|
||||
if int(d.get("message_type", 0) or 0) != 103:
|
||||
return empty
|
||||
except (TypeError, ValueError):
|
||||
return empty
|
||||
|
||||
elements = d.get("msg_elements")
|
||||
if not isinstance(elements, list) or not elements:
|
||||
return empty
|
||||
|
||||
# msg_elements[0] carries the referenced message. Additional elements
|
||||
# (if any) are very rare in practice; we concatenate their text and
|
||||
# union their attachments for completeness.
|
||||
quoted_text_parts: List[str] = []
|
||||
all_attachments: List[Dict[str, Any]] = []
|
||||
for elem in elements:
|
||||
if not isinstance(elem, dict):
|
||||
continue
|
||||
etext = str(elem.get("content", "")).strip()
|
||||
if etext:
|
||||
quoted_text_parts.append(etext)
|
||||
eatts = elem.get("attachments")
|
||||
if isinstance(eatts, list):
|
||||
for a in eatts:
|
||||
if isinstance(a, dict):
|
||||
all_attachments.append(a)
|
||||
|
||||
att_result = await self._process_attachments(all_attachments)
|
||||
quoted_voice = att_result.get("voice_transcripts") or []
|
||||
quoted_info = att_result.get("attachment_info") or ""
|
||||
quoted_images = att_result.get("image_urls") or []
|
||||
quoted_image_types = att_result.get("image_media_types") or []
|
||||
|
||||
lines: List[str] = []
|
||||
if quoted_text_parts:
|
||||
lines.append(" ".join(quoted_text_parts))
|
||||
for t in quoted_voice:
|
||||
lines.append(t)
|
||||
if quoted_info:
|
||||
lines.append(quoted_info)
|
||||
|
||||
if not lines and not quoted_images:
|
||||
return empty
|
||||
|
||||
if lines:
|
||||
quote_block = "[Quoted message]:\n" + "\n".join(lines)
|
||||
else:
|
||||
# Images-only quote: give the LLM at least a marker so it knows
|
||||
# context was referenced.
|
||||
quote_block = "[Quoted message]: (image)"
|
||||
|
||||
return {
|
||||
"quote_block": quote_block,
|
||||
"image_urls": quoted_images,
|
||||
"image_media_types": quoted_image_types,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _merge_quote_into(text: str, quote_block: str) -> str:
|
||||
"""Prepend ``quote_block`` to *text*, separated by a blank line."""
|
||||
if not quote_block:
|
||||
return text
|
||||
if text.strip():
|
||||
return f"{quote_block}\n\n{text}".strip()
|
||||
return quote_block
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attachment processing
|
||||
# ------------------------------------------------------------------
|
||||
@@ -2361,44 +1992,26 @@ class QQAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error=error_msg, retryable=retryable)
|
||||
|
||||
async def _send_c2c_text(
|
||||
self,
|
||||
openid: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
keyboard: Optional[InlineKeyboard] = None,
|
||||
self, openid: str, content: str, reply_to: Optional[str] = None
|
||||
) -> SendResult:
|
||||
"""Send text to a C2C user via REST API.
|
||||
|
||||
:param keyboard: Optional inline keyboard attached to the message.
|
||||
"""
|
||||
"""Send text to a C2C user via REST API."""
|
||||
self._next_msg_seq(reply_to or openid)
|
||||
body = self._build_text_body(content, reply_to)
|
||||
if reply_to:
|
||||
body["msg_id"] = reply_to
|
||||
if keyboard is not None:
|
||||
body["keyboard"] = keyboard.to_dict()
|
||||
|
||||
data = await self._api_request("POST", f"/v2/users/{openid}/messages", body)
|
||||
msg_id = str(data.get("id", uuid.uuid4().hex[:12]))
|
||||
return SendResult(success=True, message_id=msg_id, raw_response=data)
|
||||
|
||||
async def _send_group_text(
|
||||
self,
|
||||
group_openid: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
keyboard: Optional[InlineKeyboard] = None,
|
||||
self, group_openid: str, content: str, reply_to: Optional[str] = None
|
||||
) -> SendResult:
|
||||
"""Send text to a group via REST API.
|
||||
|
||||
:param keyboard: Optional inline keyboard attached to the message.
|
||||
"""
|
||||
"""Send text to a group via REST API."""
|
||||
self._next_msg_seq(reply_to or group_openid)
|
||||
body = self._build_text_body(content, reply_to)
|
||||
if reply_to:
|
||||
body["msg_id"] = reply_to
|
||||
if keyboard is not None:
|
||||
body["keyboard"] = keyboard.to_dict()
|
||||
|
||||
data = await self._api_request(
|
||||
"POST", f"/v2/groups/{group_openid}/messages", body
|
||||
@@ -2418,156 +2031,6 @@ class QQAdapter(BasePlatformAdapter):
|
||||
msg_id = str(data.get("id", uuid.uuid4().hex[:12]))
|
||||
return SendResult(success=True, message_id=msg_id, raw_response=data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inline-keyboard outbound helpers (approval / update-prompt flows)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_with_keyboard(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
keyboard: InlineKeyboard,
|
||||
reply_to: Optional[str] = None,
|
||||
) -> SendResult:
|
||||
"""Send a single text message with an inline keyboard attached.
|
||||
|
||||
Unlike :meth:`send`, this does NOT split long content into chunks —
|
||||
a keyboard message has exactly one interactive surface, and splitting
|
||||
would orphan the buttons from the first chunk. Callers should keep
|
||||
approval/update-prompt bodies short.
|
||||
|
||||
Guild (channel) chats don't support inline keyboards; returns a
|
||||
non-retryable failure for those.
|
||||
"""
|
||||
if not self.is_connected:
|
||||
if not await self._wait_for_reconnection():
|
||||
return SendResult(
|
||||
success=False, error="Not connected", retryable=True
|
||||
)
|
||||
|
||||
chat_type = self._guess_chat_type(chat_id)
|
||||
formatted = self.format_message(content)
|
||||
truncated = formatted[: self.MAX_MESSAGE_LENGTH]
|
||||
try:
|
||||
if chat_type == "c2c":
|
||||
return await self._send_c2c_text(
|
||||
chat_id, truncated, reply_to, keyboard=keyboard,
|
||||
)
|
||||
if chat_type == "group":
|
||||
return await self._send_group_text(
|
||||
chat_id, truncated, reply_to, keyboard=keyboard,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=(
|
||||
f"Inline keyboards not supported for chat_type "
|
||||
f"{chat_type!r}"
|
||||
),
|
||||
retryable=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[%s] send_with_keyboard failed: %s", self._log_tag, exc
|
||||
)
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
async def send_approval_request(
|
||||
self,
|
||||
chat_id: str,
|
||||
req: ApprovalRequest,
|
||||
reply_to: Optional[str] = None,
|
||||
) -> SendResult:
|
||||
"""Send a 3-button approval request (``allow-once / allow-always / deny``).
|
||||
|
||||
The rendered text comes from :func:`build_approval_text`; callers can
|
||||
override by passing a custom :class:`ApprovalRequest`.
|
||||
|
||||
Users click the button → ``INTERACTION_CREATE`` fires → the adapter's
|
||||
registered :meth:`set_interaction_callback` handler decodes
|
||||
``button_data`` via :func:`parse_approval_button_data`.
|
||||
"""
|
||||
from gateway.platforms.qqbot.keyboards import build_approval_text
|
||||
return await self.send_with_keyboard(
|
||||
chat_id,
|
||||
build_approval_text(req),
|
||||
build_approval_keyboard(req.session_key),
|
||||
reply_to=reply_to,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cross-adapter gateway contract — send_exec_approval + send_update_prompt
|
||||
# ------------------------------------------------------------------
|
||||
#
|
||||
# These mirror the signatures that gateway/run.py detects on the adapter
|
||||
# class (e.g. type(adapter).send_exec_approval, type(adapter).send_update_prompt)
|
||||
# for button-based approval / update-confirm UX. Discord, Telegram, Slack,
|
||||
# Matrix, and Feishu already implement the same contract.
|
||||
|
||||
async def send_exec_approval(
|
||||
self,
|
||||
chat_id: str,
|
||||
command: str,
|
||||
session_key: str,
|
||||
description: str = "dangerous command",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a button-based exec-approval prompt for a dangerous command.
|
||||
|
||||
Called by ``gateway/run.py``'s ``_approval_notify_sync`` when the
|
||||
agent is blocked waiting for approval. Button clicks resolve via
|
||||
:func:`tools.approval.resolve_gateway_approval` — dispatched by the
|
||||
adapter's interaction callback (:meth:`_default_interaction_dispatch`).
|
||||
"""
|
||||
del metadata # QQ doesn't have thread_id / DM targeting overrides.
|
||||
|
||||
# Use the reply-to message for passive-message context when we have one.
|
||||
# QQ requires a msg_id on outbound messages to a user we've never
|
||||
# seen; the last inbound msg_id is the natural choice.
|
||||
msg_id = self._last_msg_id.get(chat_id)
|
||||
|
||||
req = ApprovalRequest(
|
||||
session_key=session_key,
|
||||
title=f"Execute this command?",
|
||||
description=description,
|
||||
command_preview=command,
|
||||
timeout_sec=self._APPROVAL_TIMEOUT_SECONDS,
|
||||
)
|
||||
return await self.send_approval_request(
|
||||
chat_id, req, reply_to=msg_id,
|
||||
)
|
||||
|
||||
_APPROVAL_TIMEOUT_SECONDS = 300 # matches gateway's default gateway_timeout
|
||||
|
||||
async def send_update_prompt(
|
||||
self,
|
||||
chat_id: str,
|
||||
prompt: str,
|
||||
default: str = "",
|
||||
session_key: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a Yes/No update-confirmation prompt with inline buttons.
|
||||
|
||||
Matches the cross-adapter contract used by
|
||||
``gateway/run.py``'s ``hermes update --gateway`` watcher. Button
|
||||
clicks surface as ``INTERACTION_CREATE`` with
|
||||
``button_data = 'update_prompt:y'`` or ``'update_prompt:n'``;
|
||||
the adapter's interaction callback writes the answer to
|
||||
``~/.hermes/.update_response`` so the detached update process
|
||||
can read it.
|
||||
"""
|
||||
del session_key, metadata # present for contract parity only.
|
||||
|
||||
default_hint = f" (default: {default})" if default else ""
|
||||
content = f"⚕ **Update Needs Your Input**\n\n{prompt}{default_hint}"
|
||||
msg_id = self._last_msg_id.get(chat_id)
|
||||
return await self.send_with_keyboard(
|
||||
chat_id,
|
||||
content,
|
||||
build_update_prompt_keyboard(),
|
||||
reply_to=msg_id,
|
||||
)
|
||||
|
||||
def _build_text_body(
|
||||
self, content: str, reply_to: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
@@ -2697,62 +2160,42 @@ class QQAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
file_name: Optional[str] = None,
|
||||
) -> SendResult:
|
||||
"""Upload media and send as a native message.
|
||||
|
||||
Upload strategy:
|
||||
|
||||
- **HTTP(S) URLs** → single ``POST /v2/{users|groups}/{id}/files``
|
||||
with ``url=...``. The QQ platform fetches the URL directly; fastest
|
||||
path when the source is already hosted.
|
||||
- **Local files** → three-step chunked upload (prepare / PUT parts /
|
||||
complete). Handles files up to the platform's ~100 MB per-file
|
||||
limit without the ~10 MB inline-base64 cap of the old adapter.
|
||||
"""
|
||||
"""Upload media and send as a native message."""
|
||||
if not self.is_connected:
|
||||
if not await self._wait_for_reconnection():
|
||||
return SendResult(success=False, error="Not connected", retryable=True)
|
||||
|
||||
chat_type = self._guess_chat_type(chat_id)
|
||||
if chat_type == "guild":
|
||||
# Guild channels don't support native media upload in the same way.
|
||||
return SendResult(
|
||||
success=False,
|
||||
error="Guild media send not supported via this path",
|
||||
try:
|
||||
# Resolve media source
|
||||
data, content_type, resolved_name = await self._load_media(
|
||||
media_source, file_name
|
||||
)
|
||||
|
||||
try:
|
||||
if self._is_url(media_source):
|
||||
# URL upload — let the platform fetch it directly.
|
||||
resolved_name = (
|
||||
file_name
|
||||
or Path(urlparse(media_source).path).name
|
||||
or "media"
|
||||
)
|
||||
upload = await self._upload_media(
|
||||
chat_type,
|
||||
chat_id,
|
||||
file_type,
|
||||
url=media_source,
|
||||
srv_send_msg=False,
|
||||
file_name=resolved_name if file_type == MEDIA_TYPE_FILE else None,
|
||||
)
|
||||
else:
|
||||
# Local file — chunked upload (prepare / PUT parts / complete).
|
||||
resolved_name, upload = await self._upload_local_file(
|
||||
chat_type,
|
||||
chat_id,
|
||||
media_source,
|
||||
file_type,
|
||||
file_name,
|
||||
# Route
|
||||
chat_type = self._guess_chat_type(chat_id)
|
||||
|
||||
if chat_type == "guild":
|
||||
# Guild channels don't support native media upload in the same way
|
||||
# Send as URL fallback
|
||||
return SendResult(
|
||||
success=False, error="Guild media send not supported via this path"
|
||||
)
|
||||
|
||||
file_info = upload.get("file_info") or (
|
||||
upload.get("data", {}) or {}
|
||||
).get("file_info")
|
||||
# Upload
|
||||
upload = await self._upload_media(
|
||||
chat_type,
|
||||
chat_id,
|
||||
file_type,
|
||||
file_data=data if not self._is_url(media_source) else None,
|
||||
url=media_source if self._is_url(media_source) else None,
|
||||
srv_send_msg=False,
|
||||
file_name=resolved_name if file_type == MEDIA_TYPE_FILE else None,
|
||||
)
|
||||
|
||||
file_info = upload.get("file_info")
|
||||
if not file_info:
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=f"Upload returned no file_info: {upload}",
|
||||
success=False, error=f"Upload returned no file_info: {upload}"
|
||||
)
|
||||
|
||||
# Send media message
|
||||
@@ -2781,86 +2224,10 @@ class QQAdapter(BasePlatformAdapter):
|
||||
message_id=str(send_data.get("id", uuid.uuid4().hex[:12])),
|
||||
raw_response=send_data,
|
||||
)
|
||||
except UploadDailyLimitExceededError as exc:
|
||||
# Non-retryable: daily quota hit. Give the caller actionable text
|
||||
# so the model can compose a helpful reply.
|
||||
logger.warning(
|
||||
"[%s] Daily upload limit exceeded for %s (%s)",
|
||||
self._log_tag, exc.file_name, exc.file_size_human,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=(
|
||||
f"QQ daily upload limit exceeded for {exc.file_name!r} "
|
||||
f"({exc.file_size_human}). Retry tomorrow."
|
||||
),
|
||||
retryable=False,
|
||||
)
|
||||
except UploadFileTooLargeError as exc:
|
||||
logger.warning(
|
||||
"[%s] File too large: %s (%s, platform limit %s)",
|
||||
self._log_tag, exc.file_name, exc.file_size_human, exc.limit_human,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=(
|
||||
f"{exc.file_name!r} ({exc.file_size_human}) exceeds the "
|
||||
f"QQ per-file upload limit ({exc.limit_human})."
|
||||
),
|
||||
retryable=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("[%s] Media send failed: %s", self._log_tag, exc)
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
async def _upload_local_file(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: str,
|
||||
media_source: str,
|
||||
file_type: int,
|
||||
file_name: Optional[str],
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Chunked-upload a local file and return ``(resolved_name, complete_response)``.
|
||||
|
||||
The returned ``complete_response`` contains the ``file_info`` token
|
||||
that goes into the subsequent RichMedia message body.
|
||||
|
||||
:raises UploadDailyLimitExceededError: On biz_code 40093002.
|
||||
:raises UploadFileTooLargeError: When the file exceeds the platform limit.
|
||||
:raises FileNotFoundError: If the path does not exist.
|
||||
:raises ValueError: If the path looks like a placeholder (``<path>``).
|
||||
:raises RuntimeError: If the HTTP client is not initialized.
|
||||
"""
|
||||
if not self._http_client:
|
||||
raise RuntimeError("HTTP client not initialized — not connected?")
|
||||
|
||||
local_path = Path(media_source).expanduser()
|
||||
if not local_path.is_absolute():
|
||||
local_path = (Path.cwd() / local_path).resolve()
|
||||
|
||||
if not local_path.exists() or not local_path.is_file():
|
||||
if media_source.startswith("<") or len(media_source) < 3:
|
||||
raise ValueError(
|
||||
f"Invalid media source (looks like a placeholder): {media_source!r}"
|
||||
)
|
||||
raise FileNotFoundError(f"Media file not found: {local_path}")
|
||||
|
||||
resolved_name = file_name or local_path.name
|
||||
uploader = ChunkedUploader(
|
||||
api_request=self._api_request,
|
||||
http_put=self._http_client.put,
|
||||
log_tag=self._log_tag,
|
||||
)
|
||||
complete = await uploader.upload(
|
||||
chat_type=chat_type,
|
||||
target_id=chat_id,
|
||||
file_path=str(local_path),
|
||||
file_type=file_type,
|
||||
file_name=resolved_name,
|
||||
)
|
||||
return resolved_name, complete
|
||||
|
||||
async def _load_media(
|
||||
self, source: str, file_name: Optional[str] = None
|
||||
) -> Tuple[str, str, str]:
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
"""QQ Bot chunked upload flow.
|
||||
|
||||
The QQ v2 API caps inline base64 uploads (``file_data`` / ``url``) at ~10 MB.
|
||||
For files between 10 MB and ~100 MB we have to use the three-step chunked
|
||||
upload flow::
|
||||
|
||||
1. POST /v2/{users|groups}/{id}/upload_prepare
|
||||
→ returns upload_id, block_size, and an array of pre-signed COS part URLs.
|
||||
2. For each part:
|
||||
PUT the part bytes to its pre-signed COS URL,
|
||||
then POST /v2/{users|groups}/{id}/upload_part_finish to acknowledge.
|
||||
3. POST /v2/{users|groups}/{id}/files with {"upload_id": ...}
|
||||
→ returns the ``file_info`` token the caller uses in a RichMedia
|
||||
message.
|
||||
|
||||
Error-code semantics (from the QQ Bot v2 API spec):
|
||||
|
||||
- ``40093001`` — ``upload_part_finish`` retryable. Retry until the server-provided
|
||||
``retry_timeout`` elapses (or a local cap).
|
||||
- ``40093002`` — daily cumulative upload quota exceeded. Not retryable; surface
|
||||
as :class:`UploadDailyLimitExceededError` so the caller can build a
|
||||
user-friendly reply.
|
||||
|
||||
Exceptions:
|
||||
|
||||
- :class:`UploadDailyLimitExceededError` — daily quota hit (non-retryable).
|
||||
- :class:`UploadFileTooLargeError` — file exceeds the platform per-file limit.
|
||||
- :class:`RuntimeError` — generic upload failure (network, part PUT, complete).
|
||||
|
||||
Ported from WideLee's qqbot-agent-sdk v1.2.2 (``media_loader.py::ChunkedUploader``)
|
||||
so the heavy-upload path stays in-tree. Authorship preserved via Co-authored-by.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
from gateway.platforms.qqbot.constants import FILE_UPLOAD_TIMEOUT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Error codes ──────────────────────────────────────────────────────
|
||||
_BIZ_CODE_DAILY_LIMIT = 40093002 # upload_prepare: daily cumulative limit
|
||||
_BIZ_CODE_PART_RETRYABLE = 40093001 # upload_part_finish: transient
|
||||
|
||||
# ── Part upload tuning ───────────────────────────────────────────────
|
||||
_DEFAULT_CONCURRENT_PARTS = 1
|
||||
_MAX_CONCURRENT_PARTS = 10
|
||||
|
||||
_PART_UPLOAD_TIMEOUT = 300.0 # 5 minutes per COS PUT
|
||||
_PART_UPLOAD_MAX_RETRIES = 2
|
||||
_PART_FINISH_RETRY_INTERVAL = 1.0
|
||||
_PART_FINISH_DEFAULT_TIMEOUT = 120.0
|
||||
_PART_FINISH_MAX_TIMEOUT = 600.0
|
||||
|
||||
_COMPLETE_UPLOAD_MAX_RETRIES = 2
|
||||
_COMPLETE_UPLOAD_BASE_DELAY = 2.0
|
||||
|
||||
# First 10,002,432 bytes used for the ``md5_10m`` hash (per QQ API spec).
|
||||
_MD5_10M_SIZE = 10_002_432
|
||||
|
||||
|
||||
# ── Exceptions ───────────────────────────────────────────────────────
|
||||
|
||||
class UploadDailyLimitExceededError(Exception):
|
||||
"""Raised when ``upload_prepare`` returns biz_code 40093002.
|
||||
|
||||
The daily cumulative upload quota for this bot has been reached. Callers
|
||||
should surface :attr:`file_name` + :attr:`file_size_human` so the model
|
||||
can compose a helpful reply.
|
||||
"""
|
||||
|
||||
def __init__(self, file_name: str, file_size: int, message: str = "") -> None:
|
||||
self.file_name = file_name
|
||||
self.file_size = file_size
|
||||
super().__init__(
|
||||
message or f"Daily upload limit exceeded for {file_name!r}"
|
||||
)
|
||||
|
||||
@property
|
||||
def file_size_human(self) -> str:
|
||||
return format_size(self.file_size)
|
||||
|
||||
|
||||
class UploadFileTooLargeError(Exception):
|
||||
"""Raised when a file exceeds the platform per-file size limit."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_name: str,
|
||||
file_size: int,
|
||||
limit_bytes: int = 0,
|
||||
message: str = "",
|
||||
) -> None:
|
||||
self.file_name = file_name
|
||||
self.file_size = file_size
|
||||
self.limit_bytes = limit_bytes
|
||||
limit_str = f" ({format_size(limit_bytes)})" if limit_bytes else ""
|
||||
super().__init__(
|
||||
message
|
||||
or (
|
||||
f"File {file_name!r} ({format_size(file_size)}) "
|
||||
f"exceeds platform limit{limit_str}"
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def file_size_human(self) -> str:
|
||||
return format_size(self.file_size)
|
||||
|
||||
@property
|
||||
def limit_human(self) -> str:
|
||||
return format_size(self.limit_bytes) if self.limit_bytes else "unknown"
|
||||
|
||||
|
||||
# ── Progress tracking ────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class _UploadProgress:
|
||||
total_parts: int = 0
|
||||
total_bytes: int = 0
|
||||
completed_parts: int = 0
|
||||
uploaded_bytes: int = 0
|
||||
|
||||
|
||||
# ── Prepare-response shape ───────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class _PreparePart:
|
||||
index: int
|
||||
presigned_url: str
|
||||
block_size: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PrepareResult:
|
||||
upload_id: str
|
||||
block_size: int
|
||||
parts: List[_PreparePart]
|
||||
concurrency: int = _DEFAULT_CONCURRENT_PARTS
|
||||
retry_timeout: float = 0.0
|
||||
|
||||
|
||||
def _parse_prepare_response(raw: Dict[str, Any]) -> _PrepareResult:
|
||||
"""Parse the upload_prepare API response into a normalized shape.
|
||||
|
||||
The API may return the response directly or wrapped in ``data``.
|
||||
"""
|
||||
src = raw.get("data") if isinstance(raw.get("data"), dict) else raw
|
||||
upload_id = str(src.get("upload_id", ""))
|
||||
if not upload_id:
|
||||
raise ValueError(
|
||||
f"upload_prepare response missing upload_id: {str(raw)[:200]}"
|
||||
)
|
||||
block_size = int(src.get("block_size", 0))
|
||||
raw_parts = src.get("parts") or src.get("part_list") or []
|
||||
if not isinstance(raw_parts, list) or not raw_parts:
|
||||
raise ValueError(
|
||||
f"upload_prepare response missing parts: {str(raw)[:200]}"
|
||||
)
|
||||
parts: List[_PreparePart] = []
|
||||
for p in raw_parts:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
parts.append(
|
||||
_PreparePart(
|
||||
index=int(p.get("part_index") or p.get("index") or 0),
|
||||
presigned_url=str(
|
||||
p.get("presigned_url") or p.get("url") or ""
|
||||
),
|
||||
block_size=int(p.get("block_size", 0)),
|
||||
)
|
||||
)
|
||||
return _PrepareResult(
|
||||
upload_id=upload_id,
|
||||
block_size=block_size,
|
||||
parts=parts,
|
||||
concurrency=int(src.get("concurrency", _DEFAULT_CONCURRENT_PARTS)) or _DEFAULT_CONCURRENT_PARTS,
|
||||
retry_timeout=float(src.get("retry_timeout", 0.0) or 0.0),
|
||||
)
|
||||
|
||||
|
||||
# ── Chunked upload driver ────────────────────────────────────────────
|
||||
|
||||
ApiRequestFn = Callable[..., Awaitable[Dict[str, Any]]]
|
||||
"""Signature of the adapter's ``_api_request`` callable.
|
||||
|
||||
We pass the bound method in rather than importing the adapter, to avoid
|
||||
circular imports and keep this module testable in isolation.
|
||||
"""
|
||||
|
||||
|
||||
class ChunkedUploader:
|
||||
"""Run the prepare → PUT parts → complete sequence.
|
||||
|
||||
:param api_request: Bound ``_api_request(method, path, body=..., timeout=...)``
|
||||
coroutine from the adapter. Must raise ``RuntimeError`` with the biz_code
|
||||
embedded in the message on API errors.
|
||||
:param http_put: Coroutine ``(url, data, headers, timeout) -> response`` for
|
||||
COS part uploads. Typically wraps ``httpx.AsyncClient.put``.
|
||||
:param log_tag: Log prefix.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_request: ApiRequestFn,
|
||||
http_put: Callable[..., Awaitable[Any]],
|
||||
log_tag: str = "QQBot",
|
||||
) -> None:
|
||||
self._api_request = api_request
|
||||
self._http_put = http_put
|
||||
self._log_tag = log_tag
|
||||
|
||||
async def upload(
|
||||
self,
|
||||
chat_type: str,
|
||||
target_id: str,
|
||||
file_path: str,
|
||||
file_type: int,
|
||||
file_name: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the full chunked upload and return the ``complete_upload`` response.
|
||||
|
||||
:param chat_type: ``'c2c'`` or ``'group'``.
|
||||
:param target_id: User or group openid.
|
||||
:param file_path: Absolute path to a local file.
|
||||
:param file_type: ``MEDIA_TYPE_*`` constant.
|
||||
:param file_name: Original filename (for upload_prepare).
|
||||
:returns: The raw response dict from ``complete_upload`` — contains
|
||||
``file_info`` that the caller uses in a RichMedia message body.
|
||||
:raises UploadDailyLimitExceededError: On biz_code 40093002.
|
||||
:raises UploadFileTooLargeError: When the file exceeds the platform limit.
|
||||
:raises RuntimeError: On other API or I/O failures.
|
||||
"""
|
||||
if chat_type not in ("c2c", "group"):
|
||||
raise ValueError(
|
||||
f"ChunkedUploader: unsupported chat_type {chat_type!r}"
|
||||
)
|
||||
|
||||
path = Path(file_path)
|
||||
file_size = path.stat().st_size
|
||||
|
||||
logger.info(
|
||||
"[%s] Chunked upload start: file=%s size=%s type=%d",
|
||||
self._log_tag, file_name, format_size(file_size), file_type,
|
||||
)
|
||||
|
||||
# Step 1: compute hashes (blocking I/O → executor).
|
||||
hashes = await asyncio.get_running_loop().run_in_executor(
|
||||
None, _compute_file_hashes, file_path, file_size
|
||||
)
|
||||
|
||||
# Step 2: upload_prepare.
|
||||
prepare = await self._prepare(
|
||||
chat_type, target_id, file_type, file_name, file_size, hashes
|
||||
)
|
||||
max_concurrent = min(prepare.concurrency, _MAX_CONCURRENT_PARTS)
|
||||
retry_timeout = min(
|
||||
prepare.retry_timeout if prepare.retry_timeout > 0 else _PART_FINISH_DEFAULT_TIMEOUT,
|
||||
_PART_FINISH_MAX_TIMEOUT,
|
||||
)
|
||||
logger.info(
|
||||
"[%s] Prepared: upload_id=%s block_size=%s parts=%d concurrency=%d",
|
||||
self._log_tag, prepare.upload_id, format_size(prepare.block_size),
|
||||
len(prepare.parts), max_concurrent,
|
||||
)
|
||||
|
||||
progress = _UploadProgress(
|
||||
total_parts=len(prepare.parts),
|
||||
total_bytes=file_size,
|
||||
)
|
||||
|
||||
# Step 3: PUT each part + notify.
|
||||
tasks: List[Callable[[], Awaitable[None]]] = [
|
||||
functools.partial(
|
||||
self._upload_one_part,
|
||||
chat_type=chat_type,
|
||||
target_id=target_id,
|
||||
file_path=file_path,
|
||||
file_size=file_size,
|
||||
upload_id=prepare.upload_id,
|
||||
rsp_block_size=prepare.block_size,
|
||||
part=part,
|
||||
retry_timeout=retry_timeout,
|
||||
progress=progress,
|
||||
)
|
||||
for part in prepare.parts
|
||||
]
|
||||
await _run_with_concurrency(tasks, max_concurrent)
|
||||
|
||||
logger.info(
|
||||
"[%s] All %d parts uploaded, completing…",
|
||||
self._log_tag, len(prepare.parts),
|
||||
)
|
||||
|
||||
# Step 4: complete_upload (retry on transient errors).
|
||||
return await self._complete(chat_type, target_id, prepare.upload_id)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Step 1 — upload_prepare
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _prepare(
|
||||
self,
|
||||
chat_type: str,
|
||||
target_id: str,
|
||||
file_type: int,
|
||||
file_name: str,
|
||||
file_size: int,
|
||||
hashes: Dict[str, str],
|
||||
) -> _PrepareResult:
|
||||
base = "/v2/users" if chat_type == "c2c" else "/v2/groups"
|
||||
path = f"{base}/{target_id}/upload_prepare"
|
||||
body = {
|
||||
"file_type": file_type,
|
||||
"file_name": file_name,
|
||||
"file_size": file_size,
|
||||
"md5": hashes["md5"],
|
||||
"sha1": hashes["sha1"],
|
||||
"md5_10m": hashes["md5_10m"],
|
||||
}
|
||||
try:
|
||||
raw = await self._api_request(
|
||||
"POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
err_msg = str(exc)
|
||||
if f"{_BIZ_CODE_DAILY_LIMIT}" in err_msg:
|
||||
raise UploadDailyLimitExceededError(
|
||||
file_name, file_size, err_msg
|
||||
) from exc
|
||||
raise
|
||||
return _parse_prepare_response(raw)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Step 2 — PUT one part + part_finish
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _upload_one_part(
|
||||
self,
|
||||
chat_type: str,
|
||||
target_id: str,
|
||||
file_path: str,
|
||||
file_size: int,
|
||||
upload_id: str,
|
||||
rsp_block_size: int,
|
||||
part: _PreparePart,
|
||||
retry_timeout: float,
|
||||
progress: _UploadProgress,
|
||||
) -> None:
|
||||
"""PUT one part to COS, then call ``upload_part_finish``."""
|
||||
part_index = part.index
|
||||
# Per-part block_size wins; fall back to the response-level value.
|
||||
actual_block_size = part.block_size if part.block_size > 0 else rsp_block_size
|
||||
offset = (part_index - 1) * rsp_block_size
|
||||
length = min(actual_block_size, file_size - offset)
|
||||
|
||||
# Read this slice of the file (blocking → executor).
|
||||
data = await asyncio.get_running_loop().run_in_executor(
|
||||
None, _read_file_chunk, file_path, offset, length
|
||||
)
|
||||
md5_hex = hashlib.md5(data).hexdigest()
|
||||
|
||||
logger.debug(
|
||||
"[%s] Part %d/%d: uploading %s (offset=%d md5=%s)",
|
||||
self._log_tag, part_index, progress.total_parts,
|
||||
format_size(length), offset, md5_hex,
|
||||
)
|
||||
|
||||
await self._put_to_presigned_url(
|
||||
part.presigned_url, data, part_index, progress.total_parts
|
||||
)
|
||||
await self._part_finish_with_retry(
|
||||
chat_type, target_id, upload_id,
|
||||
part_index, length, md5_hex, retry_timeout,
|
||||
)
|
||||
|
||||
progress.completed_parts += 1
|
||||
progress.uploaded_bytes += length
|
||||
logger.debug(
|
||||
"[%s] Part %d/%d done (%d/%d total)",
|
||||
self._log_tag, part_index, progress.total_parts,
|
||||
progress.completed_parts, progress.total_parts,
|
||||
)
|
||||
|
||||
async def _put_to_presigned_url(
|
||||
self,
|
||||
url: str,
|
||||
data: bytes,
|
||||
part_index: int,
|
||||
total_parts: int,
|
||||
) -> None:
|
||||
"""PUT part data to a pre-signed COS URL with retry."""
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(_PART_UPLOAD_MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = await asyncio.wait_for(
|
||||
self._http_put(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Length": str(len(data))},
|
||||
),
|
||||
timeout=_PART_UPLOAD_TIMEOUT,
|
||||
)
|
||||
# Caller's http_put is expected to return an httpx-like response.
|
||||
status = getattr(resp, "status_code", 0)
|
||||
if 200 <= status < 300:
|
||||
logger.debug(
|
||||
"[%s] PUT part %d/%d: %d OK",
|
||||
self._log_tag, part_index, total_parts, status,
|
||||
)
|
||||
return
|
||||
body_preview = ""
|
||||
try:
|
||||
body_preview = getattr(resp, "text", "")[:200]
|
||||
except Exception: # pragma: no cover — defensive
|
||||
pass
|
||||
raise RuntimeError(
|
||||
f"COS PUT returned {status}: {body_preview}"
|
||||
)
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt < _PART_UPLOAD_MAX_RETRIES:
|
||||
delay = 1.0 * (2 ** attempt)
|
||||
logger.warning(
|
||||
"[%s] PUT part %d/%d attempt %d failed, retry in %.1fs: %s",
|
||||
self._log_tag, part_index, total_parts,
|
||||
attempt + 1, delay, exc,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
raise RuntimeError(
|
||||
f"Part {part_index}/{total_parts} upload failed after "
|
||||
f"{_PART_UPLOAD_MAX_RETRIES + 1} attempts: {last_exc}"
|
||||
)
|
||||
|
||||
async def _part_finish_with_retry(
|
||||
self,
|
||||
chat_type: str,
|
||||
target_id: str,
|
||||
upload_id: str,
|
||||
part_index: int,
|
||||
block_size: int,
|
||||
md5: str,
|
||||
retry_timeout: float,
|
||||
) -> None:
|
||||
"""Call ``upload_part_finish``, retrying on biz_code 40093001."""
|
||||
base = "/v2/users" if chat_type == "c2c" else "/v2/groups"
|
||||
path = f"{base}/{target_id}/upload_part_finish"
|
||||
body = {
|
||||
"upload_id": upload_id,
|
||||
"part_index": part_index,
|
||||
"block_size": block_size,
|
||||
"md5": md5,
|
||||
}
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
await self._api_request(
|
||||
"POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT
|
||||
)
|
||||
return
|
||||
except RuntimeError as exc:
|
||||
err_msg = str(exc)
|
||||
if f"{_BIZ_CODE_PART_RETRYABLE}" not in err_msg:
|
||||
raise
|
||||
elapsed = loop.time() - start
|
||||
if elapsed >= retry_timeout:
|
||||
raise RuntimeError(
|
||||
f"upload_part_finish persistent retry timed out "
|
||||
f"after {retry_timeout:.0f}s ({attempt} retries): {exc}"
|
||||
) from exc
|
||||
attempt += 1
|
||||
logger.debug(
|
||||
"[%s] part_finish retryable error, attempt %d, "
|
||||
"elapsed=%.1fs: %s",
|
||||
self._log_tag, attempt, elapsed, exc,
|
||||
)
|
||||
await asyncio.sleep(_PART_FINISH_RETRY_INTERVAL)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Step 3 — complete_upload
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _complete(
|
||||
self,
|
||||
chat_type: str,
|
||||
target_id: str,
|
||||
upload_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Call ``complete_upload`` with retry.
|
||||
|
||||
This reuses the ``/files`` endpoint (same as the simple URL-based upload)
|
||||
but signals the chunked-completion path by sending only ``upload_id``.
|
||||
"""
|
||||
base = "/v2/users" if chat_type == "c2c" else "/v2/groups"
|
||||
path = f"{base}/{target_id}/files"
|
||||
body = {"upload_id": upload_id}
|
||||
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(_COMPLETE_UPLOAD_MAX_RETRIES + 1):
|
||||
try:
|
||||
return await self._api_request(
|
||||
"POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT
|
||||
)
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt < _COMPLETE_UPLOAD_MAX_RETRIES:
|
||||
delay = _COMPLETE_UPLOAD_BASE_DELAY * (2 ** attempt)
|
||||
logger.warning(
|
||||
"[%s] complete_upload attempt %d failed, "
|
||||
"retry in %.1fs: %s",
|
||||
self._log_tag, attempt + 1, delay, exc,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
raise RuntimeError(
|
||||
f"complete_upload failed after "
|
||||
f"{_COMPLETE_UPLOAD_MAX_RETRIES + 1} attempts: {last_exc}"
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers (module-level for testability) ───────────────────────────
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Return a human-readable file size string (e.g. ``'12.3 MB'``)."""
|
||||
size = float(size_bytes)
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if size < 1024.0:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.1f} TB"
|
||||
|
||||
|
||||
def _read_file_chunk(file_path: str, offset: int, length: int) -> bytes:
|
||||
"""Read *length* bytes from *file_path* starting at *offset*.
|
||||
|
||||
:raises IOError: If fewer bytes were read than expected (truncated file).
|
||||
"""
|
||||
with open(file_path, "rb") as fh:
|
||||
fh.seek(offset)
|
||||
data = fh.read(length)
|
||||
if len(data) != length:
|
||||
raise IOError(
|
||||
f"Short read from {file_path}: expected {length} bytes at "
|
||||
f"offset {offset}, got {len(data)} (file may be truncated)"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _compute_file_hashes(file_path: str, file_size: int) -> Dict[str, str]:
|
||||
"""Compute md5, sha1, and md5_10m in a single pass."""
|
||||
md5 = hashlib.md5()
|
||||
sha1 = hashlib.sha1()
|
||||
md5_10m = hashlib.md5()
|
||||
|
||||
need_10m = file_size > _MD5_10M_SIZE
|
||||
bytes_read = 0
|
||||
|
||||
with open(file_path, "rb") as fh:
|
||||
while True:
|
||||
chunk = fh.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
md5.update(chunk)
|
||||
sha1.update(chunk)
|
||||
if need_10m:
|
||||
remaining = _MD5_10M_SIZE - bytes_read
|
||||
if remaining > 0:
|
||||
md5_10m.update(chunk[:remaining])
|
||||
bytes_read += len(chunk)
|
||||
|
||||
full_md5 = md5.hexdigest()
|
||||
return {
|
||||
"md5": full_md5,
|
||||
"sha1": sha1.hexdigest(),
|
||||
# For small files the "10m" hash is just the full md5.
|
||||
"md5_10m": md5_10m.hexdigest() if need_10m else full_md5,
|
||||
}
|
||||
|
||||
|
||||
async def _run_with_concurrency(
|
||||
tasks: List[Callable[[], Awaitable[None]]],
|
||||
concurrency: int,
|
||||
) -> None:
|
||||
"""Run a list of thunks with a bounded number in flight at once."""
|
||||
if concurrency < 1:
|
||||
concurrency = 1
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _wrap(thunk: Callable[[], Awaitable[None]]) -> None:
|
||||
async with sem:
|
||||
await thunk()
|
||||
|
||||
await asyncio.gather(*(_wrap(t) for t in tasks))
|
||||
@@ -1,473 +0,0 @@
|
||||
"""QQ Bot inline keyboards + approval / update-prompt senders.
|
||||
|
||||
QQ Bot v2 supports attaching inline keyboards to outbound messages. When a
|
||||
user clicks a button, the platform dispatches an ``INTERACTION_CREATE``
|
||||
gateway event containing the button's ``data`` payload. The bot must ACK the
|
||||
interaction promptly via ``PUT /interactions/{id}`` or the user sees an
|
||||
error indicator on the button.
|
||||
|
||||
This module provides:
|
||||
|
||||
- :class:`InlineKeyboard` + button dataclasses — serialized into the
|
||||
``keyboard`` field of the outbound message body.
|
||||
- :func:`build_approval_keyboard` — 3-button ✅ once / ⭐ always / ❌ deny
|
||||
keyboard for tool-approval flows.
|
||||
- :func:`build_update_prompt_keyboard` — Yes/No keyboard for update confirms.
|
||||
- :func:`parse_approval_button_data` / :func:`parse_update_prompt_button_data`
|
||||
— decode the ``button_data`` payload from ``INTERACTION_CREATE``.
|
||||
- :class:`ApprovalRequest` + :class:`ApprovalSender` — high-level helper that
|
||||
builds an approval message with keyboard and posts it to a c2c / group chat.
|
||||
|
||||
``button_data`` formats::
|
||||
|
||||
approve:<session_key>:<decision> # decision = allow-once|allow-always|deny
|
||||
update_prompt:<answer> # answer = y|n
|
||||
|
||||
Ported from WideLee's qqbot-agent-sdk v1.2.2 (``approval.py`` + ``dto.py``
|
||||
keyboard types). Authorship preserved via Co-authored-by.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── button_data prefixes + patterns ──────────────────────────────────
|
||||
|
||||
APPROVAL_BUTTON_PREFIX = "approve:"
|
||||
UPDATE_PROMPT_PREFIX = "update_prompt:"
|
||||
|
||||
# Pattern: approve:<session_key>:<decision>
|
||||
# session_key may itself contain colons (e.g. agent:main:qqbot:c2c:OPENID),
|
||||
# so the session_key group is greedy but trails the decision.
|
||||
_APPROVAL_DATA_RE = re.compile(
|
||||
r"^approve:(.+):(allow-once|allow-always|deny)$"
|
||||
)
|
||||
|
||||
# Pattern: update_prompt:y | update_prompt:n
|
||||
_UPDATE_PROMPT_RE = re.compile(r"^update_prompt:(y|n)$")
|
||||
|
||||
|
||||
# ── Keyboard dataclasses ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class KeyboardButtonPermission:
|
||||
"""Button permission metadata. ``type=2`` means all users can click."""
|
||||
type: int = 2
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"type": self.type}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyboardButtonAction:
|
||||
"""What happens when the button is clicked.
|
||||
|
||||
:param type: ``1`` (Callback — triggers ``INTERACTION_CREATE``) or
|
||||
``2`` (Link — opens a URL).
|
||||
:param data: Payload delivered in ``data.resolved.button_data`` when
|
||||
``type=1``.
|
||||
:param permission: :class:`KeyboardButtonPermission`.
|
||||
:param click_limit: Max clicks per user (``1`` = single-use).
|
||||
"""
|
||||
type: int
|
||||
data: str
|
||||
permission: KeyboardButtonPermission = field(
|
||||
default_factory=KeyboardButtonPermission
|
||||
)
|
||||
click_limit: int = 1
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": self.type,
|
||||
"data": self.data,
|
||||
"permission": self.permission.to_dict(),
|
||||
"click_limit": self.click_limit,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyboardButtonRenderData:
|
||||
"""Visual rendering of a button.
|
||||
|
||||
:param label: Pre-click label.
|
||||
:param visited_label: Post-click label (button stays greyed in place).
|
||||
:param style: ``0`` = grey, ``1`` = blue.
|
||||
"""
|
||||
label: str
|
||||
visited_label: str
|
||||
style: int = 1
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"label": self.label,
|
||||
"visited_label": self.visited_label,
|
||||
"style": self.style,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyboardButton:
|
||||
"""One button in a keyboard.
|
||||
|
||||
:param group_id: Buttons sharing a ``group_id`` are mutually exclusive —
|
||||
clicking one greys the rest.
|
||||
"""
|
||||
id: str
|
||||
render_data: KeyboardButtonRenderData
|
||||
action: KeyboardButtonAction
|
||||
group_id: str = "default"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"render_data": self.render_data.to_dict(),
|
||||
"action": self.action.to_dict(),
|
||||
"group_id": self.group_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyboardRow:
|
||||
buttons: List[KeyboardButton] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"buttons": [b.to_dict() for b in self.buttons]}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyboardContent:
|
||||
rows: List[KeyboardRow] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"rows": [r.to_dict() for r in self.rows]}
|
||||
|
||||
|
||||
@dataclass
|
||||
class InlineKeyboard:
|
||||
"""Top-level keyboard payload — goes into ``MessageToCreate.keyboard``."""
|
||||
content: KeyboardContent = field(default_factory=KeyboardContent)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"content": self.content.to_dict()}
|
||||
|
||||
|
||||
# ── INTERACTION_CREATE parsing ───────────────────────────────────────
|
||||
|
||||
def parse_approval_button_data(button_data: str) -> Optional[tuple[str, str]]:
|
||||
"""Parse approval ``button_data`` into ``(session_key, decision)``.
|
||||
|
||||
:param button_data: Raw ``data.resolved.button_data`` from
|
||||
``INTERACTION_CREATE``.
|
||||
:returns: ``(session_key, decision)`` or ``None`` if not an approval button.
|
||||
"""
|
||||
m = _APPROVAL_DATA_RE.match(button_data or "")
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1), m.group(2)
|
||||
|
||||
|
||||
def parse_update_prompt_button_data(button_data: str) -> Optional[str]:
|
||||
"""Parse update-prompt ``button_data`` into ``'y'`` or ``'n'``."""
|
||||
m = _UPDATE_PROMPT_RE.match(button_data or "")
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1)
|
||||
|
||||
|
||||
# ── Keyboard builders ────────────────────────────────────────────────
|
||||
|
||||
def _make_callback_button(
|
||||
btn_id: str,
|
||||
label: str,
|
||||
visited_label: str,
|
||||
data: str,
|
||||
style: int,
|
||||
group_id: str,
|
||||
) -> KeyboardButton:
|
||||
return KeyboardButton(
|
||||
id=btn_id,
|
||||
render_data=KeyboardButtonRenderData(
|
||||
label=label,
|
||||
visited_label=visited_label,
|
||||
style=style,
|
||||
),
|
||||
action=KeyboardButtonAction(type=1, data=data),
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
|
||||
def build_approval_keyboard(session_key: str) -> InlineKeyboard:
|
||||
"""Build the 3-button approval keyboard.
|
||||
|
||||
Layout: ``[✅ 允许一次] [⭐ 始终允许] [❌ 拒绝]`` — all three share
|
||||
``group_id='approval'`` so clicking one greys out the rest.
|
||||
|
||||
:param session_key: Embedded into ``button_data`` so the decision
|
||||
routes back to the right pending approval.
|
||||
"""
|
||||
return InlineKeyboard(
|
||||
content=KeyboardContent(
|
||||
rows=[
|
||||
KeyboardRow(buttons=[
|
||||
_make_callback_button(
|
||||
btn_id="allow",
|
||||
label="✅ 允许一次",
|
||||
visited_label="已允许",
|
||||
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-once",
|
||||
style=1,
|
||||
group_id="approval",
|
||||
),
|
||||
_make_callback_button(
|
||||
btn_id="always",
|
||||
label="⭐ 始终允许",
|
||||
visited_label="已始终允许",
|
||||
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-always",
|
||||
style=1,
|
||||
group_id="approval",
|
||||
),
|
||||
_make_callback_button(
|
||||
btn_id="deny",
|
||||
label="❌ 拒绝",
|
||||
visited_label="已拒绝",
|
||||
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:deny",
|
||||
style=0,
|
||||
group_id="approval",
|
||||
),
|
||||
]),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_update_prompt_keyboard() -> InlineKeyboard:
|
||||
"""Build a Yes/No keyboard for update confirmation prompts."""
|
||||
return InlineKeyboard(
|
||||
content=KeyboardContent(
|
||||
rows=[
|
||||
KeyboardRow(buttons=[
|
||||
_make_callback_button(
|
||||
btn_id="yes",
|
||||
label="✓ 确认",
|
||||
visited_label="已确认",
|
||||
data=f"{UPDATE_PROMPT_PREFIX}y",
|
||||
style=1,
|
||||
group_id="update_prompt",
|
||||
),
|
||||
_make_callback_button(
|
||||
btn_id="no",
|
||||
label="✗ 取消",
|
||||
visited_label="已取消",
|
||||
data=f"{UPDATE_PROMPT_PREFIX}n",
|
||||
style=0,
|
||||
group_id="update_prompt",
|
||||
),
|
||||
]),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ── ApprovalRequest + text builder ───────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ApprovalRequest:
|
||||
"""Structured approval-request display data.
|
||||
|
||||
:param session_key: Routes the decision back to the waiting caller.
|
||||
:param title: Short title at the top.
|
||||
:param description: Optional longer description.
|
||||
:param command_preview: Command text (exec approvals).
|
||||
:param cwd: Working directory (exec approvals).
|
||||
:param tool_name: Tool name (plugin approvals).
|
||||
:param severity: ``'critical' | 'info' | ''``.
|
||||
:param timeout_sec: Seconds until the approval expires.
|
||||
"""
|
||||
session_key: str
|
||||
title: str
|
||||
description: str = ""
|
||||
command_preview: str = ""
|
||||
cwd: str = ""
|
||||
tool_name: str = ""
|
||||
severity: str = ""
|
||||
timeout_sec: int = 120
|
||||
|
||||
|
||||
def build_approval_text(req: ApprovalRequest) -> str:
|
||||
"""Render an :class:`ApprovalRequest` into the message body (markdown)."""
|
||||
if req.command_preview or req.cwd:
|
||||
return _build_exec_text(req)
|
||||
return _build_plugin_text(req)
|
||||
|
||||
|
||||
def _build_exec_text(req: ApprovalRequest) -> str:
|
||||
lines: List[str] = ["🔐 **命令执行审批**", ""]
|
||||
if req.command_preview:
|
||||
preview = req.command_preview[:300]
|
||||
lines.append(f"```\n{preview}\n```")
|
||||
if req.cwd:
|
||||
lines.append(f"📁 目录: {req.cwd}")
|
||||
if req.title and req.title != req.command_preview:
|
||||
lines.append(f"📋 {req.title}")
|
||||
if req.description:
|
||||
lines.append(f"📝 {req.description}")
|
||||
lines.append("")
|
||||
lines.append(f"⏱️ 超时: {req.timeout_sec} 秒")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_plugin_text(req: ApprovalRequest) -> str:
|
||||
icon = (
|
||||
"🔴" if req.severity == "critical"
|
||||
else "🔵" if req.severity == "info"
|
||||
else "🟡"
|
||||
)
|
||||
lines: List[str] = [f"{icon} **审批请求**", ""]
|
||||
lines.append(f"📋 {req.title}")
|
||||
if req.description:
|
||||
lines.append(f"📝 {req.description}")
|
||||
if req.tool_name:
|
||||
lines.append(f"🔧 工具: {req.tool_name}")
|
||||
lines.append("")
|
||||
lines.append(f"⏱️ 超时: {req.timeout_sec} 秒")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── ApprovalSender ───────────────────────────────────────────────────
|
||||
|
||||
PostMessageFn = Callable[..., Awaitable[Dict[str, Any]]]
|
||||
"""Signature of an async POST to ``/v2/{users|groups}/{id}/messages``.
|
||||
|
||||
Implementations accept a body dict and return the raw API response.
|
||||
"""
|
||||
|
||||
|
||||
class ApprovalSender:
|
||||
"""Send an approval-request message with an inline keyboard.
|
||||
|
||||
Decoupled from the adapter via callables so it can be unit-tested in
|
||||
isolation. Pass the adapter's ``_send_message_with_keyboard`` helper
|
||||
(or any equivalent) as ``post_message``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
post_c2c: PostMessageFn,
|
||||
post_group: PostMessageFn,
|
||||
log_tag: str = "QQBot",
|
||||
) -> None:
|
||||
self._post_c2c = post_c2c
|
||||
self._post_group = post_group
|
||||
self._log_tag = log_tag
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: str,
|
||||
req: ApprovalRequest,
|
||||
msg_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Send an approval message to *chat_id*.
|
||||
|
||||
:param chat_type: ``'c2c'`` or ``'group'``.
|
||||
:param chat_id: User openid or group openid.
|
||||
:param req: :class:`ApprovalRequest`.
|
||||
:param msg_id: Reply-to message id (required for passive messages).
|
||||
:returns: ``True`` on success, ``False`` on failure.
|
||||
"""
|
||||
text = build_approval_text(req)
|
||||
keyboard = build_approval_keyboard(req.session_key)
|
||||
|
||||
logger.info(
|
||||
"[%s] Sending approval request to %s:%s (session=%.20s…)",
|
||||
self._log_tag, chat_type, chat_id, req.session_key,
|
||||
)
|
||||
|
||||
try:
|
||||
if chat_type == "c2c":
|
||||
await self._post_c2c(chat_id, text, msg_id, keyboard)
|
||||
elif chat_type == "group":
|
||||
await self._post_group(chat_id, text, msg_id, keyboard)
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] Approval: unsupported chat_type %r",
|
||||
self._log_tag, chat_type,
|
||||
)
|
||||
return False
|
||||
logger.info(
|
||||
"[%s] Approval message sent to %s:%s",
|
||||
self._log_tag, chat_type, chat_id,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[%s] Failed to send approval message to %s:%s: %s",
|
||||
self._log_tag, chat_type, chat_id, exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# ── INTERACTION_CREATE event shape ───────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class InteractionEvent:
|
||||
"""Parsed ``INTERACTION_CREATE`` event payload.
|
||||
|
||||
See https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
|
||||
"""
|
||||
id: str = ""
|
||||
"""Interaction event id — required for the ``PUT /interactions/{id}`` ACK."""
|
||||
|
||||
type: int = 0
|
||||
"""Event type code (``11`` = message button)."""
|
||||
|
||||
chat_type: int = 0
|
||||
"""``0`` = guild, ``1`` = group, ``2`` = c2c."""
|
||||
|
||||
scene: str = ""
|
||||
"""``'guild'`` | ``'group'`` | ``'c2c'`` — human-readable scene."""
|
||||
|
||||
group_openid: str = ""
|
||||
group_member_openid: str = ""
|
||||
user_openid: str = ""
|
||||
channel_id: str = ""
|
||||
guild_id: str = ""
|
||||
|
||||
button_data: str = ""
|
||||
button_id: str = ""
|
||||
resolver_user_id: str = ""
|
||||
|
||||
@property
|
||||
def operator_openid(self) -> str:
|
||||
"""Best available operator openid (group → member; c2c → user)."""
|
||||
return (
|
||||
self.group_member_openid
|
||||
or self.user_openid
|
||||
or self.resolver_user_id
|
||||
)
|
||||
|
||||
|
||||
def parse_interaction_event(raw: Dict[str, Any]) -> InteractionEvent:
|
||||
"""Parse a raw ``INTERACTION_CREATE`` dispatch payload (``d``)."""
|
||||
data_raw = raw.get("data") or {}
|
||||
resolved = data_raw.get("resolved") or {}
|
||||
scene_code = int(raw.get("chat_type", 0) or 0)
|
||||
scene = {0: "guild", 1: "group", 2: "c2c"}.get(scene_code, "")
|
||||
return InteractionEvent(
|
||||
id=str(raw.get("id", "")),
|
||||
type=int(data_raw.get("type", 0) or 0),
|
||||
chat_type=scene_code,
|
||||
scene=scene,
|
||||
group_openid=str(raw.get("group_openid", "")),
|
||||
group_member_openid=str(raw.get("group_member_openid", "")),
|
||||
user_openid=str(raw.get("user_openid", "")),
|
||||
channel_id=str(raw.get("channel_id", "")),
|
||||
guild_id=str(raw.get("guild_id", "")),
|
||||
button_data=str(resolved.get("button_data", "")),
|
||||
button_id=str(resolved.get("button_id", "")),
|
||||
resolver_user_id=str(resolved.get("user_id", "")),
|
||||
)
|
||||
@@ -1887,12 +1887,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)
|
||||
|
||||
if not is_dm and bot_uid:
|
||||
# Check allowed channels — if set, only respond in these channels (whitelist)
|
||||
allowed_channels = self._slack_allowed_channels()
|
||||
if allowed_channels and channel_id not in allowed_channels:
|
||||
logger.debug("[Slack] Ignoring message in non-allowed channel: %s", channel_id)
|
||||
return
|
||||
|
||||
if channel_id in self._slack_free_response_channels():
|
||||
pass # Free-response channel — always process
|
||||
elif not self._slack_require_mention():
|
||||
@@ -2930,19 +2924,3 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
if s:
|
||||
return {part.strip() for part in s.split(",") if part.strip()}
|
||||
return set()
|
||||
|
||||
def _slack_allowed_channels(self) -> set:
|
||||
"""Return the whitelist of channel IDs the bot will respond in.
|
||||
|
||||
When non-empty, messages from channels NOT in this set are silently
|
||||
ignored — even if the bot is @mentioned. DMs are never filtered.
|
||||
Empty set means no restriction (fully backward compatible).
|
||||
"""
|
||||
raw = self.config.extra.get("allowed_channels")
|
||||
if raw is None:
|
||||
raw = os.getenv("SLACK_ALLOWED_CHANNELS", "")
|
||||
if isinstance(raw, list):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return {part.strip() for part in raw.split(",") if part.strip()}
|
||||
return set()
|
||||
|
||||
+19
-102
@@ -86,22 +86,6 @@ from gateway.platforms.telegram_network import (
|
||||
)
|
||||
from utils import atomic_replace
|
||||
|
||||
_TELEGRAM_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
_TELEGRAM_IMAGE_MIME_TO_EXT = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
}
|
||||
_TELEGRAM_IMAGE_EXT_TO_MIME = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
}
|
||||
|
||||
|
||||
def check_telegram_requirements() -> bool:
|
||||
"""Check if Telegram dependencies are available."""
|
||||
@@ -369,14 +353,10 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
@classmethod
|
||||
def _message_thread_id_for_typing(cls, thread_id: Optional[str]) -> Optional[int]:
|
||||
# Asymmetric with _message_thread_id_for_send on purpose. Telegram's
|
||||
# sendMessage and sendChatAction treat thread id "1" (the forum General
|
||||
# topic) differently: sends reject message_thread_id=1 and must omit it,
|
||||
# but sendChatAction needs message_thread_id=1 to place the typing
|
||||
# bubble in the General topic (omitting it hides the bubble entirely
|
||||
# from the client's view of that topic). Preserve the real id here —
|
||||
# sends still map "1" → None via _message_thread_id_for_send.
|
||||
if not thread_id:
|
||||
# Mirrors _message_thread_id_for_send: the General forum topic (thread id
|
||||
# "1") is represented as "no thread id" on the wire. User-created topics
|
||||
# keep their real id so typing stays scoped to that topic.
|
||||
if not thread_id or str(thread_id) == cls._GENERAL_TOPIC_THREAD_ID:
|
||||
return None
|
||||
return int(thread_id)
|
||||
|
||||
@@ -2775,20 +2755,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||||
|
||||
def _telegram_allowed_chats(self) -> set[str]:
|
||||
"""Return the whitelist of group/supergroup chat IDs the bot will respond in.
|
||||
|
||||
When non-empty, group messages from chats NOT in this set are silently
|
||||
ignored — even if the bot is @mentioned. DMs are never filtered.
|
||||
Empty set means no restriction (fully backward compatible).
|
||||
"""
|
||||
raw = self.config.extra.get("allowed_chats")
|
||||
if raw is None:
|
||||
raw = os.getenv("TELEGRAM_ALLOWED_CHATS", "")
|
||||
if isinstance(raw, list):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||||
|
||||
def _telegram_ignored_threads(self) -> set[int]:
|
||||
raw = self.config.extra.get("ignored_threads")
|
||||
if raw is None:
|
||||
@@ -2937,16 +2903,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""Apply Telegram group trigger rules.
|
||||
|
||||
DMs remain unrestricted. Group/supergroup messages are accepted when:
|
||||
- the chat passes the ``allowed_chats`` whitelist (when set)
|
||||
- the chat is explicitly allowlisted in ``free_response_chats``
|
||||
- ``require_mention`` is disabled
|
||||
- the message replies to the bot
|
||||
- the bot is @mentioned
|
||||
- the text/caption matches a configured regex wake-word pattern
|
||||
|
||||
When ``allowed_chats`` is non-empty, it acts as a hard gate — messages
|
||||
from any chat not in the list are ignored regardless of the other
|
||||
rules. When ``require_mention`` is enabled, slash commands are not given
|
||||
When ``require_mention`` is enabled, slash commands are not given
|
||||
special treatment — they must pass the same mention/reply checks
|
||||
as any other group message. Users can still trigger commands via
|
||||
the Telegram bot menu (``/command@botname``) or by explicitly
|
||||
@@ -2955,14 +2918,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
if not self._is_group_chat(message):
|
||||
return True
|
||||
# allowed_chats check (whitelist — must pass before other gating).
|
||||
# When set, group messages from chats NOT in this whitelist are
|
||||
# silently ignored, even if @mentioned. DMs are already excluded above.
|
||||
allowed = self._telegram_allowed_chats()
|
||||
if allowed:
|
||||
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
|
||||
if chat_id_str not in allowed:
|
||||
return False
|
||||
thread_id = getattr(message, "message_thread_id", None)
|
||||
if thread_id is not None:
|
||||
try:
|
||||
@@ -3284,59 +3239,10 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
_, ext = os.path.splitext(original_filename)
|
||||
ext = ext.lower()
|
||||
|
||||
# Normalize mime_type for robust comparisons (some clients send
|
||||
# uppercase like "IMAGE/PNG").
|
||||
doc_mime = (doc.mime_type or "").lower()
|
||||
|
||||
# If no extension from filename, reverse-lookup from MIME type
|
||||
if not ext and doc_mime:
|
||||
ext = _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, "")
|
||||
if not ext:
|
||||
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
|
||||
ext = mime_to_ext.get(doc_mime, "")
|
||||
|
||||
# Check file size early so image documents cannot bypass the
|
||||
# document size limit by taking the image path.
|
||||
MAX_DOC_BYTES = 20 * 1024 * 1024
|
||||
if not doc.file_size or doc.file_size > MAX_DOC_BYTES:
|
||||
event.text = (
|
||||
"The document is too large or its size could not be verified. "
|
||||
"Maximum: 20 MB."
|
||||
)
|
||||
logger.info("[Telegram] Document too large: %s bytes", doc.file_size)
|
||||
await self.handle_message(event)
|
||||
return
|
||||
|
||||
# Telegram may deliver screenshots/photos as documents. If the
|
||||
# payload is actually an image, route it through the image cache
|
||||
# and batching path instead of rejecting it as a document.
|
||||
if ext in _TELEGRAM_IMAGE_EXTENSIONS or doc_mime.startswith("image/"):
|
||||
file_obj = await doc.get_file()
|
||||
image_bytes = await file_obj.download_as_bytearray()
|
||||
image_ext = ext if ext in _TELEGRAM_IMAGE_EXTENSIONS else _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, ".jpg")
|
||||
try:
|
||||
cached_path = cache_image_from_bytes(bytes(image_bytes), ext=image_ext)
|
||||
except ValueError as e:
|
||||
logger.warning("[Telegram] Failed to cache image document: %s", e, exc_info=True)
|
||||
event.text = (
|
||||
f"Image document '{original_filename or doc_mime or ext or 'unknown'}' "
|
||||
"could not be read as an image."
|
||||
)
|
||||
await self.handle_message(event)
|
||||
return
|
||||
|
||||
event.message_type = MessageType.PHOTO
|
||||
event.media_urls = [cached_path]
|
||||
event.media_types = [doc_mime if doc_mime.startswith("image/") else _TELEGRAM_IMAGE_EXT_TO_MIME.get(image_ext, "image/jpeg")]
|
||||
logger.info("[Telegram] Cached user image-document at %s", cached_path)
|
||||
|
||||
media_group_id = getattr(msg, "media_group_id", None)
|
||||
if media_group_id:
|
||||
await self._queue_media_group_event(str(media_group_id), event)
|
||||
else:
|
||||
batch_key = self._photo_batch_key(event, msg)
|
||||
self._enqueue_photo_event(batch_key, event)
|
||||
return
|
||||
if not ext and doc.mime_type:
|
||||
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
|
||||
ext = mime_to_ext.get(doc.mime_type, "")
|
||||
|
||||
if not ext and doc.mime_type:
|
||||
video_mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}
|
||||
@@ -3364,6 +3270,17 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
await self.handle_message(event)
|
||||
return
|
||||
|
||||
# Check file size (Telegram Bot API limit: 20 MB)
|
||||
MAX_DOC_BYTES = 20 * 1024 * 1024
|
||||
if not doc.file_size or doc.file_size > MAX_DOC_BYTES:
|
||||
event.text = (
|
||||
"The document is too large or its size could not be verified. "
|
||||
"Maximum: 20 MB."
|
||||
)
|
||||
logger.info("[Telegram] Document too large: %s bytes", doc.file_size)
|
||||
await self.handle_message(event)
|
||||
return
|
||||
|
||||
# Download and cache
|
||||
file_obj = await doc.get_file()
|
||||
doc_bytes = await file_obj.download_as_bytearray()
|
||||
|
||||
@@ -59,29 +59,6 @@ DEFAULT_PORT = 8644
|
||||
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
|
||||
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
|
||||
|
||||
# Hostnames/IP literals that only serve connections originating on the same
|
||||
# machine. Anything else is treated as a public bind for safety-rail purposes.
|
||||
_LOOPBACK_HOSTS = frozenset({
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"::1",
|
||||
"ip6-localhost",
|
||||
"ip6-loopback",
|
||||
})
|
||||
|
||||
|
||||
def _is_loopback_host(host: str) -> bool:
|
||||
"""True when `host` binds only to the local machine.
|
||||
|
||||
Covers IPv4 loopback, the standard `localhost` alias, IPv6 loopback in
|
||||
both bracketed and bare form, and the common Debian-style aliases. Any
|
||||
falsy value (empty string, None) is conservatively treated as non-loopback
|
||||
because an unset host usually means the platform-default public bind.
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
return host.strip().lower() in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def check_webhook_requirements() -> bool:
|
||||
"""Check if webhook adapter dependencies are available."""
|
||||
@@ -149,17 +126,6 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'."
|
||||
)
|
||||
|
||||
# Safety rail: refuse to start if INSECURE_NO_AUTH is combined with a
|
||||
# non-loopback bind. The escape hatch is for local testing only;
|
||||
# serving an unauthenticated route on a public interface is a
|
||||
# deployment-grade footgun we'd rather crash early than ship.
|
||||
if secret == _INSECURE_NO_AUTH and not _is_loopback_host(self._host):
|
||||
raise ValueError(
|
||||
f"[webhook] Route '{name}' uses INSECURE_NO_AUTH secret "
|
||||
f"but is bound to non-loopback host '{self._host}'. "
|
||||
f"INSECURE_NO_AUTH is for local testing only. "
|
||||
f"Refusing to start to prevent accidental exposure."
|
||||
)
|
||||
# deliver_only routes bypass the agent — the POST body becomes a
|
||||
# direct push notification via the configured delivery target.
|
||||
# Validate up-front so misconfiguration surfaces at startup rather
|
||||
|
||||
@@ -37,7 +37,6 @@ import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -1563,11 +1562,12 @@ def qr_scan_for_bot_info(
|
||||
print(" Fetching configuration results...", end="", flush=True)
|
||||
|
||||
# ── Step 3: Poll for result ──
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
import time
|
||||
deadline = time.time() + timeout_seconds
|
||||
query_url = f"{_QR_QUERY_URL}?scode={urllib.parse.quote(scode)}"
|
||||
poll_count = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
req = urllib.request.Request(query_url, headers={"User-Agent": "HermesAgent/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
|
||||
+22
-81
@@ -23,7 +23,6 @@ import re
|
||||
import secrets
|
||||
import struct
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -33,8 +32,6 @@ from urllib.parse import quote, urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WEIXIN_COPY_LINE_WIDTH = 120
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
@@ -551,21 +548,17 @@ async def _upload_ciphertext(
|
||||
Accepts either a constructed CDN URL (from upload_param) or a direct
|
||||
upload_full_url — both use POST with the raw ciphertext as the body.
|
||||
"""
|
||||
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
|
||||
# "Timeout context manager should be used inside a task" errors when
|
||||
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
|
||||
async def _do_upload() -> str:
|
||||
async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}) as response:
|
||||
if response.status == 200:
|
||||
encrypted_param = response.headers.get("x-encrypted-param")
|
||||
if encrypted_param:
|
||||
await response.read()
|
||||
return encrypted_param
|
||||
raw = await response.text()
|
||||
raise RuntimeError(f"CDN upload missing x-encrypted-param header: {raw[:200]}")
|
||||
timeout = aiohttp.ClientTimeout(total=120)
|
||||
async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=timeout) as response:
|
||||
if response.status == 200:
|
||||
encrypted_param = response.headers.get("x-encrypted-param")
|
||||
if encrypted_param:
|
||||
await response.read()
|
||||
return encrypted_param
|
||||
raw = await response.text()
|
||||
raise RuntimeError(f"CDN upload HTTP {response.status}: {raw[:200]}")
|
||||
return await asyncio.wait_for(_do_upload(), timeout=120)
|
||||
raise RuntimeError(f"CDN upload missing x-encrypted-param header: {raw[:200]}")
|
||||
raw = await response.text()
|
||||
raise RuntimeError(f"CDN upload HTTP {response.status}: {raw[:200]}")
|
||||
|
||||
|
||||
async def _download_bytes(
|
||||
@@ -574,13 +567,10 @@ async def _download_bytes(
|
||||
url: str,
|
||||
timeout_seconds: float = 60.0,
|
||||
) -> bytes:
|
||||
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
|
||||
# "Timeout context manager should be used inside a task" errors.
|
||||
async def _do_download() -> bytes:
|
||||
async with session.get(url) as response:
|
||||
response.raise_for_status()
|
||||
return await response.read()
|
||||
return await asyncio.wait_for(_do_download(), timeout=timeout_seconds)
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_seconds)
|
||||
async with session.get(url, timeout=timeout) as response:
|
||||
response.raise_for_status()
|
||||
return await response.read()
|
||||
|
||||
|
||||
_WEIXIN_CDN_ALLOWLIST: frozenset[str] = frozenset(
|
||||
@@ -734,46 +724,6 @@ def _normalize_markdown_blocks(content: str) -> str:
|
||||
return "\n".join(result).strip()
|
||||
|
||||
|
||||
def _wrap_copy_friendly_lines_for_weixin(content: str) -> str:
|
||||
"""Wrap long display lines that are hard to copy in WeChat clients."""
|
||||
if not content:
|
||||
return content
|
||||
|
||||
wrapped: List[str] = []
|
||||
in_code_block = False
|
||||
|
||||
for raw_line in content.splitlines():
|
||||
line = raw_line.rstrip()
|
||||
stripped = line.strip()
|
||||
|
||||
if _FENCE_RE.match(stripped):
|
||||
in_code_block = not in_code_block
|
||||
wrapped.append(line)
|
||||
continue
|
||||
|
||||
if (
|
||||
in_code_block
|
||||
or len(line) <= WEIXIN_COPY_LINE_WIDTH
|
||||
or not stripped
|
||||
or stripped.startswith("|")
|
||||
or _TABLE_RULE_RE.match(stripped)
|
||||
):
|
||||
wrapped.append(line)
|
||||
continue
|
||||
|
||||
wrapped_lines = textwrap.wrap(
|
||||
line,
|
||||
width=WEIXIN_COPY_LINE_WIDTH,
|
||||
break_long_words=False,
|
||||
break_on_hyphens=False,
|
||||
replace_whitespace=False,
|
||||
drop_whitespace=True,
|
||||
)
|
||||
wrapped.extend(wrapped_lines or [line])
|
||||
|
||||
return "\n".join(wrapped).strip()
|
||||
|
||||
|
||||
def _split_markdown_blocks(content: str) -> List[str]:
|
||||
if not content:
|
||||
return []
|
||||
@@ -1087,11 +1037,11 @@ async def qr_login(
|
||||
except Exception as _qr_exc:
|
||||
print(f"(终端二维码渲染失败: {_qr_exc},请直接打开上面的二维码链接)")
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
deadline = time.time() + timeout_seconds
|
||||
current_base_url = ILINK_BASE_URL
|
||||
refresh_count = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
status_resp = await _api_get(
|
||||
session,
|
||||
@@ -1266,12 +1216,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
logger.debug("[%s] Token lock unavailable (non-fatal): %s", self.name, exc)
|
||||
|
||||
self._poll_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector())
|
||||
# Disable aiohttp's built-in ClientTimeout (total=None) to prevent
|
||||
# "Timeout context manager should be used inside a task" errors when
|
||||
# send() is invoked via asyncio.run_coroutine_threadsafe() from cron.
|
||||
# Timeout is managed externally via asyncio.wait_for() in _api_post/_api_get.
|
||||
_no_aiohttp_timeout = aiohttp.ClientTimeout(total=None, connect=None, sock_connect=None, sock_read=None)
|
||||
self._send_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector(), timeout=_no_aiohttp_timeout)
|
||||
self._send_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector())
|
||||
self._token_store.restore(self._account_id)
|
||||
self._poll_task = asyncio.create_task(self._poll_loop(), name="weixin-poll")
|
||||
self._mark_connected()
|
||||
@@ -1879,14 +1824,10 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
raise ValueError(f"Blocked unsafe URL (SSRF protection): {url}")
|
||||
|
||||
assert self._send_session is not None
|
||||
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
|
||||
# "Timeout context manager should be used inside a task" errors.
|
||||
async def _do_fetch():
|
||||
async with self._send_session.get(url) as response:
|
||||
response.raise_for_status()
|
||||
return await response.read()
|
||||
data = await asyncio.wait_for(_do_fetch(), timeout=30)
|
||||
suffix = Path(url.split("?", 1)[0]).suffix or ".bin"
|
||||
async with self._send_session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as response:
|
||||
response.raise_for_status()
|
||||
data = await response.read()
|
||||
suffix = Path(url.split("?", 1)[0]).suffix or ".bin"
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as handle:
|
||||
handle.write(data)
|
||||
return handle.name
|
||||
@@ -2065,7 +2006,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
def format_message(self, content: Optional[str]) -> str:
|
||||
if content is None:
|
||||
return ""
|
||||
return _wrap_copy_friendly_lines_for_weixin(_normalize_markdown_blocks(content))
|
||||
return _normalize_markdown_blocks(content)
|
||||
|
||||
|
||||
async def send_weixin_direct(
|
||||
|
||||
@@ -21,7 +21,6 @@ import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
@@ -55,77 +54,19 @@ def _kill_port_process(port: int) -> None:
|
||||
except subprocess.SubprocessError:
|
||||
pass
|
||||
else:
|
||||
# Try fuser first (Linux), fall back to lsof (macOS / WSL2)
|
||||
killed = False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["fuser", f"{port}/tcp"],
|
||||
result = subprocess.run(
|
||||
["fuser", f"{port}/tcp"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
subprocess.run(
|
||||
["fuser", "-k", f"{port}/tcp"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
subprocess.run(
|
||||
["fuser", "-k", f"{port}/tcp"],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
killed = True
|
||||
except FileNotFoundError:
|
||||
pass # fuser not installed
|
||||
|
||||
if not killed:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["lsof", "-ti", f":{port}"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
for pid_str in result.stdout.strip().splitlines():
|
||||
try:
|
||||
os.kill(int(pid_str), signal.SIGTERM)
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
pass # lsof not installed either
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _kill_stale_bridge_by_pidfile(session_path: Path) -> None:
|
||||
"""Kill a bridge process recorded in a PID file from a previous run.
|
||||
|
||||
The bridge writes ``bridge.pid`` into the session directory when it
|
||||
starts. If the gateway crashed without a clean shutdown the old bridge
|
||||
process becomes orphaned — this helper finds and kills it.
|
||||
"""
|
||||
pid_file = session_path / "bridge.pid"
|
||||
if not pid_file.exists():
|
||||
return
|
||||
try:
|
||||
pid = int(pid_file.read_text().strip())
|
||||
except (ValueError, OSError, TypeError):
|
||||
try:
|
||||
pid_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
try:
|
||||
os.kill(pid, 0) # check existence
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
logger.info("[whatsapp] Killed stale bridge PID %d from pidfile", pid)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
try:
|
||||
pid_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _write_bridge_pidfile(session_path: Path, pid: int) -> None:
|
||||
"""Write the bridge PID to a file for later cleanup."""
|
||||
try:
|
||||
(session_path / "bridge.pid").write_text(str(pid))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _terminate_bridge_process(proc, *, force: bool = False) -> None:
|
||||
"""Terminate the bridge process using process-tree semantics where possible."""
|
||||
if _IS_WINDOWS:
|
||||
@@ -217,7 +158,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
# WhatsApp message limits — practical UX limit, not protocol max.
|
||||
# WhatsApp allows ~65K but long messages are unreadable on mobile.
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
DEFAULT_REPLY_PREFIX = "⚕ *Hermes Agent*\n────────────\n"
|
||||
|
||||
# Default bridge location relative to the hermes-agent install
|
||||
_DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
|
||||
@@ -253,25 +193,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
# notification before the normal "✓ whatsapp disconnected" fires.
|
||||
self._shutting_down: bool = False
|
||||
|
||||
def _effective_reply_prefix(self) -> str:
|
||||
"""Return the prefix the Node bridge will add in self-chat mode."""
|
||||
whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
|
||||
if whatsapp_mode != "self-chat":
|
||||
return ""
|
||||
if self._reply_prefix is not None:
|
||||
return self._reply_prefix.replace("\\n", "\n")
|
||||
env_prefix = os.getenv("WHATSAPP_REPLY_PREFIX")
|
||||
if env_prefix is not None:
|
||||
return env_prefix.replace("\\n", "\n")
|
||||
return self.DEFAULT_REPLY_PREFIX
|
||||
|
||||
def _outgoing_chunk_limit(self) -> int:
|
||||
"""Reserve room for the bridge-side prefix so final WhatsApp text fits."""
|
||||
prefix_len = len(self._effective_reply_prefix())
|
||||
# Keep enough space for truncate_message's pagination indicator and
|
||||
# code-fence repair even if a user configures a very long prefix.
|
||||
return max(1024, self.MAX_MESSAGE_LENGTH - prefix_len)
|
||||
|
||||
def _whatsapp_require_mention(self) -> bool:
|
||||
configured = self.config.extra.get("require_mention")
|
||||
if configured is not None:
|
||||
@@ -507,7 +428,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
pass # Bridge not running, start a new one
|
||||
|
||||
# Kill any orphaned bridge from a previous gateway run
|
||||
_kill_stale_bridge_by_pidfile(self._session_path)
|
||||
_kill_port_process(self._bridge_port)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -539,7 +459,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
preexec_fn=None if _IS_WINDOWS else os.setsid,
|
||||
env=bridge_env,
|
||||
)
|
||||
_write_bridge_pidfile(self._session_path, self._bridge_process.pid)
|
||||
|
||||
# Wait for the bridge to connect to WhatsApp.
|
||||
# Phase 1: wait for the HTTP server to come up (up to 15s).
|
||||
@@ -690,12 +609,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
# Bridge was not started by us, don't kill it
|
||||
print(f"[{self.name}] Disconnecting (external bridge left running)")
|
||||
|
||||
# Clean up PID file
|
||||
try:
|
||||
(self._session_path / "bridge.pid").unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Cancel the poll task explicitly
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
@@ -800,7 +713,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
|
||||
# Format and chunk the message
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted, self._outgoing_chunk_limit())
|
||||
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
|
||||
|
||||
last_message_id = None
|
||||
for chunk in chunks:
|
||||
|
||||
+54
-496
@@ -258,18 +258,13 @@ def _ensure_ssl_certs() -> None:
|
||||
return
|
||||
|
||||
def _home_target_env_var(platform_name: str) -> str:
|
||||
"""Return the configured home-target env var for a platform.
|
||||
"""Return the configured home-target env var for a platform."""
|
||||
from cron.scheduler import _HOME_TARGET_ENV_VARS
|
||||
|
||||
Consults built-in ``_HOME_TARGET_ENV_VARS`` first, then the plugin
|
||||
registry via ``cron.scheduler._resolve_home_env_var``, then falls back
|
||||
to ``<PLATFORM>_HOME_CHANNEL`` for unknown names.
|
||||
"""
|
||||
from cron.scheduler import _resolve_home_env_var
|
||||
|
||||
resolved = _resolve_home_env_var(platform_name)
|
||||
if resolved:
|
||||
return resolved
|
||||
return f"{platform_name.upper()}_HOME_CHANNEL"
|
||||
return _HOME_TARGET_ENV_VARS.get(
|
||||
platform_name.lower(),
|
||||
f"{platform_name.upper()}_HOME_CHANNEL",
|
||||
)
|
||||
|
||||
|
||||
def _home_thread_env_var(platform_name: str) -> str:
|
||||
@@ -304,36 +299,6 @@ _env_path = _hermes_home / '.env'
|
||||
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env')
|
||||
|
||||
|
||||
def _reload_runtime_env_preserving_config_authority() -> None:
|
||||
"""Reload .env for fresh credentials without letting stale .env override config.
|
||||
|
||||
Gateway processes are long-lived, so per-turn code reloads ~/.hermes/.env to
|
||||
pick up rotated API keys. config.yaml remains authoritative for agent budget
|
||||
settings such as agent.max_turns; otherwise a stale HERMES_MAX_ITERATIONS in
|
||||
.env can replace the startup bridge on later turns.
|
||||
"""
|
||||
load_hermes_dotenv(
|
||||
hermes_home=_hermes_home,
|
||||
project_env=Path(__file__).resolve().parents[1] / '.env',
|
||||
)
|
||||
|
||||
config_path = _hermes_home / 'config.yaml'
|
||||
if not config_path.exists():
|
||||
return
|
||||
try:
|
||||
import yaml as _yaml
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = _yaml.safe_load(f) or {}
|
||||
from hermes_cli.config import _expand_env_vars
|
||||
cfg = _expand_env_vars(cfg)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
agent_cfg = cfg.get("agent", {})
|
||||
if isinstance(agent_cfg, dict) and "max_turns" in agent_cfg:
|
||||
os.environ["HERMES_MAX_ITERATIONS"] = str(agent_cfg["max_turns"])
|
||||
|
||||
|
||||
_DOCKER_VOLUME_SPEC_RE = re.compile(r"^(?P<host>.+):(?P<container>/[^:]+?)(?::(?P<options>[^:]+))?$")
|
||||
_DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS = {"/output", "/outputs"}
|
||||
|
||||
@@ -503,22 +468,22 @@ try:
|
||||
_network_cfg = (_cfg if '_cfg' in dir() else {}).get("network", {})
|
||||
if isinstance(_network_cfg, dict) and _network_cfg.get("force_ipv4"):
|
||||
apply_ipv4_preference(force=True)
|
||||
except Exception as _bootstrap_exc:
|
||||
print(f" Warning: IPv4 preference application failed: {_bootstrap_exc}", file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Validate config structure early — log warnings so gateway operators see problems
|
||||
try:
|
||||
from hermes_cli.config import print_config_warnings
|
||||
print_config_warnings()
|
||||
except Exception as _bootstrap_exc:
|
||||
print(f" Warning: config validation failed: {_bootstrap_exc}", file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Warn if user has deprecated MESSAGING_CWD / TERMINAL_CWD in .env
|
||||
try:
|
||||
from hermes_cli.config import warn_deprecated_cwd_env_vars
|
||||
warn_deprecated_cwd_env_vars()
|
||||
except Exception as _bootstrap_exc:
|
||||
print(f" Warning: deprecation check failed: {_bootstrap_exc}", file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Gateway runs in quiet mode - suppress debug output and use cwd directly (no temp dirs)
|
||||
os.environ["HERMES_QUIET"] = "1"
|
||||
@@ -648,11 +613,7 @@ def _try_resolve_fallback_provider() -> dict | None:
|
||||
explicit_base_url=entry.get("base_url"),
|
||||
explicit_api_key=entry.get("api_key"),
|
||||
)
|
||||
logger.info(
|
||||
"Fallback provider resolved: %s model=%s",
|
||||
runtime.get("provider"),
|
||||
entry.get("model"),
|
||||
)
|
||||
logger.info("Fallback provider resolved: %s", runtime.get("provider"))
|
||||
return {
|
||||
"api_key": runtime.get("api_key"),
|
||||
"base_url": runtime.get("base_url"),
|
||||
@@ -661,7 +622,6 @@ def _try_resolve_fallback_provider() -> dict | None:
|
||||
"command": runtime.get("command"),
|
||||
"args": list(runtime.get("args") or []),
|
||||
"credential_pool": runtime.get("credential_pool"),
|
||||
"model": entry.get("model"),
|
||||
}
|
||||
except Exception as fb_exc:
|
||||
logger.debug("Fallback entry %s failed: %s", entry.get("provider"), fb_exc)
|
||||
@@ -1025,26 +985,6 @@ def _normalize_empty_agent_response(
|
||||
return response
|
||||
|
||||
|
||||
def _should_clear_resume_pending_after_turn(agent_result: dict) -> bool:
|
||||
"""Return True only when a gateway turn really completed successfully.
|
||||
|
||||
Restart recovery uses ``resume_pending`` as a durable marker for sessions
|
||||
interrupted during gateway drain. A soft interrupt can still bubble out as
|
||||
a syntactically normal agent result with an empty final response; clearing
|
||||
the marker in that case loses the recovery signal and startup auto-resume
|
||||
has nothing to schedule.
|
||||
"""
|
||||
if not isinstance(agent_result, dict):
|
||||
return False
|
||||
if agent_result.get("interrupted"):
|
||||
return False
|
||||
if agent_result.get("failed") or agent_result.get("partial") or agent_result.get("error"):
|
||||
return False
|
||||
if agent_result.get("completed") is False:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class GatewayRunner:
|
||||
"""
|
||||
Main gateway controller.
|
||||
@@ -1126,13 +1066,6 @@ class GatewayRunner:
|
||||
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] = {}
|
||||
# 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
|
||||
# and _parse_session_key can't recover thread_id. Capped so it
|
||||
# cannot grow unbounded over a long-running gateway lifetime.
|
||||
self._session_sources: "OrderedDict[str, SessionSource]" = OrderedDict()
|
||||
self._session_sources_max = 512
|
||||
|
||||
# Cache AIAgent instances per session to preserve prompt caching.
|
||||
# Without this, a new AIAgent is created per message, rebuilding the
|
||||
@@ -1227,7 +1160,6 @@ class GatewayRunner:
|
||||
retention_days=int(_ckpt_cfg.get("retention_days", 7)),
|
||||
min_interval_hours=int(_ckpt_cfg.get("min_interval_hours", 24)),
|
||||
delete_orphans=bool(_ckpt_cfg.get("delete_orphans", True)),
|
||||
max_total_size_mb=int(_ckpt_cfg.get("max_total_size_mb", 500)),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("checkpoint auto-maintenance skipped: %s", exc)
|
||||
@@ -1671,14 +1603,6 @@ class GatewayRunner:
|
||||
)
|
||||
|
||||
runtime_kwargs = _resolve_runtime_agent_kwargs()
|
||||
runtime_model = runtime_kwargs.pop("model", None)
|
||||
if runtime_model:
|
||||
logger.info(
|
||||
"Runtime provider supplied explicit model override: %s -> %s",
|
||||
model,
|
||||
runtime_model,
|
||||
)
|
||||
model = runtime_model
|
||||
if override and resolved_session_key:
|
||||
model, runtime_kwargs = self._apply_session_model_override(
|
||||
resolved_session_key, model, runtime_kwargs
|
||||
@@ -2506,9 +2430,6 @@ class GatewayRunner:
|
||||
e,
|
||||
)
|
||||
|
||||
if source is None:
|
||||
source = self._get_cached_session_source(session_key)
|
||||
|
||||
if source is not None:
|
||||
platform_str = source.platform.value
|
||||
chat_id = str(source.chat_id)
|
||||
@@ -2536,14 +2457,6 @@ class GatewayRunner:
|
||||
if not adapter:
|
||||
continue
|
||||
|
||||
platform_cfg = self.config.platforms.get(platform)
|
||||
if platform_cfg is not None and not platform_cfg.gateway_restart_notification:
|
||||
logger.info(
|
||||
"Shutdown notification suppressed for active session: %s has gateway_restart_notification=false",
|
||||
platform_str,
|
||||
)
|
||||
continue
|
||||
|
||||
# Include thread_id if present so the message lands in the
|
||||
# correct forum topic / thread.
|
||||
metadata = {"thread_id": thread_id} if thread_id else None
|
||||
@@ -2569,24 +2482,11 @@ class GatewayRunner:
|
||||
platform_str, chat_id, e,
|
||||
)
|
||||
|
||||
# Snapshot adapters up front: adapter.send() can hit a fatal error
|
||||
# path that pops the adapter from self.adapters (see _handle_fatal
|
||||
# elsewhere), which would otherwise trigger
|
||||
# ``RuntimeError: dictionary changed size during iteration`` —
|
||||
# observed in a user report during gateway shutdown.
|
||||
for platform, adapter in list(self.adapters.items()):
|
||||
for platform, adapter in self.adapters.items():
|
||||
home = self.config.get_home_channel(platform)
|
||||
if not home or not home.chat_id:
|
||||
continue
|
||||
|
||||
platform_cfg = self.config.platforms.get(platform)
|
||||
if platform_cfg is not None and not platform_cfg.gateway_restart_notification:
|
||||
logger.info(
|
||||
"Shutdown notification suppressed for home channel: %s has gateway_restart_notification=false",
|
||||
platform.value,
|
||||
)
|
||||
continue
|
||||
|
||||
dedup_key = (platform.value, str(home.chat_id), str(home.thread_id) if home.thread_id else None)
|
||||
if dedup_key in notified:
|
||||
continue
|
||||
@@ -2822,83 +2722,6 @@ class GatewayRunner:
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return True
|
||||
|
||||
# Drain-timeout reasons set by _stop_impl() when a still-running turn is
|
||||
# force-interrupted; "restart_interrupted" is set by
|
||||
# SessionStore.suspend_recently_active() on crash recovery (no
|
||||
# .clean_shutdown marker). All three mean "the agent was mid-turn and
|
||||
# we killed it" — eligible for startup auto-resume.
|
||||
_AUTO_RESUME_REASONS = frozenset(
|
||||
{"restart_timeout", "shutdown_timeout", "restart_interrupted"}
|
||||
)
|
||||
|
||||
def _schedule_resume_pending_sessions(self) -> int:
|
||||
"""Auto-continue fresh restart-interrupted sessions after startup.
|
||||
|
||||
``resume_pending`` already preserves the transcript AND the existing
|
||||
``_is_resume_pending`` branch in ``_handle_message_with_agent``
|
||||
injects a reason-aware recovery system note on the next turn. This
|
||||
method closes the UX gap by synthesizing that next turn once
|
||||
adapters are back online — the event text is empty so the existing
|
||||
injection path owns the wording and we never double up.
|
||||
|
||||
Adapters that are not yet ready (adapter missing from
|
||||
``self.adapters``) are skipped silently; their sessions stay
|
||||
``resume_pending`` and will auto-resume on the next real user
|
||||
message, or on the next gateway startup.
|
||||
"""
|
||||
window = _auto_continue_freshness_window()
|
||||
try:
|
||||
with self.session_store._lock: # noqa: SLF001 — snapshot under lock
|
||||
self.session_store._ensure_loaded_locked() # noqa: SLF001
|
||||
candidates = [
|
||||
entry for entry in self.session_store._entries.values() # noqa: SLF001
|
||||
if entry.resume_pending
|
||||
and not entry.suspended
|
||||
and entry.origin is not None
|
||||
and entry.resume_reason in self._AUTO_RESUME_REASONS
|
||||
]
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to enumerate resume-pending sessions: %s", exc)
|
||||
return 0
|
||||
|
||||
now = datetime.now()
|
||||
scheduled = 0
|
||||
for entry in candidates:
|
||||
marker = entry.last_resume_marked_at or entry.updated_at
|
||||
if marker is not None and (now - marker).total_seconds() > window:
|
||||
continue
|
||||
|
||||
source = entry.origin
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if adapter is None:
|
||||
logger.debug(
|
||||
"Skipping auto-resume for %s: adapter not ready for %s",
|
||||
entry.session_key,
|
||||
getattr(source.platform, "value", source.platform),
|
||||
)
|
||||
continue
|
||||
|
||||
# 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.
|
||||
event = MessageEvent(
|
||||
text="",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
internal=True,
|
||||
)
|
||||
task = asyncio.create_task(adapter.handle_message(event))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
scheduled += 1
|
||||
|
||||
if scheduled:
|
||||
logger.info(
|
||||
"Scheduled auto-resume for %d restart-interrupted session(s)",
|
||||
scheduled,
|
||||
)
|
||||
return scheduled
|
||||
|
||||
async def start(self) -> bool:
|
||||
"""
|
||||
Start the gateway and all configured platform adapters.
|
||||
@@ -2923,29 +2746,6 @@ class GatewayRunner:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Redaction status: ON by default (#17691). Surface a prominent
|
||||
# warning if an operator has explicitly opted out so they don't
|
||||
# forget the downgrade is active — the redactor snapshots its
|
||||
# state at import time, so this log line is the source of truth
|
||||
# for this process's lifetime.
|
||||
try:
|
||||
_redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true")
|
||||
_redact_on = _redact_raw.lower() in ("1", "true", "yes", "on")
|
||||
if _redact_on:
|
||||
logger.info(
|
||||
"Secret redaction: ENABLED (tool output, logs, and chat "
|
||||
"responses are scrubbed before delivery)"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Secret redaction: DISABLED (HERMES_REDACT_SECRETS=%s). "
|
||||
"API keys and tokens may appear verbatim in chat output, "
|
||||
"session JSONs, and logs. Set security.redact_secrets: true "
|
||||
"in config.yaml to re-enable.",
|
||||
_redact_raw,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
_profile = get_active_profile_name()
|
||||
@@ -3310,12 +3110,6 @@ class GatewayRunner:
|
||||
skip_targets=skip_home_targets,
|
||||
)
|
||||
|
||||
# Automatically continue fresh sessions that were interrupted by the
|
||||
# previous gateway restart/shutdown. The resume_pending flag is cleared
|
||||
# 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()
|
||||
|
||||
# Drain any recovered process watchers (from crash recovery checkpoint)
|
||||
try:
|
||||
from tools.process_registry import process_registry
|
||||
@@ -3829,29 +3623,6 @@ class GatewayRunner:
|
||||
if interval < 1.0:
|
||||
interval = 1.0 # sanity floor — tighter than this is a footgun
|
||||
|
||||
# Read max_spawn config to limit concurrent kanban tasks
|
||||
max_spawn = kanban_cfg.get("max_spawn", None)
|
||||
if max_spawn is not None:
|
||||
logger.info(f"kanban dispatcher: max_spawn={max_spawn}")
|
||||
|
||||
raw_failure_limit = kanban_cfg.get("failure_limit", _kb.DEFAULT_FAILURE_LIMIT)
|
||||
try:
|
||||
failure_limit = int(raw_failure_limit)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"kanban dispatcher: invalid kanban.failure_limit=%r; using default %d",
|
||||
raw_failure_limit,
|
||||
_kb.DEFAULT_FAILURE_LIMIT,
|
||||
)
|
||||
failure_limit = _kb.DEFAULT_FAILURE_LIMIT
|
||||
if failure_limit < 1:
|
||||
logger.warning(
|
||||
"kanban dispatcher: kanban.failure_limit=%r is below 1; using default %d",
|
||||
raw_failure_limit,
|
||||
_kb.DEFAULT_FAILURE_LIMIT,
|
||||
)
|
||||
failure_limit = _kb.DEFAULT_FAILURE_LIMIT
|
||||
|
||||
# Initial delay so the gateway finishes wiring adapters before the
|
||||
# dispatcher spawns workers (those workers may hit gateway notify
|
||||
# subscriptions etc.). Matches the notifier watcher's delay.
|
||||
@@ -3880,12 +3651,7 @@ class GatewayRunner:
|
||||
_kb.init_db(board=slug) # idempotent, handles first-run
|
||||
except Exception:
|
||||
pass
|
||||
return _kb.dispatch_once(
|
||||
conn,
|
||||
board=slug,
|
||||
max_spawn=max_spawn,
|
||||
failure_limit=failure_limit,
|
||||
)
|
||||
return _kb.dispatch_once(conn, board=slug)
|
||||
except Exception:
|
||||
logger.exception("kanban dispatcher: tick failed on board %s", slug)
|
||||
return None
|
||||
@@ -5969,7 +5735,6 @@ class GatewayRunner:
|
||||
|
||||
if event.media_urls and event.message_type == MessageType.DOCUMENT:
|
||||
import mimetypes as _mimetypes
|
||||
from tools.credential_files import to_agent_visible_cache_path
|
||||
|
||||
_TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"}
|
||||
for i, path in enumerate(event.media_urls):
|
||||
@@ -5990,21 +5755,16 @@ class GatewayRunner:
|
||||
display_name = parts[2] if len(parts) >= 3 else basename
|
||||
display_name = re.sub(r'[^\w.\- ]', '_', display_name)
|
||||
|
||||
# Translate host cache path to in-container path if running under Docker backend.
|
||||
# This ensures the agent receives a path it can open inside its sandbox, as the
|
||||
# cache directories are auto-mounted at /root/.hermes/cache/* by get_cache_directory_mounts().
|
||||
agent_path = to_agent_visible_cache_path(path)
|
||||
|
||||
if mtype.startswith("text/"):
|
||||
context_note = (
|
||||
f"[The user sent a text document: '{display_name}'. "
|
||||
f"Its content has been included below. "
|
||||
f"The file is also saved at: {agent_path}]"
|
||||
f"The file is also saved at: {path}]"
|
||||
)
|
||||
else:
|
||||
context_note = (
|
||||
f"[The user sent a document: '{display_name}'. "
|
||||
f"The file is saved at: {agent_path}. "
|
||||
f"The file is saved at: {path}. "
|
||||
f"Ask the user what they'd like you to do with it.]"
|
||||
)
|
||||
message_text = f"{context_note}\n\n{message_text}"
|
||||
@@ -6069,41 +5829,6 @@ class GatewayRunner:
|
||||
return []
|
||||
return list(pending_native.pop(session_key, []) or [])
|
||||
|
||||
def _cache_session_source(self, session_key: str, source) -> None:
|
||||
if not session_key or source is None:
|
||||
return
|
||||
cached_sources = getattr(self, "_session_sources", None)
|
||||
if cached_sources is None:
|
||||
cached_sources = OrderedDict()
|
||||
self._session_sources = cached_sources
|
||||
try:
|
||||
cached_sources[session_key] = dataclasses.replace(source)
|
||||
except Exception:
|
||||
logger.debug("Failed to cache live session source for %s", session_key, exc_info=True)
|
||||
return
|
||||
# LRU: mark as most-recently-used and trim to max size.
|
||||
try:
|
||||
cached_sources.move_to_end(session_key)
|
||||
max_size = getattr(self, "_session_sources_max", 512)
|
||||
while len(cached_sources) > max_size:
|
||||
cached_sources.popitem(last=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_cached_session_source(self, session_key: str):
|
||||
if not session_key:
|
||||
return None
|
||||
cached_sources = getattr(self, "_session_sources", None)
|
||||
if not cached_sources:
|
||||
return None
|
||||
source = cached_sources.get(session_key)
|
||||
if source is not None:
|
||||
try:
|
||||
cached_sources.move_to_end(session_key)
|
||||
except Exception:
|
||||
pass
|
||||
return source
|
||||
|
||||
async def _handle_message_with_agent(self, event, source, _quick_key: str, run_generation: int):
|
||||
"""Inner handler that runs under the _running_agents sentinel guard."""
|
||||
_msg_start_time = time.time()
|
||||
@@ -6118,7 +5843,6 @@ class GatewayRunner:
|
||||
# Get or create session
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
session_key = session_entry.session_key
|
||||
self._cache_session_source(session_key, source)
|
||||
if self._is_telegram_topic_lane(source):
|
||||
try:
|
||||
binding = self._session_db.get_telegram_topic_binding(
|
||||
@@ -6593,10 +6317,6 @@ class GatewayRunner:
|
||||
_werr,
|
||||
)
|
||||
finally:
|
||||
# Evict the cached agent so the next turn
|
||||
# rebuilds its system prompt from current
|
||||
# SOUL.md, memory, and skills.
|
||||
self._evict_cached_agent(session_key)
|
||||
self._cleanup_agent_resources(_hyg_agent)
|
||||
|
||||
except Exception as e:
|
||||
@@ -6755,7 +6475,7 @@ class GatewayRunner:
|
||||
# shutdown) — the turn ran to completion, so recovery
|
||||
# succeeded and subsequent messages should no longer receive
|
||||
# the restart-interruption system note.
|
||||
if session_key and _should_clear_resume_pending_after_turn(agent_result):
|
||||
if session_key:
|
||||
self._clear_restart_failure_count(session_key)
|
||||
try:
|
||||
self.session_store.clear_resume_pending(session_key)
|
||||
@@ -8336,27 +8056,6 @@ class GatewayRunner:
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# /goal — persistent cross-turn goals (Ralph-style loop)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
def _goal_max_turns_from_config(self) -> int:
|
||||
"""Resolve the configured /goal turn budget for gateway sessions.
|
||||
|
||||
GatewayRunner.config is a GatewayConfig dataclass, not the full
|
||||
user config mapping. Top-level config blocks such as ``goals`` are
|
||||
therefore only available through hermes_cli.config.load_config().
|
||||
"""
|
||||
try:
|
||||
goals_cfg = (
|
||||
(self.config or {}).get("goals", {})
|
||||
if isinstance(self.config, dict)
|
||||
else getattr(self.config, "goals", {}) or {}
|
||||
)
|
||||
if not goals_cfg:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
goals_cfg = (load_config() or {}).get("goals") or {}
|
||||
return int(goals_cfg.get("max_turns", 20) or 20)
|
||||
except Exception:
|
||||
return 20
|
||||
|
||||
def _get_goal_manager_for_event(self, event: "MessageEvent"):
|
||||
"""Return a GoalManager bound to the session for this gateway event.
|
||||
|
||||
@@ -8376,7 +8075,15 @@ class GatewayRunner:
|
||||
sid = getattr(session_entry, "session_id", None) or ""
|
||||
if not sid:
|
||||
return None, None
|
||||
max_turns = self._goal_max_turns_from_config()
|
||||
try:
|
||||
goals_cfg = (
|
||||
(self.config or {}).get("goals", {})
|
||||
if isinstance(self.config, dict)
|
||||
else getattr(self.config, "goals", {}) or {}
|
||||
)
|
||||
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
|
||||
except Exception:
|
||||
max_turns = 20
|
||||
return GoalManager(session_id=sid, default_max_turns=max_turns), session_entry
|
||||
|
||||
async def _handle_goal_command(self, event: "MessageEvent") -> str:
|
||||
@@ -8476,7 +8183,15 @@ class GatewayRunner:
|
||||
if not sid:
|
||||
return
|
||||
|
||||
max_turns = self._goal_max_turns_from_config()
|
||||
try:
|
||||
goals_cfg = (
|
||||
(self.config or {}).get("goals", {})
|
||||
if isinstance(self.config, dict)
|
||||
else getattr(self.config, "goals", {}) or {}
|
||||
)
|
||||
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
|
||||
except Exception:
|
||||
max_turns = 20
|
||||
|
||||
mgr = GoalManager(session_id=sid, default_max_turns=max_turns)
|
||||
if not mgr.is_active():
|
||||
@@ -9019,12 +8734,6 @@ class GatewayRunner:
|
||||
from urllib.parse import quote as _quote
|
||||
|
||||
try:
|
||||
# Capture [[as_document]] before extract_media strips it, so the
|
||||
# dispatch partition below can route image-extension files
|
||||
# through send_document (preserving bytes) instead of
|
||||
# send_multiple_images (Telegram sendPhoto recompresses to ~1280px).
|
||||
force_document_attachments = "[[as_document]]" in response
|
||||
|
||||
media_files, _ = adapter.extract_media(response)
|
||||
_, cleaned = adapter.extract_images(response)
|
||||
local_files, _ = adapter.extract_local_files(cleaned)
|
||||
@@ -9037,24 +8746,19 @@ class GatewayRunner:
|
||||
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||
|
||||
# Partition out images so they can be sent as a single batch
|
||||
# (e.g. Signal's multi-attachment RPC). When [[as_document]] was
|
||||
# set, image-extension files skip the photo path and route to
|
||||
# send_document below — preserving original bytes.
|
||||
# (e.g. Signal's multi-attachment RPC)
|
||||
image_paths: list = []
|
||||
non_image_media: list = []
|
||||
for media_path, is_voice in media_files:
|
||||
ext = Path(media_path).suffix.lower()
|
||||
if (ext in _IMAGE_EXTS
|
||||
and not is_voice
|
||||
and not force_document_attachments):
|
||||
if ext in _IMAGE_EXTS and not is_voice:
|
||||
image_paths.append(media_path)
|
||||
else:
|
||||
non_image_media.append((media_path, is_voice))
|
||||
|
||||
non_image_local: list = []
|
||||
for file_path in local_files:
|
||||
if (Path(file_path).suffix.lower() in _IMAGE_EXTS
|
||||
and not force_document_attachments):
|
||||
if Path(file_path).suffix.lower() in _IMAGE_EXTS:
|
||||
image_paths.append(file_path)
|
||||
else:
|
||||
non_image_local.append(file_path)
|
||||
@@ -9796,9 +9500,6 @@ class GatewayRunner:
|
||||
_aux_fail_model = getattr(compressor, "_last_aux_model_failure_model", None)
|
||||
_aux_fail_err = getattr(compressor, "_last_aux_model_failure_error", None)
|
||||
finally:
|
||||
# Evict cached agent so next turn rebuilds system prompt
|
||||
# from current files (SOUL.md, memory, etc.).
|
||||
self._evict_cached_agent(session_key)
|
||||
self._cleanup_agent_resources(tmp_agent)
|
||||
lines = [f"🗜️ {summary['headline']}"]
|
||||
if focus_topic:
|
||||
@@ -11672,14 +11373,6 @@ class GatewayRunner:
|
||||
)
|
||||
return None
|
||||
|
||||
platform_cfg = self.config.platforms.get(platform)
|
||||
if platform_cfg is not None and not platform_cfg.gateway_restart_notification:
|
||||
logger.info(
|
||||
"Restart notification suppressed: %s has gateway_restart_notification=false",
|
||||
platform_str,
|
||||
)
|
||||
return None
|
||||
|
||||
metadata = {"thread_id": thread_id} if thread_id else None
|
||||
result = await adapter.send(
|
||||
str(chat_id),
|
||||
@@ -11731,14 +11424,6 @@ class GatewayRunner:
|
||||
if not home or not home.chat_id:
|
||||
continue
|
||||
|
||||
platform_cfg = self.config.platforms.get(platform)
|
||||
if platform_cfg is not None and not platform_cfg.gateway_restart_notification:
|
||||
logger.info(
|
||||
"Home-channel startup notification suppressed: %s has gateway_restart_notification=false",
|
||||
platform.value,
|
||||
)
|
||||
continue
|
||||
|
||||
target = (platform.value, str(home.chat_id), str(home.thread_id) if home.thread_id else None)
|
||||
if target in skipped or target in delivered:
|
||||
continue
|
||||
@@ -12009,10 +11694,6 @@ class GatewayRunner:
|
||||
exc,
|
||||
)
|
||||
|
||||
cached_source = self._get_cached_session_source(session_key)
|
||||
if cached_source is not None:
|
||||
return cached_source
|
||||
|
||||
_parsed = _parse_session_key(session_key)
|
||||
if _parsed:
|
||||
derived_platform = _parsed["platform"]
|
||||
@@ -12256,7 +11937,6 @@ class GatewayRunner:
|
||||
# Add more here as new baked-at-construction config settings are added.
|
||||
_CACHE_BUSTING_CONFIG_KEYS: tuple = (
|
||||
("model", "context_length"),
|
||||
("model", "max_tokens"),
|
||||
("compression", "enabled"),
|
||||
("compression", "threshold"),
|
||||
("compression", "target_ratio"),
|
||||
@@ -13114,24 +12794,6 @@ class GatewayRunner:
|
||||
last_tool = [None] # Mutable container for tracking in closure
|
||||
last_progress_msg = [None] # Track last message for dedup
|
||||
repeat_count = [0] # How many times the same message repeated
|
||||
|
||||
# Auto-cleanup of temporary progress bubbles (Telegram + any adapter
|
||||
# that implements ``delete_message``). When enabled via
|
||||
# ``display.platforms.<platform>.cleanup_progress: true``, message IDs
|
||||
# from the tool-progress / "Still working..." / status-callback bubbles
|
||||
# are collected here and deleted after the final response lands.
|
||||
# Failed runs skip cleanup so the bubbles remain as breadcrumbs.
|
||||
_cleanup_progress = bool(
|
||||
resolve_display_setting(user_config, platform_key, "cleanup_progress")
|
||||
)
|
||||
_cleanup_adapter = self.adapters.get(source.platform) if _cleanup_progress else None
|
||||
if _cleanup_adapter is not None and (
|
||||
type(_cleanup_adapter).delete_message is BasePlatformAdapter.delete_message
|
||||
):
|
||||
# Adapter doesn't support deletion — silently disable.
|
||||
_cleanup_progress = False
|
||||
_cleanup_adapter = None
|
||||
_cleanup_msg_ids: List[str] = []
|
||||
# First-touch onboarding latch: fires at most once per run, even if
|
||||
# several tools exceed the threshold.
|
||||
long_tool_hint_fired = [False]
|
||||
@@ -13254,19 +12916,12 @@ class GatewayRunner:
|
||||
# - Slack DM threading needs event_message_id fallback (reply thread)
|
||||
# - Telegram uses message_thread_id only for forum topics; passing a
|
||||
# normal DM/group message id as thread_id causes send failures
|
||||
# - Feishu only honors reply_in_thread when sending a reply, so topic
|
||||
# progress uses the triggering event message as the reply target
|
||||
# - Other platforms should use explicit source.thread_id only
|
||||
if source.platform == Platform.SLACK:
|
||||
_progress_thread_id = source.thread_id or event_message_id
|
||||
else:
|
||||
_progress_thread_id = source.thread_id
|
||||
_progress_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
|
||||
_progress_reply_to = (
|
||||
event_message_id
|
||||
if source.platform == Platform.FEISHU and source.thread_id and event_message_id
|
||||
else None
|
||||
)
|
||||
|
||||
async def send_progress_messages():
|
||||
if not progress_queue:
|
||||
@@ -13380,40 +13035,17 @@ class GatewayRunner:
|
||||
adapter.name,
|
||||
)
|
||||
can_edit = False
|
||||
_flood_result = await adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=msg,
|
||||
reply_to=_progress_reply_to,
|
||||
metadata=_progress_metadata,
|
||||
)
|
||||
if (
|
||||
_cleanup_progress
|
||||
and getattr(_flood_result, "success", False)
|
||||
and getattr(_flood_result, "message_id", None)
|
||||
):
|
||||
_cleanup_msg_ids.append(str(_flood_result.message_id))
|
||||
await adapter.send(chat_id=source.chat_id, content=msg, metadata=_progress_metadata)
|
||||
else:
|
||||
if can_edit:
|
||||
# First tool: send all accumulated text as new message
|
||||
full_text = "\n".join(progress_lines)
|
||||
result = await adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=full_text,
|
||||
reply_to=_progress_reply_to,
|
||||
metadata=_progress_metadata,
|
||||
)
|
||||
result = await adapter.send(chat_id=source.chat_id, content=full_text, metadata=_progress_metadata)
|
||||
else:
|
||||
# Editing unsupported: send just this line
|
||||
result = await adapter.send(
|
||||
chat_id=source.chat_id,
|
||||
content=msg,
|
||||
reply_to=_progress_reply_to,
|
||||
metadata=_progress_metadata,
|
||||
)
|
||||
result = await adapter.send(chat_id=source.chat_id, content=msg, metadata=_progress_metadata)
|
||||
if result.success and result.message_id:
|
||||
progress_msg_id = result.message_id
|
||||
if _cleanup_progress:
|
||||
_cleanup_msg_ids.append(str(result.message_id))
|
||||
|
||||
_last_edit_ts = time.monotonic()
|
||||
|
||||
@@ -13511,23 +13143,13 @@ class GatewayRunner:
|
||||
# Bridge sync status_callback → async adapter.send for context pressure
|
||||
_status_adapter = self.adapters.get(source.platform)
|
||||
_status_chat_id = source.chat_id
|
||||
if source.platform == Platform.FEISHU and source.thread_id and event_message_id:
|
||||
# Feishu topics only keep messages inside the topic when they are
|
||||
# sent via the reply API with reply_in_thread=true. Status/interim,
|
||||
# approval, and stream-consumer paths usually only receive metadata,
|
||||
# so carry the triggering message id as a Feishu-specific fallback.
|
||||
_status_thread_metadata: Optional[Dict[str, Any]] = {
|
||||
"thread_id": _progress_thread_id,
|
||||
"reply_to_message_id": event_message_id,
|
||||
}
|
||||
else:
|
||||
_status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
|
||||
_status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
|
||||
|
||||
def _status_callback_sync(event_type: str, message: str) -> None:
|
||||
if not _status_adapter or not _run_still_current():
|
||||
return
|
||||
try:
|
||||
_fut = asyncio.run_coroutine_threadsafe(
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
_status_adapter.send(
|
||||
_status_chat_id,
|
||||
message,
|
||||
@@ -13535,16 +13157,6 @@ class GatewayRunner:
|
||||
),
|
||||
_loop_for_step,
|
||||
)
|
||||
if _cleanup_progress:
|
||||
def _track_status_id(fut) -> None:
|
||||
try:
|
||||
res = fut.result()
|
||||
except Exception:
|
||||
return
|
||||
mid = getattr(res, "message_id", None)
|
||||
if getattr(res, "success", False) and mid:
|
||||
_cleanup_msg_ids.append(str(mid))
|
||||
_fut.add_done_callback(_track_status_id)
|
||||
except Exception as _e:
|
||||
logger.debug("status_callback error (%s): %s", event_type, _e)
|
||||
|
||||
@@ -13578,9 +13190,13 @@ class GatewayRunner:
|
||||
combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip()
|
||||
|
||||
# Re-read .env and config for fresh credentials (gateway is long-lived,
|
||||
# keys may change without restart). Keep config.yaml authoritative for
|
||||
# runtime budget settings bridged into env vars.
|
||||
_reload_runtime_env_preserving_config_authority()
|
||||
# keys may change without restart).
|
||||
try:
|
||||
load_dotenv(_env_path, override=True, encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
load_dotenv(_env_path, override=True, encoding="latin-1")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
model, runtime_kwargs = self._resolve_session_agent_runtime(
|
||||
@@ -13671,7 +13287,7 @@ class GatewayRunner:
|
||||
adapter=_adapter,
|
||||
chat_id=source.chat_id,
|
||||
config=_consumer_cfg,
|
||||
metadata=_status_thread_metadata,
|
||||
metadata={"thread_id": _progress_thread_id} if _progress_thread_id else None,
|
||||
on_new_message=(
|
||||
(lambda: progress_queue.put(("__reset__",)))
|
||||
if progress_queue is not None
|
||||
@@ -14148,11 +13764,6 @@ class GatewayRunner:
|
||||
"messages": result.get("messages", []),
|
||||
"api_calls": result.get("api_calls", 0),
|
||||
"failed": result.get("failed", False),
|
||||
"partial": result.get("partial", False),
|
||||
"completed": result.get("completed"),
|
||||
"interrupted": result.get("interrupted", False),
|
||||
"interrupt_message": result.get("interrupt_message"),
|
||||
"error": result.get("error"),
|
||||
"compression_exhausted": result.get("compression_exhausted", False),
|
||||
"tools": tools_holder[0] or [],
|
||||
"history_offset": len(agent_history),
|
||||
@@ -14268,11 +13879,6 @@ class GatewayRunner:
|
||||
"last_reasoning": result.get("last_reasoning"),
|
||||
"messages": result_holder[0].get("messages", []) if result_holder[0] else [],
|
||||
"api_calls": result_holder[0].get("api_calls", 0) if result_holder[0] else 0,
|
||||
"completed": result_holder[0].get("completed") if result_holder[0] else None,
|
||||
"interrupted": result_holder[0].get("interrupted", False) if result_holder[0] else False,
|
||||
"partial": result_holder[0].get("partial", False) if result_holder[0] else False,
|
||||
"error": result_holder[0].get("error") if result_holder[0] else None,
|
||||
"interrupt_message": result_holder[0].get("interrupt_message") if result_holder[0] else None,
|
||||
"tools": tools_holder[0] or [],
|
||||
"history_offset": _effective_history_offset,
|
||||
"last_prompt_tokens": _last_prompt_toks,
|
||||
@@ -14411,17 +14017,11 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_notify_res = await _notify_adapter.send(
|
||||
await _notify_adapter.send(
|
||||
source.chat_id,
|
||||
f"⏳ Still working... ({_elapsed_mins} min elapsed{_status_detail})",
|
||||
metadata=_status_thread_metadata,
|
||||
)
|
||||
if (
|
||||
_cleanup_progress
|
||||
and getattr(_notify_res, "success", False)
|
||||
and getattr(_notify_res, "message_id", None)
|
||||
):
|
||||
_cleanup_msg_ids.append(str(_notify_res.message_id))
|
||||
except Exception as _ne:
|
||||
logger.debug("Long-running notification error: %s", _ne)
|
||||
|
||||
@@ -14895,49 +14495,7 @@ class GatewayRunner:
|
||||
_previewed,
|
||||
)
|
||||
response["already_sent"] = True
|
||||
|
||||
# Schedule deletion of tracked temporary progress bubbles after the
|
||||
# final response lands. Failed runs skip this so bubbles remain as
|
||||
# breadcrumbs for the user to see what work happened. Only fires on
|
||||
# adapters that support ``delete_message`` (see init above); failures
|
||||
# are swallowed — deletion is best-effort.
|
||||
if (
|
||||
_cleanup_progress
|
||||
and _cleanup_adapter is not None
|
||||
and _cleanup_msg_ids
|
||||
and session_key
|
||||
and isinstance(response, dict)
|
||||
and not response.get("failed")
|
||||
and hasattr(_cleanup_adapter, "register_post_delivery_callback")
|
||||
):
|
||||
_ids_snapshot = list(_cleanup_msg_ids)
|
||||
_chat_id_snapshot = source.chat_id
|
||||
_adapter_snapshot = _cleanup_adapter
|
||||
_loop_snapshot = asyncio.get_running_loop()
|
||||
|
||||
def _cleanup_temp_bubbles() -> None:
|
||||
async def _delete_all() -> None:
|
||||
for _mid in _ids_snapshot:
|
||||
try:
|
||||
await _adapter_snapshot.delete_message(
|
||||
_chat_id_snapshot, _mid
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(_delete_all(), _loop_snapshot)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
_cleanup_adapter.register_post_delivery_callback(
|
||||
session_key,
|
||||
_cleanup_temp_bubbles,
|
||||
generation=run_generation,
|
||||
)
|
||||
except Exception as _rpe:
|
||||
logger.debug("Post-delivery cleanup registration failed: %s", _rpe)
|
||||
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ Provides subcommands for:
|
||||
import os
|
||||
import sys
|
||||
|
||||
__version__ = "0.13.0"
|
||||
__release_date__ = "2026.5.7"
|
||||
__version__ = "0.12.0"
|
||||
__release_date__ = "2026.4.30"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
|
||||
@@ -70,9 +70,6 @@ Examples:
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes debug share Upload debug report for support
|
||||
hermes update Update to latest version
|
||||
hermes dashboard Start web UI dashboard (port 9119)
|
||||
hermes dashboard --stop Stop running dashboard processes
|
||||
hermes dashboard --status List running dashboard processes
|
||||
|
||||
For more help on a command:
|
||||
hermes <command> --help
|
||||
|
||||
+197
-499
@@ -418,7 +418,7 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
|
||||
# Auto-extend PROVIDER_REGISTRY with any api-key provider registered in
|
||||
# providers/ that is not already declared above. New providers only need a
|
||||
# plugins/model-providers/<name>/ plugin — no edits to this file required.
|
||||
# providers/*.py file — no edits to this file required.
|
||||
try:
|
||||
from providers import list_providers as _list_providers_for_registry
|
||||
for _pp in _list_providers_for_registry():
|
||||
@@ -780,121 +780,42 @@ def _auth_file_path() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def _global_auth_file_path() -> Optional[Path]:
|
||||
"""Return the global-root auth.json when the process is in profile mode.
|
||||
|
||||
Returns ``None`` when the profile and global root resolve to the same
|
||||
directory (classic mode, or custom HERMES_HOME that is not a profile).
|
||||
Used by read-only fallback paths so providers authed at the root are
|
||||
visible to profile processes that haven't configured them locally.
|
||||
|
||||
See issue #18594 follow-up (credential_pool shadowing).
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
global_root = get_default_hermes_root()
|
||||
except Exception:
|
||||
return None
|
||||
profile_home = get_hermes_home()
|
||||
try:
|
||||
if profile_home.resolve(strict=False) == global_root.resolve(strict=False):
|
||||
return None
|
||||
except Exception:
|
||||
if profile_home == global_root:
|
||||
return None
|
||||
# No pytest seat belt here: this is a pure read-only path, and
|
||||
# ``_load_global_auth_store()`` wraps the read in a try/except so an
|
||||
# unreadable global file can never break the profile process. The
|
||||
# write-side seat belt still lives on ``_auth_file_path()`` where it
|
||||
# belongs (that's what protects the real user's auth store from being
|
||||
# corrupted by a mis-configured test).
|
||||
return global_root / "auth.json"
|
||||
|
||||
|
||||
def _load_global_auth_store() -> Dict[str, Any]:
|
||||
"""Load the global-root auth store (read-only fallback).
|
||||
|
||||
Returns an empty dict when no global fallback exists (classic mode,
|
||||
or the global auth.json is absent). Never raises on missing file.
|
||||
|
||||
Seat belt: under pytest, refuses to read the real user's
|
||||
``~/.hermes/auth.json`` even when HERMES_HOME is set to a profile
|
||||
path. The hermetic conftest does not redirect ``HOME``, so
|
||||
``get_default_hermes_root()`` for a profile-shaped HERMES_HOME can
|
||||
still resolve to the real user's home on a dev machine. That would
|
||||
leak real credentials into tests. This guard uses the unmodified
|
||||
``HOME`` env var (what ``os.path.expanduser('~')`` would resolve to),
|
||||
not ``Path.home()``, because ``Path.home`` is sometimes monkeypatched
|
||||
by fixtures that want to relocate the global root to a tmp path.
|
||||
"""
|
||||
global_path = _global_auth_file_path()
|
||||
if global_path is None or not global_path.exists():
|
||||
return {}
|
||||
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:
|
||||
pass
|
||||
try:
|
||||
return _load_auth_store(global_path)
|
||||
except Exception:
|
||||
# A malformed global store must not break profile reads. The
|
||||
# profile's own auth store is still authoritative.
|
||||
return {}
|
||||
|
||||
|
||||
def _auth_lock_path() -> Path:
|
||||
return _auth_file_path().with_suffix(".lock")
|
||||
|
||||
|
||||
_auth_lock_holder = threading.local()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _file_lock(
|
||||
lock_path: Path,
|
||||
holder: threading.local,
|
||||
timeout_seconds: float,
|
||||
timeout_message: str,
|
||||
):
|
||||
"""Cross-process advisory flock helper.
|
||||
|
||||
Reentrant per-thread via ``holder.depth``. Falls back to a depth-only
|
||||
guard when neither ``fcntl`` nor ``msvcrt`` is available (rare).
|
||||
Callers supply their own ``threading.local`` so independent locks
|
||||
(e.g. profile auth.json vs shared Nous store) don't share reentrancy
|
||||
state — that would let one lock's reentrant acquisition silently skip
|
||||
the other's kernel-level flock.
|
||||
"""
|
||||
if getattr(holder, "depth", 0) > 0:
|
||||
holder.depth += 1
|
||||
def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
|
||||
"""Cross-process advisory lock for auth.json reads+writes. Reentrant."""
|
||||
# Reentrant: if this thread already holds the lock, just yield.
|
||||
if getattr(_auth_lock_holder, "depth", 0) > 0:
|
||||
_auth_lock_holder.depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
holder.depth -= 1
|
||||
_auth_lock_holder.depth -= 1
|
||||
return
|
||||
|
||||
lock_path = _auth_lock_path()
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if fcntl is None and msvcrt is None:
|
||||
holder.depth = 1
|
||||
_auth_lock_holder.depth = 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
holder.depth = 0
|
||||
_auth_lock_holder.depth = 0
|
||||
return
|
||||
|
||||
# On Windows, msvcrt.locking needs the file to have content and the
|
||||
# file pointer at position 0. Ensure the lock file has at least 1 byte.
|
||||
# file pointer at position 0. Ensure the lock file has at least 1 byte.
|
||||
if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0):
|
||||
lock_path.write_text(" ", encoding="utf-8")
|
||||
|
||||
with lock_path.open("r+" if msvcrt else "a+") as lock_file:
|
||||
deadline = time.monotonic() + max(1.0, timeout_seconds)
|
||||
deadline = time.time() + max(1.0, timeout_seconds)
|
||||
while True:
|
||||
try:
|
||||
if fcntl:
|
||||
@@ -904,15 +825,15 @@ def _file_lock(
|
||||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
break
|
||||
except (BlockingIOError, OSError, PermissionError):
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(timeout_message)
|
||||
if time.time() >= deadline:
|
||||
raise TimeoutError("Timed out waiting for auth store lock")
|
||||
time.sleep(0.05)
|
||||
|
||||
holder.depth = 1
|
||||
_auth_lock_holder.depth = 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
holder.depth = 0
|
||||
_auth_lock_holder.depth = 0
|
||||
if fcntl:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
elif msvcrt:
|
||||
@@ -923,25 +844,6 @@ def _file_lock(
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
|
||||
"""Cross-process advisory lock for auth.json reads+writes. Reentrant.
|
||||
|
||||
Lock ordering invariant: when this lock is held together with
|
||||
``_nous_shared_store_lock``, acquire ``_auth_store_lock`` FIRST
|
||||
(outer) and the shared Nous lock SECOND (inner). All runtime
|
||||
refresh paths follow this order; violating it risks deadlock
|
||||
against a concurrent import on the shared store.
|
||||
"""
|
||||
with _file_lock(
|
||||
_auth_lock_path(),
|
||||
_auth_lock_holder,
|
||||
timeout_seconds,
|
||||
"Timed out waiting for auth store lock",
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
|
||||
auth_file = auth_file or _auth_file_path()
|
||||
if not auth_file.exists():
|
||||
@@ -985,27 +887,12 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
|
||||
def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
|
||||
auth_file = _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.
|
||||
try:
|
||||
os.chmod(auth_file.parent, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
auth_store["version"] = AUTH_STORE_VERSION
|
||||
auth_store["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
payload = json.dumps(auth_store, indent=2) + "\n"
|
||||
tmp_path = auth_file.with_name(f"{auth_file.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
|
||||
try:
|
||||
# Create with 0o600 atomically via os.open(O_EXCL) + fdopen to close
|
||||
# the TOCTOU window where default umask (often 0o644) briefly exposed
|
||||
# OAuth tokens to other local users between open() and chmod().
|
||||
# Mirrors agent/google_oauth.py (#19673) and tools/mcp_oauth.py (#21148).
|
||||
fd = os.open(
|
||||
str(tmp_path),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
with tmp_path.open("w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
@@ -1079,50 +966,15 @@ def get_auth_provider_display_name(provider_id: str) -> str:
|
||||
|
||||
|
||||
def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Return the persisted credential pool, or one provider slice.
|
||||
|
||||
In profile mode, the profile's credential pool is authoritative. If a
|
||||
provider has no entries in the profile, entries from the global-root
|
||||
``auth.json`` are used as a read-only fallback — so workers spawned in a
|
||||
profile can see providers that were only authenticated at global scope.
|
||||
|
||||
Profile entries always win: the global fallback only applies per-provider
|
||||
when the profile has zero entries for that provider. Once the user runs
|
||||
``hermes auth add <provider>`` inside the profile, profile entries
|
||||
fully shadow global for that provider on the next read.
|
||||
|
||||
Writes always go to the profile (``write_credential_pool`` is unchanged).
|
||||
See issue #18594 follow-up.
|
||||
"""
|
||||
"""Return the persisted credential pool, or one provider slice."""
|
||||
auth_store = _load_auth_store()
|
||||
pool = auth_store.get("credential_pool")
|
||||
if not isinstance(pool, dict):
|
||||
pool = {}
|
||||
|
||||
global_pool: Dict[str, Any] = {}
|
||||
global_store = _load_global_auth_store()
|
||||
maybe_global_pool = global_store.get("credential_pool") if global_store else None
|
||||
if isinstance(maybe_global_pool, dict):
|
||||
global_pool = maybe_global_pool
|
||||
|
||||
if provider_id is None:
|
||||
merged = dict(pool)
|
||||
for gp_key, gp_entries in global_pool.items():
|
||||
if not isinstance(gp_entries, list) or not gp_entries:
|
||||
continue
|
||||
# Per-provider shadowing: profile wins whenever it has ANY entries.
|
||||
existing = merged.get(gp_key)
|
||||
if isinstance(existing, list) and existing:
|
||||
continue
|
||||
merged[gp_key] = list(gp_entries)
|
||||
return merged
|
||||
|
||||
return dict(pool)
|
||||
provider_entries = pool.get(provider_id)
|
||||
if isinstance(provider_entries, list) and provider_entries:
|
||||
return list(provider_entries)
|
||||
# Profile has no entries for this provider — fall back to global.
|
||||
global_entries = global_pool.get(provider_id)
|
||||
return list(global_entries) if isinstance(global_entries, list) else []
|
||||
return list(provider_entries) if isinstance(provider_entries, list) else []
|
||||
|
||||
|
||||
def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path:
|
||||
@@ -1181,25 +1033,9 @@ def unsuppress_credential_source(provider_id: str, source: str) -> bool:
|
||||
|
||||
|
||||
def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return persisted auth state for a provider, or None.
|
||||
|
||||
In profile mode, falls back to the global-root ``auth.json`` when the
|
||||
profile has no state for this provider. Profile state always wins when
|
||||
present. Writes (``_save_auth_store`` / ``persist_*_credentials``) are
|
||||
unchanged — they still target the profile only. This mirrors
|
||||
``read_credential_pool``'s per-provider shadowing semantics so that
|
||||
``_seed_from_singletons`` can reseed a profile's credential pool from
|
||||
global-scope provider state (e.g. a globally-authenticated Anthropic
|
||||
OAuth or Nous device-code session). See issue #18594 follow-up.
|
||||
"""
|
||||
"""Return persisted auth state for a provider, or None."""
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, provider_id)
|
||||
if state is not None:
|
||||
return state
|
||||
global_store = _load_global_auth_store()
|
||||
if not global_store:
|
||||
return None
|
||||
return _load_provider_state(global_store, provider_id)
|
||||
return _load_provider_state(auth_store, provider_id)
|
||||
|
||||
|
||||
def get_active_provider() -> Optional[str]:
|
||||
@@ -1393,7 +1229,7 @@ def resolve_provider(
|
||||
"vllm": "custom", "llamacpp": "custom",
|
||||
"llama.cpp": "custom", "llama-cpp": "custom",
|
||||
}
|
||||
# Extend with aliases declared in plugins/model-providers/<name>/ that aren't already mapped.
|
||||
# Extend with aliases declared in providers/*.py that aren't already mapped.
|
||||
# This keeps providers/ as the single source for new aliases while the
|
||||
# hardcoded dict above remains authoritative for existing ones.
|
||||
try:
|
||||
@@ -1569,33 +1405,10 @@ def _read_qwen_cli_tokens() -> Dict[str, Any]:
|
||||
def _save_qwen_cli_tokens(tokens: Dict[str, Any]) -> Path:
|
||||
auth_path = _qwen_cli_auth_path()
|
||||
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(auth_path.parent, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
# Per-process random temp suffix avoids collisions between concurrent
|
||||
# writers and stale leftovers from a crashed prior write.
|
||||
tmp_path = auth_path.with_name(f"{auth_path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
|
||||
# Create with 0o600 atomically via os.open(O_EXCL) — closes the TOCTOU
|
||||
# window where write_text() + post-write chmod briefly exposed tokens
|
||||
# at process umask (typically 0o644). See #19673, #21148.
|
||||
fd = os.open(
|
||||
str(tmp_path),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(tokens, indent=2, sort_keys=True) + "\n")
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
atomic_replace(tmp_path, auth_path)
|
||||
finally:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
tmp_path = auth_path.with_suffix(".tmp")
|
||||
tmp_path.write_text(json.dumps(tokens, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
tmp_path.replace(auth_path)
|
||||
return auth_path
|
||||
|
||||
|
||||
@@ -2012,9 +1825,9 @@ def _spotify_wait_for_callback(
|
||||
|
||||
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.monotonic() + max(5.0, timeout_seconds)
|
||||
deadline = time.time() + max(5.0, timeout_seconds)
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
while time.time() < deadline:
|
||||
if result["code"] or result["error"]:
|
||||
return result
|
||||
time.sleep(0.1)
|
||||
@@ -2777,10 +2590,10 @@ def _poll_for_token(
|
||||
poll_interval: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Poll the token endpoint until the user approves or the code expires."""
|
||||
deadline = time.monotonic() + max(1, expires_in)
|
||||
deadline = time.time() + max(1, expires_in)
|
||||
current_interval = max(1, min(poll_interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
while time.time() < deadline:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/api/oauth/token",
|
||||
data={
|
||||
@@ -2838,7 +2651,6 @@ def _poll_for_token(
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
NOUS_SHARED_STORE_FILENAME = "nous_auth.json"
|
||||
_nous_shared_lock_holder = threading.local()
|
||||
|
||||
|
||||
def _nous_shared_auth_dir() -> Path:
|
||||
@@ -2878,69 +2690,6 @@ def _nous_shared_store_path() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _nous_shared_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
|
||||
"""Cross-profile lock for the shared Nous OAuth store.
|
||||
|
||||
Lock ordering invariant: if both this and ``_auth_store_lock`` need
|
||||
to be held, acquire ``_auth_store_lock`` FIRST. All runtime refresh
|
||||
paths follow this order. The one exception is
|
||||
``_try_import_shared_nous_state``, which holds this lock alone for
|
||||
the entire refresh+mint cycle so concurrent imports on sibling
|
||||
profiles can't race on the single-use shared refresh token; that
|
||||
helper must NOT be called with ``_auth_store_lock`` already held.
|
||||
"""
|
||||
try:
|
||||
lock_path = _nous_shared_store_path().with_suffix(".lock")
|
||||
except RuntimeError:
|
||||
# No HERMES_HOME yet (pre-setup): fall through without locking.
|
||||
yield
|
||||
return
|
||||
|
||||
with _file_lock(
|
||||
lock_path,
|
||||
_nous_shared_lock_holder,
|
||||
timeout_seconds,
|
||||
"Timed out waiting for shared Nous auth lock",
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool:
|
||||
"""Copy fresher shared OAuth tokens into a profile-local Nous state."""
|
||||
shared = _read_shared_nous_state()
|
||||
if not shared:
|
||||
return False
|
||||
|
||||
shared_refresh = shared.get("refresh_token")
|
||||
if not isinstance(shared_refresh, str) or not shared_refresh.strip():
|
||||
return False
|
||||
|
||||
local_refresh = state.get("refresh_token")
|
||||
shared_access_exp = _parse_iso_timestamp(shared.get("expires_at")) or 0.0
|
||||
local_access_exp = _parse_iso_timestamp(state.get("expires_at")) or 0.0
|
||||
refresh_changed = shared_refresh.strip() != str(local_refresh or "").strip()
|
||||
fresher_access = shared_access_exp > local_access_exp
|
||||
if not refresh_changed and not fresher_access:
|
||||
return False
|
||||
|
||||
for key in (
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"token_type",
|
||||
"scope",
|
||||
"client_id",
|
||||
"portal_base_url",
|
||||
"inference_base_url",
|
||||
"obtained_at",
|
||||
"expires_at",
|
||||
):
|
||||
value = shared.get(key)
|
||||
if value not in (None, ""):
|
||||
state[key] = value
|
||||
return True
|
||||
|
||||
|
||||
def _write_shared_nous_state(state: Dict[str, Any]) -> None:
|
||||
"""Persist a minimal copy of the Nous OAuth state to the shared store.
|
||||
|
||||
@@ -2973,34 +2722,15 @@ def _write_shared_nous_state(state: Dict[str, Any]) -> None:
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
try:
|
||||
with _nous_shared_store_lock():
|
||||
path = _nous_shared_store_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(path.parent, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
|
||||
# Create with 0o600 atomically via os.open(O_EXCL) — closes the TOCTOU
|
||||
# window where write_text() + post-write chmod briefly exposed Nous
|
||||
# refresh_token at process umask. See #19673, #21148.
|
||||
fd = os.open(
|
||||
str(tmp),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(shared, indent=2, sort_keys=True))
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
path = _nous_shared_store_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(shared, indent=2, sort_keys=True))
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, path)
|
||||
_oauth_trace(
|
||||
"nous_shared_store_written",
|
||||
path=str(path),
|
||||
@@ -3057,38 +2787,36 @@ def _try_import_shared_nous_state(
|
||||
etc.) — caller should then fall through to the normal device-code
|
||||
flow.
|
||||
"""
|
||||
shared = _read_shared_nous_state()
|
||||
if not shared:
|
||||
return None
|
||||
|
||||
# Build a full state dict so refresh_nous_oauth_from_state has every
|
||||
# field it needs. force_refresh=True gets us a fresh access_token
|
||||
# for this profile; force_mint=True gets us a fresh agent_key.
|
||||
state: Dict[str, Any] = {
|
||||
"access_token": shared.get("access_token"),
|
||||
"refresh_token": shared.get("refresh_token"),
|
||||
"client_id": shared.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
|
||||
"portal_base_url": shared.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
|
||||
"inference_base_url": shared.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL,
|
||||
"token_type": shared.get("token_type") or "Bearer",
|
||||
"scope": shared.get("scope") or DEFAULT_NOUS_SCOPE,
|
||||
"obtained_at": shared.get("obtained_at"),
|
||||
"expires_at": shared.get("expires_at"),
|
||||
"agent_key": None,
|
||||
"agent_key_expires_at": None,
|
||||
"tls": {"insecure": False, "ca_bundle": None},
|
||||
}
|
||||
|
||||
try:
|
||||
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
|
||||
shared = _read_shared_nous_state()
|
||||
if not shared:
|
||||
return None
|
||||
|
||||
# Build a full state dict so refresh_nous_oauth_from_state has every
|
||||
# field it needs. force_refresh=True gets us a fresh access_token
|
||||
# for this profile; force_mint=True gets us a fresh agent_key.
|
||||
state: Dict[str, Any] = {
|
||||
"access_token": shared.get("access_token"),
|
||||
"refresh_token": shared.get("refresh_token"),
|
||||
"client_id": shared.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
|
||||
"portal_base_url": shared.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
|
||||
"inference_base_url": shared.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL,
|
||||
"token_type": shared.get("token_type") or "Bearer",
|
||||
"scope": shared.get("scope") or DEFAULT_NOUS_SCOPE,
|
||||
"obtained_at": shared.get("obtained_at"),
|
||||
"expires_at": shared.get("expires_at"),
|
||||
"agent_key": None,
|
||||
"agent_key_expires_at": None,
|
||||
"tls": {"insecure": False, "ca_bundle": None},
|
||||
}
|
||||
|
||||
refreshed = refresh_nous_oauth_from_state(
|
||||
state,
|
||||
min_key_ttl_seconds=min_key_ttl_seconds,
|
||||
timeout_seconds=timeout_seconds,
|
||||
force_refresh=True,
|
||||
force_mint=True,
|
||||
)
|
||||
_write_shared_nous_state(refreshed)
|
||||
refreshed = refresh_nous_oauth_from_state(
|
||||
state,
|
||||
min_key_ttl_seconds=min_key_ttl_seconds,
|
||||
timeout_seconds=timeout_seconds,
|
||||
force_refresh=True,
|
||||
force_mint=True,
|
||||
)
|
||||
except AuthError as exc:
|
||||
_oauth_trace(
|
||||
"nous_shared_import_failed",
|
||||
@@ -3290,65 +3018,59 @@ def resolve_nous_access_token(
|
||||
client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID)
|
||||
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
|
||||
|
||||
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
|
||||
merged_shared = _merge_shared_nous_oauth_state(state)
|
||||
access_token = state.get("access_token")
|
||||
refresh_token = state.get("refresh_token")
|
||||
if not isinstance(access_token, str) or not access_token:
|
||||
raise AuthError(
|
||||
"No access token found for Nous Portal login.",
|
||||
provider="nous",
|
||||
relogin_required=True,
|
||||
)
|
||||
access_token = state.get("access_token")
|
||||
refresh_token = state.get("refresh_token")
|
||||
if not isinstance(access_token, str) or not access_token:
|
||||
raise AuthError(
|
||||
"No access token found for Nous Portal login.",
|
||||
provider="nous",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
if not _is_expiring(state.get("expires_at"), refresh_skew_seconds):
|
||||
if merged_shared:
|
||||
_save_provider_state(auth_store, "nous", state)
|
||||
_save_auth_store(auth_store)
|
||||
return access_token
|
||||
if not _is_expiring(state.get("expires_at"), refresh_skew_seconds):
|
||||
return access_token
|
||||
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
raise AuthError(
|
||||
"Session expired and no refresh token is available.",
|
||||
provider="nous",
|
||||
relogin_required=True,
|
||||
)
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
raise AuthError(
|
||||
"Session expired and no refresh token is available.",
|
||||
provider="nous",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
|
||||
with httpx.Client(
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
verify=verify,
|
||||
) as client:
|
||||
refreshed = _refresh_access_token(
|
||||
client=client,
|
||||
portal_base_url=portal_base_url,
|
||||
client_id=client_id,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
|
||||
with httpx.Client(
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
verify=verify,
|
||||
) as client:
|
||||
refreshed = _refresh_access_token(
|
||||
client=client,
|
||||
portal_base_url=portal_base_url,
|
||||
client_id=client_id,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl,
|
||||
tz=timezone.utc,
|
||||
).isoformat()
|
||||
state["portal_base_url"] = portal_base_url
|
||||
state["client_id"] = client_id
|
||||
state["tls"] = {
|
||||
"insecure": verify is False,
|
||||
"ca_bundle": verify if isinstance(verify, str) else None,
|
||||
}
|
||||
_save_provider_state(auth_store, "nous", state)
|
||||
_save_auth_store(auth_store)
|
||||
_write_shared_nous_state(state)
|
||||
return state["access_token"]
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl,
|
||||
tz=timezone.utc,
|
||||
).isoformat()
|
||||
state["portal_base_url"] = portal_base_url
|
||||
state["client_id"] = client_id
|
||||
state["tls"] = {
|
||||
"insecure": verify is False,
|
||||
"ca_bundle": verify if isinstance(verify, str) else None,
|
||||
}
|
||||
_save_provider_state(auth_store, "nous", state)
|
||||
_save_auth_store(auth_store)
|
||||
return state["access_token"]
|
||||
|
||||
|
||||
def refresh_nous_oauth_pure(
|
||||
@@ -3616,53 +3338,46 @@ def resolve_nous_runtime_credentials(
|
||||
|
||||
# Step 1: refresh access token if expiring
|
||||
if _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS):
|
||||
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
|
||||
if _merge_shared_nous_oauth_state(state):
|
||||
access_token = state.get("access_token")
|
||||
refresh_token = state.get("refresh_token")
|
||||
_persist_state("post_shared_merge_access_expiring")
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
raise AuthError("Session expired and no refresh token is available.",
|
||||
provider="nous", relogin_required=True)
|
||||
|
||||
if _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS):
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
raise AuthError("Session expired and no refresh token is available.",
|
||||
provider="nous", relogin_required=True)
|
||||
|
||||
_oauth_trace(
|
||||
"refresh_start",
|
||||
sequence_id=sequence_id,
|
||||
reason="access_expiring",
|
||||
refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
refreshed = _refresh_access_token(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
client_id=client_id, refresh_token=refresh_token,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
previous_refresh_token = refresh_token
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
inference_base_url = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl, tz=timezone.utc
|
||||
).isoformat()
|
||||
access_token = state["access_token"]
|
||||
refresh_token = state["refresh_token"]
|
||||
_oauth_trace(
|
||||
"refresh_success",
|
||||
sequence_id=sequence_id,
|
||||
reason="access_expiring",
|
||||
previous_refresh_token_fp=_token_fingerprint(previous_refresh_token),
|
||||
new_refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
# Persist immediately so downstream mint failures cannot drop rotated refresh tokens.
|
||||
_persist_state("post_refresh_access_expiring")
|
||||
_oauth_trace(
|
||||
"refresh_start",
|
||||
sequence_id=sequence_id,
|
||||
reason="access_expiring",
|
||||
refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
refreshed = _refresh_access_token(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
client_id=client_id, refresh_token=refresh_token,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
previous_refresh_token = refresh_token
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
inference_base_url = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl, tz=timezone.utc
|
||||
).isoformat()
|
||||
access_token = state["access_token"]
|
||||
refresh_token = state["refresh_token"]
|
||||
_oauth_trace(
|
||||
"refresh_success",
|
||||
sequence_id=sequence_id,
|
||||
reason="access_expiring",
|
||||
previous_refresh_token_fp=_token_fingerprint(previous_refresh_token),
|
||||
new_refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
# Persist immediately so downstream mint failures cannot drop rotated refresh tokens.
|
||||
_persist_state("post_refresh_access_expiring")
|
||||
|
||||
# Step 2: mint agent key if missing/expiring
|
||||
used_cached_key = False
|
||||
@@ -3695,47 +3410,41 @@ def resolve_nous_runtime_credentials(
|
||||
and isinstance(latest_refresh_token, str)
|
||||
and latest_refresh_token
|
||||
):
|
||||
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
|
||||
if _merge_shared_nous_oauth_state(state):
|
||||
access_token = state.get("access_token")
|
||||
latest_refresh_token = state.get("refresh_token")
|
||||
_persist_state("post_shared_merge_mint_retry")
|
||||
else:
|
||||
_oauth_trace(
|
||||
"refresh_start",
|
||||
sequence_id=sequence_id,
|
||||
reason="mint_retry_after_invalid_token",
|
||||
refresh_token_fp=_token_fingerprint(latest_refresh_token),
|
||||
)
|
||||
refreshed = _refresh_access_token(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
client_id=client_id, refresh_token=latest_refresh_token,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or latest_refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
inference_base_url = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl, tz=timezone.utc
|
||||
).isoformat()
|
||||
access_token = state["access_token"]
|
||||
refresh_token = state["refresh_token"]
|
||||
_oauth_trace(
|
||||
"refresh_success",
|
||||
sequence_id=sequence_id,
|
||||
reason="mint_retry_after_invalid_token",
|
||||
previous_refresh_token_fp=_token_fingerprint(latest_refresh_token),
|
||||
new_refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
# Persist retry refresh immediately for crash safety and cross-process visibility.
|
||||
_persist_state("post_refresh_mint_retry")
|
||||
_oauth_trace(
|
||||
"refresh_start",
|
||||
sequence_id=sequence_id,
|
||||
reason="mint_retry_after_invalid_token",
|
||||
refresh_token_fp=_token_fingerprint(latest_refresh_token),
|
||||
)
|
||||
refreshed = _refresh_access_token(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
client_id=client_id, refresh_token=latest_refresh_token,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or latest_refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
inference_base_url = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl, tz=timezone.utc
|
||||
).isoformat()
|
||||
access_token = state["access_token"]
|
||||
refresh_token = state["refresh_token"]
|
||||
_oauth_trace(
|
||||
"refresh_success",
|
||||
sequence_id=sequence_id,
|
||||
reason="mint_retry_after_invalid_token",
|
||||
previous_refresh_token_fp=_token_fingerprint(latest_refresh_token),
|
||||
new_refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
# Persist retry refresh immediately for crash safety and cross-process visibility.
|
||||
_persist_state("post_refresh_mint_retry")
|
||||
|
||||
mint_payload = _mint_agent_key(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
@@ -4231,14 +3940,6 @@ def _config_provider_matches(provider_id: Optional[str]) -> bool:
|
||||
return _get_config_provider() == provider_id.strip().lower()
|
||||
|
||||
|
||||
def _should_reset_config_provider_on_logout(provider_id: Optional[str]) -> bool:
|
||||
"""Return True when logout should reset the model provider config."""
|
||||
if not provider_id:
|
||||
return False
|
||||
normalized = provider_id.strip().lower()
|
||||
return normalized in PROVIDER_REGISTRY and _config_provider_matches(normalized)
|
||||
|
||||
|
||||
def _logout_default_provider_from_config() -> Optional[str]:
|
||||
"""Fallback logout target when auth.json has no active provider.
|
||||
|
||||
@@ -5324,18 +5025,15 @@ def logout_command(args) -> None:
|
||||
print("No provider is currently logged in.")
|
||||
return
|
||||
|
||||
should_reset_config = _should_reset_config_provider_on_logout(target)
|
||||
config_matches = _config_provider_matches(target)
|
||||
provider_name = get_auth_provider_display_name(target)
|
||||
|
||||
if clear_provider_auth(target) or should_reset_config:
|
||||
if should_reset_config:
|
||||
_reset_config_provider()
|
||||
if clear_provider_auth(target) or config_matches:
|
||||
_reset_config_provider()
|
||||
print(f"Logged out of {provider_name}.")
|
||||
if should_reset_config and os.getenv("OPENROUTER_API_KEY"):
|
||||
if os.getenv("OPENROUTER_API_KEY"):
|
||||
print("Hermes will use OpenRouter for inference.")
|
||||
elif should_reset_config:
|
||||
print("Run `hermes model` or configure an API key to use Hermes.")
|
||||
else:
|
||||
print("Model provider configuration was unchanged.")
|
||||
print("Run `hermes model` or configure an API key to use Hermes.")
|
||||
else:
|
||||
print(f"No auth state found for {provider_name}.")
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
"""`hermes checkpoints` CLI subcommand.
|
||||
|
||||
Gives users direct visibility and control over the filesystem checkpoint
|
||||
store at ``~/.hermes/checkpoints/``. Actions:
|
||||
|
||||
hermes checkpoints # same as `status`
|
||||
hermes checkpoints status # total size, project count, breakdown
|
||||
hermes checkpoints list # per-project checkpoint counts + workdir
|
||||
hermes checkpoints prune [opts] # force a sweep (ignores the 24h marker)
|
||||
hermes checkpoints clear [-f] # nuke the entire base (asks first)
|
||||
hermes checkpoints clear-legacy # delete just the legacy-* archives
|
||||
|
||||
Examples::
|
||||
|
||||
hermes checkpoints
|
||||
hermes checkpoints prune --retention-days 3 --max-size-mb 200
|
||||
hermes checkpoints clear -f
|
||||
|
||||
None of these require the agent to be running. Safe to call any time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def _fmt_bytes(n: int) -> str:
|
||||
units = ("B", "KB", "MB", "GB", "TB")
|
||||
size = float(n or 0)
|
||||
for unit in units:
|
||||
if size < 1024 or unit == units[-1]:
|
||||
if unit == "B":
|
||||
return f"{int(size)} {unit}"
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} TB"
|
||||
|
||||
|
||||
def _fmt_ts(ts: Any) -> str:
|
||||
try:
|
||||
return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M")
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def _fmt_age(ts: Any) -> str:
|
||||
try:
|
||||
age = time.time() - float(ts)
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
if age < 0:
|
||||
return "now"
|
||||
if age < 60:
|
||||
return f"{int(age)}s ago"
|
||||
if age < 3600:
|
||||
return f"{int(age / 60)}m ago"
|
||||
if age < 86400:
|
||||
return f"{int(age / 3600)}h ago"
|
||||
return f"{int(age / 86400)}d ago"
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
from tools.checkpoint_manager import store_status
|
||||
|
||||
info = store_status()
|
||||
base = info["base"]
|
||||
print(f"Checkpoint base: {base}")
|
||||
print(f"Total size: {_fmt_bytes(info['total_size_bytes'])}")
|
||||
print(f" store/ {_fmt_bytes(info['store_size_bytes'])}")
|
||||
print(f" legacy-* {_fmt_bytes(info['legacy_size_bytes'])}")
|
||||
print(f"Projects: {info['project_count']}")
|
||||
|
||||
projects = sorted(
|
||||
info["projects"],
|
||||
key=lambda p: (p.get("last_touch") or 0),
|
||||
reverse=True,
|
||||
)
|
||||
if projects:
|
||||
print()
|
||||
print(f" {'WORKDIR':<60} {'COMMITS':>7} {'LAST TOUCH':>12} STATE")
|
||||
for p in projects[: args.limit if hasattr(args, "limit") and args.limit else 20]:
|
||||
wd = p.get("workdir") or "(unknown)"
|
||||
if len(wd) > 60:
|
||||
wd = "…" + wd[-59:]
|
||||
exists = p.get("exists")
|
||||
state = "live" if exists else "orphan"
|
||||
commits = p.get("commits", 0)
|
||||
last = _fmt_age(p.get("last_touch"))
|
||||
print(f" {wd:<60} {commits:>7} {last:>12} {state}")
|
||||
|
||||
legacy = info.get("legacy_archives", [])
|
||||
if legacy:
|
||||
print()
|
||||
print(f"Legacy archives ({len(legacy)}):")
|
||||
for arch in sorted(legacy, key=lambda a: a.get("mtime", 0), reverse=True):
|
||||
print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}")
|
||||
print()
|
||||
print("Clear with: hermes checkpoints clear-legacy")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
# `list` is just a terser status — already covered.
|
||||
return cmd_status(args)
|
||||
|
||||
|
||||
def cmd_prune(args: argparse.Namespace) -> int:
|
||||
from tools.checkpoint_manager import prune_checkpoints
|
||||
|
||||
retention_days = args.retention_days
|
||||
max_size_mb = args.max_size_mb
|
||||
|
||||
print("Pruning checkpoint store…")
|
||||
print(f" retention_days: {retention_days}")
|
||||
print(f" delete_orphans: {not args.keep_orphans}")
|
||||
print(f" max_total_size_mb: {max_size_mb}")
|
||||
print()
|
||||
|
||||
result = prune_checkpoints(
|
||||
retention_days=retention_days,
|
||||
delete_orphans=not args.keep_orphans,
|
||||
max_total_size_mb=max_size_mb,
|
||||
)
|
||||
print(f"Scanned: {result['scanned']}")
|
||||
print(f"Deleted orphan: {result['deleted_orphan']}")
|
||||
print(f"Deleted stale: {result['deleted_stale']}")
|
||||
print(f"Errors: {result['errors']}")
|
||||
print(f"Bytes reclaimed: {_fmt_bytes(result['bytes_freed'])}")
|
||||
return 0
|
||||
|
||||
|
||||
def _confirm(prompt: str) -> bool:
|
||||
try:
|
||||
resp = input(f"{prompt} [y/N]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
return False
|
||||
return resp in ("y", "yes")
|
||||
|
||||
|
||||
def cmd_clear(args: argparse.Namespace) -> int:
|
||||
from tools.checkpoint_manager import CHECKPOINT_BASE, clear_all, store_status
|
||||
|
||||
info = store_status()
|
||||
if info["total_size_bytes"] == 0 and not Path(CHECKPOINT_BASE).exists():
|
||||
print("Nothing to clear — checkpoint base does not exist.")
|
||||
return 0
|
||||
|
||||
print(f"This will delete the ENTIRE checkpoint base at {info['base']}")
|
||||
print(f" size: {_fmt_bytes(info['total_size_bytes'])}")
|
||||
print(f" projects: {info['project_count']}")
|
||||
print(f" legacy dirs: {len(info.get('legacy_archives', []))}")
|
||||
print()
|
||||
print("All /rollback history for every working directory will be lost.")
|
||||
if not args.force and not _confirm("Proceed?"):
|
||||
print("Aborted.")
|
||||
return 1
|
||||
|
||||
result = clear_all()
|
||||
if result["deleted"]:
|
||||
print(f"Cleared. Reclaimed {_fmt_bytes(result['bytes_freed'])}.")
|
||||
return 0
|
||||
print("Could not clear checkpoint base (see logs).")
|
||||
return 2
|
||||
|
||||
|
||||
def cmd_clear_legacy(args: argparse.Namespace) -> int:
|
||||
from tools.checkpoint_manager import clear_legacy, store_status
|
||||
|
||||
info = store_status()
|
||||
legacy = info.get("legacy_archives", [])
|
||||
if not legacy:
|
||||
print("No legacy archives to clear.")
|
||||
return 0
|
||||
|
||||
total = sum(a.get("size_bytes", 0) for a in legacy)
|
||||
print(f"Found {len(legacy)} legacy archive(s), total {_fmt_bytes(total)}:")
|
||||
for arch in legacy:
|
||||
print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}")
|
||||
print()
|
||||
print("Legacy archives hold pre-v2 per-project shadow repos, moved aside")
|
||||
print("during the single-store migration. Delete when you're confident")
|
||||
print("you don't need the old /rollback history.")
|
||||
if not args.force and not _confirm("Delete all legacy archives?"):
|
||||
print("Aborted.")
|
||||
return 1
|
||||
|
||||
result = clear_legacy()
|
||||
print(f"Deleted {result['deleted']} archive(s), reclaimed {_fmt_bytes(result['bytes_freed'])}.")
|
||||
return 0
|
||||
|
||||
|
||||
def register_cli(parser: argparse.ArgumentParser) -> None:
|
||||
"""Wire subcommands onto the ``hermes checkpoints`` parser."""
|
||||
parser.set_defaults(func=cmd_status) # bare `hermes checkpoints` → status
|
||||
subs = parser.add_subparsers(dest="checkpoints_command", metavar="COMMAND")
|
||||
|
||||
p_status = subs.add_parser(
|
||||
"status",
|
||||
help="Show total size, project count, and per-project breakdown",
|
||||
)
|
||||
p_status.add_argument("--limit", type=int, default=20,
|
||||
help="Max projects to list (default 20)")
|
||||
p_status.set_defaults(func=cmd_status)
|
||||
|
||||
p_list = subs.add_parser(
|
||||
"list",
|
||||
help="Alias for 'status'",
|
||||
)
|
||||
p_list.add_argument("--limit", type=int, default=20)
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
p_prune = subs.add_parser(
|
||||
"prune",
|
||||
help="Delete orphan/stale checkpoints and GC the store",
|
||||
)
|
||||
p_prune.add_argument("--retention-days", type=int, default=7,
|
||||
help="Drop projects whose last_touch is older than N days (default 7)")
|
||||
p_prune.add_argument("--max-size-mb", type=int, default=500,
|
||||
help="After orphan/stale prune, drop oldest commits "
|
||||
"per project until total size <= this (default 500)")
|
||||
p_prune.add_argument("--keep-orphans", action="store_true",
|
||||
help="Skip deleting projects whose workdir no longer exists")
|
||||
p_prune.set_defaults(func=cmd_prune)
|
||||
|
||||
p_clear = subs.add_parser(
|
||||
"clear",
|
||||
help="Delete the entire checkpoint base (all /rollback history)",
|
||||
)
|
||||
p_clear.add_argument("-f", "--force", action="store_true",
|
||||
help="Skip confirmation prompt")
|
||||
p_clear.set_defaults(func=cmd_clear)
|
||||
|
||||
p_legacy = subs.add_parser(
|
||||
"clear-legacy",
|
||||
help="Delete only the legacy-<ts>/ archives from v1 migration",
|
||||
)
|
||||
p_legacy.add_argument("-f", "--force", action="store_true",
|
||||
help="Skip confirmation prompt")
|
||||
p_legacy.set_defaults(func=cmd_clear_legacy)
|
||||
@@ -157,9 +157,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)",
|
||||
CommandDef("curator", "Background skill maintenance (status, run, pin, archive)",
|
||||
"Tools & Skills", args_hint="[subcommand]",
|
||||
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")),
|
||||
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore")),
|
||||
CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)",
|
||||
"Tools & Skills", args_hint="[subcommand]",
|
||||
subcommands=("list", "ls", "show", "create", "assign", "link", "unlink",
|
||||
|
||||
+29
-232
@@ -544,25 +544,12 @@ DEFAULT_CONFIG = {
|
||||
# via TERMINAL_LOCAL_PERSISTENT env var.
|
||||
"persistent_shell": True,
|
||||
},
|
||||
|
||||
"web": {
|
||||
"backend": "", # shared fallback — applies to both search and extract
|
||||
"search_backend": "", # per-capability override for web_search (e.g. "searxng")
|
||||
"extract_backend": "", # per-capability override for web_extract (e.g. "native")
|
||||
},
|
||||
|
||||
|
||||
"browser": {
|
||||
"inactivity_timeout": 120,
|
||||
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
|
||||
"record_sessions": False, # Auto-record browser sessions as WebM videos
|
||||
"allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
|
||||
# Browser engine for local mode. Passed as ``--engine <value>`` to
|
||||
# agent-browser v0.25.3+.
|
||||
# "auto" — use Chrome (default, don't pass --engine at all)
|
||||
# "lightpanda" — use Lightpanda (1.3-5.8x faster navigation, no screenshots)
|
||||
# "chrome" — explicitly request Chrome
|
||||
# Also settable via AGENT_BROWSER_ENGINE env var.
|
||||
"engine": "auto",
|
||||
"auto_local_for_private_urls": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud
|
||||
"cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome
|
||||
# CDP supervisor — dialog + frame detection via a persistent WebSocket.
|
||||
@@ -580,39 +567,21 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
|
||||
# Filesystem checkpoints — automatic snapshots before destructive file ops.
|
||||
# When enabled, the agent takes a snapshot of the working directory once
|
||||
# per conversation turn (on first write_file/patch call). Use /rollback
|
||||
# to restore.
|
||||
#
|
||||
# Defaults changed in v2 (single shared shadow store, real pruning):
|
||||
# - enabled: True -> False (opt-in; most users never use /rollback)
|
||||
# - max_snapshots: 50 -> 20 (now actually enforced via ref rewrite)
|
||||
# - auto_prune: False -> True (orphans/stale pruned automatically)
|
||||
# Opt in via ``hermes chat --checkpoints`` or set enabled=True here.
|
||||
# When enabled, the agent takes a snapshot of the working directory once per
|
||||
# conversation turn (on first write_file/patch call). Use /rollback to restore.
|
||||
"checkpoints": {
|
||||
"enabled": False,
|
||||
# Max checkpoints to keep per working directory. Pre-v2 this only
|
||||
# limited the `/rollback` listing; v2 actually rewrites the ref and
|
||||
# garbage-collects older commits.
|
||||
"max_snapshots": 20,
|
||||
# Hard ceiling on total ``~/.hermes/checkpoints/`` size (MB). When
|
||||
# exceeded, the oldest checkpoint per project is dropped in a
|
||||
# round-robin pass until total size falls under the cap.
|
||||
# 0 disables the size cap.
|
||||
"max_total_size_mb": 500,
|
||||
# Skip any single file larger than this when staging a checkpoint.
|
||||
# Prevents accidental snapshotting of datasets, model weights, and
|
||||
# other large generated assets. 0 disables the filter.
|
||||
"max_file_size_mb": 10,
|
||||
# Auto-maintenance: hermes sweeps the checkpoint base at startup
|
||||
# (at most once per ``min_interval_hours``) and:
|
||||
# * deletes project entries whose workdir no longer exists (orphan)
|
||||
# * deletes project entries whose last_touch is older than
|
||||
# ``retention_days``
|
||||
# * GCs the single shared store to reclaim unreachable objects
|
||||
# * enforces ``max_total_size_mb`` across remaining projects
|
||||
# * deletes ``legacy-*`` archives older than ``retention_days``
|
||||
"auto_prune": True,
|
||||
"enabled": True,
|
||||
"max_snapshots": 50, # Max checkpoints to keep per directory
|
||||
# Auto-maintenance: shadow repos accumulate forever under
|
||||
# ~/.hermes/checkpoints/ (one per cd'd working directory). Field
|
||||
# reports put the typical offender at 1000+ repos / ~12 GB. When
|
||||
# auto_prune is on, hermes sweeps at startup (at most once per
|
||||
# min_interval_hours) and deletes:
|
||||
# * orphan repos: HERMES_WORKDIR no longer exists on disk
|
||||
# * stale repos: newest mtime older than retention_days
|
||||
# Opt-in so users who rely on /rollback against long-ago sessions
|
||||
# never lose data silently.
|
||||
"auto_prune": False,
|
||||
"retention_days": 7,
|
||||
"delete_orphans": True,
|
||||
"min_interval_hours": 24,
|
||||
@@ -780,19 +749,6 @@ DEFAULT_CONFIG = {
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Triage specifier — flesh out a rough one-liner in the Kanban
|
||||
# Triage column into a concrete spec, then promote it to ``todo``.
|
||||
# Invoked by ``hermes kanban specify`` (single id or --all). Set a
|
||||
# cheap, capable model here (gemini-flash works well); the main
|
||||
# model is overkill for short spec expansion.
|
||||
"triage_specifier": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 120,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Curator — skill-usage review fork. Timeout is generous because the
|
||||
# review pass can take several minutes on reasoning models (umbrella
|
||||
# building over hundreds of candidate skills). "auto" = use main chat
|
||||
@@ -822,18 +778,13 @@ DEFAULT_CONFIG = {
|
||||
"show_reasoning": False,
|
||||
"streaming": False,
|
||||
"final_response_markdown": "strip", # render | strip | raw
|
||||
# Preserve recent classic CLI output across Ctrl+L, /redraw, and
|
||||
# terminal resize full-screen clears. Disable if a terminal emulator
|
||||
# behaves badly with replayed scrollback.
|
||||
"persistent_output": True,
|
||||
"persistent_output_max_lines": 200,
|
||||
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
# UI language for static user-facing messages (approval prompts, a
|
||||
# handful of gateway slash-command replies). Does NOT affect agent
|
||||
# responses, log lines, tool outputs, or slash-command descriptions.
|
||||
# Supported: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.
|
||||
# Supported: en, zh, ja, de, es. Unknown values fall back to en.
|
||||
"language": "en",
|
||||
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
|
||||
# spinner), or ascii. Live-swappable via `/indicator <style>`.
|
||||
@@ -1113,14 +1064,6 @@ DEFAULT_CONFIG = {
|
||||
# Empty string means use server-local time.
|
||||
"timezone": "",
|
||||
|
||||
# Slack platform settings (gateway mode)
|
||||
"slack": {
|
||||
"require_mention": True, # Require @mention to respond in channels
|
||||
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
|
||||
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
|
||||
"channel_prompts": {}, # Per-channel ephemeral system prompts
|
||||
},
|
||||
|
||||
# Discord platform settings (gateway mode)
|
||||
"discord": {
|
||||
"require_mention": True, # Require @mention to respond in server channels
|
||||
@@ -1129,12 +1072,6 @@ DEFAULT_CONFIG = {
|
||||
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
|
||||
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
|
||||
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
|
||||
# Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES
|
||||
# authorizes only guild messages in the role's own guild — DMs require
|
||||
# DISCORD_ALLOWED_USERS. Set dm_role_auth_guild to a guild ID to also
|
||||
# authorize DMs from members of that one trusted guild holding the
|
||||
# allowed role. Unset / empty / 0 = secure default (DM role-auth off).
|
||||
"dm_role_auth_guild": "",
|
||||
# discord / discord_admin tools: restrict which actions the agent may call.
|
||||
# Default (empty) = all actions allowed (subject to bot privileged intents).
|
||||
# Accepts comma-separated string ("list_guilds,list_channels,fetch_messages")
|
||||
@@ -1157,24 +1094,18 @@ DEFAULT_CONFIG = {
|
||||
"telegram": {
|
||||
"reactions": False, # Add 👀/✅/❌ reactions to messages during processing
|
||||
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
|
||||
"allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
|
||||
},
|
||||
|
||||
# Slack platform settings (gateway mode)
|
||||
"slack": {
|
||||
"channel_prompts": {}, # Per-channel ephemeral system prompts
|
||||
},
|
||||
|
||||
# Mattermost platform settings (gateway mode)
|
||||
"mattermost": {
|
||||
"require_mention": True, # Require @mention to respond in channels
|
||||
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
|
||||
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
|
||||
"channel_prompts": {}, # Per-channel ephemeral system prompts
|
||||
},
|
||||
|
||||
# Matrix platform settings (gateway mode)
|
||||
"matrix": {
|
||||
"require_mention": True, # Require @mention to respond in rooms
|
||||
"free_response_rooms": "", # Comma-separated room IDs where bot responds without mention
|
||||
"allowed_rooms": "", # If set, bot ONLY responds in these room IDs (whitelist)
|
||||
},
|
||||
|
||||
# Approval mode for dangerous commands:
|
||||
# manual — always prompt the user (default)
|
||||
# smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk
|
||||
@@ -1187,14 +1118,6 @@ DEFAULT_CONFIG = {
|
||||
"mode": "manual",
|
||||
"timeout": 60,
|
||||
"cron_mode": "deny",
|
||||
# Trust engine threshold — how much risk should auto-approve when
|
||||
# no rule in trust.json matches. Levels:
|
||||
# none — prompt on every flagged command
|
||||
# low — auto-allow low-risk only (default)
|
||||
# medium — auto-allow low + medium
|
||||
# high — auto-allow everything (equivalent to yolo-except-hardline)
|
||||
# Deny rules in trust.json always beat this threshold.
|
||||
"auto_approve_up_to": "low",
|
||||
# When true, /reload-mcp asks the user to confirm before rebuilding
|
||||
# the MCP tool set for the active session. Reloading invalidates
|
||||
# the provider prompt cache (tool schemas are baked into the system
|
||||
@@ -1232,7 +1155,7 @@ DEFAULT_CONFIG = {
|
||||
# Pre-exec security scanning via tirith
|
||||
"security": {
|
||||
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
|
||||
"redact_secrets": True,
|
||||
"redact_secrets": False,
|
||||
"tirith_enabled": True,
|
||||
"tirith_path": "tirith",
|
||||
"tirith_timeout": 5,
|
||||
@@ -1271,10 +1194,6 @@ DEFAULT_CONFIG = {
|
||||
# Seconds between dispatcher ticks (idle or not). Lower = snappier
|
||||
# pickup of newly-ready tasks; higher = less SQL pressure.
|
||||
"dispatch_interval_seconds": 60,
|
||||
# Auto-block after this many consecutive non-success attempts for the
|
||||
# same task/profile (spawn_failed, timed_out, or crashed). Reassignment
|
||||
# resets the streak for the new profile.
|
||||
"failure_limit": 2,
|
||||
},
|
||||
|
||||
# execute_code settings — controls the tool used for programmatic tool calls.
|
||||
@@ -1877,22 +1796,6 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"SEARXNG_URL": {
|
||||
"description": "URL of your SearXNG instance for free self-hosted web search",
|
||||
"prompt": "SearXNG URL (e.g. http://localhost:8080)",
|
||||
"url": "https://searxng.github.io/searxng/",
|
||||
"tools": ["web_search"],
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"BRAVE_SEARCH_API_KEY": {
|
||||
"description": "Brave Search API subscription token (free tier: 2,000 queries/mo)",
|
||||
"prompt": "Brave Search subscription token",
|
||||
"url": "https://brave.com/search/api/",
|
||||
"tools": ["web_search"],
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"BROWSERBASE_API_KEY": {
|
||||
"description": "Browserbase API key for cloud browser (optional — local browser works without this)",
|
||||
"prompt": "Browserbase API key",
|
||||
@@ -1924,15 +1827,6 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"AGENT_BROWSER_ENGINE": {
|
||||
"description": "Browser engine for local mode: auto (default Chrome), lightpanda (faster, no screenshots), chrome",
|
||||
"prompt": "Browser engine (auto/lightpanda/chrome)",
|
||||
"url": "https://github.com/vercel-labs/agent-browser",
|
||||
"tools": ["browser_navigate", "browser_snapshot", "browser_click", "browser_vision"],
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
"advanced": True,
|
||||
},
|
||||
"CAMOFOX_URL": {
|
||||
"description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)",
|
||||
"prompt": "Camofox server URL",
|
||||
@@ -2011,7 +1905,7 @@ OPTIONAL_ENV_VARS = {
|
||||
"LINEAR_API_KEY": {
|
||||
"description": "Linear personal API key (used by the `linear` skill)",
|
||||
"prompt": "Linear API key",
|
||||
"url": "https://linear.app/settings/account/security",
|
||||
"url": "https://linear.app/settings/api",
|
||||
"password": True,
|
||||
"category": "skill",
|
||||
"advanced": True,
|
||||
@@ -4027,10 +3921,10 @@ def load_config() -> Dict[str, Any]:
|
||||
|
||||
_SECURITY_COMMENT = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# Secret redaction is ON by default — strings that look like API keys,
|
||||
# tokens, and passwords are masked in tool output, logs, and chat
|
||||
# responses before the model or user ever sees them. Set redact_secrets
|
||||
# to false to disable (e.g. when developing the redactor itself).
|
||||
# Secret redaction is OFF by default — tool output (terminal stdout,
|
||||
# read_file results, web content) passes through unmodified. Set
|
||||
# redact_secrets to true to mask strings that look like API keys, tokens,
|
||||
# and passwords before they enter the model context and logs.
|
||||
# tirith pre-exec scanning is enabled by default when the tirith binary
|
||||
# is available. Configure via security.tirith_* keys or env vars
|
||||
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
|
||||
@@ -4070,8 +3964,8 @@ _FALLBACK_COMMENT = """
|
||||
|
||||
_COMMENTED_SECTIONS = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# Secret redaction is ON by default. Set to false to pass tool output,
|
||||
# logs, and chat responses through unmodified (e.g. for redactor dev).
|
||||
# Secret redaction is OFF by default. Set to true to mask strings that
|
||||
# look like API keys, tokens, and passwords in tool output and logs.
|
||||
#
|
||||
# security:
|
||||
# redact_secrets: true
|
||||
@@ -4990,100 +4884,3 @@ def _inject_profile_env_vars() -> None:
|
||||
|
||||
# Eagerly inject so that OPTIONAL_ENV_VARS is fully populated at import time.
|
||||
_inject_profile_env_vars()
|
||||
|
||||
|
||||
# ── Platform-plugin env var injection ────────────────────────────────────────
|
||||
# Bundled platform plugins under ``plugins/platforms/*/plugin.yaml`` declare
|
||||
# their required env vars via ``requires_env``. This mirror of
|
||||
# ``_inject_profile_env_vars`` surfaces them in ``hermes config`` UI so users
|
||||
# can configure Teams / IRC / Google Chat without the core repo ever needing
|
||||
# to know they exist.
|
||||
#
|
||||
# Each ``requires_env`` entry may be a bare string (name only) or a dict:
|
||||
#
|
||||
# requires_env:
|
||||
# - TEAMS_CLIENT_ID # minimal
|
||||
# - name: TEAMS_CLIENT_SECRET # rich
|
||||
# description: "Teams bot client secret"
|
||||
# url: "https://portal.azure.com/"
|
||||
# password: true
|
||||
# prompt: "Teams client secret"
|
||||
#
|
||||
# An optional ``optional_env`` block surfaces non-required vars the same way
|
||||
# (e.g. allowlist, home channel).
|
||||
|
||||
_platform_plugin_env_vars_injected = False
|
||||
|
||||
|
||||
def _inject_platform_plugin_env_vars() -> None:
|
||||
"""Populate OPTIONAL_ENV_VARS from bundled platform plugin manifests.
|
||||
|
||||
Called once at module load time. Idempotent — repeated calls are no-ops.
|
||||
Failures are swallowed so a malformed plugin.yaml can't break CLI import.
|
||||
"""
|
||||
global _platform_plugin_env_vars_injected
|
||||
if _platform_plugin_env_vars_injected:
|
||||
return
|
||||
_platform_plugin_env_vars_injected = True
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
|
||||
# Resolve the bundled plugins dir from this file's location so the
|
||||
# injector works regardless of CWD.
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
platforms_dir = repo_root / "plugins" / "platforms"
|
||||
if not platforms_dir.is_dir():
|
||||
return
|
||||
for child in platforms_dir.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
manifest_path = child / "plugin.yaml"
|
||||
if not manifest_path.exists():
|
||||
manifest_path = child / "plugin.yml"
|
||||
if not manifest_path.exists():
|
||||
continue
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
continue
|
||||
label = manifest.get("label") or manifest.get("name") or child.name
|
||||
# Merge required + optional env var declarations.
|
||||
entries = list(manifest.get("requires_env") or [])
|
||||
entries.extend(manifest.get("optional_env") or [])
|
||||
for entry in entries:
|
||||
if isinstance(entry, str):
|
||||
name = entry
|
||||
meta: dict = {}
|
||||
elif isinstance(entry, dict) and entry.get("name"):
|
||||
name = entry["name"]
|
||||
meta = entry
|
||||
else:
|
||||
continue
|
||||
if name in OPTIONAL_ENV_VARS:
|
||||
continue # hardcoded entry wins (back-compat)
|
||||
# Heuristic: anything named *TOKEN, *SECRET, *KEY, *PASSWORD
|
||||
# is a password field unless explicitly overridden.
|
||||
name_upper = name.upper()
|
||||
is_secret = bool(meta.get("password") or meta.get("secret"))
|
||||
if not is_secret and not meta.get("password") is False:
|
||||
is_secret = any(
|
||||
name_upper.endswith(suf)
|
||||
for suf in ("_TOKEN", "_SECRET", "_KEY", "_PASSWORD", "_JSON")
|
||||
)
|
||||
OPTIONAL_ENV_VARS[name] = {
|
||||
"description": (
|
||||
meta.get("description")
|
||||
or f"{label} configuration"
|
||||
),
|
||||
"prompt": meta.get("prompt") or name,
|
||||
"url": meta.get("url") or None,
|
||||
"password": is_secret,
|
||||
"category": meta.get("category") or "messaging",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Eagerly inject so that platform plugin env vars show up in the setup wizard.
|
||||
_inject_platform_plugin_env_vars()
|
||||
|
||||
@@ -212,9 +212,9 @@ def copilot_device_code_login(
|
||||
print(" Waiting for authorization...", end="", flush=True)
|
||||
|
||||
# Step 3: Poll for completion
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
deadline = time.time() + timeout_seconds
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
while time.time() < deadline:
|
||||
time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN)
|
||||
|
||||
poll_data = urllib.parse.urlencode({
|
||||
|
||||
+8
-37
@@ -12,7 +12,6 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -58,8 +57,7 @@ def _cmd_status(args) -> int:
|
||||
print(f" last summary: {summary}")
|
||||
_report = state.get("last_report_path")
|
||||
if _report:
|
||||
suffix = "" if Path(_report).exists() else " (missing)"
|
||||
print(f" last report: {_report}{suffix}")
|
||||
print(f" last report: {_report}")
|
||||
_ih = curator.get_interval_hours()
|
||||
_interval_label = (
|
||||
f"{_ih // 24}d" if _ih % 24 == 0 and _ih >= 24
|
||||
@@ -163,8 +161,6 @@ def _cmd_run(args) -> int:
|
||||
return 1
|
||||
|
||||
dry = bool(getattr(args, "dry_run", False))
|
||||
background = bool(getattr(args, "background", False))
|
||||
synchronous = bool(getattr(args, "synchronous", False)) or not background
|
||||
if dry:
|
||||
print("curator: running DRY-RUN (report only, no mutations)...")
|
||||
else:
|
||||
@@ -175,7 +171,7 @@ def _cmd_run(args) -> int:
|
||||
|
||||
result = curator.run_curator_review(
|
||||
on_summary=_on_summary,
|
||||
synchronous=synchronous,
|
||||
synchronous=bool(args.synchronous),
|
||||
dry_run=dry,
|
||||
)
|
||||
auto = result.get("auto_transitions", {})
|
||||
@@ -192,19 +188,13 @@ def _cmd_run(args) -> int:
|
||||
f"archived={auto.get('archived', 0)} "
|
||||
f"reactivated={auto.get('reactivated', 0)}"
|
||||
)
|
||||
if not synchronous:
|
||||
if not args.synchronous:
|
||||
print("llm pass running in background — check `hermes curator status` later")
|
||||
if dry:
|
||||
if synchronous:
|
||||
print(
|
||||
"dry-run: no changes applied. Read the report with "
|
||||
"`hermes curator status` and run `hermes curator run` (no flag) to apply."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"dry-run: no changes applied. When the report lands, read it with "
|
||||
"`hermes curator status` and run `hermes curator run` (no flag) to apply."
|
||||
)
|
||||
print(
|
||||
"dry-run: no changes applied. When the report lands, read it with "
|
||||
"`hermes curator status` and run `hermes curator run` (no flag) to apply."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -452,18 +442,6 @@ def _cmd_rollback(args) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_list_archived(args) -> int:
|
||||
"""List archived (recoverable) skills."""
|
||||
from tools import skill_usage
|
||||
names = skill_usage.list_archived_skill_names()
|
||||
if not names:
|
||||
print("curator: no archived skills")
|
||||
return 0
|
||||
for name in names:
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring (called from hermes_cli.main)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -483,11 +461,7 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
p_run = subs.add_parser("run", help="Trigger a curator review now")
|
||||
p_run.add_argument(
|
||||
"--sync", "--synchronous", dest="synchronous", action="store_true",
|
||||
help="Wait for the LLM review pass to finish (default for manual runs)",
|
||||
)
|
||||
p_run.add_argument(
|
||||
"--background", dest="background", action="store_true",
|
||||
help="Start the LLM review pass in a background thread and return immediately",
|
||||
help="Wait for the LLM review pass to finish (default: background thread)",
|
||||
)
|
||||
p_run.add_argument(
|
||||
"--dry-run", dest="dry_run", action="store_true",
|
||||
@@ -514,9 +488,6 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
p_restore.add_argument("skill", help="Skill name")
|
||||
p_restore.set_defaults(func=_cmd_restore)
|
||||
|
||||
subs.add_parser("list-archived", help="List archived skills") \
|
||||
.set_defaults(func=_cmd_list_archived)
|
||||
|
||||
p_archive = subs.add_parser(
|
||||
"archive",
|
||||
help="Manually archive a skill (move to .archive/, excluded from prompt)",
|
||||
|
||||
+6
-50
@@ -91,15 +91,6 @@ def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
return steps
|
||||
|
||||
|
||||
def _termux_install_all_fallback_notes() -> list[str]:
|
||||
return [
|
||||
"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).",
|
||||
"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).",
|
||||
"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).",
|
||||
"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).",
|
||||
]
|
||||
|
||||
|
||||
def _has_provider_env_config(content: str) -> bool:
|
||||
"""Return True when ~/.hermes/.env contains provider auth/base URL settings."""
|
||||
return any(key in content for key in _PROVIDER_ENV_HINTS)
|
||||
@@ -116,35 +107,15 @@ def _honcho_is_configured_for_doctor() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _is_kanban_worker_env_gate(item: dict) -> bool:
|
||||
"""Return True when Kanban is unavailable only because this is not a worker process."""
|
||||
if item.get("name") != "kanban":
|
||||
return False
|
||||
if os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return False
|
||||
|
||||
tools = item.get("tools") or []
|
||||
return bool(tools) and all(str(tool).startswith("kanban_") for tool in tools)
|
||||
|
||||
|
||||
def _doctor_tool_availability_detail(toolset: str) -> str:
|
||||
"""Optional explanatory suffix for toolsets whose doctor status needs context."""
|
||||
if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
|
||||
return "(runtime-gated; loaded only for dispatcher-spawned workers)"
|
||||
return ""
|
||||
|
||||
|
||||
def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
|
||||
"""Adjust runtime-gated tool availability for doctor diagnostics."""
|
||||
if not _honcho_is_configured_for_doctor():
|
||||
return available, unavailable
|
||||
|
||||
updated_available = list(available)
|
||||
updated_unavailable = []
|
||||
for item in unavailable:
|
||||
name = item.get("name")
|
||||
if _is_kanban_worker_env_gate(item):
|
||||
if "kanban" not in updated_available:
|
||||
updated_available.append("kanban")
|
||||
continue
|
||||
if name == "honcho" and _honcho_is_configured_for_doctor():
|
||||
if item.get("name") == "honcho":
|
||||
if "honcho" not in updated_available:
|
||||
updated_available.append("honcho")
|
||||
continue
|
||||
@@ -206,7 +177,7 @@ def _build_apikey_providers_list() -> list:
|
||||
|
||||
Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)
|
||||
Base list augmented with any ProviderProfile with auth_type="api_key" not
|
||||
already present — adding plugins/model-providers/<name>/ is sufficient to get into doctor.
|
||||
already present — adding providers/*.py is sufficient to get into doctor.
|
||||
"""
|
||||
_static = [
|
||||
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
|
||||
@@ -1093,11 +1064,6 @@ def run_doctor(args):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _is_termux():
|
||||
check_info("Termux compatibility fallbacks:")
|
||||
for note in _termux_install_all_fallback_notes():
|
||||
check_info(note)
|
||||
|
||||
# =========================================================================
|
||||
# Check: API connectivity
|
||||
# =========================================================================
|
||||
@@ -1239,16 +1205,6 @@ def run_doctor(args):
|
||||
headers=_headers,
|
||||
timeout=10,
|
||||
)
|
||||
if (
|
||||
_pname == "Alibaba/DashScope"
|
||||
and not _base
|
||||
and _resp.status_code == 401
|
||||
):
|
||||
_resp = httpx.get(
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1/models",
|
||||
headers=_headers,
|
||||
timeout=10,
|
||||
)
|
||||
if _resp.status_code == 200:
|
||||
print(f"\r {color('✓', Colors.GREEN)} {_label} ")
|
||||
elif _resp.status_code == 401:
|
||||
@@ -1322,7 +1278,7 @@ def run_doctor(args):
|
||||
|
||||
for tid in available:
|
||||
info = TOOLSET_REQUIREMENTS.get(tid, {})
|
||||
check_ok(info.get("name", tid), _doctor_tool_availability_detail(tid))
|
||||
check_ok(info.get("name", tid))
|
||||
|
||||
for item in unavailable:
|
||||
env_vars = item.get("missing_vars") or item.get("env_vars") or []
|
||||
|
||||
+39
-320
@@ -505,7 +505,6 @@ def _read_systemd_unit_properties(
|
||||
"SubState",
|
||||
"Result",
|
||||
"ExecMainStatus",
|
||||
"MainPID",
|
||||
),
|
||||
) -> dict[str, str]:
|
||||
"""Return selected ``systemctl show`` properties for the gateway unit."""
|
||||
@@ -539,41 +538,6 @@ def _read_systemd_unit_properties(
|
||||
return parsed
|
||||
|
||||
|
||||
def _systemd_main_pid_from_props(props: dict[str, str]) -> int | None:
|
||||
try:
|
||||
pid = int(props.get("MainPID", "0") or "0")
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return pid if pid > 0 else None
|
||||
|
||||
|
||||
def _systemd_main_pid(system: bool = False) -> int | None:
|
||||
return _systemd_main_pid_from_props(_read_systemd_unit_properties(system=system))
|
||||
|
||||
|
||||
def _read_gateway_runtime_status() -> dict | None:
|
||||
try:
|
||||
from gateway.status import read_runtime_status
|
||||
|
||||
state = read_runtime_status()
|
||||
except Exception:
|
||||
return None
|
||||
return state if isinstance(state, dict) else None
|
||||
|
||||
|
||||
def _gateway_runtime_status_for_pid(pid: int | None) -> dict | None:
|
||||
if not pid:
|
||||
return None
|
||||
state = _read_gateway_runtime_status()
|
||||
if not state:
|
||||
return None
|
||||
try:
|
||||
state_pid = int(state.get("pid", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return state if state_pid == pid else None
|
||||
|
||||
|
||||
def _wait_for_systemd_service_restart(
|
||||
*,
|
||||
system: bool = False,
|
||||
@@ -585,10 +549,9 @@ def _wait_for_systemd_service_restart(
|
||||
|
||||
svc = get_service_name()
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
deadline = time.monotonic() + timeout
|
||||
printed_runtime_wait = False
|
||||
deadline = time.time() + timeout
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
while time.time() < deadline:
|
||||
props = _read_systemd_unit_properties(system=system)
|
||||
active_state = props.get("ActiveState", "")
|
||||
sub_state = props.get("SubState", "")
|
||||
@@ -599,32 +562,19 @@ def _wait_for_systemd_service_restart(
|
||||
new_pid = get_running_pid()
|
||||
except Exception:
|
||||
new_pid = None
|
||||
if not new_pid:
|
||||
new_pid = _systemd_main_pid_from_props(props)
|
||||
|
||||
if active_state == "active":
|
||||
if new_pid and (previous_pid is None or new_pid != previous_pid):
|
||||
runtime_state = _gateway_runtime_status_for_pid(new_pid)
|
||||
gateway_state = (runtime_state or {}).get("gateway_state")
|
||||
if gateway_state == "running":
|
||||
print(f"✓ {scope_label} service restarted (PID {new_pid})")
|
||||
return True
|
||||
if gateway_state == "startup_failed":
|
||||
reason = (runtime_state or {}).get("exit_reason") or "startup failed"
|
||||
print(f"⚠ {scope_label} service process restarted (PID {new_pid}), but gateway startup failed: {reason}")
|
||||
return False
|
||||
if not printed_runtime_wait:
|
||||
print(f"⏳ {scope_label} service process started (PID {new_pid}); waiting for gateway runtime...")
|
||||
printed_runtime_wait = True
|
||||
print(f"✓ {scope_label} service restarted (PID {new_pid})")
|
||||
return True
|
||||
if previous_pid is None:
|
||||
print(f"✓ {scope_label} service restarted")
|
||||
return True
|
||||
|
||||
if active_state == "activating" and sub_state == "auto-restart":
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
if _systemd_unit_is_start_limited(props):
|
||||
_print_systemd_start_limit_wait(system=system)
|
||||
return False
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
print(
|
||||
@@ -635,46 +585,6 @@ def _wait_for_systemd_service_restart(
|
||||
return False
|
||||
|
||||
|
||||
def _systemd_unit_is_start_limited(props: dict[str, str]) -> bool:
|
||||
result = props.get("Result", "").lower()
|
||||
sub_state = props.get("SubState", "").lower()
|
||||
return result == "start-limit-hit" or sub_state == "start-limit-hit"
|
||||
|
||||
|
||||
def _systemd_error_indicates_start_limit(exc: subprocess.CalledProcessError) -> bool:
|
||||
parts: list[str] = []
|
||||
for attr in ("stderr", "stdout", "output"):
|
||||
value = getattr(exc, attr, None)
|
||||
if not value:
|
||||
continue
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode(errors="replace")
|
||||
parts.append(str(value))
|
||||
text = "\n".join(parts).lower()
|
||||
return (
|
||||
"start-limit-hit" in text
|
||||
or "start request repeated too quickly" in text
|
||||
or "start-limit" in text
|
||||
)
|
||||
|
||||
|
||||
def _systemd_service_is_start_limited(system: bool = False) -> bool:
|
||||
return _systemd_unit_is_start_limited(_read_systemd_unit_properties(system=system))
|
||||
|
||||
|
||||
def _print_systemd_start_limit_wait(system: bool = False) -> None:
|
||||
svc = get_service_name()
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
scope_flag = " --system" if system else ""
|
||||
systemctl_prefix = "systemctl " if system else "systemctl --user "
|
||||
journal_prefix = "journalctl " if system else "journalctl --user "
|
||||
print(f"⏳ {scope_label} service is temporarily rate-limited by systemd.")
|
||||
print(" systemd is refusing another immediate start after repeated exits.")
|
||||
print(f" Wait for the start-limit window to expire, then run: {'sudo ' if system else ''}hermes gateway restart{scope_flag}")
|
||||
print(f" Or clear the failed state manually: {systemctl_prefix}reset-failed {svc}")
|
||||
print(f" Check logs: {journal_prefix}-u {svc} -l --since '5 min ago'")
|
||||
|
||||
|
||||
def _recover_pending_systemd_restart(system: bool = False, previous_pid: int | None = None) -> bool:
|
||||
"""Recover a planned service restart that is stuck in systemd state."""
|
||||
props = _read_systemd_unit_properties(system=system)
|
||||
@@ -830,46 +740,6 @@ def _print_other_profiles_gateway_status() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _gateway_list() -> None:
|
||||
"""List all profiles and their gateway running status.
|
||||
|
||||
Provides a single-command overview of every known profile and whether
|
||||
its gateway is currently running, so multi-profile users don't have to
|
||||
check each profile individually.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.profiles import list_profiles, get_active_profile_name
|
||||
except Exception:
|
||||
print("Unable to list profiles.")
|
||||
return
|
||||
|
||||
profiles = list_profiles()
|
||||
if not profiles:
|
||||
print("No profiles found.")
|
||||
return
|
||||
|
||||
current = get_active_profile_name()
|
||||
|
||||
print("Gateways:")
|
||||
for prof in profiles:
|
||||
marker = "✓" if prof.gateway_running else "✗"
|
||||
label = prof.name
|
||||
if prof.name == current:
|
||||
label += " (current)"
|
||||
parts = [f" {marker} {label:<24s}"]
|
||||
if prof.gateway_running:
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
pid = get_running_pid(prof.path / "gateway.pid", cleanup_stale=False)
|
||||
if pid:
|
||||
parts.append(f"PID {pid}")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
parts.append("not running")
|
||||
print(" — ".join(parts))
|
||||
|
||||
|
||||
def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None,
|
||||
all_profiles: bool = False) -> int:
|
||||
"""Kill any running gateway processes. Returns count killed.
|
||||
@@ -1097,27 +967,6 @@ class UserSystemdUnavailableError(RuntimeError):
|
||||
"""
|
||||
|
||||
|
||||
class SystemScopeRequiresRootError(RuntimeError):
|
||||
"""Raised when a system-scope gateway operation is attempted as non-root.
|
||||
|
||||
System-scope units live in ``/etc/systemd/system/`` and require root for
|
||||
install / uninstall / start / stop / restart via ``systemctl``. The
|
||||
previous behavior was ``sys.exit(1)`` which blew past the wizard's
|
||||
``except Exception`` guards and dumped the user at a bare shell prompt
|
||||
with no guidance. Raising a typed exception lets callers that can
|
||||
recover (the setup wizard) print actionable remediation instead, while
|
||||
``gateway_command`` still exits 1 with the same message for the direct
|
||||
CLI path.
|
||||
|
||||
``args[0]`` carries the user-facing message, ``args[1]`` the action name.
|
||||
``str(e)`` returns only the message (not the tuple repr) so format
|
||||
strings like ``f"Failed: {e}"`` render cleanly.
|
||||
"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.args[0] if self.args else ""
|
||||
|
||||
|
||||
def _user_dbus_socket_path() -> Path:
|
||||
"""Return the expected per-user D-Bus socket path (regardless of existence)."""
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
@@ -1533,10 +1382,8 @@ def print_systemd_scope_conflict_warning() -> None:
|
||||
|
||||
def _require_root_for_system_service(action: str) -> None:
|
||||
if os.geteuid() != 0:
|
||||
raise SystemScopeRequiresRootError(
|
||||
f"System gateway {action} requires root. Re-run with sudo.",
|
||||
action,
|
||||
)
|
||||
print(f"System gateway {action} requires root. Re-run with sudo.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _system_service_identity(run_as_user: str | None = None) -> tuple[str, str, str]:
|
||||
@@ -2083,47 +1930,6 @@ def _select_systemd_scope(system: bool = False) -> bool:
|
||||
return get_systemd_unit_path(system=True).exists() and not get_systemd_unit_path(system=False).exists()
|
||||
|
||||
|
||||
def _system_scope_wizard_would_need_root(system: bool = False) -> bool:
|
||||
"""True when the setup wizard is about to trigger a system-scope operation
|
||||
as a non-root user.
|
||||
|
||||
Replicates the decision ``_select_systemd_scope`` makes inside
|
||||
``systemd_start`` / ``systemd_restart`` / ``systemd_stop`` so the wizard
|
||||
can detect the dead-end BEFORE prompting, rather than letting
|
||||
``SystemScopeRequiresRootError`` propagate out and leave the user
|
||||
staring at a bare shell.
|
||||
"""
|
||||
if os.geteuid() == 0:
|
||||
return False
|
||||
return _select_systemd_scope(system=system)
|
||||
|
||||
|
||||
def _print_system_scope_remediation(action: str) -> None:
|
||||
"""Print actionable remediation when the wizard skips a system-scope
|
||||
prompt because the user isn't root. Keeps the wizard flowing instead of
|
||||
aborting.
|
||||
"""
|
||||
svc = get_service_name()
|
||||
print_warning(
|
||||
f"Gateway is installed as a system-wide service — "
|
||||
f"{action} requires root."
|
||||
)
|
||||
print_info(" Options:")
|
||||
print_info(f" 1. {action.capitalize()} it this time:")
|
||||
if action == "start":
|
||||
print_info(f" sudo systemctl start {svc}")
|
||||
elif action == "stop":
|
||||
print_info(f" sudo systemctl stop {svc}")
|
||||
elif action == "restart":
|
||||
print_info(f" sudo systemctl restart {svc}")
|
||||
else:
|
||||
print_info(f" sudo systemctl {action} {svc}")
|
||||
print_info(" 2. Switch to a per-user service (recommended for personal use):")
|
||||
print_info(" sudo hermes gateway uninstall --system")
|
||||
print_info(" hermes gateway install")
|
||||
print_info(" hermes gateway start")
|
||||
|
||||
|
||||
def _get_restart_drain_timeout() -> float:
|
||||
"""Return the configured gateway restart drain timeout in seconds."""
|
||||
raw = os.getenv("HERMES_RESTART_DRAIN_TIMEOUT", "").strip()
|
||||
@@ -2265,52 +2071,41 @@ def systemd_restart(system: bool = False):
|
||||
refresh_systemd_unit_if_needed(system=system)
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
pid = get_running_pid() or _systemd_main_pid(system=system)
|
||||
if pid is not None:
|
||||
pid = get_running_pid()
|
||||
if pid is not None and _request_gateway_self_restart(pid):
|
||||
import time
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
svc = get_service_name()
|
||||
drain_timeout = _get_restart_drain_timeout()
|
||||
|
||||
print(f"⏳ {scope_label} service restarting gracefully (PID {pid})...")
|
||||
if _graceful_restart_via_sigusr1(pid, drain_timeout + 5):
|
||||
# The gateway exits with code 75 for a planned service restart.
|
||||
# RestartSec can otherwise delay the relaunch even though the
|
||||
# operator asked for an immediate restart, so kick the unit once
|
||||
# the old PID has exited and then wait for the replacement PID.
|
||||
_run_systemctl(
|
||||
["reset-failed", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
_run_systemctl(
|
||||
["restart", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=90,
|
||||
)
|
||||
if _wait_for_systemd_service_restart(system=system, previous_pid=pid):
|
||||
return
|
||||
if _systemd_service_is_start_limited(system=system):
|
||||
return
|
||||
# Phase 1: wait for old process to exit (drain + shutdown)
|
||||
print(f"⏳ {scope_label} service draining active work...")
|
||||
deadline = time.time() + 90
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
time.sleep(1)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
break # old process is gone
|
||||
else:
|
||||
print(f"⚠ Old process (PID {pid}) still alive after 90s")
|
||||
|
||||
print(
|
||||
f"⚠ Graceful restart did not complete within {int(drain_timeout + 5)}s; "
|
||||
"forcing a service restart..."
|
||||
)
|
||||
# The gateway exits with code 75 for a planned service restart.
|
||||
# systemd can sit in the RestartSec window or even wedge itself into a
|
||||
# failed/rate-limited state if the operator asks for another restart in
|
||||
# the middle of that handoff. Clear any stale failed state and kick the
|
||||
# unit immediately so `hermes gateway restart` behaves idempotently.
|
||||
_run_systemctl(
|
||||
["reset-failed", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
try:
|
||||
_run_systemctl(["restart", svc], system=system, check=True, timeout=90)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if _systemd_error_indicates_start_limit(exc) or _systemd_service_is_start_limited(system=system):
|
||||
_print_systemd_start_limit_wait(system=system)
|
||||
return
|
||||
raise
|
||||
_run_systemctl(
|
||||
["start", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=90,
|
||||
)
|
||||
_wait_for_systemd_service_restart(system=system, previous_pid=pid)
|
||||
return
|
||||
|
||||
@@ -2323,14 +2118,8 @@ def systemd_restart(system: bool = False):
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
try:
|
||||
_run_systemctl(["restart", get_service_name()], system=system, check=True, timeout=90)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if _systemd_error_indicates_start_limit(exc) or _systemd_service_is_start_limited(system=system):
|
||||
_print_systemd_start_limit_wait(system=system)
|
||||
return
|
||||
raise
|
||||
_wait_for_systemd_service_restart(system=system, previous_pid=pid)
|
||||
_run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90)
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service restarted")
|
||||
|
||||
|
||||
|
||||
@@ -2402,10 +2191,6 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False)
|
||||
result_code = unit_props.get("Result", "")
|
||||
if active_state == "activating" and sub_state == "auto-restart":
|
||||
print(" ⏳ Restart pending: systemd is waiting to relaunch the gateway")
|
||||
elif _systemd_unit_is_start_limited(unit_props):
|
||||
print(" ⏳ Restart pending: systemd is temporarily rate-limiting starts")
|
||||
print(f" Run after the start-limit window expires: {'sudo ' if system else ''}hermes gateway restart{scope_flag}")
|
||||
print(f" Or clear it manually: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()}")
|
||||
elif active_state == "failed" and exec_main_status == str(GATEWAY_SERVICE_RESTART_EXIT_CODE):
|
||||
print(" ⚠ Planned restart is stuck in systemd failed state (exit 75)")
|
||||
print(f" Run: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()} && {'sudo ' if system else ''}hermes gateway start{scope_flag}")
|
||||
@@ -2770,42 +2555,6 @@ def launchd_status(deep: bool = False):
|
||||
# Gateway Runner
|
||||
# =============================================================================
|
||||
|
||||
def _truthy_env(value: str | None) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _is_official_docker_checkout() -> bool:
|
||||
return (
|
||||
str(PROJECT_ROOT) == "/opt/hermes"
|
||||
and (PROJECT_ROOT / "docker" / "entrypoint.sh").is_file()
|
||||
)
|
||||
|
||||
|
||||
def _guard_official_docker_root_gateway() -> None:
|
||||
"""Refuse gateway startup when the official Docker privilege drop was bypassed."""
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
return
|
||||
if _truthy_env(os.getenv("HERMES_ALLOW_ROOT_GATEWAY")):
|
||||
return
|
||||
if not _is_official_docker_checkout():
|
||||
return
|
||||
|
||||
print_error(
|
||||
"Refusing to run the Hermes gateway as root inside the official Docker image."
|
||||
)
|
||||
print(
|
||||
" The image entrypoint normally drops privileges to the 'hermes' user. "
|
||||
"If you override entrypoint in Docker Compose, include "
|
||||
"/opt/hermes/docker/entrypoint.sh before the Hermes command."
|
||||
)
|
||||
print(
|
||||
" Running the gateway as root can leave root-owned files in "
|
||||
"$HERMES_HOME and break later non-root dashboard/gateway runs."
|
||||
)
|
||||
print(" Set HERMES_ALLOW_ROOT_GATEWAY=1 only if you intentionally accept this risk.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
"""Run the gateway in foreground.
|
||||
|
||||
@@ -2816,7 +2565,6 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
This prevents systemd restart loops when the old process
|
||||
hasn't fully exited yet.
|
||||
"""
|
||||
_guard_official_docker_root_gateway()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# Refresh the systemd unit definition on every boot so that restart
|
||||
@@ -4367,9 +4115,7 @@ def gateway_setup():
|
||||
print_success("Gateway service is installed and running.")
|
||||
elif service_installed:
|
||||
print_warning("Gateway service is installed but not running.")
|
||||
if supports_systemd_services() and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("start")
|
||||
elif prompt_yes_no(" Start it now?", True):
|
||||
if prompt_yes_no(" Start it now?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
systemd_start()
|
||||
@@ -4379,12 +4125,6 @@ def gateway_setup():
|
||||
print_error(" Failed to start — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
# Defense in depth: the pre-check above should have caught
|
||||
# this, but handle the race/edge case gracefully instead of
|
||||
# letting the exception escape the wizard.
|
||||
print_error(f" Failed to start: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Failed to start: {e}")
|
||||
else:
|
||||
@@ -4434,9 +4174,7 @@ def gateway_setup():
|
||||
service_running = _is_service_running()
|
||||
|
||||
if service_running:
|
||||
if supports_systemd_services() and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("restart")
|
||||
elif prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
systemd_restart()
|
||||
@@ -4449,15 +4187,10 @@ def gateway_setup():
|
||||
print_error(" Restart failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
_print_system_scope_remediation("restart")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
elif service_installed:
|
||||
if supports_systemd_services() and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("start")
|
||||
elif prompt_yes_no(" Start the gateway service?", True):
|
||||
if prompt_yes_no(" Start the gateway service?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
systemd_start()
|
||||
@@ -4467,9 +4200,6 @@ def gateway_setup():
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
else:
|
||||
@@ -4543,14 +4273,6 @@ def gateway_command(args):
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
sys.exit(1)
|
||||
except SystemScopeRequiresRootError as e:
|
||||
# The direct ``hermes gateway install|uninstall|start|stop|restart``
|
||||
# path lands here when the user typed a system-scope action without
|
||||
# sudo. Same exit code as before — just gives the wizard a way to
|
||||
# intercept the same condition with friendlier guidance before the
|
||||
# error is raised.
|
||||
print(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _gateway_command_inner(args):
|
||||
@@ -4875,9 +4597,6 @@ def _gateway_command_inner(args):
|
||||
# Show other profiles' gateway status for multi-profile awareness
|
||||
_print_other_profiles_gateway_status()
|
||||
|
||||
elif subcmd == "list":
|
||||
_gateway_list()
|
||||
|
||||
elif subcmd == "migrate-legacy":
|
||||
# Stop, disable, and remove legacy Hermes gateway unit files from
|
||||
# pre-rename installs (e.g. hermes.service). Profile units and
|
||||
|
||||
+4
-188
@@ -70,7 +70,6 @@ def _task_to_dict(t: kb.Task) -> dict[str, Any]:
|
||||
"completed_at": t.completed_at,
|
||||
"result": t.result,
|
||||
"skills": list(t.skills) if t.skills else [],
|
||||
"max_retries": t.max_retries,
|
||||
}
|
||||
|
||||
|
||||
@@ -285,15 +284,6 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
"(repeatable). Appended to the built-in "
|
||||
"kanban-worker skill. Example: "
|
||||
"--skill translation --skill github-code-review")
|
||||
p_create.add_argument("--max-retries", type=int, default=None,
|
||||
metavar="N",
|
||||
help="Per-task override for the consecutive-failure "
|
||||
"circuit breaker. Trip on the Nth failure — "
|
||||
"e.g. --max-retries 1 blocks on the first "
|
||||
"failure (no retries), --max-retries 3 allows "
|
||||
"two retries. Omit to use the dispatcher's "
|
||||
"kanban.failure_limit config "
|
||||
f"(default {kb.DEFAULT_FAILURE_LIMIT}).")
|
||||
p_create.add_argument("--json", action="store_true", help="Emit JSON output")
|
||||
|
||||
# --- list ---
|
||||
@@ -453,8 +443,8 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
help="Cap number of spawns this pass")
|
||||
p_disp.add_argument("--failure-limit", type=int,
|
||||
default=kb.DEFAULT_SPAWN_FAILURE_LIMIT,
|
||||
help=f"Auto-block a task after this many consecutive non-success attempts "
|
||||
f"(spawn_failed, timed_out, or crashed; default: {kb.DEFAULT_SPAWN_FAILURE_LIMIT})")
|
||||
help=f"Auto-block a task after this many consecutive spawn failures "
|
||||
f"(default: {kb.DEFAULT_SPAWN_FAILURE_LIMIT})")
|
||||
p_disp.add_argument("--json", action="store_true")
|
||||
|
||||
# --- daemon (deprecated) ---
|
||||
@@ -570,42 +560,6 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
)
|
||||
p_ctx.add_argument("task_id")
|
||||
|
||||
# --- specify --- (triage → todo via auxiliary LLM)
|
||||
p_specify = sub.add_parser(
|
||||
"specify",
|
||||
help="Flesh out a triage-column task into a concrete spec "
|
||||
"(title + body) and promote it to todo. Uses the auxiliary "
|
||||
"LLM configured under auxiliary.triage_specifier.",
|
||||
)
|
||||
p_specify.add_argument(
|
||||
"task_id",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Task id to specify (required unless --all is given)",
|
||||
)
|
||||
p_specify.add_argument(
|
||||
"--all",
|
||||
dest="all_triage",
|
||||
action="store_true",
|
||||
help="Specify every task currently in the triage column",
|
||||
)
|
||||
p_specify.add_argument(
|
||||
"--tenant",
|
||||
default=None,
|
||||
help="When used with --all, restrict the sweep to this tenant",
|
||||
)
|
||||
p_specify.add_argument(
|
||||
"--author",
|
||||
default=None,
|
||||
help="Author name recorded on the audit comment "
|
||||
"(default: $HERMES_PROFILE or 'specifier')",
|
||||
)
|
||||
p_specify.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Emit one JSON object per task on stdout",
|
||||
)
|
||||
|
||||
# --- gc ---
|
||||
p_gc = sub.add_parser(
|
||||
"gc", help="Garbage-collect archived-task workspaces, old events, and old logs",
|
||||
@@ -720,7 +674,6 @@ def kanban_command(args: argparse.Namespace) -> int:
|
||||
"notify-list": _cmd_notify_list,
|
||||
"notify-unsubscribe": _cmd_notify_unsubscribe,
|
||||
"context": _cmd_context,
|
||||
"specify": _cmd_specify,
|
||||
"gc": _cmd_gc,
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
@@ -990,12 +943,7 @@ def _cmd_init(args: argparse.Namespace) -> int:
|
||||
|
||||
def _cmd_heartbeat(args: argparse.Namespace) -> int:
|
||||
with kb.connect() as conn:
|
||||
ok = kb.heartbeat_worker(
|
||||
conn,
|
||||
args.task_id,
|
||||
note=getattr(args, "note", None),
|
||||
expected_run_id=_worker_run_id_for(args.task_id),
|
||||
)
|
||||
ok = kb.heartbeat_worker(conn, args.task_id, note=getattr(args, "note", None))
|
||||
if not ok:
|
||||
print(f"cannot heartbeat {args.task_id} (not running?)", file=sys.stderr)
|
||||
return 1
|
||||
@@ -1029,14 +977,6 @@ def _cmd_create(args: argparse.Namespace) -> int:
|
||||
except ValueError as exc:
|
||||
print(f"kanban: --max-runtime: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
max_retries = getattr(args, "max_retries", None)
|
||||
if max_retries is not None and max_retries < 1:
|
||||
print(
|
||||
f"kanban: --max-retries must be >= 1 (got {max_retries}); "
|
||||
"use 1 to trip on the first failure.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
task_id = kb.create_task(
|
||||
conn,
|
||||
@@ -1053,7 +993,6 @@ def _cmd_create(args: argparse.Namespace) -> int:
|
||||
idempotency_key=getattr(args, "idempotency_key", None),
|
||||
max_runtime_seconds=max_runtime,
|
||||
skills=getattr(args, "skills", None) or None,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
task = kb.get_task(conn, task_id)
|
||||
if getattr(args, "json", False):
|
||||
@@ -1127,16 +1066,10 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
||||
parents = kb.parent_ids(conn, args.task_id)
|
||||
children = kb.child_ids(conn, args.task_id)
|
||||
runs = kb.list_runs(conn, args.task_id)
|
||||
# Workers hand off via ``task_runs.summary`` (kanban-worker skill);
|
||||
# ``tasks.result`` is left NULL unless the caller explicitly passed
|
||||
# ``result=``. Surfacing the latest summary here keeps ``show`` from
|
||||
# looking like a no-op when the worker actually did real work.
|
||||
latest_summary = kb.latest_summary(conn, args.task_id)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
payload = {
|
||||
"task": _task_to_dict(task),
|
||||
"latest_summary": latest_summary,
|
||||
"parents": parents,
|
||||
"children": children,
|
||||
"comments": [
|
||||
@@ -1181,23 +1114,6 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
||||
(f" @ {task.workspace_path}" if task.workspace_path else ""))
|
||||
if task.skills:
|
||||
print(f" skills: {', '.join(task.skills)}")
|
||||
# Effective retry threshold. Show the per-task override if set,
|
||||
# otherwise the dispatcher's resolved value from config (or the
|
||||
# default if config doesn't set it either). Helps operators see
|
||||
# why a task auto-blocked earlier/later than they expected.
|
||||
if task.max_retries is not None:
|
||||
print(f" max-retries: {task.max_retries} (task)")
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
cfg_val = (cfg.get("kanban", {}) or {}).get("failure_limit")
|
||||
except Exception:
|
||||
cfg_val = None
|
||||
if cfg_val is not None and int(cfg_val) != kb.DEFAULT_FAILURE_LIMIT:
|
||||
print(f" max-retries: {int(cfg_val)} (config kanban.failure_limit)")
|
||||
else:
|
||||
print(f" max-retries: {kb.DEFAULT_FAILURE_LIMIT} (default)")
|
||||
print(f" created: {_fmt_ts(task.created_at)} by {task.created_by or '-'}")
|
||||
|
||||
# Diagnostics section — surface active distress signals at the top
|
||||
@@ -1240,13 +1156,6 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
||||
print()
|
||||
print("Result:")
|
||||
print(task.result)
|
||||
elif latest_summary:
|
||||
# Worker handoff lives on the latest run, not on tasks.result.
|
||||
# Surface it at top-level so a glance at ``hermes kanban show <id>``
|
||||
# tells you what the worker did even if tasks.result is empty.
|
||||
print()
|
||||
print("Latest summary:")
|
||||
print(latest_summary)
|
||||
if comments:
|
||||
print()
|
||||
print(f"Comments ({len(comments)}):")
|
||||
@@ -1497,18 +1406,6 @@ def _cmd_comment(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _worker_run_id_for(task_id: str) -> Optional[int]:
|
||||
if os.environ.get("HERMES_KANBAN_TASK") != task_id:
|
||||
return None
|
||||
raw = os.environ.get("HERMES_KANBAN_RUN_ID")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _cmd_complete(args: argparse.Namespace) -> int:
|
||||
"""Mark one or more tasks done. Supports a single id or a list."""
|
||||
ids = list(args.task_ids or [])
|
||||
@@ -1545,7 +1442,6 @@ def _cmd_complete(args: argparse.Namespace) -> int:
|
||||
result=args.result,
|
||||
summary=summary,
|
||||
metadata=metadata,
|
||||
expected_run_id=_worker_run_id_for(tid),
|
||||
):
|
||||
failed.append(tid)
|
||||
print(f"cannot complete {tid} (unknown id or terminal state)", file=sys.stderr)
|
||||
@@ -1591,12 +1487,7 @@ def _cmd_block(args: argparse.Namespace) -> int:
|
||||
for tid in ids:
|
||||
if reason:
|
||||
kb.add_comment(conn, tid, author, f"BLOCKED: {reason}")
|
||||
if not kb.block_task(
|
||||
conn,
|
||||
tid,
|
||||
reason=reason,
|
||||
expected_run_id=_worker_run_id_for(tid),
|
||||
):
|
||||
if not kb.block_task(conn, tid, reason=reason):
|
||||
failed.append(tid)
|
||||
print(f"cannot block {tid}", file=sys.stderr)
|
||||
else:
|
||||
@@ -1730,7 +1621,6 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
|
||||
" kanban:\n"
|
||||
" dispatch_in_gateway: true # default\n"
|
||||
" dispatch_interval_seconds: 60\n"
|
||||
" failure_limit: 2 # consecutive non-success attempts before auto-block\n"
|
||||
"\n"
|
||||
"Running both the gateway AND this standalone daemon will\n"
|
||||
"race for claims. If you truly need the old standalone\n"
|
||||
@@ -2017,80 +1907,6 @@ def _cmd_context(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_specify(args: argparse.Namespace) -> int:
|
||||
"""Flesh out a triage task (or all of them) via auxiliary LLM,
|
||||
then promote to todo. Thin wrapper over ``kanban_specify``."""
|
||||
from hermes_cli import kanban_specify as spec
|
||||
|
||||
all_flag = bool(getattr(args, "all_triage", False))
|
||||
tenant = getattr(args, "tenant", None)
|
||||
author = getattr(args, "author", None) or _profile_author()
|
||||
want_json = bool(getattr(args, "json", False))
|
||||
|
||||
if args.task_id and all_flag:
|
||||
print(
|
||||
"kanban: pass either a task id OR --all, not both",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if all_flag:
|
||||
ids = spec.list_triage_ids(tenant=tenant)
|
||||
if not ids:
|
||||
msg = (
|
||||
"No triage tasks"
|
||||
+ (f" for tenant {tenant!r}" if tenant else "")
|
||||
+ "."
|
||||
)
|
||||
if want_json:
|
||||
print(json.dumps({"specified": 0, "total": 0}))
|
||||
else:
|
||||
print(msg)
|
||||
return 0
|
||||
elif args.task_id:
|
||||
ids = [args.task_id]
|
||||
else:
|
||||
print(
|
||||
"kanban: specify requires a task id or --all",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
ok_count = 0
|
||||
fail_count = 0
|
||||
for tid in ids:
|
||||
outcome = spec.specify_task(tid, author=author)
|
||||
if outcome.ok:
|
||||
ok_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
if want_json:
|
||||
print(json.dumps({
|
||||
"task_id": outcome.task_id,
|
||||
"ok": outcome.ok,
|
||||
"reason": outcome.reason,
|
||||
"new_title": outcome.new_title,
|
||||
}))
|
||||
else:
|
||||
if outcome.ok:
|
||||
title_suffix = (
|
||||
f" — retitled: {outcome.new_title!r}"
|
||||
if outcome.new_title
|
||||
else ""
|
||||
)
|
||||
print(f"Specified {outcome.task_id} → todo{title_suffix}")
|
||||
else:
|
||||
print(
|
||||
f"kanban: specify {outcome.task_id}: {outcome.reason}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not all_flag:
|
||||
return 0 if ok_count == 1 else 1
|
||||
# --all: succeed if at least one promotion landed; exit 1 only when
|
||||
# every candidate failed (honest signal for scripts).
|
||||
return 0 if (ok_count > 0 or not ids) else 1
|
||||
|
||||
|
||||
def _cmd_gc(args: argparse.Namespace) -> int:
|
||||
"""Remove scratch workspaces of archived tasks, prune old events, and
|
||||
delete old worker logs."""
|
||||
|
||||
+163
-876
File diff suppressed because it is too large
Load Diff
@@ -312,57 +312,21 @@ def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
)]
|
||||
|
||||
|
||||
def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
"""Task's unified ``consecutive_failures`` counter is climbing —
|
||||
something about this task+profile combo is broken and each retry
|
||||
fails the same way. Triggers regardless of the specific failure
|
||||
mode (spawn error, timeout, crash) because operationally they
|
||||
all look the same: the kernel keeps retrying and the operator
|
||||
needs to intervene.
|
||||
def _rule_repeated_spawn_failures(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
"""Task's ``spawn_failures`` counter is climbing — worker can't
|
||||
even start. Usually a profile misconfiguration (missing config.yaml,
|
||||
bad PATH/venv, wrong credentials).
|
||||
|
||||
Threshold: cfg["failure_threshold"] (default 3). A threshold of 3
|
||||
is one below the circuit-breaker's default (5), so the diagnostic
|
||||
surfaces BEFORE the breaker trips — giving operators a window to
|
||||
fix the problem while the dispatcher's still retrying.
|
||||
|
||||
Accepts the legacy ``spawn_failure_threshold`` config key for
|
||||
back-compat.
|
||||
Threshold: cfg["spawn_failure_threshold"] (default 3).
|
||||
"""
|
||||
threshold = int(cfg.get(
|
||||
"failure_threshold",
|
||||
cfg.get("spawn_failure_threshold", 3),
|
||||
))
|
||||
# Read the new unified counter name, with a fallback to the legacy
|
||||
# column name so this rule keeps working against old DB rows the
|
||||
# caller somehow materialised without running the migration.
|
||||
failures = (
|
||||
_task_field(task, "consecutive_failures", None)
|
||||
if _task_field(task, "consecutive_failures", None) is not None
|
||||
else _task_field(task, "spawn_failures", 0)
|
||||
)
|
||||
threshold = int(cfg.get("spawn_failure_threshold", 3))
|
||||
failures = _task_field(task, "spawn_failures", 0)
|
||||
if failures is None or failures < threshold:
|
||||
return []
|
||||
last_err = (
|
||||
_task_field(task, "last_failure_error", None)
|
||||
if _task_field(task, "last_failure_error", None) is not None
|
||||
else _task_field(task, "last_spawn_error", None)
|
||||
)
|
||||
last_err = _task_field(task, "last_spawn_error")
|
||||
assignee = _task_field(task, "assignee")
|
||||
|
||||
# Classify the most recent failure by peeking at run outcomes so
|
||||
# the title + suggested action can be specific without a separate
|
||||
# per-outcome rule.
|
||||
ordered_runs = sorted(runs, key=lambda r: _task_field(r, "id", 0))
|
||||
most_recent_outcome = None
|
||||
for r in reversed(ordered_runs):
|
||||
oc = _task_field(r, "outcome")
|
||||
if oc in ("spawn_failed", "timed_out", "crashed"):
|
||||
most_recent_outcome = oc
|
||||
break
|
||||
|
||||
actions: list[DiagnosticAction] = []
|
||||
if most_recent_outcome == "spawn_failed" and assignee and assignee != "default":
|
||||
# Spawn is failing specifically — profile setup issue.
|
||||
if assignee and assignee != "default":
|
||||
actions.append(DiagnosticAction(
|
||||
kind="cli_hint",
|
||||
label=f"Verify profile: hermes -p {assignee} doctor",
|
||||
@@ -374,49 +338,28 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
label=f"Fix profile auth: hermes -p {assignee} auth",
|
||||
payload={"command": f"hermes -p {assignee} auth"},
|
||||
))
|
||||
elif most_recent_outcome in ("timed_out", "crashed"):
|
||||
# Worker got off the ground but died. Logs are the right place
|
||||
# to diagnose; reclaim/reassign are the recovery levers.
|
||||
task_id = _task_field(task, "id")
|
||||
if task_id:
|
||||
actions.append(DiagnosticAction(
|
||||
kind="cli_hint",
|
||||
label=f"Check logs: hermes kanban log {task_id}",
|
||||
payload={"command": f"hermes kanban log {task_id}"},
|
||||
suggested=True,
|
||||
))
|
||||
actions.extend(_generic_recovery_actions(
|
||||
task, running=_task_field(task, "status") == "running",
|
||||
))
|
||||
|
||||
actions.extend(_generic_recovery_actions(task, running=False))
|
||||
severity = "critical" if failures >= threshold * 2 else "error"
|
||||
err_text = (last_err or "").strip() if last_err else ""
|
||||
err_snippet = err_text[:500] + ("…" if len(err_text) > 500 else "") if err_text else ""
|
||||
outcome_label = {
|
||||
"spawn_failed": "spawn",
|
||||
"timed_out": "timeout",
|
||||
"crashed": "crash",
|
||||
}.get(most_recent_outcome or "", "failure")
|
||||
if err_snippet:
|
||||
title = f"Agent {outcome_label} x{failures}: {err_snippet.splitlines()[0][:160]}"
|
||||
title = f"Agent spawn failed {failures}x: {err_snippet.splitlines()[0][:160]}"
|
||||
detail = (
|
||||
f"This task has failed {failures} times in a row "
|
||||
f"(most recent: {outcome_label}). Full last error:\n\n"
|
||||
f"{err_snippet}\n\n"
|
||||
f"The dispatcher will keep retrying until the consecutive-"
|
||||
f"failures counter trips the circuit breaker (default 5), "
|
||||
f"at which point the task auto-blocks. Fix the root cause "
|
||||
f"and reclaim to retry."
|
||||
f"The dispatcher tried to launch a worker {failures} times "
|
||||
f"and failed every time. Full last error:\n\n{err_snippet}\n\n"
|
||||
f"Common causes: missing config.yaml, bad venv/PATH, or "
|
||||
f"missing credentials for the profile's configured provider."
|
||||
)
|
||||
else:
|
||||
title = f"Agent {outcome_label} x{failures} (no error recorded)"
|
||||
title = f"Agent spawn failed {failures}x (no error recorded)"
|
||||
detail = (
|
||||
f"This task has failed {failures} times in a row "
|
||||
f"(most recent: {outcome_label}) but no error text was "
|
||||
f"captured. Check the suggested command or the worker log."
|
||||
f"The dispatcher tried to launch a worker {failures} times "
|
||||
f"and failed every time, but no error text was captured. "
|
||||
f"Usually a profile configuration issue — check profile "
|
||||
f"health with the suggested command."
|
||||
)
|
||||
return [Diagnostic(
|
||||
kind="repeated_failures",
|
||||
kind="repeated_spawn_failures",
|
||||
severity=severity,
|
||||
title=title,
|
||||
detail=detail,
|
||||
@@ -424,11 +367,7 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
first_seen_at=now,
|
||||
last_seen_at=now,
|
||||
count=failures,
|
||||
data={
|
||||
"consecutive_failures": failures,
|
||||
"most_recent_outcome": most_recent_outcome,
|
||||
"last_error": last_err,
|
||||
},
|
||||
data={"spawn_failures": failures, "last_spawn_error": last_err},
|
||||
)]
|
||||
|
||||
|
||||
@@ -439,23 +378,7 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
broken (OOM, missing dependency, tool it needs is down).
|
||||
|
||||
Threshold: cfg["crash_threshold"] (default 2).
|
||||
|
||||
Narrower than ``repeated_failures`` — fires earlier (2 crashes vs 3
|
||||
total failures) so the operator gets a crash-specific heads-up
|
||||
before the unified rule kicks in. Suppresses itself when the
|
||||
unified rule is also about to fire, to avoid double-flagging.
|
||||
"""
|
||||
failure_threshold = int(cfg.get(
|
||||
"failure_threshold",
|
||||
cfg.get("spawn_failure_threshold", 3),
|
||||
))
|
||||
unified_counter = (
|
||||
_task_field(task, "consecutive_failures", 0) or 0
|
||||
)
|
||||
# Unified rule will catch this — let it handle to avoid double fire.
|
||||
if unified_counter >= failure_threshold:
|
||||
return []
|
||||
|
||||
threshold = int(cfg.get("crash_threshold", 2))
|
||||
ordered = sorted(runs, key=lambda r: _task_field(r, "id", 0))
|
||||
# Count trailing consecutive 'crashed' outcomes.
|
||||
@@ -575,7 +498,7 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]:
|
||||
_RULES: list[RuleFn] = [
|
||||
_rule_hallucinated_cards,
|
||||
_rule_prose_phantom_refs,
|
||||
_rule_repeated_failures,
|
||||
_rule_repeated_spawn_failures,
|
||||
_rule_repeated_crashes,
|
||||
_rule_stuck_in_blocked,
|
||||
]
|
||||
@@ -586,15 +509,13 @@ _RULES: list[RuleFn] = [
|
||||
DIAGNOSTIC_KINDS = (
|
||||
"hallucinated_cards",
|
||||
"prose_phantom_refs",
|
||||
"repeated_failures",
|
||||
"repeated_spawn_failures",
|
||||
"repeated_crashes",
|
||||
"stuck_in_blocked",
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"failure_threshold": 3,
|
||||
# Legacy alias accepted at read time by _rule_repeated_failures.
|
||||
"spawn_failure_threshold": 3,
|
||||
"crash_threshold": 2,
|
||||
"blocked_stale_hours": 24,
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"""Kanban triage specifier — flesh out a one-liner into a real spec.
|
||||
|
||||
Used by ``hermes kanban specify [task_id | --all]``. Takes a task that
|
||||
lives in the Triage column (a rough idea, typically only a title), calls
|
||||
the auxiliary LLM to produce:
|
||||
|
||||
* A tightened title (optional — only replaces if the model proposes a
|
||||
materially different one)
|
||||
* A concrete body: goal, proposed approach, acceptance criteria
|
||||
|
||||
and then flips the task ``triage -> todo`` via
|
||||
``kanban_db.specify_triage_task``. The dispatcher promotes it to
|
||||
``ready`` on its next tick (or immediately if there are no open parents).
|
||||
|
||||
Design notes
|
||||
------------
|
||||
|
||||
* This module intentionally mirrors ``hermes_cli/goals.py`` — same aux
|
||||
client pattern, same "empty config => skip, don't crash" tolerance.
|
||||
Keeps the surface area tiny and the failure modes predictable.
|
||||
|
||||
* The prompt is a short system + user pair. We ask for JSON with
|
||||
``{title, body}``; if parsing fails, we fall back to treating the
|
||||
whole response as the body and leave the title untouched. No
|
||||
retry loop — one shot, keep cost bounded.
|
||||
|
||||
* Structured output / JSON mode is not requested explicitly so the
|
||||
specifier works on providers that don't implement it. The parse
|
||||
is lenient (tolerates markdown code fences around the JSON).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = """You are the Kanban triage specifier for the Hermes Agent board.
|
||||
A user dropped a rough idea into the Triage column. Your job is to turn it
|
||||
into a concrete, actionable task spec that an autonomous worker can pick up
|
||||
and execute without further clarification.
|
||||
|
||||
Output a single JSON object with exactly two keys:
|
||||
|
||||
{
|
||||
"title": "<tightened task title, <= 80 chars, imperative voice>",
|
||||
"body": "<multi-line spec, see structure below>"
|
||||
}
|
||||
|
||||
The body MUST include these sections, each prefixed with a bold markdown
|
||||
heading, in this order:
|
||||
|
||||
**Goal** — one sentence, user-facing outcome.
|
||||
**Approach** — 2-5 bullets on how a worker should tackle it.
|
||||
**Acceptance criteria** — checklist of concrete, verifiable conditions.
|
||||
**Out of scope** — short list of things NOT to touch (omit if nothing
|
||||
obvious; never invent scope creep).
|
||||
|
||||
Rules:
|
||||
- Keep the tightened title close in meaning to the original idea — do
|
||||
NOT invent a different project.
|
||||
- If the original idea is already detailed, preserve its substance and
|
||||
just reformat into the sections above.
|
||||
- Never add invented requirements the user didn't hint at.
|
||||
- No preamble, no closing remarks, no code fences around the JSON.
|
||||
- Output only the JSON object and nothing else.
|
||||
"""
|
||||
|
||||
|
||||
_USER_TEMPLATE = """Task id: {task_id}
|
||||
Current title: {title}
|
||||
Current body:
|
||||
{body}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpecifyOutcome:
|
||||
"""Result of specifying a single triage task."""
|
||||
|
||||
task_id: str
|
||||
ok: bool
|
||||
reason: str = ""
|
||||
new_title: Optional[str] = None
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1] + "…"
|
||||
|
||||
|
||||
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json_blob(raw: str) -> Optional[dict]:
|
||||
"""Lenient JSON extraction — tolerates fenced code blocks and
|
||||
leading/trailing whitespace. Returns None if nothing parses."""
|
||||
if not raw:
|
||||
return None
|
||||
stripped = _FENCE_RE.sub("", raw.strip())
|
||||
# Greedy: find the first `{` and last `}` and try that slice.
|
||||
first = stripped.find("{")
|
||||
last = stripped.rfind("}")
|
||||
if first == -1 or last == -1 or last <= first:
|
||||
return None
|
||||
candidate = stripped[first : last + 1]
|
||||
try:
|
||||
val = json.loads(candidate)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(val, dict):
|
||||
return None
|
||||
return val
|
||||
|
||||
|
||||
def _profile_author() -> str:
|
||||
"""Mirror of ``hermes_cli.kanban._profile_author``. Kept local to
|
||||
avoid a circular import when kanban.py imports this module."""
|
||||
return (
|
||||
os.environ.get("HERMES_PROFILE")
|
||||
or os.environ.get("USER")
|
||||
or "specifier"
|
||||
)
|
||||
|
||||
|
||||
def specify_task(
|
||||
task_id: str,
|
||||
*,
|
||||
author: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
) -> SpecifyOutcome:
|
||||
"""Specify a single triage task and promote it to ``todo``.
|
||||
|
||||
Returns an outcome describing what happened. Never raises for expected
|
||||
failure modes (task not in triage, no aux client configured, API
|
||||
error, malformed response) — those surface via ``ok=False`` so the
|
||||
``--all`` sweep can continue past individual failures.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
task = kb.get_task(conn, task_id)
|
||||
if task is None:
|
||||
return SpecifyOutcome(task_id, False, "unknown task id")
|
||||
if task.status != "triage":
|
||||
return SpecifyOutcome(
|
||||
task_id, False, f"task is not in triage (status={task.status!r})"
|
||||
)
|
||||
|
||||
try:
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
except Exception as exc: # pragma: no cover — import smoke test
|
||||
logger.debug("specify: auxiliary client import failed: %s", exc)
|
||||
return SpecifyOutcome(task_id, False, "auxiliary client unavailable")
|
||||
|
||||
try:
|
||||
client, model = get_text_auxiliary_client("triage_specifier")
|
||||
except Exception as exc:
|
||||
logger.debug("specify: get_text_auxiliary_client failed: %s", exc)
|
||||
return SpecifyOutcome(task_id, False, "auxiliary client unavailable")
|
||||
|
||||
if client is None or not model:
|
||||
return SpecifyOutcome(
|
||||
task_id, False, "no auxiliary client configured"
|
||||
)
|
||||
|
||||
user_msg = _USER_TEMPLATE.format(
|
||||
task_id=task.id,
|
||||
title=_truncate(task.title or "", 400),
|
||||
body=_truncate(task.body or "(no body)", 4000),
|
||||
)
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=1500,
|
||||
timeout=timeout or 120,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(
|
||||
"specify: API call failed for %s (%s) — skipping",
|
||||
task_id, exc,
|
||||
)
|
||||
return SpecifyOutcome(
|
||||
task_id, False, f"LLM error: {type(exc).__name__}"
|
||||
)
|
||||
|
||||
try:
|
||||
raw = resp.choices[0].message.content or ""
|
||||
except Exception:
|
||||
raw = ""
|
||||
|
||||
parsed = _extract_json_blob(raw)
|
||||
|
||||
new_title: Optional[str]
|
||||
new_body: Optional[str]
|
||||
if parsed is None:
|
||||
# Fall back: treat the whole reply as the body, leave title as-is.
|
||||
# Worst case the user edits afterward — still better than stranding
|
||||
# the task in triage on a malformed LLM reply.
|
||||
stripped_raw = raw.strip()
|
||||
if not stripped_raw:
|
||||
return SpecifyOutcome(
|
||||
task_id, False, "LLM returned an empty response"
|
||||
)
|
||||
new_title = None
|
||||
new_body = stripped_raw
|
||||
else:
|
||||
title_val = parsed.get("title")
|
||||
body_val = parsed.get("body")
|
||||
new_title = (
|
||||
title_val.strip()
|
||||
if isinstance(title_val, str) and title_val.strip()
|
||||
else None
|
||||
)
|
||||
new_body = (
|
||||
body_val if isinstance(body_val, str) and body_val.strip() else None
|
||||
)
|
||||
if new_body is None and new_title is None:
|
||||
return SpecifyOutcome(
|
||||
task_id, False, "LLM response missing title and body"
|
||||
)
|
||||
|
||||
with kb.connect() as conn:
|
||||
ok = kb.specify_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
title=new_title,
|
||||
body=new_body,
|
||||
author=author or _profile_author(),
|
||||
)
|
||||
if not ok:
|
||||
# Race: someone else promoted / archived the task between our
|
||||
# read above and the write. Report, don't crash.
|
||||
return SpecifyOutcome(
|
||||
task_id, False, "task moved out of triage before promotion"
|
||||
)
|
||||
return SpecifyOutcome(task_id, True, "specified", new_title=new_title)
|
||||
|
||||
|
||||
def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
|
||||
"""Return task ids currently in the triage column.
|
||||
|
||||
``tenant`` narrows the sweep; ``None`` returns every triage task.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
tasks = kb.list_tasks(
|
||||
conn,
|
||||
status="triage",
|
||||
tenant=tenant,
|
||||
include_archived=False,
|
||||
)
|
||||
return [t.id for t in tasks]
|
||||
+34
-207
@@ -230,7 +230,6 @@ except Exception:
|
||||
pass # best-effort — don't crash if config isn't available yet
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time as _time
|
||||
from datetime import datetime
|
||||
|
||||
@@ -1707,7 +1706,7 @@ def _is_profile_api_key_provider(provider_id: str) -> bool:
|
||||
"""Return True when provider_id maps to a profile with auth_type='api_key'.
|
||||
|
||||
Used as a catch-all in select_provider_and_model() so that new providers
|
||||
declared in plugins/model-providers/<name>/ automatically dispatch to _model_flow_api_key_provider
|
||||
declared in providers/*.py automatically dispatch to _model_flow_api_key_provider
|
||||
without requiring an explicit elif branch here.
|
||||
"""
|
||||
try:
|
||||
@@ -5239,19 +5238,12 @@ def cmd_cron(args):
|
||||
|
||||
|
||||
def cmd_webhook(args):
|
||||
"""Entry point for 'hermes webhook' command."""
|
||||
"""Webhook subscription management."""
|
||||
from hermes_cli.webhook import webhook_command
|
||||
|
||||
webhook_command(args)
|
||||
|
||||
|
||||
def cmd_trust(args):
|
||||
"""Entry point for 'hermes trust' command."""
|
||||
from hermes_cli.trust import trust_command
|
||||
|
||||
trust_command(args)
|
||||
|
||||
|
||||
def cmd_slack(args):
|
||||
"""Slack integration helpers.
|
||||
|
||||
@@ -6453,68 +6445,17 @@ def _load_installable_optional_extras() -> list[str]:
|
||||
return referenced
|
||||
|
||||
|
||||
def _run_install_with_heartbeat(
|
||||
cmd: list[str],
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
heartbeat_interval_seconds: int = 30,
|
||||
) -> None:
|
||||
"""Run dependency install command with periodic heartbeat output.
|
||||
|
||||
Some resolvers/build backends (especially when compiling Rust/C extensions)
|
||||
can stay quiet for minutes. Emit a simple elapsed-time heartbeat so users
|
||||
know ``hermes update`` is still progressing even if pip/uv itself is silent.
|
||||
"""
|
||||
done = threading.Event()
|
||||
start = _time.time()
|
||||
|
||||
def _heartbeat() -> None:
|
||||
# Wait first, then print, so short installs don't emit noise.
|
||||
while not done.wait(heartbeat_interval_seconds):
|
||||
elapsed = int(_time.time() - start)
|
||||
print(
|
||||
f" … still installing dependencies ({elapsed}s elapsed)"
|
||||
" — compiling Rust/C extensions can take several minutes",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
t = threading.Thread(target=_heartbeat, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd,
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
finally:
|
||||
done.set()
|
||||
t.join(timeout=0.2)
|
||||
|
||||
|
||||
def _install_python_dependencies_with_optional_fallback(
|
||||
install_cmd_prefix: list[str],
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Install base deps plus as many optional extras as the environment supports.
|
||||
|
||||
We intentionally do NOT pass ``--quiet`` to pip. On platforms without
|
||||
prebuilt wheels for some extras (Termux/Android aarch64, older musl
|
||||
distros, fresh Raspberry Pi) pip has to compile C/Rust extensions from
|
||||
source, which can take several minutes with zero network activity.
|
||||
Without progress output the call looks like a hang and users Ctrl+C it.
|
||||
Pip's default output is proportional to actual work (one line per
|
||||
Collecting/Building/Installing step), so keeping it visible costs
|
||||
nothing on fast hardware and prevents the "hermes update hangs" reports
|
||||
on slow hardware.
|
||||
|
||||
We also add periodic heartbeat lines in case the resolver/build backend is
|
||||
itself silent for long stretches.
|
||||
"""
|
||||
"""Install base deps plus as many optional extras as the environment supports."""
|
||||
try:
|
||||
_run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "-e", ".[all]"],
|
||||
subprocess.run(
|
||||
install_cmd_prefix + ["install", "-e", ".[all]", "--quiet"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
return
|
||||
@@ -6523,8 +6464,10 @@ def _install_python_dependencies_with_optional_fallback(
|
||||
" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..."
|
||||
)
|
||||
|
||||
_run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "-e", "."],
|
||||
subprocess.run(
|
||||
install_cmd_prefix + ["install", "-e", ".", "--quiet"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
@@ -6532,8 +6475,10 @@ def _install_python_dependencies_with_optional_fallback(
|
||||
installed_extras: list[str] = []
|
||||
for extra in _load_installable_optional_extras():
|
||||
try:
|
||||
_run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "-e", f".[{extra}]"],
|
||||
subprocess.run(
|
||||
install_cmd_prefix + ["install", "-e", f".[{extra}]", "--quiet"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
installed_extras.append(extra)
|
||||
@@ -7375,9 +7320,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
for p in all_profiles:
|
||||
try:
|
||||
r = seed_profile_skills(p.path, quiet=True)
|
||||
if r and r.get("skipped_opt_out"):
|
||||
status = "opted out (--no-skills)"
|
||||
elif r:
|
||||
if r:
|
||||
copied = len(r.get("copied", []))
|
||||
updated = len(r.get("updated", []))
|
||||
modified = len(r.get("user_modified", []))
|
||||
@@ -7448,8 +7391,11 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
.lower()
|
||||
)
|
||||
elif not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
print(" ℹ Non-interactive session — applying safe config migrations.")
|
||||
response = "auto"
|
||||
print(" ℹ Non-interactive session — skipping config migration prompt.")
|
||||
print(
|
||||
" Run 'hermes config migrate' later to apply any new config/env options."
|
||||
)
|
||||
response = "n"
|
||||
else:
|
||||
try:
|
||||
response = (
|
||||
@@ -7460,22 +7406,19 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except EOFError:
|
||||
response = "n"
|
||||
|
||||
if response in ("", "y", "yes", "auto"):
|
||||
if response in ("", "y", "yes"):
|
||||
print()
|
||||
# Gateway mode, --yes, and non-interactive update contexts
|
||||
# (dashboard / web server actions) cannot prompt for API keys.
|
||||
# Still run the non-interactive migration pass before restarting
|
||||
# so new default config fields and version bumps are written
|
||||
# before the freshly updated gateway validates config at startup.
|
||||
interactive_migration = not (
|
||||
gateway_mode or assume_yes or response == "auto"
|
||||
# In gateway mode OR under --yes, run auto-migrations only (no
|
||||
# input() prompts for API keys which would hang the detached
|
||||
# process / defeat the point of --yes).
|
||||
results = migrate_config(
|
||||
interactive=not (gateway_mode or assume_yes), quiet=False
|
||||
)
|
||||
results = migrate_config(interactive=interactive_migration, quiet=False)
|
||||
|
||||
if results["env_added"] or results["config_added"]:
|
||||
print()
|
||||
print("✓ Configuration updated!")
|
||||
if (gateway_mode or assume_yes or response == "auto") and missing_env:
|
||||
if (gateway_mode or assume_yes) and missing_env:
|
||||
print(" ℹ API keys require manual entry: hermes config migrate")
|
||||
else:
|
||||
print()
|
||||
@@ -7779,23 +7722,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# when the graceful path failed (unit missing
|
||||
# SIGUSR1 wiring, drain exceeded the budget,
|
||||
# restart-policy mismatch).
|
||||
#
|
||||
# Always `reset-failed` first. If systemd's own
|
||||
# auto-restart attempts already parked the unit
|
||||
# in a failed state (transient CHDIR / OOM /
|
||||
# filesystem race after our drain + exit-75),
|
||||
# a plain `systemctl restart` can wedge against
|
||||
# the RestartSec backoff and leave the unit
|
||||
# dead. Clearing the failed state first makes
|
||||
# the restart idempotent. Mirrors the recovery
|
||||
# path in `hermes gateway restart`
|
||||
# (`systemd_restart()`) as of PR #20949.
|
||||
subprocess.run(
|
||||
scope_cmd + ["reset-failed", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
restart = subprocess.run(
|
||||
scope_cmd + ["restart", svc_name],
|
||||
capture_output=True,
|
||||
@@ -7815,19 +7741,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
else:
|
||||
# Retry once — transient startup failures
|
||||
# (stale module cache, import race) often
|
||||
# resolve on the second attempt. Again
|
||||
# clear any failed state first so the
|
||||
# retry isn't blocked by the previous
|
||||
# crash.
|
||||
# resolve on the second attempt.
|
||||
print(
|
||||
f" ⚠ {svc_name} died after restart, retrying..."
|
||||
)
|
||||
subprocess.run(
|
||||
scope_cmd + ["reset-failed", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
scope_cmd + ["restart", svc_name],
|
||||
capture_output=True,
|
||||
@@ -7842,13 +7759,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
restarted_services.append(svc_name)
|
||||
print(f" ✓ {svc_name} recovered on retry")
|
||||
else:
|
||||
_scope_flag = "--user " if scope == "user" else ""
|
||||
print(
|
||||
f" ✗ {svc_name} failed to stay running after restart.\n"
|
||||
f" Check logs: journalctl {_scope_flag}-u {svc_name} --since '2 min ago'\n"
|
||||
f" Recover manually:\n"
|
||||
f" systemctl {_scope_flag}reset-failed {svc_name}\n"
|
||||
f" systemctl {_scope_flag}restart {svc_name}"
|
||||
f" Check logs: journalctl --user -u {svc_name} --since '2 min ago'\n"
|
||||
f" Restart manually: systemctl {'--user ' if scope == 'user' else ''}restart {svc_name}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
@@ -8077,7 +7991,6 @@ def _coalesce_session_name_args(argv: list) -> list:
|
||||
"plugins",
|
||||
"acp",
|
||||
"webhook",
|
||||
"trust",
|
||||
"memory",
|
||||
"dump",
|
||||
"debug",
|
||||
@@ -8200,7 +8113,6 @@ def cmd_profile(args):
|
||||
clone = getattr(args, "clone", False)
|
||||
clone_all = getattr(args, "clone_all", False)
|
||||
no_alias = getattr(args, "no_alias", False)
|
||||
no_skills = getattr(args, "no_skills", False)
|
||||
|
||||
try:
|
||||
clone_from = getattr(args, "clone_from", None)
|
||||
@@ -8211,7 +8123,6 @@ def cmd_profile(args):
|
||||
clone_all=clone_all,
|
||||
clone_config=clone,
|
||||
no_alias=no_alias,
|
||||
no_skills=no_skills,
|
||||
)
|
||||
print(f"\nProfile '{name}' created at {profile_dir}")
|
||||
|
||||
@@ -8236,17 +8147,10 @@ def cmd_profile(args):
|
||||
except Exception:
|
||||
pass # Honcho plugin not installed or not configured
|
||||
|
||||
# Seed bundled skills (skip if --clone-all already copied them, or
|
||||
# if --no-skills was passed — in which case seed_profile_skills()
|
||||
# honors the marker file and returns skipped_opt_out=True).
|
||||
# Seed bundled skills (skip if --clone-all already copied them)
|
||||
if not clone_all:
|
||||
result = seed_profile_skills(profile_dir)
|
||||
if result and result.get("skipped_opt_out"):
|
||||
print(
|
||||
"No bundled skills seeded (--no-skills). "
|
||||
"Delete .no-bundled-skills in the profile to opt back in."
|
||||
)
|
||||
elif result:
|
||||
if result:
|
||||
copied = len(result.get("copied", []))
|
||||
print(f"{copied} bundled skills synced.")
|
||||
else:
|
||||
@@ -8764,9 +8668,6 @@ def main():
|
||||
help="Target the Linux system-level gateway service",
|
||||
)
|
||||
|
||||
# gateway list
|
||||
gateway_subparsers.add_parser("list", help="List all profiles and their gateway status")
|
||||
|
||||
# gateway setup
|
||||
gateway_subparsers.add_parser("setup", help="Configure messaging platforms")
|
||||
|
||||
@@ -9273,53 +9174,6 @@ def main():
|
||||
|
||||
webhook_parser.set_defaults(func=cmd_webhook)
|
||||
|
||||
# =========================================================================
|
||||
# trust command — rule-based permission engine
|
||||
# =========================================================================
|
||||
trust_parser = subparsers.add_parser(
|
||||
"trust",
|
||||
help="Manage trust rules — allow/deny/ask tool invocations without prompting",
|
||||
description=(
|
||||
"Trust rules live in ~/.hermes/trust.json and sit BEFORE the yolo bypass. "
|
||||
"A deny rule is an invariant that even --yolo cannot override; an allow rule "
|
||||
"short-circuits the dangerous-command check; an ask rule forces a prompt even "
|
||||
"under yolo. See 'hermes trust why' to debug a specific invocation."
|
||||
),
|
||||
)
|
||||
trust_subparsers = trust_parser.add_subparsers(dest="trust_action")
|
||||
|
||||
trust_subparsers.add_parser("list", aliases=["ls"], help="List all rules")
|
||||
|
||||
t_add = trust_subparsers.add_parser("add", help="Add a new rule")
|
||||
t_add.add_argument("--id", default="", help="Rule id (auto-generated if omitted)")
|
||||
t_add.add_argument("--tool", default="*", help="Tool name the rule applies to (or '*')")
|
||||
t_add.add_argument("--pattern", default="*", help="fnmatch glob against the candidate string")
|
||||
t_add.add_argument("--scope", default="everywhere",
|
||||
help="Path prefix for file tools, or 'everywhere' (default)")
|
||||
t_add.add_argument("--decision", required=True, choices=["allow", "deny", "ask"])
|
||||
t_add.add_argument("--priority", type=int, default=50,
|
||||
help="Higher priority wins; deny beats allow on ties (default: 50)")
|
||||
|
||||
t_rm = trust_subparsers.add_parser("remove", aliases=["rm"], help="Remove a rule by id")
|
||||
t_rm.add_argument("id", help="Rule id")
|
||||
|
||||
t_show = trust_subparsers.add_parser("show", help="Show a single rule's full body")
|
||||
t_show.add_argument("id", help="Rule id")
|
||||
|
||||
t_why = trust_subparsers.add_parser(
|
||||
"why", help="Explain what would happen for a given (tool, command) pair"
|
||||
)
|
||||
t_why.add_argument("--tool", default="terminal", help="Tool name (default: terminal)")
|
||||
t_why.add_argument("--cmd", required=True, help="Candidate string (shell command, file path, ...)")
|
||||
|
||||
t_init = trust_subparsers.add_parser(
|
||||
"init", help="Seed a sensible starter bundle (git status / ls / file_read)"
|
||||
)
|
||||
t_init.add_argument("--force", action="store_true",
|
||||
help="Overwrite an existing trust.json")
|
||||
|
||||
trust_parser.set_defaults(func=cmd_trust)
|
||||
|
||||
# =========================================================================
|
||||
# kanban command — multi-profile collaboration board
|
||||
# =========================================================================
|
||||
@@ -9514,20 +9368,6 @@ Examples:
|
||||
)
|
||||
backup_parser.set_defaults(func=cmd_backup)
|
||||
|
||||
# =========================================================================
|
||||
# checkpoints command
|
||||
# =========================================================================
|
||||
checkpoints_parser = subparsers.add_parser(
|
||||
"checkpoints",
|
||||
help="Inspect / prune / clear ~/.hermes/checkpoints/",
|
||||
description="Manage the filesystem checkpoint store — the shadow git "
|
||||
"repo hermes uses to snapshot working directories before "
|
||||
"write_file/patch/terminal calls. Lets you see how much "
|
||||
"space checkpoints occupy, force a prune, or wipe the base.",
|
||||
)
|
||||
from hermes_cli.checkpoints import register_cli as _register_checkpoints_cli
|
||||
_register_checkpoints_cli(checkpoints_parser)
|
||||
|
||||
# =========================================================================
|
||||
# import command
|
||||
# =========================================================================
|
||||
@@ -10131,15 +9971,7 @@ Examples:
|
||||
)
|
||||
mcp_add_p.add_argument("name", help="Server name (used as config key)")
|
||||
mcp_add_p.add_argument("--url", help="HTTP/SSE endpoint URL")
|
||||
# dest="mcp_command" so this flag does not clobber the top-level
|
||||
# subparser's args.command attribute, which the dispatcher reads to
|
||||
# route to cmd_mcp. Without an explicit dest, argparse derives
|
||||
# dest="command" from the flag name and sets it to None when the
|
||||
# flag is omitted, causing `hermes mcp add ...` to fall through to
|
||||
# interactive chat.
|
||||
mcp_add_p.add_argument(
|
||||
"--command", dest="mcp_command", help="Stdio command (e.g. npx)"
|
||||
)
|
||||
mcp_add_p.add_argument("--command", help="Stdio command (e.g. npx)")
|
||||
mcp_add_p.add_argument(
|
||||
"--args", nargs="*", default=[], help="Arguments for stdio command"
|
||||
)
|
||||
@@ -10666,11 +10498,6 @@ Examples:
|
||||
profile_create.add_argument(
|
||||
"--no-alias", action="store_true", help="Skip wrapper script creation"
|
||||
)
|
||||
profile_create.add_argument(
|
||||
"--no-skills",
|
||||
action="store_true",
|
||||
help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)",
|
||||
)
|
||||
|
||||
profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile")
|
||||
profile_delete.add_argument("profile_name", help="Profile to delete")
|
||||
|
||||
@@ -221,10 +221,7 @@ def cmd_mcp_add(args):
|
||||
"""Add a new MCP server with discovery-first tool selection."""
|
||||
name = args.name
|
||||
url = getattr(args, "url", None)
|
||||
# Read from `mcp_command` (set by --command via explicit dest) — see
|
||||
# mcp_add_p.add_argument("--command", dest="mcp_command", ...) in
|
||||
# hermes_cli/main.py for why the dest is renamed.
|
||||
command = getattr(args, "mcp_command", None)
|
||||
command = getattr(args, "command", None)
|
||||
cmd_args = getattr(args, "args", None) or []
|
||||
auth_type = getattr(args, "auth", None)
|
||||
preset_name = getattr(args, "preset", None)
|
||||
|
||||
@@ -393,21 +393,14 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
|
||||
if provider in _AGGREGATOR_PROVIDERS:
|
||||
return _prepend_vendor(name)
|
||||
|
||||
# --- OpenCode Zen / OpenCode Go: flat-namespace resellers.
|
||||
# Their /v1/models API returns bare IDs only (no vendor prefix), and
|
||||
# the inference endpoint rejects vendor-prefixed names with HTTP 401
|
||||
# "Model not supported". Strip ANY leading ``vendor/`` so config
|
||||
# entries like ``minimax/minimax-m2.7`` or ``deepseek/deepseek-v4-flash``
|
||||
# — commonly copied from aggregator slugs into fallback_model lists —
|
||||
# resolve to bare ``minimax-m2.7`` / ``deepseek-v4-flash`` the API
|
||||
# actually serves. See PR reviewing opencode-go fallback 401s. ---
|
||||
if provider in {"opencode-zen", "opencode-go"}:
|
||||
if "/" in name:
|
||||
_, bare_after_slash = name.split("/", 1)
|
||||
name = bare_after_slash.strip() or name
|
||||
if provider == "opencode-zen" and name.lower().startswith("claude-"):
|
||||
return _dots_to_hyphens(name)
|
||||
return name
|
||||
# --- OpenCode Zen: Claude stays hyphenated; other models keep dots ---
|
||||
if provider == "opencode-zen":
|
||||
bare = _strip_matching_provider_prefix(name, provider)
|
||||
if "/" in bare:
|
||||
return bare
|
||||
if bare.lower().startswith("claude-"):
|
||||
return _dots_to_hyphens(bare)
|
||||
return bare
|
||||
|
||||
# --- Anthropic: strip matching provider prefix, dots -> hyphens ---
|
||||
if provider in _DOT_TO_HYPHEN_PROVIDERS:
|
||||
|
||||
@@ -799,12 +799,6 @@ def switch_model(
|
||||
)
|
||||
|
||||
# --- Step d: Aggregator catalog search ---
|
||||
# Track whether the live catalog of the CURRENT provider resolved the
|
||||
# model — if so, step e must not second-guess and switch providers.
|
||||
# Critical for flat-namespace resellers like opencode-go / opencode-zen
|
||||
# whose live /v1/models returns bare IDs (e.g. "deepseek-v4-flash") that
|
||||
# coincidentally match entries in native providers' static catalogs.
|
||||
resolved_in_current_catalog = False
|
||||
if is_aggregator(target_provider) and not resolved_alias:
|
||||
catalog = list_provider_models(target_provider)
|
||||
if catalog:
|
||||
@@ -812,7 +806,6 @@ def switch_model(
|
||||
for mid in catalog:
|
||||
if mid.lower() == new_model_lower:
|
||||
new_model = mid
|
||||
resolved_in_current_catalog = True
|
||||
break
|
||||
else:
|
||||
for mid in catalog:
|
||||
@@ -820,7 +813,6 @@ def switch_model(
|
||||
_, bare = mid.split("/", 1)
|
||||
if bare.lower() == new_model_lower:
|
||||
new_model = mid
|
||||
resolved_in_current_catalog = True
|
||||
break
|
||||
|
||||
# --- Step e: detect_provider_for_model() as last resort ---
|
||||
@@ -833,7 +825,6 @@ def switch_model(
|
||||
target_provider == current_provider
|
||||
and not is_custom
|
||||
and not resolved_alias
|
||||
and not resolved_in_current_catalog
|
||||
):
|
||||
detected = detect_provider_for_model(new_model, current_provider)
|
||||
if detected:
|
||||
@@ -1637,8 +1628,7 @@ def list_authenticated_providers(
|
||||
groups[group_key]["models"].append(m)
|
||||
|
||||
_section4_emitted_slugs: set = set()
|
||||
for grp_key, grp in groups.items():
|
||||
api_url, api_key = grp_key
|
||||
for grp in groups.values():
|
||||
slug = grp["slug"]
|
||||
# If the slug is already claimed by a built-in / overlay /
|
||||
# user-provider row (sections 1-3), skip this custom group
|
||||
@@ -1676,18 +1666,6 @@ def list_authenticated_providers(
|
||||
_grp_url_norm = _pair_key[1]
|
||||
if _grp_url_norm and _grp_url_norm in _builtin_endpoints:
|
||||
continue
|
||||
# Live model discovery from custom provider endpoints (matches
|
||||
# Section 3 behavior for user ``providers:`` entries).
|
||||
if api_url and api_key:
|
||||
try:
|
||||
from hermes_cli.models import fetch_api_models
|
||||
|
||||
live_models = fetch_api_models(api_key, api_url)
|
||||
if live_models:
|
||||
grp["models"] = live_models
|
||||
grp["total_models"] = len(live_models)
|
||||
except Exception:
|
||||
pass
|
||||
results.append({
|
||||
"slug": slug,
|
||||
"name": grp["name"],
|
||||
@@ -1709,11 +1687,9 @@ def list_authenticated_providers(
|
||||
|
||||
def list_picker_providers(
|
||||
current_provider: str = "",
|
||||
current_base_url: str = "",
|
||||
user_providers: dict = None,
|
||||
custom_providers: list | None = None,
|
||||
max_models: int = 8,
|
||||
current_model: str = "",
|
||||
) -> List[dict]:
|
||||
"""Interactive-picker variant of :func:`list_authenticated_providers`.
|
||||
|
||||
@@ -1738,11 +1714,9 @@ def list_picker_providers(
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
current_base_url=current_base_url,
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
max_models=max_models,
|
||||
current_model=current_model,
|
||||
)
|
||||
|
||||
filtered: List[dict] = []
|
||||
|
||||
+3
-20
@@ -46,7 +46,6 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("xiaomi/mimo-v2.5-pro", ""),
|
||||
("xiaomi/mimo-v2.5", ""),
|
||||
("tencent/hy3-preview:free", "free"),
|
||||
("tencent/hy3-preview", ""),
|
||||
("openai/gpt-5.3-codex", ""),
|
||||
("google/gemini-3-pro-image-preview", ""),
|
||||
("google/gemini-3-flash-preview", ""),
|
||||
@@ -62,14 +61,12 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("z-ai/glm-5v-turbo", ""),
|
||||
("z-ai/glm-5-turbo", ""),
|
||||
("x-ai/grok-4.20", ""),
|
||||
("x-ai/grok-4.3", ""),
|
||||
("nvidia/nemotron-3-super-120b-a12b", ""),
|
||||
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
|
||||
("arcee-ai/trinity-large-preview:free", "free"),
|
||||
("arcee-ai/trinity-large-thinking", ""),
|
||||
("openai/gpt-5.5-pro", ""),
|
||||
("openai/gpt-5.4-nano", ""),
|
||||
("deepseek/deepseek-v4-pro", ""),
|
||||
]
|
||||
|
||||
_openrouter_catalog_cache: list[tuple[str, str]] | None = None
|
||||
@@ -184,12 +181,10 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"z-ai/glm-5v-turbo",
|
||||
"z-ai/glm-5-turbo",
|
||||
"x-ai/grok-4.20-beta",
|
||||
"x-ai/grok-4.3",
|
||||
"nvidia/nemotron-3-super-120b-a12b",
|
||||
"arcee-ai/trinity-large-thinking",
|
||||
"openai/gpt-5.5-pro",
|
||||
"openai/gpt-5.4-nano",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
],
|
||||
# Native OpenAI Chat Completions (api.openai.com). Used by /model counts and
|
||||
# provider_model_ids fallback when /v1/models is unavailable.
|
||||
@@ -417,18 +412,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"glm-4.7",
|
||||
"MiniMax-M2.5",
|
||||
],
|
||||
# Alibaba Coding Plan — same platform as alibaba (DashScope coding-intl),
|
||||
# separate provider ID with its own base_url_env_var.
|
||||
"alibaba-coding-plan": [
|
||||
"qwen3.6-plus",
|
||||
"qwen3.5-plus",
|
||||
"qwen3-coder-plus",
|
||||
"qwen3-coder-next",
|
||||
"kimi-k2.5",
|
||||
"glm-5",
|
||||
"glm-4.7",
|
||||
"MiniMax-M2.5",
|
||||
],
|
||||
# Curated HF model list — only agentic models that map to OpenRouter defaults.
|
||||
"huggingface": [
|
||||
"moonshotai/Kimi-K2.5",
|
||||
@@ -824,9 +807,9 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
]
|
||||
|
||||
# Auto-extend CANONICAL_PROVIDERS with any provider registered in providers/
|
||||
# that is not already in the list above. Adding plugins/model-providers/<name>/
|
||||
# is sufficient to expose a new provider in the model picker, /model, and all
|
||||
# downstream consumers — no edits to this file needed.
|
||||
# that is not already in the list above. Adding providers/*.py is sufficient
|
||||
# to expose a new provider in the model picker, /model, and all downstream
|
||||
# consumers — no edits to this file needed.
|
||||
_canonical_slugs = {p.slug for p in CANONICAL_PROVIDERS}
|
||||
try:
|
||||
from providers import list_providers as _list_providers_for_canonical
|
||||
|
||||
@@ -255,10 +255,6 @@ def get_nous_subscription_features(
|
||||
terminal_cfg = config.get("terminal") if isinstance(config.get("terminal"), dict) else {}
|
||||
|
||||
web_backend = str(web_cfg.get("backend") or "").strip().lower()
|
||||
# Per-capability overrides: if set, they determine which backend is active for
|
||||
# search/extract independently of web.backend.
|
||||
web_search_backend = str(web_cfg.get("search_backend") or "").strip().lower()
|
||||
web_extract_backend = str(web_cfg.get("extract_backend") or "").strip().lower()
|
||||
tts_provider = str(tts_cfg.get("provider") or "edge").strip().lower()
|
||||
browser_provider_explicit = "cloud_provider" in browser_cfg
|
||||
browser_provider = normalize_browser_cloud_provider(
|
||||
@@ -284,7 +280,6 @@ def get_nous_subscription_features(
|
||||
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
|
||||
direct_parallel = bool(get_env_value("PARALLEL_API_KEY"))
|
||||
direct_tavily = bool(get_env_value("TAVILY_API_KEY"))
|
||||
direct_searxng = bool(get_env_value("SEARXNG_URL"))
|
||||
direct_fal = fal_key_is_configured()
|
||||
direct_openai_tts = bool(resolve_openai_audio_api_key())
|
||||
direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY"))
|
||||
@@ -328,18 +323,10 @@ def get_nous_subscription_features(
|
||||
or (web_backend == "firecrawl" and direct_firecrawl)
|
||||
or (web_backend == "parallel" and direct_parallel)
|
||||
or (web_backend == "tavily" and direct_tavily)
|
||||
or (web_backend == "searxng" and direct_searxng)
|
||||
# Per-capability overrides: search_backend or extract_backend may be set
|
||||
# without web.backend (using the new split config from #20061)
|
||||
or (web_search_backend == "searxng" and direct_searxng)
|
||||
or (web_search_backend == "exa" and direct_exa)
|
||||
or (web_search_backend == "firecrawl" and direct_firecrawl)
|
||||
or (web_search_backend == "parallel" and direct_parallel)
|
||||
or (web_search_backend == "tavily" and direct_tavily)
|
||||
)
|
||||
)
|
||||
web_available = bool(
|
||||
managed_web_available or direct_exa or direct_firecrawl or direct_parallel or direct_tavily or direct_searxng
|
||||
managed_web_available or direct_exa or direct_firecrawl or direct_parallel or direct_tavily
|
||||
)
|
||||
|
||||
image_managed = image_tool_enabled and managed_image_available and not direct_fal
|
||||
@@ -425,8 +412,8 @@ def get_nous_subscription_features(
|
||||
managed_by_nous=web_managed,
|
||||
direct_override=web_active and not web_managed,
|
||||
toolset_enabled=web_tool_enabled,
|
||||
current_provider=web_backend or web_search_backend or "",
|
||||
explicit_configured=bool(web_backend or web_search_backend),
|
||||
current_provider=web_backend or "",
|
||||
explicit_configured=bool(web_backend),
|
||||
),
|
||||
"image_gen": NousFeatureState(
|
||||
key="image_gen",
|
||||
|
||||
@@ -73,24 +73,6 @@ def _cmd_approve(store, platform: str, code: str):
|
||||
display = f"{name} ({uid})" if name else uid
|
||||
print(f"\n Approved! User {display} on {platform} can now use the bot~")
|
||||
print(" They'll be recognized automatically on their next message.\n")
|
||||
elif store._is_locked_out(platform):
|
||||
# Disambiguate: approve_code returns None for both invalid codes
|
||||
# and lockout. Tell the operator it's lockout so they don't chase
|
||||
# a "wrong code" rabbit hole (#10195).
|
||||
import time as _time
|
||||
limits = store._load_json(store._rate_limit_path())
|
||||
lockout_until = limits.get(f"_lockout:{platform}", 0)
|
||||
remaining = max(0, int(lockout_until - _time.time()))
|
||||
mins = remaining // 60
|
||||
print(
|
||||
f"\n Platform '{platform}' is locked out after too many failed "
|
||||
f"approval attempts."
|
||||
)
|
||||
print(f" Lockout clears in ~{mins} minute(s).")
|
||||
print(
|
||||
" To reset sooner, delete the '_lockout:{0}' entry from "
|
||||
"~/.hermes/platforms/pairing/_rate_limits.json\n".format(platform)
|
||||
)
|
||||
else:
|
||||
print(f"\n Code '{code}' not found or expired for platform '{platform}'.")
|
||||
print(" Run 'hermes pairing list' to see pending codes.\n")
|
||||
|
||||
@@ -80,10 +80,6 @@ VALID_HOOKS: Set[str] = {
|
||||
"post_tool_call",
|
||||
"transform_terminal_output",
|
||||
"transform_tool_result",
|
||||
# Transform LLM output before it's returned to the user.
|
||||
# Plugins return a string to replace the response text, or None/empty to leave unchanged.
|
||||
# First non-None string wins. Useful for vocabulary/personality transformation.
|
||||
"transform_llm_output",
|
||||
"pre_llm_call",
|
||||
"post_llm_call",
|
||||
"pre_api_request",
|
||||
|
||||
@@ -71,22 +71,6 @@ _CLONE_ALL_STRIP = [
|
||||
"processes.json",
|
||||
]
|
||||
|
||||
# Marker file written by `hermes profile create --no-skills`. When present in
|
||||
# a profile's root, callers of seed_profile_skills() (fresh-create, `hermes
|
||||
# update`'s all-profile sync, the web dashboard) skip bundled-skill seeding
|
||||
# for that profile. The user can still install skills manually via
|
||||
# `hermes skills install` or drop SKILL.md files into the profile's skills/.
|
||||
# Delete the marker file to opt back in.
|
||||
NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills"
|
||||
|
||||
|
||||
def has_bundled_skills_opt_out(profile_dir: Path) -> bool:
|
||||
"""Return True if the profile opted out of bundled-skill seeding."""
|
||||
try:
|
||||
return (profile_dir / NO_BUNDLED_SKILLS_MARKER).exists()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _clone_all_copytree_ignore(source_dir: Path):
|
||||
"""Ignore ``profiles/`` at the root of *source_dir* only.
|
||||
@@ -443,7 +427,6 @@ def create_profile(
|
||||
clone_all: bool = False,
|
||||
clone_config: bool = False,
|
||||
no_alias: bool = False,
|
||||
no_skills: bool = False,
|
||||
) -> Path:
|
||||
"""Create a new profile directory.
|
||||
|
||||
@@ -461,22 +444,12 @@ def create_profile(
|
||||
skills, and selected profile identity files from the source profile.
|
||||
no_alias:
|
||||
If True, skip wrapper script creation.
|
||||
no_skills:
|
||||
If True, create an empty profile with no bundled skills, and write
|
||||
a marker file so ``hermes update`` skips re-seeding this profile's
|
||||
skills. Mutually exclusive with ``clone_config``/``clone_all`` (those
|
||||
explicitly copy skills from the source).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Path
|
||||
The newly created profile directory.
|
||||
"""
|
||||
if no_skills and (clone_config or clone_all):
|
||||
raise ValueError(
|
||||
"--no-skills is mutually exclusive with --clone / --clone-all "
|
||||
"(cloning explicitly copies skills from the source profile)."
|
||||
)
|
||||
canon = normalize_profile_name(name)
|
||||
validate_profile_name(canon)
|
||||
|
||||
@@ -554,19 +527,6 @@ def create_profile(
|
||||
except Exception:
|
||||
pass # best-effort — don't fail profile creation over this
|
||||
|
||||
# Write the opt-out marker so seed_profile_skills() and `hermes update`'s
|
||||
# all-profile sync loop both skip this profile for bundled-skill seeding.
|
||||
if no_skills:
|
||||
try:
|
||||
(profile_dir / NO_BUNDLED_SKILLS_MARKER).write_text(
|
||||
"This profile opted out of bundled-skill seeding "
|
||||
"(`hermes profile create --no-skills`).\n"
|
||||
"Delete this file to re-enable sync on the next `hermes update`.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
pass # best-effort — the feature still works via the empty skills/ dir
|
||||
|
||||
return profile_dir
|
||||
|
||||
|
||||
@@ -575,19 +535,7 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
|
||||
|
||||
Uses subprocess because sync_skills() caches HERMES_HOME at module level.
|
||||
Returns the sync result dict, or None on failure.
|
||||
|
||||
Profiles that opted out of bundled skills (via ``hermes profile create
|
||||
--no-skills`` — which writes ``.no-bundled-skills`` to the profile root)
|
||||
are skipped and get an empty-result dict so callers can report
|
||||
"opted out" instead of "failed".
|
||||
"""
|
||||
if has_bundled_skills_opt_out(profile_dir):
|
||||
return {
|
||||
"copied": [],
|
||||
"updated": [],
|
||||
"user_modified": [],
|
||||
"skipped_opt_out": True,
|
||||
}
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
|
||||
@@ -319,10 +319,9 @@ def _try_resolve_from_custom_pool(
|
||||
base_url: str,
|
||||
provider_label: str,
|
||||
api_mode_override: Optional[str] = None,
|
||||
provider_name: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Check if a credential pool exists for a custom endpoint and return a runtime dict if so."""
|
||||
pool_key = get_custom_provider_pool_key(base_url, provider_name=provider_name)
|
||||
pool_key = get_custom_provider_pool_key(base_url)
|
||||
if not pool_key:
|
||||
return None
|
||||
try:
|
||||
@@ -522,7 +521,7 @@ def _resolve_named_custom_runtime(
|
||||
return None
|
||||
|
||||
# Check if a credential pool exists for this custom endpoint
|
||||
pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode"), provider_name=custom_provider.get("name"))
|
||||
pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode"))
|
||||
if pool_result:
|
||||
# Propagate the model name even when using pooled credentials —
|
||||
# the pool doesn't know about the custom_providers model field.
|
||||
@@ -641,11 +640,8 @@ def _resolve_openrouter_runtime(
|
||||
|
||||
# For custom endpoints, check if a credential pool exists
|
||||
if effective_provider == "custom" and base_url:
|
||||
# Pass requested_provider so pool lookup prefers name match over base_url,
|
||||
# fixing credential mix-ups when multiple custom providers share a base_url.
|
||||
pool_result = _try_resolve_from_custom_pool(
|
||||
base_url, effective_provider, _parse_api_mode(model_cfg.get("api_mode")),
|
||||
provider_name=requested_provider if requested_norm != "custom" else None,
|
||||
)
|
||||
if pool_result:
|
||||
return pool_result
|
||||
|
||||
+3
-23
@@ -394,7 +394,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
label = f"Web Search & Extract ({subscription_features.web.current_provider})"
|
||||
tool_status.append((label, True, None))
|
||||
else:
|
||||
tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, TAVILY_API_KEY, or SEARXNG_URL"))
|
||||
tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, or TAVILY_API_KEY"))
|
||||
|
||||
# Browser tools (local Chromium, Camofox, Browserbase, Browser Use, or Firecrawl)
|
||||
browser_provider = subscription_features.browser.current_provider
|
||||
@@ -2462,9 +2462,6 @@ def setup_gateway(config: dict):
|
||||
launchd_start,
|
||||
launchd_restart,
|
||||
UserSystemdUnavailableError,
|
||||
SystemScopeRequiresRootError,
|
||||
_system_scope_wizard_would_need_root,
|
||||
_print_system_scope_remediation,
|
||||
)
|
||||
|
||||
service_installed = _is_service_installed()
|
||||
@@ -2482,9 +2479,7 @@ def setup_gateway(config: dict):
|
||||
print()
|
||||
|
||||
if service_running:
|
||||
if supports_systemd and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("restart")
|
||||
elif prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
try:
|
||||
if supports_systemd:
|
||||
systemd_restart()
|
||||
@@ -2494,19 +2489,10 @@ def setup_gateway(config: dict):
|
||||
print_error(" Restart failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
# Defense in depth: the pre-check above should have
|
||||
# caught this, but a race (unit file appearing mid-run)
|
||||
# could still land here. Previously this exited the
|
||||
# whole wizard via sys.exit(1).
|
||||
print_error(f" Restart failed: {e}")
|
||||
_print_system_scope_remediation("restart")
|
||||
except Exception as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
elif service_installed:
|
||||
if supports_systemd and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("start")
|
||||
elif prompt_yes_no(" Start the gateway service?", True):
|
||||
if prompt_yes_no(" Start the gateway service?", True):
|
||||
try:
|
||||
if supports_systemd:
|
||||
systemd_start()
|
||||
@@ -2516,9 +2502,6 @@ def setup_gateway(config: dict):
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except Exception as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
elif supports_service_manager:
|
||||
@@ -2546,9 +2529,6 @@ def setup_gateway(config: dict):
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except Exception as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -42,7 +42,6 @@ All fields are optional. Missing values inherit from the ``default`` skin.
|
||||
session_border: "#8B8682" # Session ID dim color
|
||||
status_bar_bg: "#1a1a2e" # TUI status/usage bar background
|
||||
voice_status_bg: "#1a1a2e" # TUI voice status background
|
||||
selection_bg: "#333355" # TUI mouse-selection highlight background
|
||||
completion_menu_bg: "#1a1a2e" # Completion menu background
|
||||
completion_menu_current_bg: "#333355" # Active completion row background
|
||||
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
|
||||
|
||||
+1
-1
@@ -192,7 +192,7 @@ TIPS = [
|
||||
"Voice messages on Telegram, Discord, WhatsApp, and Slack are auto-transcribed.",
|
||||
|
||||
# --- Gateway & Messaging ---
|
||||
"Hermes runs on 21 messaging platforms: Telegram, Discord, Slack, WhatsApp, Signal, Matrix, IRC, Microsoft Teams, email, and more.",
|
||||
"Hermes runs on 18 platforms: Telegram, Discord, Slack, WhatsApp, Signal, Matrix, email, and more.",
|
||||
"hermes gateway install sets it up as a system service that starts on boot.",
|
||||
"DingTalk uses Stream Mode — no webhooks or public URL needed.",
|
||||
"BlueBubbles brings iMessage to Hermes via a local macOS server.",
|
||||
|
||||
@@ -299,32 +299,6 @@ TOOL_CATEGORIES = {
|
||||
{"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "SearXNG",
|
||||
"badge": "free · self-hosted · search only",
|
||||
"tag": "Privacy-respecting metasearch engine — search only (pair with any extract provider)",
|
||||
"web_backend": "searxng",
|
||||
"env_vars": [
|
||||
{"key": "SEARXNG_URL", "prompt": "Your SearXNG instance URL (e.g., http://localhost:8080)", "url": "https://searxng.github.io/searxng/"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Brave Search (Free Tier)",
|
||||
"badge": "free tier · search only",
|
||||
"tag": "2,000 queries/mo free — search only (pair with any extract provider)",
|
||||
"web_backend": "brave-free",
|
||||
"env_vars": [
|
||||
{"key": "BRAVE_SEARCH_API_KEY", "prompt": "Brave Search subscription token", "url": "https://brave.com/search/api/"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "DuckDuckGo (ddgs)",
|
||||
"badge": "free · no key · search only",
|
||||
"tag": "Search via the ddgs Python package — no API key (pair with any extract provider)",
|
||||
"web_backend": "ddgs",
|
||||
"env_vars": [],
|
||||
"post_setup": "ddgs",
|
||||
},
|
||||
],
|
||||
},
|
||||
"image_gen": {
|
||||
@@ -686,32 +660,6 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_info(" Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md")
|
||||
_print_info(" Switch voices by setting tts.piper.voice in ~/.hermes/config.yaml")
|
||||
|
||||
elif post_setup_key == "ddgs":
|
||||
try:
|
||||
__import__("ddgs")
|
||||
_print_success(" ddgs is already installed")
|
||||
except ImportError:
|
||||
import subprocess
|
||||
_print_info(" Installing ddgs (DuckDuckGo search package)...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", "ddgs", "--quiet"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" ddgs installed")
|
||||
else:
|
||||
_print_warning(" ddgs install failed:")
|
||||
_print_info(f" {result.stderr.strip()[:300]}")
|
||||
_print_info(" Run manually: python -m pip install -U ddgs")
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(" ddgs install timed out (>5min)")
|
||||
_print_info(" Run manually: python -m pip install -U ddgs")
|
||||
return
|
||||
_print_info(" No API key required. DuckDuckGo enforces server-side rate limits.")
|
||||
_print_info(" Pair with an extract provider if you also need web_extract.")
|
||||
|
||||
elif post_setup_key == "spotify":
|
||||
# Run the full `hermes auth spotify` flow — if the user has no
|
||||
# client_id yet, this drops them into the interactive wizard
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
"""hermes trust — manage trust rules for tool invocations.
|
||||
|
||||
Subcommands:
|
||||
|
||||
hermes trust list # show all rules
|
||||
hermes trust add --tool terminal --pattern 'git status*' --decision allow
|
||||
hermes trust remove <rule-id>
|
||||
hermes trust show <rule-id> # print one rule's full body
|
||||
hermes trust why --tool <t> --cmd '<c>' # explain: what would happen?
|
||||
hermes trust init # seed a sensible starter bundle
|
||||
|
||||
All rules persist to ~/.hermes/trust.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from typing import List
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
from tools.trust import TrustRule, explain, load_rules, save_rules
|
||||
|
||||
|
||||
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
def trust_command(args) -> None:
|
||||
sub = getattr(args, "trust_action", None)
|
||||
|
||||
if not sub:
|
||||
print("Usage: hermes trust {list|add|remove|show|why|init}")
|
||||
print("Run 'hermes trust --help' for details.")
|
||||
return
|
||||
|
||||
if sub in ("list", "ls"):
|
||||
_cmd_list(args)
|
||||
elif sub == "add":
|
||||
_cmd_add(args)
|
||||
elif sub in ("remove", "rm"):
|
||||
_cmd_remove(args)
|
||||
elif sub == "show":
|
||||
_cmd_show(args)
|
||||
elif sub == "why":
|
||||
_cmd_why(args)
|
||||
elif sub == "init":
|
||||
_cmd_init(args)
|
||||
else:
|
||||
print(f"Unknown trust subcommand: {sub}")
|
||||
|
||||
|
||||
def _cmd_list(args) -> None:
|
||||
rules = load_rules()
|
||||
if not rules:
|
||||
print("No trust rules configured.")
|
||||
print()
|
||||
print(f"File: {display_hermes_home()}/trust.json")
|
||||
print("Add one with:")
|
||||
print(" hermes trust add --tool terminal --pattern 'git status*' --decision allow")
|
||||
return
|
||||
|
||||
print(f"{'ID':<28} {'TOOL':<14} {'DECISION':<8} {'PRIO':<5} PATTERN")
|
||||
for rule in sorted(rules, key=lambda r: (-r.priority, r.id)):
|
||||
print(
|
||||
f"{rule.id:<28} {rule.tool:<14} {rule.decision:<8} {rule.priority:<5} "
|
||||
f"{rule.pattern}"
|
||||
)
|
||||
|
||||
|
||||
def _cmd_add(args) -> None:
|
||||
rule_id = (args.id or "").strip().lower()
|
||||
if not rule_id:
|
||||
rule_id = f"rule-{uuid.uuid4().hex[:8]}"
|
||||
if not _ID_RE.match(rule_id):
|
||||
print(f"Error: id must be lowercase alphanumerics + '-'/'_' (got {args.id!r})")
|
||||
return
|
||||
|
||||
if args.decision not in ("allow", "deny", "ask"):
|
||||
print(f"Error: --decision must be allow/deny/ask (got {args.decision!r})")
|
||||
return
|
||||
|
||||
rules = load_rules()
|
||||
if any(r.id == rule_id for r in rules):
|
||||
print(f"Error: a rule with id '{rule_id}' already exists. Remove it first or pick another --id.")
|
||||
return
|
||||
|
||||
new_rule = TrustRule(
|
||||
id=rule_id,
|
||||
tool=args.tool or "*",
|
||||
pattern=args.pattern or "*",
|
||||
scope=args.scope or "everywhere",
|
||||
decision=args.decision,
|
||||
priority=int(args.priority),
|
||||
)
|
||||
rules.append(new_rule)
|
||||
save_rules(rules)
|
||||
|
||||
print(f"Added rule '{rule_id}':")
|
||||
print(json.dumps(new_rule.__dict__, indent=2))
|
||||
|
||||
|
||||
def _cmd_remove(args) -> None:
|
||||
rule_id = args.id.strip().lower()
|
||||
rules = load_rules()
|
||||
kept = [r for r in rules if r.id != rule_id]
|
||||
if len(kept) == len(rules):
|
||||
print(f"No rule with id '{rule_id}' — nothing removed.")
|
||||
return
|
||||
save_rules(kept)
|
||||
print(f"Removed rule '{rule_id}'.")
|
||||
|
||||
|
||||
def _cmd_show(args) -> None:
|
||||
rule_id = args.id.strip().lower()
|
||||
for rule in load_rules():
|
||||
if rule.id == rule_id:
|
||||
print(json.dumps(rule.__dict__, indent=2))
|
||||
return
|
||||
print(f"No rule with id '{rule_id}'.")
|
||||
|
||||
|
||||
def _cmd_why(args) -> None:
|
||||
payload = explain(args.tool, args.cmd)
|
||||
print(json.dumps(payload, indent=2))
|
||||
|
||||
# A readable summary under the JSON.
|
||||
print()
|
||||
print("Decision:")
|
||||
winner = payload.get("winning_rule")
|
||||
if winner:
|
||||
print(
|
||||
f" ➜ {winner['decision'].upper()} via rule '{winner['id']}' "
|
||||
f"(priority {winner['priority']}, pattern {winner['pattern']!r})"
|
||||
)
|
||||
else:
|
||||
risk = payload.get("risk")
|
||||
thr = payload.get("threshold")
|
||||
allowed = payload.get("threshold_allows_risk")
|
||||
print(
|
||||
f" ➜ no rule matched; risk={risk}, threshold={thr} → "
|
||||
f"{'auto-approved' if allowed else 'prompts'}"
|
||||
)
|
||||
|
||||
|
||||
def _cmd_init(args) -> None:
|
||||
"""Seed a sensible starter bundle of read-only allow rules.
|
||||
|
||||
Intentionally minimal — users should review before relying on it.
|
||||
Refuses to overwrite an existing trust.json.
|
||||
"""
|
||||
existing = load_rules()
|
||||
if existing and not getattr(args, "force", False):
|
||||
print(
|
||||
f"Refusing to overwrite existing trust rules. Re-run with --force "
|
||||
f"or inspect {display_hermes_home()}/trust.json first."
|
||||
)
|
||||
return
|
||||
|
||||
starter: List[TrustRule] = [
|
||||
TrustRule(id="starter-allow-git-status", tool="terminal",
|
||||
pattern="git status*", decision="allow", priority=50),
|
||||
TrustRule(id="starter-allow-git-log", tool="terminal",
|
||||
pattern="git log*", decision="allow", priority=50),
|
||||
TrustRule(id="starter-allow-git-diff", tool="terminal",
|
||||
pattern="git diff*", decision="allow", priority=50),
|
||||
TrustRule(id="starter-allow-ls", tool="terminal",
|
||||
pattern="ls*", decision="allow", priority=50),
|
||||
TrustRule(id="starter-allow-cat-readonly", tool="terminal",
|
||||
pattern="cat *", decision="allow", priority=50),
|
||||
TrustRule(id="starter-allow-file-read", tool="file_read",
|
||||
pattern="*", decision="allow", priority=50),
|
||||
TrustRule(id="starter-allow-search-files", tool="search_files",
|
||||
pattern="*", decision="allow", priority=50),
|
||||
]
|
||||
save_rules(starter)
|
||||
print(f"Seeded {len(starter)} starter rule(s) to {display_hermes_home()}/trust.json.")
|
||||
print("Inspect with 'hermes trust list'; remove any you don't want.")
|
||||
+36
-148
@@ -281,8 +281,6 @@ _recorder_lock = threading.Lock()
|
||||
# ── Continuous (VAD) state ───────────────────────────────────────────
|
||||
_continuous_lock = threading.Lock()
|
||||
_continuous_active = False
|
||||
_continuous_stopping = False
|
||||
_continuous_auto_restart: bool = True
|
||||
_continuous_recorder: Any = None
|
||||
|
||||
# ── TTS-vs-STT feedback guard ────────────────────────────────────────
|
||||
@@ -372,43 +370,32 @@ def start_continuous(
|
||||
on_silent_limit: Optional[Callable[[], None]] = None,
|
||||
silence_threshold: int = 200,
|
||||
silence_duration: float = 3.0,
|
||||
auto_restart: bool = True,
|
||||
) -> bool:
|
||||
) -> None:
|
||||
"""Start a VAD-driven continuous recording loop.
|
||||
|
||||
The loop calls ``on_transcript(text)`` each time speech is detected and
|
||||
transcribed successfully. If ``auto_restart`` is True, it auto-restarts
|
||||
for the next turn and resets the no-speech counter for that loop. If
|
||||
``auto_restart`` is False, the first silence-triggered transcription ends
|
||||
the loop and reports ``"idle"``; no-speech counts are retained across
|
||||
starts so a push-to-talk caller can still enforce the three-strikes guard.
|
||||
After ``_CONTINUOUS_NO_SPEECH_LIMIT`` consecutive silent cycles (no speech
|
||||
picked up at all) the loop stops itself and calls ``on_silent_limit`` so the
|
||||
UI can reflect "voice off". Returns False if a previous stop is still
|
||||
transcribing/cleaning up; otherwise returns True. Idempotent — calling while
|
||||
already active is a successful no-op.
|
||||
transcribed successfully, then auto-restarts. After
|
||||
``_CONTINUOUS_NO_SPEECH_LIMIT`` consecutive silent cycles (no speech
|
||||
picked up at all) the loop stops itself and calls ``on_silent_limit``
|
||||
so the UI can reflect "voice off". Idempotent — calling while already
|
||||
active is a no-op.
|
||||
|
||||
``on_status`` is called with ``"listening"`` / ``"transcribing"`` /
|
||||
``"idle"`` so the UI can show a live indicator.
|
||||
"""
|
||||
global _continuous_active, _continuous_recorder, _continuous_auto_restart
|
||||
global _continuous_active, _continuous_recorder
|
||||
global _continuous_on_transcript, _continuous_on_status, _continuous_on_silent_limit
|
||||
global _continuous_no_speech_count
|
||||
|
||||
with _continuous_lock:
|
||||
if _continuous_active:
|
||||
_debug("start_continuous: already active — no-op")
|
||||
return True
|
||||
if _continuous_stopping:
|
||||
_debug("start_continuous: stop/transcribe in progress — busy")
|
||||
return False
|
||||
return
|
||||
_continuous_active = True
|
||||
_continuous_auto_restart = auto_restart
|
||||
_continuous_on_transcript = on_transcript
|
||||
_continuous_on_status = on_status
|
||||
_continuous_on_silent_limit = on_silent_limit
|
||||
if auto_restart:
|
||||
_continuous_no_speech_count = 0
|
||||
_continuous_no_speech_count = 0
|
||||
|
||||
if _continuous_recorder is None:
|
||||
_continuous_recorder = create_audio_recorder()
|
||||
@@ -441,18 +428,15 @@ def start_continuous(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def stop_continuous(force_transcribe: bool = False) -> None:
|
||||
def stop_continuous() -> None:
|
||||
"""Stop the active continuous loop and release the microphone.
|
||||
|
||||
Idempotent — calling while not active is a no-op. If ``force_transcribe`` is
|
||||
True, the recorder stops synchronously, then transcription/cleanup runs on a
|
||||
background thread before reporting ``"idle"``. Otherwise the buffer is
|
||||
discarded.
|
||||
Idempotent — calling while not active is a no-op. Any in-flight
|
||||
transcription completes but its result is discarded (the callback
|
||||
checks ``_continuous_active`` before firing).
|
||||
"""
|
||||
global _continuous_active, _continuous_on_transcript, _continuous_stopping
|
||||
global _continuous_active, _continuous_on_transcript
|
||||
global _continuous_on_status, _continuous_on_silent_limit
|
||||
global _continuous_recorder, _continuous_no_speech_count
|
||||
|
||||
@@ -462,98 +446,18 @@ def stop_continuous(force_transcribe: bool = False) -> None:
|
||||
_continuous_active = False
|
||||
rec = _continuous_recorder
|
||||
on_status = _continuous_on_status
|
||||
on_transcript = _continuous_on_transcript
|
||||
on_silent_limit = _continuous_on_silent_limit
|
||||
auto_restart = _continuous_auto_restart
|
||||
track_no_speech = force_transcribe and not auto_restart
|
||||
_continuous_stopping = rec is not None
|
||||
_continuous_on_transcript = None
|
||||
_continuous_on_status = None
|
||||
_continuous_on_silent_limit = None
|
||||
if not track_no_speech:
|
||||
_continuous_no_speech_count = 0
|
||||
_continuous_no_speech_count = 0
|
||||
|
||||
if rec is not None:
|
||||
if force_transcribe and on_transcript:
|
||||
if on_status:
|
||||
try:
|
||||
on_status("transcribing")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
wav_path = rec.stop()
|
||||
except Exception as e:
|
||||
logger.warning("failed to stop recorder: %s", e)
|
||||
try:
|
||||
rec.cancel()
|
||||
except Exception as cancel_error:
|
||||
logger.warning("failed to cancel recorder: %s", cancel_error)
|
||||
wav_path = None
|
||||
|
||||
def _transcribe_and_cleanup():
|
||||
global _continuous_no_speech_count, _continuous_stopping
|
||||
transcript: Optional[str] = None
|
||||
should_halt = False
|
||||
|
||||
try:
|
||||
if wav_path:
|
||||
try:
|
||||
result = transcribe_recording(wav_path)
|
||||
if result.get("success"):
|
||||
text = (result.get("transcript") or "").strip()
|
||||
if text and not is_whisper_hallucination(text):
|
||||
transcript = text
|
||||
finally:
|
||||
if os.path.isfile(wav_path):
|
||||
os.unlink(wav_path)
|
||||
except Exception as e:
|
||||
logger.warning("failed to stop/transcribe recorder: %s", e)
|
||||
finally:
|
||||
if transcript:
|
||||
try:
|
||||
on_transcript(transcript)
|
||||
except Exception as e:
|
||||
logger.warning("on_transcript callback raised: %s", e)
|
||||
|
||||
if track_no_speech:
|
||||
with _continuous_lock:
|
||||
if transcript:
|
||||
_continuous_no_speech_count = 0
|
||||
else:
|
||||
_continuous_no_speech_count += 1
|
||||
should_halt = (
|
||||
_continuous_no_speech_count
|
||||
>= _CONTINUOUS_NO_SPEECH_LIMIT
|
||||
)
|
||||
if should_halt:
|
||||
_continuous_no_speech_count = 0
|
||||
if should_halt and on_silent_limit:
|
||||
try:
|
||||
on_silent_limit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_play_beep(frequency=660, count=2)
|
||||
with _continuous_lock:
|
||||
_continuous_stopping = False
|
||||
if on_status:
|
||||
try:
|
||||
on_status("idle")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_transcribe_and_cleanup, daemon=True).start()
|
||||
return
|
||||
else:
|
||||
try:
|
||||
# cancel() (not stop()) discards buffered frames — the loop
|
||||
# is over, we don't want to transcribe a half-captured turn.
|
||||
rec.cancel()
|
||||
except Exception as e:
|
||||
logger.warning("failed to cancel recorder: %s", e)
|
||||
|
||||
with _continuous_lock:
|
||||
_continuous_stopping = False
|
||||
try:
|
||||
# cancel() (not stop()) discards buffered frames — the loop
|
||||
# is over, we don't want to transcribe a half-captured turn.
|
||||
rec.cancel()
|
||||
except Exception as e:
|
||||
logger.warning("failed to cancel recorder: %s", e)
|
||||
|
||||
# Audible "recording stopped" cue (CLI parity: same 660 Hz × 2 the
|
||||
# silence-auto-stop path plays).
|
||||
@@ -699,39 +603,23 @@ def _continuous_on_silence() -> None:
|
||||
_debug("_continuous_on_silence: stopped while waiting for TTS")
|
||||
return
|
||||
|
||||
if _continuous_auto_restart:
|
||||
# Restart for the next turn.
|
||||
_debug(f"_continuous_on_silence: restarting loop (no_speech={no_speech})")
|
||||
_play_beep(frequency=880, count=1)
|
||||
try:
|
||||
rec.start(on_silence_stop=_continuous_on_silence)
|
||||
except Exception as e:
|
||||
logger.error("failed to restart continuous recording: %s", e)
|
||||
_debug(f"_continuous_on_silence: restart raised {type(e).__name__}: {e}")
|
||||
with _continuous_lock:
|
||||
_continuous_active = False
|
||||
if on_status:
|
||||
try:
|
||||
on_status("idle")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if on_status:
|
||||
try:
|
||||
on_status("listening")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Do not auto-restart. Clean up state and notify idle.
|
||||
_debug("_continuous_on_silence: auto_restart=False, stopping loop")
|
||||
# Restart for the next turn.
|
||||
_debug(f"_continuous_on_silence: restarting loop (no_speech={no_speech})")
|
||||
_play_beep(frequency=880, count=1)
|
||||
try:
|
||||
rec.start(on_silence_stop=_continuous_on_silence)
|
||||
except Exception as e:
|
||||
logger.error("failed to restart continuous recording: %s", e)
|
||||
_debug(f"_continuous_on_silence: restart raised {type(e).__name__}: {e}")
|
||||
with _continuous_lock:
|
||||
_continuous_active = False
|
||||
if on_status:
|
||||
try:
|
||||
on_status("idle")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if on_status:
|
||||
try:
|
||||
on_status("listening")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── TTS API ──────────────────────────────────────────────────────────
|
||||
|
||||
+13
-185
@@ -52,7 +52,7 @@ from gateway.status import get_running_pid, read_runtime_status
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
@@ -1877,8 +1877,8 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
name=f"oauth-codex-{sid[:6]}",
|
||||
).start()
|
||||
# Block briefly until the worker has populated the user_code, OR error.
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
with _oauth_sessions_lock:
|
||||
s = _oauth_sessions.get(sid)
|
||||
if s and (s.get("user_code") or s["status"] != "pending"):
|
||||
@@ -2012,10 +2012,10 @@ def _codex_full_login_worker(session_id: str) -> None:
|
||||
sess["expires_at"] = time.time() + sess["expires_in"]
|
||||
|
||||
# Step 2: poll until authorized
|
||||
deadline = time.monotonic() + sess["expires_in"]
|
||||
deadline = time.time() + sess["expires_in"]
|
||||
code_resp = None
|
||||
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
|
||||
while time.monotonic() < deadline:
|
||||
while time.time() < deadline:
|
||||
time.sleep(poll_interval)
|
||||
poll = client.post(
|
||||
f"{issuer}/api/accounts/deviceauth/token",
|
||||
@@ -2173,83 +2173,6 @@ async def cancel_oauth_session(session_id: str, request: Request):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
def _session_latest_descendant(session_id: str):
|
||||
"""Resolve a session id to the newest child leaf session.
|
||||
|
||||
/model may create child sessions. Dashboard refresh should continue the
|
||||
newest child instead of reopening the old parent.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
def row_get(row, key, index):
|
||||
if isinstance(row, dict):
|
||||
return row.get(key)
|
||||
try:
|
||||
return row[key]
|
||||
except Exception:
|
||||
try:
|
||||
return row[index]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid or not db.get_session(sid):
|
||||
return None, []
|
||||
|
||||
conn = (
|
||||
getattr(db, "conn", None)
|
||||
or getattr(db, "_conn", None)
|
||||
or getattr(db, "connection", None)
|
||||
or getattr(db, "_connection", None)
|
||||
)
|
||||
|
||||
rows = []
|
||||
if conn is not None:
|
||||
raw_rows = conn.execute(
|
||||
"SELECT id, parent_session_id, started_at FROM sessions"
|
||||
).fetchall()
|
||||
for row in raw_rows:
|
||||
rows.append({
|
||||
"id": row_get(row, "id", 0),
|
||||
"parent_session_id": row_get(row, "parent_session_id", 1),
|
||||
"started_at": row_get(row, "started_at", 2),
|
||||
})
|
||||
else:
|
||||
rows = db.list_sessions_rich(limit=10000, offset=0)
|
||||
|
||||
children = {}
|
||||
for row in rows:
|
||||
rid = row.get("id")
|
||||
parent = row.get("parent_session_id")
|
||||
if rid and parent:
|
||||
children.setdefault(parent, []).append(row)
|
||||
|
||||
def started(row):
|
||||
try:
|
||||
return float(row.get("started_at") or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
current = sid
|
||||
path = [sid]
|
||||
seen = {sid}
|
||||
|
||||
while children.get(current):
|
||||
candidates = [r for r in children[current] if r.get("id") not in seen]
|
||||
if not candidates:
|
||||
break
|
||||
candidates.sort(key=started, reverse=True)
|
||||
current = candidates[0]["id"]
|
||||
path.append(current)
|
||||
seen.add(current)
|
||||
|
||||
return current, path
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session_detail(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
@@ -2264,19 +2187,6 @@ async def get_session_detail(session_id: str):
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/latest-descendant")
|
||||
async def get_session_latest_descendant(session_id: str):
|
||||
latest, path = _session_latest_descendant(session_id)
|
||||
if not latest:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {
|
||||
"requested_session_id": path[0] if path else session_id,
|
||||
"session_id": latest,
|
||||
"path": path,
|
||||
"changed": bool(path and latest != path[0]),
|
||||
}
|
||||
|
||||
@app.get("/api/sessions/{session_id}/messages")
|
||||
async def get_session_messages(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
@@ -2456,7 +2366,6 @@ async def delete_cron_job(job_id: str):
|
||||
class ProfileCreate(BaseModel):
|
||||
name: str
|
||||
clone_from_default: bool = False
|
||||
no_skills: bool = False
|
||||
|
||||
|
||||
class ProfileRename(BaseModel):
|
||||
@@ -2562,13 +2471,11 @@ async def create_profile_endpoint(body: ProfileCreate):
|
||||
name=body.name,
|
||||
clone_from="default" if body.clone_from_default else None,
|
||||
clone_config=body.clone_from_default,
|
||||
no_skills=body.no_skills,
|
||||
)
|
||||
# Match the CLI's profile-create flow: fresh named profiles get the
|
||||
# bundled skills installed. When cloning from default, create_profile()
|
||||
# has already copied the source profile's skills, including any
|
||||
# user-installed skills. When no_skills=True, create_profile() wrote
|
||||
# the opt-out marker and seed_profile_skills() will no-op.
|
||||
# user-installed skills.
|
||||
if not body.clone_from_default:
|
||||
profiles_mod.seed_profile_skills(path, quiet=True)
|
||||
|
||||
@@ -3039,18 +2946,8 @@ def _resolve_chat_argv(
|
||||
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
|
||||
env = os.environ.copy()
|
||||
env.setdefault("NODE_ENV", "production")
|
||||
# Browser-embedded chat should prefer stable wheel-based scrollback over
|
||||
# native terminal mouse tracking. When mouse tracking is enabled, wheel
|
||||
# events are consumed by the TUI and forwarded as terminal input, which
|
||||
# makes browser-side transcript scrolling feel broken. Keep the terminal
|
||||
# build unchanged for native CLI usage; only disable mouse tracking for
|
||||
# the dashboard PTY path.
|
||||
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
|
||||
|
||||
if resume:
|
||||
latest_resume, _latest_path = _session_latest_descendant(resume)
|
||||
if latest_resume:
|
||||
resume = latest_resume
|
||||
env["HERMES_TUI_RESUME"] = resume
|
||||
|
||||
if sidecar_url:
|
||||
@@ -3308,42 +3205,12 @@ async def events_ws(ws: WebSocket) -> None:
|
||||
_event_channels.pop(channel, None)
|
||||
|
||||
|
||||
def _normalise_prefix(raw: Optional[str]) -> str:
|
||||
"""Normalise an X-Forwarded-Prefix header value.
|
||||
|
||||
Returns a string like ``"/hermes"`` (no trailing slash) or ``""`` when
|
||||
no prefix is set / the header is malformed. We deliberately reject
|
||||
anything containing ``..`` or non-printable bytes so a hostile proxy
|
||||
can't inject HTML via the prefix.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
p = raw.strip()
|
||||
if not p:
|
||||
return ""
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
p = p.rstrip("/")
|
||||
if "//" in p or ".." in p or any(c in p for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t")):
|
||||
return ""
|
||||
if len(p) > 64:
|
||||
return ""
|
||||
return p
|
||||
|
||||
|
||||
def mount_spa(application: FastAPI):
|
||||
"""Mount the built SPA. Falls back to index.html for client-side routing.
|
||||
|
||||
The session token is injected into index.html via a ``<script>`` tag so
|
||||
the SPA can authenticate against protected API endpoints without a
|
||||
separate (unauthenticated) token-dispensing endpoint.
|
||||
|
||||
When served behind a path-prefix reverse proxy (e.g.
|
||||
``mission-control.tilos.com/hermes/*`` -> local Caddy -> :9119), the
|
||||
proxy injects ``X-Forwarded-Prefix: /hermes`` on every request. We
|
||||
rewrite the served ``index.html`` so absolute asset URLs (``/assets/...``)
|
||||
and the SPA's runtime ``__HERMES_BASE_PATH__`` honour that prefix
|
||||
without rebuilding the bundle.
|
||||
"""
|
||||
if not WEB_DIST.exists():
|
||||
@application.get("/{full_path:path}")
|
||||
@@ -3356,62 +3223,24 @@ def mount_spa(application: FastAPI):
|
||||
|
||||
_index_path = WEB_DIST / "index.html"
|
||||
|
||||
def _serve_index(prefix: str = ""):
|
||||
"""Return index.html with the session token + base-path injected.
|
||||
|
||||
``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``)
|
||||
or empty string when served at root.
|
||||
"""
|
||||
def _serve_index():
|
||||
"""Return index.html with the session token injected."""
|
||||
html = _index_path.read_text()
|
||||
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
|
||||
token_script = (
|
||||
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
|
||||
f'window.__HERMES_BASE_PATH__="{prefix}";</script>'
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};</script>"
|
||||
)
|
||||
if prefix:
|
||||
# Rewrite absolute asset URLs baked into the Vite build so the
|
||||
# browser fetches them through the same proxy prefix.
|
||||
html = html.replace('href="/assets/', f'href="{prefix}/assets/')
|
||||
html = html.replace('src="/assets/', f'src="{prefix}/assets/')
|
||||
html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"')
|
||||
html = html.replace('href="/fonts/', f'href="{prefix}/fonts/')
|
||||
html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/')
|
||||
html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/')
|
||||
html = html.replace("</head>", f"{token_script}</head>", 1)
|
||||
return HTMLResponse(
|
||||
html,
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
|
||||
# When served behind a path-prefix proxy, the built CSS contains
|
||||
# absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references.
|
||||
# Browsers resolve those against the document origin, which means
|
||||
# under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...``
|
||||
# (the MC Pages app), not the Hermes backend. Intercept CSS asset
|
||||
# requests BEFORE the StaticFiles mount and rewrite the absolute paths
|
||||
# when a prefix is in play.
|
||||
@application.get("/assets/{filename}.css")
|
||||
async def serve_css(filename: str, request: Request):
|
||||
css_path = WEB_DIST / "assets" / f"{filename}.css"
|
||||
if not css_path.is_file() or not css_path.resolve().is_relative_to(
|
||||
WEB_DIST.resolve()
|
||||
):
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
||||
css = css_path.read_text()
|
||||
if prefix:
|
||||
for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"):
|
||||
css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}")
|
||||
css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}")
|
||||
css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}")
|
||||
return Response(content=css, media_type="text/css")
|
||||
|
||||
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||
|
||||
@application.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str, request: Request):
|
||||
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
||||
async def serve_spa(full_path: str):
|
||||
file_path = WEB_DIST / full_path
|
||||
# Prevent path traversal via url-encoded sequences (%2e%2e/)
|
||||
if (
|
||||
@@ -3421,7 +3250,7 @@ def mount_spa(application: FastAPI):
|
||||
and file_path.is_file()
|
||||
):
|
||||
return FileResponse(file_path)
|
||||
return _serve_index(prefix)
|
||||
return _serve_index()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3431,9 +3260,8 @@ def mount_spa(application: FastAPI):
|
||||
# Built-in dashboard themes — label + description only. The actual color
|
||||
# definitions live in the frontend (web/src/themes/presets.ts).
|
||||
_BUILTIN_DASHBOARD_THEMES = [
|
||||
{"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
|
||||
{"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"},
|
||||
{"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
|
||||
{"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
|
||||
{"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
|
||||
{"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"},
|
||||
{"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"},
|
||||
{"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"},
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
#
|
||||
# Keys are dotted paths; nesting below is purely for readability. Values may
|
||||
# contain {placeholder} tokens for str.format substitution. When adding a
|
||||
# new key, add it to EVERY locale file (en/zh/ja/de/es/fr/tr/uk) in the same commit --
|
||||
# new key, add it to EVERY locale file (en/zh/ja/de/es) in the same commit --
|
||||
# tests/agent/test_i18n.py asserts catalog parity.
|
||||
|
||||
approval:
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Hermes static-message catalog -- French (français)
|
||||
# See locales/en.yaml for the source of truth; keep keys in sync.
|
||||
|
||||
approval:
|
||||
dangerous_header: "⚠️ COMMANDE DANGEREUSE : {description}"
|
||||
choose_long: " [o]ne fois | [s]ession | [t]oujours | [r]efuser"
|
||||
choose_short: " [o]ne fois | [s]ession | [r]efuser"
|
||||
prompt_long: " Choix [o/s/t/R] : "
|
||||
prompt_short: " Choix [o/s/R] : "
|
||||
timeout: " ⏱ Délai dépassé — commande refusée"
|
||||
allowed_once: " ✓ Autorisé une fois"
|
||||
allowed_session: " ✓ Autorisé pour cette session"
|
||||
allowed_always: " ✓ Ajouté à la liste d'autorisation permanente"
|
||||
denied: " ✗ Refusé"
|
||||
cancelled: " ✗ Annulé"
|
||||
blocklist_message: "Cette commande est sur la liste de blocage inconditionnel et ne peut pas être approuvée."
|
||||
|
||||
gateway:
|
||||
approval_expired: "⚠️ Approbation expirée (l'agent n'attend plus). Demandez à l'agent de réessayer."
|
||||
draining: "⏳ Vidage de {count} agent(s) actif(s) avant redémarrage..."
|
||||
goal_cleared: "✓ Objectif effacé."
|
||||
no_active_goal: "Aucun objectif actif."
|
||||
config_read_failed: "⚠️ Impossible de lire config.yaml : {error}"
|
||||
config_save_failed: "⚠️ Impossible de sauvegarder la configuration : {error}"
|
||||
@@ -1,24 +0,0 @@
|
||||
# Hermes statik mesaj katalogu -- Turkce
|
||||
# See locales/en.yaml for the source of truth; keep keys in sync.
|
||||
|
||||
approval:
|
||||
dangerous_header: "⚠️ TEHLİKELİ KOMUT: {description}"
|
||||
choose_long: " [b]ir kez | [o]turum | [h]er zaman | [r]eddet"
|
||||
choose_short: " [b]ir kez | [o]turum | [r]eddet"
|
||||
prompt_long: " Seçim [b/o/h/R]: "
|
||||
prompt_short: " Seçim [b/o/R]: "
|
||||
timeout: " ⏱ Zaman aşımı — komut reddedildi"
|
||||
allowed_once: " ✓ Bir kez izin verildi"
|
||||
allowed_session: " ✓ Bu oturum için izin verildi"
|
||||
allowed_always: " ✓ Kalıcı izin listesine eklendi"
|
||||
denied: " ✗ Reddedildi"
|
||||
cancelled: " ✗ İptal edildi"
|
||||
blocklist_message: "Bu komut koşulsuz engelleme listesinde ve onaylanamaz."
|
||||
|
||||
gateway:
|
||||
approval_expired: "⚠️ Onay süresi doldu (ajan artık beklemiyor). Ajanın tekrar denemesini isteyin."
|
||||
draining: "⏳ Yeniden başlatmadan önce {count} aktif ajan bekleniyor..."
|
||||
goal_cleared: "✓ Hedef temizlendi."
|
||||
no_active_goal: "Aktif hedef yok."
|
||||
config_read_failed: "⚠️ config.yaml okunamadı: {error}"
|
||||
config_save_failed: "⚠️ Yapılandırma kaydedilemedi: {error}"
|
||||
@@ -1,24 +0,0 @@
|
||||
# Каталог статичних повідомлень Hermes -- Українська
|
||||
# See locales/en.yaml for the source of truth; keep keys in sync.
|
||||
|
||||
approval:
|
||||
dangerous_header: "⚠️ НЕБЕЗПЕЧНА КОМАНДА: {description}"
|
||||
choose_long: " [o]один раз | [s]сеанс | [a]завжди | [d]відхилити"
|
||||
choose_short: " [o]один раз | [s]сеанс | [d]відхилити"
|
||||
prompt_long: " Вибір [o/s/a/D]: "
|
||||
prompt_short: " Вибір [o/s/D]: "
|
||||
timeout: " ⏱ Час очікування вичерпано — команду відхилено"
|
||||
allowed_once: " ✓ Дозволено один раз"
|
||||
allowed_session: " ✓ Дозволено для цього сеансу"
|
||||
allowed_always: " ✓ Додано до постійного списку дозволених команд"
|
||||
denied: " ✗ Відхилено"
|
||||
cancelled: " ✗ Скасовано"
|
||||
blocklist_message: "Ця команда є в безумовному списку блокування, її не можна схвалити."
|
||||
|
||||
gateway:
|
||||
approval_expired: "⚠️ Час схвалення минув (агент більше не очікує). Попросіть агента спробувати ще раз."
|
||||
draining: "⏳ Очікування завершення {count} активних агент(ів) перед перезапуском..."
|
||||
goal_cleared: "✓ Ціль очищено."
|
||||
no_active_goal: "Немає активної цілі."
|
||||
config_read_failed: "⚠️ Не вдалося прочитати config.yaml: {error}"
|
||||
config_save_failed: "⚠️ Не вдалося зберегти конфігурацію: {error}"
|
||||
+1
-31
@@ -115,25 +115,6 @@ def _load_channel_directory() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _coerce_int(
|
||||
value,
|
||||
*,
|
||||
default: int,
|
||||
minimum: int,
|
||||
maximum: int,
|
||||
) -> int:
|
||||
"""Coerce value to int with fallback and clamping.
|
||||
|
||||
Used at MCP tool boundaries to handle invalid types from external clients.
|
||||
Returns default if value cannot be converted to int.
|
||||
"""
|
||||
try:
|
||||
coerced = int(value)
|
||||
except (TypeError, ValueError):
|
||||
coerced = default
|
||||
return max(minimum, min(coerced, maximum))
|
||||
|
||||
|
||||
def _extract_message_content(msg: dict) -> str:
|
||||
"""Extract text content from a message, handling multi-part content."""
|
||||
content = msg.get("content", "")
|
||||
@@ -484,7 +465,6 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
|
||||
limit: Maximum number of conversations to return (default 50)
|
||||
search: Optional text to filter conversations by name
|
||||
"""
|
||||
limit = _coerce_int(limit, default=50, minimum=1, maximum=200)
|
||||
entries = _load_sessions_index()
|
||||
conversations = []
|
||||
|
||||
@@ -572,7 +552,6 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
|
||||
session_key: The session key from conversations_list
|
||||
limit: Maximum number of messages to return (default 50, most recent)
|
||||
"""
|
||||
limit = _coerce_int(limit, default=50, minimum=1, maximum=200)
|
||||
entries = _load_sessions_index()
|
||||
entry = entries.get(session_key)
|
||||
if not entry:
|
||||
@@ -685,8 +664,6 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
|
||||
session_key: Optional filter to one conversation
|
||||
limit: Maximum events to return (default 20)
|
||||
"""
|
||||
after_cursor = _coerce_int(after_cursor, default=0, minimum=0, maximum=10**18)
|
||||
limit = _coerce_int(limit, default=20, minimum=1, maximum=200)
|
||||
result = bridge.poll_events(
|
||||
after_cursor=after_cursor,
|
||||
session_key=session_key,
|
||||
@@ -712,17 +689,10 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
|
||||
session_key: Optional filter to one conversation
|
||||
timeout_ms: Maximum wait time in milliseconds (default 30000)
|
||||
"""
|
||||
after_cursor = _coerce_int(after_cursor, default=0, minimum=0, maximum=10**18)
|
||||
timeout_ms = _coerce_int(
|
||||
timeout_ms,
|
||||
default=30000,
|
||||
minimum=0,
|
||||
maximum=300000,
|
||||
) # Cap at 5 minutes
|
||||
event = bridge.wait_for_event(
|
||||
after_cursor=after_cursor,
|
||||
session_key=session_key,
|
||||
timeout_ms=timeout_ms,
|
||||
timeout_ms=min(timeout_ms, 300000), # Cap at 5 minutes
|
||||
)
|
||||
if event:
|
||||
return json.dumps({"event": event}, indent=2)
|
||||
|
||||
+6
-6
@@ -730,8 +730,8 @@ def handle_function_call(
|
||||
session_id=session_id or "",
|
||||
tool_call_id=tool_call_id or "",
|
||||
)
|
||||
except Exception as _hook_err:
|
||||
logger.debug("pre_tool_call hook error: %s", _hook_err)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if block_message is not None:
|
||||
return json.dumps({"error": block_message}, ensure_ascii=False)
|
||||
@@ -782,8 +782,8 @@ def handle_function_call(
|
||||
tool_call_id=tool_call_id or "",
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
except Exception as _hook_err:
|
||||
logger.debug("post_tool_call hook error: %s", _hook_err)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Generic tool-result canonicalization seam: plugins receive the
|
||||
# final result string (JSON, usually) and may replace it by
|
||||
@@ -807,8 +807,8 @@ def handle_function_call(
|
||||
if isinstance(hook_result, str):
|
||||
result = hook_result
|
||||
break
|
||||
except Exception as _hook_err:
|
||||
logger.debug("transform_tool_result hook error: %s", _hook_err)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
---
|
||||
name: 3-statement-model
|
||||
description: Build fully-integrated 3-statement models (IS, BS, CF) in Excel with working capital schedules, D&A roll-forwards, debt schedule, and the plugs that make cash and retained earnings tie. Pairs with excel-author.
|
||||
version: 1.0.0
|
||||
author: Anthropic (adapted by Nous Research)
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [finance, three-statement, income-statement, balance-sheet, cash-flow, excel, openpyxl, modeling]
|
||||
related_skills: [excel-author, pptx-author, dcf-model, lbo-model]
|
||||
---
|
||||
|
||||
## Environment
|
||||
|
||||
This skill assumes **headless openpyxl** — you are producing an .xlsx file on disk.
|
||||
Follow the `excel-author` skill's conventions for cell coloring, formulas, named ranges, and sensitivity tables.
|
||||
Recalculate before delivery: `python /path/to/excel-author/scripts/recalc.py ./out/model.xlsx`.
|
||||
|
||||
# 3-Statement Financial Model Template Completion
|
||||
|
||||
Complete and populate integrated financial model templates with proper linkages between Income Statement, Balance Sheet, and Cash Flow Statement.
|
||||
|
||||
## ⚠️ CRITICAL PRINCIPLES — Read Before Populating Any Template
|
||||
|
||||
**Formulas over hardcodes (non-negotiable):**
|
||||
- Every projection cell, roll-forward, linkage, and subtotal MUST be an Excel formula — never a pre-computed value
|
||||
- When using Python/openpyxl: write formula strings (`ws["D15"] = "=D14*(1+Assumptions!$B$5)"`), NOT computed results (`ws["D15"] = 12500`)
|
||||
- The ONLY cells that should contain hardcoded numbers are: (1) historical actuals, (2) assumption drivers in the Assumptions tab
|
||||
- If you find yourself computing a value in Python and writing the result to a cell — STOP. Write the formula instead.
|
||||
- Why: the model must flex when scenarios toggle or assumptions change. Hardcodes break every downstream integrity check silently.
|
||||
|
||||
**Verify step-by-step with the user:**
|
||||
1. **After mapping the template** → show the user which tabs/sections you've identified and confirm before touching any cells
|
||||
2. **After populating historicals** → show the user the historical block and confirm values/periods match source data
|
||||
3. **After building IS projections** → run the subtotal checks, show the user the projected IS, confirm before moving to BS
|
||||
4. **After building BS** → show the user the balance check (Assets = L+E) for every period, confirm before moving to CF
|
||||
5. **After building CF** → show the user the cash tie-out (CF ending cash = BS cash), confirm before finalizing
|
||||
6. **Do NOT populate the entire model end-to-end and present it complete** — break at each statement, show the work, catch errors early
|
||||
|
||||
## Formatting — Professional Blue/Grey Palette (Default unless template/user specifies otherwise)
|
||||
|
||||
**Keep colors minimal.** Use only blues and greys for cell fills. Do NOT introduce greens, yellows, oranges, or multiple accent colors — a clean model uses restraint.
|
||||
|
||||
| Element | Fill | Font |
|
||||
|---|---|---|
|
||||
| Section headers (IS / BS / CF titles) | Dark blue `#1F4E79` | White bold |
|
||||
| Column headers (FY2024A, FY2025E, etc.) | Light blue `#D9E1F2` | Black bold |
|
||||
| Input cells (historicals, assumption drivers) | Light grey `#F2F2F2` or white | Blue `#0000FF` |
|
||||
| Formula cells | White | Black |
|
||||
| Cross-tab links | White | Green `#008000` |
|
||||
| Check rows / key totals | Medium blue `#BDD7EE` | Black bold |
|
||||
|
||||
**That's 3 blues + 1 grey + white.** If the template has its own color scheme, follow the template instead.
|
||||
|
||||
Font color signals *what* a cell is (input/formula/link). Fill color signals *where* you are (header/data/check).
|
||||
|
||||
## Model Structure
|
||||
|
||||
### Identifying Template Tab Organization
|
||||
|
||||
Templates vary in their tab naming conventions and organization. Before populating, review all tabs to understand the template's structure. Below are common tab names and their typical contents:
|
||||
|
||||
| Common Tab Names | Contents to Look For |
|
||||
|------------------|----------------------|
|
||||
| IS, P&L, Income Statement | Income Statement |
|
||||
| BS, Balance Sheet | Balance Sheet |
|
||||
| CF, CFS, Cash Flow | Cash Flow Statement |
|
||||
| WC, Working Capital | Working Capital Schedule |
|
||||
| DA, D&A, Depreciation, PP&E | Depreciation & Amortization Schedule |
|
||||
| Debt, Debt Schedule | Debt Schedule |
|
||||
| NOL, Tax, DTA | Net Operating Loss Schedule |
|
||||
| Assumptions, Inputs, Drivers | Driver assumptions and inputs |
|
||||
| Checks, Audit, Validation | Error-checking dashboard |
|
||||
|
||||
**Template Review Checklist**
|
||||
- Identify which tabs exist in the template (not all templates include every schedule)
|
||||
- Note any template-specific tabs not listed above
|
||||
- Understand tab dependencies (e.g., which schedules feed into the main statements)
|
||||
- Locate input cells vs. formula cells on each tab
|
||||
|
||||
### Understanding Template Structure
|
||||
|
||||
Before populating a template, familiarize yourself with its existing layout to ensure data is entered in the correct locations and formulas remain intact.
|
||||
|
||||
**Identifying Row Structure**
|
||||
- Locate the model title at top of each tab
|
||||
- Identify section headers and their visual separation
|
||||
- Find the units row indicating $ millions, %, x, etc.
|
||||
- Note column headers distinguishing Actuals vs. Estimates periods
|
||||
- Confirm period labels (e.g., FY2024A, FY2025E)
|
||||
- Identify input cells vs. formula cells (typically distinguished by font color)
|
||||
|
||||
**Identifying Column Structure**
|
||||
- Confirm line item labels in leftmost column
|
||||
- Verify historical years precede projection years
|
||||
- Note the visual border separating historical from projected periods
|
||||
- Check for consistent column order across all tabs
|
||||
|
||||
**Working with Named Ranges**
|
||||
Templates often use named ranges for key inputs and outputs. Before entering data:
|
||||
- Review existing named ranges in the template (Formulas → Name Manager in Excel)
|
||||
- Common named ranges include: Revenue growth rates, cost percentages, key outputs (Net Income, EBITDA, Total Debt, Cash), scenario selector cell
|
||||
- Ensure inputs are entered in cells that feed into these named ranges
|
||||
|
||||
### Projection Period
|
||||
- Templates typically project 5 years forward from last historical year
|
||||
- Verify historical (A) vs. projected (E) columns are clearly separated
|
||||
- Confirm columns use fiscal year notation (e.g., FY2024A, FY2025E)
|
||||
|
||||
## Margin Analysis
|
||||
|
||||
**Note: The following margin analysis should only be performed if prompted by the user or if the template explicitly requires it. If no prompt is given, skip this section.**
|
||||
|
||||
Calculate and display profitability margins on the Income Statement (IS) tab to track operational efficiency and enable peer comparison.
|
||||
|
||||
### Core Margins to Include
|
||||
|
||||
| Margin | Formula | What It Measures |
|
||||
|--------|---------|------------------|
|
||||
| Gross Margin | Gross Profit / Revenue | Pricing power, production efficiency |
|
||||
| EBITDA Margin | EBITDA / Revenue | Core operating profitability |
|
||||
| EBIT Margin | EBIT / Revenue | Operating profitability after D&A |
|
||||
| Net Income Margin | Net Income / Revenue | Bottom-line profitability |
|
||||
|
||||
### Income Statement Layout with Margins
|
||||
|
||||
Display margin percentages directly below each profit line item:
|
||||
- Gross Margin % below Gross Profit
|
||||
- EBIT Margin % below EBIT
|
||||
- EBITDA Margin % below EBITDA
|
||||
- Net Income Margin % below Net Income
|
||||
|
||||
## Credit Metrics
|
||||
|
||||
**Note: The following Credit analysis should only be performed if prompted by the user or if the template explicitly requires it. If no prompt is given, skip this section.**
|
||||
|
||||
Calculate and display credit/leverage metrics on the Balance Sheet (BS) tab to assess financial health, debt capacity, and covenant compliance.
|
||||
|
||||
### Core Credit Metrics to Include
|
||||
|
||||
| Metric | Formula | What It Measures |
|
||||
|--------|---------|------------------|
|
||||
| Total Debt / EBITDA | Total Debt / LTM EBITDA | Leverage multiple |
|
||||
| Net Debt / EBITDA | (Total Debt - Cash) / LTM EBITDA | Leverage net of cash |
|
||||
| Interest Coverage | EBITDA / Interest Expense | Ability to service debt |
|
||||
| Debt / Total Cap | Total Debt / (Total Debt + Equity) | Capital structure |
|
||||
| Debt / Equity | Total Debt / Total Equity | Financial leverage |
|
||||
| Current Ratio | Current Assets / Current Liabilities | Short-term liquidity |
|
||||
| Quick Ratio | (Current Assets - Inventory) / Current Liabilities | Immediate liquidity |
|
||||
|
||||
### Credit Metric Hierarchy Checks
|
||||
|
||||
Validate that Upside shows strongest credit profile:
|
||||
- Leverage: Upside < Base < Downside (lower is better)
|
||||
- Coverage: Upside > Base > Downside (higher is better)
|
||||
- Liquidity: Upside > Base > Downside (higher is better)
|
||||
|
||||
### Covenant Compliance Tracking
|
||||
|
||||
If debt covenants are known, add explicit compliance checks comparing actual metrics to covenant thresholds.
|
||||
|
||||
## Scenario Analysis (Base / Upside / Downside)
|
||||
|
||||
Use a scenario toggle (dropdown) in the Assumptions tab with CHOOSE or INDEX/MATCH formulas.
|
||||
|
||||
| Scenario | Description |
|
||||
|----------|-------------|
|
||||
| Base Case | Management guidance or consensus estimates |
|
||||
| Upside Case | Above-guidance growth, margin expansion |
|
||||
| Downside Case | Below-trend growth, margin compression |
|
||||
|
||||
**Key Drivers to Sensitize**: Revenue growth, Gross margin, SG&A %, DSO/DIO/DPO, CapEx %, Interest rate, Tax rate.
|
||||
|
||||
**Scenario Audit Checks**: Toggle switches all statements, BS balances in all scenarios, Cash ties out, Hierarchy holds (Upside > Base > Downside for NI, EBITDA, FCF, margins).
|
||||
|
||||
## SEC Filings Data Extraction
|
||||
|
||||
If the template specifically requires pulling data from SEC filings (10-K, 10-Q), see [references/sec-filings.md](references/sec-filings.md) for detailed extraction guidance. This reference is only needed when populating templates with public company data from regulatory filings.
|
||||
|
||||
## Completing Model Templates
|
||||
|
||||
This section provides general guidance for completing any 3-statement financial model template while preserving existing formulas and ensuring data integrity.
|
||||
|
||||
### Step 1: Analyze the Template Structure
|
||||
|
||||
Before entering any data, thoroughly review the template to understand its architecture:
|
||||
|
||||
**Identify Input vs. Formula Cells**
|
||||
- Look for visual cues (font color, cell shading) that distinguish input cells from formula cells
|
||||
- Common conventions: Blue font = inputs, Black font = formulas, Green font = links to other sheets
|
||||
- Use Excel's Trace Precedents/Dependents (Formulas → Trace Precedents) to understand cell relationships
|
||||
- Check for named ranges that may control key inputs (Formulas → Name Manager)
|
||||
|
||||
**Map the Template's Flow**
|
||||
- Identify which tabs feed into others (e.g., Assumptions → IS → BS → CF)
|
||||
- Note any supporting schedules and their linkages to main statements
|
||||
- Document the template's specific line items and structure before populating
|
||||
|
||||
### Step 2: Filling in Data Without Breaking Formulas
|
||||
|
||||
**Golden Rules for Data Entry**
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| Only edit input cells | Never overwrite cells containing formulas unless intentionally replacing the formula |
|
||||
| Preserve cell references | When copying data, use Paste Values (Ctrl+Shift+V) to avoid overwriting formulas with source formatting |
|
||||
| Match the template's units | Verify if template uses thousands, millions, or actual values before entering data |
|
||||
| Respect sign conventions | Follow the template's existing sign convention (e.g., expenses as positive or negative) |
|
||||
| Check for circular references | If the template uses iterative calculations, ensure Enable Iterative Calculation is turned on |
|
||||
|
||||
**Safe Data Entry Process**
|
||||
1. Identify the exact cells designated for input (usually highlighted or labeled)
|
||||
2. Enter historical data first, then verify formulas are calculating correctly for those periods
|
||||
3. Enter assumption drivers that feed forecast calculations
|
||||
4. Review calculated outputs to confirm formulas are working as intended
|
||||
5. If a formula cell must be modified, document the original formula before making changes
|
||||
|
||||
**Handling Pre-Built Formulas**
|
||||
- If formulas reference cells you haven't populated yet, expect temporary errors (#REF!, #DIV/0!) until all inputs are complete
|
||||
- When formulas produce unexpected results, trace precedents to identify missing or incorrect inputs
|
||||
- Never delete rows/columns without checking for formula dependencies across all tabs
|
||||
|
||||
### Step 3: Validating Formulas
|
||||
|
||||
**Formula Integrity Checks**
|
||||
|
||||
Before relying on template outputs, validate that formulas are functioning correctly:
|
||||
|
||||
| Check Type | Method |
|
||||
|------------|--------|
|
||||
| Trace precedents | Select a formula cell → Formulas → Trace Precedents to verify it references correct inputs |
|
||||
| Trace dependents | Verify key inputs flow to expected output cells |
|
||||
| Evaluate formula | Use Formulas → Evaluate Formula to step through complex calculations |
|
||||
| Check for hardcodes | Projection formulas should reference assumptions, not contain hardcoded values |
|
||||
| Test with known values | Input simple test values to verify formulas produce expected results |
|
||||
| Cross-tab consistency | Ensure the same formula logic applies across all projection periods |
|
||||
|
||||
**Common Formula Issues to Watch For**
|
||||
- Mixed absolute/relative references causing incorrect results when copied across periods
|
||||
- Broken links to external files or deleted ranges (#REF! errors)
|
||||
- Division by zero in early periods before revenue ramps (#DIV/0! errors)
|
||||
- Circular reference warnings (may be intentional for interest calculations)
|
||||
- Inconsistent formulas across projection columns (use Ctrl+\ to find differences)
|
||||
|
||||
**Validating Cross-Tab Linkages**
|
||||
- Confirm values that appear on multiple tabs are linked (not duplicated)
|
||||
- Verify schedule totals tie to corresponding line items on main statements
|
||||
- Check that period labels align across all tabs
|
||||
|
||||
### Step 4: Quality Checks by Sheet
|
||||
|
||||
Perform these validation checks on each sheet after populating the template:
|
||||
|
||||
**Income Statement (IS) Quality Checks**
|
||||
- Revenue figures match source data for historical periods
|
||||
- All expense line items sum to reported totals
|
||||
- Subtotals (Gross Profit, EBIT, EBT, Net Income) calculate correctly
|
||||
- Tax calculation logic is appropriate (handles losses correctly)
|
||||
- Forecast drivers reference assumptions tab (no hardcodes)
|
||||
- Period-over-period changes are directionally reasonable
|
||||
|
||||
**Balance Sheet (BS) Quality Checks**
|
||||
- Assets = Liabilities + Equity for every period (primary check)
|
||||
- Cash balance matches Cash Flow Statement ending cash
|
||||
- Working capital accounts tie to supporting schedules (if applicable)
|
||||
- Retained Earnings rolls forward correctly: Prior RE + Net Income - Dividends +/- Adjustments = Ending RE
|
||||
- Debt balances tie to debt schedule (if applicable)
|
||||
- All balance sheet items have appropriate signs (assets positive, most liabilities positive)
|
||||
|
||||
**Cash Flow Statement (CF) Quality Checks**
|
||||
- Net Income at top of CFO matches Income Statement Net Income
|
||||
- Non-cash add-backs (D&A, SBC, etc.) tie to their source schedules/statements
|
||||
- Working capital changes have correct signs (increase in asset = use of cash = negative)
|
||||
- CapEx ties to PP&E schedule or fixed asset roll-forward
|
||||
- Financing activities tie to changes in debt and equity accounts on BS
|
||||
- Ending Cash matches Balance Sheet Cash
|
||||
- Beginning Cash equals prior period Ending Cash
|
||||
|
||||
**Supporting Schedule Quality Checks**
|
||||
- Opening balances equal prior period closing balances
|
||||
- Roll-forward logic is complete (Beginning + Additions - Deductions = Ending)
|
||||
- Schedule totals tie to main statement line items
|
||||
- Assumptions used in calculations match Assumptions tab
|
||||
|
||||
### Step 5: Cross-Statement Integrity Checks
|
||||
|
||||
After validating individual sheets, confirm the three statements are properly integrated:
|
||||
|
||||
| Check | Formula | Expected Result |
|
||||
|-------|---------|-----------------|
|
||||
| Balance Sheet Balance | Assets - Liabilities - Equity | = 0 |
|
||||
| Cash Tie-Out | CF Ending Cash - BS Cash | = 0 |
|
||||
| Net Income Link | IS Net Income - CF Starting Net Income | = 0 |
|
||||
| Retained Earnings | Prior RE + NI - Dividends - BS Ending RE | = 0 (adjust for SBC/other items as needed) |
|
||||
|
||||
### Step 6: Final Review
|
||||
|
||||
Before considering the model complete:
|
||||
- Toggle through all scenarios (if applicable) to verify checks pass in each case
|
||||
- Review all #REF!, #DIV/0!, #VALUE!, and #NAME? errors and resolve or document
|
||||
- Confirm all input cells have been populated (search for placeholder values)
|
||||
- Verify units are consistent across all tabs
|
||||
- Save a clean version before making any additional modifications
|
||||
|
||||
## Model Validation and Audit
|
||||
|
||||
This section consolidates all validation checks and audit procedures for completed templates.
|
||||
|
||||
### Core Linkages (Must Always Hold)
|
||||
|
||||
See [references/formulas.md](references/formulas.md) for all formula details.
|
||||
|
||||
| Check | Formula | Expected Result |
|
||||
|-------|---------|-----------------|
|
||||
| Balance Sheet Balance | Assets - Liabilities - Equity | = 0 |
|
||||
| Cash Tie-Out | CF Ending Cash - BS Cash | = 0 |
|
||||
| Cash Monthly vs Annual | Closing Cash (Monthly) - Closing Cash (Annual) | = 0 |
|
||||
| Net Income Link | IS Net Income - CF Starting Net Income | = 0 |
|
||||
| Retained Earnings | Prior RE + NI + SBC - Dividends - BS Ending RE | = 0 |
|
||||
| Equity Financing | ΔCommon Stock/APIC (BS) - Equity Issuance (CFF) | = 0 |
|
||||
| Year 0 Equity | Equity Raised (Year 0) - Beginning Equity Capital (Year 1) | = 0 |
|
||||
|
||||
### Sign Convention Reference
|
||||
|
||||
| Statement | Item | Sign Convention |
|
||||
|-----------|------|-----------------|
|
||||
| CFO | D&A, SBC | Positive (add-back) |
|
||||
| CFO | ΔAR (increase) | Negative (use of cash) |
|
||||
| CFO | ΔAP (increase) | Positive (source of cash) |
|
||||
| CFI | CapEx | Negative |
|
||||
| CFF | Debt issuance | Positive |
|
||||
| CFF | Debt repayments | Negative |
|
||||
| CFF | Dividends | Negative |
|
||||
|
||||
### Circular Reference Handling
|
||||
|
||||
Interest expense creates circularity: Interest → Net Income → Cash → Debt Balance → Interest
|
||||
|
||||
Enable iterative calculation in Excel: File → Options → Formulas → Enable iterative calculation. Set maximum iterations to 100, maximum change to 0.001. Add a circuit breaker toggle in Assumptions tab.
|
||||
|
||||
### Check Categories
|
||||
|
||||
**Section 1: Currency Consistency**
|
||||
- Currency identified and documented in Assumptions
|
||||
- All tabs use consistent currency symbol and scale
|
||||
- Units row matches model currency
|
||||
|
||||
**Section 2: Balance Sheet Integrity**
|
||||
- Assets = Liabilities + Equity (for each period)
|
||||
- Formula: Assets - Liabilities - Equity (must = 0)
|
||||
|
||||
**Section 3: Cash Flow Integrity**
|
||||
- Cash ties to BS (CF Ending Cash = BS Cash)
|
||||
- Cash Monthly vs Annual: Closing Cash (Monthly) = Closing Cash (Annual)
|
||||
- NI ties to IS (CF Net Income = IS Net Income)
|
||||
- D&A ties to schedule
|
||||
- SBC ties to IS
|
||||
- ΔAR, ΔInventory, ΔAP tie to WC schedule
|
||||
- CapEx ties to DA schedule
|
||||
|
||||
**Section 4: Retained Earnings**
|
||||
- RE roll-forward check: Prior RE + NI + SBC - Dividends = Ending RE
|
||||
- Show component breakdown for debugging
|
||||
|
||||
**Section 5: Working Capital**
|
||||
- AR, Inventory, AP tie to BS
|
||||
- DSO, DIO, DPO reasonability checks (flag if outside normal ranges)
|
||||
|
||||
**Section 6: Debt Schedule**
|
||||
- Total Debt ties to BS (Current + LT Debt)
|
||||
- Interest calculation ties to IS
|
||||
|
||||
**Section 6b: Equity Financing**
|
||||
- Equity issuance proceeds tie to BS Common Stock/APIC increase
|
||||
- Cash increase from equity = Equity account increase (must balance)
|
||||
- Equity Raise Tie-Out: ΔCommon Stock/APIC (BS) = Equity Issuance (CFF) (must = 0)
|
||||
- Year 0 Equity Tie-Out: Equity Raised (Year 0) = Beginning Equity Capital (Year 1)
|
||||
|
||||
**Section 6c: NOL Schedule**
|
||||
- Beginning NOL (Year 1 / Formation) = 0 (new business starts with zero NOL)
|
||||
- NOL increases only when EBT < 0 (losses must be realized to generate NOL)
|
||||
- DTA ties to BS (NOL Schedule DTA = BS Deferred Tax Asset)
|
||||
- NOL utilization ≤ 80% of EBT (post-2017 federal limitation)
|
||||
- NOL balance is non-negative (cannot utilize more than available)
|
||||
- NOL generated only when EBT < 0
|
||||
- Tax expense = 0 when taxable income ≤ 0
|
||||
|
||||
**Section 7: Scenario Hierarchy**
|
||||
- Absolute metrics: Upside > Base > Downside (NI, EBITDA, FCF)
|
||||
- Margins: Upside > Base > Downside (GM%, EBITDA%, NI%)
|
||||
- Credit metrics: Upside < Base < Downside for leverage (inverted)
|
||||
|
||||
**Section 8: Formula Integrity**
|
||||
- COGS, S&M, G&A, R&D, SBC driven by % of Revenue (no hardcodes)
|
||||
- Consistent formulas across projection years
|
||||
- No #REF!, #DIV/0!, #VALUE! errors
|
||||
|
||||
**Section 9: Credit Metric Thresholds**
|
||||
- Flag metrics as Green/Yellow/Red based on covenant thresholds
|
||||
- Summary of any red flags
|
||||
|
||||
### Master Check Formula
|
||||
|
||||
Aggregate all section statuses into a single master check:
|
||||
- If all sections pass → "✓ ALL CHECKS PASS"
|
||||
- If any section fails → "✗ ERRORS DETECTED - REVIEW BELOW"
|
||||
|
||||
### Quick Debug Workflow
|
||||
|
||||
When Master Status shows errors:
|
||||
1. Scroll to find red-highlighted sections
|
||||
2. Identify which check category has failures
|
||||
3. Navigate to source tab to investigate
|
||||
4. Fix the underlying issue
|
||||
5. Return to Checks tab to verify resolution
|
||||
|
||||
|
||||
## Data sources — MCP first, web fallback
|
||||
|
||||
Many passages below say "use the S&P Kensho MCP / Daloopa MCP / FactSet MCP". Those are commercial financial-data MCPs from the original Cowork plugin context. In Hermes:
|
||||
|
||||
- **If you have any structured financial-data MCP configured** (Hermes supports MCP — see `native-mcp` skill), prefer it for point-in-time comps, precedent transactions, and filings.
|
||||
- **Otherwise**, fall back to:
|
||||
- `web_search` / `web_extract` against SEC EDGAR (`https://www.sec.gov/cgi-bin/browse-edgar`) for US filings
|
||||
- Company IR pages for press releases, earnings decks
|
||||
- `browser_navigate` for interactive data portals
|
||||
- User-provided data (explicitly ask when the context doesn't have it)
|
||||
- **Never fabricate**. If a multiple, precedent, or filing number can't be sourced, flag the cell as `[UNSOURCED]` and surface it to the user.
|
||||
|
||||
## Attribution
|
||||
|
||||
This skill is adapted from Anthropic's Claude for Financial Services plugin suite (Apache-2.0). The Office-JS / Cowork live-Excel paths have been removed; this version targets headless openpyxl via the `excel-author` skill's conventions. Original: https://github.com/anthropics/financial-services
|
||||
@@ -1,118 +0,0 @@
|
||||
# Formatting Standards Reference
|
||||
|
||||
| Element | Format |
|
||||
|---------|--------|
|
||||
| Hard-coded inputs | Blue font |
|
||||
| Formulas | Black font |
|
||||
| Links to other sheets | Green font |
|
||||
| Check cells | Red if error, green if balanced |
|
||||
| Negative values | Parentheses, not minus signs |
|
||||
| Currency | No decimals for large figures, 2 decimals for per-share |
|
||||
| Percentages | 1 decimal place |
|
||||
| Headers | Bold, bottom border |
|
||||
| Units row | Include units row below headers ($ millions, %, etc.) |
|
||||
|
||||
## Visual Separation Guidelines
|
||||
|
||||
- Thin vertical border between historical and projected columns
|
||||
- Thick bottom border after section totals (e.g., Total Assets)
|
||||
- Single bottom border for subtotals
|
||||
- Double bottom border for grand totals
|
||||
|
||||
## Total and Subtotal Row Formatting
|
||||
|
||||
All total and subtotal rows must use **bold font formatting** for their numerical values to clearly distinguish aggregated figures from individual line items.
|
||||
|
||||
### Income Statement (P&L) Tab
|
||||
| Row | Formatting |
|
||||
|-----|------------|
|
||||
| Gross Revenue | Bold |
|
||||
| Total Cost of Revenue | Bold |
|
||||
| Gross Profit | Bold |
|
||||
| Total SG&A | Bold |
|
||||
| EBITDA | Bold |
|
||||
| EBIT | Bold |
|
||||
| EBT | Bold |
|
||||
| Net Profit After Tax | Bold |
|
||||
|
||||
### Balance Sheet Tab
|
||||
| Row | Formatting |
|
||||
|-----|------------|
|
||||
| Total Current Assets | Bold |
|
||||
| Total Non-Current Assets | Bold |
|
||||
| Total Other Assets | Bold |
|
||||
| Total Assets | Bold |
|
||||
| Total Current Liabilities | Bold |
|
||||
| Total Non-Current Liabilities | Bold |
|
||||
| Total Equity | Bold |
|
||||
| Total Liabilities and Equity | Bold |
|
||||
|
||||
### Cash Flow Statement Tab
|
||||
| Row | Formatting |
|
||||
|-----|------------|
|
||||
| Cash Generated from Operations Before Working Capital Changes | Bold |
|
||||
| Total Working Capital Changes | Bold |
|
||||
| Net Cash Generated from Operations | Bold |
|
||||
| Net Cash Flow from Investing Activities | Bold |
|
||||
| Net Cash Flow from Financing Activities | Bold |
|
||||
| Closing Cash Balance | Bold |
|
||||
|
||||
**Note:** This list is non-exhaustive. Apply bold formatting to any row that represents a total, subtotal, or summary calculation across the model.
|
||||
|
||||
## Balance Sheet Check Row Formatting
|
||||
|
||||
The Balance Sheet check row (below Total Liabilities and Equity) uses conditional number formatting that displays non-zero values in red. When the balance sheet balances correctly (check = 0), the values display in black or standard formatting.
|
||||
|
||||
| Check Value | Font Color |
|
||||
|-------------|------------|
|
||||
| = 0 (balanced) | Black (standard) |
|
||||
| ≠ 0 (error) | Red |
|
||||
|
||||
**Implementation:** Apply custom number format `[Red][<>0]0.00;[Red][<>0](0.00);0.00` or use Excel conditional formatting with the rule "Cell Value ≠ 0" → Red font.
|
||||
|
||||
## Margin Row Formatting
|
||||
|
||||
| Element | Format |
|
||||
|---------|--------|
|
||||
| Margin % rows | Indent, italics, 1 decimal place |
|
||||
| Positive trend | No special formatting (or subtle green) |
|
||||
| Negative trend | Flag for review (subtle yellow) |
|
||||
| Below peer average | Consider highlighting for discussion |
|
||||
|
||||
## Credit Metric Formatting
|
||||
|
||||
| Element | Format |
|
||||
|---------|--------|
|
||||
| Leverage multiples | 1 decimal with "x" suffix (e.g., 2.5x) |
|
||||
| Percentages | 1 decimal with "%" suffix |
|
||||
| Net Debt negative | Parentheses, indicates net cash position |
|
||||
| Section header | Bold, "CREDIT METRICS" |
|
||||
| Separator line | Thin border above credit metrics section |
|
||||
|
||||
## Credit Metric Threshold Colors
|
||||
|
||||
| Metric | Green | Yellow | Red |
|
||||
|--------|-------|--------|-----|
|
||||
| Total Debt / EBITDA | < 2.5x | 2.5x-4.0x | > 4.0x |
|
||||
| Net Debt / EBITDA | < 2.0x | 2.0x-3.5x | > 3.5x |
|
||||
| Interest Coverage | > 4.0x | 2.5x-4.0x | < 2.5x |
|
||||
| Debt / Total Cap | < 40% | 40%-60% | > 60% |
|
||||
| Current Ratio | > 1.5x | 1.0x-1.5x | < 1.0x |
|
||||
| Quick Ratio | > 1.0x | 0.75x-1.0x | < 0.75x |
|
||||
|
||||
## Conditional Formatting for Checks Tab
|
||||
|
||||
- Cell contains pass indicator → Green fill
|
||||
- Cell contains fail indicator → Red fill
|
||||
- Cell contains warning → Yellow fill
|
||||
- Difference cells = 0 → Light green fill
|
||||
- Difference cells ≠ 0 → Light red fill
|
||||
|
||||
## Margin Reasonability Flags
|
||||
|
||||
- Gross Margin < 0% → ERROR: Review COGS
|
||||
- Gross Margin > 80% → WARNING: Verify revenue/COGS
|
||||
- EBITDA Margin < 0% → FLAG: Operating losses
|
||||
- EBITDA Margin > 50% → WARNING: Unusually high
|
||||
- Net Margin < 0% → FLAG: Net losses (may be acceptable in growth phase)
|
||||
- Net Margin > Gross Margin → ERROR: Formula issue
|
||||
@@ -1,292 +0,0 @@
|
||||
# Formula Reference
|
||||
|
||||
**IMPORTANT:** Use the formulas outlined in this reference document unless otherwise specified by the user.
|
||||
|
||||
---
|
||||
|
||||
## Core Linkages
|
||||
|
||||
```
|
||||
Balance Sheet: Assets = Liabilities + Equity
|
||||
Net Income: IS Net Income → CF Operations (starting point)
|
||||
Cash Flow: ΔCash = CFO + CFI + CFF
|
||||
Cash Tie-Out: Ending Cash (CF) = Cash (BS Asset)
|
||||
Cash Monthly/Annual: Closing Cash (Monthly) = Closing Cash (Annual)
|
||||
Retained Earnings: Prior RE + Net Income - Dividends = Ending RE
|
||||
Equity Raise: ΔCommon Stock/APIC (BS) = Equity Issuance (CFF)
|
||||
Year 0 Equity: Equity Raised (Year 0) = Beginning Equity (Year 1)
|
||||
```
|
||||
|
||||
## Gross Profit Calculation
|
||||
|
||||
**IMPORTANT:** Gross Profit must be calculated from Net Revenue, not Gross Revenue.
|
||||
|
||||
```
|
||||
Net Revenue - Cost of Revenue = Gross Profit
|
||||
```
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| Gross Revenue | Total revenue before any deductions |
|
||||
| Net Revenue | Gross Revenue - Returns - Allowances - Discounts |
|
||||
| Cost of Revenue | Direct costs attributable to production of goods/services sold |
|
||||
| Gross Profit | Net Revenue - Cost of Revenue |
|
||||
|
||||
**Note:** Always use Net Revenue (also called "Net Sales" or simply "Revenue" on most financial statements) as the starting point for profitability calculations. Gross Revenue overstates the true top-line performance.
|
||||
|
||||
## Margin Formulas
|
||||
|
||||
```
|
||||
Gross Margin % = Gross Profit / Net Revenue
|
||||
EBITDA = EBIT + D&A (or = Gross Profit - OpEx)
|
||||
EBITDA Margin % = EBITDA / Net Revenue
|
||||
EBIT Margin % = EBIT / Net Revenue
|
||||
Net Income Margin % = Net Income / Net Revenue
|
||||
```
|
||||
|
||||
## Credit Metric Formulas
|
||||
|
||||
```
|
||||
Total Debt = Current Portion of Debt + Long-Term Debt
|
||||
Net Debt = Total Debt - Cash
|
||||
Total Debt / EBITDA = Total Debt / EBITDA (from IS)
|
||||
Net Debt / EBITDA = Net Debt / EBITDA (from IS)
|
||||
Interest Coverage = EBITDA / Interest Expense (from IS)
|
||||
Net Int Exp % Debt = Net Interest Expense / Long-Term Debt
|
||||
Debt / Total Cap = Total Debt / (Total Debt + Total Equity)
|
||||
Debt / Equity = Total Debt / Total Equity
|
||||
Current Ratio = Total Current Assets / Total Current Liabilities
|
||||
Quick Ratio = (Total Current Assets - Inventory) / Total Current Liabilities
|
||||
```
|
||||
|
||||
## Forecast Formulas (% of Net Revenue Method)
|
||||
|
||||
```
|
||||
Cost of Revenue (Forecast) = Net Revenue × Cost of Revenue % Assumption
|
||||
S&M (Forecast) = Net Revenue × S&M % Assumption
|
||||
G&A (Forecast) = Net Revenue × G&A % Assumption
|
||||
R&D (Forecast) = Net Revenue × R&D % Assumption
|
||||
SBC (Forecast) = Net Revenue × SBC % Assumption
|
||||
```
|
||||
|
||||
## Working Capital Formulas
|
||||
|
||||
```
|
||||
Accounts Receivable
|
||||
Prior AR
|
||||
+ Revenue (from IS)
|
||||
- Cash Collections (plug)
|
||||
= Ending AR
|
||||
DSO = (AR / Revenue) × 365
|
||||
|
||||
Inventory
|
||||
Prior Inventory
|
||||
+ Purchases (plug)
|
||||
- COGS (from IS)
|
||||
= Ending Inventory
|
||||
DIO = (Inventory / COGS) × 365
|
||||
|
||||
Accounts Payable
|
||||
Prior AP
|
||||
+ Purchases (from Inventory calc)
|
||||
- Cash Payments (plug)
|
||||
= Ending AP
|
||||
DPO = (AP / COGS) × 365
|
||||
|
||||
Net Working Capital = AR + Inventory - AP
|
||||
ΔWC = Current NWC - Prior NWC
|
||||
```
|
||||
|
||||
## D&A Schedule Formulas
|
||||
|
||||
```
|
||||
Beginning PP&E (Gross)
|
||||
+ CapEx
|
||||
= Ending PP&E (Gross)
|
||||
|
||||
Beginning Accumulated Depreciation
|
||||
+ Depreciation Expense
|
||||
= Ending Accumulated Depreciation
|
||||
|
||||
PP&E (Net) = Gross PP&E - Accumulated Depreciation
|
||||
```
|
||||
|
||||
## Debt Schedule Formulas
|
||||
|
||||
```
|
||||
Beginning Debt Balance
|
||||
+ New Borrowings
|
||||
- Repayments
|
||||
= Ending Debt Balance
|
||||
|
||||
Interest Expense = Avg Debt Balance × Interest Rate
|
||||
(Use beginning balance to avoid circularity, or iterate if circular refs enabled)
|
||||
```
|
||||
|
||||
## Retained Earnings Formula
|
||||
|
||||
```
|
||||
Beginning Retained Earnings
|
||||
+ Net Income (from IS)
|
||||
+ Stock-Based Compensation (SBC) (from IS)
|
||||
- Dividends
|
||||
= Ending Retained Earnings
|
||||
```
|
||||
|
||||
## NOL (Net Operating Loss) Schedule Formulas
|
||||
|
||||
```
|
||||
NOL CARRYFORWARD SCHEDULE
|
||||
|
||||
Beginning NOL Balance (Year 1 / Formation = 0)
|
||||
+ NOL Generated (if EBT < 0, then ABS(EBT), else 0)
|
||||
- NOL Utilized (limited by taxable income and utilization cap)
|
||||
= Ending NOL Balance
|
||||
|
||||
STARTING BALANCE RULE
|
||||
|
||||
For a new business or first modeled period:
|
||||
Beginning NOL Balance = 0
|
||||
NOL can only increase through realized losses (EBT < 0)
|
||||
NOL cannot be created from thin air or assumed
|
||||
|
||||
NOL UTILIZATION CALCULATION
|
||||
|
||||
Pre-Tax Income (EBT)
|
||||
If EBT > 0:
|
||||
NOL Available = Beginning NOL Balance
|
||||
Utilization Limit = EBT × 80% (post-2017 federal limit)
|
||||
NOL Utilized = MIN(NOL Available, Utilization Limit)
|
||||
Taxable Income = EBT - NOL Utilized
|
||||
If EBT ≤ 0:
|
||||
NOL Utilized = 0
|
||||
Taxable Income = 0
|
||||
NOL Generated = ABS(EBT)
|
||||
|
||||
TAX CALCULATION WITH NOL
|
||||
|
||||
Taxes Payable = MAX(0, Taxable Income × Tax Rate)
|
||||
(Taxes cannot be negative; losses create NOL asset instead)
|
||||
|
||||
DEFERRED TAX ASSET (DTA) FOR NOL
|
||||
|
||||
DTA - NOL Carryforward = Ending NOL Balance × Tax Rate
|
||||
ΔDTA = Current DTA - Prior DTA
|
||||
(Increase in DTA = non-cash benefit on CF)
|
||||
(Decrease in DTA = non-cash expense on CF)
|
||||
```
|
||||
|
||||
## Balance Sheet Structure
|
||||
|
||||
```
|
||||
ASSETS
|
||||
Cash (from CF ending cash)
|
||||
Accounts Receivable (from WC)
|
||||
Inventory (from WC)
|
||||
Total Current Assets
|
||||
|
||||
PP&E, Net (from DA)
|
||||
Deferred Tax Asset - NOL (from NOL schedule)
|
||||
Total Non-Current Assets
|
||||
Total Assets
|
||||
|
||||
LIABILITIES
|
||||
Accounts Payable (from WC)
|
||||
Current Portion of Debt (from Debt)
|
||||
Total Current Liabilities
|
||||
|
||||
Long-Term Debt (from Debt)
|
||||
Total Liabilities
|
||||
|
||||
EQUITY
|
||||
Common Stock
|
||||
Retained Earnings (from RE schedule)
|
||||
Total Equity
|
||||
|
||||
CHECK: Assets - Liabilities - Equity = 0
|
||||
```
|
||||
|
||||
## Cash Flow Statement Structure
|
||||
|
||||
```
|
||||
CASH FROM OPERATIONS (CFO)
|
||||
Net Income (LINK: IS)
|
||||
+ D&A (LINK: DA schedule)
|
||||
+ Stock-Based Compensation (SBC) (LINK: IS or Assumptions)
|
||||
- ΔDTA (Deferred Tax Asset) (LINK: NOL schedule; increase in DTA = use of cash)
|
||||
- ΔAR (LINK: WC)
|
||||
- ΔInventory (LINK: WC)
|
||||
+ ΔAP (LINK: WC)
|
||||
= CFO
|
||||
|
||||
CASH FROM INVESTING (CFI)
|
||||
- CapEx (LINK: DA schedule)
|
||||
= CFI
|
||||
|
||||
CASH FROM FINANCING (CFF)
|
||||
+ Debt Issuance (LINK: Debt)
|
||||
- Debt Repayment (LINK: Debt)
|
||||
+ Equity Issuance (LINK: BS Common Stock/APIC)
|
||||
- Dividends (LINK: RE schedule)
|
||||
= CFF
|
||||
|
||||
Net Change in Cash = CFO + CFI + CFF
|
||||
Beginning Cash
|
||||
+ Net Change in Cash
|
||||
= Ending Cash (LINK TO: BS Cash)
|
||||
```
|
||||
|
||||
## Income Statement Structure
|
||||
|
||||
```
|
||||
Net Revenue
|
||||
Growth %
|
||||
(-) Cost of Revenue
|
||||
% of Net Revenue
|
||||
────────────────
|
||||
Gross Profit (= Net Revenue - Cost of Revenue)
|
||||
Gross Margin %
|
||||
|
||||
(-) S&M
|
||||
% of Net Revenue
|
||||
(-) G&A
|
||||
% of Net Revenue
|
||||
(-) R&D
|
||||
% of Net Revenue
|
||||
(-) D&A
|
||||
(-) SBC
|
||||
% of Net Revenue
|
||||
────────────────
|
||||
EBIT
|
||||
EBIT Margin %
|
||||
|
||||
EBITDA
|
||||
EBITDA Margin %
|
||||
|
||||
(-) Interest Expense
|
||||
────────────────
|
||||
EBT (Pre-Tax Income)
|
||||
(-) NOL Utilization (from NOL schedule, reduces taxable income)
|
||||
────────────────
|
||||
Taxable Income
|
||||
(-) Taxes (Taxable Income × Tax Rate)
|
||||
────────────────
|
||||
Net Income
|
||||
Net Income Margin %
|
||||
```
|
||||
|
||||
## Check Formulas
|
||||
|
||||
```
|
||||
BS Balance Check: = Assets - Liabilities - Equity (must = 0)
|
||||
Cash Tie-Out: = BS Cash - CF Ending Cash (must = 0)
|
||||
RE Roll-Forward: = Prior RE + NI + SBC - Div - BS RE (must = 0)
|
||||
DTA Tie-Out: = NOL Schedule DTA - BS DTA (must = 0)
|
||||
Equity Raise Tie-Out: = ΔCommon Stock/APIC (BS) - Equity Issuance (CFF) (must = 0)
|
||||
Year 0 Equity Tie-Out: = Equity Raised (Year 0) - Beginning Equity (Year 1) (must = 0)
|
||||
Cash Monthly vs Annual: = Closing Cash (Monthly) - Closing Cash (Annual) (must = 0)
|
||||
NOL Utilization Cap: = NOL Utilized ≤ EBT × 80% (must be TRUE for post-2017)
|
||||
NOL Non-Negative: = Ending NOL Balance ≥ 0 (must be TRUE)
|
||||
NOL Starting Balance: = Beginning NOL (Year 1) = 0 (must be TRUE for new business)
|
||||
NOL Accumulation: = NOL increases only when EBT < 0 (losses generate NOL)
|
||||
```
|
||||
@@ -1,125 +0,0 @@
|
||||
# SEC Filings Data Extraction Reference
|
||||
|
||||
**When to Use:** Only reference this file when a model template specifically requires pulling data from SEC filings (10-K, 10-Q). For templates that provide data directly or use other data sources, this reference is not needed.
|
||||
|
||||
---
|
||||
|
||||
## Extracting Data from SEC Filings (10-K / 10-Q)
|
||||
|
||||
When populating a model template with public company data, extract financials directly from SEC filings.
|
||||
|
||||
### Step 1: Locate the Filing
|
||||
|
||||
1. Use SEC EDGAR: `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=[TICKER]&type=10-K`
|
||||
2. For quarterly data, use `type=10-Q`
|
||||
|
||||
### Step 2: Identify Filing Currency
|
||||
|
||||
Before extracting data, identify the reporting currency:
|
||||
- Check the cover page or header for reporting currency
|
||||
- Look at statement headers (e.g., "in thousands of U.S. dollars")
|
||||
- Review Note 1 (Summary of Significant Accounting Policies)
|
||||
|
||||
**Common Currency Indicators**
|
||||
|
||||
| Indicator | Currency |
|
||||
|-----------|----------|
|
||||
| $, USD | US Dollar |
|
||||
| €, EUR | Euro |
|
||||
| £, GBP | British Pound |
|
||||
| ¥, JPY | Japanese Yen |
|
||||
| ¥, CNY, RMB | Chinese Yuan |
|
||||
| CHF | Swiss Franc |
|
||||
| CAD, C$ | Canadian Dollar |
|
||||
|
||||
Set model currency to match filing; document in Assumptions tab.
|
||||
|
||||
### Step 3: Navigate to Financial Statements
|
||||
|
||||
Within the 10-K or 10-Q, locate:
|
||||
- **Item 8** (10-K) or **Item 1** (10-Q): Financial Statements
|
||||
- Key sections to extract:
|
||||
- Consolidated Statements of Operations (Income Statement)
|
||||
- Consolidated Balance Sheets
|
||||
- Consolidated Statements of Cash Flows
|
||||
- Notes to Financial Statements (for schedule details)
|
||||
|
||||
### Step 4: Data Extraction Mapping
|
||||
|
||||
**Income Statement (from Consolidated Statements of Operations)**
|
||||
|
||||
| Filing Line Item | Model Line Item |
|
||||
|------------------|-----------------|
|
||||
| Net revenues / Net sales | Revenue |
|
||||
| Cost of goods sold | COGS |
|
||||
| Selling, general and administrative | SG&A |
|
||||
| Depreciation and amortization | D&A |
|
||||
| Interest expense, net | Interest Expense |
|
||||
| Income tax expense | Taxes |
|
||||
| Net income | Net Income |
|
||||
|
||||
**Balance Sheet (from Consolidated Balance Sheets)**
|
||||
|
||||
| Filing Line Item | Model Line Item |
|
||||
|------------------|-----------------|
|
||||
| Cash and cash equivalents | Cash |
|
||||
| Accounts receivable, net | AR |
|
||||
| Inventories | Inventory |
|
||||
| Property, plant and equipment, net | PP&E (Net) |
|
||||
| Total assets | Total Assets |
|
||||
| Accounts payable | AP |
|
||||
| Short-term debt / Current portion of LT debt | Current Debt |
|
||||
| Long-term debt | LT Debt |
|
||||
| Retained earnings | Retained Earnings |
|
||||
| Total stockholders' equity | Total Equity |
|
||||
|
||||
**Cash Flow Statement (from Consolidated Statements of Cash Flows)**
|
||||
|
||||
| Filing Line Item | Model Line Item |
|
||||
|------------------|-----------------|
|
||||
| Net income | Net Income |
|
||||
| Depreciation and amortization | D&A |
|
||||
| Changes in accounts receivable | ΔAR |
|
||||
| Changes in inventories | ΔInventory |
|
||||
| Changes in accounts payable | ΔAP |
|
||||
| Capital expenditures | CapEx |
|
||||
| Proceeds from issuance of common stock | Equity Issuance |
|
||||
| Proceeds from / Repayments of debt | Debt activity |
|
||||
| Dividends paid | Dividends |
|
||||
|
||||
### Step 5: Extract Supporting Detail from Notes
|
||||
|
||||
For schedules, pull from Notes to Financial Statements:
|
||||
- **Note: Debt** → Maturity schedule, interest rates, covenants
|
||||
- **Note: Property, Plant & Equipment** → Gross PP&E, accumulated depreciation, useful lives
|
||||
- **Note: Revenue** → Segment breakdowns, geographic splits
|
||||
- **Note: Leases** → Operating vs. finance lease obligations
|
||||
|
||||
### Step 6: Historical Data Requirements
|
||||
|
||||
Extract 3 years of historical data minimum:
|
||||
- 10-K provides 3 years of IS/CF, 2 years of BS
|
||||
- For 3rd year BS, pull from prior year's 10-K
|
||||
- Use 10-Qs to fill in quarterly granularity if needed
|
||||
|
||||
### Data Extraction Checklist
|
||||
|
||||
- Identify reporting currency and scale (thousands, millions)
|
||||
- 3 years historical Income Statement
|
||||
- 3 years historical Cash Flow Statement
|
||||
- 3 years historical Balance Sheet
|
||||
- Verify IS Net Income = CF starting Net Income (each year)
|
||||
- Verify BS Cash = CF Ending Cash (each year)
|
||||
- Extract debt maturity schedule from notes
|
||||
- Extract D&A detail or useful life assumptions
|
||||
- Note any non-recurring / one-time items to normalize
|
||||
|
||||
### Handling Common Filing Variations
|
||||
|
||||
| Variation | How to Handle |
|
||||
|-----------|---------------|
|
||||
| D&A embedded in COGS/SG&A | Pull D&A from Cash Flow Statement |
|
||||
| "Other" line items are material | Check notes for breakdown |
|
||||
| Restatements | Use restated figures, note in assumptions |
|
||||
| Fiscal year ≠ calendar year | Label with fiscal year end (e.g., FYE Jan 2025) |
|
||||
| Non-USD reporting currency | Adapt model currency to match filing |
|
||||
@@ -1,661 +0,0 @@
|
||||
---
|
||||
name: comps-analysis
|
||||
description: Build comparable company analysis in Excel — operating metrics, valuation multiples, statistical benchmarking vs peer sets. Pairs with excel-author. Use for public-company valuation, IPO pricing, sector benchmarking, or outlier detection.
|
||||
version: 1.0.0
|
||||
author: Anthropic (adapted by Nous Research)
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [finance, valuation, comps, excel, openpyxl, modeling, investment-banking]
|
||||
related_skills: [excel-author, pptx-author, dcf-model, lbo-model]
|
||||
---
|
||||
|
||||
## Environment
|
||||
|
||||
This skill assumes **headless openpyxl** — you are producing an .xlsx file on disk.
|
||||
Follow the `excel-author` skill's conventions for cell coloring, formulas, named ranges, and sensitivity tables.
|
||||
Recalculate before delivery: `python /path/to/excel-author/scripts/recalc.py ./out/model.xlsx`.
|
||||
|
||||
# Comparable Company Analysis
|
||||
|
||||
## ⚠️ CRITICAL: Data Source Priority (READ FIRST)
|
||||
|
||||
**ALWAYS follow this data source hierarchy:**
|
||||
|
||||
1. **FIRST: Check for MCP data sources** - If S&P Kensho MCP, FactSet MCP, or Daloopa MCP are available, use them exclusively for financial and trading information
|
||||
2. **DO NOT use web search** if the above MCP data sources are available
|
||||
3. **ONLY if MCPs are unavailable:** Then use Bloomberg Terminal, SEC EDGAR filings, or other institutional sources
|
||||
4. **NEVER use web search as a primary data source** - it lacks the accuracy, audit trails, and reliability required for institutional-grade analysis
|
||||
|
||||
**Why this matters:** MCP sources provide verified, institutional-grade data with proper citations. Web search results can be outdated, inaccurate, or unreliable for financial analysis.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
This skill teaches the agent to build institutional-grade comparable company analyses that combine operating metrics, valuation multiples, and statistical benchmarking. The output is a structured Excel/spreadsheet that enables informed investment decisions through peer comparison.
|
||||
|
||||
**Reference Material & Contextualization:**
|
||||
|
||||
An example comparable company analysis is provided in `examples/comps_example.xlsx`. When using this or other example files in this skill directory, use them intelligently:
|
||||
|
||||
**DO use examples for:**
|
||||
- Understanding structural hierarchy (how sections flow)
|
||||
- Grasping the level of rigor expected (statistical depth, documentation standards)
|
||||
- Learning principles (clear headers, transparent formulas, audit trails)
|
||||
|
||||
**DO NOT use examples for:**
|
||||
- Exact reproduction of format or metrics
|
||||
- Copying layout without considering context
|
||||
- Applying the same visual style regardless of audience
|
||||
|
||||
**ALWAYS ask yourself first:**
|
||||
1. **"Do you have a preferred format or should I adapt the template style?"**
|
||||
2. **"Who is the audience?"** (Investment committee, board presentation, quick reference, detailed memo)
|
||||
3. **"What's the key question?"** (Valuation, growth analysis, competitive positioning, efficiency)
|
||||
4. **"What's the context?"** (M&A evaluation, investment decision, sector benchmarking, performance review)
|
||||
|
||||
**Adapt based on specifics:**
|
||||
- **Industry context**: Big tech mega-caps need different metrics than emerging SaaS startups
|
||||
- **Sector-specific needs**: Add relevant metrics early (e.g., cloud ARR, enterprise customers, developer ecosystem for tech)
|
||||
- **Company familiarity**: Well-known companies may need less background, more focus on delta analysis
|
||||
- **Decision type**: M&A requires different emphasis than ongoing portfolio monitoring
|
||||
|
||||
**Core principle:** Use template principles (clear structure, statistical rigor, transparent formulas) but vary execution based on context. The goal is institutional-quality analysis, not institutional-looking templates.
|
||||
|
||||
User-provided examples and explicit preferences always take precedence over defaults.
|
||||
|
||||
## Core Philosophy
|
||||
**"Build the right structure first, then let the data tell the story."**
|
||||
|
||||
Start with headers that force strategic thinking about what matters, input clean data, build transparent formulas, and let statistics emerge automatically. A good comp should be immediately readable by someone who didn't build it.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ CRITICAL: Formulas Over Hardcodes + Step-by-Step Verification
|
||||
|
||||
**Formulas, not hardcodes:**
|
||||
- Every derived value (margin, multiple, statistic) MUST be an Excel formula referencing input cells — never a pre-computed number pasted in
|
||||
- When using Python/openpyxl to build the sheet: write `cell.value = "=E7/C7"` (formula string), NOT `cell.value = 0.687` (computed result)
|
||||
- The only hardcoded values should be raw input data (revenue, EBITDA, share price, etc.) — and every one of those gets a cell comment with its source
|
||||
- Why: the model must update automatically when an input changes. A hardcoded margin is a silent bug waiting to happen.
|
||||
|
||||
**Verify step-by-step with the user:**
|
||||
- After setting up the structure → show the user the header layout before filling data
|
||||
- After entering raw inputs → show the user the input block and confirm sources/periods before building formulas
|
||||
- After building operating metrics formulas → show the calculated margins and sanity-check with the user before moving to valuation
|
||||
- After building valuation multiples → show the multiples and confirm they look reasonable before adding statistics
|
||||
- Do NOT build the entire sheet end-to-end and then present it — catch errors early by confirming each section
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Document Structure & Setup
|
||||
|
||||
### Header Block (Rows 1-3)
|
||||
```
|
||||
Row 1: [ANALYSIS TITLE] - COMPARABLE COMPANY ANALYSIS
|
||||
Row 2: [List of Companies with Tickers] • [Company 1 (TICK1)] • [Company 2 (TICK2)] • [Company 3 (TICK3)]
|
||||
Row 3: As of [Period] | All figures in [USD Millions/Billions] except per-share amounts and ratios
|
||||
```
|
||||
|
||||
**Why this matters:** Establishes context immediately. Anyone opening this file knows what they're looking at, when it was created, and how to interpret the numbers.
|
||||
|
||||
### Visual Convention Standards (OPTIONAL - User preferences and uploaded templates always override)
|
||||
|
||||
**IMPORTANT: These are suggested defaults only. Always prioritize:**
|
||||
1. User's explicit formatting preferences
|
||||
2. Formatting from any uploaded template files
|
||||
3. Company/team style guides
|
||||
4. These defaults (only if no other guidance provided)
|
||||
|
||||
**Suggested Font & Typography:**
|
||||
- **Font family**: Times New Roman (professional, readable, industry standard)
|
||||
- **Font size**: 11pt for data cells, 12pt for headers
|
||||
- **Bold text**: Section headers, company names, statistic labels
|
||||
|
||||
**Default Color & Shading — Professional Blue/Grey Palette (minimal is better):**
|
||||
- **Keep it restrained** — only blues and greys. Do NOT introduce greens, oranges, reds, or multiple accent colors. A clean comps sheet uses 3-4 colors total.
|
||||
- **Section headers** (e.g., "OPERATING STATISTICS & FINANCIAL METRICS"):
|
||||
- Dark blue background (`#1F4E79` or `#17365D` navy)
|
||||
- White bold text
|
||||
- Full row shading across all columns
|
||||
- **Column headers** (e.g., "Company", "Revenue", "Margin"):
|
||||
- Light blue background (`#D9E1F2` or similar pale blue)
|
||||
- Black bold text
|
||||
- Centered alignment
|
||||
- **Data rows**:
|
||||
- White background for company data
|
||||
- Black text for formulas; blue text for hardcoded inputs
|
||||
- **Statistics rows** (Maximum, 75th Percentile, etc.):
|
||||
- Light grey background (`#F2F2F2`)
|
||||
- Black text, left-aligned labels
|
||||
- **That's the whole palette**: dark blue + light blue + light grey + white. Nothing else unless the user's template says otherwise.
|
||||
|
||||
**Suggested Formatting Conventions:**
|
||||
- **Decimal precision**:
|
||||
- Percentages: 1 decimal (12.3%)
|
||||
- Multiples: 1 decimal (13.5x)
|
||||
- Dollar amounts: No decimals, thousands separator (69,632)
|
||||
- Margins shown as percentages: 1 decimal (68.7%)
|
||||
- **Borders**: No borders (clean, minimal appearance)
|
||||
- **Alignment**: All metrics center-aligned for clean, uniform appearance
|
||||
- **Cell dimensions**: All column widths should be uniform/even, all row heights should be consistent (creates clean, professional grid)
|
||||
|
||||
**Note:** If the user provides a template file or specifies different formatting, use that instead.
|
||||
|
||||
---
|
||||
|
||||
## Section 2: Operating Statistics & Financial Metrics
|
||||
|
||||
### Core Columns (Start with these)
|
||||
1. **Company** - Names with consistent formatting
|
||||
2. **Revenue** - Size metric (can be LTM, quarterly, or annual depending on context)
|
||||
3. **Revenue Growth** - Year-over-year percentage change
|
||||
4. **Gross Profit** - Revenue minus cost of goods sold
|
||||
5. **Gross Margin** - GP/Revenue (fundamental profitability)
|
||||
6. **EBITDA** - Earnings before interest, tax, depreciation, amortization
|
||||
7. **EBITDA Margin** - EBITDA/Revenue (operating efficiency)
|
||||
|
||||
### Optional Additions (Choose based on industry/purpose)
|
||||
- **Quarterly vs LTM** - Include both if seasonality matters
|
||||
- **Free Cash Flow** - For capital-intensive or SaaS businesses
|
||||
- **FCF Margin** - FCF/Revenue (cash generation efficiency)
|
||||
- **Net Income** - For mature, profitable companies
|
||||
- **Operating Income** - For businesses with varying D&A
|
||||
- **CapEx metrics** - For asset-heavy industries
|
||||
- **Rule of 40** - Specifically for SaaS (Growth % + Margin %)
|
||||
- **FCF Conversion** - For quality of earnings analysis (advanced)
|
||||
|
||||
### Formula Examples (Using Row 7 as example)
|
||||
```excel
|
||||
// Core ratios - these are always calculated
|
||||
Gross Margin (F7): =E7/C7
|
||||
EBITDA Margin (H7): =G7/C7
|
||||
|
||||
// Optional ratios - include if relevant
|
||||
FCF Margin: =[FCF]/[Revenue]
|
||||
Net Margin: =[Net Income]/[Revenue]
|
||||
Rule of 40: =[Growth %]+[FCF Margin %]
|
||||
```
|
||||
|
||||
**Golden Rule:** Every ratio should be [Something] / [Revenue] or [Something] / [Something from this sheet]. Keep it simple.
|
||||
|
||||
### Statistics Block (After company data)
|
||||
|
||||
**CRITICAL: Add statistics formulas for all comparable metrics (ratios, margins, growth rates, multiples).**
|
||||
|
||||
```
|
||||
[Leave one blank row for visual separation]
|
||||
- Maximum: =MAX(B7:B9)
|
||||
- 75th Percentile: =QUARTILE(B7:B9,3)
|
||||
- Median: =MEDIAN(B7:B9)
|
||||
- 25th Percentile: =QUARTILE(B7:B9,1)
|
||||
- Minimum: =MIN(B7:B9)
|
||||
```
|
||||
|
||||
**Columns that NEED statistics (comparable metrics):**
|
||||
- Revenue Growth %, Gross Margin %, EBITDA Margin %, EPS
|
||||
- EV/Revenue, EV/EBITDA, P/E, Dividend Yield %, Beta
|
||||
|
||||
**Columns that DON'T need statistics (size metrics):**
|
||||
- Revenue, EBITDA, Net Income (absolute size varies by company scale)
|
||||
- Market Cap, Enterprise Value (not comparable across different-sized companies)
|
||||
|
||||
**Note:** Add one blank row between company data and statistics rows for visual separation. Do NOT add a "SECTOR STATISTICS" or "VALUATION STATISTICS" header row.
|
||||
|
||||
**Why quartiles matter:** They show distribution, not just average. A 75th percentile multiple tells you what "premium" companies trade at.
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Valuation Multiples & Investment Metrics
|
||||
|
||||
### Core Valuation Columns (Start with these)
|
||||
1. **Company** - Same order as operating section
|
||||
2. **Market Cap** - Current market valuation
|
||||
3. **Enterprise Value** - Market Cap ± Net Debt/Cash
|
||||
4. **EV/Revenue** - How much market pays per dollar of sales
|
||||
5. **EV/EBITDA** - How much market pays per dollar of earnings
|
||||
6. **P/E Ratio** - Price relative to net earnings
|
||||
|
||||
### Optional Valuation Metrics (Choose based on context)
|
||||
- **FCF Yield** - FCF/Market Cap (for cash-focused analysis)
|
||||
- **PEG Ratio** - P/E/Growth Rate (for growth companies)
|
||||
- **Price/Book** - Market value vs. book value (for asset-heavy businesses)
|
||||
- **ROE/ROA** - Return metrics (for profitability comparison)
|
||||
- **Revenue/EBITDA CAGR** - Historical growth rates (for trend analysis)
|
||||
- **Asset Turnover** - Revenue/Assets (for operational efficiency)
|
||||
- **Debt/Equity** - Leverage (for capital structure analysis)
|
||||
|
||||
**Key Principle:** Include 3-5 core multiples that matter for your industry. Don't include every possible metric just because you can.
|
||||
|
||||
### Formula Examples
|
||||
```excel
|
||||
// Core multiples - always include these
|
||||
EV/Revenue: =[Enterprise Value]/[LTM Revenue]
|
||||
EV/EBITDA: =[Enterprise Value]/[LTM EBITDA]
|
||||
P/E Ratio: =[Market Cap]/[Net Income]
|
||||
|
||||
// Optional multiples - include if data available
|
||||
FCF Yield: =[LTM FCF]/[Market Cap]
|
||||
PEG Ratio: =[P/E]/[Growth Rate %]
|
||||
```
|
||||
|
||||
### Cross-Reference Rule
|
||||
**CRITICAL:** Valuation multiples MUST reference the operating metrics section. Never input the same raw data twice. If revenue is in C7, then EV/Revenue formula should reference C7.
|
||||
|
||||
### Statistics Block
|
||||
Same structure as operating section: Max, 75th, Median, 25th, Min for every metric. Add one blank row for visual separation between company data and statistics. Do NOT add a "VALUATION STATISTICS" header row.
|
||||
|
||||
---
|
||||
|
||||
## Section 4: Notes & Methodology Documentation
|
||||
|
||||
### Required Components
|
||||
|
||||
**Data Sources & Quality:**
|
||||
- Where did the data come from? (S&P Kensho MCP, FactSet MCP, Daloopa MCP, Bloomberg, SEC filings)
|
||||
- What period does it cover? (Q4 2024, audited figures)
|
||||
- How was it verified? (Cross-checked against 10-K/10-Q)
|
||||
- Note: Prioritize MCP data sources (S&P Kensho, FactSet, Daloopa) if available for better accuracy and traceability
|
||||
|
||||
**Key Definitions:**
|
||||
- EBITDA calculation method (Gross Profit + D&A, or Operating Income + D&A)
|
||||
- Free Cash Flow formula (Operating CF - CapEx)
|
||||
- Special metrics explained (Rule of 40, FCF Conversion)
|
||||
- Time period definitions (LTM, CAGR calculation periods)
|
||||
|
||||
**Valuation Methodology:**
|
||||
- How was Enterprise Value calculated? (Market Cap + Net Debt)
|
||||
- What growth rates were used? (Historical CAGR, forward estimates)
|
||||
- Any adjustments made? (One-time items excluded, normalized margins)
|
||||
|
||||
**Analysis Framework:**
|
||||
- What's the investment thesis? (Cloud/SaaS efficiency)
|
||||
- What metrics matter most? (Cash generation, capital efficiency)
|
||||
- How should readers interpret the statistics? (Quartiles provide context)
|
||||
|
||||
---
|
||||
|
||||
## Section 5: Choosing the Right Metrics (Decision Framework)
|
||||
|
||||
### Start with "What question am I answering?"
|
||||
|
||||
**"Which company is undervalued?"**
|
||||
→ Focus on: EV/Revenue, EV/EBITDA, P/E, Market Cap
|
||||
→ Skip: Operational details, growth metrics
|
||||
|
||||
**"Which company is most efficient?"**
|
||||
→ Focus on: Gross Margin, EBITDA Margin, FCF Margin, Asset Turnover
|
||||
→ Skip: Size metrics, absolute dollar amounts
|
||||
|
||||
**"Which company is growing fastest?"**
|
||||
→ Focus on: Revenue Growth %, EBITDA CAGR, User/Customer Growth
|
||||
→ Skip: Margin metrics, leverage ratios
|
||||
|
||||
**"Which is the best cash generator?"**
|
||||
→ Focus on: FCF, FCF Margin, FCF Conversion, CapEx intensity
|
||||
→ Skip: EBITDA, P/E ratios
|
||||
|
||||
### Industry-Specific Metric Selection
|
||||
|
||||
**Software/SaaS:**
|
||||
Must have: Revenue Growth, Gross Margin, Rule of 40
|
||||
Optional: ARR, Net Dollar Retention, CAC Payback
|
||||
Skip: Asset Turnover, Inventory metrics
|
||||
|
||||
**Manufacturing/Industrials:**
|
||||
Must have: EBITDA Margin, Asset Turnover, CapEx/Revenue
|
||||
Optional: ROA, Inventory Turns, Backlog
|
||||
Skip: Rule of 40, SaaS metrics
|
||||
|
||||
**Financial Services:**
|
||||
Must have: ROE, ROA, Efficiency Ratio, P/E
|
||||
Optional: Net Interest Margin, Loan Loss Reserves
|
||||
Skip: Gross Margin, EBITDA (not meaningful for banks)
|
||||
|
||||
**Retail/E-commerce:**
|
||||
Must have: Revenue Growth, Gross Margin, Inventory Turnover
|
||||
Optional: Same-Store Sales, Customer Acquisition Cost
|
||||
Skip: Heavy R&D or CapEx metrics
|
||||
|
||||
### The "5-10 Rule"
|
||||
|
||||
**5 operating metrics** - Revenue, Growth, 2-3 margins/efficiency metrics
|
||||
**5 valuation metrics** - Market Cap, EV, 3 multiples
|
||||
**= 10 total columns** - Enough to tell the story, not so many you lose the thread
|
||||
|
||||
If you have more than 15 metrics, you're probably including noise. Edit ruthlessly.
|
||||
|
||||
---
|
||||
|
||||
## Section 6: Best Practices & Quality Checks
|
||||
|
||||
### Before You Start
|
||||
1. **Define the peer group** - Companies must be truly comparable (similar business model, scale, geography)
|
||||
2. **Choose the right period** - LTM smooths seasonality; quarterly shows trends
|
||||
3. **Standardize units upfront** - Millions vs. billions decision affects everything
|
||||
4. **Map data sources** - Know where each number comes from
|
||||
|
||||
### As You Build
|
||||
1. **Input all raw data first** - Complete the blue text before writing formulas
|
||||
2. **Add cell comments to ALL hard-coded inputs** - Right-click cell → Insert Comment → Document source OR assumption
|
||||
|
||||
**For sourced data, cite exactly where it came from:**
|
||||
- Example: "Bloomberg Terminal - MSFT Equity DES, accessed 2024-10-02"
|
||||
- Example: "Q4 2024 10-K filing, page 42, line item 'Total Revenue'"
|
||||
- Example: "FactSet consensus estimate as of 2024-10-02"
|
||||
- **Include hyperlinks when possible**: Right-click cell → Link → paste URL to SEC filing, data source, or report
|
||||
|
||||
**For assumptions, explain the reasoning:**
|
||||
- Example: "Assumed 15% EBITDA margin based on peer median, company does not disclose"
|
||||
- Example: "Estimated Enterprise Value as Market Cap + $50M net debt (from Q3 balance sheet, Q4 not yet available)"
|
||||
- Example: "Forward P/E based on street consensus EPS of $3.45 (average of 12 analyst estimates)"
|
||||
|
||||
**Why this matters**: Enables audit trails, data verification, assumption transparency, and future updates
|
||||
3. **Build formulas row by row** - Test each calculation before moving on
|
||||
4. **Use absolute references for headers** - $C$6 locks the header row
|
||||
5. **Format consistently** - Percentages as percentages, not decimals
|
||||
6. **Add conditional formatting** - Highlight outliers automatically
|
||||
|
||||
### Sanity Checks
|
||||
- **Margin test**: Gross margin > EBITDA margin > Net margin (always true by definition)
|
||||
- **Multiple reasonableness**:
|
||||
- EV/Revenue: typically 0.5-20x (varies widely by industry)
|
||||
- EV/EBITDA: typically 8-25x (fairly consistent across industries)
|
||||
- P/E: typically 10-50x (depends on growth rate)
|
||||
- **Growth-multiple correlation**: Higher growth usually means higher multiples
|
||||
- **Size-efficiency trade-off**: Larger companies often have better margins (scale benefits)
|
||||
|
||||
### Common Mistakes to Avoid
|
||||
❌ Mixing market cap and enterprise value in formulas
|
||||
❌ Using different time periods for numerator and denominator (LTM vs quarterly)
|
||||
❌ Hardcoding numbers into formulas instead of cell references
|
||||
❌ **Hard-coded inputs without cell comments citing the source OR explaining the assumption**
|
||||
❌ Missing hyperlinks to SEC filings or data sources when available
|
||||
❌ Including too many metrics without clear purpose
|
||||
❌ Including non-comparable companies (different business models)
|
||||
❌ Using outdated data without disclosure
|
||||
❌ Calculating averages of percentages incorrectly (should be median)
|
||||
|
||||
---
|
||||
|
||||
## Section 6: Advanced Features
|
||||
|
||||
### Dynamic Headers
|
||||
For columns showing calculations, use clear unit labels:
|
||||
```
|
||||
Revenue Growth (YoY) % | EBITDA Margin | FCF Margin | Rule of 40
|
||||
```
|
||||
|
||||
### Quartile Analysis Benefits
|
||||
Instead of just mean/median, quartiles show:
|
||||
- **75th percentile** = "Premium" companies trade here
|
||||
- **Median** = Typical market valuation
|
||||
- **25th percentile** = "Discount" territory
|
||||
|
||||
This helps answer: "Is our target company trading rich or cheap vs. peers?"
|
||||
|
||||
### Industry-Specific Modifications
|
||||
|
||||
**Software/SaaS:**
|
||||
- Add: ARR, Net Dollar Retention, CAC Payback Period
|
||||
- Emphasize: Rule of 40, FCF margins, gross margins >70%
|
||||
|
||||
**Healthcare:**
|
||||
- Add: R&D/Revenue, Pipeline value, Regulatory status
|
||||
- Emphasize: EBITDA margins, growth rates, reimbursement risk
|
||||
|
||||
**Industrials:**
|
||||
- Add: Backlog, Order book trends, Geographic mix
|
||||
- Emphasize: ROIC, asset turnover, cyclical adjustments
|
||||
|
||||
**Consumer:**
|
||||
- Add: Same-store sales, Customer acquisition cost, Brand value
|
||||
- Emphasize: Revenue growth, gross margins, inventory turns
|
||||
|
||||
---
|
||||
|
||||
## Section 7: Workflow & Practical Tips
|
||||
|
||||
### Step-by-Step Process
|
||||
1. **Set up structure** (30 minutes)
|
||||
- Create all headers
|
||||
- Format cells (blue for inputs, black for formulas)
|
||||
- Lock in units and date references
|
||||
|
||||
2. **Gather data** (60-90 minutes)
|
||||
- Pull from primary sources (S&P Kensho MCP, FactSet MCP, Daloopa MCP if available; otherwise Bloomberg, SEC)
|
||||
- Input all raw numbers in blue
|
||||
- Document sources in notes section
|
||||
|
||||
3. **Build formulas** (30 minutes)
|
||||
- Start with simple ratios (margins)
|
||||
- Progress to multiples (EV/Revenue)
|
||||
- Add cross-checks (do margins make sense?)
|
||||
|
||||
4. **Add statistics** (15 minutes)
|
||||
- Copy formula structure for all columns
|
||||
- Verify ranges are correct (B7:B9, not B7:B10)
|
||||
- Check quartile logic
|
||||
|
||||
5. **Quality control** (30 minutes)
|
||||
- Run sanity checks
|
||||
- Verify formula references
|
||||
- Check for #DIV/0! or #REF! errors
|
||||
- Compare against known benchmarks
|
||||
|
||||
6. **Documentation** (15 minutes)
|
||||
- Complete notes section
|
||||
- Add data sources
|
||||
- Define methodologies
|
||||
- Date-stamp the analysis
|
||||
|
||||
### Pro Tips
|
||||
- **Save templates**: Build once, reuse forever
|
||||
- **Color-code outliers**: Conditional formatting for values >2 standard deviations
|
||||
- **Link to source files**: Hyperlink to Bloomberg screenshots or SEC filings
|
||||
- **Version control**: Save as "Comps_v1_2024-12-15" with clear dating
|
||||
- **Collaborative reviews**: Have someone else check your formulas
|
||||
|
||||
### Excel Formatting Checklist (Optional - adapt to user preferences)
|
||||
- [ ] Font set to user's preferred style (default: Times New Roman, 11pt data, 12pt headers)
|
||||
- [ ] Section headers formatted per user's template (default: dark blue #17365D with white bold text)
|
||||
- [ ] Column headers formatted per user's template (default: light blue/gray #D9E2F3 with black bold text)
|
||||
- [ ] Statistics rows formatted per user's template (default: light gray #F2F2F2)
|
||||
- [ ] No borders applied (clean, minimal appearance)
|
||||
- [ ] **Column widths set to uniform/even width** (creates clean, professional appearance)
|
||||
- [ ] **Row heights set to consistent height** (typically 20-25pt for data rows)
|
||||
- [ ] Numbers formatted with proper decimal precision and thousands separators
|
||||
- [ ] **All metrics center-aligned** for clean, uniform appearance
|
||||
- [ ] **One blank row for separation between company data and statistics rows**
|
||||
- [ ] **No separate "SECTOR STATISTICS" or "VALUATION STATISTICS" header rows**
|
||||
- [ ] **Every hard-coded input cell has a comment with either: (1) exact data source, OR (2) assumption explanation**
|
||||
- [ ] **Hyperlinks added to cells where applicable** (SEC filings, data provider pages, reports)
|
||||
|
||||
---
|
||||
|
||||
## Section 8: Example Template Layout
|
||||
|
||||
**Simple Version (Start here):**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TECHNOLOGY - COMPARABLE COMPANY ANALYSIS │
|
||||
│ Microsoft • Alphabet • Amazon │
|
||||
│ As of Q4 2024 | All figures in USD Millions │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ OPERATING METRICS │
|
||||
├──────────┬─────────┬─────────┬──────────┬──────────────────┤
|
||||
│ Company │ Revenue │ Growth │ Gross │ EBITDA │ EBITDA │
|
||||
│ │ (LTM) │ (YoY) │ Margin │ (LTM) │ Margin │
|
||||
├──────────┼─────────┼─────────┼──────────┼─────────┼────────┤
|
||||
│ MSFT │ 261,400 │ 12.3% │ 68.7% │ 205,100 │ 78.4% │
|
||||
│ GOOGL │ 349,800 │ 11.8% │ 57.9% │ 239,300 │ 68.4% │
|
||||
│ AMZN │ 638,100 │ 10.5% │ 47.3% │ 152,600 │ 23.9% │
|
||||
│ │ │ │ │ │ │ [blank row]
|
||||
│ Median │ =MEDIAN │ =MEDIAN │ =MEDIAN │ =MEDIAN │=MEDIAN │
|
||||
│ 75th % │ =QUART │ =QUART │ =QUART │ =QUART │=QUART │
|
||||
│ 25th % │ =QUART │ =QUART │ =QUART │ =QUART │=QUART │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ VALUATION MULTIPLES │
|
||||
├──────────┬──────────┬──────────┬──────────┬────────────────┤
|
||||
│ Company │ Mkt Cap │ EV │ EV/Rev │ EV/EBITDA │ P/E│
|
||||
├──────────┼──────────┼──────────┼──────────┼───────────┼────┤
|
||||
│ MSFT │3,550,000 │3,530,000 │ 13.5x │ 17.2x │36.0│
|
||||
│ GOOGL │2,030,000 │1,960,000 │ 5.6x │ 8.2x │24.5│
|
||||
│ AMZN │2,226,000 │2,320,000 │ 3.6x │ 15.2x │58.3│
|
||||
│ │ │ │ │ │ │ [blank row]
|
||||
│ Median │ =MEDIAN │ =MEDIAN │ =MEDIAN │ =MEDIAN │=MED│
|
||||
│ 75th % │ =QUART │ =QUART │ =QUART │ =QUART │=QRT│
|
||||
│ 25th % │ =QUART │ =QUART │ =QUART │ =QUART │=QRT│
|
||||
└──────────┴──────────┴──────────┴──────────┴───────────┴────┘
|
||||
```
|
||||
|
||||
**Add complexity only when needed:**
|
||||
- Include quarterly AND LTM if seasonality matters
|
||||
- Add FCF metrics if cash generation is key story
|
||||
- Include industry-specific metrics (Rule of 40 for SaaS, etc.)
|
||||
- Add more statistics rows if you have >5 companies
|
||||
|
||||
---
|
||||
|
||||
## Section 9: Industry-Specific Additions (Optional)
|
||||
|
||||
Only add these if they're critical to your analysis. Most comps work fine with just core metrics.
|
||||
|
||||
**Software/SaaS:**
|
||||
Add if relevant: ARR, Net Dollar Retention, Rule of 40
|
||||
|
||||
**Financial Services:**
|
||||
Add if relevant: ROE, Net Interest Margin, Efficiency Ratio
|
||||
|
||||
**E-commerce:**
|
||||
Add if relevant: GMV, Take Rate, Active Buyers
|
||||
|
||||
**Healthcare:**
|
||||
Add if relevant: R&D/Revenue, Pipeline Value, Patent Timeline
|
||||
|
||||
**Manufacturing:**
|
||||
Add if relevant: Asset Turnover, Inventory Turns, Backlog
|
||||
|
||||
---
|
||||
|
||||
## Section 10: Red Flags & Warning Signs
|
||||
|
||||
### Data Quality Issues
|
||||
🚩 Inconsistent time periods (mixing quarterly and annual)
|
||||
🚩 Missing data without explanation
|
||||
🚩 Significant differences between data sources (>10% variance)
|
||||
|
||||
### Valuation Red Flags
|
||||
🚩 Negative EBITDA companies being valued on EBITDA multiples (use revenue multiples instead)
|
||||
🚩 P/E ratios >100x without hypergrowth story
|
||||
🚩 Margins that don't make sense for the industry
|
||||
|
||||
### Comparability Issues
|
||||
🚩 Different fiscal year ends (causes timing problems)
|
||||
🚩ixing pure-play and conglomerates
|
||||
🚩 Materially different business models labeled as "comps"
|
||||
|
||||
**When in doubt, exclude the company.** Better to have 3 perfect comps than 6 questionable ones.
|
||||
|
||||
---
|
||||
|
||||
## Section 11: Formulas Reference Guide
|
||||
|
||||
### Essential Excel Formulas
|
||||
```excel
|
||||
// Statistical Functions
|
||||
=AVERAGE(range) // Simple mean
|
||||
=MEDIAN(range) // Middle value
|
||||
=QUARTILE(range, 1) // 25th percentile
|
||||
=QUARTILE(range, 3) // 75th percentile
|
||||
=MAX(range) // Maximum value
|
||||
=MIN(range) // Minimum value
|
||||
=STDEV.P(range) // Standard deviation
|
||||
|
||||
// Financial Calculations
|
||||
=B7/C7 // Simple ratio (Margin)
|
||||
=SUM(B7:B9)/3 // Average of multiple companies
|
||||
=IF(B7>0, C7/B7, "N/A") // Conditional calculation
|
||||
=IFERROR(C7/D7, 0) // Handle divide by zero
|
||||
|
||||
// Cross-Sheet References
|
||||
='Sheet1'!B7 // Reference another sheet
|
||||
=VLOOKUP(A7, Table1, 2) // Lookup from data table
|
||||
=INDEX(MATCH()) // Advanced lookup
|
||||
|
||||
// Formatting
|
||||
=TEXT(B7, "0.0%") // Format as percentage
|
||||
=TEXT(C7, "#,##0") // Thousands separator
|
||||
```
|
||||
|
||||
### Common Ratio Formulas
|
||||
```excel
|
||||
Gross Margin = Gross Profit / Revenue
|
||||
EBITDA Margin = EBITDA / Revenue
|
||||
FCF Margin = Free Cash Flow / Revenue
|
||||
FCF Conversion = FCF / Operating Cash Flow
|
||||
ROE = Net Income / Shareholders' Equity
|
||||
ROA = Net Income / Total Assets
|
||||
Asset Turnover = Revenue / Total Assets
|
||||
Debt/Equity = Total Debt / Shareholders' Equity
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Principles Summary
|
||||
|
||||
1. **Structure drives insight** - Right headers force right thinking
|
||||
2. **Less is more** - 5-10 metrics that matter beat 20 that don't
|
||||
3. **Choose metrics for your question** - Valuation analysis ≠ efficiency analysis
|
||||
4. **Statistics show patterns** - Median/quartiles reveal more than average
|
||||
5. **Transparency beats complexity** - Simple formulas everyone understands
|
||||
6. **Comparability is king** - Better to exclude than force a bad comp
|
||||
7. **Document your choices** - Explain which metrics and why in notes section
|
||||
|
||||
---
|
||||
|
||||
## Output Checklist
|
||||
|
||||
Before delivering a comp analysis, verify:
|
||||
- [ ] All companies are truly comparable
|
||||
- [ ] Data is from consistent time periods
|
||||
- [ ] Units are clearly labeled (millions/billions)
|
||||
- [ ] Formulas reference cells, not hardcoded values
|
||||
- [ ] **All hard-coded input cells have comments with either: (1) exact data source with citation, OR (2) clear assumption with explanation**
|
||||
- [ ] **Hyperlinks added where relevant** (SEC EDGAR filings, Bloomberg pages, research reports)
|
||||
- [ ] Statistics include at least 5 metrics (Max, 75th, Med, 25th, Min)
|
||||
- [ ] Notes section documents sources and methodology
|
||||
- [ ] Visual formatting follows conventions (blue = input, black = formula)
|
||||
- [ ] Sanity checks pass (margins logical, multiples reasonable)
|
||||
- [ ] Date stamp is current ("As of [Date]")
|
||||
- [ ] Formula auditing shows no errors (#DIV/0!, #REF!, #N/A)
|
||||
|
||||
---
|
||||
|
||||
## Continuous Improvement
|
||||
|
||||
After completing a comp analysis, ask:
|
||||
1. Did the statistics reveal unexpected insights?
|
||||
2. Were there any data gaps that limited analysis?
|
||||
3. Did stakeholders ask for metrics you didn't include?
|
||||
4. How long did it take vs. how long should it take?
|
||||
5. What would make this more useful next time?
|
||||
|
||||
The best comp analyses evolve with each iteration. Save templates, learn from feedback, and refine the structure based on what decision-makers actually use.
|
||||
|
||||
|
||||
## Data sources — MCP first, web fallback
|
||||
|
||||
Many passages below say "use the S&P Kensho MCP / Daloopa MCP / FactSet MCP". Those are commercial financial-data MCPs from the original Cowork plugin context. In Hermes:
|
||||
|
||||
- **If you have any structured financial-data MCP configured** (Hermes supports MCP — see `native-mcp` skill), prefer it for point-in-time comps, precedent transactions, and filings.
|
||||
- **Otherwise**, fall back to:
|
||||
- `web_search` / `web_extract` against SEC EDGAR (`https://www.sec.gov/cgi-bin/browse-edgar`) for US filings
|
||||
- Company IR pages for press releases, earnings decks
|
||||
- `browser_navigate` for interactive data portals
|
||||
- User-provided data (explicitly ask when the context doesn't have it)
|
||||
- **Never fabricate**. If a multiple, precedent, or filing number can't be sourced, flag the cell as `[UNSOURCED]` and surface it to the user.
|
||||
|
||||
## Attribution
|
||||
|
||||
This skill is adapted from Anthropic's Claude for Financial Services plugin suite (Apache-2.0). The Office-JS / Cowork live-Excel paths have been removed; this version targets headless openpyxl via the `excel-author` skill's conventions. Original: https://github.com/anthropics/financial-services
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
# DCF Model Troubleshooting Guide
|
||||
|
||||
**When to read this file:** If recalc.py shows errors OR valuation results seem unreasonable OR case selector not working properly.
|
||||
|
||||
## Model Returns Error Values
|
||||
|
||||
### #REF! Errors
|
||||
- Usually caused by formulas referencing wrong rows after headers were inserted
|
||||
- Solution: Rebuild with correct row references, or start over following layout planning
|
||||
- Prevention: Define all row positions BEFORE writing formulas
|
||||
|
||||
### #DIV/0! Errors
|
||||
- Division by zero or empty cells
|
||||
- Solution: Add IF statements to handle zeros: `=IF([Divisor]=0,0,[Numerator]/[Divisor])`
|
||||
|
||||
### #VALUE! Errors
|
||||
- Wrong data type in calculation (text instead of number)
|
||||
- Solution: Verify all inputs are formatted as numbers
|
||||
|
||||
## Valuation Seems Unreasonable
|
||||
|
||||
### Implied price far too high
|
||||
- Check terminal value isn't >80% of EV
|
||||
- Verify terminal growth < WACC
|
||||
- Review if growth assumptions are realistic
|
||||
- Consider if margins are too optimistic
|
||||
|
||||
### Implied price far too low
|
||||
- Verify net debt vs net cash is correct
|
||||
- Check if WACC is too high
|
||||
- Review if projections are too conservative
|
||||
- Consider if terminal growth is too low
|
||||
|
||||
## Case Selector Not Working
|
||||
|
||||
### Consolidation column not updating when switching scenarios
|
||||
- Verify case selector cell contains 1, 2, or 3
|
||||
- Check INDEX/OFFSET formulas reference correct row range and selector cell
|
||||
- Ensure absolute references ($B$6) are used for selector
|
||||
- Test by manually changing the selector cell and verifying projection values update
|
||||
@@ -1,7 +0,0 @@
|
||||
# DCF Model Builder - Python Dependencies
|
||||
|
||||
# Excel file handling
|
||||
openpyxl>=3.0.0
|
||||
|
||||
# HTTP requests
|
||||
requests>=2.28.0
|
||||
@@ -1,292 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DCF Model Validation Script
|
||||
Validates Excel DCF models for formula errors and common DCF mistakes
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class DCFModelValidator:
|
||||
"""Validates DCF models for errors and quality issues"""
|
||||
|
||||
def __init__(self, excel_path: str):
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
raise ImportError("openpyxl not installed. Run: pip install openpyxl")
|
||||
|
||||
self.excel_path = excel_path
|
||||
self.openpyxl = openpyxl
|
||||
|
||||
if not Path(excel_path).exists():
|
||||
raise FileNotFoundError(f"File not found: {excel_path}")
|
||||
|
||||
self.workbook_formulas = openpyxl.load_workbook(excel_path, data_only=False)
|
||||
self.workbook_values = openpyxl.load_workbook(excel_path, data_only=True)
|
||||
self.errors = []
|
||||
self.warnings = []
|
||||
self.info = []
|
||||
|
||||
def validate_all(self) -> dict:
|
||||
"""
|
||||
Run all validation checks
|
||||
|
||||
Returns:
|
||||
Dict with validation results
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
self.check_sheet_structure()
|
||||
self.check_formula_errors()
|
||||
self.check_dcf_logic()
|
||||
|
||||
results = {
|
||||
'file': self.excel_path,
|
||||
'validation_date': datetime.now().isoformat(),
|
||||
'status': 'PASS' if len(self.errors) == 0 else 'FAIL',
|
||||
'error_count': len(self.errors),
|
||||
'warning_count': len(self.warnings),
|
||||
'errors': self.errors,
|
||||
'warnings': self.warnings,
|
||||
'info': self.info
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
def check_sheet_structure(self):
|
||||
"""Verify required sheets exist"""
|
||||
required_sheets = ['DCF', 'WACC', 'Sensitivity']
|
||||
sheet_names = self.workbook_values.sheetnames
|
||||
|
||||
for sheet in required_sheets:
|
||||
if sheet not in sheet_names:
|
||||
self.warnings.append(f"Recommended sheet missing: {sheet}")
|
||||
else:
|
||||
self.info.append(f"Found sheet: {sheet}")
|
||||
|
||||
def check_formula_errors(self):
|
||||
"""Check for Excel formula errors in all sheets"""
|
||||
excel_errors = ['#VALUE!', '#DIV/0!', '#REF!', '#NAME?', '#NULL!', '#NUM!', '#N/A']
|
||||
error_details = {err: [] for err in excel_errors}
|
||||
total_errors = 0
|
||||
total_formulas = 0
|
||||
|
||||
for sheet_name in self.workbook_values.sheetnames:
|
||||
ws_values = self.workbook_values[sheet_name]
|
||||
ws_formulas = self.workbook_formulas[sheet_name]
|
||||
|
||||
for row in ws_values.iter_rows():
|
||||
for cell in row:
|
||||
formula_cell = ws_formulas[cell.coordinate]
|
||||
|
||||
# Count formulas
|
||||
if formula_cell.value and isinstance(formula_cell.value, str) and formula_cell.value.startswith('='):
|
||||
total_formulas += 1
|
||||
|
||||
# Check for errors
|
||||
if cell.value is not None and isinstance(cell.value, str):
|
||||
for err in excel_errors:
|
||||
if err in cell.value:
|
||||
location = f"{sheet_name}!{cell.coordinate}"
|
||||
error_details[err].append(location)
|
||||
total_errors += 1
|
||||
self.errors.append(f"{err} at {location}")
|
||||
break
|
||||
|
||||
# Add summary info
|
||||
self.info.append(f"Total formulas: {total_formulas}")
|
||||
if total_errors == 0:
|
||||
self.info.append("✓ No formula errors found")
|
||||
else:
|
||||
self.errors.append(f"Total formula errors: {total_errors}")
|
||||
|
||||
return error_details, total_errors
|
||||
|
||||
def check_dcf_logic(self):
|
||||
"""Validate DCF-specific logic and calculations"""
|
||||
self._check_terminal_growth_vs_wacc()
|
||||
self._check_wacc_range()
|
||||
self._check_terminal_value_proportion()
|
||||
|
||||
def _check_terminal_growth_vs_wacc(self):
|
||||
"""Critical check: Terminal growth must be less than WACC"""
|
||||
try:
|
||||
dcf_sheet = self.workbook_values['DCF']
|
||||
|
||||
terminal_growth = None
|
||||
wacc = None
|
||||
|
||||
# Search for terminal growth and WACC values
|
||||
for row in dcf_sheet.iter_rows(max_row=100, max_col=20):
|
||||
for cell in row:
|
||||
if cell.value and isinstance(cell.value, str):
|
||||
cell_str = cell.value.lower()
|
||||
if 'terminal' in cell_str and 'growth' in cell_str:
|
||||
# Look for value in adjacent cells
|
||||
for offset in range(1, 5):
|
||||
adjacent = dcf_sheet.cell(cell.row, cell.column + offset).value
|
||||
if isinstance(adjacent, (int, float)) and 0 < adjacent < 1:
|
||||
terminal_growth = adjacent
|
||||
break
|
||||
if 'wacc' in cell_str and wacc is None:
|
||||
for offset in range(1, 5):
|
||||
adjacent = dcf_sheet.cell(cell.row, cell.column + offset).value
|
||||
if isinstance(adjacent, (int, float)) and 0 < adjacent < 1:
|
||||
wacc = adjacent
|
||||
break
|
||||
|
||||
if terminal_growth is not None and wacc is not None:
|
||||
if terminal_growth >= wacc:
|
||||
self.errors.append(
|
||||
f"CRITICAL: Terminal growth ({terminal_growth:.2%}) >= WACC ({wacc:.2%}). "
|
||||
"This creates infinite value and is mathematically invalid."
|
||||
)
|
||||
else:
|
||||
self.info.append(
|
||||
f"✓ Terminal growth ({terminal_growth:.2%}) < WACC ({wacc:.2%})"
|
||||
)
|
||||
else:
|
||||
self.warnings.append("Could not locate terminal growth and WACC values")
|
||||
|
||||
except KeyError:
|
||||
self.warnings.append("DCF sheet not found")
|
||||
except Exception as e:
|
||||
self.warnings.append(f"Could not validate terminal growth vs WACC: {str(e)}")
|
||||
|
||||
def _check_wacc_range(self):
|
||||
"""Check if WACC is in reasonable range"""
|
||||
try:
|
||||
wacc_sheet = self.workbook_values.get('WACC') or self.workbook_values['DCF']
|
||||
wacc = None
|
||||
|
||||
for row in wacc_sheet.iter_rows(max_row=100, max_col=20):
|
||||
for cell in row:
|
||||
if cell.value and isinstance(cell.value, str):
|
||||
if 'wacc' in cell.value.lower():
|
||||
for offset in range(1, 5):
|
||||
adjacent = wacc_sheet.cell(cell.row, cell.column + offset).value
|
||||
if isinstance(adjacent, (int, float)) and 0 < adjacent < 1:
|
||||
wacc = adjacent
|
||||
break
|
||||
|
||||
if wacc is not None:
|
||||
if wacc < 0.05 or wacc > 0.20:
|
||||
self.warnings.append(
|
||||
f"WACC ({wacc:.2%}) is outside typical range (5%-20%). Verify calculation."
|
||||
)
|
||||
else:
|
||||
self.info.append(f"✓ WACC ({wacc:.2%}) in reasonable range")
|
||||
else:
|
||||
self.warnings.append("Could not locate WACC value")
|
||||
|
||||
except Exception as e:
|
||||
self.warnings.append(f"Could not validate WACC range: {str(e)}")
|
||||
|
||||
def _check_terminal_value_proportion(self):
|
||||
"""Check if terminal value is reasonable proportion of enterprise value"""
|
||||
try:
|
||||
dcf_sheet = self.workbook_values['DCF']
|
||||
|
||||
terminal_value = None
|
||||
enterprise_value = None
|
||||
|
||||
for row in dcf_sheet.iter_rows(max_row=200, max_col=20):
|
||||
for cell in row:
|
||||
if cell.value and isinstance(cell.value, str):
|
||||
cell_str = cell.value.lower()
|
||||
if 'terminal' in cell_str and 'value' in cell_str and 'pv' in cell_str:
|
||||
for offset in range(1, 5):
|
||||
adjacent = dcf_sheet.cell(cell.row, cell.column + offset).value
|
||||
if isinstance(adjacent, (int, float)) and adjacent > 0:
|
||||
terminal_value = adjacent
|
||||
break
|
||||
if 'enterprise' in cell_str and 'value' in cell_str:
|
||||
for offset in range(1, 5):
|
||||
adjacent = dcf_sheet.cell(cell.row, cell.column + offset).value
|
||||
if isinstance(adjacent, (int, float)) and adjacent > 0:
|
||||
enterprise_value = adjacent
|
||||
break
|
||||
|
||||
if terminal_value is not None and enterprise_value is not None and enterprise_value > 0:
|
||||
proportion = terminal_value / enterprise_value
|
||||
if proportion > 0.80:
|
||||
self.warnings.append(
|
||||
f"Terminal value is {proportion:.1%} of EV (typically should be 50-70%). "
|
||||
"Model may be over-reliant on terminal assumptions."
|
||||
)
|
||||
elif proportion < 0.40:
|
||||
self.warnings.append(
|
||||
f"Terminal value is {proportion:.1%} of EV (typically should be 50-70%). "
|
||||
"Check if terminal assumptions are too conservative."
|
||||
)
|
||||
else:
|
||||
self.info.append(f"✓ Terminal value is {proportion:.1%} of EV")
|
||||
else:
|
||||
self.warnings.append("Could not locate terminal value and enterprise value")
|
||||
|
||||
except Exception as e:
|
||||
self.warnings.append(f"Could not validate terminal value proportion: {str(e)}")
|
||||
|
||||
|
||||
|
||||
def validate_dcf_model(excel_path: str) -> dict:
|
||||
"""
|
||||
Validate a DCF model Excel file
|
||||
|
||||
Args:
|
||||
excel_path: Path to Excel DCF model
|
||||
|
||||
Returns:
|
||||
Dict with validation results
|
||||
"""
|
||||
validator = DCFModelValidator(excel_path)
|
||||
return validator.validate_all()
|
||||
|
||||
|
||||
def main():
|
||||
"""Command-line interface"""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python validate_dcf.py <excel_file> [output.json]")
|
||||
print("\nValidates DCF model for:")
|
||||
print(" - Formula errors (#REF!, #DIV/0!, etc.)")
|
||||
print(" - Terminal growth < WACC (critical)")
|
||||
print(" - WACC in reasonable range (5-20%)")
|
||||
print(" - Terminal value proportion of EV (40-80%)")
|
||||
print("\nReturns JSON with errors, warnings, and info")
|
||||
print("\nExample: python validate_dcf.py model.xlsx")
|
||||
print("Example: python validate_dcf.py model.xlsx results.json")
|
||||
sys.exit(1)
|
||||
|
||||
excel_file = sys.argv[1]
|
||||
output_file = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
try:
|
||||
results = validate_dcf_model(excel_file)
|
||||
|
||||
# Print results
|
||||
print(json.dumps(results, indent=2))
|
||||
|
||||
# Save to file if requested
|
||||
if output_file:
|
||||
with open(output_file, 'w') as f:
|
||||
json.dump(results, f, indent=2)
|
||||
|
||||
# Exit with error code if validation failed
|
||||
sys.exit(0 if results['status'] == 'PASS' else 1)
|
||||
|
||||
except Exception as e:
|
||||
error_result = {
|
||||
'file': excel_file,
|
||||
'status': 'ERROR',
|
||||
'error': str(e)
|
||||
}
|
||||
print(json.dumps(error_result, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,243 +0,0 @@
|
||||
---
|
||||
name: excel-author
|
||||
description: Build auditable Excel workbooks headless with openpyxl — blue/black/green cell conventions, formulas over hardcodes, named ranges, balance checks, sensitivity tables. Use for financial models, audit outputs, reconciliations.
|
||||
version: 1.0.0
|
||||
author: Anthropic (adapted by Nous Research)
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [excel, openpyxl, finance, spreadsheet, modeling]
|
||||
related_skills: [pptx-author, dcf-model, comps-analysis, lbo-model, 3-statement-model]
|
||||
---
|
||||
|
||||
# excel-author
|
||||
|
||||
Produce an .xlsx file on disk using `openpyxl`. Follow the banker-grade conventions below so the model is auditable, flexible, and reviewable by someone other than the person who built it.
|
||||
|
||||
Adapted from Anthropic's `xlsx-author` and `audit-xls` skills in the [anthropics/financial-services](https://github.com/anthropics/financial-services) repo. The MCP / Office-JS / Cowork-specific branches of the originals are dropped — this skill assumes headless Python.
|
||||
|
||||
## Output contract
|
||||
|
||||
- Write to `./out/<name>.xlsx`. Create `./out/` if it does not exist.
|
||||
- Return the relative path in your final message so downstream tools can pick it up.
|
||||
- One logical model per file. Do not append to an existing workbook unless explicitly asked.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pip install "openpyxl>=3.0"
|
||||
```
|
||||
|
||||
## Core conventions (non-negotiable)
|
||||
|
||||
### Blue / black / green cell color
|
||||
- **Blue** (`Font(color="0000FF")`) — hardcoded input a human entered. Revenue drivers, WACC inputs, terminal growth, market data.
|
||||
- **Black** (default) — formula. Every derived cell is a live Excel formula.
|
||||
- **Green** (`Font(color="006100")`) — link to another sheet or external file.
|
||||
|
||||
A reviewer can then scan the sheet and immediately see what's an assumption vs. what's computed.
|
||||
|
||||
### Formulas over hardcodes
|
||||
Every calculation cell MUST be a formula string, never a number computed in Python and pasted as a value.
|
||||
|
||||
```python
|
||||
# WRONG — silent bug waiting to happen
|
||||
ws["D20"] = revenue_prior_year * (1 + growth)
|
||||
|
||||
# CORRECT — flexes when the user changes the assumption
|
||||
ws["D20"] = "=D19*(1+$B$8)"
|
||||
```
|
||||
|
||||
The only hardcoded numbers permitted:
|
||||
1. Raw historical inputs (actual revenues, reported EBITDA, etc.)
|
||||
2. Assumption drivers the user is meant to flex (growth rates, WACC inputs, terminal g)
|
||||
3. Current market data (share price, debt balance) — with a cell comment documenting source + date
|
||||
|
||||
If you catch yourself computing a value in Python and writing the result, stop.
|
||||
|
||||
### Named ranges for cross-sheet references
|
||||
Use named ranges for any figure referenced from another sheet, a deck, or a memo.
|
||||
|
||||
```python
|
||||
from openpyxl.workbook.defined_name import DefinedName
|
||||
wb.defined_names["WACC"] = DefinedName("WACC", attr_text="Inputs!$C$8")
|
||||
# then elsewhere:
|
||||
calc["D30"] = "=D29/WACC"
|
||||
```
|
||||
|
||||
### Balance checks tab
|
||||
Include a `Checks` tab that ties everything and surfaces TRUE/FALSE:
|
||||
- Balance sheet balances (assets = liabilities + equity)
|
||||
- Cash flow ties to period-over-period cash change on the BS
|
||||
- Sum-of-parts ties to consolidated totals
|
||||
- No rogue hardcodes inside calc ranges
|
||||
|
||||
Example:
|
||||
```python
|
||||
checks = wb.create_sheet("Checks")
|
||||
checks["A2"] = "BS balances"
|
||||
checks["B2"] = "=IS!D20-IS!D21-IS!D22"
|
||||
checks["C2"] = "=ABS(B2)<0.01" # TRUE/FALSE
|
||||
```
|
||||
|
||||
### Cell comments on every hardcoded input
|
||||
Add the comment AS you create the cell, not later.
|
||||
|
||||
```python
|
||||
from openpyxl.comments import Comment
|
||||
ws["C2"] = 1_250_000_000
|
||||
ws["C2"].font = Font(color="0000FF")
|
||||
ws["C2"].comment = Comment("Source: 10-K FY2024, p.47, revenue line", "analyst")
|
||||
```
|
||||
|
||||
Format: `Source: [System/Document], [Date], [Reference], [URL if applicable]`.
|
||||
|
||||
Never defer sourcing. Never write `TODO: add source`.
|
||||
|
||||
## Skeleton: typical financial model
|
||||
|
||||
```python
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.comments import Comment
|
||||
from openpyxl.utils import get_column_letter
|
||||
from pathlib import Path
|
||||
|
||||
BLUE = Font(color="0000FF")
|
||||
BLACK = Font(color="000000")
|
||||
GREEN = Font(color="006100")
|
||||
BOLD = Font(bold=True)
|
||||
HEADER_FILL = PatternFill("solid", fgColor="1F4E79")
|
||||
HEADER_FONT = Font(color="FFFFFF", bold=True)
|
||||
|
||||
wb = Workbook()
|
||||
|
||||
# --- Inputs tab ---
|
||||
inp = wb.active
|
||||
inp.title = "Inputs"
|
||||
inp["A1"] = "MARKET DATA & KEY INPUTS"
|
||||
inp["A1"].font = HEADER_FONT
|
||||
inp["A1"].fill = HEADER_FILL
|
||||
inp.merge_cells("A1:C1")
|
||||
|
||||
inp["B3"] = "Revenue FY2024"
|
||||
inp["C3"] = 1_250_000_000
|
||||
inp["C3"].font = BLUE
|
||||
inp["C3"].comment = Comment("Source: 10-K FY2024 p.47", "model")
|
||||
|
||||
inp["B4"] = "Growth Rate"
|
||||
inp["C4"] = 0.12
|
||||
inp["C4"].font = BLUE
|
||||
|
||||
# --- Calc tab ---
|
||||
calc = wb.create_sheet("DCF")
|
||||
calc["B2"] = "Projected Revenue"
|
||||
calc["C2"] = "=Inputs!C3*(1+Inputs!C4)" # formula, black
|
||||
|
||||
# --- Checks tab ---
|
||||
chk = wb.create_sheet("Checks")
|
||||
chk["A2"] = "BS balances"
|
||||
chk["B2"] = "=ABS(BS!D20-BS!D21-BS!D22)<0.01"
|
||||
|
||||
Path("./out").mkdir(exist_ok=True)
|
||||
wb.save("./out/model.xlsx")
|
||||
```
|
||||
|
||||
## Section headers with merged cells
|
||||
|
||||
openpyxl quirk: when you merge, set the value on the top-left cell and style the full range separately.
|
||||
|
||||
```python
|
||||
ws["A7"] = "CASH FLOW PROJECTION"
|
||||
ws["A7"].font = HEADER_FONT
|
||||
ws.merge_cells("A7:H7")
|
||||
for col in range(1, 9): # A..H
|
||||
ws.cell(row=7, column=col).fill = HEADER_FILL
|
||||
```
|
||||
|
||||
## Sensitivity tables
|
||||
|
||||
Build with loops, not hardcoded formulas per cell. Rules:
|
||||
|
||||
- **Odd number of rows/cols** (5×5 or 7×7) — guarantees a true center cell.
|
||||
- **Center cell = base case.** The middle row/col header must equal the model's actual WACC and terminal g so the center output equals the base-case implied share price. That's the sanity check.
|
||||
- **Highlight the center cell** with medium-blue fill (`"BDD7EE"`) and bold.
|
||||
- Populate every cell with a full recalculation formula — never an approximation.
|
||||
|
||||
```python
|
||||
# 5x5 WACC (rows) x terminal growth (cols) sensitivity
|
||||
wacc_axis = [0.08, 0.085, 0.09, 0.095, 0.10] # center row = base 9.0%
|
||||
term_axis = [0.02, 0.025, 0.03, 0.035, 0.04] # center col = base 3.0%
|
||||
|
||||
start_row = 40
|
||||
ws.cell(row=start_row, column=1).value = "Implied Share Price ($)"
|
||||
ws.cell(row=start_row, column=1).font = BOLD
|
||||
|
||||
for j, g in enumerate(term_axis):
|
||||
ws.cell(row=start_row+1, column=2+j).value = g
|
||||
ws.cell(row=start_row+1, column=2+j).font = BLUE
|
||||
|
||||
for i, w in enumerate(wacc_axis):
|
||||
r = start_row + 2 + i
|
||||
ws.cell(row=r, column=1).value = w
|
||||
ws.cell(row=r, column=1).font = BLUE
|
||||
for j, g in enumerate(term_axis):
|
||||
c = 2 + j
|
||||
# Full DCF recalc formula (simplified for illustration).
|
||||
# In a real model this references the full projection block.
|
||||
ws.cell(row=r, column=c).value = (
|
||||
f"=SUMPRODUCT(FCF_range,1/(1+{w})^year_offset) + "
|
||||
f"FCF_terminal*(1+{g})/({w}-{g})/(1+{w})^terminal_year"
|
||||
)
|
||||
|
||||
# Highlight center cell (base case)
|
||||
center = ws.cell(row=start_row+2+len(wacc_axis)//2,
|
||||
column=2+len(term_axis)//2)
|
||||
center.fill = PatternFill("solid", fgColor="BDD7EE")
|
||||
center.font = BOLD
|
||||
```
|
||||
|
||||
## Recalculating before delivery
|
||||
|
||||
openpyxl writes formula strings but does not compute them. Excel recalculates on open, but downstream consumers (auto-check scripts, CI) need computed values.
|
||||
|
||||
Run LibreOffice or a dedicated recalc step before delivery:
|
||||
|
||||
```bash
|
||||
# LibreOffice headless recalc
|
||||
libreoffice --headless --calc --convert-to xlsx ./out/model.xlsx --outdir ./out/
|
||||
```
|
||||
|
||||
Or use a Python recalc helper (see `scripts/recalc.py` in this skill).
|
||||
|
||||
## Model layout planning
|
||||
|
||||
Before writing any formula:
|
||||
1. Define ALL section row positions
|
||||
2. Write ALL headers and labels
|
||||
3. Write ALL section dividers and blank rows
|
||||
4. THEN write formulas using the locked row positions
|
||||
|
||||
This prevents the cascading-formula-breakage pattern where inserting a header row after formulas are written shifts every downstream reference.
|
||||
|
||||
## Verify step-by-step with the user
|
||||
|
||||
For large models (DCFs, 3-statement, LBO), stop and show the user intermediate artifacts before continuing. Catching a wrong margin assumption before you've built downstream sensitivity tables saves an hour.
|
||||
|
||||
Checkpoint pattern:
|
||||
- After Inputs block → show raw inputs, confirm before projecting
|
||||
- After Revenue projections → confirm top line + growth
|
||||
- After FCF build → confirm the full schedule
|
||||
- After WACC → confirm inputs
|
||||
- After valuation → confirm the equity bridge
|
||||
- THEN build sensitivity tables
|
||||
|
||||
## When NOT to use this skill
|
||||
|
||||
- Users in a live Excel session with an Office MCP available — drive their live workbook instead.
|
||||
- Pure tabular data export with no formulas — `csv` or `pandas.to_excel` is simpler.
|
||||
- Dashboards / charts with heavy interactivity — use a real BI tool.
|
||||
|
||||
## Attribution
|
||||
|
||||
Conventions (blue/black/green, formulas-over-hardcodes, named ranges, sensitivity rules) adapted from Anthropic's Claude for Financial Services plugin suite, Apache-2.0 licensed. Original: https://github.com/anthropics/financial-services/tree/main/plugins/vertical-plugins/financial-analysis/skills/xlsx-author
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recalculate an .xlsx file's formulas using LibreOffice headless.
|
||||
|
||||
Usage: python recalc.py <path.xlsx> [timeout_seconds]
|
||||
|
||||
openpyxl writes formula strings but does not compute them. Downstream scripts
|
||||
that open the file with data_only=True get None for every formula cell until
|
||||
something has actually calculated the workbook. Excel does this on open;
|
||||
headless pipelines need LibreOffice (or similar) to do it explicitly.
|
||||
|
||||
Exits 0 on success (workbook recomputed and resaved in place), non-zero on
|
||||
failure. Writes status JSON to stdout either way.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_libreoffice() -> str | None:
|
||||
for cmd in ("libreoffice", "soffice"):
|
||||
path = shutil.which(cmd)
|
||||
if path:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def recalc(xlsx_path: str, timeout: int = 60) -> dict:
|
||||
src = Path(xlsx_path).resolve()
|
||||
if not src.exists():
|
||||
return {"status": "error", "error": f"File not found: {src}"}
|
||||
|
||||
lo = find_libreoffice()
|
||||
if lo is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "libreoffice not found on PATH — install it or recalc in a real Excel session",
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
lo,
|
||||
"--headless",
|
||||
"--calc",
|
||||
"--convert-to",
|
||||
"xlsx",
|
||||
str(src),
|
||||
"--outdir",
|
||||
td,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"status": "error", "error": f"libreoffice timed out after {timeout}s"}
|
||||
except subprocess.CalledProcessError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"libreoffice exited {e.returncode}: {e.stderr.decode(errors='replace')[:500]}",
|
||||
}
|
||||
|
||||
produced = Path(td) / src.name
|
||||
if not produced.exists():
|
||||
return {"status": "error", "error": "libreoffice did not produce output file"}
|
||||
|
||||
shutil.copy(produced, src)
|
||||
|
||||
return {"status": "success", "file": str(src)}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python recalc.py <path.xlsx> [timeout_seconds]", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 60
|
||||
result = recalc(sys.argv[1], timeout=timeout)
|
||||
print(json.dumps(result, indent=2))
|
||||
sys.exit(0 if result["status"] == "success" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,290 +0,0 @@
|
||||
---
|
||||
name: lbo-model
|
||||
description: Build leveraged buyout models in Excel — sources & uses, debt schedule, cash sweep, exit multiple, IRR/MOIC sensitivity. Pairs with excel-author. Use for PE screening, sponsor-case valuation, or illustrative LBO in a pitch.
|
||||
version: 1.0.0
|
||||
author: Anthropic (adapted by Nous Research)
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [finance, valuation, lbo, private-equity, excel, openpyxl, modeling]
|
||||
related_skills: [excel-author, pptx-author, dcf-model, 3-statement-model]
|
||||
---
|
||||
|
||||
## Environment
|
||||
|
||||
This skill assumes **headless openpyxl** — you are producing an .xlsx file on disk.
|
||||
Follow the `excel-author` skill's conventions for cell coloring, formulas, named ranges, and sensitivity tables.
|
||||
Recalculate before delivery: `python /path/to/excel-author/scripts/recalc.py ./out/model.xlsx`.
|
||||
|
||||
---
|
||||
|
||||
## TEMPLATE REQUIREMENT
|
||||
|
||||
**This skill uses templates for LBO models. Always check for an attached template file first.**
|
||||
|
||||
Before starting any LBO model:
|
||||
1. **If a template file is attached/provided**: Use that template's structure exactly - copy it and populate with the user's data
|
||||
2. **If no template is attached**: Ask the user: *"Do you have a specific LBO template you'd like me to use? If not, I can use the standard template which includes Sources & Uses, Operating Model, Debt Schedule, and Returns Analysis."*
|
||||
3. **If using the standard template**: Copy `examples/LBO_Model.xlsx` as your starting point and populate it with the user's assumptions
|
||||
|
||||
**IMPORTANT**: When a file like `LBO_Model.xlsx` is attached, you MUST use it as your template - do not build from scratch. Even if the template seems complex or has more features than needed, copy it and adapt it to the user's requirements. Never decide to "build from scratch" when a template is provided.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL INSTRUCTIONS — READ FIRST
|
||||
|
||||
Use Python/openpyxl. Write formula strings (`ws["D20"] = "=B5*B6"`), then run the `excel-author` skill's `recalc.py` helper before delivery.
|
||||
|
||||
### Core Principles
|
||||
* **Every calculation must be an Excel formula** - NEVER compute values in Python and hardcode results into cells. When using openpyxl, write `cell.value = "=B5*B6"` (formula string), NOT `cell.value = 1250` (computed result). The model must be dynamic and update when inputs change.
|
||||
* **Use the template structure** - Follow the organization in `examples/LBO_Model.xlsx` or the user's provided template. Do not invent your own layout.
|
||||
* **Use proper cell references** - All formulas should reference the appropriate cells. Never type numbers that should come from other cells.
|
||||
* **Maintain sign convention consistency** - Follow whatever sign convention the template uses (some use negative for outflows, some use positive). Be consistent throughout.
|
||||
* **Work section by section, verify with user at each step** - Complete one section fully, show the user what was built, run the section's verification checks, and get confirmation BEFORE moving to the next section. Do NOT build the entire model end-to-end and then present it — later sections depend on earlier ones, so catching a mistake in Sources & Uses after the returns are already built means rework everywhere.
|
||||
|
||||
### Formula Color Conventions
|
||||
* **Blue (0000FF)**: Hardcoded inputs - typed numbers that don't reference other cells
|
||||
* **Black (000000)**: Formulas with calculations - any formula using operators or functions (`=B4*B5`, `=SUM()`, `=-MAX(0,B4)`)
|
||||
* **Purple (800080)**: Links to cells on the **same tab** - direct references with no calculation (`=B9`, `=B45`)
|
||||
* **Green (008000)**: Links to cells on **different tabs** - cross-sheet references (`=Assumptions!B5`, `='Operating Model'!C10`)
|
||||
|
||||
### Fill Color Palette — Professional Blues & Greys (Default unless user/template specifies otherwise)
|
||||
* **Keep it minimal** — only use blues and greys for cell fills. Do NOT introduce greens, yellows, reds, or multiple accents. A professional LBO model uses restraint.
|
||||
* **Default fill palette:**
|
||||
* **Section headers** (Sources & Uses, Operating Model, etc.): Dark blue `#1F4E79` with white bold text
|
||||
* **Column headers** (Year 1, Year 2, etc.): Light blue `#D9E1F2` with black bold text
|
||||
* **Input cells**: Light grey `#F2F2F2` (or just white) — the blue *font* is the signal, fill is secondary
|
||||
* **Formula/calculated cells**: White, no fill
|
||||
* **Key outputs** (IRR, MOIC, Exit Equity): Medium blue `#BDD7EE` with black bold text
|
||||
* **That's the whole palette.** 3 blues + 1 grey + white. If the template uses its own colors, follow the template instead.
|
||||
* Note: The blue/black/purple/green **font** colors above are for distinguishing inputs vs formulas vs links. Those are separate from the **fill** palette here — both work together.
|
||||
|
||||
### Number Formatting Standards
|
||||
* **Currency**: `$#,##0;($#,##0);"-"` or `$#,##0.0` depending on template
|
||||
* **Percentages**: `0.0%` (one decimal)
|
||||
* **Multiples**: `0.0"x"` (one decimal)
|
||||
* **MOIC/Detailed Ratios**: `0.00"x"` (two decimals for precision)
|
||||
* **All numeric cells**: Right-aligned
|
||||
|
||||
---
|
||||
|
||||
### Clarify Requirements First
|
||||
|
||||
Before filling any formulas:
|
||||
|
||||
* **Examine the template structure** - Identify all sections, understand the timeline (which columns are which periods), note any existing formulas
|
||||
* **Ask the user if anything is unclear** - If the template structure, calculation methods, or requirements are ambiguous, ask before proceeding
|
||||
* **Confirm key assumptions** - Any key inputs, calculation preferences, or specific requirements
|
||||
* **ONLY AFTER understanding the template**, proceed to fill in formulas
|
||||
|
||||
---
|
||||
|
||||
## TEMPLATE ANALYSIS PHASE - DO THIS FIRST
|
||||
|
||||
Before filling any formulas, examine the template thoroughly:
|
||||
|
||||
1. **Map the structure** - Identify where each section lives and how they relate to each other. Note which sections feed into others.
|
||||
|
||||
2. **Understand the timeline** - Which columns represent which periods? Is there a "Closing" or "Pro Forma" column? Where does the projection period start?
|
||||
|
||||
3. **Identify input vs formula cells** - Templates often use color coding, borders, or shading to indicate which cells need inputs vs formulas. Respect these conventions.
|
||||
|
||||
4. **Read existing labels carefully** - The row labels tell you exactly what calculation is expected. Don't assume - read what the template is asking for.
|
||||
|
||||
5. **Check for existing formulas** - Some templates come partially filled. Don't overwrite working formulas unless specifically asked.
|
||||
|
||||
6. **Note template-specific conventions** - Sign conventions, subtotal structures, how sections are organized, whether there are separate tabs for different components, etc.
|
||||
|
||||
---
|
||||
|
||||
## FILLING FORMULAS - GENERAL APPROACH
|
||||
|
||||
For each cell that needs a formula, follow this hierarchy:
|
||||
|
||||
### Step 1: Check the Template
|
||||
* Does the cell already have a formula? If yes, verify it's correct and move on.
|
||||
* Is there a comment or note indicating the expected calculation?
|
||||
* Does the row/column label make the calculation obvious?
|
||||
* Do neighboring cells show a pattern you should follow?
|
||||
|
||||
### Step 2: Check the User's Instructions
|
||||
* Did the user specify a particular calculation method?
|
||||
* Are there stated assumptions that affect this formula?
|
||||
* Any special requirements mentioned?
|
||||
|
||||
### Step 3: Apply Standard Practice
|
||||
* If neither template nor user specifies, use standard LBO modeling conventions
|
||||
* Document any assumptions you make
|
||||
* If genuinely uncertain, ask the user
|
||||
|
||||
---
|
||||
|
||||
## COMMON PROBLEM AREAS
|
||||
|
||||
The following calculation patterns frequently cause issues across LBO models. Pay special attention when you encounter these:
|
||||
|
||||
### Balancing Sections
|
||||
* When two sections must equal (e.g., Sources = Uses), one item is typically the "plug" (balancing figure)
|
||||
* Identify which item is the plug and calculate it as the difference
|
||||
|
||||
### Tax Calculations
|
||||
* Tax formulas should only reference the relevant income line and tax rate
|
||||
* Should NOT reference unrelated sections (e.g., debt schedules)
|
||||
* Consider whether losses create tax shields or are simply ignored
|
||||
|
||||
### Interest and Circular References
|
||||
* Interest calculations can create circularity if they reference balances affected by cash flows
|
||||
* Use **Beginning Balance** (not average or ending) to break circular references
|
||||
* Pattern: Interest → Cash Flow → Paydown → Ending Balance (if interest uses ending balance, this circles back)
|
||||
|
||||
### Debt Paydown / Cash Sweeps
|
||||
* When multiple debt tranches exist, there's usually a priority order
|
||||
* Cash sweep should respect the priority waterfall
|
||||
* Balances cannot go negative - use MAX or MIN functions appropriately
|
||||
|
||||
### Returns Calculations (IRR/MOIC)
|
||||
* Cash flows must have correct signs: Investment = negative, Proceeds = positive
|
||||
* If using XIRR, need corresponding dates
|
||||
* If using IRR, cash flows should be in consecutive periods
|
||||
* MOIC = Total Proceeds / Total Investment
|
||||
|
||||
### Sensitivity Tables
|
||||
* **Use ODD dimensions** (5×5 or 7×7) — never 4×4 or 6×6. Odd dimensions guarantee a true center cell.
|
||||
* **Center cell = base case.** Build the row and column axis values symmetrically around the model's actual assumptions (e.g., if base entry multiple = 10.0x, axis = `[8.0x, 9.0x, 10.0x, 11.0x, 12.0x]`). The center cell's IRR/MOIC MUST then equal the model's actual IRR/MOIC output — this is the proof the table is wired correctly.
|
||||
* **Highlight the center cell** — medium-blue fill (`#BDD7EE`) + bold font so the base case is visually anchored.
|
||||
* Excel's DATA TABLE function may not work with openpyxl — instead write explicit formulas that reference row/column headers
|
||||
* Each cell should show a DIFFERENT value — if all same, formulas aren't varying correctly
|
||||
* Use mixed references (e.g., `$A5` for row input, `B$4` for column input)
|
||||
|
||||
---
|
||||
|
||||
## VERIFICATION CHECKLIST - RUN AFTER COMPLETION
|
||||
|
||||
### Run Formula Validation
|
||||
```bash
|
||||
python /path/to/excel-author/scripts/recalc.py model.xlsx
|
||||
```
|
||||
Must return success with zero errors.
|
||||
|
||||
### Section Balancing
|
||||
- [ ] Any sections that must balance (Sources/Uses, Assets/Liabilities) balance exactly
|
||||
- [ ] Plug items are calculated correctly as the balancing figure
|
||||
- [ ] Amounts that should match across sections are consistent
|
||||
|
||||
### Income/Operating Projections
|
||||
- [ ] Revenue/top-line builds correctly from drivers or growth rates
|
||||
- [ ] All cost and expense items calculated appropriately
|
||||
- [ ] Subtotals and totals sum correctly
|
||||
- [ ] Margins and ratios are reasonable
|
||||
- [ ] Links to assumptions are correct
|
||||
|
||||
### Balance Sheet (if applicable)
|
||||
- [ ] Assets = Liabilities + Equity (must balance)
|
||||
- [ ] All items link to appropriate schedules or roll-forwards
|
||||
- [ ] Beginning balances = prior period ending balances
|
||||
- [ ] Check row included and shows zero
|
||||
|
||||
### Cash Flow (if applicable)
|
||||
- [ ] Starts with correct income figure
|
||||
- [ ] Non-cash items added/subtracted appropriately
|
||||
- [ ] Working capital changes have correct signs
|
||||
- [ ] Ending Cash = Beginning Cash + Net Cash Flow
|
||||
- [ ] Cash balances are consistent across statements
|
||||
|
||||
### Supporting Schedules
|
||||
- [ ] Roll-forward schedules balance (Beginning + Changes = Ending)
|
||||
- [ ] Schedules link correctly to main statements
|
||||
- [ ] Calculated items use appropriate drivers
|
||||
- [ ] All periods are calculated consistently
|
||||
|
||||
### Debt/Financing Schedules (if applicable)
|
||||
- [ ] Beginning balances tie to sources or prior period
|
||||
- [ ] Interest calculated on appropriate balance (typically beginning)
|
||||
- [ ] Paydowns respect cash availability and priority
|
||||
- [ ] Ending balances cannot be negative
|
||||
- [ ] Totals sum tranches correctly
|
||||
|
||||
### Returns/Output Analysis
|
||||
- [ ] Exit/terminal values calculated correctly
|
||||
- [ ] All relevant adjustments included
|
||||
- [ ] Cash flow signs are correct (negative for investment, positive for proceeds)
|
||||
- [ ] IRR/MOIC formulas reference complete ranges
|
||||
- [ ] Results are reasonable for the scenario
|
||||
|
||||
### Sensitivity Tables (if applicable)
|
||||
- [ ] Grid dimensions are ODD (5×5 or 7×7) — there is a true center cell
|
||||
- [ ] Row and column axis values are symmetric around the base case (`[base-2Δ, base-Δ, base, base+Δ, base+2Δ]`)
|
||||
- [ ] Center cell output equals the model's actual IRR/MOIC — confirms the table is wired correctly
|
||||
- [ ] Center cell is highlighted (medium-blue fill `#BDD7EE`, bold font)
|
||||
- [ ] Row and column headers contain appropriate input values
|
||||
- [ ] Each data cell contains a formula (not hardcoded)
|
||||
- [ ] Each data cell shows a DIFFERENT value
|
||||
- [ ] Values move in expected directions (higher exit multiple → higher IRR, etc.)
|
||||
|
||||
### Formatting
|
||||
- [ ] Hardcoded inputs are blue (0000FF)
|
||||
- [ ] Calculated formulas are black (000000)
|
||||
- [ ] Same-tab links are purple (800080)
|
||||
- [ ] Cross-tab links are green (008000)
|
||||
- [ ] All numbers are right-aligned
|
||||
- [ ] Appropriate number formats applied throughout
|
||||
- [ ] No cells show error values (#REF!, #DIV/0!, #VALUE!, #NAME?)
|
||||
|
||||
### Logical Sanity Checks
|
||||
- [ ] Numbers are reasonable order of magnitude
|
||||
- [ ] Trends make sense (growth, decline, stabilization as expected)
|
||||
- [ ] No obviously wrong values (negative where should be positive, impossible percentages, etc.)
|
||||
- [ ] Key outputs are within reasonable ranges for the type of analysis
|
||||
|
||||
---
|
||||
|
||||
## COMMON ERRORS TO AVOID
|
||||
|
||||
| Error | What Goes Wrong | How to Fix |
|
||||
|-------|-----------------|------------|
|
||||
| Hardcoding calculated values | Model doesn't update when inputs change | Always use formulas that reference source cells |
|
||||
| Wrong cell references after copying | Formulas point to wrong cells | Verify all links, use appropriate $ anchoring |
|
||||
| Circular reference errors | Model can't calculate | Use beginning balances for interest-type calcs, break the circle |
|
||||
| Sections don't balance | Totals that should match don't | Ensure one item is the plug (calculated as difference) |
|
||||
| Negative balances where impossible | Paying/using more than available | Use MAX(0, ...) or MIN functions appropriately |
|
||||
| IRR/return errors | Wrong signs or incomplete ranges | Check cash flow signs and ensure formula covers all periods |
|
||||
| Sensitivity table shows same value | Formula not varying with inputs | Check cell references - need mixed references ($A5, B$4) |
|
||||
| Roll-forwards don't tie | Beginning ≠ prior ending | Verify links between periods |
|
||||
| Inconsistent sign conventions | Additions become subtractions or vice versa | Follow template's convention consistently throughout |
|
||||
|
||||
---
|
||||
|
||||
## WORKING WITH THE USER — SECTION-BY-SECTION CHECKPOINTS
|
||||
|
||||
* **If the template structure is unclear**, ask before proceeding
|
||||
* **If the user's requirements conflict with the template**, confirm their preference
|
||||
* **After completing each major section**, STOP and verify with the user before continuing:
|
||||
- **After Sources & Uses** → show the balanced table, confirm the plug is correct, get sign-off before building the operating model
|
||||
- **After Operating Model / Projections** → show the projected P&L, confirm growth rates and margins look right, get sign-off before the debt schedule
|
||||
- **After Debt Schedule** → show beginning/ending balances and interest, confirm the waterfall logic, get sign-off before returns
|
||||
- **After Returns (IRR/MOIC)** → show the cash flow series and outputs, confirm signs and ranges, get sign-off before sensitivity tables
|
||||
- **After Sensitivity Tables** → show that each cell varies, confirm the base case lands where expected
|
||||
* **If errors are found during verification**, fix them before moving to the next section
|
||||
* **Show your work** - explain key formulas or assumptions when helpful
|
||||
* **Never present a completed model without having checked in at each section** — it's faster to catch a wrong cell reference at the source than to trace it backwards from a broken IRR
|
||||
|
||||
---
|
||||
|
||||
**This skill produces investment banking-quality LBO models by filling templates with correct formulas, proper formatting, and validated calculations. The skill adapts to any template structure while ensuring financial accuracy and professional presentation standards.**
|
||||
|
||||
|
||||
## Data sources — MCP first, web fallback
|
||||
|
||||
Many passages below say "use the S&P Kensho MCP / Daloopa MCP / FactSet MCP". Those are commercial financial-data MCPs from the original Cowork plugin context. In Hermes:
|
||||
|
||||
- **If you have any structured financial-data MCP configured** (Hermes supports MCP — see `native-mcp` skill), prefer it for point-in-time comps, precedent transactions, and filings.
|
||||
- **Otherwise**, fall back to:
|
||||
- `web_search` / `web_extract` against SEC EDGAR (`https://www.sec.gov/cgi-bin/browse-edgar`) for US filings
|
||||
- Company IR pages for press releases, earnings decks
|
||||
- `browser_navigate` for interactive data portals
|
||||
- User-provided data (explicitly ask when the context doesn't have it)
|
||||
- **Never fabricate**. If a multiple, precedent, or filing number can't be sourced, flag the cell as `[UNSOURCED]` and surface it to the user.
|
||||
|
||||
## Attribution
|
||||
|
||||
This skill is adapted from Anthropic's Claude for Financial Services plugin suite (Apache-2.0). The Office-JS / Cowork live-Excel paths have been removed; this version targets headless openpyxl via the `excel-author` skill's conventions. Original: https://github.com/anthropics/financial-services
|
||||
@@ -1,143 +0,0 @@
|
||||
---
|
||||
name: merger-model
|
||||
description: Build accretion/dilution (merger) models in Excel — pro-forma P&L, synergies, financing mix, EPS impact. Pairs with excel-author. Use for M&A pitches, board materials, or deal evaluation.
|
||||
version: 1.0.0
|
||||
author: Anthropic (adapted by Nous Research)
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [finance, m-and-a, merger, accretion-dilution, excel, openpyxl, modeling, investment-banking]
|
||||
related_skills: [excel-author, pptx-author, dcf-model, 3-statement-model]
|
||||
---
|
||||
|
||||
## Environment
|
||||
|
||||
This skill assumes **headless openpyxl** — you are producing an .xlsx file on disk.
|
||||
Follow the `excel-author` skill's conventions for cell coloring, formulas, named ranges, and sensitivity tables.
|
||||
Recalculate before delivery: `python /path/to/excel-author/scripts/recalc.py ./out/model.xlsx`.
|
||||
|
||||
# Merger Model
|
||||
|
||||
Build accretion/dilution analysis for M&A transactions. Models pro forma EPS impact, synergy sensitivities, and purchase price allocation. Use when evaluating a potential acquisition, preparing merger consequences analysis for a pitch, or advising on deal terms.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Gather Inputs
|
||||
|
||||
**Acquirer:**
|
||||
- Company name, current share price, shares outstanding
|
||||
- LTM and NTM EPS (GAAP and adjusted)
|
||||
- P/E multiple
|
||||
- Pre-tax cost of debt, tax rate
|
||||
- Cash on balance sheet, existing debt
|
||||
|
||||
**Target:**
|
||||
- Company name, current share price, shares outstanding (if public)
|
||||
- LTM and NTM EPS or net income
|
||||
- Enterprise value or equity value
|
||||
|
||||
**Deal Terms:**
|
||||
- Offer price per share (or premium to current)
|
||||
- Consideration mix: % cash vs. % stock
|
||||
- New debt raised to fund cash portion
|
||||
- Expected synergies (revenue and cost) and phase-in timeline
|
||||
- Transaction fees and financing costs
|
||||
- Expected close date
|
||||
|
||||
### Step 2: Purchase Price Analysis
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Offer price per share | |
|
||||
| Premium to current | |
|
||||
| Equity value | |
|
||||
| Plus: net debt assumed | |
|
||||
| Enterprise value | |
|
||||
| EV / EBITDA implied | |
|
||||
| P/E implied | |
|
||||
|
||||
### Step 3: Sources & Uses
|
||||
|
||||
| Sources | $ | Uses | $ |
|
||||
|---------|---|------|---|
|
||||
| New debt | | Equity purchase price | |
|
||||
| Cash on hand | | Refinance target debt | |
|
||||
| New equity issued | | Transaction fees | |
|
||||
| | | Financing fees | |
|
||||
| **Total** | | **Total** | |
|
||||
|
||||
### Step 4: Pro Forma EPS (Accretion / Dilution)
|
||||
|
||||
Calculate year-by-year (Year 1-3):
|
||||
|
||||
| | Standalone | Pro Forma | Accretion/(Dilution) |
|
||||
|---|-----------|-----------|---------------------|
|
||||
| Acquirer net income | | | |
|
||||
| Target net income | | | |
|
||||
| Synergies (after tax) | | | |
|
||||
| Foregone interest on cash (after tax) | | | |
|
||||
| New debt interest (after tax) | | | |
|
||||
| Intangible amortization (after tax) | | | |
|
||||
| Pro forma net income | | | |
|
||||
| Pro forma shares | | | |
|
||||
| **Pro forma EPS** | | | |
|
||||
| **Accretion / (Dilution) %** | | | |
|
||||
|
||||
### Step 5: Sensitivity Analysis
|
||||
|
||||
**Accretion/Dilution vs. Synergies and Offer Premium:**
|
||||
|
||||
| | $0M syn | $25M syn | $50M syn | $75M syn | $100M syn |
|
||||
|---|---------|----------|----------|----------|-----------|
|
||||
| 15% premium | | | | | |
|
||||
| 20% premium | | | | | |
|
||||
| 25% premium | | | | | |
|
||||
| 30% premium | | | | | |
|
||||
|
||||
**Accretion/Dilution vs. Cash/Stock Mix:**
|
||||
|
||||
| | 100% cash | 75/25 | 50/50 | 25/75 | 100% stock |
|
||||
|---|-----------|-------|-------|-------|------------|
|
||||
| Year 1 | | | | | |
|
||||
| Year 2 | | | | | |
|
||||
|
||||
### Step 6: Breakeven Synergies
|
||||
|
||||
Calculate the minimum synergies needed for the deal to be EPS-neutral in Year 1.
|
||||
|
||||
### Step 7: Output
|
||||
|
||||
- Excel workbook with:
|
||||
- Assumptions tab
|
||||
- Sources & uses
|
||||
- Pro forma income statement
|
||||
- Accretion/dilution summary
|
||||
- Sensitivity tables
|
||||
- Breakeven analysis
|
||||
- One-page merger consequences summary for pitch book
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Always show both GAAP and adjusted (cash) EPS where relevant
|
||||
- Stock deals: use acquirer's current price for exchange ratio, note dilution from new shares
|
||||
- Include purchase price allocation — goodwill and intangible amortization matter for GAAP EPS
|
||||
- Synergy phase-in is critical — Year 1 is often only 25-50% of run-rate synergies
|
||||
- Don't forget foregone interest income on cash used and new interest expense on debt raised
|
||||
- Tax rate on synergies and interest adjustments should match the acquirer's marginal rate
|
||||
|
||||
|
||||
## Data sources — MCP first, web fallback
|
||||
|
||||
Many passages below say "use the S&P Kensho MCP / Daloopa MCP / FactSet MCP". Those are commercial financial-data MCPs from the original Cowork plugin context. In Hermes:
|
||||
|
||||
- **If you have any structured financial-data MCP configured** (Hermes supports MCP — see `native-mcp` skill), prefer it for point-in-time comps, precedent transactions, and filings.
|
||||
- **Otherwise**, fall back to:
|
||||
- `web_search` / `web_extract` against SEC EDGAR (`https://www.sec.gov/cgi-bin/browse-edgar`) for US filings
|
||||
- Company IR pages for press releases, earnings decks
|
||||
- `browser_navigate` for interactive data portals
|
||||
- User-provided data (explicitly ask when the context doesn't have it)
|
||||
- **Never fabricate**. If a multiple, precedent, or filing number can't be sourced, flag the cell as `[UNSOURCED]` and surface it to the user.
|
||||
|
||||
## Attribution
|
||||
|
||||
This skill is adapted from Anthropic's Claude for Financial Services plugin suite (Apache-2.0). The Office-JS / Cowork live-Excel paths have been removed; this version targets headless openpyxl via the `excel-author` skill's conventions. Original: https://github.com/anthropics/financial-services
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user