diff --git a/.gitea/scripts/ai_review.py b/.gitea/scripts/ai_review.py
new file mode 100644
index 000000000000..74b9543967ed
--- /dev/null
+++ b/.gitea/scripts/ai_review.py
@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+"""AI peer review script.
+
+Uses Qwen 3.5 397B MoE on aibeast (10.15.0.166:8000) via the OpenAI-
+compatible vLLM endpoint. The model has a 524K context window, so even
+large diffs fit without truncation in most cases.
+
+Usage:
+ Normal: python ai_review.py
+ Dry run: python ai_review.py --dry-run
+"""
+import os
+import re
+import subprocess
+import sys
+
+import httpx
+from openai import OpenAI
+
+DRY_RUN = "--dry-run" in sys.argv
+
+base_sha = os.environ["BASE_SHA"]
+head_sha = os.environ["HEAD_SHA"]
+
+# ── Gather diff ───────────────────────────────────────────────────────────────
+
+diff = subprocess.check_output(["git", "diff", base_sha, head_sha], text=True)
+changed = subprocess.check_output(
+ ["git", "diff", "--name-only", base_sha, head_sha], text=True
+).strip()
+
+MAX_DIFF = 200_000 # 200K chars — Qwen 3.5 has 524K context
+truncated = len(diff) > MAX_DIFF
+if truncated:
+ diff = diff[:MAX_DIFF] + "\n\n[diff truncated at 200K chars]"
+
+if not diff.strip():
+ print("Empty diff — nothing to review.")
+ sys.exit(0)
+
+# ── Gather extra context for key changed files ────────────────────────────────
+
+# Read the full content of up to 5 key changed files so the reviewer can
+# see imports, callers, and surrounding code — not just the diff hunks.
+context_files = ""
+key_extensions = {".py", ".yml", ".yaml", ".sh", ".toml"}
+files_added = 0
+for fname in changed.split("\n"):
+ if files_added >= 5:
+ break
+ fname = fname.strip()
+ if not fname:
+ continue
+ if not any(fname.endswith(ext) for ext in key_extensions):
+ continue
+ if not os.path.isfile(fname):
+ continue
+ try:
+ content = open(fname).read()
+ if len(content) > 20_000:
+ content = content[:20_000] + "\n[file truncated at 20K chars]"
+ context_files += f"\n\n--- {fname} (full file) ---\n{content}"
+ files_added += 1
+ except Exception:
+ pass
+
+PROMPT = f"""You are peer-reviewing code changes. Be thorough and constructively critical.
+
+Changed files:
+{changed}
+
+Diff:
+{diff}
+{context_files}
+
+Structure your review as:
+
+## Summary
+Brief overview of what changed and why.
+
+## Issues
+- 🚨 CRITICAL: security holes, data loss, broken logic, race conditions
+- ⚠️ WARNING: bugs, bad patterns, missing error handling, test gaps
+- 💡 SUGGESTION: improvements, simplifications, naming
+
+If there are no issues in a category, omit it.
+
+## Verdict
+One of: APPROVED | NEEDS_WORK | CRITICAL_ISSUES
+"""
+
+# ── Call Qwen 3.5 397B MoE on aibeast ────────────────────────────────────────
+
+print(f"Reviewing {len(changed.split(chr(10)))} files "
+ f"({len(diff):,} chars diff) with Qwen 3.5 397B MoE...")
+
+client = OpenAI(
+ api_key=os.environ.get("OPENAI_API_KEY", "not-needed"),
+ base_url=os.environ.get("OPENAI_BASE_URL", "http://10.15.0.166:8000/v1"),
+)
+
+try:
+ response = client.chat.completions.create(
+ model="qwen35-397b",
+ messages=[
+ {
+ "role": "system",
+ "content": (
+ "You are an expert code reviewer. Be thorough, specific, "
+ "and cite file:line when pointing out issues. Focus on "
+ "correctness, security, and maintainability."
+ ),
+ },
+ {"role": "user", "content": PROMPT},
+ ],
+ max_tokens=8192,
+ temperature=0.7,
+ # Qwen 3.5 MoE supports extended thinking
+ extra_body={
+ "chat_template_kwargs": {
+ "enable_thinking": True,
+ },
+ },
+ )
+ review = response.choices[0].message.content or ""
+
+ # Strip thinking blocks if the model returned them
+ review = re.sub(r".*?", "", review, flags=re.DOTALL).strip()
+
+except Exception as e:
+ review = f"API Error: {e}"
+
+if not review.strip():
+ print("Reviewer produced no output — skipping comment.")
+ sys.exit(0)
+
+# ── Format comment ────────────────────────────────────────────────────────────
+
+body = f"## 🤖 Qwen 3.5 Peer Review\n\n{review}"
+if truncated:
+ body += "\n\n> ⚠️ Diff exceeded 200K chars and was truncated."
+
+# Add model info footer
+usage = getattr(response, "usage", None)
+if usage:
+ body += (
+ f"\n\nModel: Qwen 3.5 397B MoE on aibeast | "
+ f"Tokens: {usage.prompt_tokens:,} in / {usage.completion_tokens:,} out"
+ )
+
+print(body)
+
+if DRY_RUN:
+ print("\n[dry-run] Gitea comment not posted.")
+ sys.exit(0)
+
+# ── Post comment ──────────────────────────────────────────────────────────────
+
+r = httpx.post(
+ f"{os.environ['GITEA_API']}/repos/{os.environ['REPO']}/issues"
+ f"/{os.environ['PR_NUMBER']}/comments",
+ headers={
+ "Authorization": f"token {os.environ['GITEA_TOKEN']}",
+ "Content-Type": "application/json",
+ },
+ json={"body": body},
+ timeout=120,
+)
+r.raise_for_status()
+print(f"\nComment posted (id={r.json()['id']})")
+
+# ── Verdict ───────────────────────────────────────────────────────────────────
+
+if "CRITICAL_ISSUES" in review or "🚨 CRITICAL:" in review:
+ print("Critical issues flagged — failing check.")
+ sys.exit(1)
diff --git a/.gitea/workflows/ai-review.yml b/.gitea/workflows/ai-review.yml
new file mode 100644
index 000000000000..eb010bf6f317
--- /dev/null
+++ b/.gitea/workflows/ai-review.yml
@@ -0,0 +1,36 @@
+name: AI Peer Review
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+concurrency:
+ group: ai-review-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ review:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+
+ - name: Run AI review
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ REPO: ${{ github.repository }}
+ GITEA_API: http://10.15.0.6:3300/api/v1
+ GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+ # Qwen 3.5 397B MoE on aibeast via OpenAI-compatible vLLM
+ OPENAI_API_KEY: not-needed
+ OPENAI_BASE_URL: http://10.15.0.166:8000/v1
+ run: |
+ uv run --with openai --with httpx \
+ python .gitea/scripts/ai_review.py
diff --git a/.gitea/workflows/build-push.yml b/.gitea/workflows/build-push.yml
new file mode 100644
index 000000000000..42bdbc6e5df0
--- /dev/null
+++ b/.gitea/workflows/build-push.yml
@@ -0,0 +1,35 @@
+name: Build and Push Image
+
+on:
+ push:
+ branches: [main]
+
+env:
+ REGISTRY: 10.15.0.6:3300
+ IMAGE: 10.15.0.6:3300/angelos/hermes-agent
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Build
+ run: |
+ podman-remote build --network=host \
+ --build-arg GIT_COMMIT=${{ github.sha }} \
+ --build-arg GIT_REF=${{ github.ref_name }} \
+ --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
+ --build-arg IMAGE_SOURCE=$IMAGE \
+ -t $IMAGE:latest \
+ -t $IMAGE:${{ github.sha }} \
+ .
+
+ - name: Push
+ run: |
+ podman-remote login --tls-verify=false \
+ -u "${{ secrets.REGISTRY_USER }}" -p "${{ secrets.REGISTRY_TOKEN }}" \
+ $REGISTRY
+ podman-remote push --tls-verify=false $IMAGE:latest
+ podman-remote push --tls-verify=false $IMAGE:${{ github.sha }}
diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml
new file mode 100644
index 000000000000..3a26ecba738f
--- /dev/null
+++ b/.gitea/workflows/tests.yml
@@ -0,0 +1,40 @@
+name: Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+concurrency:
+ group: tests-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+
+ - name: Set up Python 3.11
+ run: uv python install 3.11
+
+ - name: Install dependencies
+ run: |
+ uv venv .venv --python 3.11
+ source .venv/bin/activate
+ uv pip install -e ".[all,dev]"
+
+ - name: Run tests
+ run: |
+ source .venv/bin/activate
+ python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short -n auto
+ env:
+ OPENROUTER_API_KEY: ""
+ OPENAI_API_KEY: ""
+ NOUS_API_KEY: ""
diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
deleted file mode 100644
index 3c471f376d0c..000000000000
--- a/.github/workflows/deploy-site.yml
+++ /dev/null
@@ -1,74 +0,0 @@
-name: Deploy Site
-
-on:
- push:
- branches: [main]
- paths:
- - 'website/**'
- - 'landingpage/**'
- - 'skills/**'
- - 'optional-skills/**'
- - '.github/workflows/deploy-site.yml'
- workflow_dispatch:
-
-permissions:
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: false
-
-jobs:
- build-and-deploy:
- # Only run on the upstream repository, not on forks
- if: github.repository == 'NousResearch/hermes-agent'
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deploy.outputs.page_url }}
- steps:
- - uses: actions/checkout@v4
-
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
- cache-dependency-path: website/package-lock.json
-
- - uses: actions/setup-python@v5
- with:
- python-version: '3.11'
-
- - name: Install PyYAML for skill extraction
- run: pip install pyyaml
-
- - name: Extract skill metadata for dashboard
- run: python3 website/scripts/extract-skills.py
-
- - name: Install dependencies
- run: npm ci
- working-directory: website
-
- - name: Build Docusaurus
- run: npm run build
- working-directory: website
-
- - name: Stage deployment
- run: |
- mkdir -p _site/docs
- # Landing page at root
- cp -r landingpage/* _site/
- # Docusaurus at /docs/
- cp -r website/build/* _site/docs/
- # CNAME so GitHub Pages keeps the custom domain between deploys
- echo "hermes-agent.nousresearch.com" > _site/CNAME
-
- - name: Upload artifact
- uses: actions/upload-pages-artifact@v3
- with:
- path: _site
-
- - name: Deploy to GitHub Pages
- id: deploy
- uses: actions/deploy-pages@v4
diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml
deleted file mode 100644
index b94e1dda4333..000000000000
--- a/.github/workflows/supply-chain-audit.yml
+++ /dev/null
@@ -1,192 +0,0 @@
-name: Supply Chain Audit
-
-on:
- pull_request:
- types: [opened, synchronize, reopened]
-
-permissions:
- pull-requests: write
- contents: read
-
-jobs:
- scan:
- name: Scan PR for supply chain risks
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Scan diff for suspicious patterns
- id: scan
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -euo pipefail
-
- BASE="${{ github.event.pull_request.base.sha }}"
- HEAD="${{ github.event.pull_request.head.sha }}"
-
- # Get the full diff (added lines only)
- DIFF=$(git diff "$BASE".."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
-
- FINDINGS=""
- CRITICAL=false
-
- # --- .pth files (auto-execute on Python startup) ---
- PTH_FILES=$(git diff --name-only "$BASE".."$HEAD" | grep '\.pth$' || true)
- if [ -n "$PTH_FILES" ]; then
- CRITICAL=true
- FINDINGS="${FINDINGS}
- ### 🚨 CRITICAL: .pth file added or modified
- Python \`.pth\` files in \`site-packages/\` execute automatically when the interpreter starts — no import required. This is the exact mechanism used in the [litellm supply chain attack](https://github.com/BerriAI/litellm/issues/24512).
-
- **Files:**
- \`\`\`
- ${PTH_FILES}
- \`\`\`
- "
- fi
-
- # --- base64 + exec/eval combo (the litellm attack pattern) ---
- B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
- if [ -n "$B64_EXEC_HITS" ]; then
- CRITICAL=true
- FINDINGS="${FINDINGS}
- ### 🚨 CRITICAL: base64 decode + exec/eval combo
- This is the exact pattern used in the [litellm supply chain attack](https://github.com/BerriAI/litellm/issues/24512) — base64-decoded strings passed to exec/eval to hide credential-stealing payloads.
-
- **Matches:**
- \`\`\`
- ${B64_EXEC_HITS}
- \`\`\`
- "
- fi
-
- # --- base64 decode/encode (alone — legitimate uses exist) ---
- B64_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|b64encode|decodebytes|encodebytes|urlsafe_b64decode)|atob\(|btoa\(|Buffer\.from\(.*base64' | head -20 || true)
- if [ -n "$B64_HITS" ]; then
- FINDINGS="${FINDINGS}
- ### ⚠️ WARNING: base64 encoding/decoding detected
- Base64 has legitimate uses (images, JWT, etc.) but is also commonly used to obfuscate malicious payloads. Verify the usage is appropriate.
-
- **Matches (first 20):**
- \`\`\`
- ${B64_HITS}
- \`\`\`
- "
- fi
-
- # --- exec/eval with string arguments ---
- EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E '(exec|eval)\s*\(' | grep -v '^\+\s*#' | grep -v 'test_\|mock\|assert\|# ' | head -20 || true)
- if [ -n "$EXEC_HITS" ]; then
- FINDINGS="${FINDINGS}
- ### ⚠️ WARNING: exec() or eval() usage
- Dynamic code execution can hide malicious behavior, especially when combined with base64 or network fetches.
-
- **Matches (first 20):**
- \`\`\`
- ${EXEC_HITS}
- \`\`\`
- "
- fi
-
- # --- subprocess with encoded/obfuscated commands ---
- PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|decode|encode|\\x|chr\(' | head -10 || true)
- if [ -n "$PROC_HITS" ]; then
- CRITICAL=true
- FINDINGS="${FINDINGS}
- ### 🚨 CRITICAL: subprocess with encoded/obfuscated command
- Subprocess calls with encoded arguments are a strong indicator of payload execution.
-
- **Matches:**
- \`\`\`
- ${PROC_HITS}
- \`\`\`
- "
- fi
-
- # --- Network calls to non-standard domains ---
- EXFIL_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'requests\.(post|put)\(|httpx\.(post|put)\(|urllib\.request\.urlopen' | grep -v '^\+\s*#' | grep -v 'test_\|mock\|assert' | head -10 || true)
- if [ -n "$EXFIL_HITS" ]; then
- FINDINGS="${FINDINGS}
- ### ⚠️ WARNING: Outbound network calls (POST/PUT)
- Outbound POST/PUT requests in new code could be data exfiltration. Verify the destination URLs are legitimate.
-
- **Matches (first 10):**
- \`\`\`
- ${EXFIL_HITS}
- \`\`\`
- "
- fi
-
- # --- setup.py / setup.cfg install hooks ---
- SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(setup\.py|setup\.cfg|__init__\.pth|sitecustomize\.py|usercustomize\.py)$' || true)
- if [ -n "$SETUP_HITS" ]; then
- FINDINGS="${FINDINGS}
- ### ⚠️ WARNING: Install hook files modified
- These files can execute code during package installation or interpreter startup.
-
- **Files:**
- \`\`\`
- ${SETUP_HITS}
- \`\`\`
- "
- fi
-
- # --- Compile/marshal/pickle (code object injection) ---
- MARSHAL_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'marshal\.loads|pickle\.loads|compile\(' | grep -v '^\+\s*#' | grep -v 'test_\|re\.compile\|ast\.compile' | head -10 || true)
- if [ -n "$MARSHAL_HITS" ]; then
- FINDINGS="${FINDINGS}
- ### ⚠️ WARNING: marshal/pickle/compile usage
- These can deserialize or construct executable code objects.
-
- **Matches:**
- \`\`\`
- ${MARSHAL_HITS}
- \`\`\`
- "
- fi
-
- # --- Output results ---
- if [ -n "$FINDINGS" ]; then
- echo "found=true" >> "$GITHUB_OUTPUT"
- if [ "$CRITICAL" = true ]; then
- echo "critical=true" >> "$GITHUB_OUTPUT"
- else
- echo "critical=false" >> "$GITHUB_OUTPUT"
- fi
- # Write findings to a file (multiline env vars are fragile)
- echo "$FINDINGS" > /tmp/findings.md
- else
- echo "found=false" >> "$GITHUB_OUTPUT"
- echo "critical=false" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Post warning comment
- if: steps.scan.outputs.found == 'true'
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- SEVERITY="⚠️ Supply Chain Risk Detected"
- if [ "${{ steps.scan.outputs.critical }}" = "true" ]; then
- SEVERITY="🚨 CRITICAL Supply Chain Risk Detected"
- fi
-
- BODY="## ${SEVERITY}
-
- This PR contains patterns commonly associated with supply chain attacks. This does **not** mean the PR is malicious — but these patterns require careful human review before merging.
-
- $(cat /tmp/findings.md)
-
- ---
- *Automated scan triggered by [supply-chain-audit](/.github/workflows/supply-chain-audit.yml). If this is a false positive, a maintainer can approve after manual review.*"
-
- gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY"
-
- - name: Fail on critical findings
- if: steps.scan.outputs.critical == 'true'
- run: |
- echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
- exit 1
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
deleted file mode 100644
index 1e45193b8d07..000000000000
--- a/.github/workflows/tests.yml
+++ /dev/null
@@ -1,73 +0,0 @@
-name: Tests
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
-
-# Cancel in-progress runs for the same PR/branch
-concurrency:
- group: tests-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- test:
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Install system dependencies
- run: sudo apt-get update && sudo apt-get install -y ripgrep
-
- - name: Install uv
- uses: astral-sh/setup-uv@v5
-
- - name: Set up Python 3.11
- run: uv python install 3.11
-
- - name: Install dependencies
- run: |
- uv venv .venv --python 3.11
- source .venv/bin/activate
- uv pip install -e ".[all,dev]"
-
- - name: Run tests
- run: |
- source .venv/bin/activate
- python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short -n auto
- env:
- # Ensure tests don't accidentally call real APIs
- OPENROUTER_API_KEY: ""
- OPENAI_API_KEY: ""
- NOUS_API_KEY: ""
-
- e2e:
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Install uv
- uses: astral-sh/setup-uv@v5
-
- - name: Set up Python 3.11
- run: uv python install 3.11
-
- - name: Install dependencies
- run: |
- uv venv .venv --python 3.11
- source .venv/bin/activate
- uv pip install -e ".[all,dev]"
-
- - name: Run e2e tests
- run: |
- source .venv/bin/activate
- python -m pytest tests/e2e/ -v --tb=short
- env:
- OPENROUTER_API_KEY: ""
- OPENAI_API_KEY: ""
- NOUS_API_KEY: ""
diff --git a/Containerfile b/Containerfile
new file mode 100644
index 000000000000..d3c6935b9308
--- /dev/null
+++ b/Containerfile
@@ -0,0 +1,72 @@
+FROM debian:13.4
+
+
+# Disable Python stdout buffering to ensure logs are printed immediately
+ENV PYTHONUNBUFFERED=1
+
+# ── Build metadata ───────────────────────────────────────────────────────────
+
+ARG GIT_COMMIT=unknown
+ARG GIT_REF=unknown
+ARG BUILD_DATE=unknown
+ARG IMAGE_SOURCE=http://10.15.0.6:3300/angelos/hermes-agent
+
+LABEL org.opencontainers.image.title="hermes-agent" \
+ org.opencontainers.image.description="Self-improving AI agent — creates skills from experience" \
+ org.opencontainers.image.source="${IMAGE_SOURCE}" \
+ org.opencontainers.image.base.name="debian:13.4" \
+ org.opencontainers.image.revision="${GIT_COMMIT}" \
+ org.opencontainers.image.ref.name="${GIT_REF}" \
+ org.opencontainers.image.created="${BUILD_DATE}"
+
+RUN printf 'IMAGE_TITLE=hermes-agent\nIMAGE_SOURCE=%s\nGIT_COMMIT=%s\nGIT_REF=%s\nBUILD_DATE=%s\n' \
+ "${IMAGE_SOURCE}" "${GIT_COMMIT}" "${GIT_REF}" "${BUILD_DATE}" \
+ > /etc/hermes-release
+
+# ── System dependencies ──────────────────────────────────────────────────────
+
+# Use apt-cacher-ng on the LAN for faster package downloads.
+# The proxy is only used during build (not baked into the image).
+ARG APT_PROXY=http://10.15.0.6:3142
+RUN echo "Acquire::HTTP::Proxy \"${APT_PROXY}\";" > /etc/apt/apt.conf.d/01proxy && \
+ apt-get update && \
+ apt-get upgrade -y --target-release=stable-security && \
+ apt-get install -y --no-install-recommends \
+ build-essential nodejs npm python3 python3-pip ripgrep ffmpeg gcc \
+ python3-dev libffi-dev podman-remote curl && \
+ rm -rf /var/lib/apt/lists/* && \
+ rm -f /etc/apt/apt.conf.d/01proxy
+
+# ── Install uv (much faster than pip for dependency resolution) ──────────────
+
+RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
+ ln -sf /root/.local/bin/uv /usr/local/bin/uv
+
+# ── Application source (includes oikos patches) ─────────────────────────────
+
+COPY . /opt/hermes
+WORKDIR /opt/hermes
+
+RUN uv pip install --system --break-system-packages --no-cache -e ".[all]" && \
+ npm install --prefer-offline --no-audit && \
+ npx playwright install --with-deps chromium --only-shell && \
+ cd /opt/hermes/scripts/whatsapp-bridge && \
+ npm install --prefer-offline --no-audit && \
+ npm cache clean --force
+
+RUN chmod +x /opt/hermes/docker/entrypoint.sh \
+ /opt/hermes/docker/wait-for-honcho.sh
+
+# ── Podman-remote shim ───────────────────────────────────────────────────────
+# Bridges DOCKER_HOST env var to podman-remote --url flag
+
+RUN printf '#!/bin/sh\nprintf "%%s podman-remote-shim: %%s\n" "$(date -u +%%Y-%%m-%%dT%%H:%%M:%%SZ)" "$*" >> /opt/data/logs/shim.log 2>/dev/null\nexec /usr/bin/podman-remote --url "${DOCKER_HOST:-unix:///var/run/docker.sock}" "$@"\n' \
+ > /usr/local/bin/docker && chmod +x /usr/local/bin/docker
+
+# ── Runtime ──────────────────────────────────────────────────────────────────
+
+ENV HERMES_HOME=/opt/data
+VOLUME ["/opt/data"]
+ENTRYPOINT ["/opt/hermes/docker/wait-for-honcho.sh"]
+
+# ffmpeg confirmed present for TTS voice bubble conversion
diff --git a/acp_adapter/server.py b/acp_adapter/server.py
index 11064a1e4e3e..b7aef2a8e3c2 100644
--- a/acp_adapter/server.py
+++ b/acp_adapter/server.py
@@ -192,6 +192,7 @@ async def _register_session_mcp_servers(
enabled_toolsets=enabled_toolsets,
disabled_toolsets=disabled_toolsets,
quiet_mode=True,
+ platform="acp",
)
state.agent.valid_tool_names = {
tool["function"]["name"] for tool in state.agent.tools or []
@@ -585,7 +586,7 @@ def _cmd_tools(self, args: str, state: SessionState) -> str:
try:
from model_tools import get_tool_definitions
toolsets = getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
- tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
+ tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True, platform="acp")
if not tools:
return "No tools available."
lines = [f"Available tools ({len(tools)}):"]
diff --git a/agent/model_metadata.py b/agent/model_metadata.py
index 791f778c2263..321dce4c7484 100644
--- a/agent/model_metadata.py
+++ b/agent/model_metadata.py
@@ -223,8 +223,28 @@ def _is_known_provider_base_url(base_url: str) -> bool:
return _infer_provider_from_url(base_url) is not None
+def _load_local_endpoints() -> set:
+ """Load additional local endpoint hostnames from config.
+
+ Reads ``model.local_endpoints`` from config.yaml — a list of hostnames
+ that should be treated as local even though they aren't IPs or localhost.
+ Typical use: Docker/Podman DNS names for LiteLLM or other local proxies.
+ """
+ try:
+ from hermes_cli.config import load_config
+ cfg = load_config()
+ model_cfg = cfg.get("model", {})
+ if isinstance(model_cfg, dict):
+ entries = model_cfg.get("local_endpoints", [])
+ if isinstance(entries, list):
+ return {str(e).strip().lower() for e in entries if e}
+ except Exception:
+ pass
+ return set()
+
+
def is_local_endpoint(base_url: str) -> bool:
- """Return True if base_url points to a local machine (localhost / RFC-1918 / WSL)."""
+ """Return True if base_url points to a local machine (localhost / RFC-1918 / WSL / configured hostnames)."""
normalized = _normalize_base_url(base_url)
if not normalized:
return False
@@ -236,13 +256,29 @@ def is_local_endpoint(base_url: str) -> bool:
return False
if host in _LOCAL_HOSTS:
return True
- # RFC-1918 private ranges and link-local
+ # Unqualified hostnames (no dots) are local — Docker/Podman DNS, mDNS,
+ # /etc/hosts entries. A hostname like "hermes-litellm" or "ollama" is
+ # always on the local network.
+ if "." not in host:
+ return True
+ # Check configured local endpoints (e.g. custom DNS names)
+ if host.lower() in _load_local_endpoints():
+ return True
+ # Try resolving hostname to IP and check if it's private
import ipaddress
try:
addr = ipaddress.ip_address(host)
return addr.is_private or addr.is_loopback or addr.is_link_local
except ValueError:
pass
+ # DNS resolution fallback — resolve hostname and check if IP is private
+ import socket
+ try:
+ resolved_ip = socket.gethostbyname(host)
+ addr = ipaddress.ip_address(resolved_ip)
+ return addr.is_private or addr.is_loopback or addr.is_link_local
+ except (socket.gaierror, ValueError):
+ pass
# Bare IP that looks like a private range (e.g. 172.26.x.x for WSL)
parts = host.split(".")
if len(parts) == 4:
@@ -456,6 +492,11 @@ def fetch_endpoint_model_metadata(
if alternate and alternate not in candidates:
candidates.append(alternate)
+ # Fall back to LITELLM_KEY env var (used when called from cost-estimation
+ # paths that don't have the configured api_key in scope)
+ if not api_key:
+ import os as _os
+ api_key = _os.environ.get("LITELLM_KEY", "")
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
last_error: Optional[Exception] = None
diff --git a/agent/smart_model_routing.py b/agent/smart_model_routing.py
index 8a62e98fc3e5..f6cc4d7da7b5 100644
--- a/agent/smart_model_routing.py
+++ b/agent/smart_model_routing.py
@@ -59,11 +59,18 @@ def _coerce_int(value: Any, default: int) -> int:
return default
-def choose_cheap_model_route(user_message: str, routing_config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+def choose_cheap_model_route(
+ user_message: str,
+ routing_config: Optional[Dict[str, Any]],
+ context_tokens: int = 0,
+) -> Optional[Dict[str, Any]]:
"""Return the configured cheap-model route when a message looks simple.
Conservative by design: if the message has signs of code/tool/debugging/
- long-form work, keep the primary model.
+ long-form work, keep the primary model. Also skips routing when the
+ conversation context is too large for the cheap model to be fast —
+ the whole point of routing is speed, and a slow prefill on a small
+ model defeats the purpose.
"""
cfg = routing_config or {}
if not _coerce_bool(cfg.get("enabled"), False):
@@ -77,6 +84,13 @@ def choose_cheap_model_route(user_message: str, routing_config: Optional[Dict[st
if not provider or not model:
return None
+ # Context size guard: the cheap model is meant to be fast. If the
+ # conversation context is too large, either trim it or skip the route.
+ max_context = _coerce_int(cfg.get("max_context_tokens"), 0)
+ trim_context = _coerce_bool(cfg.get("trim_context"), False)
+ if max_context > 0 and context_tokens > max_context and not trim_context:
+ return None
+
text = (user_message or "").strip()
if not text:
return None
@@ -104,15 +118,25 @@ def choose_cheap_model_route(user_message: str, routing_config: Optional[Dict[st
route["provider"] = provider
route["model"] = model
route["routing_reason"] = "simple_turn"
+ # When trim_context is enabled and context exceeds the cap, signal the
+ # caller to trim conversation history to max_context_tokens before
+ # sending to the cheap model.
+ if trim_context and max_context > 0 and context_tokens > max_context:
+ route["trim_to_tokens"] = max_context
return route
-def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any]], primary: Dict[str, Any]) -> Dict[str, Any]:
+def resolve_turn_route(
+ user_message: str,
+ routing_config: Optional[Dict[str, Any]],
+ primary: Dict[str, Any],
+ context_tokens: int = 0,
+) -> Dict[str, Any]:
"""Resolve the effective model/runtime for one turn.
Returns a dict with model/runtime/signature/label fields.
"""
- route = choose_cheap_model_route(user_message, routing_config)
+ route = choose_cheap_model_route(user_message, routing_config, context_tokens=context_tokens)
if not route:
return {
"model": primary.get("model"),
@@ -172,7 +196,7 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any
),
}
- return {
+ result = {
"model": route.get("model"),
"runtime": {
"api_key": runtime.get("api_key"),
@@ -192,3 +216,6 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any
tuple(runtime.get("args") or ()),
),
}
+ if route.get("trim_to_tokens"):
+ result["trim_to_tokens"] = route["trim_to_tokens"]
+ return result
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index 346e6e851ff7..90c933849ae9 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -172,6 +172,29 @@ terminal:
# docker_forward_env:
# - "GITHUB_TOKEN"
# - "NPM_TOKEN"
+# #
+# # Inject env vars from files on the host, re-read on every `docker exec`.
+# # Use this for ROTATING credentials that a sidecar process keeps fresh on
+# # disk: the sandbox container picks up the new value on the next tool call,
+# # no respawn required. The canonical case is BW_SESSION written by a
+# # Bitwarden-unlock sidecar, but the same pattern works for Vault tokens,
+# # OIDC token mints, AWS credential helpers, refreshable OAuth access
+# # tokens, etc.
+# #
+# # Format: "VAR_NAME:/host/path/to/file". The path is canonicalized at
+# # startup (symlinks resolved once) and validated against an allowlist of
+# # safe parent directories: /run/hermes-creds, /run/secrets, $XDG_RUNTIME_DIR,
+# # and $HERMES_HOME by default. Override via the
+# # TERMINAL_DOCKER_ENV_FILES_ALLOWED_DIRS env var (colon-separated, empty
+# # disables the check). Files are capped at 64 KiB and a single trailing
+# # newline is trimmed (`echo > file` shape). Missing files are non-fatal:
+# # the variable is just skipped on that exec.
+# #
+# # Security: values from these files are NOT baked into the long-lived
+# # container's environment (so `docker inspect` doesn't reveal them) —
+# # they are scoped to the lifetime of each `docker exec` process only.
+# docker_env_files:
+# - "BW_SESSION:/run/hermes-creds/bw-session"
# -----------------------------------------------------------------------------
# OPTION 4: Singularity/Apptainer container
@@ -428,6 +451,13 @@ session_reset:
# explicitly want one shared "room brain" per group/channel.
group_sessions_per_user: true
+# When true, a graceful gateway shutdown writes a small restart ledger for
+# in-flight sessions and the next gateway boot injects a hidden continuation
+# turn so interrupted work can resume from the last persisted context.
+# Sessions that were waiting on dangerous-command approval are resumed with a
+# note that approval state was lost and must be requested again if needed.
+resume_inflight_sessions_on_restart: false
+
# ─────────────────────────────────────────────────────────────────────────────
# Gateway Streaming
# ─────────────────────────────────────────────────────────────────────────────
@@ -725,9 +755,10 @@ code_execution:
# Subagent Delegation
# =============================================================================
# The delegate_task tool spawns child agents with isolated context.
-# Supports single tasks and batch mode (up to 3 parallel).
+# Supports single tasks and batch mode (parallel, limit via max_concurrent_children).
delegation:
max_iterations: 50 # Max tool-calling turns per child (default: 50)
+ max_concurrent_children: 3 # Max parallel subagents in a batch delegate_task (default: 3)
default_toolsets: ["terminal", "file", "web"] # Default toolsets for subagents
# model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent)
# provider: "openrouter" # Override provider for subagents (empty = inherit parent)
diff --git a/cli.py b/cli.py
index b93fde77a590..42a0125580b8 100644
--- a/cli.py
+++ b/cli.py
@@ -217,6 +217,7 @@ def load_cli_config() -> Dict[str, Any]:
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"docker_volumes": [], # host:container volume mounts for Docker backend
"docker_mount_cwd_to_workspace": False, # explicit opt-in only; default off for sandbox isolation
+ "enable_gateway_local": False,
},
"browser": {
"inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min
@@ -294,6 +295,8 @@ def load_cli_config() -> Dict[str, Any]:
"provider": "", # Subagent provider override (empty = inherit parent provider)
"base_url": "", # Direct OpenAI-compatible endpoint for subagents
"api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
+ "workspace_visibility": "inherit",
+ "workspace_mappings": [],
},
}
@@ -419,7 +422,9 @@ def load_cli_config() -> Dict[str, Any]:
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
+ "docker_network": "TERMINAL_DOCKER_NETWORK",
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
+ "enable_gateway_local": "TERMINAL_ENABLE_GATEWAY_LOCAL",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
# Persistent shell (non-local backends)
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
@@ -1397,6 +1402,18 @@ def _parse_skills_argument(skills: str | list[str] | tuple[str, ...] | None) ->
return parsed
+def save_config_value_result(key_path: str, value: any):
+ """Save a config key and return a structured result."""
+ from hermes_cli.config import save_config_key_result
+
+ # Use the same precedence as load_cli_config: user config first, then project config
+ user_config_path = _hermes_home / 'config.yaml'
+ project_config_path = Path(__file__).parent / 'cli-config.yaml'
+ config_path = user_config_path if user_config_path.exists() else project_config_path
+
+ return save_config_key_result(key_path, value, config_path=config_path)
+
+
def save_config_value(key_path: str, value: any) -> bool:
"""
Save a value to the active config file at the specified key path.
@@ -1412,48 +1429,29 @@ def save_config_value(key_path: str, value: any) -> bool:
Returns:
True if successful, False otherwise
"""
- # Use the same precedence as load_cli_config: user config first, then project config
- user_config_path = _hermes_home / 'config.yaml'
- project_config_path = Path(__file__).parent / 'cli-config.yaml'
- config_path = user_config_path if user_config_path.exists() else project_config_path
-
try:
- # Ensure parent directory exists (for ~/.hermes/config.yaml on first use)
- config_path.parent.mkdir(parents=True, exist_ok=True)
-
- # Load existing config
- if config_path.exists():
- with open(config_path, 'r') as f:
- config = yaml.safe_load(f) or {}
- else:
- config = {}
-
- # Navigate to the key and set value
- keys = key_path.split('.')
- current = config
- for key in keys[:-1]:
- if key not in current or not isinstance(current[key], dict):
- current[key] = {}
- current = current[key]
- current[keys[-1]] = value
-
- # Save back atomically — write to temp file + fsync + os.replace
- # so an interrupt never leaves config.yaml truncated or empty.
- from utils import atomic_yaml_write
- atomic_yaml_write(config_path, config)
-
- # Enforce owner-only permissions on config files (contain API keys)
- try:
- os.chmod(config_path, 0o600)
- except (OSError, NotImplementedError):
- pass
-
- return True
+ result = save_config_value_result(key_path, value)
+ if result:
+ # Enforce owner-only permissions on config files (contain API keys)
+ try:
+ os.chmod(result.path, 0o600)
+ except (OSError, NotImplementedError):
+ pass
+ return bool(result)
except Exception as e:
logger.error("Failed to save config: %s", e)
return False
+def _print_config_write_failure(action: str, result) -> None:
+ """Render a readable config write failure in the interactive CLI."""
+ from hermes_cli.config import describe_config_write_failure
+
+ print(f" (! ) {action} could not be persisted.")
+ print()
+ print(describe_config_write_failure(result, action=action))
+
+
# ============================================================================
@@ -2553,11 +2551,11 @@ def _ensure_runtime_credentials(self) -> bool:
return True
- def _resolve_turn_agent_config(self, user_message: str) -> dict:
+ def _resolve_turn_agent_config(self, user_message: str, context_tokens: int = 0) -> dict:
"""Resolve model/runtime overrides for a single user turn."""
from agent.smart_model_routing import resolve_turn_route
- return resolve_turn_route(
+ result = resolve_turn_route(
user_message,
self._smart_model_routing,
{
@@ -2570,7 +2568,11 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict:
"args": list(self.acp_args or []),
"credential_pool": getattr(self, "_credential_pool", None),
},
+ context_tokens=context_tokens,
)
+ if result.get("label"):
+ logger.info("smart_model_routing: %s (context: ~%s tokens, message: %r)", result["label"], f"{context_tokens:,}" if context_tokens else "unknown", user_message[:80])
+ return result
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, route_label: str = None) -> bool:
"""
@@ -2676,6 +2678,7 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No
checkpoints_enabled=self.checkpoints_enabled,
checkpoint_max_snapshots=self.checkpoint_max_snapshots,
pass_session_id=self.pass_session_id,
+ message_callback=self._on_agent_message,
tool_progress_callback=self._on_tool_progress,
tool_start_callback=self._on_tool_start if self._inline_diffs_enabled else None,
tool_complete_callback=self._on_tool_complete if self._inline_diffs_enabled else None,
@@ -2730,7 +2733,7 @@ def show_banner(self):
self._show_status()
else:
# Get tools for display
- tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
+ tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True, platform="cli")
# Get terminal working directory (where commands will execute)
cwd = os.getenv("TERMINAL_CWD", os.getcwd())
@@ -3287,7 +3290,7 @@ def _show_tool_availability_warnings(self):
def _show_status(self):
"""Show current status bar."""
# Get tool count
- tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
+ tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True, platform="cli")
tool_count = len(tools) if tools else 0
# Format model name (shorten if needed)
@@ -3354,7 +3357,7 @@ def show_help(self):
def show_tools(self):
"""Display available tools with kawaii ASCII art."""
- tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
+ tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True, platform="cli")
if not tools:
print("(;_;) No tools available")
@@ -4242,6 +4245,11 @@ def _show_model_and_providers(self):
if is_active:
print(f" model: {self.model} ← current")
print(" (use hermes model to change)")
+ elif p["id"].startswith("custom:"):
+ # Named custom provider — label already contains endpoint + model hint
+ if is_active:
+ print(f" model: {self.model} ← current")
+ print(f" (use /model {p['id']}: to switch)")
else:
print(" (use hermes model to change)")
print()
@@ -4254,7 +4262,63 @@ def _show_model_and_providers(self):
print(" To change model or provider, use: hermes model")
+ def _handle_prompt_command(self, cmd: str):
+ """Handle the /prompt command to view or set system prompt."""
+ parts = cmd.split(maxsplit=1)
+ if len(parts) > 1:
+ # Set new prompt
+ new_prompt = parts[1].strip()
+
+ if new_prompt.lower() == "clear":
+ self.system_prompt = ""
+ self.agent = None # Force re-init
+ result = save_config_value_result("agent.system_prompt", "")
+ if result:
+ print("(^_^)b System prompt cleared (saved to config)")
+ else:
+ print("(^_^) System prompt cleared (session only)")
+ _print_config_write_failure("clear the system prompt", result)
+ else:
+ self.system_prompt = new_prompt
+ self.agent = None # Force re-init
+ result = save_config_value_result("agent.system_prompt", new_prompt)
+ if result:
+ print("(^_^)b System prompt set (saved to config)")
+ else:
+ print("(^_^) System prompt set (session only)")
+ _print_config_write_failure("save the system prompt", result)
+ print(f" \"{new_prompt[:60]}{'...' if len(new_prompt) > 60 else ''}\"")
+ else:
+ # Show current prompt
+ print()
+ print("+" + "-" * 50 + "+")
+ print("|" + " " * 15 + "(^_^) System Prompt" + " " * 15 + "|")
+ print("+" + "-" * 50 + "+")
+ print()
+ if self.system_prompt:
+ # Word wrap the prompt for display
+ words = self.system_prompt.split()
+ lines = []
+ current_line = ""
+ for word in words:
+ if len(current_line) + len(word) + 1 <= 50:
+ current_line += (" " if current_line else "") + word
+ else:
+ lines.append(current_line)
+ current_line = word
+ if current_line:
+ lines.append(current_line)
+ for line in lines:
+ print(f" {line}")
+ else:
+ print(" (no custom prompt set - using default)")
+ print()
+ print(" Usage:")
+ print(" /prompt - Set a custom system prompt")
+ print(" /prompt clear - Remove custom prompt")
+ print(" /personality - Use a predefined personality")
+ print()
@staticmethod
@@ -4283,7 +4347,9 @@ def _handle_personality_command(self, cmd: str):
if save_config_value("agent.system_prompt", ""):
print("(^_^)b Personality cleared (saved to config)")
else:
+ result = save_config_value_result("agent.system_prompt", "")
print("(^_^) Personality cleared (session only)")
+ _print_config_write_failure("clear the personality overlay", result)
print(" No personality overlay — using base agent behavior.")
elif personality_name in self.personalities:
self.system_prompt = self._resolve_personality_prompt(self.personalities[personality_name])
@@ -4291,7 +4357,9 @@ def _handle_personality_command(self, cmd: str):
if save_config_value("agent.system_prompt", self.system_prompt):
print(f"(^_^)b Personality set to '{personality_name}' (saved to config)")
else:
+ result = save_config_value_result("agent.system_prompt", self.system_prompt)
print(f"(^_^) Personality set to '{personality_name}' (session only)")
+ _print_config_write_failure("save the personality overlay", result)
print(f" \"{self.system_prompt[:60]}{'...' if len(self.system_prompt) > 60 else ''}\"")
else:
print(f"(._.) Unknown personality: {personality_name}")
@@ -4676,7 +4744,7 @@ def process_command(self, command: str) -> bool:
if self.compact or term_w < 80:
cc.print(_build_compact_banner())
else:
- tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
+ tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True, platform="cli")
cwd = os.getenv("TERMINAL_CWD", os.getcwd())
ctx_len = None
if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'):
@@ -5483,10 +5551,12 @@ def _handle_skin_command(self, cmd: str):
return
set_active_skin(new_skin)
- if save_config_value("display.skin", new_skin):
+ result = save_config_value_result("display.skin", new_skin)
+ if result:
print(f" Skin set to: {new_skin} (saved)")
else:
- print(f" Skin set to: {new_skin}")
+ print(f" Skin set to: {new_skin} (session only)")
+ _print_config_write_failure("save the active skin", result)
print(" Note: banner colors will update on next session start.")
if self._apply_tui_skin_style():
print(" Prompt + TUI colors updated.")
@@ -5563,16 +5633,24 @@ def _handle_reasoning_command(self, cmd: str):
self.show_reasoning = True
if self.agent:
self.agent.reasoning_callback = self._current_reasoning_callback()
- save_config_value("display.show_reasoning", True)
- _cprint(f" {_GOLD}✓ Reasoning display: ON (saved){_RST}")
+ result = save_config_value_result("display.show_reasoning", True)
+ if result:
+ _cprint(f" {_GOLD}✓ Reasoning display: ON (saved){_RST}")
+ else:
+ _cprint(f" {_GOLD}✓ Reasoning display: ON (session only){_RST}")
+ _print_config_write_failure("save reasoning display settings", result)
_cprint(f" {_DIM} Model thinking will be shown during and after each response.{_RST}")
return
if arg in ("hide", "off"):
self.show_reasoning = False
if self.agent:
self.agent.reasoning_callback = self._current_reasoning_callback()
- save_config_value("display.show_reasoning", False)
- _cprint(f" {_GOLD}✓ Reasoning display: OFF (saved){_RST}")
+ result = save_config_value_result("display.show_reasoning", False)
+ if result:
+ _cprint(f" {_GOLD}✓ Reasoning display: OFF (saved){_RST}")
+ else:
+ _cprint(f" {_GOLD}✓ Reasoning display: OFF (session only){_RST}")
+ _print_config_write_failure("save reasoning display settings", result)
return
# Effort level change
@@ -5586,10 +5664,12 @@ def _handle_reasoning_command(self, cmd: str):
self.reasoning_config = parsed
self.agent = None # Force agent re-init with new reasoning config
- if save_config_value("agent.reasoning_effort", arg):
+ result = save_config_value_result("agent.reasoning_effort", arg)
+ if result:
_cprint(f" {_GOLD}✓ Reasoning effort set to '{arg}' (saved to config){_RST}")
else:
_cprint(f" {_GOLD}✓ Reasoning effort set to '{arg}' (session only){_RST}")
+ _print_config_write_failure("save reasoning effort", result)
def _on_reasoning(self, reasoning_text: str):
"""Callback for intermediate reasoning display during tool-call loops."""
@@ -5859,6 +5939,7 @@ def _reload_mcp(self):
enabled_toolsets=self.agent.enabled_toolsets
if hasattr(self.agent, "enabled_toolsets") else None,
quiet_mode=True,
+ platform="cli",
)
self.agent.valid_tool_names = {
tool["function"]["name"] for tool in self.agent.tools
@@ -5917,6 +5998,16 @@ def _on_tool_gen_start(self, tool_name: str) -> None:
emoji = get_tool_emoji(tool_name, default="⚡")
_cprint(f" ┊ {emoji} preparing {tool_name}…")
+ def _on_agent_message(self, text: str) -> None:
+ """Render an in-session user-facing update from the agent."""
+ if not text:
+ return
+ if getattr(self, "_stream_box_opened", False):
+ self._flush_stream()
+ self._stream_box_opened = False
+ self._close_reasoning_box()
+ _cprint(f" ┊ 💬 {text}")
+
# ====================================================================
# Tool progress callback (audio cues for voice mode)
# ====================================================================
@@ -6738,7 +6829,9 @@ def chat(self, message, images: list = None) -> Optional[str]:
if not self._ensure_runtime_credentials():
return None
- turn_route = self._resolve_turn_agent_config(message)
+ # Estimate context tokens from conversation history for smart routing
+ _est_ctx = sum(len(str(m.get("content", ""))) for m in self.conversation_history) // 4 if self.conversation_history else 0
+ turn_route = self._resolve_turn_agent_config(message, context_tokens=_est_ctx)
if turn_route["signature"] != self._active_agent_route_signature:
self.agent = None
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index 4c6366cbe5da..b84f579d4c72 100644
--- a/docker/entrypoint.sh
+++ b/docker/entrypoint.sh
@@ -2,7 +2,14 @@
# Docker entrypoint: bootstrap config files into the mounted volume, then run hermes.
set -e
-HERMES_HOME="/opt/data"
+# Show image provenance at startup for traceability
+if [ -f /etc/hermes-release ]; then
+ echo "=== hermes-release ==="
+ cat /etc/hermes-release
+ echo "======================"
+fi
+
+HERMES_HOME="${HERMES_HOME:-/opt/data}"
INSTALL_DIR="/opt/hermes"
# Create essential directory structure. Cache and platform directories
diff --git a/docker/wait-for-honcho.sh b/docker/wait-for-honcho.sh
new file mode 100644
index 000000000000..c3770fde5980
--- /dev/null
+++ b/docker/wait-for-honcho.sh
@@ -0,0 +1,23 @@
+#!/bin/sh
+# Wait for Honcho API to be ready before starting Hermes gateway
+HONCHO_URL="${HONCHO_BASE_URL:-http://hermes-honcho-api:8000}"
+MAX_WAIT=120
+INTERVAL=3
+elapsed=0
+
+echo "Waiting for Honcho at $HONCHO_URL ..."
+while [ $elapsed -lt $MAX_WAIT ]; do
+ if python3 -c "import urllib.request,sys; urllib.request.urlopen(sys.argv[1], timeout=5)" "$HONCHO_URL/openapi.json" 2>/dev/null; then
+ echo "Honcho is ready (${elapsed}s)"
+ break
+ fi
+ sleep $INTERVAL
+ elapsed=$((elapsed + INTERVAL))
+done
+
+if [ $elapsed -ge $MAX_WAIT ]; then
+ echo "WARNING: Honcho not ready after ${MAX_WAIT}s, starting anyway"
+fi
+
+# Delegate to the original entrypoint
+exec /opt/hermes/docker/entrypoint.sh "$@"
diff --git a/docs/CONTAINER_CLEANUP.md b/docs/CONTAINER_CLEANUP.md
new file mode 100644
index 000000000000..919efce72cbb
--- /dev/null
+++ b/docs/CONTAINER_CLEANUP.md
@@ -0,0 +1,191 @@
+# Container Cleanup Options for Hermes Agent
+
+## Current Cleanup Mechanisms
+
+✅ **What EXISTS:**
+1. `DockerEnvironment.cleanup()` - Per-container cleanup on session end
+2. `cleanup_all_environments()` - Manual cleanup of ALL active environments
+3. `_atexit_cleanup()` - Runs when Python process exits gracefully
+
+❌ **What's MISSING:**
+1. ❌ Startup cleanup of orphaned containers
+2. ❌ Periodic cleanup cron job
+3. ❌ Gateway crash recovery cleanup
+
+## Option 1: Quick Manual Cleanup (IMMEDIATE)
+
+```bash
+# On oikos (Alpine LXC), remove ALL exited hermes-* containers:
+podman ps -a --filter "name=^hermes-" --filter "status=exited" -q | xargs -r podman rm -f
+
+# Remove ALL hermes-* containers (including stopped ones):
+podman ps -a --filter "name=^hermes-" -q | xargs -r podman rm -f
+
+# Remove containers older than 1 day:
+podman container prune --filter "until=24h" --filter "name=^hermes-"
+
+# View what would be removed (dry-run):
+podman ps -a --filter "name=^hermes-" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}"
+```
+
+## Option 2: Add Startup Cleanup to Gateway (PERMANENT FIX)
+
+**File to modify:** `gateway/run.py`
+
+**Add this function** (after imports, before main code):
+
+```python
+def _cleanup_orphaned_containers():
+ """Clean up exited hermes-* containers from previous runs.
+
+ This prevents accumulation of stopped containers when Hermes
+ gateway crashes or restarts without proper cleanup.
+ """
+ import subprocess
+ import logging
+ logger = logging.getLogger(__name__)
+
+ try:
+ # Find all hermes-* containers (stopped or running)
+ cmd = [
+ "podman", "ps", "-a",
+ "--filter", "name=^hermes-",
+ "--format", "{{.ID}} {{.Status}}"
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+
+ if result.returncode != 0:
+ logger.debug("Could not list containers: %s", result.stderr)
+ return
+
+ exited_count = 0
+ for line in result.stdout.strip().split('\n'):
+ if not line:
+ continue
+ parts = line.split()
+ if len(parts) >= 2:
+ container_id = parts[0]
+ status = parts[1]
+
+ # Only clean up exited/failed containers
+ if status.lower() in ['exited', 'dead', 'created']:
+ try:
+ subprocess.run(
+ ["podman", "rm", "-f", container_id],
+ capture_output=True,
+ timeout=30
+ )
+ exited_count += 1
+ logger.info("Cleaned up orphaned container: %s (%s)",
+ container_id[:12], status)
+ except Exception as e:
+ logger.warning("Failed to remove container %s: %s",
+ container_id[:12], e)
+
+ if exited_count > 0:
+ logger.info("Startup cleanup: removed %d orphaned hermes-* containers",
+ exited_count)
+
+ except FileNotFoundError:
+ logger.debug("Podman not found, skipping container cleanup")
+ except Exception as e:
+ logger.warning("Container cleanup failed: %s", e)
+```
+
+**Call this function** in your gateway startup code (e.g., in `run_gateway()` or similar):
+
+```python
+# After establishing logging, before starting gateway loop:
+_cleanup_orphaned_containers()
+```
+
+## Option 3: Create CLI Command (CONVENIENT)
+
+**File to modify:** `hermes_cli/main.py` or similar CLI entry point
+
+**Add command:**
+
+```python
+@cli.command()
+@click.option('--dry-run', is_flag=True, help='Show what would be removed')
+@click.option('--all', 'remove_all', is_flag=True, help='Remove all hermes-* containers')
+@click.option('--older-than', default='24h', help='Remove containers older than this')
+def container_cleanup(dry_run: bool, remove_all: bool, older_than: str):
+ """Clean up orphaned or old Hermes agent containers."""
+ import subprocess
+
+ cmd = ["podman", "ps", "-a", "--filter", "name=^hermes-"]
+
+ if not remove_all:
+ cmd.extend(["--filter", f"until={older_than}"])
+
+ if dry_run:
+ cmd.extend(["--format", "table {{.ID}}\t{{.Names}}\t{{.Status}}"])
+ else:
+ cmd.extend(["-q"])
+
+ result = subprocess.run(cmd, capture_output=True, text=True)
+
+ if result.returncode != 0:
+ print(f"Error: {result.stderr}")
+ return
+
+ containers = [c for c in result.stdout.strip().split('\n') if c]
+
+ if dry_run:
+ print("Would remove these containers:")
+ print(result.stdout)
+ else:
+ if containers:
+ print(f"Removing {len(containers)} container(s)...")
+ for container_id in containers:
+ subprocess.run(["podman", "rm", "-f", container_id],
+ capture_output=True)
+ print("Cleanup complete!")
+ else:
+ print("No containers to clean up.")
+```
+
+**Usage:**
+```bash
+hermes container-cleanup # Remove containers older than 24h
+hermes container-cleanup --all # Remove ALL hermes-* containers
+hermes container-cleanup --dry-run # Preview what would be removed
+hermes container-cleanup --older-than=1h # Remove containers older than 1 hour
+```
+
+## Option 4: Add Cron Job (AUTOMATED)
+
+**On oikos (Alpine LXC), add to crontab:**
+
+```bash
+# Clean up exited containers every hour
+0 * * * * podman ps -a --filter "name=^hermes-" --filter "status=exited" -q | xargs -r podman rm -f
+
+# Or use the Hermes CLI if Option 3 is implemented
+0 * * * * hermes container-cleanup --older-than=1h
+```
+
+## Recommended Approach
+
+**For IMMEDIATE relief:**
+```bash
+# Option 1 - Manual cleanup now
+podman ps -a --filter "name=^hermes-" --filter "status=exited" -q | xargs -r podman rm -f
+```
+
+**For LONG-TERM fix:**
+- Implement **Option 2** (startup cleanup for automatic recovery)
+- Implement **Option 3** (CLI command for manual control)
+- Optionally **Option 4** (cron for scheduled cleanup)
+
+## Why This Happens
+
+Containers accumulate when:
+1. ✅ Hermes gateway crashes (no chance to call cleanup)
+2. ✅ Oikos host reboots (containers left in "exited" state)
+3. ✅ OOM killer terminates containers (force kill, no cleanup)
+4. ✅ Podman restart fails (containers stuck in "created" state)
+5. ✅ Manual `podman stop` without `rm` (containers accumulate)
+
+The cleanup function only runs on **graceful exits**, so crashes leave orphans behind.
diff --git a/gateway/builtin_hooks/boot_md.py b/gateway/builtin_hooks/boot_md.py
index c4b6c2d46ac5..2a857d086b31 100644
--- a/gateway/builtin_hooks/boot_md.py
+++ b/gateway/builtin_hooks/boot_md.py
@@ -47,14 +47,19 @@ def _build_boot_prompt(content: str) -> str:
def _run_boot_agent(content: str) -> None:
"""Spawn a one-shot agent session to execute the boot instructions."""
try:
+ from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
from run_agent import AIAgent
prompt = _build_boot_prompt(content)
+ runtime_kwargs = _resolve_runtime_agent_kwargs()
agent = AIAgent(
+ model=_resolve_gateway_model(),
+ platform="gateway",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
max_iterations=20,
+ **runtime_kwargs,
)
result = agent.run_conversation(prompt)
response = result.get("final_response", "")
diff --git a/gateway/config.py b/gateway/config.py
index e4f04d89115e..a753d8cadfca 100644
--- a/gateway/config.py
+++ b/gateway/config.py
@@ -255,6 +255,9 @@ class GatewayConfig:
# Streaming configuration
streaming: StreamingConfig = field(default_factory=StreamingConfig)
+ # Restart recovery
+ resume_inflight_sessions_on_restart: bool = False
+
def get_connected_platforms(self) -> List[Platform]:
"""Return list of platforms that are enabled and configured."""
connected = []
@@ -341,6 +344,7 @@ def to_dict(self) -> Dict[str, Any]:
"thread_sessions_per_user": self.thread_sessions_per_user,
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
"streaming": self.streaming.to_dict(),
+ "resume_inflight_sessions_on_restart": self.resume_inflight_sessions_on_restart,
}
@classmethod
@@ -402,6 +406,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
unauthorized_dm_behavior=unauthorized_dm_behavior,
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
+ resume_inflight_sessions_on_restart=_coerce_bool(
+ data.get("resume_inflight_sessions_on_restart"), False
+ ),
)
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
@@ -494,6 +501,11 @@ def load_gateway_config() -> GatewayConfig:
"pair",
)
+ if "resume_inflight_sessions_on_restart" in yaml_cfg:
+ gw_data["resume_inflight_sessions_on_restart"] = yaml_cfg[
+ "resume_inflight_sessions_on_restart"
+ ]
+
# Merge platforms section from config.yaml into gw_data so that
# nested keys like platforms.webhook.extra.routes are loaded.
yaml_platforms = yaml_cfg.get("platforms")
diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py
index 132790e5bd3e..8b0d063dfede 100644
--- a/gateway/platforms/api_server.py
+++ b/gateway/platforms/api_server.py
@@ -27,7 +27,7 @@
import sqlite3
import time
import uuid
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Set, Tuple
try:
from aiohttp import web
@@ -424,12 +424,229 @@ def _ensure_session_db(self):
# Agent creation helper
# ------------------------------------------------------------------
+ # Patterns emitted by Open WebUI (and similar frontends) for background
+ # meta-tasks that should never touch the agent's tool loop or session.
+ _META_REQUEST_PREFIXES = (
+ "### Task:\nSuggest ", # follow-up suggestions
+ "### Task:\nCreate a concise", # title generation
+ "### Task:\nGenerate a concise",
+ )
+
+ @staticmethod
+ def _is_openwebui_meta_request(user_message: Any) -> bool:
+ """Return True if this looks like an Open WebUI background meta-request.
+
+ Open WebUI sends fire-and-forget requests for follow-up suggestions and
+ title generation. These should be answered with a plain LLM call — no
+ tools, no session persistence — rather than a full agent loop.
+
+ Accepts either a plain string (the common case) or a structured
+ content list (OpenAI multimodal format). Multimodal requests are
+ never meta-requests — Open WebUI always sends its background tasks
+ as plain text — so a list is reported as False without crashing.
+ """
+ if not isinstance(user_message, str):
+ return False
+ stripped = user_message.lstrip()
+ return any(stripped.startswith(p) for p in APIServerAdapter._META_REQUEST_PREFIXES)
+
+ @staticmethod
+ def _normalize_openai_content(
+ content: Any,
+ ) -> Tuple[str, List[str], List[str]]:
+ """Flatten an OpenAI chat content field to plain text + image refs.
+
+ The OpenAI chat completions API accepts ``content`` as either a
+ plain string or a list of content parts (``{"type": "text", ...}``
+ and ``{"type": "image_url", ...}`` for multimodal inputs).
+
+ Returns ``(text, image_refs, unsupported_types)``:
+ - ``text`` — the concatenation of every ``type="text"`` part
+ (or the original string, unchanged).
+ - ``image_refs`` — the URL field of every ``image_url`` part,
+ in document order. ``data:`` URIs are returned as-is here;
+ caller is responsible for persisting them to disk via
+ :func:`_persist_image_data_uri` if it wants a local path
+ the agent can hand to ``vision_analyze``.
+ - ``unsupported_types`` — sorted list of part types that
+ were present but Hermes doesn't yet know how to forward
+ (e.g. ``input_audio``, ``video_url``). Callers reject
+ the request with a clear 400 if this is non-empty for
+ the active user turn.
+
+ Silently coerces non-string, non-list content to ``str(content)``
+ rather than raising, so a malformed request still produces a
+ sensible error downstream instead of a 500.
+ """
+ if isinstance(content, str):
+ return content, [], []
+ if content is None:
+ return "", [], []
+ if not isinstance(content, list):
+ return str(content), [], []
+
+ text_parts: List[str] = []
+ image_refs: List[str] = []
+ unsupported: Set[str] = set()
+ for part in content:
+ if isinstance(part, dict):
+ part_type = part.get("type", "")
+ if part_type == "text":
+ text_value = part.get("text", "")
+ if isinstance(text_value, str):
+ text_parts.append(text_value)
+ continue
+ if part_type == "image_url":
+ image_url_field = part.get("image_url")
+ url = ""
+ if isinstance(image_url_field, dict):
+ url = image_url_field.get("url", "") or ""
+ elif isinstance(image_url_field, str):
+ # Some clients pass the URL string directly.
+ url = image_url_field
+ if url:
+ image_refs.append(url)
+ continue
+ if part_type:
+ unsupported.add(part_type)
+ else:
+ unsupported.add("unknown")
+ elif isinstance(part, str):
+ text_parts.append(part)
+ else:
+ unsupported.add(type(part).__name__)
+ return "".join(text_parts), image_refs, sorted(unsupported)
+
+ # Compiled lazily on first call.
+ _DATA_URI_RE = None
+
+ @classmethod
+ def _persist_image_data_uri(
+ cls, data_uri: str, dest_dir: "Path", index: int
+ ) -> Optional[str]:
+ """Decode a ``data:image/...;base64,...`` URI and write it to disk.
+
+ Returns the absolute path of the written file, or ``None`` if
+ ``data_uri`` was not a valid base64 image data URI (in which
+ case the caller should fall through to treating it as a remote
+ URL). Errors during write are logged and surfaced as ``None``
+ so a single broken attachment cannot fail the whole request.
+ """
+ import base64
+ import re
+
+ if cls._DATA_URI_RE is None:
+ cls._DATA_URI_RE = re.compile(
+ r"^data:image/(?P[a-zA-Z0-9.+-]+);base64,(?P.+)$",
+ re.DOTALL,
+ )
+ match = cls._DATA_URI_RE.match(data_uri)
+ if not match:
+ return None
+
+ ext_raw = match.group("ext").lower()
+ # Map common MIME subtypes to plain file extensions.
+ ext_map = {
+ "jpeg": "jpg",
+ "svg+xml": "svg",
+ "x-icon": "ico",
+ }
+ ext = ext_map.get(ext_raw, ext_raw)
+ # Defensive: keep the extension simple and filesystem-friendly.
+ ext = re.sub(r"[^a-z0-9]", "", ext)[:8] or "bin"
+
+ try:
+ payload = base64.b64decode(match.group("payload"), validate=False)
+ except Exception as exc:
+ logger.warning("Failed to decode data URI #%d: %s", index, exc)
+ return None
+
+ try:
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ dest = dest_dir / f"image_{index:02d}.{ext}"
+ dest.write_bytes(payload)
+ return str(dest)
+ except OSError as exc:
+ logger.warning("Failed to write image attachment %s: %s", dest, exc)
+ return None
+
+ @classmethod
+ def _materialize_image_refs(
+ cls, refs: List[str], dest_dir: "Path"
+ ) -> List[str]:
+ """Convert a list of image_url values to references the agent can use.
+
+ - ``data:image/...;base64,...`` URIs are decoded and saved to
+ ``dest_dir``; the local path is returned in their place.
+ - ``http://`` and ``https://`` URLs are returned as-is so the
+ agent can pass them straight to ``vision_analyze`` (which
+ accepts both URLs and local paths).
+ - Anything else (e.g. an unsupported scheme, an empty string,
+ a malformed data URI that fails to decode) is dropped with a
+ warning so it does not poison the prompt.
+ """
+ out: List[str] = []
+ for idx, ref in enumerate(refs, start=1):
+ if not isinstance(ref, str) or not ref:
+ continue
+ if ref.startswith("data:image/"):
+ local = cls._persist_image_data_uri(ref, dest_dir, idx)
+ if local:
+ out.append(local)
+ continue
+ if ref.startswith("http://") or ref.startswith("https://"):
+ out.append(ref)
+ continue
+ logger.warning(
+ "Dropping image_url with unsupported scheme: %r",
+ ref[:60],
+ )
+ return out
+
+ @staticmethod
+ def _cleanup_attachments(attachment_dir: "Path") -> None:
+ """Best-effort removal of an attachment directory.
+
+ Used both on early-return error paths (so a rejected request
+ does not leak files) and after the agent finishes a successful
+ completion. Errors are swallowed and logged at debug — there
+ is nothing the caller can usefully do with them.
+ """
+ import shutil
+ try:
+ if attachment_dir.exists():
+ shutil.rmtree(attachment_dir, ignore_errors=True)
+ except Exception as exc:
+ logger.debug("attachment cleanup failed for %s: %s", attachment_dir, exc)
+
+ @staticmethod
+ def _build_image_attachment_hint(refs: List[str]) -> str:
+ """Build the text suffix that tells the agent images are attached.
+
+ The agent loads ``vision_analyze`` from the
+ ``hermes-api-server`` toolset, which accepts both local file
+ paths and remote URLs, so the same hint format works for
+ either input. The wording explicitly tells the agent the
+ attachments are first-class context for the user's request,
+ so it knows to inspect them when relevant rather than
+ pretending it does not see anything.
+ """
+ if not refs:
+ return ""
+ bullets = "\n".join(f"- {r}" for r in refs)
+ return (
+ "\n\n[Attached images from the user — inspect with the "
+ "vision_analyze tool when relevant to the request:\n"
+ f"{bullets}\n]"
+ )
+
def _create_agent(
self,
ephemeral_system_prompt: Optional[str] = None,
session_id: Optional[str] = None,
stream_delta_callback=None,
tool_progress_callback=None,
+ btw_mode: bool = False,
) -> Any:
"""
Create an AIAgent instance using the gateway's runtime config.
@@ -463,13 +680,14 @@ def _create_agent(
quiet_mode=True,
verbose_logging=False,
ephemeral_system_prompt=ephemeral_system_prompt or None,
- enabled_toolsets=enabled_toolsets,
+ enabled_toolsets=[] if btw_mode else enabled_toolsets,
session_id=session_id,
platform="api_server",
stream_delta_callback=stream_delta_callback,
tool_progress_callback=tool_progress_callback,
session_db=self._ensure_session_db(),
fallback_model=fallback_model,
+ persist_session=not btw_mode,
)
return agent
@@ -521,23 +739,77 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons
status=400,
)
+
stream = body.get("stream", False)
+ # Image attachments from OpenAI structured content land in a
+ # per-request subdirectory under HERMES_HOME so cleanup is a
+ # single ``rmtree`` regardless of how many parts were attached
+ # and so concurrent requests cannot collide on filenames. The
+ # directory is lazy-created by the first ``_persist_image_data_uri``
+ # call — text-only requests never touch the filesystem.
+ from hermes_cli.config import get_hermes_home
+ attachment_id = uuid.uuid4().hex
+ attachment_dir = (
+ get_hermes_home() / "api_server_attachments" / attachment_id
+ )
+
# Extract system message (becomes ephemeral system prompt layered ON TOP of core)
+ # OpenAI chat completions accepts ``content`` as either a plain
+ # string or a list of structured parts (``{"type": "text"}``,
+ # ``{"type": "image_url"}``, etc.). We flatten everything to
+ # text here, persist any ``image_url`` parts to disk so the
+ # agent can hand them to ``vision_analyze``, and collect any
+ # part types we don't yet know how to forward — those still
+ # produce a 400 (only on the active user turn) until we grow
+ # support for them.
system_prompt = None
conversation_messages: List[Dict[str, str]] = []
+ unsupported_content_types: Set[str] = set()
+ materialized_image_refs: List[str] = []
+
+ # Find the index of the last user message in the original
+ # ``messages`` array so the unsupported-parts check fires on
+ # the active turn only (historical multimodal parts are best-
+ # effort flattened so stale Open WebUI history with prior
+ # images doesn't hard-fail every text follow-up). In normal
+ # usage the last message IS a user message; this lookup makes
+ # the rejection logic robust to trailing assistant turns.
+ last_user_idx = -1
+ for _idx in range(len(messages) - 1, -1, -1):
+ if messages[_idx].get("role") == "user":
+ last_user_idx = _idx
+ break
- for msg in messages:
+ for idx, msg in enumerate(messages):
role = msg.get("role", "")
- content = msg.get("content", "")
+ text, image_refs, unsupported = self._normalize_openai_content(
+ msg.get("content", "")
+ )
if role == "system":
- # Accumulate system messages
if system_prompt is None:
- system_prompt = content
+ system_prompt = text
else:
- system_prompt = system_prompt + "\n" + content
- elif role in ("user", "assistant"):
- conversation_messages.append({"role": role, "content": content})
+ system_prompt = system_prompt + "\n" + text
+ # Multimodal parts on system messages are dropped —
+ # they are never meaningful there. Not surfaced in
+ # ``unsupported_content_types``.
+ continue
+ if role not in ("user", "assistant"):
+ continue
+
+ if image_refs:
+ local_refs = self._materialize_image_refs(image_refs, attachment_dir)
+ materialized_image_refs.extend(local_refs)
+ if local_refs:
+ text = (text or "") + self._build_image_attachment_hint(local_refs)
+
+ conversation_messages.append({"role": role, "content": text})
+
+ # Only the active user turn's unsupported parts trigger a
+ # 400 — historical turns are best-effort flattened.
+ if idx == last_user_idx:
+ unsupported_content_types.update(unsupported)
# Extract the last user message as the primary input
user_message = ""
@@ -546,12 +818,111 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons
user_message = conversation_messages[-1].get("content", "")
history = conversation_messages[:-1]
+ if unsupported_content_types:
+ # Audio / video / unknown parts on the active turn are
+ # still rejected with a clear 400 — only image_url is
+ # forwarded today. Clean up any persisted images first
+ # so a rejected request doesn't leak attachment files.
+ self._cleanup_attachments(attachment_dir)
+ types_list = ", ".join(unsupported_content_types)
+ logger.warning(
+ "Rejecting chat completion with unsupported content parts (%s)",
+ types_list,
+ )
+ return web.json_response(
+ {
+ "error": {
+ "message": (
+ f"This endpoint does not yet support content "
+ f"parts of type {types_list}. Send a text or "
+ f"image-only message, or use one of the gateway "
+ f"platforms (Telegram, Discord, Matrix) which "
+ f"support audio and video natively."
+ ),
+ "type": "invalid_request_error",
+ "code": "unsupported_content_part",
+ }
+ },
+ status=400,
+ )
+
if not user_message:
+ self._cleanup_attachments(attachment_dir)
return web.json_response(
{"error": {"message": "No user message found in messages", "type": "invalid_request_error"}},
status=400,
)
+ if materialized_image_refs:
+ logger.info(
+ "api_server: forwarded %d image attachment(s) for %s",
+ len(materialized_image_refs),
+ attachment_id,
+ )
+
+ # Wrap the rest of the handler in try/finally so persisted
+ # image attachments are removed exactly once, regardless of
+ # which return branch fires. ``materialized_image_refs`` may
+ # be empty (text-only request) — the cleanup is a no-op then.
+ try:
+ return await self._dispatch_chat_completion(
+ request=request,
+ body=body,
+ user_message=user_message,
+ history=history,
+ system_prompt=system_prompt,
+ stream=stream,
+ )
+ finally:
+ if materialized_image_refs:
+ self._cleanup_attachments(attachment_dir)
+
+ async def _dispatch_chat_completion(
+ self,
+ request: "web.Request",
+ body: Dict[str, Any],
+ user_message: str,
+ history: List[Dict[str, Any]],
+ system_prompt: Optional[str],
+ stream: bool,
+ ) -> "web.Response":
+ """Run the agent and build the chat-completion response.
+
+ Split out from :meth:`_handle_chat_completions` purely so the
+ attachment-cleanup ``finally`` block in the parent stays small
+ and easy to read — the agent dispatch flow itself is unchanged.
+ """
+ # Open WebUI meta-requests (follow-up suggestions, title generation) get a
+ # btw-style run: ephemeral, no tools, not persisted. The full chat history
+ # is embedded in the prompt by Open WebUI, so we pass it as-is.
+ if self._is_openwebui_meta_request(user_message):
+ completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
+ model_name = body.get("model", "hermes-agent")
+ created = int(time.time())
+ try:
+ result, usage = await self._run_agent(
+ user_message=user_message,
+ conversation_history=history,
+ ephemeral_system_prompt=system_prompt,
+ session_id=None,
+ btw_mode=True,
+ )
+ except Exception as e:
+ logger.error("Error running meta-request agent: %s", e, exc_info=True)
+ return web.json_response(
+ _openai_error(f"Internal server error: {e}", err_type="server_error"),
+ status=500,
+ )
+ final_response = result.get("final_response", "") or result.get("error", "(No response)")
+ return web.json_response({
+ "id": completion_id,
+ "object": "chat.completion",
+ "created": created,
+ "model": model_name,
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": final_response}, "finish_reason": "stop"}],
+ "usage": {"prompt_tokens": usage.get("input_tokens", 0), "completion_tokens": usage.get("output_tokens", 0), "total_tokens": usage.get("total_tokens", 0)},
+ })
+
# Allow caller to continue an existing session by passing X-Hermes-Session-Id.
# When provided, history is loaded from state.db instead of from the request body.
provided_session_id = request.headers.get("X-Hermes-Session-Id", "").strip()
@@ -1315,6 +1686,7 @@ async def _run_agent(
stream_delta_callback=None,
tool_progress_callback=None,
agent_ref: Optional[list] = None,
+ btw_mode: bool = False,
) -> tuple:
"""
Create an agent and run a conversation in a thread executor.
@@ -1335,6 +1707,7 @@ def _run():
session_id=session_id,
stream_delta_callback=stream_delta_callback,
tool_progress_callback=tool_progress_callback,
+ btw_mode=btw_mode,
)
if agent_ref is not None:
agent_ref[0] = agent
diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py
index 0a8390a7a5fe..d8b443a6bd7b 100644
--- a/gateway/platforms/base.py
+++ b/gateway/platforms/base.py
@@ -531,6 +531,10 @@ class MessageEvent:
# Auto-loaded skill for topic/channel bindings (e.g., Telegram DM Topics)
auto_skill: Optional[str] = None
+
+ # Optional clean text to persist in transcripts when ``text`` contains
+ # internal routing hints or restart-resume prefixes.
+ persist_user_message: Optional[str] = None
# Internal flag — set for synthetic events (e.g. background process
# completion notifications) that must bypass user authorization checks.
diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py
index e127841b5de3..951f99c44af2 100644
--- a/gateway/platforms/telegram.py
+++ b/gateway/platforms/telegram.py
@@ -357,14 +357,28 @@ def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id:
"""Save a newly created thread_id back into config.yaml so it persists across restarts."""
try:
from hermes_constants import get_hermes_home
+ from hermes_cli.config import (
+ describe_config_write_failure,
+ load_raw_config_mapping_result,
+ save_yaml_config_result,
+ )
config_path = get_hermes_home() / "config.yaml"
if not config_path.exists():
logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path)
return
- import yaml as _yaml
- with open(config_path, "r") as f:
- config = _yaml.safe_load(f) or {}
+ config, load_error = load_raw_config_mapping_result(
+ config_path,
+ action="persist Telegram DM topic thread IDs",
+ )
+ if load_error is not None:
+ logger.warning(
+ "[%s] %s",
+ self.name,
+ describe_config_write_failure(load_error, action="persist Telegram DM topic thread IDs"),
+ )
+ return
+ assert config is not None
# Navigate to platforms.telegram.extra.dm_topics
dm_topics = (
@@ -387,8 +401,14 @@ def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id:
break
if changed:
- with open(config_path, "w") as f:
- _yaml.dump(config, f, default_flow_style=False, sort_keys=False)
+ result = save_yaml_config_result(config_path, config, sort_keys=False)
+ if not result:
+ logger.warning(
+ "[%s] %s",
+ self.name,
+ describe_config_write_failure(result, action="persist Telegram DM topic thread IDs"),
+ )
+ return
logger.info(
"[%s] Persisted thread_id=%s for topic '%s' in config.yaml",
self.name, thread_id, topic_name,
diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py
index 6d4885d2b034..dc13c808342d 100644
--- a/gateway/platforms/webhook.py
+++ b/gateway/platforms/webhook.py
@@ -186,6 +186,9 @@ async def send(
if deliver_type == "github_comment":
return await self._deliver_github_comment(content, delivery)
+ if deliver_type == "gitea_comment":
+ return await self._deliver_gitea_comment(content, delivery)
+
# Cross-platform delivery (telegram, discord, etc.)
if self.gateway_runner and deliver_type in (
"telegram",
@@ -333,6 +336,7 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
event_type = (
request.headers.get("X-GitHub-Event", "")
or request.headers.get("X-GitLab-Event", "")
+ or request.headers.get("X-Gitea-Event", "")
or payload.get("event_type", "")
or "unknown"
)
@@ -348,6 +352,51 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
{"status": "ignored", "event": event_type}
)
+ # Merge per-event overrides on top of route config.
+ # Keys in per_event[event_type] shadow route-level keys; nested dicts
+ # (reaction, deliver_extra) are shallow-merged so shared keys like
+ # gitea_url don't need repeating.
+ per_event = route_config.get("per_event", {})
+ if event_type in per_event:
+ route_config = dict(route_config)
+ for k, v in per_event[event_type].items():
+ if isinstance(v, dict) and isinstance(route_config.get(k), dict):
+ route_config[k] = {**route_config[k], **v}
+ else:
+ route_config[k] = v
+
+ # Check payload field filter (allowlist — ignore if field not in list)
+ payload_filter = route_config.get("payload_filter", {})
+ for field_path, allowed_values in payload_filter.items():
+ value: Any = payload
+ for part in field_path.split("."):
+ value = value.get(part) if isinstance(value, dict) else None
+ allowed = [allowed_values] if isinstance(allowed_values, str) else allowed_values
+ if value not in allowed:
+ logger.debug(
+ "[webhook] Ignoring %s for route %s (%s=%s not in %s)",
+ event_type, route_name, field_path, value, allowed,
+ )
+ return web.json_response(
+ {"status": "ignored", "event": event_type, "reason": f"{field_path}={value}"}
+ )
+
+ # Check payload exclude filter (denylist — ignore if field matches)
+ payload_exclude = route_config.get("payload_exclude", {})
+ for field_path, denied_values in payload_exclude.items():
+ value = payload
+ for part in field_path.split("."):
+ value = value.get(part) if isinstance(value, dict) else None
+ denied = [denied_values] if isinstance(denied_values, str) else denied_values
+ if value in denied:
+ logger.debug(
+ "[webhook] Ignoring %s for route %s (%s=%s is excluded)",
+ event_type, route_name, field_path, value,
+ )
+ return web.json_response(
+ {"status": "ignored", "event": event_type, "reason": f"{field_path}={value} excluded"}
+ )
+
# Format prompt from template
prompt_template = route_config.get("prompt", "")
prompt = self._render_prompt(
@@ -386,7 +435,10 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
# Build a unique delivery ID
delivery_id = request.headers.get(
"X-GitHub-Delivery",
- request.headers.get("X-Request-ID", str(int(time.time() * 1000))),
+ request.headers.get(
+ "X-Gitea-Delivery",
+ request.headers.get("X-Request-ID", str(int(time.time() * 1000))),
+ ),
)
# ── Idempotency ─────────────────────────────────────────
@@ -451,6 +503,13 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
delivery_id,
)
+ # Post acknowledgement reaction (👀) before agent starts, if configured
+ reaction_config = route_config.get("reaction")
+ if reaction_config:
+ rt = asyncio.create_task(self._post_reaction(reaction_config, payload))
+ self._background_tasks.add(rt)
+ rt.add_done_callback(self._background_tasks.discard)
+
# Non-blocking — return 202 Accepted immediately
task = asyncio.create_task(self.handle_message(event))
self._background_tasks.add(task)
@@ -487,6 +546,14 @@ def _validate_signature(
if gl_token:
return hmac.compare_digest(gl_token, secret)
+ # Gitea: X-Gitea-Signature = (no prefix)
+ gitea_sig = request.headers.get("X-Gitea-Signature", "")
+ if gitea_sig:
+ expected = hmac.new(
+ secret.encode(), body, hashlib.sha256
+ ).hexdigest()
+ return hmac.compare_digest(gitea_sig, expected)
+
# Generic: X-Webhook-Signature =
generic_sig = request.headers.get("X-Webhook-Signature", "")
if generic_sig:
@@ -561,6 +628,64 @@ def _render_delivery_extra(
# Response delivery
# ------------------------------------------------------------------
+ async def _post_reaction(self, reaction_config: dict, payload: dict) -> None:
+ """Post an emoji reaction to a Gitea issue, PR, or comment.
+
+ reaction_config keys:
+ type — must be "gitea_reaction"
+ emoji — reaction content (default: "eyes")
+ gitea_url — base URL (falls back to GITEA_BASE_URL env var)
+ token_env — env var for API token (default: GITEA_TOKEN)
+ repo — "owner/repo" template (e.g. "{repository.full_name}")
+ issue_number — issue/PR index template (for PR and issue events)
+ comment_id — comment ID template (for comment events)
+ """
+ if reaction_config.get("type") != "gitea_reaction":
+ return
+
+ gitea_url = self._render_prompt(
+ reaction_config.get("gitea_url", ""), payload, "", ""
+ ).rstrip("/") or os.getenv("GITEA_BASE_URL", "").rstrip("/")
+ token = os.getenv(reaction_config.get("token_env", "GITEA_TOKEN"), "")
+ repo = self._render_prompt(reaction_config.get("repo", ""), payload, "", "")
+ emoji = reaction_config.get("emoji", "eyes")
+
+ if not gitea_url or not token or not repo:
+ logger.debug("[webhook] _post_reaction: missing gitea_url/token/repo")
+ return
+
+ comment_id_tpl = reaction_config.get("comment_id", "")
+ issue_number_tpl = reaction_config.get("issue_number", "")
+
+ if comment_id_tpl:
+ cid = self._render_prompt(comment_id_tpl, payload, "", "")
+ url = f"{gitea_url}/api/v1/repos/{repo}/issues/comments/{cid}/reactions"
+ elif issue_number_tpl:
+ num = self._render_prompt(issue_number_tpl, payload, "", "")
+ url = f"{gitea_url}/api/v1/repos/{repo}/issues/{num}/reactions"
+ else:
+ return
+
+ try:
+ import aiohttp as _aiohttp
+ async with _aiohttp.ClientSession() as session:
+ async with session.post(
+ url,
+ json={"content": emoji},
+ headers={"Authorization": f"token {token}"},
+ timeout=_aiohttp.ClientTimeout(total=10),
+ ) as resp:
+ if resp.status in (200, 201):
+ logger.debug("[webhook] Posted :%s: reaction to %s", emoji, url)
+ else:
+ body = await resp.text()
+ logger.warning(
+ "[webhook] Reaction POST %s returned %d: %s",
+ url, resp.status, body[:120],
+ )
+ except Exception as e:
+ logger.warning("[webhook] _post_reaction failed: %s", e)
+
async def _deliver_github_comment(
self, content: str, delivery: dict
) -> SendResult:
@@ -615,6 +740,76 @@ async def _deliver_github_comment(
logger.error("[webhook] github_comment delivery error: %s", e)
return SendResult(success=False, error=str(e))
+ async def _deliver_gitea_comment(
+ self, content: str, delivery: dict
+ ) -> SendResult:
+ """Post agent response as a Gitea PR/issue comment via the Gitea REST API.
+
+ deliver_extra keys:
+ repo — "owner/repo" (required)
+ pr_number — PR or issue index (required)
+ gitea_url — base URL e.g. "http://10.15.0.6:3300"
+ (falls back to GITEA_BASE_URL env var)
+ token_env — env var holding the API token (default: GITEA_TOKEN)
+ """
+ extra = delivery.get("deliver_extra", {})
+ repo = extra.get("repo", "")
+ pr_number = extra.get("pr_number", "")
+ gitea_url = extra.get("gitea_url", os.getenv("GITEA_BASE_URL", ""))
+ token_env = extra.get("token_env", "GITEA_TOKEN")
+ token = os.getenv(token_env, "")
+
+ if not repo or not pr_number:
+ logger.error(
+ "[webhook] gitea_comment delivery missing repo or pr_number"
+ )
+ return SendResult(success=False, error="Missing repo or pr_number")
+ if not gitea_url:
+ logger.error(
+ "[webhook] gitea_comment delivery missing gitea_url "
+ "(set deliver_extra.gitea_url or GITEA_BASE_URL env var)"
+ )
+ return SendResult(success=False, error="Missing gitea_url")
+ if not token:
+ logger.error(
+ "[webhook] gitea_comment delivery: no token in env var %s",
+ token_env,
+ )
+ return SendResult(success=False, error=f"No token in {token_env}")
+
+ api_url = (
+ f"{gitea_url.rstrip('/')}/api/v1/repos/{repo}/issues/{pr_number}/comments"
+ )
+ try:
+ import aiohttp as _aiohttp
+
+ async with _aiohttp.ClientSession() as session:
+ async with session.post(
+ api_url,
+ json={"body": content},
+ headers={"Authorization": f"token {token}"},
+ timeout=_aiohttp.ClientTimeout(total=30),
+ ) as resp:
+ if resp.status in (200, 201):
+ logger.info(
+ "[webhook] Posted Gitea comment on %s#%s",
+ repo,
+ pr_number,
+ )
+ return SendResult(success=True)
+ resp_text = await resp.text()
+ logger.error(
+ "[webhook] Gitea API error %d: %s",
+ resp.status,
+ resp_text[:200],
+ )
+ return SendResult(
+ success=False, error=f"Gitea API {resp.status}"
+ )
+ except Exception as e:
+ logger.error("[webhook] gitea_comment delivery error: %s", e)
+ return SendResult(success=False, error=str(e))
+
async def _deliver_cross_platform(
self, platform_name: str, content: str, delivery: dict
) -> SendResult:
diff --git a/gateway/run.py b/gateway/run.py
index b75b0e1f0b23..5b8d095f5241 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -18,6 +18,7 @@
import logging
import os
import re
+import uuid
import shlex
import sys
import signal
@@ -25,7 +26,7 @@
import threading
import time
from pathlib import Path
-from datetime import datetime
+from datetime import datetime, timedelta
from typing import Dict, Optional, Any, List
# ---------------------------------------------------------------------------
@@ -89,6 +90,7 @@ def _ensure_ssl_certs() -> None:
# Bridge config.yaml values into the environment so os.getenv() picks them up.
# config.yaml is authoritative for terminal settings — overrides .env.
_config_path = _hermes_home / 'config.yaml'
+_INFLIGHT_RESUME_LEDGER_PATH = _hermes_home / ".inflight_resume.json"
if _config_path.exists():
try:
import yaml as _yaml
@@ -124,12 +126,22 @@ def _ensure_ssl_certs() -> None:
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
+ "docker_network": "TERMINAL_DOCKER_NETWORK",
+ "enable_gateway_local": "TERMINAL_ENABLE_GATEWAY_LOCAL",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
for _cfg_key, _env_var in _terminal_env_map.items():
if _cfg_key in _terminal_cfg:
_val = _terminal_cfg[_cfg_key]
+ # Don't clobber an already-resolved absolute TERMINAL_CWD.
+ # cli.py resolves "." to os.getcwd() at import time, but
+ # this module-level code runs again when gateway/run.py is
+ # imported as a plugin — it would overwrite the absolute
+ # path with the raw "." and then line 208-211 falls back
+ # to Path.home().
+ if _cfg_key == "cwd" and os.path.isabs(os.environ.get(_env_var, "")):
+ continue
if isinstance(_val, list):
os.environ[_env_var] = json.dumps(_val)
else:
@@ -287,6 +299,8 @@ def _expand_whatsapp_auth_aliases(identifier: str) -> set:
# session from bypassing the "already running" guard during the async gap
# between the guard check and actual agent creation.
_AGENT_PENDING_SENTINEL = object()
+_SELF_NUDGE_NO_REPLY = "NO_REPLY"
+_SELF_NUDGE_MAX_DELAY_SECONDS = 86400
def _resolve_runtime_agent_kwargs() -> dict:
@@ -504,6 +518,11 @@ def __init__(self, config: Optional[GatewayConfig] = None):
self._running_agents: Dict[str, Any] = {}
self._running_agents_ts: Dict[str, float] = {} # start timestamp per session
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
+ self._inflight_turns: Dict[str, Dict[str, Any]] = {}
+ self._resuming_inflight_sessions: set[str] = set()
+ self._self_nudge_tasks: Dict[str, asyncio.Task] = {}
+ self._self_nudge_entries: Dict[str, Dict[str, Any]] = {}
+ self._pending_hidden_turns: Dict[str, Dict[str, Any]] = {}
# Cache AIAgent instances per session to preserve prompt caching.
# Without this, a new AIAgent is created per message, rebuilding the
@@ -780,7 +799,7 @@ def _session_key_for_source(self, source: SessionSource) -> str:
thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False),
)
- def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict:
+ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict, context_tokens: int = 0) -> dict:
from agent.smart_model_routing import resolve_turn_route
primary = {
@@ -793,7 +812,10 @@ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwar
"args": list(runtime_kwargs.get("args") or []),
"credential_pool": runtime_kwargs.get("credential_pool"),
}
- return resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary)
+ result = resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary, context_tokens=context_tokens)
+ if result.get("label"):
+ logger.info("smart_model_routing: %s (context: ~%s tokens, message: %r)", result["label"], f"{context_tokens:,}" if context_tokens else "unknown", user_message[:80])
+ return result
async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> None:
"""React to an adapter failure after startup.
@@ -860,6 +882,425 @@ def _request_clean_exit(self, reason: str) -> None:
self._exit_cleanly = True
self._exit_reason = reason
self._shutdown_event.set()
+
+ @staticmethod
+ def _serialize_resume_event(event: Any) -> Optional[Dict[str, Any]]:
+ """Convert a MessageEvent-like object into JSON-safe resume metadata."""
+ if not event:
+ return None
+ return {
+ "text": getattr(event, "text", "") or "",
+ "message_type": getattr(getattr(event, "message_type", None), "value", "text"),
+ "message_id": getattr(event, "message_id", None),
+ "media_urls": list(getattr(event, "media_urls", []) or []),
+ "media_types": list(getattr(event, "media_types", []) or []),
+ "reply_to_message_id": getattr(event, "reply_to_message_id", None),
+ "reply_to_text": getattr(event, "reply_to_text", None),
+ "auto_skill": getattr(event, "auto_skill", None),
+ "persist_user_message": getattr(event, "persist_user_message", None),
+ "timestamp": (
+ getattr(event, "timestamp", None).isoformat()
+ if getattr(event, "timestamp", None) is not None
+ else None
+ ),
+ }
+
+ @staticmethod
+ def _deserialize_resume_event(source: SessionSource, data: Dict[str, Any]):
+ """Rebuild a MessageEvent from persisted resume metadata."""
+ from gateway.platforms.base import MessageEvent, MessageType
+
+ message_type = MessageType.TEXT
+ raw_type = data.get("message_type")
+ if raw_type:
+ try:
+ message_type = MessageType(raw_type)
+ except Exception:
+ pass
+
+ timestamp = None
+ raw_timestamp = data.get("timestamp")
+ if raw_timestamp:
+ try:
+ timestamp = datetime.fromisoformat(raw_timestamp)
+ except Exception:
+ pass
+
+ kwargs = {
+ "text": data.get("text", "") or "",
+ "message_type": message_type,
+ "source": source,
+ "message_id": data.get("message_id"),
+ "media_urls": list(data.get("media_urls") or []),
+ "media_types": list(data.get("media_types") or []),
+ "reply_to_message_id": data.get("reply_to_message_id"),
+ "reply_to_text": data.get("reply_to_text"),
+ "auto_skill": data.get("auto_skill"),
+ "persist_user_message": data.get("persist_user_message"),
+ }
+ if timestamp is not None:
+ kwargs["timestamp"] = timestamp
+ return MessageEvent(**kwargs)
+
+ def _load_inflight_resume_entries(self) -> List[Dict[str, Any]]:
+ """Read persisted interrupted-session recovery entries from disk."""
+ if not _INFLIGHT_RESUME_LEDGER_PATH.exists():
+ return []
+ try:
+ data = json.loads(_INFLIGHT_RESUME_LEDGER_PATH.read_text(encoding="utf-8"))
+ if isinstance(data, list):
+ return [entry for entry in data if isinstance(entry, dict)]
+ except Exception as e:
+ logger.warning("Failed to load inflight resume ledger: %s", e)
+ return []
+
+ def _save_inflight_resume_entries(self, entries: List[Dict[str, Any]]) -> None:
+ """Persist interrupted-session recovery entries atomically."""
+ from utils import atomic_json_write
+
+ if entries:
+ atomic_json_write(_INFLIGHT_RESUME_LEDGER_PATH, entries)
+ else:
+ try:
+ _INFLIGHT_RESUME_LEDGER_PATH.unlink(missing_ok=True)
+ except OSError as e:
+ logger.debug("Failed to remove inflight resume ledger: %s", e)
+
+ def _remove_inflight_resume_entry(self, session_key: str) -> None:
+ """Delete one recovered session entry from the restart ledger."""
+ entries = [
+ entry
+ for entry in self._load_inflight_resume_entries()
+ if entry.get("session_key") != session_key
+ ]
+ self._save_inflight_resume_entries(entries)
+
+ def _collect_pending_resume_event(self, session_key: str, source: SessionSource) -> Optional[Dict[str, Any]]:
+ """Capture any queued follow-up message that would otherwise be lost on restart."""
+ adapter = self.adapters.get(source.platform)
+ if adapter is not None:
+ pending_event = getattr(adapter, "_pending_messages", {}).get(session_key)
+ serialized = self._serialize_resume_event(pending_event)
+ if serialized:
+ return serialized
+
+ queued_text = self._pending_messages.get(session_key)
+ if queued_text:
+ from gateway.platforms.base import MessageEvent, MessageType
+
+ return self._serialize_resume_event(
+ MessageEvent(
+ text=queued_text,
+ message_type=MessageType.TEXT,
+ source=source,
+ )
+ )
+ return None
+
+ def _build_inflight_resume_entries(self) -> List[Dict[str, Any]]:
+ """Capture the in-memory state needed to resume interrupted gateway turns."""
+ try:
+ from tools.approval import has_blocking_approval
+ except Exception:
+ has_blocking_approval = None
+
+ entries: List[Dict[str, Any]] = []
+ for session_key, meta in list(getattr(self, "_inflight_turns", {}).items()):
+ source_data = meta.get("source")
+ if not source_data:
+ continue
+ try:
+ source = SessionSource.from_dict(source_data)
+ except Exception:
+ continue
+
+ entry: Dict[str, Any] = {
+ "session_key": session_key,
+ "session_id": meta.get("session_id"),
+ "source": source_data,
+ "started_at": meta.get("started_at"),
+ "interrupted_event": meta.get("event"),
+ "pending_event": self._collect_pending_resume_event(session_key, source),
+ "approval_blocked": (
+ (
+ bool(has_blocking_approval(session_key))
+ if has_blocking_approval is not None
+ else False
+ )
+ or session_key in getattr(self, "_pending_approvals", {})
+ ),
+ }
+
+ override = getattr(self, "_session_model_overrides", {}).get(session_key)
+ if override:
+ entry["session_model_override"] = dict(override)
+
+ entries.append(entry)
+ return entries
+
+ async def _drain_inflight_turns_for_shutdown(self, timeout_seconds: float = 1.5) -> None:
+ """Give interrupted turns a brief chance to finish before snapshotting them."""
+ deadline = time.monotonic() + max(timeout_seconds, 0.0)
+ while time.monotonic() < deadline:
+ if not getattr(self, "_inflight_turns", {}):
+ return
+ await asyncio.sleep(0.05)
+
+ def _schedule_inflight_resumption_recovery(self) -> None:
+ """Resume any persisted interrupted sessions that are now routable."""
+ if not getattr(self.config, "resume_inflight_sessions_on_restart", False):
+ if _INFLIGHT_RESUME_LEDGER_PATH.exists():
+ logger.info(
+ "Discarding inflight resume ledger because resume_inflight_sessions_on_restart is disabled"
+ )
+ self._save_inflight_resume_entries([])
+ return
+
+ task = asyncio.create_task(self._recover_inflight_sessions())
+ self._background_tasks.add(task)
+
+ def _cleanup(done_task):
+ self._background_tasks.discard(done_task)
+
+ task.add_done_callback(_cleanup)
+
+ async def _recover_inflight_sessions(self) -> None:
+ """Schedule hidden continuation turns for sessions interrupted by restart."""
+ entries = self._load_inflight_resume_entries()
+ if not entries:
+ return
+
+ resuming = getattr(self, "_resuming_inflight_sessions", None)
+ if resuming is None:
+ resuming = set()
+ self._resuming_inflight_sessions = resuming
+
+ for entry in entries:
+ try:
+ source = SessionSource.from_dict(entry.get("source") or {})
+ except Exception:
+ continue
+ session_key = entry.get("session_key") or ""
+ if (
+ source.platform not in self.adapters
+ or not session_key
+ or session_key in resuming
+ ):
+ continue
+ resuming.add(session_key)
+ task = asyncio.create_task(self._resume_interrupted_session(entry))
+ self._background_tasks.add(task)
+
+ def _cleanup(done_task, key=session_key):
+ self._background_tasks.discard(done_task)
+ self._resuming_inflight_sessions.discard(key)
+
+ task.add_done_callback(_cleanup)
+
+ async def _resume_interrupted_session(self, entry: Dict[str, Any]) -> None:
+ """Run a hidden continuation turn after a graceful restart."""
+ session_key = entry.get("session_key") or ""
+ if not session_key or session_key in self._running_agents:
+ return
+
+ try:
+ source = SessionSource.from_dict(entry.get("source") or {})
+ except Exception as e:
+ logger.warning("Skipping invalid inflight resume entry: %s", e)
+ return
+
+ persisted_note = (
+ "[System note: The gateway restarted while a previous turn was in progress. "
+ "Resume from the last persisted context and continue the unfinished work.]"
+ )
+ resume_prompt = (
+ persisted_note
+ + (
+ " [A dangerous-command approval was pending before restart and was lost. "
+ "If the task still needs that action, request approval again.]"
+ if entry.get("approval_blocked")
+ else ""
+ )
+ )
+
+ pending_event_data = entry.get("pending_event")
+ if pending_event_data:
+ event = self._deserialize_resume_event(source, pending_event_data)
+ clean_text = event.persist_user_message or event.text
+ hidden_prefix = (
+ "[System note: The gateway restarted while you were processing the previous turn. "
+ "That in-flight turn was interrupted. Continue from the last persisted context "
+ "and handle the queued user follow-up below.]"
+ )
+ event.text = f"{hidden_prefix}\n\n{event.text or ''}".strip()
+ event.persist_user_message = clean_text
+ else:
+ from gateway.platforms.base import MessageEvent
+
+ event = MessageEvent(
+ text=resume_prompt,
+ source=source,
+ message_id=f"restart-resume-{uuid.uuid4().hex[:8]}",
+ persist_user_message=persisted_note,
+ )
+
+ override = entry.get("session_model_override")
+ if override:
+ self._session_model_overrides[session_key] = dict(override)
+
+ self._running_agents[session_key] = _AGENT_PENDING_SENTINEL
+ self._running_agents_ts[session_key] = time.time()
+ try:
+ await self._handle_message_with_agent(event, source, session_key)
+ self._remove_inflight_resume_entry(session_key)
+ finally:
+ if self._running_agents.get(session_key) is _AGENT_PENDING_SENTINEL:
+ del self._running_agents[session_key]
+ self._running_agents_ts.pop(session_key, None)
+ getattr(self, "_inflight_turns", {}).pop(session_key, None)
+
+ def _build_self_nudge_text(self, entry: Dict[str, Any]) -> str:
+ """Build the hidden prompt text for a fired self-nudge."""
+ hidden_prefix = (
+ "[System note: A self-nudge timer you armed earlier has fired. "
+ "Resume from the last persisted context and continue the follow-up task below.]"
+ )
+ note = str(entry.get("note") or "").strip()
+ if note:
+ return f"{hidden_prefix}\n\nReminder:\n{note}"
+ return hidden_prefix
+
+ async def _cancel_self_nudge(self, session_key: str, reason: str = "") -> bool:
+ """Cancel the active self-nudge for a session, if any."""
+ task = getattr(self, "_self_nudge_tasks", {}).pop(session_key, None)
+ entry = getattr(self, "_self_nudge_entries", {}).pop(session_key, None)
+ getattr(self, "_pending_hidden_turns", {}).pop(session_key, None)
+ if task:
+ task.cancel()
+ if entry and reason:
+ logger.debug("Cancelled self-nudge for %s (%s)", session_key[:20], reason)
+ return bool(task or entry)
+
+ async def _arm_self_nudge(
+ self,
+ session_key: str,
+ source: SessionSource,
+ delay_seconds: int,
+ note: str = "",
+ ) -> Dict[str, Any]:
+ """Arm or replace a one-shot self-nudge for a session."""
+ seconds = int(delay_seconds)
+ if seconds <= 0:
+ return {"armed": False, "error": "delay_seconds must be greater than zero."}
+ if seconds > _SELF_NUDGE_MAX_DELAY_SECONDS:
+ return {
+ "armed": False,
+ "error": (
+ f"delay_seconds exceeds the maximum of "
+ f"{_SELF_NUDGE_MAX_DELAY_SECONDS} seconds."
+ ),
+ }
+
+ replaced = await self._cancel_self_nudge(session_key, reason="replaced")
+ due_at = datetime.now() + timedelta(seconds=seconds)
+ entry = {
+ "session_key": session_key,
+ "source": source.to_dict(),
+ "delay_seconds": seconds,
+ "note": str(note or "").strip(),
+ "due_at": due_at.isoformat(),
+ }
+ self._self_nudge_entries[session_key] = entry
+
+ task = asyncio.create_task(self._fire_self_nudge(entry))
+ self._self_nudge_tasks[session_key] = task
+
+ def _cleanup(done_task, key=session_key):
+ current = self._self_nudge_tasks.get(key)
+ if current is done_task:
+ self._self_nudge_tasks.pop(key, None)
+
+ task.add_done_callback(_cleanup)
+ return {
+ "armed": True,
+ "delay_seconds": seconds,
+ "due_at": due_at.isoformat(),
+ "replaced_existing": replaced,
+ "note": entry["note"],
+ }
+
+ async def _fire_self_nudge(self, entry: Dict[str, Any]) -> None:
+ """Wait for a self-nudge timer, then enqueue or run its hidden turn."""
+ session_key = entry.get("session_key") or ""
+ delay = max(int(entry.get("delay_seconds") or 0), 0)
+ if not session_key or delay <= 0:
+ return
+
+ try:
+ await asyncio.sleep(delay)
+ except asyncio.CancelledError:
+ raise
+
+ current = self._self_nudge_entries.get(session_key)
+ if current is not entry:
+ return
+ self._self_nudge_entries.pop(session_key, None)
+ if session_key in self._running_agents:
+ self._pending_hidden_turns[session_key] = entry
+ return
+ await self._run_self_nudge_entry(entry)
+
+ async def _run_self_nudge_entry(self, entry: Dict[str, Any]) -> None:
+ """Inject a hidden follow-up turn for a fired self-nudge."""
+ session_key = entry.get("session_key") or ""
+ if not session_key or session_key in self._running_agents:
+ return
+ try:
+ source = SessionSource.from_dict(entry.get("source") or {})
+ except Exception as e:
+ logger.warning("Skipping invalid self-nudge entry: %s", e)
+ return
+
+ event = MessageEvent(
+ text=self._build_self_nudge_text(entry),
+ source=source,
+ message_id=f"self-nudge-{uuid.uuid4().hex[:8]}",
+ persist_user_message="[System note: a self-nudge timer fired.]",
+ )
+
+ # Send typing indicator so the user sees activity during the
+ # self-nudge hidden turn (same as the normal message path).
+ adapter = self.adapters.get(source.platform)
+ if adapter and hasattr(adapter, "send_typing"):
+ try:
+ _thread_meta = {"thread_id": source.thread_id} if source.thread_id else None
+ await adapter.send_typing(source.chat_id, metadata=_thread_meta)
+ except Exception:
+ pass
+
+ self._running_agents[session_key] = _AGENT_PENDING_SENTINEL
+ self._running_agents_ts[session_key] = time.time()
+ try:
+ response = await self._handle_message_with_agent(event, source, session_key)
+ # _handle_message_with_agent returns None when streaming already
+ # delivered the response. For self-nudge turns, streaming typically
+ # doesn't deliver (no user message to stream-edit), so we must
+ # explicitly send the response here.
+ if response and adapter:
+ _thread_meta = {"thread_id": source.thread_id} if source.thread_id else None
+ try:
+ await adapter.send(
+ source.chat_id,
+ response,
+ metadata=_thread_meta,
+ )
+ except Exception as e:
+ logger.warning("Failed to deliver self-nudge response: %s", e)
+ finally:
+ if self._running_agents.get(session_key) is _AGENT_PENDING_SENTINEL:
+ del self._running_agents[session_key]
+ self._running_agents_ts.pop(session_key, None)
+ getattr(self, "_inflight_turns", {}).pop(session_key, None)
@staticmethod
def _load_prefill_messages() -> List[Dict[str, Any]]:
@@ -1251,6 +1692,8 @@ async def start(self) -> bool:
except Exception as e:
logger.error("Recovered watcher setup error: %s", e)
+ self._schedule_inflight_resumption_recovery()
+
# Start background session expiry watcher for proactive memory flushing
asyncio.create_task(self._session_expiry_watcher())
@@ -1441,6 +1884,7 @@ async def _platform_reconnect_watcher(self) -> None:
build_channel_directory(self.adapters)
except Exception:
pass
+ self._schedule_inflight_resumption_recovery()
else:
# Check if the failure is non-retryable
if adapter.has_fatal_error and not adapter.fatal_error_retryable:
@@ -1500,6 +1944,13 @@ async def stop(self) -> None:
except Exception:
pass
+ if getattr(self.config, "resume_inflight_sessions_on_restart", False):
+ try:
+ await self._drain_inflight_turns_for_shutdown()
+ self._save_inflight_resume_entries(self._build_inflight_resume_entries())
+ except Exception as e:
+ logger.warning("Failed to persist inflight resume ledger: %s", e)
+
for platform, adapter in list(self.adapters.items()):
try:
await adapter.cancel_background_tasks()
@@ -1515,9 +1966,15 @@ async def stop(self) -> None:
for _task in list(self._background_tasks):
_task.cancel()
self._background_tasks.clear()
+ for _task in list(getattr(self, "_self_nudge_tasks", {}).values()):
+ _task.cancel()
+ getattr(self, "_self_nudge_tasks", {}).clear()
+ getattr(self, "_self_nudge_entries", {}).clear()
+ getattr(self, "_pending_hidden_turns", {}).clear()
self.adapters.clear()
self._running_agents.clear()
+ getattr(self, "_inflight_turns", {}).clear()
self._pending_messages.clear()
self._pending_approvals.clear()
self._shutdown_event.set()
@@ -2089,6 +2546,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
if canonical == "provider":
return await self._handle_provider_command(event)
+
+ if canonical == "models":
+ return await self._handle_models_command(event)
if canonical == "personality":
return await self._handle_personality_command(event)
@@ -2301,6 +2761,8 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
# message arriving during any of those yields would pass the
# "already running" guard and spin up a duplicate agent for the
# same session — corrupting the transcript.
+ if not str(getattr(event, "message_id", "") or "").startswith("self-nudge-"):
+ await self._cancel_self_nudge(_quick_key, reason="new_user_message")
self._running_agents[_quick_key] = _AGENT_PENDING_SENTINEL
self._running_agents_ts[_quick_key] = time.time()
@@ -2314,6 +2776,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
if self._running_agents.get(_quick_key) is _AGENT_PENDING_SENTINEL:
del self._running_agents[_quick_key]
self._running_agents_ts.pop(_quick_key, None)
+ getattr(self, "_inflight_turns", {}).pop(_quick_key, None)
async def _handle_message_with_agent(self, event, source, _quick_key: str):
"""Inner handler that runs under the _running_agents sentinel guard."""
@@ -2345,6 +2808,18 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
# Build session context
context = build_session_context(source, self.config, session_entry)
+
+ inflight_turns = getattr(self, "_inflight_turns", None)
+ if inflight_turns is None:
+ inflight_turns = {}
+ self._inflight_turns = inflight_turns
+ inflight_turns[session_key] = {
+ "session_key": session_key,
+ "session_id": session_entry.session_id,
+ "source": source.to_dict(),
+ "event": self._serialize_resume_event(event),
+ "started_at": datetime.now().isoformat(),
+ }
# Set environment variables for tools
self._set_session_env(context)
@@ -2925,6 +3400,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
session_id=session_entry.session_id,
session_key=session_key,
event_message_id=event.message_id,
+ persist_user_message=getattr(event, "persist_user_message", None),
)
# Stop persistent typing indicator now that the agent is done
@@ -2946,6 +3422,17 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
_response_time, _api_calls, _resp_len,
)
+ if (
+ agent_result.get("self_nudge_armed")
+ and isinstance(response, str)
+ and response.strip().upper() == _SELF_NUDGE_NO_REPLY
+ ):
+ response = ""
+ if agent_messages and agent_messages[-1].get("role") == "assistant":
+ _last_content = str(agent_messages[-1].get("content") or "").strip().upper()
+ if _last_content == _SELF_NUDGE_NO_REPLY:
+ agent_messages.pop()
+
# Surface error details when the agent failed silently (final_response=None)
if not response and agent_result.get("failed"):
error_detail = agent_result.get("error", "unknown error")
@@ -3090,9 +3577,15 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
skip_db=agent_persisted,
)
- # Token counts and model are now persisted by the agent directly.
- # Keep only last_prompt_tokens here for context-window tracking and
- # compression decisions.
+ # Token counts are persisted by the agent directly into SessionDB
+ # via _flush_messages_to_session_db. The gateway only tracks
+ # last_prompt_tokens here for context-window tracking and
+ # compression decisions. /status reads token totals from SessionDB
+ # via SessionDB.get_session_token_totals — see
+ # _handle_status_command. Adopted from upstream PR #5989 (which
+ # also restored the upstream-correct update_session signature
+ # after fork-local commit 1daa37bb had reintroduced the
+ # accumulator pattern).
self.session_store.update_session(
session_entry.session_key,
last_prompt_tokens=agent_result.get("last_prompt_tokens", 0),
@@ -3278,6 +3771,7 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
except Exception as e:
logger.debug("Gateway memory flush on reset failed: %s", e)
self._evict_cached_agent(session_key)
+ await self._cancel_self_nudge(session_key, reason="session_reset")
try:
from tools.env_passthrough import clear_env_passthrough
@@ -3389,11 +3883,22 @@ async def _handle_status_command(self, event: MessageEvent) -> str:
is_running = session_key in self._running_agents
title = None
+ token_totals = None
if self._session_db:
try:
title = self._session_db.get_session_title(session_entry.session_id)
+ token_totals = self._session_db.get_session_token_totals(session_entry.session_id)
except Exception:
title = None
+ token_totals = None
+
+ # Use SessionDB token totals for authoritative count; fall back to
+ # session_store. SessionDB is the source of truth — tokens are
+ # persisted there directly by the agent (commit 20441cf2). The
+ # session_store fallback is only hit when the DB row is missing
+ # (fresh install, DB unavailable, or a session that predates the
+ # SessionDB persistence).
+ total_tokens = token_totals["total_tokens"] if token_totals else session_entry.total_tokens
lines = [
"📊 **Hermes Gateway Status**",
@@ -3405,7 +3910,7 @@ async def _handle_status_command(self, event: MessageEvent) -> str:
lines.extend([
f"**Created:** {session_entry.created_at.strftime('%Y-%m-%d %H:%M')}",
f"**Last Activity:** {session_entry.updated_at.strftime('%Y-%m-%d %H:%M')}",
- f"**Tokens:** {session_entry.total_tokens:,}",
+ f"**Tokens:** {total_tokens:,}",
f"**Agent Running:** {'Yes ⚡' if is_running else 'No'}",
"",
f"**Connected Platforms:** {', '.join(connected_platforms)}",
@@ -3884,24 +4389,130 @@ async def _handle_provider_command(self, event: MessageEvent) -> str:
lines.append("Setup: `hermes setup`")
return "\n".join(lines)
- async def _handle_personality_command(self, event: MessageEvent) -> str:
- """Handle /personality command - list or set a personality."""
+ async def _handle_models_command(self, event: MessageEvent) -> str:
+ """Handle /models [provider|custom:name] — list available models."""
import yaml
+ from hermes_cli.models import (
+ normalize_provider,
+ provider_model_ids,
+ curated_models_for_provider,
+ _PROVIDER_LABELS,
+ fetch_api_models,
+ )
- args = event.get_command_args().strip().lower()
- config_path = _hermes_home / 'config.yaml'
+ _TRUNCATE = 50
+ # ── Resolve current provider + model from config ──────────────────
+ current_provider = "openrouter"
+ current_model = ""
+ config_path = _hermes_home / "config.yaml"
try:
if config_path.exists():
- with open(config_path, 'r', encoding="utf-8") as f:
- config = yaml.safe_load(f) or {}
- personalities = config.get("agent", {}).get("personalities", {})
- else:
- config = {}
- personalities = {}
+ with open(config_path, encoding="utf-8") as f:
+ cfg = yaml.safe_load(f) or {}
+ model_cfg = cfg.get("model", {})
+ if isinstance(model_cfg, dict):
+ current_provider = model_cfg.get("provider", current_provider)
+ current_model = model_cfg.get("model", "") or ""
except Exception:
- config = {}
- personalities = {}
+ pass
+
+ current_provider = normalize_provider(current_provider)
+
+ # ── Parse requested provider from args ────────────────────────────
+ arg = event.get_command_args().strip()
+ if arg:
+ requested = arg.lower()
+ else:
+ requested = current_provider
+
+ # ── Named custom provider: custom:lmstudio ────────────────────────
+ if requested.startswith("custom:"):
+ custom_name = requested[len("custom:"):].strip()
+ models: list[str] = []
+ base_url = ""
+ api_key = ""
+ try:
+ def _norm(s: str) -> str:
+ return s.strip().lower().replace(" ", "-")
+ from hermes_cli.config import load_config as _load_cfg
+ _cfg = _load_cfg()
+ for entry in (_cfg.get("custom_providers") or []):
+ if not isinstance(entry, dict):
+ continue
+ ename = _norm(str(entry.get("name", "")))
+ if ename == _norm(custom_name):
+ base_url = str(entry.get("base_url", "")).strip()
+ api_key = str(entry.get("api_key", "") or "").strip()
+ break
+ except Exception:
+ pass
+
+ if not base_url:
+ return f"No custom provider named `{custom_name}` found in config.\nCheck `~/.hermes/config.yaml` → `custom_providers`."
+
+ live = fetch_api_models(api_key, base_url)
+ if live:
+ models = live
+ if not models:
+ return f"Could not fetch models from `{base_url}/models`. The endpoint may not support model listing."
+
+ provider_label = f"custom:{custom_name}"
+ lines = [f"🤖 **Models for {provider_label}** ({len(models)} total)\n"]
+ shown = models[:_TRUNCATE]
+ for m in shown:
+ marker = " ← active" if m == current_model else ""
+ lines.append(f"`{m}`{marker}")
+ if len(models) > _TRUNCATE:
+ lines.append(f"\n_…and {len(models) - _TRUNCATE} more. Use `/model {provider_label}:` to switch._")
+ else:
+ lines.append(f"\nUse `/model {provider_label}:` to switch.")
+ return "\n".join(lines)
+
+ # ── Named provider ─────────────────────────────────────────────────
+ normalized = normalize_provider(requested)
+ provider_label = _PROVIDER_LABELS.get(normalized, normalized)
+ models = provider_model_ids(normalized)
+ if not models:
+ pairs = curated_models_for_provider(normalized)
+ models = [m for m, _ in pairs]
+
+ if not models:
+ return (
+ f"No model list available for `{normalized}`.\n"
+ "The provider may not support model listing or may not be configured."
+ )
+
+ total = len(models)
+ shown = models[:_TRUNCATE]
+ is_active_provider = (normalized == current_provider)
+
+ lines = [f"🤖 **Models for {provider_label}** ({total} total)\n"]
+ for m in shown:
+ marker = " ← active" if (is_active_provider and m == current_model) else ""
+ lines.append(f"`{m}`{marker}")
+
+ if total > _TRUNCATE:
+ lines.append(f"\n_…and {total - _TRUNCATE} more._")
+
+ lines.append(f"\nUse `/model {normalized}:` to switch.")
+ return "\n".join(lines)
+
+ async def _handle_personality_command(self, event: MessageEvent) -> str:
+ """Handle /personality command - list or set a personality."""
+ args = event.get_command_args().strip().lower()
+ config_path = _hermes_home / 'config.yaml'
+
+ from hermes_cli.config import load_raw_config_mapping_result
+
+ config, error_result = load_raw_config_mapping_result(
+ config_path,
+ action="read personality settings",
+ )
+ if error_result is not None:
+ return f"⚠️ {error_result.error}"
+ assert config is not None
+ personalities = config.get("agent", {}).get("personalities", {})
if not personalities:
return "No personalities configured in `~/.hermes/config.yaml`"
@@ -3930,10 +4541,14 @@ def _resolve_prompt(value):
if args in ("none", "default", "neutral"):
try:
+ from hermes_cli.config import describe_config_write_failure, save_yaml_config_result
+
if "agent" not in config or not isinstance(config.get("agent"), dict):
config["agent"] = {}
config["agent"]["system_prompt"] = ""
- atomic_yaml_write(config_path, config)
+ result = save_yaml_config_result(config_path, config)
+ if not result:
+ return f"⚠️ Personality cleared for this session only.\n\n{describe_config_write_failure(result, action='save the personality change')}"
except Exception as e:
return f"⚠️ Failed to save personality change: {e}"
self._ephemeral_system_prompt = ""
@@ -3943,10 +4558,18 @@ def _resolve_prompt(value):
# Write to config.yaml, same pattern as CLI save_config_value.
try:
+ from hermes_cli.config import describe_config_write_failure, save_yaml_config_result
+
if "agent" not in config or not isinstance(config.get("agent"), dict):
config["agent"] = {}
config["agent"]["system_prompt"] = new_prompt
- atomic_yaml_write(config_path, config)
+ result = save_yaml_config_result(config_path, config)
+ if not result:
+ self._ephemeral_system_prompt = new_prompt
+ return (
+ f"🎭 Personality set to **{args}**\n_(session only — config is locked)_\n\n"
+ f"{describe_config_write_failure(result, action='save the personality change')}"
+ )
except Exception as e:
return f"⚠️ Failed to save personality change: {e}"
@@ -4026,21 +4649,31 @@ async def _handle_set_home_command(self, event: MessageEvent) -> str:
chat_name = source.chat_name or chat_id
env_key = f"{platform_name.upper()}_HOME_CHANNEL"
+ try:
+ from gateway.config import HomeChannel
+
+ if getattr(self, "config", None) and source.platform in self.config.platforms:
+ self.config.platforms[source.platform].home_channel = HomeChannel(
+ platform=source.platform,
+ chat_id=str(chat_id),
+ name=str(chat_name),
+ )
+ except Exception:
+ logger.debug("Failed to update in-memory home channel", exc_info=True)
- # Save to config.yaml
+ # Save to .env
try:
- import yaml
- config_path = _hermes_home / 'config.yaml'
- user_config = {}
- if config_path.exists():
- with open(config_path, encoding="utf-8") as f:
- user_config = yaml.safe_load(f) or {}
- user_config[env_key] = chat_id
- atomic_yaml_write(config_path, user_config)
+ from hermes_cli.config import save_env_value
+
+ save_env_value(env_key, str(chat_id))
# Also set in the current environment so it takes effect immediately
os.environ[env_key] = str(chat_id)
except Exception as e:
- return f"Failed to save home channel: {e}"
+ os.environ[env_key] = str(chat_id)
+ return (
+ f"✅ Home channel set to **{chat_name}** (ID: {chat_id}) for this running gateway only.\n"
+ f"_(could not persist to .env: {e})_"
+ )
return (
f"✅ Home channel set to **{chat_name}** (ID: {chat_id}).\n"
@@ -4854,22 +5487,17 @@ async def _handle_reasoning_command(self, event: MessageEvent) -> str:
def _save_config_key(key_path: str, value):
"""Save a dot-separated key to config.yaml."""
try:
- user_config = {}
- if config_path.exists():
- with open(config_path, encoding="utf-8") as f:
- user_config = yaml.safe_load(f) or {}
- keys = key_path.split(".")
- current = user_config
- for k in keys[:-1]:
- if k not in current or not isinstance(current[k], dict):
- current[k] = {}
- current = current[k]
- current[keys[-1]] = value
- atomic_yaml_write(config_path, user_config)
- return True
+ from hermes_cli.config import ConfigWriteResult, save_config_key_result
+
+ return save_config_key_result(key_path, value, config_path=config_path)
except Exception as e:
logger.error("Failed to save config key %s: %s", key_path, e)
- return False
+ return ConfigWriteResult(
+ success=False,
+ path=config_path,
+ error=e,
+ blocked=False,
+ )
if not args:
# Show current state
@@ -4891,13 +5519,25 @@ def _save_config_key(key_path: str, value):
# Display toggle
if args in ("show", "on"):
self._show_reasoning = True
- _save_config_key("display.show_reasoning", True)
- return "🧠 ✓ Reasoning display: **ON**\nModel thinking will be shown before each response."
+ result = _save_config_key("display.show_reasoning", True)
+ if result:
+ return "🧠 ✓ Reasoning display: **ON**\nModel thinking will be shown before each response."
+ from hermes_cli.config import describe_config_write_failure
+ return (
+ "🧠 ✓ Reasoning display: **ON**\n_(session only — config is locked)_\n\n"
+ f"{describe_config_write_failure(result, action='save reasoning display settings')}"
+ )
if args in ("hide", "off"):
self._show_reasoning = False
- _save_config_key("display.show_reasoning", False)
- return "🧠 ✓ Reasoning display: **OFF**"
+ result = _save_config_key("display.show_reasoning", False)
+ if result:
+ return "🧠 ✓ Reasoning display: **OFF**"
+ from hermes_cli.config import describe_config_write_failure
+ return (
+ "🧠 ✓ Reasoning display: **OFF**\n_(session only — config is locked)_\n\n"
+ f"{describe_config_write_failure(result, action='save reasoning display settings')}"
+ )
# Effort level change
effort = args.strip()
@@ -4913,10 +5553,14 @@ def _save_config_key(key_path: str, value):
)
self._reasoning_config = parsed
- if _save_config_key("agent.reasoning_effort", effort):
+ result = _save_config_key("agent.reasoning_effort", effort)
+ if result:
return f"🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_"
- else:
- return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)"
+ from hermes_cli.config import describe_config_write_failure
+ return (
+ f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)\n\n"
+ f"{describe_config_write_failure(result, action='save reasoning effort')}"
+ )
async def _handle_yolo_command(self, event: MessageEvent) -> str:
"""Handle /yolo — toggle dangerous command approval bypass."""
@@ -4935,19 +5579,19 @@ async def _handle_verbose_command(self, event: MessageEvent) -> str:
When enabled, cycles the tool progress mode through off → new → all →
verbose → off, same as the CLI.
"""
- import yaml
-
config_path = _hermes_home / "config.yaml"
# --- check config gate ------------------------------------------------
- try:
- user_config = {}
- if config_path.exists():
- with open(config_path, encoding="utf-8") as f:
- user_config = yaml.safe_load(f) or {}
- gate_enabled = user_config.get("display", {}).get("tool_progress_command", False)
- except Exception:
- gate_enabled = False
+ from hermes_cli.config import load_raw_config_mapping_result
+
+ user_config, error_result = load_raw_config_mapping_result(
+ config_path,
+ action="read `/verbose` settings",
+ )
+ if error_result is not None:
+ return f"⚠️ {error_result.error}"
+ assert user_config is not None
+ gate_enabled = user_config.get("display", {}).get("tool_progress_command", False)
if not gate_enabled:
return (
@@ -4980,11 +5624,18 @@ async def _handle_verbose_command(self, event: MessageEvent) -> str:
# Save to config.yaml
try:
+ from hermes_cli.config import describe_config_write_failure, save_yaml_config_result
+
if "display" not in user_config or not isinstance(user_config.get("display"), dict):
user_config["display"] = {}
user_config["display"]["tool_progress"] = new_mode
- atomic_yaml_write(config_path, user_config)
- return f"{descriptions[new_mode]}\n_(saved to config — takes effect on next message)_"
+ result = save_yaml_config_result(config_path, user_config)
+ if result:
+ return f"{descriptions[new_mode]}\n_(saved to config — takes effect on next message)_"
+ return (
+ f"{descriptions[new_mode]}\n_(session only — config is locked)_\n\n"
+ f"{describe_config_write_failure(result, action='save tool progress mode')}"
+ )
except Exception as e:
logger.warning("Failed to save tool_progress mode: %s", e)
return f"{descriptions[new_mode]}\n_(could not save to config: {e})_"
@@ -5933,16 +6584,17 @@ async def _send_update_notification(self) -> bool:
def _set_session_env(self, context: SessionContext) -> None:
"""Set environment variables for the current session."""
+ os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_SESSION_PLATFORM"] = context.source.platform.value
os.environ["HERMES_SESSION_CHAT_ID"] = context.source.chat_id
if context.source.chat_name:
os.environ["HERMES_SESSION_CHAT_NAME"] = context.source.chat_name
if context.source.thread_id:
os.environ["HERMES_SESSION_THREAD_ID"] = str(context.source.thread_id)
-
+
def _clear_session_env(self) -> None:
"""Clear session environment variables."""
- for var in ["HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME", "HERMES_SESSION_THREAD_ID"]:
+ for var in ["HERMES_GATEWAY_SESSION", "HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME", "HERMES_SESSION_THREAD_ID"]:
if var in os.environ:
del os.environ[var]
@@ -6300,6 +6952,7 @@ async def _run_agent(
session_key: str = None,
_interrupt_depth: int = 0,
event_message_id: Optional[str] = None,
+ persist_user_message: Optional[str] = None,
) -> Dict[str, Any]:
"""
Run the agent with the given message and context.
@@ -6614,6 +7267,59 @@ def _status_callback_sync(event_type: str, message: str) -> None:
except Exception as _e:
logger.debug("status_callback error (%s): %s", event_type, _e)
+ def _message_callback_sync(message: str) -> None:
+ if not _status_adapter or not message:
+ return
+ try:
+ asyncio.run_coroutine_threadsafe(
+ _status_adapter.send(
+ _status_chat_id,
+ message,
+ metadata=_status_thread_metadata,
+ ),
+ _loop_for_step,
+ )
+ except Exception as _e:
+ logger.debug("message_callback error: %s", _e)
+
+ def _media_message_callback_sync(media_files: list) -> None:
+ """Deliver media files extracted from a send_user_message call."""
+ if not _status_adapter or not media_files:
+ return
+ from pathlib import Path
+ _AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'}
+ _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
+ _IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
+ for media_path, is_voice in media_files:
+ try:
+ ext = Path(media_path).suffix.lower()
+ if ext in _AUDIO_EXTS:
+ coro = _status_adapter.send_voice(chat_id=_status_chat_id, audio_path=media_path, metadata=_status_thread_metadata)
+ elif ext in _VIDEO_EXTS:
+ coro = _status_adapter.send_video(chat_id=_status_chat_id, video_path=media_path, metadata=_status_thread_metadata)
+ elif ext in _IMAGE_EXTS:
+ coro = _status_adapter.send_image_file(chat_id=_status_chat_id, image_path=media_path, metadata=_status_thread_metadata)
+ else:
+ coro = _status_adapter.send_document(chat_id=_status_chat_id, file_path=media_path, metadata=_status_thread_metadata)
+ asyncio.run_coroutine_threadsafe(coro, _loop_for_step)
+ except Exception as _e:
+ logger.debug("media_message_callback error for %s: %s", media_path, _e)
+
+ def _self_nudge_callback_sync(delay_seconds: int, note: str = "") -> dict:
+ try:
+ future = asyncio.run_coroutine_threadsafe(
+ self._arm_self_nudge(
+ session_key=session_key,
+ source=source,
+ delay_seconds=delay_seconds,
+ note=note,
+ ),
+ _loop_for_step,
+ )
+ return future.result(timeout=15)
+ except Exception as _e:
+ return {"armed": False, "error": f"Failed to arm self-nudge: {_e}"}
+
def run_sync():
# The conditional re-assignment of `message` further below
# (prepending model-switch notes) makes Python treat it as a
@@ -6692,7 +7398,48 @@ def run_sync():
except Exception as _sc_err:
logger.debug("Could not set up stream consumer: %s", _sc_err)
- turn_route = self._resolve_turn_agent_config(message, model, runtime_kwargs)
+ # Estimate context tokens for smart routing decision.
+ # Use cached agent's last prompt tokens if available; otherwise
+ # rough estimate from history length (4 chars ≈ 1 token).
+ _est_context_tokens = 0
+ _cache_lock = getattr(self, "_agent_cache_lock", None)
+ _cache = getattr(self, "_agent_cache", None)
+ if _cache_lock and _cache is not None:
+ with _cache_lock:
+ _cached_entry = _cache.get(session_key)
+ if _cached_entry:
+ _cached_agent = _cached_entry[0]
+ _est_context_tokens = getattr(_cached_agent, "session_prompt_tokens", 0) or 0
+ if not _est_context_tokens and history:
+ _est_context_tokens = sum(len(str(m.get("content", ""))) for m in history) // 4
+
+ turn_route = self._resolve_turn_agent_config(message, model, runtime_kwargs, context_tokens=_est_context_tokens)
+
+ # Smart routing context trim: when routing to a cheap model with
+ # a large context, trim conversation history from the head (keep
+ # the most recent messages) to fit within the cheap model's
+ # comfortable context window.
+ # NOTE: uses _trimmed_history to avoid reassigning the `history`
+ # parameter — Python treats any assignment as local, causing
+ # UnboundLocalError on earlier reads.
+ _trim_to = turn_route.get("trim_to_tokens")
+ _trimmed_history = None
+ if _trim_to and history:
+ _kept = []
+ _token_budget = _trim_to
+ for msg in reversed(history):
+ msg_tokens = len(str(msg.get("content", ""))) // 4
+ if _token_budget - msg_tokens < 0 and _kept:
+ break
+ _kept.append(msg)
+ _token_budget -= msg_tokens
+ if len(_kept) < len(history):
+ _trimmed_history = list(reversed(_kept))
+ logger.info(
+ "smart_model_routing: trimmed history from ~%s to ~%s tokens (%d→%d messages)",
+ f"{_est_context_tokens:,}", f"{_trim_to:,}",
+ len(history), len(_trimmed_history),
+ )
# Check agent cache — reuse the AIAgent from the previous message
# in this session to preserve the frozen system prompt and tool
@@ -6704,8 +7451,6 @@ def run_sync():
combined_ephemeral,
)
agent = None
- _cache_lock = getattr(self, "_agent_cache_lock", None)
- _cache = getattr(self, "_agent_cache", None)
if _cache_lock and _cache is not None:
with _cache_lock:
cached = _cache.get(session_key)
@@ -6747,6 +7492,9 @@ def run_sync():
agent.tool_progress_callback = progress_callback if tool_progress_enabled else None
agent.step_callback = _step_callback_sync if _hooks_ref.loaded_hooks else None
agent.stream_delta_callback = _stream_delta_cb
+ agent.message_callback = _message_callback_sync
+ agent.media_message_callback = _media_message_callback_sync
+ agent.self_nudge_callback = _self_nudge_callback_sync
agent.status_callback = _status_callback_sync
agent.reasoning_config = reasoning_config
@@ -6782,7 +7530,8 @@ def _bg_review_send(message: str) -> None:
# - These must be passed through intact so the API sees valid
# assistant→tool sequences (dropping tool_calls causes 500 errors)
agent_history = []
- for msg in history:
+ _effective_history = _trimmed_history if _trimmed_history is not None else history
+ for msg in _effective_history:
role = msg.get("role")
if not role:
continue
@@ -6915,14 +7664,29 @@ def _approval_notify_sync(approval_data: dict) -> None:
# Prepend pending model switch note so the model knows about the switch
_pending_notes = getattr(self, '_pending_model_notes', {})
_msn = _pending_notes.pop(session_key, None) if session_key else None
+ prompt_message = message
if _msn:
- message = _msn + "\n\n" + message
+ prompt_message = _msn + "\n\n" + prompt_message
_approval_session_key = session_key or ""
_approval_session_token = set_current_session_key(_approval_session_key)
register_gateway_notify(_approval_session_key, _approval_notify_sync)
try:
- result = agent.run_conversation(message, conversation_history=agent_history, task_id=session_id)
+ _run_kwargs = {
+ "conversation_history": agent_history,
+ "task_id": session_id,
+ }
+ if persist_user_message is not None:
+ try:
+ import inspect as _inspect
+
+ if "persist_user_message" in _inspect.signature(
+ agent.run_conversation
+ ).parameters:
+ _run_kwargs["persist_user_message"] = persist_user_message
+ except Exception:
+ _run_kwargs["persist_user_message"] = persist_user_message
+ result = agent.run_conversation(prompt_message, **_run_kwargs)
finally:
unregister_gateway_notify(_approval_session_key)
reset_current_session_key(_approval_session_token)
@@ -6946,6 +7710,19 @@ def _approval_notify_sync(approval_data: dict) -> None:
_output_toks = getattr(_agent, "session_completion_tokens", 0)
_resolved_model = getattr(_agent, "model", None) if _agent else None
+ if (
+ _agent
+ and getattr(_agent, "_self_nudge_armed_this_turn", False)
+ and isinstance(final_response, str)
+ and final_response.strip().upper() == _SELF_NUDGE_NO_REPLY
+ ):
+ final_response = ""
+ _msgs = result.get("messages") or []
+ if _msgs and _msgs[-1].get("role") == "assistant":
+ _last_content = str(_msgs[-1].get("content") or "").strip().upper()
+ if _last_content == _SELF_NUDGE_NO_REPLY:
+ _msgs.pop()
+
if not final_response:
error_msg = f"⚠️ {result['error']}" if result.get("error") else "(No response generated)"
return {
@@ -7048,6 +7825,9 @@ def _approval_notify_sync(approval_data: dict) -> None:
"output_tokens": _output_toks,
"model": _resolved_model,
"session_id": effective_session_id,
+ "self_nudge_armed": bool(
+ _agent and getattr(_agent, "_self_nudge_armed_this_turn", False)
+ ),
}
# Start progress message sender if enabled
@@ -7370,6 +8150,11 @@ async def _notify_long_running():
# new message).
# Process the pending message with updated history
+ if session_key:
+ await self._cancel_self_nudge(
+ session_key,
+ reason="queued_user_followup",
+ )
updated_history = result.get("messages", history)
return await self._run_agent(
message=pending,
@@ -7380,6 +8165,41 @@ async def _notify_long_running():
session_key=session_key,
_interrupt_depth=_interrupt_depth + 1,
)
+
+ hidden_entry = None
+ if session_key:
+ hidden_entry = getattr(self, "_pending_hidden_turns", {}).pop(
+ session_key,
+ None,
+ )
+ hidden_entry = self._pending_hidden_turns.pop(session_key, None)
+ if hidden_entry:
+ _sc = stream_consumer_holder[0]
+ _already_streamed = _sc and getattr(_sc, "already_sent", False)
+ first_response = (result or {}).get("final_response", "")
+ if first_response and not _already_streamed:
+ try:
+ await adapter.send(
+ source.chat_id,
+ first_response,
+ metadata=getattr(event, "metadata", None),
+ )
+ except Exception as e:
+ logger.warning(
+ "Failed to send response before self-nudge follow-up: %s",
+ e,
+ )
+ updated_history = (result or {}).get("messages", history)
+ return await self._run_agent(
+ message=self._build_self_nudge_text(hidden_entry),
+ context_prompt=context_prompt,
+ history=updated_history,
+ source=source,
+ session_id=session_id,
+ session_key=session_key,
+ _interrupt_depth=_interrupt_depth + 1,
+ persist_user_message="[System note: a self-nudge timer fired.]",
+ )
finally:
# Stop progress sender, interrupt monitor, and notification task
if progress_task:
@@ -7660,6 +8480,69 @@ def signal_handler():
return True
+# =============================================================================
+# Container Cleanup - Prevent accumulation of orphaned containers
+# =============================================================================
+
+def _cleanup_orphaned_containers():
+ """Clean up exited hermes-* containers from previous runs.
+
+ This prevents accumulation of stopped containers when Hermes
+ gateway crashes or restarts without proper cleanup.
+ """
+ import subprocess
+ from tools.environments.docker import find_docker
+
+ _logger = logging.getLogger(__name__)
+
+ docker_exe = find_docker()
+ if not docker_exe:
+ _logger.debug("Docker/podman not found, skipping container cleanup")
+ return
+
+ try:
+ result = subprocess.run(
+ [docker_exe, "ps", "-a",
+ "--filter", "name=^hermes-",
+ "--format", "{{.ID}} {{.Status}}"],
+ capture_output=True, text=True, timeout=30,
+ )
+
+ if result.returncode != 0:
+ _logger.debug("Could not list containers: %s", result.stderr)
+ return
+
+ exited_count = 0
+ for line in result.stdout.strip().split('\n'):
+ if not line:
+ continue
+ parts = line.split()
+ if len(parts) >= 2:
+ container_id = parts[0]
+ status = parts[1]
+ if status.lower() in ('exited', 'dead', 'created'):
+ try:
+ subprocess.run(
+ [docker_exe, "rm", "-f", container_id],
+ capture_output=True, timeout=30,
+ )
+ exited_count += 1
+ _logger.info("Cleaned up orphaned container: %s (%s)",
+ container_id[:12], status)
+ except Exception as e:
+ _logger.debug("Failed to remove container %s: %s",
+ container_id[:12], e)
+
+ if exited_count > 0:
+ _logger.info("Startup cleanup: removed %d orphaned hermes-* containers",
+ exited_count)
+ else:
+ _logger.debug("No orphaned containers found")
+
+ except Exception as e:
+ _logger.warning("Container cleanup failed: %s", e)
+
+
def main():
"""CLI entry point for the gateway."""
import argparse
@@ -7677,6 +8560,9 @@ def main():
data = json.load(f)
config = GatewayConfig.from_dict(data)
+ # Clean up orphaned containers from previous runs before starting
+ _cleanup_orphaned_containers()
+
# Run the gateway - exit with code 1 if no platforms connected,
# so systemd Restart=on-failure will retry on transient errors (e.g. DNS)
success = asyncio.run(start_gateway(config))
diff --git a/gateway/session.py b/gateway/session.py
index 72c3eb161889..c9e525f995c1 100644
--- a/gateway/session.py
+++ b/gateway/session.py
@@ -812,7 +812,15 @@ def update_session(
session_key: str,
last_prompt_tokens: int = None,
) -> None:
- """Update lightweight session metadata after an interaction."""
+ """Update lightweight session metadata after an interaction.
+
+ Token totals (input/output/cache/reasoning) are persisted directly
+ by the agent into SessionDB via _flush_messages_to_session_db (see
+ upstream commit 20441cf2). The gateway only tracks
+ last_prompt_tokens here for context-window tracking and compression
+ decisions. /status reads token totals from SessionDB via
+ SessionDB.get_session_token_totals — see _handle_status_command.
+ """
with self._lock:
self._ensure_loaded_locked()
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index 4d59f7dbf9b3..c252902a6d3c 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -2397,7 +2397,22 @@ def _update_config_for_provider(
config_path = get_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
- config = read_raw_config()
+ from hermes_cli.config import (
+ ConfigWriteError,
+ load_raw_config_mapping_result,
+ save_yaml_config_result,
+ )
+
+ config, load_error = load_raw_config_mapping_result(config_path, action="save provider settings")
+ if load_error is not None:
+ raise ConfigWriteError(
+ path=config_path,
+ action="save provider settings",
+ error=load_error.error or OSError("invalid existing config"),
+ blocked=load_error.blocked,
+ diff=load_error.diff,
+ )
+ assert config is not None
current_model = config.get("model")
if isinstance(current_model, dict):
@@ -2424,7 +2439,15 @@ def _update_config_for_provider(
config["model"] = model_cfg
- config_path.write_text(yaml.safe_dump(config, sort_keys=False))
+ result = save_yaml_config_result(config_path, config, sort_keys=False)
+ if not result:
+ raise ConfigWriteError(
+ path=config_path,
+ action="save provider settings",
+ error=result.error or OSError("unknown config write failure"),
+ blocked=result.blocked,
+ diff=result.diff,
+ )
return config_path
@@ -2434,16 +2457,38 @@ def _reset_config_provider() -> Path:
if not config_path.exists():
return config_path
- config = read_raw_config()
- if not config:
- return config_path
+ from hermes_cli.config import (
+ ConfigWriteError,
+ load_raw_config_mapping_result,
+ save_yaml_config_result,
+ )
+
+ config, load_error = load_raw_config_mapping_result(config_path, action="reset provider settings")
+ if load_error is not None:
+ raise ConfigWriteError(
+ path=config_path,
+ action="reset provider settings",
+ error=load_error.error or OSError("invalid existing config"),
+ blocked=load_error.blocked,
+ diff=load_error.diff,
+ )
+ assert config is not None
model = config.get("model")
if isinstance(model, dict):
model["provider"] = "auto"
if "base_url" in model:
model["base_url"] = OPENROUTER_BASE_URL
- config_path.write_text(yaml.safe_dump(config, sort_keys=False))
+
+ result = save_yaml_config_result(config_path, config, sort_keys=False)
+ if not result:
+ raise ConfigWriteError(
+ path=config_path,
+ action="reset provider settings",
+ error=result.error or OSError("unknown config write failure"),
+ blocked=result.blocked,
+ diff=result.diff,
+ )
return config_path
diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py
index eca6b2924c88..c9ebb191028a 100644
--- a/hermes_cli/auth_commands.py
+++ b/hermes_cli/auth_commands.py
@@ -28,6 +28,7 @@
)
import hermes_cli.auth as auth_mod
from hermes_cli.auth import PROVIDER_REGISTRY
+from hermes_cli.config import guard_config_command
from hermes_constants import OPENROUTER_BASE_URL
@@ -520,6 +521,7 @@ def _interactive_strategy() -> None:
print(f"Set {provider} strategy to: {strategy}")
+@guard_config_command
def auth_command(args) -> None:
action = getattr(args, "auth_action", "")
if action == "add":
diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py
index 9f26b4bb0755..28e4cf3695d4 100644
--- a/hermes_cli/commands.py
+++ b/hermes_cli/commands.py
@@ -87,7 +87,10 @@ class CommandDef:
CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--global]"),
CommandDef("provider", "Show available providers and current provider",
"Configuration"),
-
+ CommandDef("models", "List available models for current or specified provider",
+ "Configuration", args_hint="[provider|custom:name]"),
+ CommandDef("prompt", "View/set custom system prompt", "Configuration",
+ cli_only=True, args_hint="[text]", subcommands=("clear",)),
CommandDef("personality", "Set a predefined personality", "Configuration",
args_hint="[name]"),
CommandDef("statusbar", "Toggle the context/model status bar", "Configuration",
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 6ae094e3f0b1..1f53630dc526 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -19,9 +19,13 @@
import subprocess
import sys
import tempfile
-from dataclasses import dataclass
+import difflib
+import errno
+import shutil
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
+from dataclasses import dataclass
+from functools import wraps
from tools.tool_backend_helpers import managed_nous_tools_enabled as _managed_nous_tools_enabled
@@ -64,6 +68,65 @@
"nixos": "NixOS",
}
+_LOCKED_CONFIG_ERRNOS = {errno.EACCES, errno.EPERM, errno.EROFS, errno.EBUSY}
+
+
+@dataclass
+class ConfigWriteResult:
+ """Structured outcome for config.yaml write attempts."""
+
+ success: bool
+ path: Path
+ error: Optional[BaseException] = None
+ blocked: bool = False
+ diff: str = ""
+
+ def __bool__(self) -> bool:
+ return self.success
+
+
+class ConfigWriteError(OSError):
+ """Raised when Hermes cannot persist config.yaml changes."""
+
+ def __init__(
+ self,
+ *,
+ path: Path,
+ action: str,
+ error: BaseException,
+ blocked: bool,
+ diff: str = "",
+ ) -> None:
+ self.path = Path(path)
+ self.action = action
+ self.original_error = error
+ self.blocked = blocked
+ self.diff = diff
+ super().__init__(describe_config_write_failure(
+ ConfigWriteResult(
+ success=False,
+ path=self.path,
+ error=error,
+ blocked=blocked,
+ diff=diff,
+ ),
+ action=action,
+ ))
+
+
+def guard_config_command(func):
+ """Catch ConfigWriteError for user-facing CLI command entry points."""
+
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ try:
+ return func(*args, **kwargs)
+ except ConfigWriteError as exc:
+ print(str(exc))
+ return None
+
+ return wrapper
+
def get_managed_system() -> Optional[str]:
"""Return the package manager owning this install, if any."""
@@ -105,6 +168,245 @@ def recommended_update_command() -> str:
return get_managed_update_command() or "hermes update"
+def _render_yaml_text(
+ data: Any,
+ *,
+ default_flow_style: bool = False,
+ sort_keys: bool = False,
+ extra_content: Optional[str] = None,
+) -> str:
+ text = yaml.dump(data, default_flow_style=default_flow_style, sort_keys=sort_keys)
+ if extra_content:
+ text += extra_content
+ return text
+
+
+def _build_config_diff(path: Path, before: str, after: str) -> str:
+ diff_lines = list(
+ difflib.unified_diff(
+ before.splitlines(),
+ after.splitlines(),
+ fromfile=str(path),
+ tofile=f"{path} (proposed)",
+ lineterm="",
+ )
+ )
+ return "\n".join(diff_lines)
+
+
+def _looks_like_locked_config_error(path: Path, exc: BaseException) -> bool:
+ if isinstance(exc, OSError) and exc.errno in _LOCKED_CONFIG_ERRNOS:
+ return True
+
+ text = str(exc).lower()
+ if any(
+ token in text
+ for token in (
+ "read-only",
+ "resource busy",
+ "operation not permitted",
+ "permission denied",
+ "device or resource busy",
+ )
+ ):
+ return True
+
+ try:
+ if path.exists() and not os.access(path, os.W_OK):
+ return True
+ except OSError:
+ pass
+
+ try:
+ if not os.access(path.parent, os.W_OK):
+ return True
+ except OSError:
+ pass
+
+ return False
+
+
+def describe_config_write_failure(result: ConfigWriteResult, *, action: str = "save configuration") -> str:
+ """Format a user-facing config write failure with a manual patch when possible."""
+ path = Path(result.path)
+ reason = str(result.error) if result.error else "unknown error"
+ if result.blocked:
+ header = (
+ f"Hermes could not {action} because `{path}` is read-only or otherwise locked."
+ )
+ else:
+ header = f"Hermes could not {action}: {reason}"
+
+ lines = [header]
+ if result.blocked:
+ lines.append(f"Reason: {reason}")
+
+ if result.diff:
+ lines.extend(
+ [
+ "",
+ "Apply this patch manually:",
+ "```diff",
+ result.diff,
+ "```",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def save_text_config_result(path: Path, text: str) -> ConfigWriteResult:
+ """Attempt to atomically replace a config file with already-rendered text."""
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ before = ""
+ if path.exists():
+ try:
+ before = path.read_text(encoding="utf-8")
+ except Exception:
+ before = ""
+
+ fd, tmp_path = tempfile.mkstemp(
+ dir=str(path.parent),
+ prefix=f".{path.stem}_",
+ suffix=".tmp",
+ )
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ f.write(text)
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp_path, path)
+ return ConfigWriteResult(success=True, path=path)
+ except Exception as exc:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ return ConfigWriteResult(
+ success=False,
+ path=path,
+ error=exc,
+ blocked=_looks_like_locked_config_error(path, exc),
+ diff=_build_config_diff(path, before, text),
+ )
+
+
+def load_raw_config_mapping_result(
+ path: Path,
+ *,
+ action: str,
+) -> tuple[Optional[Dict[str, Any]], Optional[ConfigWriteResult]]:
+ """Load the raw user config as a mapping or return a structured failure."""
+ path = Path(path)
+ if not path.exists():
+ return {}, None
+
+ try:
+ raw_text = path.read_text(encoding="utf-8")
+ except Exception as exc:
+ return None, ConfigWriteResult(success=False, path=path, error=exc, blocked=False)
+
+ try:
+ parsed = yaml.safe_load(raw_text) if raw_text.strip() else {}
+ except yaml.YAMLError as exc:
+ return None, ConfigWriteResult(
+ success=False,
+ path=path,
+ error=ValueError(
+ f"`{path}` contains invalid YAML. Fix it before Hermes can {action}."
+ ),
+ blocked=False,
+ )
+
+ if parsed is None:
+ return {}, None
+ if not isinstance(parsed, dict):
+ return None, ConfigWriteResult(
+ success=False,
+ path=path,
+ error=ValueError(
+ f"`{path}` must contain a top-level mapping/object. Fix it before Hermes can {action}."
+ ),
+ blocked=False,
+ )
+
+ return parsed, None
+
+
+def save_yaml_config_result(
+ path: Path,
+ data: Dict[str, Any],
+ *,
+ default_flow_style: bool = False,
+ sort_keys: bool = False,
+ extra_content: Optional[str] = None,
+) -> ConfigWriteResult:
+ """Attempt to persist a YAML config file and capture fallback context on failure."""
+ from utils import atomic_yaml_write
+
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ before = ""
+ if path.exists():
+ try:
+ before = path.read_text(encoding="utf-8")
+ except Exception:
+ before = ""
+
+ after = _render_yaml_text(
+ data,
+ default_flow_style=default_flow_style,
+ sort_keys=sort_keys,
+ extra_content=extra_content,
+ )
+
+ try:
+ atomic_yaml_write(
+ path,
+ data,
+ default_flow_style=default_flow_style,
+ sort_keys=sort_keys,
+ extra_content=extra_content,
+ )
+ return ConfigWriteResult(success=True, path=path)
+ except Exception as exc:
+ return ConfigWriteResult(
+ success=False,
+ path=path,
+ error=exc,
+ blocked=_looks_like_locked_config_error(path, exc),
+ diff=_build_config_diff(path, before, after),
+ )
+
+
+def save_config_key_result(
+ key_path: str,
+ value: Any,
+ *,
+ config_path: Optional[Path] = None,
+) -> ConfigWriteResult:
+ """Update a dot-separated key in a YAML config and return a structured result."""
+ path = Path(config_path) if config_path is not None else get_config_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ config, error_result = load_raw_config_mapping_result(path, action=f"update `{key_path}`")
+ if error_result is not None:
+ return error_result
+ assert config is not None
+
+ keys = key_path.split(".")
+ current = config
+ for key in keys[:-1]:
+ if key not in current or not isinstance(current[key], dict):
+ current[key] = {}
+ current = current[key]
+ current[keys[-1]] = value
+
+ return save_yaml_config_result(path, config)
+
+
def format_managed_message(action: str = "modify this Hermes installation") -> str:
"""Build a user-facing error for managed installs."""
managed_system = get_managed_system() or "a package manager"
@@ -299,6 +601,10 @@ def _ensure_hermes_home_managed(home: Path):
# Explicit opt-in: mount the host cwd into /workspace for Docker sessions.
# Default off because passing host directories into a sandbox weakens isolation.
"docker_mount_cwd_to_workspace": False,
+ # Allow a one-shot escape hatch for running a command directly inside
+ # the gateway process/container instead of the configured remote
+ # sandbox. Disabled by default and still guarded by explicit approval.
+ "enable_gateway_local": False,
# Persistent shell — keep a long-lived bash shell across execute() calls
# so cwd/env vars/shell variables survive between commands.
# Enabled by default for non-local backends (SSH); local is always opt-in
@@ -513,6 +819,8 @@ def _ensure_hermes_home_managed(home: Path):
"api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
"max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget,
# independent of the parent's max_iterations)
+ "workspace_visibility": "inherit", # inherit | full_rw | full_ro | mapped
+ "workspace_mappings": [], # [{source, target, read_only}] for mapped mode
},
# Ephemeral prefill messages file — JSON list of {role, content} dicts
@@ -2138,11 +2446,19 @@ def save_config(config: Dict[str, Any]):
if is_managed():
managed_error("save configuration")
return
- from utils import atomic_yaml_write
ensure_hermes_home()
config_path = get_config_path()
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
+ _, load_error = load_raw_config_mapping_result(config_path, action="save configuration")
+ if load_error is not None:
+ raise ConfigWriteError(
+ path=config_path,
+ action="save configuration",
+ error=load_error.error or OSError("invalid existing config"),
+ blocked=load_error.blocked,
+ diff=load_error.diff,
+ )
# Build optional commented-out sections for features that are off by
# default or only relevant when explicitly configured.
@@ -2154,11 +2470,19 @@ def save_config(config: Dict[str, Any]):
if not fb or not (fb.get("provider") and fb.get("model")):
parts.append(_FALLBACK_COMMENT)
- atomic_yaml_write(
+ result = save_yaml_config_result(
config_path,
normalized,
extra_content="".join(parts) if parts else None,
)
+ if not result:
+ raise ConfigWriteError(
+ path=config_path,
+ action="save configuration",
+ error=result.error or OSError("unknown config write failure"),
+ blocked=result.blocked,
+ diff=result.diff,
+ )
_secure_file(config_path)
@@ -2608,8 +2932,12 @@ def edit_config():
# Ensure config exists
if not config_path.exists():
- save_config(DEFAULT_CONFIG)
- print(f"Created {config_path}")
+ try:
+ save_config(DEFAULT_CONFIG)
+ print(f"Created {config_path}")
+ except ConfigWriteError as exc:
+ print(str(exc))
+ return
# Find editor
editor = os.getenv('EDITOR') or os.getenv('VISUAL')
@@ -2617,7 +2945,6 @@ def edit_config():
if not editor:
# Try common editors
for cmd in ['nano', 'vim', 'vi', 'code', 'notepad']:
- import shutil
if shutil.which(cmd):
editor = cmd
break
@@ -2627,8 +2954,54 @@ def edit_config():
print(f" {config_path}")
return
- print(f"Opening {config_path} in {editor}...")
- subprocess.run([editor, str(config_path)])
+ original_text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
+ fd, temp_path_raw = tempfile.mkstemp(prefix="hermes-config-", suffix=".yaml")
+ temp_path = Path(temp_path_raw)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as tmp:
+ tmp.write(original_text)
+
+ while True:
+ print(f"Opening temporary copy in {editor}...")
+ completed = subprocess.run([editor, str(temp_path)])
+ if completed.returncode != 0:
+ print(f"Editor exited with status {completed.returncode}; config not changed.")
+ return
+
+ edited_text = temp_path.read_text(encoding="utf-8")
+ try:
+ parsed = yaml.safe_load(edited_text) if edited_text.strip() else {}
+ except yaml.YAMLError as exc:
+ print("Config not saved: YAML syntax is invalid.")
+ print(f" {exc}")
+ retry = input("Re-open the editor to fix it? [Y/n]: ").strip().lower()
+ if retry in ("", "y", "yes"):
+ continue
+ print("Aborted without changing config.yaml.")
+ return
+
+ if parsed is None:
+ parsed = {}
+ if not isinstance(parsed, dict):
+ print("Config not saved: top-level YAML must be a mapping/object.")
+ retry = input("Re-open the editor to fix it? [Y/n]: ").strip().lower()
+ if retry in ("", "y", "yes"):
+ continue
+ print("Aborted without changing config.yaml.")
+ return
+
+ result = save_text_config_result(config_path, edited_text)
+ if result:
+ _secure_file(config_path)
+ print(f"Saved {config_path}")
+ else:
+ print(describe_config_write_failure(result, action="save configuration"))
+ return
+ finally:
+ try:
+ temp_path.unlink(missing_ok=True)
+ except OSError:
+ pass
def set_config_value(key: str, value: str):
@@ -2655,27 +3028,6 @@ def set_config_value(key: str, value: str):
print(f"✓ Set {key} in {get_env_path()}")
return
- # Otherwise it goes to config.yaml
- # Read the raw user config (not merged with defaults) to avoid
- # dumping all default values back to the file
- config_path = get_config_path()
- user_config = {}
- if config_path.exists():
- try:
- with open(config_path, encoding="utf-8") as f:
- user_config = yaml.safe_load(f) or {}
- except Exception:
- user_config = {}
-
- # Handle nested keys (e.g., "tts.provider")
- parts = key.split('.')
- current = user_config
-
- for part in parts[:-1]:
- if part not in current or not isinstance(current.get(part), dict):
- current[part] = {}
- current = current[part]
-
# Convert value to appropriate type
if value.lower() in ('true', 'yes', 'on'):
value = True
@@ -2686,12 +3038,11 @@ def set_config_value(key: str, value: str):
elif value.replace('.', '', 1).isdigit():
value = float(value)
- current[parts[-1]] = value
-
- # Write only user config back (not the full merged defaults)
- ensure_hermes_home()
- with open(config_path, 'w', encoding="utf-8") as f:
- yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
+ config_path = get_config_path()
+ result = save_config_key_result(key, value, config_path=config_path)
+ if not result:
+ print(describe_config_write_failure(result, action=f"update `{key}`"))
+ return
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
@@ -2703,6 +3054,7 @@ def set_config_value(key: str, value: str):
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
+ "terminal.enable_gateway_local": "TERMINAL_ENABLE_GATEWAY_LOCAL",
"terminal.cwd": "TERMINAL_CWD",
"terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 7d4a4a9241ac..6028e7dc5ece 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -866,7 +866,15 @@ def cmd_setup(args):
def cmd_model(args):
"""Select default model — starts with provider selection, then model picker."""
_require_tty("model")
- select_provider_and_model(args=args)
+ try:
+ select_provider_and_model(args=args)
+ except Exception as exc:
+ from hermes_cli.config import ConfigWriteError
+
+ if isinstance(exc, ConfigWriteError):
+ print(str(exc))
+ return
+ raise
def select_provider_and_model(args=None):
@@ -1520,14 +1528,14 @@ def _model_flow_custom(config):
context_length = None
if model_name:
- _save_model_choice(model_name)
-
- # Update config and deactivate any OAuth provider
+ # Update config and deactivate any OAuth provider with a single save so
+ # locked/read-only config fallbacks can show the full intended patch.
cfg = load_config()
model = cfg.get("model")
if not isinstance(model, dict):
model = {"default": model} if model else {}
cfg["model"] = model
+ model["default"] = model_name
model["provider"] = "custom"
model["base_url"] = effective_url
if effective_key:
diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py
index 9154ed50a35e..1563b72046ce 100644
--- a/hermes_cli/mcp_config.py
+++ b/hermes_cli/mcp_config.py
@@ -17,6 +17,7 @@
from typing import Any, Dict, List, Optional, Tuple
from hermes_cli.config import (
+ guard_config_command,
load_config,
save_config,
get_env_value,
@@ -608,6 +609,7 @@ def cmd_mcp_configure(args):
# ─── Dispatcher ───────────────────────────────────────────────────────────────
+@guard_config_command
def mcp_command(args):
"""Main dispatcher for ``hermes mcp`` subcommands."""
action = getattr(args, "mcp_action", None)
diff --git a/hermes_cli/models.py b/hermes_cli/models.py
index b55249a70cba..174a252e477d 100644
--- a/hermes_cli/models.py
+++ b/hermes_cli/models.py
@@ -802,6 +802,35 @@ def list_available_providers() -> list[dict[str, str]]:
"aliases": alias_list,
"authenticated": has_creds,
})
+
+ # Append named custom providers from config.yaml custom_providers list.
+ # These are shown in the interactive `hermes model` menu (hermes_cli/main.py)
+ # but were missing from this function, causing /model to omit them.
+ try:
+ from hermes_cli.config import load_config as _load_cfg
+ _cfg = _load_cfg() or {}
+ _custom_providers_cfg = _cfg.get("custom_providers") or []
+ if isinstance(_custom_providers_cfg, list):
+ for _entry in _custom_providers_cfg:
+ if not isinstance(_entry, dict):
+ continue
+ _name = _entry.get("name", "").strip()
+ _base_url = _entry.get("base_url", "").strip()
+ if not _name or not _base_url:
+ continue
+ _pid = "custom:" + _name.lower().replace(" ", "-")
+ _short_url = _base_url.replace("https://", "").replace("http://", "").rstrip("/")
+ _saved_model = _entry.get("model", "")
+ _model_hint = f" — {_saved_model}" if _saved_model else ""
+ result.append({
+ "id": _pid,
+ "label": f"{_name} ({_short_url}){_model_hint}",
+ "aliases": [],
+ "authenticated": True, # presence in config.yaml implies configured
+ })
+ except Exception:
+ pass
+
return result
diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py
index 4727d4b7135c..00af631e9d5d 100644
--- a/hermes_cli/plugins_cmd.py
+++ b/hermes_cli/plugins_cmd.py
@@ -16,7 +16,7 @@
import sys
from pathlib import Path
-from hermes_constants import get_hermes_home
+from hermes_cli.config import guard_config_command
logger = logging.getLogger(__name__)
@@ -665,6 +665,7 @@ def cmd_toggle() -> None:
console.print("\n[dim]No changes.[/dim]")
+@guard_config_command
def plugins_command(args) -> None:
"""Dispatch hermes plugins subcommands."""
action = getattr(args, "plugins_action", None)
diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py
index 72b8aab18e50..96ef21d72720 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -319,6 +319,7 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c
get_hermes_home,
get_config_path,
get_env_path,
+ guard_config_command,
load_config,
save_config,
save_env_value,
@@ -2838,6 +2839,7 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool:
]
+@guard_config_command
def run_setup_wizard(args):
"""Run the interactive setup wizard.
diff --git a/hermes_cli/skills_config.py b/hermes_cli/skills_config.py
index d7e47ca5f286..7c5a532c88f3 100644
--- a/hermes_cli/skills_config.py
+++ b/hermes_cli/skills_config.py
@@ -13,7 +13,7 @@
"""
from typing import List, Optional, Set
-from hermes_cli.config import load_config, save_config
+from hermes_cli.config import guard_config_command, load_config, save_config
from hermes_cli.colors import Colors, color
PLATFORMS = {
@@ -134,6 +134,7 @@ def _toggle_by_category(skills: List[dict], disabled: Set[str]) -> Set[str]:
# ─── Entry Point ──────────────────────────────────────────────────────────────
+@guard_config_command
def skills_command(args=None):
"""Entry point for `hermes skills`."""
from hermes_cli.curses_ui import curses_checklist
diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py
index 9a50a2c5d5f5..87cf071bd5b6 100644
--- a/hermes_cli/tools_config.py
+++ b/hermes_cli/tools_config.py
@@ -17,7 +17,11 @@
from hermes_cli.config import (
- load_config, save_config, get_env_value, save_env_value,
+ guard_config_command,
+ load_config,
+ save_config,
+ get_env_value,
+ save_env_value,
)
from hermes_cli.colors import Colors, color
from hermes_cli.nous_subscription import (
@@ -81,6 +85,7 @@ def _prompt(question: str, default: str = None, password: bool = False) -> str:
("memory", "💾 Memory", "persistent memory across sessions"),
("session_search", "🔎 Session Search", "search past conversations"),
("clarify", "❓ Clarifying Questions", "clarify"),
+ ("user_updates", "💬 User Updates", "send_user_message"),
("delegation", "👥 Task Delegation", "delegate_task"),
("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"),
("rl", "🧪 RL Training", "Tinker-Atropos training tools"),
@@ -1297,6 +1302,7 @@ def _reconfigure_simple_requirements(ts_key: str):
# ─── Main Entry Point ─────────────────────────────────────────────────────────
+@guard_config_command
def tools_command(args=None, first_install: bool = False, config: dict = None):
"""Entry point for `hermes tools` and `hermes setup tools`.
@@ -1741,6 +1747,7 @@ def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str =
_print_info(f"{srv_name} {color('all tools enabled', Colors.DIM)}")
+@guard_config_command
def tools_disable_enable_command(args):
"""Enable, disable, or list tools for a platform.
diff --git a/hermes_state.py b/hermes_state.py
index c6825a3e665f..1e13e524ccaa 100644
--- a/hermes_state.py
+++ b/hermes_state.py
@@ -595,6 +595,39 @@ def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
row = cursor.fetchone()
return dict(row) if row else None
+ def get_session_token_totals(self, session_id: str) -> Optional[Dict[str, int]]:
+ """Get token totals for a session from SessionDB.
+
+ Returns a dict with input_tokens, output_tokens, cache_read_tokens,
+ cache_write_tokens, reasoning_tokens, and total_tokens (sum of all).
+ Returns None if the session is not found.
+ """
+ with self._lock:
+ cursor = self._conn.execute(
+ """SELECT input_tokens, output_tokens, cache_read_tokens,
+ cache_write_tokens, reasoning_tokens
+ FROM sessions WHERE id = ?""",
+ (session_id,),
+ )
+ row = cursor.fetchone()
+ if row:
+ totals = {
+ "input_tokens": row["input_tokens"] or 0,
+ "output_tokens": row["output_tokens"] or 0,
+ "cache_read_tokens": row["cache_read_tokens"] or 0,
+ "cache_write_tokens": row["cache_write_tokens"] or 0,
+ "reasoning_tokens": row["reasoning_tokens"] or 0,
+ }
+ totals["total_tokens"] = (
+ totals["input_tokens"]
+ + totals["output_tokens"]
+ + totals["cache_read_tokens"]
+ + totals["cache_write_tokens"]
+ + totals["reasoning_tokens"]
+ )
+ return totals
+ return None
+
def resolve_session_id(self, session_id_or_prefix: str) -> Optional[str]:
"""Resolve an exact or uniquely prefixed session ID to the full ID.
diff --git a/model_tools.py b/model_tools.py
index c37007c413ce..f285e5444e03 100644
--- a/model_tools.py
+++ b/model_tools.py
@@ -152,6 +152,8 @@ def _discover_tools():
"tools.memory_tool",
"tools.session_search_tool",
"tools.clarify_tool",
+ "tools.send_user_message_tool",
+ "tools.self_nudge_tool",
"tools.code_execution_tool",
"tools.delegate_tool",
"tools.process_registry",
@@ -235,6 +237,7 @@ def get_tool_definitions(
enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None,
quiet_mode: bool = False,
+ platform: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Get tool definitions for model API calls with toolset-based filtering.
@@ -245,6 +248,7 @@ def get_tool_definitions(
enabled_toolsets: Only include tools from these toolsets.
disabled_toolsets: Exclude tools from these toolsets (if enabled_toolsets is None).
quiet_mode: Suppress status prints.
+ platform: Runtime platform hint used for context-sensitive tool filtering.
Returns:
Filtered list of OpenAI-format tool definitions.
@@ -301,6 +305,82 @@ def get_tool_definitions(
# Ask the registry for schemas (only returns tools whose check_fn passes)
filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode)
+ # Clarify requires a live platform callback that can synchronously wait
+ # for the user's answer. Today only the interactive CLI wires that
+ # callback, so keep the tool out of other contexts instead of exposing a
+ # deterministic runtime error.
+ if platform != "cli":
+ filtered_tools = [
+ td for td in filtered_tools
+ if td.get("function", {}).get("name") != "clarify"
+ ]
+
+ # Interactive in-session messaging is only meaningful when Hermes has a
+ # live session surface that can route updates back to the current user.
+ # Keep it out of non-interactive/default contexts so the model does not
+ # waste turns on a tool that will deterministically fail at runtime.
+ interactive_message_platforms = {
+ "acp",
+ "cli",
+ "discord",
+ "dingtalk",
+ "email",
+ "feishu",
+ "homeassistant",
+ "matrix",
+ "mattermost",
+ "signal",
+ "slack",
+ "telegram",
+ "wecom",
+ "whatsapp",
+ }
+ if platform not in interactive_message_platforms:
+ filtered_tools = [
+ td for td in filtered_tools
+ if td.get("function", {}).get("name") != "send_user_message"
+ ]
+
+ gateway_self_nudge_platforms = {
+ "discord",
+ "dingtalk",
+ "email",
+ "feishu",
+ "homeassistant",
+ "matrix",
+ "mattermost",
+ "signal",
+ "slack",
+ "telegram",
+ "wecom",
+ "whatsapp",
+ }
+ if platform not in gateway_self_nudge_platforms:
+ filtered_tools = [
+ td for td in filtered_tools
+ if td.get("function", {}).get("name") != "self_nudge"
+ ]
+
+ try:
+ from tools.terminal_tool import can_offer_gateway_local
+ expose_gateway_local = can_offer_gateway_local()
+ except Exception:
+ expose_gateway_local = False
+ if not expose_gateway_local:
+ for i, td in enumerate(filtered_tools):
+ if td.get("function", {}).get("name") == "terminal":
+ fn = td["function"]
+ params = dict(fn.get("parameters", {}))
+ properties = dict(params.get("properties", {}))
+ if "gateway_local" in properties:
+ properties.pop("gateway_local", None)
+ params["properties"] = properties
+ filtered_tools[i] = {
+ "type": "function",
+ "function": {**fn, "parameters": params},
+ }
+ break
+
# The set of tool names that actually passed check_fn filtering.
# Use this (not tools_to_include) for any downstream schema that references
# other tools by name — otherwise the model sees tools mentioned in
@@ -361,7 +441,7 @@ def get_tool_definitions(
# because they need agent-level state (TodoStore, MemoryStore, etc.).
# The registry still holds their schemas; dispatch just returns a stub error
# so if something slips through, the LLM sees a sensible message.
-_AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"}
+_AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task", "list_models", "send_user_message", "self_nudge"}
_READ_SEARCH_TOOLS = {"read_file", "search_files"}
diff --git a/run_agent.py b/run_agent.py
index 94555cbfe7eb..5d46c1366f0b 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -213,7 +213,7 @@ def remaining(self) -> int:
# Tools that must never run concurrently (interactive / user-facing).
# When any of these appear in a batch, we fall back to sequential execution.
-_NEVER_PARALLEL_TOOLS = frozenset({"clarify"})
+_NEVER_PARALLEL_TOOLS = frozenset({"clarify", "send_user_message", "self_nudge"})
# Read-only tools with no shared mutable session state.
_PARALLEL_SAFE_TOOLS = frozenset({
@@ -497,6 +497,7 @@ def __init__(
step_callback: callable = None,
stream_delta_callback: callable = None,
tool_gen_callback: callable = None,
+ message_callback: callable = None,
status_callback: callable = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
@@ -514,6 +515,9 @@ def __init__(
checkpoint_max_snapshots: int = 50,
pass_session_id: bool = False,
persist_session: bool = True,
+ _shared_memory_store=None, # Share parent's memory store (for subagents)
+ _is_subagent=False, # Mark as subagent for memory access control
+ subagent_memory_mode: str = "read_only", # "read_only" | "full" | "none"
):
"""
Initialize the AI Agent.
@@ -542,6 +546,8 @@ def __init__(
tool_progress_callback (callable): Callback function(tool_name, args_preview) for progress notifications
clarify_callback (callable): Callback function(question, choices) -> str for interactive user questions.
Provided by the platform layer (CLI or gateway). If None, the clarify tool returns an error.
+ message_callback (callable): Callback function(message_text) -> None for sending
+ a natural-language update to the current user without ending the turn.
max_tokens (int): Maximum tokens for model responses (optional, uses model default if not set)
reasoning_config (Dict): OpenRouter reasoning configuration override (e.g. {"effort": "none"} to disable thinking).
If None, defaults to {"enabled": True, "effort": "medium"} for OpenRouter. Set to disable/customize reasoning.
@@ -629,6 +635,8 @@ def __init__(
self.clarify_callback = clarify_callback
self.step_callback = step_callback
self.stream_delta_callback = stream_delta_callback
+ self.message_callback = message_callback
+ self.self_nudge_callback = None
self.status_callback = status_callback
self.tool_gen_callback = tool_gen_callback
@@ -646,6 +654,7 @@ def __init__(
self._delegate_depth = 0 # 0 = top-level agent, incremented for children
self._active_children = [] # Running child AIAgents (for interrupt propagation)
self._active_children_lock = threading.Lock()
+ self._self_nudge_armed_this_turn = False
# Store OpenRouter provider preferences
self.providers_allowed = providers_allowed
@@ -895,6 +904,7 @@ def __init__(
enabled_toolsets=enabled_toolsets,
disabled_toolsets=disabled_toolsets,
quiet_mode=self.quiet_mode,
+ platform=self.platform,
)
# Show tool configuration and store valid tool names for validation
@@ -1012,7 +1022,16 @@ def __init__(
self._memory_flush_min_turns = 6
self._turns_since_memory = 0
self._iters_since_skill = 0
- if not skip_memory:
+
+ self._is_subagent = _is_subagent
+ self.subagent_memory_mode = subagent_memory_mode
+
+ # Use shared memory store if provided (for subagents)
+ if _shared_memory_store is not None:
+ self._memory_store = _shared_memory_store
+ self._memory_enabled = getattr(_shared_memory_store, '_memory_enabled', True)
+ self._user_profile_enabled = getattr(_shared_memory_store, '_user_profile_enabled', True)
+ elif not skip_memory:
try:
mem_config = _agent_cfg.get("memory", {})
self._memory_enabled = mem_config.get("memory_enabled", False)
@@ -1138,8 +1157,10 @@ def __init__(
_model_cfg = _agent_cfg.get("model", {})
if isinstance(_model_cfg, dict):
_config_context_length = _model_cfg.get("context_length")
+ self._config_extra_body = _model_cfg.get("extra_body") or {}
else:
_config_context_length = None
+ self._config_extra_body = {}
if _config_context_length is not None:
try:
_config_context_length = int(_config_context_length)
@@ -1525,6 +1546,109 @@ def _emit_status(self, message: str) -> None:
except Exception:
logger.debug("status_callback error in _emit_status", exc_info=True)
+ def _emit_user_message(self, message: str) -> str:
+ """Send a natural-language update to the current user.
+
+ Parses MEDIA: directives from the message before delivery:
+ - Cleaned text is sent via message_callback (skipped if empty after stripping)
+ - Media files are delivered via media_message_callback if wired
+ - If the message contains only MEDIA: tags, only the files are sent
+
+ Falls back to status_callback("agent_message", ...) for older embeddings
+ that do not wire the dedicated callbacks yet.
+ """
+ raw = str(message or "").strip()
+ if not raw:
+ return json.dumps({"error": "Message text is required."}, ensure_ascii=False)
+
+ # Extract MEDIA: tags — reuse platform adapter logic if available
+ media_files: list = []
+ text = raw
+ try:
+ from gateway.platforms.base import BasePlatformAdapter
+ media_files, text = BasePlatformAdapter.extract_media(raw)
+ text = text.strip()
+ except Exception:
+ pass # No gateway context (CLI, tests) — send raw text as-is
+
+ has_text = bool(text)
+ has_media = bool(media_files)
+
+ if not has_text and not has_media:
+ return json.dumps({"error": "Message text is required."}, ensure_ascii=False)
+
+ media_callback = getattr(self, "media_message_callback", None)
+
+ if self.message_callback:
+ try:
+ if has_text:
+ self.message_callback(text)
+ if has_media and media_callback:
+ media_callback(media_files)
+ return json.dumps({"sent": True, "message": text, "media": len(media_files)}, ensure_ascii=False)
+ except Exception as exc:
+ return json.dumps(
+ {"error": f"Failed to send user message: {exc}"},
+ ensure_ascii=False,
+ )
+
+ if self.status_callback:
+ try:
+ if has_text:
+ self.status_callback("agent_message", text)
+ if has_media and media_callback:
+ media_callback(media_files)
+ return json.dumps({"sent": True, "message": text, "media": len(media_files)}, ensure_ascii=False)
+ except Exception as exc:
+ return json.dumps(
+ {"error": f"Failed to send user message: {exc}"},
+ ensure_ascii=False,
+ )
+
+ return json.dumps(
+ {"error": "send_user_message is not available in this execution context."},
+ ensure_ascii=False,
+ )
+
+ def _emit_self_nudge(self, delay_seconds: int, note: str = "") -> str:
+ """Arm a one-shot hidden follow-up timer for the current session."""
+ try:
+ seconds = int(delay_seconds)
+ except (TypeError, ValueError):
+ return json.dumps(
+ {"error": "delay_seconds must be an integer number of seconds."},
+ ensure_ascii=False,
+ )
+
+ if seconds <= 0:
+ return json.dumps(
+ {"error": "delay_seconds must be greater than zero."},
+ ensure_ascii=False,
+ )
+
+ callback = getattr(self, "self_nudge_callback", None)
+ if not callback:
+ return json.dumps(
+ {"error": "self_nudge is not available in this execution context."},
+ ensure_ascii=False,
+ )
+
+ try:
+ payload = callback(seconds, str(note or ""))
+ except Exception as exc:
+ return json.dumps(
+ {"error": f"Failed to arm self-nudge: {exc}"},
+ ensure_ascii=False,
+ )
+
+ if isinstance(payload, dict) and payload.get("armed"):
+ self._self_nudge_armed_this_turn = True
+ return json.dumps(payload, ensure_ascii=False)
+
+ if isinstance(payload, dict):
+ return json.dumps(payload, ensure_ascii=False)
+ return json.dumps({"error": "Failed to arm self-nudge."}, ensure_ascii=False)
+
def _is_direct_openai_url(self, base_url: str = None) -> bool:
"""Return True when a base URL targets OpenAI's native API."""
url = (base_url or self._base_url_lower).lower()
@@ -1811,7 +1935,8 @@ def _spawn_background_review(
prompt = self._SKILL_REVIEW_PROMPT
def _run_review():
- import contextlib, os as _os
+ import contextlib
+ import os as _os
review_agent = None
try:
with open(_os.devnull, "w") as _devnull, \
@@ -2920,31 +3045,35 @@ def _sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any
@staticmethod
def _cap_delegate_task_calls(tool_calls: list) -> list:
- """Truncate excess delegate_task calls to MAX_CONCURRENT_CHILDREN.
+ """Truncate excess delegate_task calls to max_concurrent_children.
The delegate_tool caps the task list inside a single call, but the
model can emit multiple separate delegate_task tool_calls in one
turn. This truncates the excess, preserving all non-delegate calls.
+ Uses the same configurable limit as delegate_task itself
+ (delegation.max_concurrent_children in config.yaml, default 3).
+
Returns the original list if no truncation was needed.
"""
- from tools.delegate_tool import MAX_CONCURRENT_CHILDREN
+ from tools.delegate_tool import _get_max_concurrent_children
+ max_children = _get_max_concurrent_children()
delegate_count = sum(1 for tc in tool_calls if tc.function.name == "delegate_task")
- if delegate_count <= MAX_CONCURRENT_CHILDREN:
+ if delegate_count <= max_children:
return tool_calls
kept_delegates = 0
truncated = []
for tc in tool_calls:
if tc.function.name == "delegate_task":
- if kept_delegates < MAX_CONCURRENT_CHILDREN:
+ if kept_delegates < max_children:
truncated.append(tc)
kept_delegates += 1
else:
truncated.append(tc)
logger.warning(
"Truncated %d excess delegate_task call(s) to enforce "
- "MAX_CONCURRENT_CHILDREN=%d limit",
- delegate_count - MAX_CONCURRENT_CHILDREN, MAX_CONCURRENT_CHILDREN,
+ "max_concurrent_children=%d limit",
+ delegate_count - max_children, max_children,
)
return truncated
@@ -5651,16 +5780,19 @@ def _build_api_kwargs(self, api_messages: list) -> dict:
if _is_nous:
extra_body["tags"] = ["product=hermes-agent"]
+ if getattr(self, "_config_extra_body", None):
+ extra_body.update(self._config_extra_body)
+
# Ollama num_ctx: override the 2048 default so the model actually
# uses the context window it was trained for. Passed via the OpenAI
# SDK's extra_body → options.num_ctx, which Ollama's OpenAI-compat
# endpoint forwards to the runner as --ctx-size.
- if self._ollama_num_ctx:
+ if getattr(self, "_ollama_num_ctx", None):
options = extra_body.get("options", {})
options["num_ctx"] = self._ollama_num_ctx
extra_body["options"] = options
- if self._is_qwen_portal():
+ if hasattr(self, "_is_qwen_portal") and self._is_qwen_portal():
extra_body["vl_high_resolution_images"] = True
if extra_body:
@@ -5842,7 +5974,10 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
"type": tool_call.type,
"function": {
"name": tool_call.function.name,
- "arguments": tool_call.function.arguments
+ "arguments": self._sanitize_private_tool_arguments(
+ tool_call.function.name,
+ tool_call.function.arguments,
+ ),
},
}
# Preserve extra_content (e.g. Gemini thought_signature) so it
@@ -5858,6 +5993,21 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
return msg
+ @staticmethod
+ def _sanitize_private_tool_arguments(function_name: str, raw_arguments: Any) -> Any:
+ """Redact private tool-call fields before persisting/replaying history."""
+ if function_name != "self_nudge" or not isinstance(raw_arguments, str):
+ return raw_arguments
+ try:
+ parsed = json.loads(raw_arguments)
+ except Exception:
+ return raw_arguments
+ if not isinstance(parsed, dict) or "note" not in parsed:
+ return raw_arguments
+ parsed = dict(parsed)
+ parsed.pop("note", None)
+ return json.dumps(parsed, ensure_ascii=False)
+
@staticmethod
def _sanitize_tool_calls_for_strict_api(api_msg: dict) -> dict:
"""Strip Codex Responses API fields from tool_calls for strict providers.
@@ -6247,6 +6397,13 @@ def _invoke_tool(self, function_name: str, function_args: dict, effective_task_i
choices=function_args.get("choices"),
callback=self.clarify_callback,
)
+ elif function_name == "send_user_message":
+ return self._emit_user_message(function_args.get("message", ""))
+ elif function_name == "self_nudge":
+ return self._emit_self_nudge(
+ function_args.get("delay_seconds", 0),
+ function_args.get("note", ""),
+ )
elif function_name == "delegate_task":
from tools.delegate_tool import delegate_task as _delegate_task
return _delegate_task(
@@ -6255,8 +6412,12 @@ def _invoke_tool(self, function_name: str, function_args: dict, effective_task_i
toolsets=function_args.get("toolsets"),
tasks=function_args.get("tasks"),
max_iterations=function_args.get("max_iterations"),
+ model=function_args.get("model"),
parent_agent=self,
)
+ elif function_name == "list_models":
+ from tools.delegate_tool import list_models as _list_models
+ return _list_models(parent_agent=self)
else:
return handle_function_call(
function_name, function_args, effective_task_id,
@@ -6341,7 +6502,7 @@ def _execute_tool_calls_concurrent(self, assistant_message, messages: list, effe
print(f" 📞 Tool {i}: {name}({list(args.keys())}) - {args_preview}")
for tc, name, args in parsed_calls:
- if self.tool_progress_callback:
+ if self.tool_progress_callback and name != "send_user_message":
try:
preview = _build_tool_preview(name, args)
self.tool_progress_callback("tool.started", name, preview, args)
@@ -6537,7 +6698,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe
self._current_tool = function_name
self._touch_activity(f"executing tool: {function_name}")
- if self.tool_progress_callback:
+ if self.tool_progress_callback and function_name != "send_user_message":
try:
preview = _build_tool_preview(function_name, function_args)
self.tool_progress_callback("tool.started", function_name, preview, function_args)
@@ -6610,6 +6771,8 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe
content=function_args.get("content"),
old_text=function_args.get("old_text"),
store=self._memory_store,
+ is_subagent=getattr(self, '_is_subagent', False),
+ subagent_memory_mode=getattr(self, 'subagent_memory_mode', 'read_only'),
)
tool_duration = time.time() - tool_start_time
if self._should_emit_quiet_tool_messages():
@@ -6624,6 +6787,25 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe
tool_duration = time.time() - tool_start_time
if self._should_emit_quiet_tool_messages():
self._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}")
+ elif function_name == "send_user_message":
+ function_result = self._emit_user_message(function_args.get("message", ""))
+ tool_duration = time.time() - tool_start_time
+ if self.quiet_mode:
+ self._vprint(f" {_get_cute_tool_message_impl('send_user_message', function_args, tool_duration, result=function_result)}")
+ elif function_name == "self_nudge":
+ function_result = self._emit_self_nudge(
+ function_args.get("delay_seconds", 0),
+ function_args.get("note", ""),
+ )
+ tool_duration = time.time() - tool_start_time
+ if self.quiet_mode:
+ self._vprint(f" {_get_cute_tool_message_impl('self_nudge', function_args, tool_duration, result=function_result)}")
+ elif function_name == "list_models":
+ from tools.delegate_tool import list_models as _list_models
+ function_result = _list_models(parent_agent=self)
+ tool_duration = time.time() - tool_start_time
+ if self.quiet_mode:
+ self._vprint(f" {_get_cute_tool_message_impl('list_models', function_args, tool_duration, result=function_result)}")
elif function_name == "delegate_task":
from tools.delegate_tool import delegate_task as _delegate_task
tasks_arg = function_args.get("tasks")
@@ -6646,6 +6828,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe
toolsets=function_args.get("toolsets"),
tasks=tasks_arg,
max_iterations=function_args.get("max_iterations"),
+ model=function_args.get("model"),
parent_agent=self,
)
_delegate_result = function_result
@@ -7087,6 +7270,7 @@ def run_conversation(
self._stream_callback = stream_callback
self._persist_user_message_idx = None
self._persist_user_message_override = persist_user_message
+ self._self_nudge_armed_this_turn = False
# Generate unique task_id if not provided to isolate VMs between concurrent tasks
effective_task_id = task_id or str(uuid.uuid4())
diff --git a/tests/cli/test_cli_provider_resolution.py b/tests/cli/test_cli_provider_resolution.py
index 353b3234eb39..213cd744adbf 100644
--- a/tests/cli/test_cli_provider_resolution.py
+++ b/tests/cli/test_cli_provider_resolution.py
@@ -1,8 +1,10 @@
import importlib
+import copy
import sys
import types
from contextlib import nullcontext
from types import SimpleNamespace
+from pathlib import Path
import pytest
@@ -556,7 +558,6 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
)
saved_env = {}
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: saved_env.__setitem__(key, value))
- monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: saved_env.__setitem__("MODEL", model))
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *args, **kwargs: None)
monkeypatch.setattr(
@@ -569,11 +570,15 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
"used_fallback": True,
},
)
+ saved_calls = []
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": {"default": "", "provider": "custom", "base_url": ""}},
)
- monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
+ monkeypatch.setattr(
+ "hermes_cli.config.save_config",
+ lambda cfg: saved_calls.append(copy.deepcopy(cfg)),
+ )
# After the probe detects a single model ("llm"), the flow asks
# "Use this model? [Y/n]:" — confirm with Enter, then context length.
@@ -581,14 +586,53 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
monkeypatch.setattr("getpass.getpass", lambda _prompt="": next(answers))
- hermes_main._model_flow_custom({})
+ config = {}
+ hermes_main._model_flow_custom(config)
output = capsys.readouterr().out
+ saved_model = None
+ for call in saved_calls:
+ model_cfg = call.get("model")
+ if isinstance(model_cfg, dict) and model_cfg.get("default") == "llm":
+ saved_model = model_cfg
+ break
+
assert "Saving the working base URL instead" in output
assert "Detected model: llm" in output
# OPENAI_BASE_URL is no longer saved to .env — config.yaml is authoritative
assert "OPENAI_BASE_URL" not in saved_env
- assert saved_env["MODEL"] == "llm"
+ assert saved_model is not None
+ assert saved_model["default"] == "llm"
+ assert saved_model["provider"] == "custom"
+ assert saved_model["base_url"] == "http://localhost:8000/v1"
+ assert saved_model["api_key"] == "local-key"
+ assert config["model"]["default"] == "llm"
+ assert config["model"]["provider"] == "custom"
+ assert config["model"]["base_url"] == "http://localhost:8000/v1"
+ assert config["model"]["api_key"] == "local-key"
+
+
+def test_cmd_model_prints_readable_config_error(monkeypatch, capsys):
+ from hermes_cli.config import ConfigWriteError
+
+ monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None)
+
+ def _boom(*args, **kwargs):
+ raise ConfigWriteError(
+ path=Path("/tmp/config.yaml"),
+ action="save configuration",
+ error=OSError(16, "Device or resource busy"),
+ blocked=True,
+ diff="--- /tmp/config.yaml\n+++ /tmp/config.yaml (proposed)",
+ )
+
+ monkeypatch.setattr(hermes_main, "select_provider_and_model", _boom)
+
+ hermes_main.cmd_model(SimpleNamespace())
+ output = capsys.readouterr().out
+
+ assert "read-only or otherwise locked" in output
+ assert "Apply this patch manually" in output
def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
diff --git a/tests/cli/test_cli_save_config_value.py b/tests/cli/test_cli_save_config_value.py
index 7d030c03c2c0..a9a455ffe76d 100644
--- a/tests/cli/test_cli_save_config_value.py
+++ b/tests/cli/test_cli_save_config_value.py
@@ -1,5 +1,6 @@
"""Tests for save_config_value() in cli.py — atomic write behavior."""
+import errno
import os
import yaml
from pathlib import Path
@@ -78,3 +79,29 @@ def exploding_write(*args, **kwargs):
assert result is False
assert config_env.read_text() == original_content
+
+ def test_returns_structured_result_for_locked_config(self, config_env, monkeypatch):
+ """Read-only config saves should return a patch the user can apply manually."""
+ def locked_write(*args, **kwargs):
+ raise OSError(errno.EROFS, "Read-only file system")
+
+ monkeypatch.setattr("utils.atomic_yaml_write", locked_write)
+
+ from cli import save_config_value_result
+
+ result = save_config_value_result("display.skin", "mono")
+
+ assert not result
+ assert result.blocked is True
+ assert "display:" in result.diff
+ assert "+ skin: mono" in result.diff or "+skin: mono" in result.diff
+
+ def test_rejects_invalid_existing_yaml(self, config_env):
+ config_env.write_text("display: [broken\n", encoding="utf-8")
+
+ from cli import save_config_value_result
+
+ result = save_config_value_result("display.skin", "mono")
+
+ assert not result
+ assert "contains invalid YAML" in str(result.error)
diff --git a/tests/conftest.py b/tests/conftest.py
index 02114046674e..32faaf836755 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -107,6 +107,36 @@ def _ensure_current_event_loop(request):
asyncio.set_event_loop(None)
+@pytest.fixture(autouse=True)
+def _reset_terminal_runtime_state():
+ """Isolate process-global terminal state between tests.
+
+ Terminal/code-execution/file tools cache environments and interrupt state at
+ module scope. Full-suite runs can otherwise reuse a sandbox created by an
+ earlier test with a different backend (for example Modal), which makes later
+ tests nondeterministically inherit the wrong environment.
+ """
+ from tools.interrupt import set_interrupt
+ from tools.file_tools import clear_file_ops_cache
+ from tools import terminal_tool as _terminal_tool
+
+ def _reset():
+ set_interrupt(False)
+ clear_file_ops_cache()
+ _terminal_tool._stop_cleanup_thread()
+ for task_id in list(_terminal_tool._active_environments.keys()):
+ _terminal_tool.cleanup_vm(task_id)
+ _terminal_tool._creation_locks.clear()
+ _terminal_tool._task_env_overrides.clear()
+
+ _reset()
+
+ try:
+ yield
+ finally:
+ _reset()
+
+
@pytest.fixture(autouse=True)
def _enforce_test_timeout():
"""Kill any individual test that takes longer than 30 seconds.
diff --git a/tests/cron/test_codex_execution_paths.py b/tests/cron/test_codex_execution_paths.py
index 354c95ddeb54..2b99f93ddf76 100644
--- a/tests/cron/test_codex_execution_paths.py
+++ b/tests/cron/test_codex_execution_paths.py
@@ -138,6 +138,7 @@ def test_gateway_run_agent_codex_path_handles_internal_401_refresh(monkeypatch):
"api_key": "codex-token",
},
)
+ monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.3-codex")
monkeypatch.setenv("HERMES_TOOL_PROGRESS", "false")
monkeypatch.setenv("HERMES_MODEL", "gpt-5.3-codex")
diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py
index 038900089ba2..45978c382af2 100644
--- a/tests/gateway/test_api_server.py
+++ b/tests/gateway/test_api_server.py
@@ -381,6 +381,370 @@ async def test_empty_messages_returns_400(self, adapter):
resp = await cli.post("/v1/chat/completions", json={"model": "test", "messages": []})
assert resp.status == 400
+ @pytest.mark.asyncio
+ async def test_multimodal_image_content_does_not_crash(self, adapter):
+ """Regression: OpenAI structured content with image parts must
+ not crash the request router.
+
+ Before the (a) hotfix, ``_is_openwebui_meta_request`` called
+ ``user_message.lstrip()`` unconditionally, which raised
+ ``AttributeError: 'list' object has no attribute 'lstrip'`` on
+ any multimodal request from Open WebUI and returned a bare 500.
+ Since the (b) follow-up the endpoint forwards images to the
+ agent via ``vision_analyze``-friendly file paths, so this
+ request is now answered normally (200) instead of rejected.
+ """
+ app = _create_app(adapter)
+
+ captured_kwargs = {}
+
+ async def _fake_run_agent(**kwargs):
+ captured_kwargs.update(kwargs)
+ return (
+ {"final_response": "ok", "messages": [], "api_calls": 1},
+ {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
+ )
+
+ async with TestClient(TestServer(app)) as cli:
+ with patch.object(adapter, "_run_agent", side_effect=_fake_run_agent):
+ resp = await cli.post(
+ "/v1/chat/completions",
+ json={
+ "model": "test",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "See the attached image"},
+ {
+ "type": "image_url",
+ "image_url": {
+ # Smallest valid base64 PNG
+ "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvIAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="
+ },
+ },
+ ],
+ }
+ ],
+ },
+ )
+ assert resp.status == 200, await resp.text()
+ # The agent received the user's text plus an inlined hint
+ # pointing at the on-disk attachment.
+ assert "See the attached image" in captured_kwargs["user_message"]
+ assert "vision_analyze" in captured_kwargs["user_message"]
+ assert "image_01.png" in captured_kwargs["user_message"]
+
+ @pytest.mark.asyncio
+ async def test_image_only_request_is_forwarded_with_hint_only(self, adapter):
+ """A request containing only an image (no text part) is still
+ valid: the user_message becomes just the attachment hint, and
+ the agent gets the file path so it can call vision_analyze.
+ """
+ app = _create_app(adapter)
+ captured = {}
+
+ async def _fake_run_agent(**kwargs):
+ captured.update(kwargs)
+ return (
+ {"final_response": "ok", "messages": [], "api_calls": 1},
+ {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
+ )
+
+ async with TestClient(TestServer(app)) as cli:
+ with patch.object(adapter, "_run_agent", side_effect=_fake_run_agent):
+ resp = await cli.post(
+ "/v1/chat/completions",
+ json={
+ "model": "test",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvIAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="
+ },
+ }
+ ],
+ }
+ ],
+ },
+ )
+ assert resp.status == 200, await resp.text()
+ assert "vision_analyze" in captured["user_message"]
+ assert "image_01.png" in captured["user_message"]
+
+ @pytest.mark.asyncio
+ async def test_audio_content_part_still_rejected_with_clear_error(self, adapter):
+ """Audio / video / unknown parts are not forwarded yet — they
+ still produce a 400 with a clear error code so the client
+ knows what failed.
+ """
+ app = _create_app(adapter)
+ async with TestClient(TestServer(app)) as cli:
+ resp = await cli.post(
+ "/v1/chat/completions",
+ json={
+ "model": "test",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "transcribe please"},
+ {
+ "type": "input_audio",
+ "input_audio": {"data": "xxx", "format": "wav"},
+ },
+ ],
+ }
+ ],
+ },
+ )
+ assert resp.status == 400
+ data = await resp.json()
+ assert data["error"]["code"] == "unsupported_content_part"
+ assert "input_audio" in data["error"]["message"]
+
+ @pytest.mark.asyncio
+ async def test_image_url_http_url_passed_through_as_is(self, adapter):
+ """Remote http(s) image URLs are forwarded to the agent as-is —
+ no fetch, no re-encoding. ``vision_analyze`` accepts URLs.
+ """
+ app = _create_app(adapter)
+ captured = {}
+
+ async def _fake_run_agent(**kwargs):
+ captured.update(kwargs)
+ return (
+ {"final_response": "ok", "messages": [], "api_calls": 1},
+ {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
+ )
+
+ async with TestClient(TestServer(app)) as cli:
+ with patch.object(adapter, "_run_agent", side_effect=_fake_run_agent):
+ resp = await cli.post(
+ "/v1/chat/completions",
+ json={
+ "model": "test",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "describe"},
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": "https://example.com/cat.png"
+ },
+ },
+ ],
+ }
+ ],
+ },
+ )
+ assert resp.status == 200
+ assert "https://example.com/cat.png" in captured["user_message"]
+
+ @pytest.mark.asyncio
+ async def test_stale_image_in_history_does_not_block_text_followup(self, adapter):
+ """Historical turns with multimodal parts must be flattened
+ (with an image hint inlined), not hard-reject a plain text
+ follow-up request.
+ """
+ app = _create_app(adapter)
+ captured = {}
+
+ async def _fake_run_agent(**kwargs):
+ captured.update(kwargs)
+ return (
+ {"final_response": "ok", "messages": [], "api_calls": 1},
+ {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
+ )
+
+ async with TestClient(TestServer(app)) as cli:
+ with patch.object(adapter, "_run_agent", side_effect=_fake_run_agent):
+ resp = await cli.post(
+ "/v1/chat/completions",
+ json={
+ "model": "test",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "look at this"},
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvIAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="
+ },
+ },
+ ],
+ },
+ {"role": "assistant", "content": "I see a screenshot."},
+ {"role": "user", "content": "what did you notice?"},
+ ],
+ },
+ )
+ # Final turn is plain text — request must succeed.
+ assert resp.status == 200, await resp.text()
+ # Final user_message is the active text turn, NOT the historical image
+ assert "what did you notice" in captured["user_message"]
+ # Historical user turn was inlined into history with the image hint
+ history = captured["conversation_history"]
+ assert any(
+ "look at this" in (m.get("content") or "")
+ and "vision_analyze" in (m.get("content") or "")
+ for m in history
+ if m.get("role") == "user"
+ )
+
+ @pytest.mark.asyncio
+ async def test_attachment_dir_cleaned_up_after_completion(self, adapter, monkeypatch):
+ """The per-request attachment directory must not leak after
+ the agent finishes. ``_cleanup_attachments`` is called from
+ the ``finally`` block of ``_handle_chat_completions``.
+ """
+ from pathlib import Path
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as td:
+ home = Path(td)
+ monkeypatch.setattr(
+ "hermes_cli.config.get_hermes_home", lambda: home
+ )
+
+ app = _create_app(adapter)
+
+ async def _fake_run_agent(**kwargs):
+ # While the agent is "running", the attachment dir must exist.
+ attach_root = home / "api_server_attachments"
+ assert attach_root.exists()
+ # Exactly one subdirectory (this completion's).
+ subs = list(attach_root.iterdir())
+ assert len(subs) == 1
+ files = list(subs[0].iterdir())
+ assert any(f.suffix == ".png" for f in files)
+ return (
+ {"final_response": "ok", "messages": [], "api_calls": 1},
+ {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
+ )
+
+ async with TestClient(TestServer(app)) as cli:
+ with patch.object(adapter, "_run_agent", side_effect=_fake_run_agent):
+ resp = await cli.post(
+ "/v1/chat/completions",
+ json={
+ "model": "test",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "describe"},
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvIAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="
+ },
+ },
+ ],
+ }
+ ],
+ },
+ )
+ assert resp.status == 200
+ # After the response returns, the per-completion subdir
+ # is gone (the parent ``api_server_attachments`` may
+ # remain — that's fine, it's an empty parent dir).
+ attach_root = home / "api_server_attachments"
+ if attach_root.exists():
+ assert list(attach_root.iterdir()) == []
+
+ @pytest.mark.asyncio
+ async def test_meta_request_detection_tolerates_non_string_content(self, adapter):
+ """_is_openwebui_meta_request must not crash on list content."""
+ assert adapter._is_openwebui_meta_request("### Task:\nSuggest 3 follow-ups") is True
+ assert (
+ adapter._is_openwebui_meta_request(
+ [{"type": "text", "text": "### Task:\nSuggest"}]
+ )
+ is False
+ )
+ assert adapter._is_openwebui_meta_request(None) is False
+ assert adapter._is_openwebui_meta_request(42) is False
+
+ def test_normalize_openai_content_strings_pass_through(self, adapter):
+ text, images, unsupported = adapter._normalize_openai_content("hello world")
+ assert text == "hello world"
+ assert images == []
+ assert unsupported == []
+
+ def test_normalize_openai_content_flattens_text_parts(self, adapter):
+ text, images, unsupported = adapter._normalize_openai_content(
+ [
+ {"type": "text", "text": "part A "},
+ {"type": "text", "text": "part B"},
+ ]
+ )
+ assert text == "part A part B"
+ assert images == []
+ assert unsupported == []
+
+ def test_normalize_openai_content_extracts_image_urls(self, adapter):
+ text, images, unsupported = adapter._normalize_openai_content(
+ [
+ {"type": "text", "text": "see: "},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/png;base64,AAA="},
+ },
+ {
+ "type": "image_url",
+ "image_url": {"url": "https://example.com/x.jpg"},
+ },
+ ]
+ )
+ assert text == "see: "
+ assert images == [
+ "data:image/png;base64,AAA=",
+ "https://example.com/x.jpg",
+ ]
+ assert unsupported == []
+
+ def test_normalize_openai_content_reports_unsupported_types(self, adapter):
+ text, images, unsupported = adapter._normalize_openai_content(
+ [
+ {"type": "text", "text": "see: "},
+ {"type": "input_audio", "input_audio": {"data": "xxx"}},
+ {"type": "video_url", "video_url": {"url": "x"}},
+ ]
+ )
+ assert text == "see: "
+ assert images == []
+ assert unsupported == ["input_audio", "video_url"]
+
+ def test_normalize_openai_content_handles_empty_and_none(self, adapter):
+ assert adapter._normalize_openai_content(None) == ("", [], [])
+ assert adapter._normalize_openai_content("") == ("", [], [])
+ assert adapter._normalize_openai_content([]) == ("", [], [])
+
+ def test_normalize_openai_content_image_url_string_form(self, adapter):
+ """Some lax clients pass ``image_url`` as a bare string instead
+ of ``{"url": "..."}``. Both forms must extract correctly.
+ """
+ text, images, unsupported = adapter._normalize_openai_content(
+ [
+ {
+ "type": "image_url",
+ "image_url": "https://example.com/y.png",
+ },
+ ]
+ )
+ assert text == ""
+ assert images == ["https://example.com/y.png"]
+ assert unsupported == []
+
@pytest.mark.asyncio
async def test_stream_true_returns_sse(self, adapter):
"""stream=true returns SSE format with the full response."""
diff --git a/tests/gateway/test_api_server_toolset.py b/tests/gateway/test_api_server_toolset.py
index 943d867e6132..cd636d67b02e 100644
--- a/tests/gateway/test_api_server_toolset.py
+++ b/tests/gateway/test_api_server_toolset.py
@@ -55,6 +55,14 @@ def test_toolset_excludes_send_message(self):
tools = resolve_toolset("hermes-api-server")
assert "send_message" not in tools
+ def test_toolset_excludes_send_user_message(self):
+ tools = resolve_toolset("hermes-api-server")
+ assert "send_user_message" not in tools
+
+ def test_toolset_excludes_self_nudge(self):
+ tools = resolve_toolset("hermes-api-server")
+ assert "self_nudge" not in tools
+
def test_toolset_excludes_text_to_speech(self):
tools = resolve_toolset("hermes-api-server")
assert "text_to_speech" not in tools
diff --git a/tests/gateway/test_boot_md.py b/tests/gateway/test_boot_md.py
new file mode 100644
index 000000000000..5ad6fdb4fb86
--- /dev/null
+++ b/tests/gateway/test_boot_md.py
@@ -0,0 +1,52 @@
+from unittest.mock import patch
+
+import pytest
+
+from gateway.builtin_hooks import boot_md
+
+
+def test_run_boot_agent_uses_gateway_runtime():
+ captured = {}
+ pool = object()
+
+ class FakeAgent:
+ def __init__(self, **kwargs):
+ captured["kwargs"] = kwargs
+
+ def run_conversation(self, prompt):
+ captured["prompt"] = prompt
+ return {"final_response": "[SILENT]"}
+
+ runtime_kwargs = {
+ "api_key": "sk-test",
+ "base_url": "http://example.test/v1",
+ "provider": "openai",
+ "api_mode": "chat_completions",
+ "command": "copilot-acp",
+ "args": ["serve", "--stdio"],
+ "credential_pool": pool,
+ }
+
+ with patch("gateway.run._resolve_gateway_model", return_value="gpt-test"), patch(
+ "gateway.run._resolve_runtime_agent_kwargs", return_value=runtime_kwargs
+ ), patch("run_agent.AIAgent", FakeAgent):
+ boot_md._run_boot_agent("Check the startup state.")
+
+ assert captured["kwargs"]["model"] == "gpt-test"
+ assert captured["kwargs"]["platform"] == "gateway"
+ assert captured["kwargs"]["api_key"] == "sk-test"
+ assert captured["kwargs"]["base_url"] == "http://example.test/v1"
+ assert captured["kwargs"]["provider"] == "openai"
+ assert captured["kwargs"]["api_mode"] == "chat_completions"
+ assert captured["kwargs"]["command"] == "copilot-acp"
+ assert captured["kwargs"]["args"] == ["serve", "--stdio"]
+ assert captured["kwargs"]["credential_pool"] is pool
+ assert "BOOT.md" in captured["prompt"]
+ assert "Check the startup state." in captured["prompt"]
+
+
+@pytest.mark.asyncio
+async def test_handle_skips_missing_boot_file(tmp_path):
+ missing = tmp_path / "BOOT.md"
+ with patch("gateway.builtin_hooks.boot_md.BOOT_FILE", missing):
+ assert await boot_md.handle("gateway:startup", {}) is None
diff --git a/tests/gateway/test_config_command_errors.py b/tests/gateway/test_config_command_errors.py
new file mode 100644
index 000000000000..513b0334d51b
--- /dev/null
+++ b/tests/gateway/test_config_command_errors.py
@@ -0,0 +1,62 @@
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+import gateway.run as gateway_run
+from gateway.config import Platform
+from gateway.platforms.base import MessageEvent
+from gateway.session import SessionSource
+
+
+def _make_event(text, platform=Platform.TELEGRAM, user_id="12345", chat_id="67890"):
+ source = SessionSource(
+ platform=platform,
+ user_id=user_id,
+ chat_id=chat_id,
+ user_name="testuser",
+ )
+ return MessageEvent(text=text, source=source)
+
+
+def _make_runner():
+ runner = object.__new__(gateway_run.GatewayRunner)
+ runner.adapters = {}
+ runner._ephemeral_system_prompt = ""
+ runner._prefill_messages = []
+ runner._reasoning_config = None
+ runner._show_reasoning = False
+ runner._provider_routing = {}
+ runner._fallback_model = None
+ runner._running_agents = {}
+ runner.hooks = MagicMock()
+ runner.hooks.emit = AsyncMock()
+ runner.hooks.loaded_hooks = []
+ runner._session_db = None
+ runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
+ return runner
+
+
+@pytest.mark.asyncio
+async def test_verbose_reports_invalid_yaml(tmp_path, monkeypatch):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text("display: [\n", encoding="utf-8")
+ monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
+
+ runner = _make_runner()
+ result = await runner._handle_verbose_command(_make_event("/verbose"))
+
+ assert "invalid YAML" in result
+
+
+@pytest.mark.asyncio
+async def test_personality_reports_invalid_yaml(tmp_path, monkeypatch):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text("agent: [\n", encoding="utf-8")
+ monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
+
+ runner = _make_runner()
+ result = await runner._handle_personality_command(_make_event("/personality"))
+
+ assert "invalid YAML" in result
diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py
index b9a94c3438bc..c81a4893a97d 100644
--- a/tests/gateway/test_dm_topics.py
+++ b/tests/gateway/test_dm_topics.py
@@ -280,6 +280,22 @@ def test_persist_dm_topic_thread_id_skips_if_already_set(tmp_path):
assert topics[0]["thread_id"] == 500 # unchanged
+def test_persist_dm_topic_thread_id_does_not_overwrite_invalid_yaml(tmp_path, caplog):
+ config_file = tmp_path / ".hermes" / "config.yaml"
+ config_file.parent.mkdir(parents=True)
+ original = "platforms: [broken\n"
+ config_file.write_text(original, encoding="utf-8")
+
+ adapter = _make_adapter()
+
+ with patch.object(Path, "home", return_value=tmp_path), \
+ patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
+ adapter._persist_dm_topic_thread_id(111, "General", 999)
+
+ assert config_file.read_text(encoding="utf-8") == original
+ assert "contains invalid YAML" in caplog.text
+
+
# ── _get_dm_topic_info ──
diff --git a/tests/gateway/test_matrix_voice.py b/tests/gateway/test_matrix_voice.py
index 93d56caf1daf..c728867195b2 100644
--- a/tests/gateway/test_matrix_voice.py
+++ b/tests/gateway/test_matrix_voice.py
@@ -1,18 +1,59 @@
"""Tests for Matrix voice message support (MSC3245)."""
import io
+import sys
import types
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
-# Try importing real nio; skip entire file if not available.
-# A MagicMock in sys.modules (from another test) is not the real package.
-try:
- import nio as _nio_probe
- if not isinstance(_nio_probe, types.ModuleType) or not hasattr(_nio_probe, "__file__"):
- pytest.skip("nio in sys.modules is a mock, not the real package", allow_module_level=True)
-except ImportError:
- pytest.skip("matrix-nio not installed", allow_module_level=True)
+def _make_fake_nio_module():
+ nio = types.ModuleType("nio")
+
+ class _RoomMessageImage:
+ pass
+
+ class _RoomMessageAudio:
+ pass
+
+ class _RoomMessageVideo:
+ pass
+
+ class _RoomMessageFile:
+ pass
+
+ class _MemoryDownloadResponse:
+ pass
+
+ class _DownloadError:
+ pass
+
+ class _UploadResponse:
+ pass
+
+ class _RoomSendResponse:
+ pass
+
+ nio.RoomMessageImage = _RoomMessageImage
+ nio.RoomMessageAudio = _RoomMessageAudio
+ nio.RoomMessageVideo = _RoomMessageVideo
+ nio.RoomMessageFile = _RoomMessageFile
+ nio.MemoryDownloadResponse = _MemoryDownloadResponse
+ nio.DownloadError = _DownloadError
+ nio.UploadResponse = _UploadResponse
+ nio.RoomSendResponse = _RoomSendResponse
+ return nio
+
+
+@pytest.fixture(autouse=True)
+def _install_fake_nio(monkeypatch):
+ existing = sys.modules.get("nio")
+ if existing is not None and getattr(existing, "__file__", None):
+ yield
+ return
+
+ fake_nio = _make_fake_nio_module()
+ monkeypatch.setitem(sys.modules, "nio", fake_nio)
+ yield
from gateway.platforms.base import MessageType
diff --git a/tests/gateway/test_models_command.py b/tests/gateway/test_models_command.py
new file mode 100644
index 000000000000..dfea6024d97b
--- /dev/null
+++ b/tests/gateway/test_models_command.py
@@ -0,0 +1,167 @@
+"""Tests for the /models slash command."""
+
+import asyncio
+from unittest.mock import MagicMock, AsyncMock, patch
+
+import pytest
+import yaml
+
+import gateway.run as gateway_run
+from gateway.config import Platform
+from gateway.platforms.base import MessageEvent
+from gateway.session import SessionSource
+
+
+def _make_event(text="/models"):
+ source = SessionSource(
+ platform=Platform.TELEGRAM,
+ user_id="u1",
+ chat_id="c1",
+ user_name="testuser",
+ )
+ return MessageEvent(text=text, source=source)
+
+
+def _make_runner():
+ runner = object.__new__(gateway_run.GatewayRunner)
+ runner.adapters = {}
+ runner._ephemeral_system_prompt = ""
+ runner._prefill_messages = []
+ runner._reasoning_config = None
+ runner._show_reasoning = False
+ runner._provider_routing = {}
+ runner._fallback_model = None
+ runner._running_agents = {}
+ runner.hooks = MagicMock()
+ runner.hooks.emit = AsyncMock()
+ runner.hooks.loaded_hooks = []
+ runner._session_db = None
+ return runner
+
+
+def _write_config(path, provider="openai", model="gpt-4o"):
+ path.write_text(
+ yaml.dump({"model": {"provider": provider, "model": model}}),
+ encoding="utf-8",
+ )
+
+
+class TestModelsCommand:
+
+ def test_lists_models_for_current_provider(self, tmp_path, monkeypatch):
+ _write_config(tmp_path / "config.yaml", provider="openai", model="gpt-4o")
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+
+ with patch("hermes_cli.models.provider_model_ids", return_value=["gpt-4o", "gpt-4-turbo"]):
+ result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models")))
+
+ assert "gpt-4o" in result
+ assert "gpt-4-turbo" in result
+
+ def test_marks_active_model(self, tmp_path, monkeypatch):
+ _write_config(tmp_path / "config.yaml", provider="openai", model="gpt-4o")
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+
+ with patch("hermes_cli.models.provider_model_ids", return_value=["gpt-4o", "gpt-4-turbo"]):
+ result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models")))
+
+ assert "← active" in result
+ assert result.count("← active") == 1
+ assert "`gpt-4o` ← active" in result
+
+ def test_explicit_provider_arg(self, tmp_path, monkeypatch):
+ _write_config(tmp_path / "config.yaml", provider="openai", model="gpt-4o")
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+
+ with patch("hermes_cli.models.provider_model_ids", return_value=["claude-opus-4-5", "claude-sonnet-4-5"]):
+ result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models anthropic")))
+
+ assert "claude-opus-4-5" in result
+ assert "claude-sonnet-4-5" in result
+ # Active model belongs to openai, not anthropic — no marker expected
+ assert "← active" not in result
+
+ def test_truncates_long_list(self, tmp_path, monkeypatch):
+ _write_config(tmp_path / "config.yaml", provider="openrouter", model="openai/gpt-4o")
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+
+ models = [f"model-{i}" for i in range(80)]
+ with patch("hermes_cli.models.provider_model_ids", return_value=models):
+ result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models openrouter")))
+
+ assert "model-0" in result
+ assert "model-49" in result
+ assert "model-50" not in result
+ assert "30 more" in result
+
+ def test_no_truncation_when_under_limit(self, tmp_path, monkeypatch):
+ _write_config(tmp_path / "config.yaml", provider="anthropic", model="claude-sonnet-4-5")
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+
+ models = [f"claude-model-{i}" for i in range(5)]
+ with patch("hermes_cli.models.provider_model_ids", return_value=models):
+ result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models anthropic")))
+
+ assert "more" not in result
+ for m in models:
+ assert m in result
+
+ def test_unknown_provider_returns_error(self, tmp_path, monkeypatch):
+ _write_config(tmp_path / "config.yaml")
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+
+ with patch("hermes_cli.models.provider_model_ids", return_value=[]), \
+ patch("hermes_cli.models.curated_models_for_provider", return_value=[]):
+ result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models nonexistentprovider")))
+
+ assert "No model list available" in result
+
+ def test_custom_named_provider(self, tmp_path, monkeypatch):
+ _write_config(tmp_path / "config.yaml")
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+
+ custom_cfg = {
+ "model": {"provider": "openai", "model": "gpt-4o"},
+ "custom_providers": [
+ {"name": "lmstudio", "base_url": "http://localhost:1234/v1", "api_key": ""}
+ ],
+ }
+ with patch("hermes_cli.config.load_config", return_value=custom_cfg), \
+ patch("hermes_cli.models.fetch_api_models", return_value=["qwen2.5-7b", "llama-3.2-3b"]):
+ result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models custom:lmstudio")))
+
+ assert "qwen2.5-7b" in result
+ assert "llama-3.2-3b" in result
+ assert "lmstudio" in result
+
+ def test_custom_named_provider_not_found(self, tmp_path, monkeypatch):
+ _write_config(tmp_path / "config.yaml")
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+
+ custom_cfg = {"model": {}, "custom_providers": []}
+ with patch("hermes_cli.config.load_config", return_value=custom_cfg):
+ result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models custom:unknown")))
+
+ assert "No custom provider named" in result
+ assert "unknown" in result
+
+ def test_custom_provider_endpoint_unreachable(self, tmp_path, monkeypatch):
+ _write_config(tmp_path / "config.yaml")
+ monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
+
+ custom_cfg = {
+ "model": {},
+ "custom_providers": [
+ {"name": "lmstudio", "base_url": "http://localhost:1234/v1", "api_key": ""}
+ ],
+ }
+ with patch("hermes_cli.config.load_config", return_value=custom_cfg), \
+ patch("hermes_cli.models.fetch_api_models", return_value=None):
+ result = asyncio.run(_make_runner()._handle_models_command(_make_event("/models custom:lmstudio")))
+
+ assert "Could not fetch" in result
+
+ def test_models_is_dispatched_in_handle_message(self):
+ import inspect
+ source = inspect.getsource(gateway_run.GatewayRunner._handle_message)
+ assert '"models"' in source
diff --git a/tests/gateway/test_restart_resume.py b/tests/gateway/test_restart_resume.py
new file mode 100644
index 000000000000..747d9033669a
--- /dev/null
+++ b/tests/gateway/test_restart_resume.py
@@ -0,0 +1,294 @@
+import asyncio
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from gateway.config import GatewayConfig, Platform, PlatformConfig
+from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
+from gateway.run import GatewayRunner
+from gateway.session import SessionSource, build_session_key
+
+
+class StubAdapter(BasePlatformAdapter):
+ def __init__(self):
+ super().__init__(PlatformConfig(enabled=True, token="***"), Platform.TELEGRAM)
+
+ async def connect(self):
+ return True
+
+ async def disconnect(self):
+ return None
+
+ async def send(self, chat_id, content, reply_to=None, metadata=None):
+ return SendResult(success=True, message_id="1")
+
+ async def send_typing(self, chat_id, metadata=None):
+ return None
+
+ async def get_chat_info(self, chat_id):
+ return {"id": chat_id}
+
+
+def _source(chat_id="123456", chat_type="dm"):
+ return SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id=chat_id,
+ chat_type=chat_type,
+ )
+
+
+def test_gateway_config_round_trips_resume_inflight_flag():
+ cfg = GatewayConfig.from_dict({"resume_inflight_sessions_on_restart": True})
+ assert cfg.resume_inflight_sessions_on_restart is True
+ assert cfg.to_dict()["resume_inflight_sessions_on_restart"] is True
+
+
+@pytest.mark.asyncio
+async def test_gateway_stop_persists_inflight_resume_ledger(tmp_path, monkeypatch):
+ import gateway.run as run_mod
+
+ ledger_path = tmp_path / "inflight_resume.json"
+ monkeypatch.setattr(run_mod, "_INFLIGHT_RESUME_LEDGER_PATH", ledger_path)
+
+ runner = object.__new__(GatewayRunner)
+ runner.config = GatewayConfig(
+ platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")},
+ resume_inflight_sessions_on_restart=True,
+ )
+ runner._running = True
+ runner._shutdown_event = asyncio.Event()
+ runner._exit_reason = None
+ runner._pending_messages = {}
+ runner._pending_approvals = {}
+ runner._background_tasks = set()
+ runner._running_agents_ts = {}
+ runner._session_model_overrides = {}
+ runner._inflight_turns = {}
+
+ source = _source()
+ session_key = build_session_key(source)
+ running_agent = MagicMock()
+ adapter = StubAdapter()
+ adapter._pending_messages[session_key] = MessageEvent(
+ text="queued follow-up",
+ source=source,
+ message_id="msg-2",
+ )
+
+ runner._running_agents = {session_key: running_agent}
+ runner.adapters = {Platform.TELEGRAM: adapter}
+ runner._pending_approvals = {session_key: {"command": "rm -rf /tmp/x"}}
+ runner._session_model_overrides = {
+ session_key: {"model": "openrouter/test-model", "provider": "openrouter"}
+ }
+ runner._inflight_turns = {
+ session_key: {
+ "session_key": session_key,
+ "session_id": "sess_001",
+ "source": source.to_dict(),
+ "event": GatewayRunner._serialize_resume_event(
+ MessageEvent(text="initial work", source=source, message_id="msg-1")
+ ),
+ "started_at": "2026-04-05T12:00:00",
+ }
+ }
+
+ with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
+ await runner.stop()
+
+ running_agent.interrupt.assert_called_once_with("Gateway shutting down")
+ assert ledger_path.exists()
+ payload = json.loads(ledger_path.read_text(encoding="utf-8"))
+ assert len(payload) == 1
+ entry = payload[0]
+ assert entry["session_key"] == session_key
+ assert entry["session_id"] == "sess_001"
+ assert entry["pending_event"]["text"] == "queued follow-up"
+ assert entry["approval_blocked"] is True
+ assert entry["session_model_override"]["model"] == "openrouter/test-model"
+ assert runner._inflight_turns == {}
+
+
+@pytest.mark.asyncio
+async def test_gateway_stop_skips_resume_ledger_for_turns_that_finish_during_shutdown(tmp_path, monkeypatch):
+ import gateway.run as run_mod
+
+ ledger_path = tmp_path / "inflight_resume.json"
+ monkeypatch.setattr(run_mod, "_INFLIGHT_RESUME_LEDGER_PATH", ledger_path)
+
+ runner = object.__new__(GatewayRunner)
+ runner.config = GatewayConfig(
+ platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")},
+ resume_inflight_sessions_on_restart=True,
+ )
+ runner._running = True
+ runner._shutdown_event = asyncio.Event()
+ runner._exit_reason = None
+ runner._pending_messages = {}
+ runner._pending_approvals = {}
+ runner._background_tasks = set()
+ runner._running_agents_ts = {}
+ runner._session_model_overrides = {}
+
+ source = _source()
+ session_key = build_session_key(source)
+ runner._inflight_turns = {
+ session_key: {
+ "session_key": session_key,
+ "session_id": "sess_001",
+ "source": source.to_dict(),
+ "event": GatewayRunner._serialize_resume_event(
+ MessageEvent(text="initial work", source=source, message_id="msg-1")
+ ),
+ "started_at": "2026-04-05T12:00:00",
+ }
+ }
+
+ running_agent = MagicMock()
+
+ def _interrupt(_reason):
+ runner._inflight_turns.pop(session_key, None)
+
+ running_agent.interrupt.side_effect = _interrupt
+ runner._running_agents = {session_key: running_agent}
+ runner.adapters = {Platform.TELEGRAM: StubAdapter()}
+
+ with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
+ await runner.stop()
+
+ assert not ledger_path.exists()
+
+
+@pytest.mark.asyncio
+async def test_resume_interrupted_session_uses_hidden_prefix_for_pending_followup(tmp_path, monkeypatch):
+ import gateway.run as run_mod
+
+ ledger_path = tmp_path / "inflight_resume.json"
+ monkeypatch.setattr(run_mod, "_INFLIGHT_RESUME_LEDGER_PATH", ledger_path)
+
+ runner = object.__new__(GatewayRunner)
+ runner._running_agents = {}
+ runner._running_agents_ts = {}
+ runner._session_model_overrides = {}
+ runner._inflight_turns = {}
+ runner._handle_message_with_agent = AsyncMock(return_value=None)
+
+ source = _source()
+ session_key = build_session_key(source)
+ entry = {
+ "session_key": session_key,
+ "session_id": "sess_002",
+ "source": source.to_dict(),
+ "pending_event": {
+ "text": "Please use the latest requirements file.",
+ "message_type": "text",
+ "message_id": "msg-3",
+ "media_urls": [],
+ "media_types": [],
+ "reply_to_message_id": None,
+ "reply_to_text": None,
+ "auto_skill": None,
+ "persist_user_message": None,
+ "timestamp": None,
+ },
+ "approval_blocked": True,
+ "session_model_override": {"model": "openrouter/override"},
+ }
+ ledger_path.write_text(json.dumps([entry]), encoding="utf-8")
+
+ await runner._resume_interrupted_session(entry)
+
+ runner._handle_message_with_agent.assert_awaited_once()
+ event, call_source, call_session_key = runner._handle_message_with_agent.call_args.args
+ assert call_source.chat_id == source.chat_id
+ assert call_session_key == session_key
+ assert "gateway restarted" in event.text.lower()
+ assert "queued user follow-up" in event.text.lower()
+ assert event.persist_user_message == "Please use the latest requirements file."
+ assert runner._session_model_overrides[session_key]["model"] == "openrouter/override"
+ assert session_key not in runner._running_agents
+ assert not ledger_path.exists()
+
+
+@pytest.mark.asyncio
+async def test_resume_interrupted_session_keeps_ledger_entry_on_failure(tmp_path, monkeypatch):
+ import gateway.run as run_mod
+
+ ledger_path = tmp_path / "inflight_resume.json"
+ monkeypatch.setattr(run_mod, "_INFLIGHT_RESUME_LEDGER_PATH", ledger_path)
+
+ runner = object.__new__(GatewayRunner)
+ runner._running_agents = {}
+ runner._running_agents_ts = {}
+ runner._session_model_overrides = {}
+ runner._inflight_turns = {}
+
+ async def _boom(*args, **kwargs):
+ raise RuntimeError("resume failed")
+
+ runner._handle_message_with_agent = AsyncMock(side_effect=_boom)
+
+ source = _source()
+ session_key = build_session_key(source)
+ entry = {
+ "session_key": session_key,
+ "session_id": "sess_003",
+ "source": source.to_dict(),
+ }
+ ledger_path.write_text(json.dumps([entry]), encoding="utf-8")
+
+ with pytest.raises(RuntimeError, match="resume failed"):
+ await runner._resume_interrupted_session(entry)
+
+ remaining = json.loads(ledger_path.read_text(encoding="utf-8"))
+ assert len(remaining) == 1
+ assert remaining[0]["session_key"] == session_key
+
+
+@pytest.mark.asyncio
+async def test_recover_inflight_sessions_only_schedules_connected_platforms(tmp_path, monkeypatch):
+ import gateway.run as run_mod
+
+ ledger_path = tmp_path / "inflight_resume.json"
+ monkeypatch.setattr(run_mod, "_INFLIGHT_RESUME_LEDGER_PATH", ledger_path)
+
+ source = _source()
+ other_source = SessionSource(platform=Platform.SLACK, chat_id="C123", chat_type="channel")
+ entries = [
+ {
+ "session_key": build_session_key(source),
+ "session_id": "sess_tg",
+ "source": source.to_dict(),
+ },
+ {
+ "session_key": build_session_key(other_source),
+ "session_id": "sess_slack",
+ "source": other_source.to_dict(),
+ },
+ ]
+ ledger_path.write_text(json.dumps(entries), encoding="utf-8")
+
+ runner = object.__new__(GatewayRunner)
+ runner.config = GatewayConfig(
+ platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")},
+ resume_inflight_sessions_on_restart=True,
+ )
+ runner.adapters = {Platform.TELEGRAM: StubAdapter()}
+ runner._background_tasks = set()
+
+ async def _resume(entry):
+ entries = json.loads(ledger_path.read_text(encoding="utf-8"))
+ entries = [item for item in entries if item.get("session_key") != entry.get("session_key")]
+ ledger_path.write_text(json.dumps(entries), encoding="utf-8")
+
+ runner._resume_interrupted_session = AsyncMock(side_effect=_resume)
+ runner._resuming_inflight_sessions = set()
+
+ await runner._recover_inflight_sessions()
+ await asyncio.sleep(0)
+
+ runner._resume_interrupted_session.assert_awaited_once()
+ remaining = json.loads(ledger_path.read_text(encoding="utf-8"))
+ assert len(remaining) == 1
+ assert remaining[0]["session_id"] == "sess_slack"
diff --git a/tests/gateway/test_self_nudge.py b/tests/gateway/test_self_nudge.py
new file mode 100644
index 000000000000..2e3fc3e343d6
--- /dev/null
+++ b/tests/gateway/test_self_nudge.py
@@ -0,0 +1,243 @@
+import asyncio
+import threading
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from gateway.platforms.base import MessageEvent
+from gateway.run import GatewayRunner
+from gateway.session import SessionSource, build_session_key
+from gateway.config import Platform
+from tests.gateway.test_restart_resume import StubAdapter
+
+
+def _source(chat_id="123456"):
+ return SessionSource(
+ platform=Platform.TELEGRAM,
+ chat_id=chat_id,
+ chat_type="dm",
+ )
+
+
+@pytest.mark.asyncio
+async def test_arm_self_nudge_replaces_existing_timer():
+ runner = object.__new__(GatewayRunner)
+ runner._self_nudge_tasks = {}
+ runner._self_nudge_entries = {}
+ runner._pending_hidden_turns = {}
+ runner._running_agents = {}
+ runner._running_agents_ts = {}
+
+ source = _source()
+ session_key = build_session_key(source)
+
+ first = await runner._arm_self_nudge(session_key, source, 300, "First note")
+ second = await runner._arm_self_nudge(session_key, source, 120, "Second note")
+
+ assert first["armed"] is True
+ assert second["armed"] is True
+ assert second["replaced_existing"] is True
+ assert runner._self_nudge_entries[session_key]["note"] == "Second note"
+
+ await runner._cancel_self_nudge(session_key, reason="test_cleanup")
+
+
+@pytest.mark.asyncio
+async def test_fire_self_nudge_queues_hidden_turn_when_session_busy(monkeypatch):
+ runner = object.__new__(GatewayRunner)
+ runner._self_nudge_tasks = {}
+ runner._self_nudge_entries = {}
+ runner._pending_hidden_turns = {}
+ runner._running_agents = {}
+ runner._running_agents_ts = {}
+
+ source = _source()
+ session_key = build_session_key(source)
+ entry = {
+ "session_key": session_key,
+ "source": source.to_dict(),
+ "delay_seconds": 5,
+ "note": "Check the deploy result.",
+ }
+ runner._self_nudge_entries[session_key] = entry
+ runner._running_agents[session_key] = object()
+
+ async def _no_sleep(_seconds):
+ return None
+
+ monkeypatch.setattr(asyncio, "sleep", _no_sleep)
+
+ await runner._fire_self_nudge(entry)
+
+ assert session_key not in runner._self_nudge_entries
+ assert runner._pending_hidden_turns[session_key]["note"] == "Check the deploy result."
+
+
+@pytest.mark.asyncio
+async def test_run_self_nudge_entry_injects_hidden_turn():
+ runner = object.__new__(GatewayRunner)
+ runner._self_nudge_tasks = {}
+ runner._self_nudge_entries = {}
+ runner._pending_hidden_turns = {}
+ runner._running_agents = {}
+ runner._running_agents_ts = {}
+ runner._inflight_turns = {}
+ runner._handle_message_with_agent = AsyncMock(return_value=None)
+
+ source = _source()
+ session_key = build_session_key(source)
+ entry = {
+ "session_key": session_key,
+ "source": source.to_dict(),
+ "delay_seconds": 300,
+ "note": "Check whether the background task finished.",
+ }
+
+ await runner._run_self_nudge_entry(entry)
+
+ runner._handle_message_with_agent.assert_awaited_once()
+ event, call_source, call_session_key = runner._handle_message_with_agent.call_args.args
+ assert call_source.chat_id == source.chat_id
+ assert call_session_key == session_key
+ assert "self-nudge timer" in event.text.lower()
+ assert "background task finished" in event.text.lower()
+ assert event.persist_user_message == "[System note: a self-nudge timer fired.]"
+ assert session_key not in runner._running_agents
+
+
+@pytest.mark.asyncio
+async def test_cancel_self_nudge_clears_pending_hidden_turn():
+ runner = object.__new__(GatewayRunner)
+ runner._self_nudge_tasks = {}
+ runner._self_nudge_entries = {}
+ runner._pending_hidden_turns = {}
+
+ source = _source()
+ session_key = build_session_key(source)
+ task = asyncio.create_task(asyncio.sleep(60))
+ runner._self_nudge_tasks[session_key] = task
+ runner._self_nudge_entries[session_key] = {"session_key": session_key, "source": source.to_dict()}
+ runner._pending_hidden_turns[session_key] = {"session_key": session_key}
+
+ cancelled = await runner._cancel_self_nudge(session_key, reason="user_message")
+
+ assert cancelled is True
+ assert session_key not in runner._self_nudge_tasks
+ assert session_key not in runner._self_nudge_entries
+ assert session_key not in runner._pending_hidden_turns
+ task.cancel()
+
+
+@pytest.mark.asyncio
+async def test_queued_user_followup_cancels_pending_hidden_turn(monkeypatch):
+ import gateway.run as run_mod
+ import hermes_cli.tools_config as tools_cfg
+ import run_agent as run_agent_mod
+
+ runner = object.__new__(GatewayRunner)
+ runner.adapters = {Platform.TELEGRAM: StubAdapter()}
+ runner._provider_routing = {}
+ runner._fallback_model = None
+ runner._session_db = None
+ runner._ephemeral_system_prompt = None
+ runner._prefill_messages = None
+ runner._show_reasoning = False
+ runner._background_tasks = set()
+ runner._running_agents = {}
+ runner._running_agents_ts = {}
+ runner._agent_cache = {}
+ runner._agent_cache_lock = threading.Lock()
+ runner.hooks = SimpleNamespace(loaded_hooks=False)
+ runner._resolve_turn_agent_config = lambda message, model, runtime: {
+ "model": model,
+ "runtime": runtime,
+ }
+ runner._load_reasoning_config = lambda: None
+ runner._evict_cached_agent = lambda *_args, **_kwargs: None
+ runner._effective_model = None
+ runner._effective_provider = None
+ runner._pending_hidden_turns = {}
+ runner._cancel_self_nudge = AsyncMock(return_value=True)
+
+ source = _source()
+ session_key = build_session_key(source)
+ adapter = runner.adapters[Platform.TELEGRAM]
+ adapter._pending_messages[session_key] = MessageEvent(
+ text="queued follow-up",
+ source=source,
+ message_id="msg-2",
+ )
+ runner._pending_hidden_turns[session_key] = {
+ "session_key": session_key,
+ "source": source.to_dict(),
+ "delay_seconds": 60,
+ "note": "nudge",
+ }
+
+ class FakeAgent:
+ def __init__(self, **kwargs):
+ self.model = kwargs.get("model", "test-model")
+ self.context_compressor = SimpleNamespace(last_prompt_tokens=0)
+ self.session_prompt_tokens = 0
+ self.session_completion_tokens = 0
+ self.tools = []
+ self.message_callback = None
+ self.self_nudge_callback = None
+ self.status_callback = None
+ self.reasoning_config = None
+ self.step_callback = None
+ self.stream_delta_callback = None
+ self.background_review_callback = None
+
+ def run_conversation(self, *args, **kwargs):
+ return {
+ "final_response": "Initial response",
+ "messages": [
+ {"role": "user", "content": "initial"},
+ {"role": "assistant", "content": "Initial response"},
+ ],
+ "api_calls": 1,
+ }
+
+ monkeypatch.setattr(run_mod, "_load_gateway_config", lambda: {})
+ monkeypatch.setattr(run_mod, "_resolve_gateway_model", lambda config=None: "test-model")
+ monkeypatch.setattr(
+ run_mod,
+ "_resolve_runtime_agent_kwargs",
+ lambda: {
+ "api_key": "sk-test",
+ "base_url": "",
+ "provider": "",
+ "api_mode": None,
+ "command": None,
+ "args": [],
+ "credential_pool": None,
+ },
+ )
+ monkeypatch.setattr(run_mod, "_platform_config_key", lambda _platform: "telegram")
+ monkeypatch.setattr(tools_cfg, "_get_platform_tools", lambda cfg, key: ["user_updates"])
+ monkeypatch.setattr(run_agent_mod, "AIAgent", FakeAgent)
+
+ recursive = AsyncMock(
+ return_value={"final_response": "follow-up", "messages": [], "history_offset": 0}
+ )
+ runner._run_agent = recursive
+
+ result = await GatewayRunner._run_agent(
+ runner,
+ message="initial",
+ context_prompt="",
+ history=[],
+ source=source,
+ session_id="sess-1",
+ session_key=session_key,
+ )
+
+ runner._cancel_self_nudge.assert_awaited_once_with(
+ session_key,
+ reason="queued_user_followup",
+ )
+ recursive.assert_awaited_once()
+ assert recursive.await_args.kwargs["message"] == "queued follow-up"
+ assert result["final_response"] == "follow-up"
diff --git a/tests/gateway/test_sethome_command.py b/tests/gateway/test_sethome_command.py
new file mode 100644
index 000000000000..ec352f358927
--- /dev/null
+++ b/tests/gateway/test_sethome_command.py
@@ -0,0 +1,59 @@
+"""Tests for gateway /sethome config persistence fallback."""
+
+import errno
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+import gateway.run as gateway_run
+from gateway.config import Platform
+from gateway.platforms.base import MessageEvent
+from gateway.session import SessionSource
+
+
+def _make_event(text="/sethome", platform=Platform.TELEGRAM, user_id="12345", chat_id="67890"):
+ source = SessionSource(
+ platform=platform,
+ user_id=user_id,
+ chat_id=chat_id,
+ user_name="testuser",
+ chat_name="Ops",
+ )
+ return MessageEvent(text=text, source=source)
+
+
+def _make_runner():
+ runner = object.__new__(gateway_run.GatewayRunner)
+ runner.adapters = {}
+ runner._ephemeral_system_prompt = ""
+ runner._prefill_messages = []
+ runner._reasoning_config = None
+ runner._show_reasoning = False
+ runner._provider_routing = {}
+ runner._fallback_model = None
+ runner._running_agents = {}
+ runner.hooks = MagicMock()
+ runner.hooks.emit = AsyncMock()
+ runner.hooks.loaded_hooks = []
+ runner._session_db = None
+ runner._get_or_create_gateway_honcho = lambda session_key: (None, None)
+ return runner
+
+
+@pytest.mark.asyncio
+async def test_sethome_reports_session_only_when_config_is_locked(tmp_path, monkeypatch):
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir()
+ (hermes_home / "config.yaml").write_text("", encoding="utf-8")
+ monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
+
+ def locked_write(*args, **kwargs):
+ raise OSError(errno.EBUSY, "Device or resource busy")
+
+ monkeypatch.setattr("hermes_cli.config.save_env_value", locked_write)
+
+ runner = _make_runner()
+ result = await runner._handle_set_home_command(_make_event())
+
+ assert "for this running gateway only" in result
+ assert "could not persist to .env" in result
diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py
index 0dbd5980b0cb..26d253dac6c9 100644
--- a/tests/gateway/test_status_command.py
+++ b/tests/gateway/test_status_command.py
@@ -53,6 +53,7 @@ def _make_runner(session_entry: SessionEntry):
runner._pending_approvals = {}
runner._session_db = MagicMock()
runner._session_db.get_session_title.return_value = None
+ runner._session_db.get_session_token_totals.return_value = None
runner._reasoning_config = None
runner._provider_routing = {}
runner._fallback_model = None
@@ -111,6 +112,57 @@ async def test_status_command_includes_session_title_when_present():
assert "**Title:** My titled session" in result
+@pytest.mark.asyncio
+async def test_status_command_prefers_sessiondb_token_totals():
+ """When SessionDB has token totals, /status uses them as the
+ authoritative source, not the (now-vestigial) SessionStore field."""
+ session_entry = SessionEntry(
+ session_key=build_session_key(_make_source()),
+ session_id="sess-1",
+ created_at=datetime.now(),
+ updated_at=datetime.now(),
+ platform=Platform.TELEGRAM,
+ chat_type="dm",
+ total_tokens=321, # stale fallback value
+ )
+ runner = _make_runner(session_entry)
+ runner._session_db.get_session_token_totals.return_value = {
+ "input_tokens": 100,
+ "output_tokens": 200,
+ "cache_read_tokens": 10,
+ "cache_write_tokens": 5,
+ "reasoning_tokens": 6,
+ "total_tokens": 3210,
+ }
+
+ result = await runner._handle_message(_make_event("/status"))
+
+ assert "**Tokens:** 3,210" in result
+ assert "**Tokens:** 321" not in result
+
+
+@pytest.mark.asyncio
+async def test_status_command_falls_back_when_sessiondb_row_missing():
+ """When SessionDB has no row for this session (fresh install, DB
+ unavailable, or pre-SessionDB session), fall back to the persisted
+ SessionStore total."""
+ session_entry = SessionEntry(
+ session_key=build_session_key(_make_source()),
+ session_id="sess-1",
+ created_at=datetime.now(),
+ updated_at=datetime.now(),
+ platform=Platform.TELEGRAM,
+ chat_type="dm",
+ total_tokens=321,
+ )
+ runner = _make_runner(session_entry)
+ runner._session_db.get_session_token_totals.return_value = None
+
+ result = await runner._handle_message(_make_event("/status"))
+
+ assert "**Tokens:** 321" in result
+
+
@pytest.mark.asyncio
async def test_handle_message_persists_agent_token_counts(monkeypatch):
import gateway.run as gateway_run
diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py
index d97b0c1f7586..19c0f6c04f06 100644
--- a/tests/hermes_cli/test_api_key_providers.py
+++ b/tests/hermes_cli/test_api_key_providers.py
@@ -124,7 +124,7 @@ def test_oauth_providers_unchanged(self):
"AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL",
"KILOCODE_API_KEY", "KILOCODE_BASE_URL",
"DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY",
- "NOUS_API_KEY", "GITHUB_TOKEN", "GH_TOKEN",
+ "NOUS_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", "COPILOT_GITHUB_TOKEN", "HF_TOKEN",
"OPENAI_BASE_URL", "HERMES_COPILOT_ACP_COMMAND", "COPILOT_CLI_PATH",
"HERMES_COPILOT_ACP_ARGS", "COPILOT_ACP_BASE_URL",
)
@@ -652,6 +652,7 @@ def test_claude_code_creds_ignored_on_fresh_install(self, monkeypatch, tmp_path)
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: True,
)
+ monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda provider_id=None: {"logged_in": False})
from hermes_cli.main import _has_any_provider_configured
assert _has_any_provider_configured() is False
diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py
index 1c245577e91d..f5bf135ffcc5 100644
--- a/tests/hermes_cli/test_config.py
+++ b/tests/hermes_cli/test_config.py
@@ -1,12 +1,15 @@
"""Tests for hermes_cli configuration management."""
+import errno
import os
from pathlib import Path
from unittest.mock import patch, MagicMock
+import pytest
import yaml
from hermes_cli.config import (
+ ConfigWriteError,
DEFAULT_CONFIG,
get_hermes_home,
ensure_hermes_home,
@@ -251,6 +254,96 @@ def test_atomic_write_creates_valid_yaml(self, tmp_path):
assert raw["model"] == "test/atomic-model"
assert raw["agent"]["max_turns"] == 77
+ def test_save_config_raises_readable_error_for_locked_file(self, tmp_path):
+ with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
+ config = load_config()
+ config["model"] = "test/locked-model"
+
+ with patch("utils.atomic_yaml_write", side_effect=OSError(errno.EROFS, "Read-only file system")):
+ with pytest.raises(ConfigWriteError) as exc_info:
+ save_config(config)
+
+ assert "read-only or otherwise locked" in str(exc_info.value)
+ assert "Apply this patch manually" in str(exc_info.value)
+
+ def test_save_config_refuses_to_overwrite_invalid_existing_yaml(self, tmp_path):
+ with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text("model: [broken\n", encoding="utf-8")
+
+ config = load_config()
+ config["model"] = "test-fixed-model"
+
+ with pytest.raises(ConfigWriteError) as exc_info:
+ save_config(config)
+
+ assert "contains invalid YAML" in str(exc_info.value)
+ assert config_path.read_text(encoding="utf-8") == "model: [broken\n"
+
+
+class TestEditConfig:
+ def test_edit_config_retries_until_yaml_is_valid(self, tmp_path, monkeypatch, capsys):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text("model: original\n", encoding="utf-8")
+
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.setenv("EDITOR", "fake-editor")
+
+ calls = {"count": 0}
+
+ def fake_run(args):
+ edited_path = Path(args[1])
+ calls["count"] += 1
+ if calls["count"] == 1:
+ edited_path.write_text("model: [broken\n", encoding="utf-8")
+ else:
+ edited_path.write_text("model: fixed\n", encoding="utf-8")
+ return MagicMock(returncode=0)
+
+ monkeypatch.setattr("hermes_cli.config.subprocess.run", fake_run)
+ monkeypatch.setattr("builtins.input", lambda prompt="": "y")
+
+ from hermes_cli.config import edit_config
+
+ edit_config()
+
+ assert yaml.safe_load(config_path.read_text(encoding="utf-8")) == {"model": "fixed"}
+ assert calls["count"] == 2
+ output = capsys.readouterr().out
+ assert "YAML syntax is invalid" in output
+ assert f"Saved {config_path}" in output
+
+ def test_edit_config_reports_manual_patch_when_target_is_locked(self, tmp_path, monkeypatch, capsys):
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text("model: original\n", encoding="utf-8")
+
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.setenv("EDITOR", "fake-editor")
+
+ def fake_run(args):
+ edited_path = Path(args[1])
+ edited_path.write_text("model: updated\n", encoding="utf-8")
+ return MagicMock(returncode=0)
+
+ monkeypatch.setattr("hermes_cli.config.subprocess.run", fake_run)
+ monkeypatch.setattr(
+ "hermes_cli.config.os.replace",
+ lambda src, dst: (_ for _ in ()).throw(OSError(errno.EBUSY, "Device or resource busy")),
+ )
+
+ from hermes_cli.config import edit_config
+
+ edit_config()
+
+ output = capsys.readouterr().out
+ assert "read-only or otherwise locked" in output
+ assert "Apply this patch manually" in output
+ assert "model: updated" in output
+
class TestSanitizeEnvLines:
"""Tests for .env file corruption repair."""
diff --git a/tests/hermes_cli/test_config_write_guard_commands.py b/tests/hermes_cli/test_config_write_guard_commands.py
new file mode 100644
index 000000000000..ff088df9fbc0
--- /dev/null
+++ b/tests/hermes_cli/test_config_write_guard_commands.py
@@ -0,0 +1,99 @@
+from argparse import Namespace
+from pathlib import Path
+
+from hermes_cli.config import ConfigWriteError
+
+
+def _config_write_error(tmp_path: Path) -> ConfigWriteError:
+ return ConfigWriteError(
+ path=tmp_path / "config.yaml",
+ action="save configuration",
+ error=OSError(16, "Device or resource busy"),
+ blocked=True,
+ diff="--- before\n+++ after",
+ )
+
+
+def test_setup_wizard_catches_config_write_error(tmp_path, monkeypatch, capsys):
+ from hermes_cli import setup as setup_mod
+
+ monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False)
+ monkeypatch.setattr(setup_mod, "ensure_hermes_home", lambda: None)
+ monkeypatch.setattr(setup_mod, "load_config", lambda: {})
+ monkeypatch.setattr(setup_mod, "get_hermes_home", lambda: tmp_path)
+ monkeypatch.setattr(setup_mod, "is_interactive_stdin", lambda: True)
+ monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None)
+ monkeypatch.setattr(setup_mod, "SETUP_SECTIONS", [("model", "Model", lambda config: None)])
+ monkeypatch.setattr(setup_mod, "save_config", lambda config: (_ for _ in ()).throw(_config_write_error(tmp_path)))
+
+ args = Namespace(section="model", non_interactive=False, reset=False)
+ setup_mod.run_setup_wizard(args)
+
+ out = capsys.readouterr().out
+ assert "read-only or otherwise locked" in out
+ assert "Apply this patch manually" in out
+
+
+def test_tools_command_catches_config_write_error(tmp_path, monkeypatch, capsys):
+ from hermes_cli import tools_config as mod
+
+ monkeypatch.setattr(mod, "_get_enabled_platforms", lambda: ["cli"])
+ monkeypatch.setattr(mod, "_get_platform_tools", lambda *args, **kwargs: set())
+ monkeypatch.setattr(mod, "_prompt_toolset_checklist", lambda *args, **kwargs: {"web"})
+ monkeypatch.setattr(mod, "_configure_toolset", lambda *args, **kwargs: None)
+ monkeypatch.setattr(mod, "apply_nous_managed_defaults", lambda *args, **kwargs: set())
+ monkeypatch.setattr(mod, "_save_platform_tools", lambda *args, **kwargs: (_ for _ in ()).throw(_config_write_error(tmp_path)))
+
+ mod.tools_command(first_install=True, config={})
+
+ out = capsys.readouterr().out
+ assert "read-only or otherwise locked" in out
+
+
+def test_mcp_command_catches_config_write_error(tmp_path, monkeypatch, capsys):
+ from hermes_cli import mcp_config as mod
+
+ monkeypatch.setattr(mod, "cmd_mcp_configure", lambda args: (_ for _ in ()).throw(_config_write_error(tmp_path)))
+ mod.mcp_command(Namespace(mcp_action="configure", name="demo"))
+
+ out = capsys.readouterr().out
+ assert "read-only or otherwise locked" in out
+
+
+def test_skills_command_catches_config_write_error(tmp_path, monkeypatch, capsys):
+ from hermes_cli import skills_config as mod
+
+ monkeypatch.setattr(mod, "_list_all_skills", lambda: [{
+ "name": "demo",
+ "category": "general",
+ "description": "demo skill",
+ }])
+ monkeypatch.setattr(mod, "_select_platform", lambda: None)
+ monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "1")
+ monkeypatch.setattr("hermes_cli.curses_ui.curses_checklist", lambda *args, **kwargs: set())
+ monkeypatch.setattr(mod, "save_disabled_skills", lambda *args, **kwargs: (_ for _ in ()).throw(_config_write_error(tmp_path)))
+
+ mod.skills_command()
+
+ out = capsys.readouterr().out
+ assert "read-only or otherwise locked" in out
+
+
+def test_auth_command_catches_config_write_error(tmp_path, monkeypatch, capsys):
+ from hermes_cli import auth_commands as mod
+
+ monkeypatch.setattr(mod, "auth_add_command", lambda args: (_ for _ in ()).throw(_config_write_error(tmp_path)))
+ mod.auth_command(Namespace(auth_action="add"))
+
+ out = capsys.readouterr().out
+ assert "read-only or otherwise locked" in out
+
+
+def test_plugins_command_catches_config_write_error(tmp_path, monkeypatch, capsys):
+ from hermes_cli import plugins_cmd as mod
+
+ monkeypatch.setattr(mod, "cmd_enable", lambda name: (_ for _ in ()).throw(_config_write_error(tmp_path)))
+ mod.plugins_command(Namespace(plugins_action="enable", name="demo"))
+
+ out = capsys.readouterr().out
+ assert "read-only or otherwise locked" in out
diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py
index aa21793ae464..03374436c974 100644
--- a/tests/hermes_cli/test_gateway_service.py
+++ b/tests/hermes_cli/test_gateway_service.py
@@ -93,8 +93,16 @@ def test_user_unit_includes_resolved_node_directory_in_path(self, monkeypatch):
assert "/home/test/.nvm/versions/node/v24.14.0/bin" in unit
- def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self):
- unit = gateway_cli.generate_systemd_unit(system=True)
+ def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self, monkeypatch):
+ monkeypatch.setattr(
+ gateway_cli, "_system_service_identity",
+ lambda run_as_user=None: ("alice", "alice", "/home/alice"),
+ )
+ monkeypatch.setattr(
+ gateway_cli, "_build_user_local_paths",
+ lambda home, existing: [],
+ )
+ unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice")
assert "ExecStart=" in unit
assert "ExecStop=" not in unit
diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py
index fbd71dbb53ba..72f0c3371b3f 100644
--- a/tests/hermes_cli/test_set_config_value.py
+++ b/tests/hermes_cli/test_set_config_value.py
@@ -127,6 +127,18 @@ def test_terminal_docker_cwd_mount_flag_goes_to_config_and_env(self, _isolated_h
or "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=True" in env_content
)
+ def test_invalid_existing_yaml_is_not_overwritten(self, _isolated_hermes_home, capsys):
+ config_path = _isolated_hermes_home / "config.yaml"
+ original = "model: [broken\n"
+ config_path.write_text(original, encoding="utf-8")
+
+ set_config_value("terminal.backend", "docker")
+
+ assert config_path.read_text(encoding="utf-8") == original
+ output = capsys.readouterr().out
+ assert "contains invalid YAML" in output
+ assert "Fix it before Hermes can update `terminal.backend`" in output
+
# ---------------------------------------------------------------------------
# Empty / falsy values — regression tests for #4277
diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py
index 830bad8d5f92..7f68cdd0968e 100644
--- a/tests/hermes_cli/test_tools_config.py
+++ b/tests/hermes_cli/test_tools_config.py
@@ -3,6 +3,7 @@
from unittest.mock import patch
from hermes_cli.tools_config import (
+ CONFIGURABLE_TOOLSETS,
_configure_provider,
_get_platform_tools,
_platform_toolset_summary,
@@ -30,6 +31,11 @@ def test_get_platform_tools_preserves_explicit_empty_selection():
assert enabled == set()
+def test_configurable_toolsets_include_user_updates():
+ keys = [ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS]
+ assert "user_updates" in keys
+
+
def test_platform_toolset_summary_uses_explicit_platform_list():
config = {}
diff --git a/tests/run_agent/test_agent_guardrails.py b/tests/run_agent/test_agent_guardrails.py
index 706b1daf8d87..032057d59f18 100644
--- a/tests/run_agent/test_agent_guardrails.py
+++ b/tests/run_agent/test_agent_guardrails.py
@@ -9,7 +9,9 @@
import types
from run_agent import AIAgent
-from tools.delegate_tool import MAX_CONCURRENT_CHILDREN
+from tools.delegate_tool import _get_max_concurrent_children
+
+MAX_CONCURRENT_CHILDREN = _get_max_concurrent_children()
# ---------------------------------------------------------------------------
diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py
index a808df098131..70d9b1605aa8 100644
--- a/tests/run_agent/test_run_agent.py
+++ b/tests/run_agent/test_run_agent.py
@@ -940,6 +940,17 @@ def test_with_tool_calls(self, agent):
assert len(result["tool_calls"]) == 1
assert result["tool_calls"][0]["function"]["name"] == "web_search"
+ def test_self_nudge_tool_call_redacts_private_note(self, agent):
+ tc = _mock_tool_call(
+ name="self_nudge",
+ arguments='{"delay_seconds":300,"note":"check the private deploy state"}',
+ call_id="c-self",
+ )
+ msg = _mock_assistant_msg(content="", tool_calls=[tc])
+ result = agent._build_assistant_message(msg, "tool_calls")
+ parsed = json.loads(result["tool_calls"][0]["function"]["arguments"])
+ assert parsed == {"delay_seconds": 300}
+
def test_with_reasoning_details(self, agent):
details = [{"type": "reasoning.summary", "text": "step1", "signature": "sig1"}]
msg = _mock_assistant_msg(content="ans", reasoning_details=details)
@@ -1160,6 +1171,37 @@ def test_clarify_forces_sequential(self, agent):
mock_seq.assert_called_once()
mock_con.assert_not_called()
+ def test_send_user_message_forces_sequential(self, agent):
+ """Batches containing user-facing updates should stay sequential."""
+ tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1")
+ tc2 = _mock_tool_call(
+ name="send_user_message",
+ arguments='{"message":"I found the right file."}',
+ call_id="c2",
+ )
+ mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
+ messages = []
+ with patch.object(agent, "_execute_tool_calls_sequential") as mock_seq:
+ with patch.object(agent, "_execute_tool_calls_concurrent") as mock_con:
+ agent._execute_tool_calls(mock_msg, messages, "task-1")
+ mock_seq.assert_called_once()
+ mock_con.assert_not_called()
+
+ def test_self_nudge_forces_sequential(self, agent):
+ tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1")
+ tc2 = _mock_tool_call(
+ name="self_nudge",
+ arguments='{"delay_seconds":300,"note":"Check the deploy status."}',
+ call_id="c2",
+ )
+ mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
+ messages = []
+ with patch.object(agent, "_execute_tool_calls_sequential") as mock_seq:
+ with patch.object(agent, "_execute_tool_calls_concurrent") as mock_con:
+ agent._execute_tool_calls(mock_msg, messages, "task-1")
+ mock_seq.assert_called_once()
+ mock_con.assert_not_called()
+
def test_multiple_tools_uses_concurrent_path(self, agent):
"""Multiple read-only tools should use concurrent path."""
tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1")
@@ -1424,6 +1466,104 @@ def test_invoke_tool_handles_agent_level_tools(self, agent):
mock_todo.assert_called_once()
assert "ok" in result
+ def test_invoke_tool_send_user_message_uses_callback(self, agent):
+ cb = MagicMock()
+ agent.message_callback = cb
+
+ result = json.loads(
+ agent._invoke_tool(
+ "send_user_message",
+ {"message": "I found the relevant files."},
+ "task-1",
+ )
+ )
+
+ cb.assert_called_once_with("I found the relevant files.")
+ assert result["sent"] is True
+ assert result["message"] == "I found the relevant files."
+
+ def test_invoke_tool_send_user_message_falls_back_to_status_callback(self, agent):
+ cb = MagicMock()
+ agent.message_callback = None
+ agent.status_callback = cb
+
+ result = json.loads(
+ agent._invoke_tool(
+ "send_user_message",
+ {"message": "Still working through the patch."},
+ "task-1",
+ )
+ )
+
+ cb.assert_called_once_with("agent_message", "Still working through the patch.")
+ assert result["sent"] is True
+
+ def test_invoke_tool_send_user_message_errors_without_callback(self, agent):
+ agent.message_callback = None
+ agent.status_callback = None
+
+ result = json.loads(
+ agent._invoke_tool(
+ "send_user_message",
+ {"message": "No route available."},
+ "task-1",
+ )
+ )
+
+ assert "error" in result
+ assert "not available" in result["error"].lower()
+
+ def test_invoke_tool_self_nudge_uses_callback(self, agent):
+ cb = MagicMock(return_value={"armed": True, "delay_seconds": 300})
+ agent.self_nudge_callback = cb
+
+ result = json.loads(
+ agent._invoke_tool(
+ "self_nudge",
+ {"delay_seconds": 300, "note": "Check the deploy output."},
+ "task-1",
+ )
+ )
+
+ cb.assert_called_once_with(300, "Check the deploy output.")
+ assert result["armed"] is True
+ assert agent._self_nudge_armed_this_turn is True
+
+ def test_invoke_tool_self_nudge_errors_without_callback(self, agent):
+ agent.self_nudge_callback = None
+
+ result = json.loads(
+ agent._invoke_tool(
+ "self_nudge",
+ {"delay_seconds": 60},
+ "task-1",
+ )
+ )
+
+ assert "error" in result
+ assert "not available" in result["error"].lower()
+
+ def test_send_user_message_skips_generic_tool_progress_callback(self, agent):
+ tc = _mock_tool_call(
+ name="send_user_message",
+ arguments='{"message":"I am updating the config now."}',
+ call_id="c1",
+ )
+ mock_msg = _mock_assistant_msg(content="", tool_calls=[tc])
+ messages = []
+ progress_cb = MagicMock()
+ message_cb = MagicMock()
+ agent.tool_progress_callback = progress_cb
+ agent.message_callback = message_cb
+
+ agent._execute_tool_calls_sequential(mock_msg, messages, "task-1")
+
+ progress_cb.assert_not_called()
+ message_cb.assert_called_once_with("I am updating the config now.")
+ assert len(messages) == 1
+ payload = json.loads(messages[0]["content"])
+ assert payload["sent"] is True
+
class TestPathsOverlap:
"""Unit tests for the _paths_overlap helper."""
diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py
index 5f9a16a529c5..fb05abd73de8 100644
--- a/tests/test_hermes_state.py
+++ b/tests/test_hermes_state.py
@@ -76,6 +76,30 @@ def test_update_token_counts_preserves_existing_model(self, db):
session = db.get_session("s1")
assert session["model"] == "anthropic/claude-opus-4.6"
+ def test_get_session_token_totals_sums_all_columns(self, db):
+ db.create_session(session_id="s1", source="cli")
+ db.set_token_counts(
+ "s1",
+ input_tokens=10,
+ output_tokens=20,
+ cache_read_tokens=3,
+ cache_write_tokens=4,
+ reasoning_tokens=5,
+ )
+
+ totals = db.get_session_token_totals("s1")
+ assert totals == {
+ "input_tokens": 10,
+ "output_tokens": 20,
+ "cache_read_tokens": 3,
+ "cache_write_tokens": 4,
+ "reasoning_tokens": 5,
+ "total_tokens": 42,
+ }
+
+ def test_get_session_token_totals_returns_none_for_missing_session(self, db):
+ assert db.get_session_token_totals("missing") is None
+
def test_parent_session(self, db):
db.create_session(session_id="parent", source="cli")
db.create_session(session_id="child", source="cli", parent_session_id="parent")
diff --git a/tests/test_mcp_serve.py b/tests/test_mcp_serve.py
index 9dc013cace52..133f12fc5c44 100644
--- a/tests/test_mcp_serve.py
+++ b/tests/test_mcp_serve.py
@@ -1098,6 +1098,12 @@ def get_messages(self, sid):
# Update sessions.json updated_at to trigger re-check
sessions_data["agent:main:telegram:dm:new"]["updated_at"] = "2026-03-29T15:00:10"
(sessions_dir / "sessions.json").write_text(json.dumps(sessions_data))
+ # Ensure the watcher observes a later mtime even on filesystems with
+ # coarse timestamp resolution (for example whole-second mtimes).
+ sessions_file = sessions_dir / "sessions.json"
+ current_mtime = sessions_file.stat().st_mtime
+ forced_mtime = max(time.time(), current_mtime + 1.1)
+ os.utime(sessions_file, (forced_mtime, forced_mtime))
# Second poll — should detect the new message
bridge._poll_once(db)
diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py
index 5e3b1d6ce1f1..7bb061086a7a 100644
--- a/tests/test_model_tools.py
+++ b/tests/test_model_tools.py
@@ -4,10 +4,12 @@
from unittest.mock import call, patch
import pytest
+import tools.terminal_tool as terminal_tool_module
from model_tools import (
handle_function_call,
get_all_tool_names,
+ get_tool_definitions,
get_toolset_for_tool,
_AGENT_LOOP_TOOLS,
_LEGACY_TOOLSET_MAP,
@@ -85,11 +87,96 @@ def test_expected_tools_in_set(self):
assert "memory" in _AGENT_LOOP_TOOLS
assert "session_search" in _AGENT_LOOP_TOOLS
assert "delegate_task" in _AGENT_LOOP_TOOLS
+ assert "send_user_message" in _AGENT_LOOP_TOOLS
+ assert "self_nudge" in _AGENT_LOOP_TOOLS
def test_no_regular_tools_in_set(self):
assert "web_search" not in _AGENT_LOOP_TOOLS
assert "terminal" not in _AGENT_LOOP_TOOLS
+ def test_send_user_message_only_exposed_on_interactive_platforms(self):
+ cli_tools = get_tool_definitions(
+ enabled_toolsets=["user_updates"],
+ quiet_mode=True,
+ platform="cli",
+ )
+ api_tools = get_tool_definitions(
+ enabled_toolsets=["user_updates"],
+ quiet_mode=True,
+ platform="api_server",
+ )
+ default_tools = get_tool_definitions(
+ enabled_toolsets=["user_updates"],
+ quiet_mode=True,
+ )
+
+ assert [t["function"]["name"] for t in cli_tools] == ["send_user_message"]
+ assert api_tools == []
+ assert default_tools == []
+
+ def test_clarify_only_exposed_on_cli(self):
+ cli_tools = get_tool_definitions(
+ enabled_toolsets=["clarify"],
+ quiet_mode=True,
+ platform="cli",
+ )
+ telegram_tools = get_tool_definitions(
+ enabled_toolsets=["clarify"],
+ quiet_mode=True,
+ platform="telegram",
+ )
+ default_tools = get_tool_definitions(
+ enabled_toolsets=["clarify"],
+ quiet_mode=True,
+ )
+
+ assert [t["function"]["name"] for t in cli_tools] == ["clarify"]
+ assert telegram_tools == []
+ assert default_tools == []
+
+ def test_self_nudge_only_exposed_on_gateway_platforms(self):
+ telegram_tools = get_tool_definitions(
+ enabled_toolsets=["user_updates"],
+ quiet_mode=True,
+ platform="telegram",
+ )
+ cli_tools = get_tool_definitions(
+ enabled_toolsets=["user_updates"],
+ quiet_mode=True,
+ platform="cli",
+ )
+ api_tools = get_tool_definitions(
+ enabled_toolsets=["user_updates"],
+ quiet_mode=True,
+ platform="api_server",
+ )
+
+ telegram_names = [t["function"]["name"] for t in telegram_tools]
+ assert "send_user_message" in telegram_names
+ assert "self_nudge" in telegram_names
+ assert [t["function"]["name"] for t in cli_tools] == ["send_user_message"]
+ assert api_tools == []
+
+ def test_terminal_gateway_local_param_hidden_when_unavailable(self, monkeypatch):
+ monkeypatch.setattr(terminal_tool_module, "can_offer_gateway_local", lambda config=None: False)
+ tools = get_tool_definitions(
+ enabled_toolsets=["terminal"],
+ quiet_mode=True,
+ platform="telegram",
+ )
+ terminal = next(t["function"] for t in tools if t["function"]["name"] == "terminal")
+ assert "gateway_local" not in terminal["parameters"]["properties"]
+
+ def test_terminal_gateway_local_param_exposed_when_available(self, monkeypatch):
+ monkeypatch.setattr(terminal_tool_module, "can_offer_gateway_local", lambda config=None: True)
+ tools = get_tool_definitions(
+ enabled_toolsets=["terminal"],
+ quiet_mode=True,
+ platform="telegram",
+ )
+ terminal = next(t["function"] for t in tools if t["function"]["name"] == "terminal")
+ assert "gateway_local" in terminal["parameters"]["properties"]
+
# =========================================================================
# Legacy toolset map
diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py
index 33653c360727..abf23f41d5fc 100644
--- a/tests/tools/test_code_execution.py
+++ b/tests/tools/test_code_execution.py
@@ -380,7 +380,7 @@ class TestStubSchemaDrift(unittest.TestCase):
# Parameters that are internal (injected by the handler, not user-facing)
_INTERNAL_PARAMS = {"task_id", "user_task"}
# Parameters intentionally blocked in the sandbox
- _BLOCKED_TERMINAL_PARAMS = {"background", "check_interval", "pty", "notify_on_complete"}
+ _BLOCKED_TERMINAL_PARAMS = {"background", "check_interval", "pty", "gateway_local"}
def test_stubs_cover_all_schema_params(self):
"""Every user-facing parameter in the real schema must appear in the
diff --git a/tests/tools/test_command_guards.py b/tests/tools/test_command_guards.py
index a4b43147f68c..72bb9aa6f46d 100644
--- a/tests/tools/test_command_guards.py
+++ b/tests/tools/test_command_guards.py
@@ -163,6 +163,42 @@ def test_dangerous_only_cli_deny(self, mock_tirith):
# allow_permanent should be True (no tirith warning)
assert cb.call_args[1]["allow_permanent"] is True
+ @patch(_TIRITH_PATCH, return_value=_tirith_result("allow"))
+ def test_extra_gateway_local_warning_gateway(self, mock_tirith):
+ os.environ["HERMES_GATEWAY_SESSION"] = "1"
+ result = check_all_command_guards(
+ "echo hello",
+ "local",
+ extra_warnings=[{
+ "pattern_key": "gateway_local_execution",
+ "description": "run command directly in the Hermes gateway container",
+ "session_only": True,
+ }],
+ disable_smart_approval=True,
+ )
+ assert result["approved"] is False
+ assert result.get("status") == "approval_required"
+ assert "gateway container" in result["description"].lower()
+
+ @patch(_TIRITH_PATCH, return_value=_tirith_result("allow"))
+ def test_extra_gateway_local_warning_cli_hides_always(self, mock_tirith):
+ os.environ["HERMES_INTERACTIVE"] = "1"
+ cb = MagicMock(return_value="session")
+ result = check_all_command_guards(
+ "echo hello",
+ "local",
+ approval_callback=cb,
+ extra_warnings=[{
+ "pattern_key": "gateway_local_execution",
+ "description": "run command directly in the Hermes gateway container",
+ "session_only": True,
+ }],
+ disable_smart_approval=True,
+ )
+ assert result["approved"] is True
+ cb.assert_called_once()
+ assert cb.call_args[1]["allow_permanent"] is False
+
# ---------------------------------------------------------------------------
# tirith warn + safe command
@@ -267,6 +303,22 @@ def test_dangerous_only_allows_permanent(self, mock_tirith):
cb.assert_called_once()
assert cb.call_args[1]["allow_permanent"] is True
+ @patch(_TIRITH_PATCH, return_value=_tirith_result("allow"))
+ def test_dangerous_only_reports_allowlist_persistence_failure(self, mock_tirith):
+ os.environ["HERMES_INTERACTIVE"] = "1"
+ cb = MagicMock(return_value="always")
+ with patch(
+ "tools.approval.save_permanent_allowlist",
+ return_value="Permanent approval was applied for this session only.",
+ ):
+ result = check_all_command_guards(
+ "rm -rf /tmp/test",
+ "local",
+ approval_callback=cb,
+ )
+ assert result["approved"] is True
+ assert "session only" in result["message"]
+
# ---------------------------------------------------------------------------
# tirith ImportError → treated as allow
diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py
index ebdf60d296b3..b90ff96f2781 100644
--- a/tests/tools/test_delegate.py
+++ b/tests/tools/test_delegate.py
@@ -19,7 +19,7 @@
from tools.delegate_tool import (
DELEGATE_BLOCKED_TOOLS,
DELEGATE_TASK_SCHEMA,
- MAX_CONCURRENT_CHILDREN,
+ _get_max_concurrent_children,
MAX_DEPTH,
check_delegate_requirements,
delegate_task,
@@ -65,6 +65,8 @@ def test_schema_valid(self):
self.assertIn("tasks", props)
self.assertIn("context", props)
self.assertIn("toolsets", props)
+ self.assertIn("workspace_visibility", props)
+ self.assertIn("workspace_mappings", props)
self.assertIn("max_iterations", props)
self.assertEqual(props["tasks"]["maxItems"], 3)
@@ -90,7 +92,7 @@ def test_empty_context_ignored(self):
class TestStripBlockedTools(unittest.TestCase):
def test_removes_blocked_toolsets(self):
result = _strip_blocked_tools(["terminal", "file", "delegation", "clarify", "memory", "code_execution"])
- self.assertEqual(sorted(result), ["file", "terminal"])
+ self.assertEqual(sorted(result), ["file", "memory", "terminal"])
def test_preserves_allowed_toolsets(self):
result = _strip_blocked_tools(["terminal", "file", "web", "browser"])
@@ -167,10 +169,13 @@ def test_batch_capped_at_3(self, mock_run):
"summary": "Done", "api_calls": 1, "duration_seconds": 1.0
}
parent = _make_mock_parent()
- tasks = [{"goal": f"Task {i}"} for i in range(5)]
+ limit = _get_max_concurrent_children()
+ tasks = [{"goal": f"Task {i}"} for i in range(limit + 2)]
result = json.loads(delegate_task(tasks=tasks, parent_agent=parent))
- # Should only run 3 tasks (MAX_CONCURRENT_CHILDREN)
- self.assertEqual(mock_run.call_count, 3)
+ # Should return an error instead of silently truncating
+ self.assertIn("error", result)
+ self.assertIn("Too many tasks", result["error"])
+ mock_run.assert_not_called()
@patch("tools.delegate_tool._run_single_child")
def test_batch_ignores_toplevel_goal(self, mock_run):
@@ -296,6 +301,49 @@ def test_child_uses_thinking_callback_when_progress_callback_available(self):
mock_child.thinking_callback("deliberating...")
parent.tool_progress_callback.assert_not_called()
+ def test_workspace_visibility_registers_child_task_overrides(self):
+ parent = _make_mock_parent(depth=0)
+
+ with patch.dict(os.environ, {
+ "TERMINAL_ENV": "docker",
+ "TERMINAL_CWD": "/tmp/delegate-workspace",
+ }, clear=False), \
+ patch("run_agent.AIAgent") as MockAgent, \
+ patch("tools.terminal_tool.register_task_env_overrides") as mock_register, \
+ patch("tools.terminal_tool.clear_task_env_overrides") as mock_clear, \
+ patch("pathlib.Path.exists", return_value=True), \
+ patch("pathlib.Path.is_dir", return_value=True):
+ mock_child = MagicMock()
+ mock_child.session_id = "child-session"
+ mock_child.run_conversation.return_value = {
+ "final_response": "done",
+ "completed": True,
+ "api_calls": 1,
+ "messages": [],
+ }
+ MockAgent.return_value = mock_child
+
+ result = json.loads(
+ delegate_task(
+ goal="Inspect the repo",
+ workspace_visibility="full_ro",
+ parent_agent=parent,
+ )
+ )
+
+ assert result["results"][0]["status"] == "completed"
+ mock_child.run_conversation.assert_called_once_with(
+ user_message="Inspect the repo",
+ task_id="child-session",
+ )
+ mock_register.assert_called_once()
+ registered_task_id, registered_overrides = mock_register.call_args.args
+ assert registered_task_id == "child-session"
+ assert registered_overrides["cwd"] == "/workspace"
+ expected_host = os.path.realpath("/tmp/delegate-workspace")
+ assert registered_overrides["docker_volumes"] == [f"{expected_host}:/workspace:ro"]
+ mock_clear.assert_called_once_with("child-session")
+
class TestToolNamePreservation(unittest.TestCase):
"""Verify _last_resolved_tool_names is restored after subagent runs."""
@@ -380,7 +428,7 @@ def test_saved_tool_names_set_on_child_before_run(self):
with patch("run_agent.AIAgent") as MockAgent:
mock_child = MagicMock()
- def capture_and_return(user_message):
+ def capture_and_return(user_message, task_id=None):
captured["saved"] = list(mock_child._delegate_saved_tool_names)
return {"final_response": "ok", "completed": True, "api_calls": 1}
@@ -557,11 +605,11 @@ def test_exit_reason_max_iterations(self):
class TestBlockedTools(unittest.TestCase):
def test_blocked_tools_constant(self):
- for tool in ["delegate_task", "clarify", "memory", "send_message", "execute_code"]:
+ for tool in ["delegate_task", "clarify", "send_message", "execute_code"]:
self.assertIn(tool, DELEGATE_BLOCKED_TOOLS)
def test_constants(self):
- self.assertEqual(MAX_CONCURRENT_CHILDREN, 3)
+ self.assertEqual(_get_max_concurrent_children(), 3)
self.assertEqual(MAX_DEPTH, 2)
diff --git a/tests/tools/test_delegate_toolset_scope.py b/tests/tools/test_delegate_toolset_scope.py
index d853dbb042c5..fb4f2e7c60da 100644
--- a/tests/tools/test_delegate_toolset_scope.py
+++ b/tests/tools/test_delegate_toolset_scope.py
@@ -52,7 +52,7 @@ def test_strip_blocked_removes_delegation(self):
child = _strip_blocked_tools(["terminal", "delegation", "clarify", "memory"])
assert "delegation" not in child
assert "clarify" not in child
- assert "memory" not in child
+ assert "memory" in child
assert "terminal" in child
def test_empty_intersection_yields_empty_toolsets(self):
diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py
index e19229a795e8..3ab08a2ab95c 100644
--- a/tests/tools/test_docker_environment.py
+++ b/tests/tools/test_docker_environment.py
@@ -178,6 +178,26 @@ def test_auto_mount_skipped_when_workspace_already_mounted(monkeypatch, tmp_path
assert run_args_str.count(":/workspace") == 1
+def test_explicit_read_only_workspace_mount_is_preserved(monkeypatch, tmp_path):
+ """An explicit read-only /workspace bind should be passed through unchanged."""
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+
+ monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
+ calls = _mock_subprocess_run(monkeypatch)
+
+ _make_dummy_env(
+ cwd="/workspace",
+ volumes=[f"{project_dir}:/workspace:ro"],
+ )
+
+ run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"]
+ assert run_calls, "docker run should have been called"
+ run_args_str = " ".join(run_calls[0][0])
+ assert f"{project_dir}:/workspace:ro" in run_args_str
+ assert run_args_str.count(":/workspace") == 1
+
+
def test_auto_mount_replaces_persistent_workspace_bind(monkeypatch, tmp_path):
"""Persistent mode should still prefer the configured host cwd at /workspace."""
project_dir = tmp_path / "my-project"
@@ -382,3 +402,286 @@ def test_normalize_env_dict_rejects_complex_values():
"BAD_DICT": {"nested": True},
})
assert result == {"GOOD": "string"}
+
+
+# ---------------------------------------------------------------------------
+# docker_env_files: per-exec re-read with allowlist + size cap
+# ---------------------------------------------------------------------------
+
+def _allow_anywhere(monkeypatch):
+ """Disable the path allowlist for tests by emptying it via env var."""
+ monkeypatch.setenv("TERMINAL_DOCKER_ENV_FILES_ALLOWED_DIRS", "")
+
+
+def test_parse_env_files_valid_entry(monkeypatch, tmp_path):
+ _allow_anywhere(monkeypatch)
+ f = tmp_path / "session"
+ f.write_text("hello")
+ parsed = docker_env.DockerEnvironment._parse_env_files(
+ [f"BW_SESSION:{f}"]
+ )
+ assert len(parsed) == 1
+ assert parsed[0][0] == "BW_SESSION"
+ assert parsed[0][1] == str(f.resolve())
+
+
+def test_parse_env_files_invalid_format_skipped(monkeypatch, caplog):
+ _allow_anywhere(monkeypatch)
+ with caplog.at_level(logging.WARNING):
+ parsed = docker_env.DockerEnvironment._parse_env_files(["NO_COLON"])
+ assert parsed == []
+ assert any("invalid entry" in r.getMessage() for r in caplog.records)
+
+
+def test_parse_env_files_empty_var_name_skipped(monkeypatch, caplog, tmp_path):
+ _allow_anywhere(monkeypatch)
+ with caplog.at_level(logging.WARNING):
+ parsed = docker_env.DockerEnvironment._parse_env_files([f":{tmp_path / 'x'}"])
+ assert parsed == []
+
+
+def test_parse_env_files_resolves_symlink(monkeypatch, tmp_path):
+ """A symlink at parse time is followed; subsequent symlink swaps don't redirect reads."""
+ _allow_anywhere(monkeypatch)
+ target = tmp_path / "real"
+ target.write_text("real-value")
+ link = tmp_path / "link"
+ link.symlink_to(target)
+ parsed = docker_env.DockerEnvironment._parse_env_files([f"X:{link}"])
+ assert len(parsed) == 1
+ assert parsed[0][1] == str(target.resolve()) # canonicalized to real path
+
+
+def test_parse_env_files_path_does_not_have_to_exist(monkeypatch, tmp_path):
+ """Sidecar may not have written the file yet at parse time — must not error."""
+ _allow_anywhere(monkeypatch)
+ missing = tmp_path / "not-yet-written"
+ parsed = docker_env.DockerEnvironment._parse_env_files([f"X:{missing}"])
+ assert len(parsed) == 1
+
+
+def test_parse_env_files_allowlist_rejects_outside_paths(monkeypatch, tmp_path, caplog):
+ """With an allowlist set, paths outside it are rejected."""
+ safe_dir = tmp_path / "allowed"
+ safe_dir.mkdir()
+ bad = tmp_path / "elsewhere" / "secret"
+ bad.parent.mkdir()
+ bad.write_text("nope")
+ monkeypatch.setenv("TERMINAL_DOCKER_ENV_FILES_ALLOWED_DIRS", str(safe_dir))
+ with caplog.at_level(logging.WARNING):
+ parsed = docker_env.DockerEnvironment._parse_env_files([f"X:{bad}"])
+ assert parsed == []
+ assert any("outside allowed dirs" in r.getMessage() for r in caplog.records)
+
+
+def test_parse_env_files_allowlist_accepts_inside_paths(monkeypatch, tmp_path):
+ """With an allowlist set, paths inside it are accepted."""
+ safe_dir = tmp_path / "allowed"
+ safe_dir.mkdir()
+ good = safe_dir / "session"
+ good.write_text("ok")
+ monkeypatch.setenv("TERMINAL_DOCKER_ENV_FILES_ALLOWED_DIRS", str(safe_dir))
+ parsed = docker_env.DockerEnvironment._parse_env_files([f"X:{good}"])
+ assert len(parsed) == 1
+
+
+def test_read_env_file_value_strips_one_trailing_newline(monkeypatch, tmp_path):
+ f = tmp_path / "session"
+ f.write_text("abc\n") # `echo abc > file` shape
+ assert docker_env.DockerEnvironment._read_env_file_value("X", str(f)) == "abc"
+
+
+def test_read_env_file_value_strips_crlf(monkeypatch, tmp_path):
+ f = tmp_path / "session"
+ f.write_bytes(b"abc\r\n")
+ assert docker_env.DockerEnvironment._read_env_file_value("X", str(f)) == "abc"
+
+
+def test_read_env_file_value_preserves_internal_whitespace(monkeypatch, tmp_path):
+ """`.strip()` would corrupt PEM bodies; we only trim one trailing newline."""
+ pem = "-----BEGIN PRIVATE KEY-----\n base64body\n-----END PRIVATE KEY-----\n"
+ f = tmp_path / "key"
+ f.write_text(pem)
+ got = docker_env.DockerEnvironment._read_env_file_value("KEY", str(f))
+ # Trailing \n stripped, internal whitespace preserved exactly
+ assert got == pem[:-1]
+ assert " base64body" in got
+ assert got.endswith("-----END PRIVATE KEY-----")
+
+
+def test_read_env_file_value_preserves_leading_whitespace(monkeypatch, tmp_path):
+ """JSON blobs with leading spaces must round-trip unchanged."""
+ f = tmp_path / "json"
+ f.write_text(" {\"key\": \"value\"}") # no trailing newline
+ got = docker_env.DockerEnvironment._read_env_file_value("J", str(f))
+ assert got == " {\"key\": \"value\"}"
+
+
+def test_read_env_file_value_size_limit(monkeypatch, tmp_path, caplog):
+ """Files larger than _ENV_FILES_MAX_SIZE are rejected with a clear log line."""
+ f = tmp_path / "huge"
+ f.write_bytes(b"A" * (docker_env.DockerEnvironment._ENV_FILES_MAX_SIZE + 100))
+ with caplog.at_level(logging.WARNING):
+ got = docker_env.DockerEnvironment._read_env_file_value("X", str(f))
+ assert got is None
+ assert any("exceeds" in r.getMessage() and "limit" in r.getMessage() for r in caplog.records)
+
+
+def test_read_env_file_value_size_limit_at_boundary(monkeypatch, tmp_path):
+ """A file at exactly the size limit is accepted."""
+ payload = b"A" * docker_env.DockerEnvironment._ENV_FILES_MAX_SIZE
+ f = tmp_path / "boundary"
+ f.write_bytes(payload)
+ got = docker_env.DockerEnvironment._read_env_file_value("X", str(f))
+ assert got == payload.decode()
+
+
+def test_read_env_file_value_missing_file_returns_none(monkeypatch, tmp_path, caplog):
+ with caplog.at_level(logging.WARNING):
+ got = docker_env.DockerEnvironment._read_env_file_value("X", str(tmp_path / "nope"))
+ assert got is None
+ assert any("could not read" in r.getMessage() for r in caplog.records)
+
+
+def test_read_env_file_value_non_utf8_returns_none(monkeypatch, tmp_path, caplog):
+ f = tmp_path / "binary"
+ f.write_bytes(b"\xff\xfe\xfd")
+ with caplog.at_level(logging.WARNING):
+ got = docker_env.DockerEnvironment._read_env_file_value("X", str(f))
+ assert got is None
+ assert any("not valid UTF-8" in r.getMessage() for r in caplog.records)
+
+
+def test_extra_env_for_exec_re_reads_file(monkeypatch, tmp_path):
+ """The exec hook re-reads the file each call so rotated values propagate."""
+ _allow_anywhere(monkeypatch)
+ f = tmp_path / "session"
+ f.write_text("session-A")
+ _mock_subprocess_run(monkeypatch)
+ monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
+ env = _make_dummy_env()
+ # Inject the parsed entry directly (don't go through __init__'s env_files arg
+ # to avoid coupling this test to constructor wiring).
+ env._env_files = [("BW_SESSION", str(f.resolve()))]
+
+ first = env._extra_env_for_exec()
+ assert first == {"BW_SESSION": "session-A"}
+
+ f.write_text("session-B") # rotation
+ second = env._extra_env_for_exec()
+ assert second == {"BW_SESSION": "session-B"}
+
+
+def test_extra_env_for_exec_skips_failed_entries(monkeypatch, tmp_path, caplog):
+ """A bad entry is skipped; good entries still get applied."""
+ _allow_anywhere(monkeypatch)
+ good = tmp_path / "good"
+ good.write_text("ok")
+ _mock_subprocess_run(monkeypatch)
+ monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
+ env = _make_dummy_env()
+ env._env_files = [
+ ("GOOD", str(good.resolve())),
+ ("MISSING", str(tmp_path / "nope")),
+ ]
+ with caplog.at_level(logging.WARNING):
+ out = env._extra_env_for_exec()
+ assert out == {"GOOD": "ok"}
+ assert any("MISSING" in r.getMessage() for r in caplog.records)
+
+
+# ---------------------------------------------------------------------------
+# docker exec cmd masking — both name-heuristic and origin-based
+# ---------------------------------------------------------------------------
+
+def _capture_exec_log(monkeypatch, env, env_overrides=None):
+ """Run env.execute() with mocked Popen and return the captured log line."""
+ popen_calls = []
+
+ class _FakePopen2:
+ def __init__(self, cmd, **kwargs):
+ popen_calls.append(cmd)
+ self.stdin = None
+ self.stdout = StringIO("")
+ self.stderr = None
+ self.returncode = 0
+ def wait(self, timeout=None):
+ return 0
+ def communicate(self, *a, **kw):
+ return ("", "")
+ def poll(self):
+ return 0
+
+ monkeypatch.setattr(docker_env.subprocess, "Popen", _FakePopen2)
+ captured_logs = []
+ real_warning = docker_env.logger.warning
+ def _capture_warning(msg, *args, **kwargs):
+ if args:
+ try:
+ captured_logs.append(msg % args)
+ except Exception:
+ captured_logs.append(str(msg))
+ else:
+ captured_logs.append(str(msg))
+ real_warning(msg, *args, **kwargs)
+ monkeypatch.setattr(docker_env.logger, "warning", _capture_warning)
+
+ if env_overrides:
+ for k, v in env_overrides.items():
+ monkeypatch.setenv(k, v)
+
+ env.execute("echo hi")
+ exec_lines = [l for l in captured_logs if "docker exec cmd:" in l]
+ return exec_lines[0] if exec_lines else ""
+
+
+def test_exec_log_masks_session_in_name(monkeypatch):
+ """SESSION-named env vars are masked even though the original heuristic missed them."""
+ _allow_anywhere(monkeypatch)
+ monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
+ env = _make_execute_only_env()
+ env._env = {"BW_SESSION": "VERYSECRETSESSIONVALUE12345"}
+ log = _capture_exec_log(monkeypatch, env)
+ assert "VERYSECRETSESSIONVALUE12345" not in log, f"session leaked in log: {log}"
+ assert "BW_SESSION=***" in log
+
+
+def test_exec_log_masks_auth_cookie_jwt_bearer(monkeypatch):
+ """The expanded sensitive-name list catches more credential-like names."""
+ _allow_anywhere(monkeypatch)
+ monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
+ env = _make_execute_only_env()
+ env._env = {
+ "AUTH_COOKIE": "auth-cookie-value-zzz",
+ "MY_JWT": "jwt.value.zzz",
+ "X_BEARER": "bearer-zzz",
+ "PASSPHRASE": "passphrase-zzz",
+ }
+ log = _capture_exec_log(monkeypatch, env)
+ for v in ("auth-cookie-value-zzz", "jwt.value.zzz", "bearer-zzz", "passphrase-zzz"):
+ assert v not in log, f"value {v} leaked in log: {log}"
+
+
+def test_exec_log_masks_dynamic_origin_regardless_of_name(monkeypatch, tmp_path):
+ """Anything from _extra_env_for_exec is masked, even with an innocuous name."""
+ _allow_anywhere(monkeypatch)
+ monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
+ f = tmp_path / "innocuous"
+ f.write_text("DYNAMICALLY_INJECTED_VALUE")
+ env = _make_execute_only_env()
+ env._env_files = [("INNOCENT_VAR", str(f.resolve()))] # NOT a sensitive-looking name
+ log = _capture_exec_log(monkeypatch, env)
+ assert "DYNAMICALLY_INJECTED_VALUE" not in log, f"dynamic value leaked: {log}"
+ assert "INNOCENT_VAR=***" in log
+
+
+def test_exec_log_does_not_mask_innocent_static_values(monkeypatch):
+ """Plain static env (not credential-like) is not over-masked — readability check."""
+ _allow_anywhere(monkeypatch)
+ monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
+ env = _make_execute_only_env()
+ env._env = {"PORT": "8080", "DEBUG": "true", "HOME": "/root"}
+ log = _capture_exec_log(monkeypatch, env)
+ assert "PORT=8080" in log
+ assert "DEBUG=true" in log
+ assert "HOME=/root" in log
diff --git a/tests/tools/test_mcp_subagent_access.py b/tests/tools/test_mcp_subagent_access.py
new file mode 100644
index 000000000000..0bcdb075b507
--- /dev/null
+++ b/tests/tools/test_mcp_subagent_access.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""
+Test to verify MCP tools are now allowed for subagents.
+"""
+
+import sys
+sys.path.insert(0, '/workspace/hermes-agent-fork')
+
+from tools.delegate_tool import DEFAULT_ALLOWED_TOOLSETS, _strip_blocked_tools, BLOCKED_TOOLSET_NAMES
+
+def test_mcp_in_allowed_toolsets():
+ """Verify 'mcp' is in the default allowed toolsets."""
+ assert "mcp" in DEFAULT_ALLOWED_TOOLSETS, f"'mcp' not found in {DEFAULT_ALLOWED_TOOLSETS}"
+ print("✅ TEST 1 PASSED: 'mcp' is in DEFAULT_ALLOWED_TOOLSETS")
+
+def test_mcp_not_blocked():
+ """Verify 'mcp' is not in blocked toolsets."""
+ assert "mcp" not in BLOCKED_TOOLSET_NAMES, f"'mcp' incorrectly in blocked: {BLOCKED_TOOLSET_NAMES}"
+ print("✅ TEST 2 PASSED: 'mcp' is NOT in BLOCKED_TOOLSET_NAMES")
+
+def test_mcp_survives_filtering():
+ """Verify MCP toolsets survive the _strip_blocked_tools filter."""
+ test_toolsets = ["terminal", "mcp", "delegation", "file"]
+ filtered = _strip_blocked_tools(test_toolsets)
+ assert "mcp" in filtered, f"'mcp' filtered out: {filtered}"
+ assert "delegation" not in filtered, f"'delegation' not filtered: {filtered}"
+ print(f"✅ TEST 3 PASSED: _strip_blocked_tools preserves 'mcp': {filtered}")
+
+def test_all_default_toolsets_valid():
+ """Verify all default toolsets are valid (not blocked)."""
+ for toolset in DEFAULT_ALLOWED_TOOLSETS:
+ assert toolset not in BLOCKED_TOOLSET_NAMES, f"Default toolset '{toolset}' is blocked!"
+ print(f"✅ TEST 4 PASSED: All default toolsets are valid: {DEFAULT_ALLOWED_TOOLSETS}")
+
+if __name__ == "__main__":
+ print("="*70)
+ print("Testing MCP Tool Access for Subagents")
+ print("="*70)
+
+ try:
+ test_mcp_in_allowed_toolsets()
+ test_mcp_not_blocked()
+ test_mcp_survives_filtering()
+ test_all_default_toolsets_valid()
+
+ print("\n" + "="*70)
+ print("🎉 ALL TESTS PASSED! MCP tools are now enabled for subagents!")
+ print("="*70)
+ print(f"\nConfiguration:")
+ print(f" DEFAULT_ALLOWED_TOOLSETS = {DEFAULT_ALLOWED_TOOLSETS}")
+ print(f" BLOCKED_TOOLSET_NAMES = {BLOCKED_TOOLSET_NAMES}")
+ print(f"\nSubagents can now use:")
+ print(f" ✅ terminal")
+ print(f" ✅ file")
+ print(f" ✅ web")
+ print(f" ✅ mcp (SearXNG, Crawl4AI)")
+ print(f"\nSubagents still blocked from:")
+ print(f" ❌ delegation (no recursive spawning)")
+ print(f" ❌ clarify (no user interaction)")
+ print(f" ❌ memory (no shared MEMORY.md writes)")
+ print(f" ❌ code_execution (no execute_code)")
+ print("="*70)
+
+ except AssertionError as e:
+ print(f"\n❌ TEST FAILED: {e}")
+ sys.exit(1)
diff --git a/tests/tools/test_memory_subagent_readonly.py b/tests/tools/test_memory_subagent_readonly.py
new file mode 100644
index 000000000000..26f4911a1596
--- /dev/null
+++ b/tests/tools/test_memory_subagent_readonly.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+"""
+Test read-only memory access for subagents.
+"""
+
+import sys
+import json
+sys.path.insert(0, '/workspace/hermes-agent-fork')
+
+from tools.memory_tool import memory_tool, MemoryStore
+
+def test_subagent_read_only_blocks_writes():
+ """Verify subagents in read_only mode cannot write to memory."""
+ store = MemoryStore()
+
+ # Try to add memory as subagent in read_only mode
+ result = memory_tool(
+ action="add",
+ target="memory",
+ content="Test observation from subagent",
+ store=store,
+ is_subagent=True,
+ subagent_memory_mode="read_only"
+ )
+
+ result_dict = json.loads(result)
+ assert result_dict["success"] == False, f"Should block write in read_only mode: {result}"
+ assert "read_only" in result_dict["error"], f"Error should mention read_only: {result_dict['error']}"
+ print("✅ TEST 1 PASSED: read_only mode blocks memory writes")
+
+def test_subagent_full_allows_writes():
+ """Verify subagents in full mode can write to memory."""
+ store = MemoryStore()
+
+ # Try to add memory as subagent in full mode
+ result = memory_tool(
+ action="add",
+ target="memory",
+ content="Test observation from subagent",
+ store=store,
+ is_subagent=True,
+ subagent_memory_mode="full"
+ )
+
+ result_dict = json.loads(result)
+ assert result_dict["success"] == True, f"Should allow write in full mode: {result}"
+ print("✅ TEST 2 PASSED: full mode allows memory writes")
+
+def test_normal_agent_always_allows_writes():
+ """Verify normal (non-subagent) agents can always write."""
+ store = MemoryStore()
+
+ # Try to add memory as normal agent (not subagent)
+ result = memory_tool(
+ action="add",
+ target="memory",
+ content="Test observation from parent agent",
+ store=store,
+ is_subagent=False,
+ subagent_memory_mode="read_only" # Even if mode is read_only, non-subagent should work
+ )
+
+ result_dict = json.loads(result)
+ assert result_dict["success"] == True, f"Normal agent should always write: {result}"
+ print("✅ TEST 3 PASSED: Normal agents can always write to memory")
+
+def test_subagent_none_mode_blocks_all():
+ """Verify subagents in none mode get blocked completely."""
+ store = MemoryStore()
+
+ # Try to add memory as subagent in none mode
+ result = memory_tool(
+ action="add",
+ target="memory",
+ content="Test observation",
+ store=store,
+ is_subagent=True,
+ subagent_memory_mode="none"
+ )
+
+ result_dict = json.loads(result)
+ assert result_dict["success"] == False, f"Should block in none mode: {result}"
+ assert "none" in result_dict["error"], f"Error should mention none mode: {result_dict['error']}"
+ print("✅ TEST 4 PASSED: none mode blocks all memory access")
+
+if __name__ == "__main__":
+ print("="*70)
+ print("Testing Read-Only Memory Access for Subagents")
+ print("="*70)
+
+ try:
+ test_subagent_read_only_blocks_writes()
+ test_subagent_full_allows_writes()
+ test_normal_agent_always_allows_writes()
+ test_subagent_none_mode_blocks_all()
+
+ print("\n" + "="*70)
+ print("🎉 ALL TESTS PASSED!")
+ print("="*70)
+ print("\nMemory access modes verified:")
+ print(" ✅ read_only: Subagents can read but NOT write")
+ print(" ✅ full: Subagents have full read/write access")
+ print(" ✅ none: Subagents cannot access memory at all")
+ print(" ✅ Normal agents: Always have full access")
+ print("="*70)
+
+ except AssertionError as e:
+ print(f"\n❌ TEST FAILED: {e}")
+ sys.exit(1)
diff --git a/tests/tools/test_modal_sandbox_fixes.py b/tests/tools/test_modal_sandbox_fixes.py
index 570ef5b21829..92868b80af27 100644
--- a/tests/tools/test_modal_sandbox_fixes.py
+++ b/tests/tools/test_modal_sandbox_fixes.py
@@ -122,12 +122,15 @@ def test_docker_default_cwd_maps_current_directory_when_enabled(self, monkeypatc
assert config["cwd"] == "/workspace"
assert config["host_cwd"] == "/home/user/project"
- def test_local_backend_uses_getcwd(self, monkeypatch):
- """Local backend should use os.getcwd(), not /root."""
- monkeypatch.setenv("TERMINAL_ENV", "local")
- monkeypatch.delenv("TERMINAL_CWD", raising=False)
- config = _tt_mod._get_env_config()
- assert config["cwd"] == os.getcwd()
+ def test_local_backend_uses_getcwd(self):
+ """Local backend should keep a current-directory cwd, not /root."""
+ with patch.dict(os.environ, {"TERMINAL_ENV": "local"}, clear=False):
+ env = os.environ.copy()
+ env.pop("TERMINAL_CWD", None)
+ with patch.dict(os.environ, env, clear=True):
+ config = _tt_mod._get_env_config()
+ assert config["cwd"] in (".", os.getcwd())
+ assert config["cwd"] != "/root"
def test_create_environment_passes_docker_host_cwd_and_flag(self, monkeypatch):
"""Docker host cwd and mount flag should reach DockerEnvironment."""
diff --git a/tests/tools/test_parse_env_var.py b/tests/tools/test_parse_env_var.py
index cffee7c9af0f..e4ce38b21506 100644
--- a/tests/tools/test_parse_env_var.py
+++ b/tests/tools/test_parse_env_var.py
@@ -1,6 +1,7 @@
"""Tests for _parse_env_var and _get_env_config env-var validation."""
import json
+from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -52,6 +53,62 @@ def test_create_environment_passes_docker_forward_env(self):
assert result is fake_env
assert mock_docker.call_args.kwargs["forward_env"] == ["GITHUB_TOKEN"]
+ def test_terminal_tool_applies_task_specific_docker_workspace_overrides(self, monkeypatch, tmp_path):
+ captured = {}
+ fake_env = SimpleNamespace(execute=lambda command, **kwargs: {"output": "ok", "returncode": 0})
+
+ monkeypatch.setattr(_tt_mod, "_active_environments", {})
+ monkeypatch.setattr(_tt_mod, "_last_activity", {})
+ monkeypatch.setattr(_tt_mod, "_start_cleanup_thread", lambda: None)
+ monkeypatch.setattr(
+ _tt_mod,
+ "_get_env_config",
+ lambda: {
+ "env_type": "docker",
+ "docker_image": "python:3.11",
+ "docker_volumes": [],
+ "docker_mount_cwd_to_workspace": False,
+ "docker_forward_env": [],
+ "docker_network": None,
+ "cwd": "/root",
+ "host_cwd": None,
+ "timeout": 180,
+ "container_cpu": 1,
+ "container_memory": 5120,
+ "container_disk": 51200,
+ "container_persistent": True,
+ "modal_mode": "auto",
+ },
+ )
+
+ def _fake_create_environment(**kwargs):
+ captured.update(kwargs)
+ return fake_env
+
+ monkeypatch.setattr(_tt_mod, "_create_environment", _fake_create_environment)
+
+ _tt_mod.register_task_env_overrides(
+ "workspace-override-test",
+ {
+ "cwd": "/workspace",
+ "host_cwd": str(tmp_path),
+ "docker_mount_cwd_to_workspace": False,
+ "docker_volumes": [f"{tmp_path}:/workspace:ro"],
+ },
+ )
+
+ try:
+ result = json.loads(
+ _tt_mod.terminal_tool("echo ok", task_id="workspace-override-test", force=True)
+ )
+ finally:
+ _tt_mod.clear_task_env_overrides("workspace-override-test")
+
+ assert result["exit_code"] == 0
+ assert captured["cwd"] == "/workspace"
+ assert captured["host_cwd"] == str(tmp_path)
+ assert captured["container_config"]["docker_volumes"] == [f"{tmp_path}:/workspace:ro"]
+
def test_falls_back_to_default(self):
with patch.dict("os.environ", {}, clear=False):
# Remove the var if it exists, rely on default
diff --git a/tests/tools/test_skill_manager_tool.py b/tests/tools/test_skill_manager_tool.py
index c1e615bde6a3..c743a2761d0a 100644
--- a/tests/tools/test_skill_manager_tool.py
+++ b/tests/tools/test_skill_manager_tool.py
@@ -230,6 +230,37 @@ def test_create_rejects_category_traversal(self, tmp_path):
assert "Invalid category '../escape'" in result["error"]
assert not (tmp_path / "escape").exists()
+
+class TestFindSkill:
+ def test_find_skill_respects_patched_skills_dir(self, tmp_path):
+ with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path):
+ _create_skill("my-skill", VALID_SKILL_CONTENT)
+ result = _find_skill("my-skill")
+
+ assert result is not None
+ assert result["path"] == tmp_path / "my-skill"
+
+ def test_find_skill_includes_external_dirs(self, tmp_path, monkeypatch):
+ local_skills = tmp_path / "local-skills"
+ external_skills = tmp_path / "external-skills"
+ local_skills.mkdir()
+ external_skills.mkdir()
+ (external_skills / "ext-skill").mkdir()
+ (external_skills / "ext-skill" / "SKILL.md").write_text(
+ VALID_SKILL_CONTENT,
+ encoding="utf-8",
+ )
+
+ with patch("tools.skill_manager_tool.SKILLS_DIR", local_skills):
+ monkeypatch.setattr(
+ "agent.skill_utils.get_external_skills_dirs",
+ lambda: [external_skills],
+ )
+ result = _find_skill("ext-skill")
+
+ assert result is not None
+ assert result["path"] == external_skills / "ext-skill"
+
def test_create_rejects_absolute_category(self, tmp_path):
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
diff --git a/tests/tools/test_subagent_workspace.py b/tests/tools/test_subagent_workspace.py
new file mode 100644
index 000000000000..d1be87ef9041
--- /dev/null
+++ b/tests/tools/test_subagent_workspace.py
@@ -0,0 +1,104 @@
+import os
+
+import pytest
+
+from tools.subagent_workspace import build_workspace_overrides, resolve_parent_workspace_root
+
+
+def test_full_ro_mounts_parent_workspace_read_only(tmp_path):
+ plan = build_workspace_overrides(
+ visibility="full_ro",
+ mappings=None,
+ workspace_root=tmp_path,
+ child_token="child-1",
+ backend="docker",
+ )
+
+ assert plan["task_env_overrides"]["cwd"] == "/workspace"
+ assert plan["task_env_overrides"]["docker_volumes"] == [f"{tmp_path.resolve()}:/workspace:ro"]
+ assert "read-only" in plan["prompt_note"]
+
+
+
+def test_mapped_rejects_workspace_escape(tmp_path):
+ outside = tmp_path.parent / "outside"
+ outside.mkdir(exist_ok=True)
+
+ with pytest.raises(ValueError, match="escapes the parent workspace"):
+ build_workspace_overrides(
+ visibility="mapped",
+ mappings=[{"source": str(outside), "target": "shared"}],
+ workspace_root=tmp_path,
+ child_token="child-3",
+ backend="docker",
+ )
+
+
+def test_mapped_supports_multiple_targets_and_read_only(tmp_path):
+ (tmp_path / "pkg-a").mkdir()
+ (tmp_path / "pkg-b").mkdir()
+
+ plan = build_workspace_overrides(
+ visibility="mapped",
+ mappings=[
+ {"source": "pkg-a", "target": "work/a"},
+ {"source": "pkg-b", "target": "/workspace/work/b", "read_only": True},
+ ],
+ workspace_root=tmp_path,
+ child_token="child-4",
+ backend="docker",
+ )
+
+ assert plan["task_env_overrides"]["cwd"] == "/workspace"
+ assert plan["task_env_overrides"]["docker_volumes"] == [
+ f"{(tmp_path / 'pkg-a').resolve()}:/workspace/work/a",
+ f"{(tmp_path / 'pkg-b').resolve()}:/workspace/work/b:ro",
+ ]
+ assert "/workspace/work/a" in plan["prompt_note"]
+ assert "/workspace/work/b" in plan["prompt_note"]
+
+
+def test_mapped_rejects_container_targets_outside_workspace(tmp_path):
+ (tmp_path / "pkg-a").mkdir()
+
+ with pytest.raises(ValueError, match="within /workspace"):
+ build_workspace_overrides(
+ visibility="mapped",
+ mappings=[{"source": "pkg-a", "target": "/workspace2"}],
+ workspace_root=tmp_path,
+ child_token="child-6",
+ backend="docker",
+ )
+
+
+def test_resolve_parent_workspace_root_falls_back_to_hermes_home(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ monkeypatch.delenv("TERMINAL_CWD", raising=False)
+ assert resolve_parent_workspace_root() == tmp_path.resolve()
+
+
+def test_resolve_parent_workspace_root_prefers_terminal_cwd_when_valid(tmp_path, monkeypatch):
+ cwd_dir = tmp_path / "cwd"
+ cwd_dir.mkdir()
+ home_dir = tmp_path / "home"
+ home_dir.mkdir()
+ monkeypatch.setenv("TERMINAL_CWD", str(cwd_dir))
+ monkeypatch.setenv("HERMES_HOME", str(home_dir))
+ assert resolve_parent_workspace_root() == cwd_dir.resolve()
+
+
+def test_resolve_parent_workspace_root_skips_nonexistent_terminal_cwd(tmp_path, monkeypatch):
+ monkeypatch.setenv("TERMINAL_CWD", "/nonexistent/workspace")
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+ assert resolve_parent_workspace_root() == tmp_path.resolve()
+
+
+def test_restricted_modes_require_docker_backend(tmp_path):
+ with pytest.raises(ValueError, match="requires the docker terminal backend"):
+ build_workspace_overrides(
+ visibility="full_ro",
+ mappings=None,
+ workspace_root=tmp_path,
+ child_token="child-5",
+ backend="local",
+ )
diff --git a/tests/tools/test_terminal_gateway_local.py b/tests/tools/test_terminal_gateway_local.py
new file mode 100644
index 000000000000..6392ba51045e
--- /dev/null
+++ b/tests/tools/test_terminal_gateway_local.py
@@ -0,0 +1,154 @@
+"""Tests for the privileged gateway-local terminal escape hatch."""
+
+import json
+from unittest.mock import patch
+
+import tools.terminal_tool as terminal_tool_module
+
+
+class _FakeEnv:
+ def __init__(self):
+ self.calls = []
+
+ def execute(self, command: str, **kwargs):
+ self.calls.append((command, kwargs))
+ return {"output": "ok", "returncode": 0}
+
+
+def _config(**overrides):
+ config = {
+ "env_type": "docker",
+ "cwd": "/workspace",
+ "timeout": 60,
+ "enable_gateway_local": True,
+ "docker_image": "img",
+ "singularity_image": "img",
+ "modal_image": "img",
+ "daytona_image": "img",
+ "container_cpu": 1,
+ "container_memory": 512,
+ "container_disk": 1024,
+ "container_persistent": True,
+ "modal_mode": "auto",
+ "docker_volumes": [],
+ "docker_mount_cwd_to_workspace": False,
+ "docker_forward_env": [],
+ "docker_network": None,
+ "local_persistent": False,
+ "host_cwd": None,
+ }
+ config.update(overrides)
+ return config
+
+
+def test_gateway_local_rejected_when_disabled(monkeypatch):
+ monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
+ monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: _config(enable_gateway_local=False))
+
+ result = json.loads(terminal_tool_module.terminal_tool("pwd", gateway_local=True))
+
+ assert "disabled" in result["error"].lower()
+
+
+def test_gateway_local_rejected_for_background(monkeypatch):
+ monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
+ monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: _config())
+
+ result = json.loads(
+ terminal_tool_module.terminal_tool("sleep 10", gateway_local=True, background=True)
+ )
+
+ assert "does not support background" in result["error"].lower()
+
+
+def test_gateway_local_uses_one_shot_local_env(monkeypatch):
+ fake_env = _FakeEnv()
+ created = {}
+ approvals = {}
+
+ monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
+ monkeypatch.setenv("MESSAGING_CWD", "/srv/gateway-work")
+ monkeypatch.setenv("TERMINAL_CWD", "/host/project")
+ monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: _config())
+ monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
+ monkeypatch.setattr(terminal_tool_module, "_active_environments", {})
+ def _create_environment(**kwargs):
+ created["kwargs"] = kwargs
+ return fake_env
+
+ monkeypatch.setattr(terminal_tool_module, "_create_environment", _create_environment)
+
+ def _approve(command, env_type, **kwargs):
+ approvals["command"] = command
+ approvals["env_type"] = env_type
+ approvals["kwargs"] = kwargs
+ return {"approved": True, "message": None}
+
+ monkeypatch.setattr(terminal_tool_module, "_check_all_guards", _approve)
+
+ result = json.loads(
+ terminal_tool_module.terminal_tool(
+ "pwd",
+ gateway_local=True,
+ task_id="task-1",
+ workdir="/workspace/app",
+ )
+ )
+
+ assert created["kwargs"]["env_type"] == "local"
+ assert created["kwargs"]["cwd"] == "/host/project"
+ assert created["kwargs"]["local_config"] == {"persistent": False}
+ assert approvals["env_type"] == "local"
+ assert approvals["kwargs"]["disable_smart_approval"] is True
+ assert approvals["kwargs"]["extra_warnings"][0]["pattern_key"] == "gateway_local_execution"
+ assert result["execution_scope"] == "gateway_local"
+ assert "gateway container" in result["output"].lower()
+ assert fake_env.calls[0][1]["cwd"] == "/workspace/app"
+ assert terminal_tool_module._active_environments == {}
+
+
+def test_gateway_local_relative_cwd_resolves_against_messaging_cwd(monkeypatch):
+ monkeypatch.setenv("MESSAGING_CWD", "/srv/chat-root")
+ monkeypatch.setenv("TERMINAL_CWD", "./project")
+
+ assert terminal_tool_module._resolve_gateway_local_cwd() == "/srv/chat-root/project"
+
+
+def test_terminal_requirements_allow_gateway_local_fallback(monkeypatch):
+ monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
+ monkeypatch.setattr(
+ terminal_tool_module,
+ "_get_env_config",
+ lambda: _config(env_type="docker", enable_gateway_local=True),
+ )
+ monkeypatch.setattr(terminal_tool_module, "can_offer_gateway_local", lambda config=None: True)
+
+ assert terminal_tool_module.check_terminal_requirements() is True
+
+
+def test_gateway_local_real_guard_wrapper_accepts_extra_warnings(monkeypatch):
+ fake_env = _FakeEnv()
+
+ monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
+ monkeypatch.setenv("MESSAGING_CWD", "/srv/gateway-work")
+ monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: _config())
+ monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
+ monkeypatch.setattr(terminal_tool_module, "_active_environments", {})
+ monkeypatch.setattr(
+ terminal_tool_module,
+ "_create_environment",
+ lambda **kwargs: fake_env,
+ )
+
+ with patch("tools.approval._get_approval_mode", return_value="off"), \
+ patch("tools.tirith_security.check_command_security", return_value={"action": "allow", "findings": [], "summary": ""}):
+ result = json.loads(
+ terminal_tool_module.terminal_tool(
+ "pwd",
+ gateway_local=True,
+ task_id="task-2",
+ )
+ )
+
+ assert result["error"] is None
+ assert result["execution_scope"] == "gateway_local"
diff --git a/tests/tools/test_terminal_tool_approval_feedback.py b/tests/tools/test_terminal_tool_approval_feedback.py
new file mode 100644
index 000000000000..3afe374224c7
--- /dev/null
+++ b/tests/tools/test_terminal_tool_approval_feedback.py
@@ -0,0 +1,40 @@
+import json
+
+
+def test_terminal_tool_surfaces_allowlist_persistence_warning(monkeypatch):
+ import tools.terminal_tool as mod
+
+ class FakeEnv:
+ def execute(self, command, **kwargs):
+ return {"output": "command output", "returncode": 0}
+
+ monkeypatch.setattr(
+ mod,
+ "_get_env_config",
+ lambda: {
+ "env_type": "local",
+ "cwd": ".",
+ "timeout": 30,
+ "docker_image": "",
+ "singularity_image": "",
+ "modal_image": "",
+ "daytona_image": "",
+ },
+ )
+ monkeypatch.setattr(mod, "_start_cleanup_thread", lambda: None)
+ monkeypatch.setattr(
+ mod,
+ "_check_all_guards",
+ lambda command, env_type, **kwargs: {
+ "approved": True,
+ "message": "Permanent approval was applied for this session only.",
+ },
+ )
+ monkeypatch.setattr(mod, "_active_environments", {"default": FakeEnv()})
+ monkeypatch.setattr(mod, "_last_activity", {})
+
+ result = json.loads(mod.terminal_tool("echo hello"))
+
+ assert result["exit_code"] == 0
+ assert "Permanent approval was applied for this session only." in result["output"]
+ assert "command output" in result["output"]
diff --git a/tools/approval.py b/tools/approval.py
index b49e444a4e29..8c918a0876c3 100644
--- a/tools/approval.py
+++ b/tools/approval.py
@@ -346,15 +346,31 @@ def load_permanent_allowlist() -> set:
return set()
-def save_permanent_allowlist(patterns: set):
- """Save permanently allowed command patterns to config."""
+def save_permanent_allowlist(patterns: set) -> str | None:
+ """Save permanently allowed command patterns to config.
+
+ Returns a user-facing warning when the allowlist could not be persisted.
+ """
try:
- from hermes_cli.config import load_config, save_config
+ from hermes_cli.config import ConfigWriteError, load_config, save_config
config = load_config()
config["command_allowlist"] = list(patterns)
save_config(config)
+ return None
+ except ConfigWriteError as exc:
+ warning = (
+ "Permanent approval was applied for this session only because Hermes "
+ "could not persist the command allowlist.\n\n"
+ f"{exc}"
+ )
+ logger.warning("Could not save allowlist: %s", exc)
+ return warning
except Exception as e:
logger.warning("Could not save allowlist: %s", e)
+ return (
+ "Permanent approval was applied for this session only because Hermes "
+ f"could not persist the command allowlist: {e}"
+ )
# =========================================================================
@@ -643,7 +659,9 @@ def _format_tirith_description(tirith_result: dict) -> str:
def check_all_command_guards(command: str, env_type: str,
- approval_callback=None) -> dict:
+ approval_callback=None,
+ extra_warnings: Optional[list[dict]] = None,
+ disable_smart_approval: bool = False) -> dict:
"""Run all pre-exec security checks and return a single approval decision.
Gathers findings from tirith and dangerous-command detection, then
@@ -706,6 +724,14 @@ def check_all_command_guards(command: str, env_type: str,
if not is_approved(session_key, pattern_key):
warnings.append((pattern_key, description, False))
+ for warning in extra_warnings or []:
+ key = warning.get("pattern_key")
+ desc = warning.get("description")
+ if not key or not desc:
+ continue
+ if not is_approved(session_key, key):
+ warnings.append((key, desc, bool(warning.get("session_only", False))))
+
# Nothing to warn about
if not warnings:
return {"approved": True, "message": None}
@@ -714,7 +740,7 @@ def check_all_command_guards(command: str, env_type: str,
# When approvals.mode=smart, ask the aux LLM before prompting the user.
# Inspired by OpenAI Codex's Smart Approvals guardian subagent
# (openai/codex#13860).
- if approval_mode == "smart":
+ if approval_mode == "smart" and not disable_smart_approval:
combined_desc_for_llm = "; ".join(desc for _, desc, _ in warnings)
verdict = _smart_approve(command, combined_desc_for_llm)
if verdict == "approve":
@@ -811,6 +837,7 @@ def check_all_command_guards(command: str, env_type: str,
"description": combined_desc,
}
+ persistence_warning = None
# User approved — persist based on scope (same logic as CLI)
for key, _, is_tirith in warnings:
if choice == "session" or (choice == "always" and is_tirith):
@@ -818,12 +845,18 @@ def check_all_command_guards(command: str, env_type: str,
elif choice == "always":
approve_session(session_key, key)
approve_permanent(key)
- save_permanent_allowlist(_permanent_approved)
+ warning = save_permanent_allowlist(_permanent_approved)
+ if warning and persistence_warning is None:
+ persistence_warning = warning
# choice == "once": no persistence — command allowed this
# single time only, matching the CLI's behavior.
- return {"approved": True, "message": None,
- "user_approved": True, "description": combined_desc}
+ return {
+ "approved": True,
+ "message": persistence_warning,
+ "user_approved": True,
+ "description": combined_desc,
+ }
# Fallback: no gateway callback registered (e.g. cron, batch).
# Return approval_required for backward compat.
@@ -858,6 +891,7 @@ def check_all_command_guards(command: str, env_type: str,
"description": combined_desc,
}
+ persistence_warning = None
# Persist approval for each warning individually
for key, _, is_tirith in warnings:
if choice == "session" or (choice == "always" and is_tirith):
@@ -867,11 +901,13 @@ def check_all_command_guards(command: str, env_type: str,
# dangerous patterns: permanent allowed
approve_session(session_key, key)
approve_permanent(key)
- save_permanent_allowlist(_permanent_approved)
-
- return {"approved": True, "message": None,
- "user_approved": True, "description": combined_desc}
-
-
-# Load permanent allowlist from config on module import
-load_permanent_allowlist()
+ warning = save_permanent_allowlist(_permanent_approved)
+ if warning and persistence_warning is None:
+ persistence_warning = warning
+
+ return {
+ "approved": True,
+ "message": persistence_warning,
+ "user_approved": True,
+ "description": combined_desc,
+ }
diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py
index 2b9e329a3eb7..9eb9ce70d28b 100644
--- a/tools/code_execution_tool.py
+++ b/tools/code_execution_tool.py
@@ -120,9 +120,9 @@ def check_sandbox_requirements() -> bool:
),
"terminal": (
"terminal",
- "command: str, timeout: int = None, workdir: str = None",
+ "command: str, timeout: int = None, workdir: str = None, gateway_local: bool = False",
'"""Run a shell command (foreground only). Returns dict with "output" and "exit_code"."""',
- '{"command": command, "timeout": timeout, "workdir": workdir}',
+ '{"command": command, "timeout": timeout, "workdir": workdir, "gateway_local": gateway_local}',
),
}
@@ -301,7 +301,7 @@ def _call(tool_name, args):
# ---------------------------------------------------------------------------
# Terminal parameters that must not be used from ephemeral sandbox scripts
-_TERMINAL_BLOCKED_PARAMS = {"background", "check_interval", "pty", "notify_on_complete"}
+_TERMINAL_BLOCKED_PARAMS = {"background", "check_interval", "pty", "gateway_local"}
def _rpc_server_loop(
diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py
index a148a31f059e..a14a61335392 100644
--- a/tools/delegate_tool.py
+++ b/tools/delegate_tool.py
@@ -18,26 +18,64 @@
import json
import logging
-logger = logging.getLogger(__name__)
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List, Optional
+logger = logging.getLogger(__name__)
+
# Tools that children must never have access to
DELEGATE_BLOCKED_TOOLS = frozenset([
"delegate_task", # no recursive delegation
"clarify", # no user interaction
- "memory", # no writes to shared MEMORY.md
"send_message", # no cross-platform side effects
"execute_code", # children should reason step-by-step, not write scripts
+ # Note: "memory" removed - subagents have read-only memory access
+ # Memory writes are blocked in memory.py based on subagent_memory_mode
])
-MAX_CONCURRENT_CHILDREN = 3
+# Toolsets that are stripped from subagents by default
+# Can be overridden via config.yaml: delegation.allowed_toolsets
+BLOCKED_TOOLSET_NAMES = frozenset([
+ "delegation", # delegate_task tool
+ "clarify", # clarify tool
+ "code_execution", # execute_code tool
+ # Note: "memory" removed - subagents now have read-only memory access
+])
+
+# Allowlist of toolsets that subagents CAN use
+# Set to None to inherit all parent toolsets (except blocked ones)
+# Configure via config.yaml: delegation.allowed_toolsets
+DEFAULT_ALLOWED_TOOLSETS = ["terminal", "file", "web", "mcp", "browser", "memory"]
+
+# Configuration constants
+_DEFAULT_MAX_CONCURRENT_CHILDREN = 3
+
+
+def _get_max_concurrent_children() -> int:
+ """Read delegation.max_concurrent_children from config.yaml, falling back
+ to DELEGATION_MAX_CONCURRENT_CHILDREN env var, then the default (3)."""
+ env_val = os.getenv("DELEGATION_MAX_CONCURRENT_CHILDREN")
+ if env_val:
+ try:
+ return max(1, int(env_val))
+ except (TypeError, ValueError):
+ pass
+ try:
+ from hermes_cli.config import read_raw_config
+ cfg = read_raw_config()
+ val = cfg.get("delegation", {}).get("max_concurrent_children")
+ if val is not None:
+ return max(1, int(val))
+ except Exception:
+ pass
+ return _DEFAULT_MAX_CONCURRENT_CHILDREN
MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2)
DEFAULT_MAX_ITERATIONS = 50
-DEFAULT_TOOLSETS = ["terminal", "file", "web"]
+DEFAULT_SUBAGENT_MEMORY_MODE = "read_only" # "read_only" | "full" | "none"
+DEFAULT_WORKSPACE_VISIBILITY = "inherit"
def check_delegate_requirements() -> bool:
@@ -48,8 +86,7 @@ def check_delegate_requirements() -> bool:
def _build_child_system_prompt(
goal: str,
context: Optional[str] = None,
- *,
- workspace_path: Optional[str] = None,
+ workspace_note: Optional[str] = None,
) -> str:
"""Build a focused system prompt for a child agent."""
parts = [
@@ -59,12 +96,8 @@ def _build_child_system_prompt(
]
if context and context.strip():
parts.append(f"\nCONTEXT:\n{context}")
- if workspace_path and str(workspace_path).strip():
- parts.append(
- "\nWORKSPACE PATH:\n"
- f"{workspace_path}\n"
- "Use this exact path for local repository/workdir operations unless the task explicitly says otherwise."
- )
+ if workspace_note and workspace_note.strip():
+ parts.append(f"\n{workspace_note.strip()}")
parts.append(
"\nComplete this task using the tools available to you. "
"When finished, provide a clear, concise summary of:\n"
@@ -105,12 +138,46 @@ def _resolve_workspace_hint(parent_agent) -> Optional[str]:
return None
+def _configure_child_workspace(child, task_index: int, task_cfg: Dict[str, Any], delegation_cfg: Dict[str, Any]):
+ """Resolve workspace visibility for a delegated child and attach task overrides."""
+ from tools.subagent_workspace import (
+ build_workspace_overrides,
+ resolve_parent_workspace_root,
+ resolve_terminal_backend,
+ )
+
+ visibility = task_cfg.get("workspace_visibility")
+ mappings = task_cfg.get("workspace_mappings")
+ if mappings is None:
+ mappings = delegation_cfg.get("workspace_mappings")
+ if visibility is None:
+ visibility = delegation_cfg.get("workspace_visibility", DEFAULT_WORKSPACE_VISIBILITY)
+ if (visibility in (None, "", "inherit")) and mappings:
+ visibility = "mapped"
+
+ plan = build_workspace_overrides(
+ visibility=visibility,
+ mappings=mappings,
+ workspace_root=resolve_parent_workspace_root(),
+ child_token=f"subagent-{task_index}-{getattr(child, 'session_id', '') or task_index}",
+ backend=resolve_terminal_backend(),
+ )
+
+ child._delegate_task_id = getattr(child, "session_id", None)
+ child._delegate_task_env_overrides = plan.get("task_env_overrides")
+ prompt_note = plan.get("prompt_note", "").strip()
+ if prompt_note:
+ child.ephemeral_system_prompt = _build_child_system_prompt(
+ task_cfg["goal"],
+ task_cfg.get("context"),
+ prompt_note,
+ )
+
+
def _strip_blocked_tools(toolsets: List[str]) -> List[str]:
"""Remove toolsets that contain only blocked tools."""
- blocked_toolset_names = {
- "delegation", "clarify", "memory", "code_execution",
- }
- return [t for t in toolsets if t not in blocked_toolset_names]
+ # Use configurable blocked toolset names
+ return [t for t in toolsets if t not in BLOCKED_TOOLSET_NAMES]
def _build_child_progress_callback(task_index: int, parent_agent, task_count: int = 1) -> Optional[callable]:
@@ -223,33 +290,22 @@ def _build_child_agent(
# When no explicit toolsets given, inherit from parent's enabled toolsets
# so disabled tools (e.g. web) don't leak to subagents.
- # Note: enabled_toolsets=None means "all tools enabled" (the default),
- # so we must derive effective toolsets from the parent's loaded tools.
- parent_enabled = getattr(parent_agent, "enabled_toolsets", None)
- if parent_enabled is not None:
- parent_toolsets = set(parent_enabled)
- elif parent_agent and hasattr(parent_agent, "valid_tool_names"):
- # enabled_toolsets is None (all tools) — derive from loaded tool names
- import model_tools
- parent_toolsets = {
- ts for name in parent_agent.valid_tool_names
- if (ts := model_tools.get_toolset_for_tool(name)) is not None
- }
- else:
- parent_toolsets = set(DEFAULT_TOOLSETS)
-
+ parent_toolsets = set(getattr(parent_agent, "enabled_toolsets", None) or DEFAULT_ALLOWED_TOOLSETS)
if toolsets:
# Intersect with parent — subagent must not gain tools the parent lacks
+ # Then apply allowlist
child_toolsets = _strip_blocked_tools([t for t in toolsets if t in parent_toolsets])
- elif parent_agent and parent_enabled is not None:
- child_toolsets = _strip_blocked_tools(parent_enabled)
- elif parent_toolsets:
- child_toolsets = _strip_blocked_tools(sorted(parent_toolsets))
+ # Filter to allowed toolsets
+ child_toolsets = [t for t in child_toolsets if t in DEFAULT_ALLOWED_TOOLSETS]
+ elif parent_agent and getattr(parent_agent, "enabled_toolsets", None):
+ child_toolsets = _strip_blocked_tools(parent_agent.enabled_toolsets)
+ # Filter to allowed toolsets
+ child_toolsets = [t for t in child_toolsets if t in DEFAULT_ALLOWED_TOOLSETS]
else:
- child_toolsets = _strip_blocked_tools(DEFAULT_TOOLSETS)
+ child_toolsets = _strip_blocked_tools(DEFAULT_ALLOWED_TOOLSETS)
workspace_hint = _resolve_workspace_hint(parent_agent)
- child_prompt = _build_child_system_prompt(goal, context, workspace_path=workspace_hint)
+ child_prompt = _build_child_system_prompt(goal, context, workspace_note=workspace_hint)
# Extract parent's API key so subagents inherit auth (e.g. Nous Portal).
parent_api_key = getattr(parent_agent, "api_key", None)
if (not parent_api_key) and hasattr(parent_agent, "_client_kwargs"):
@@ -302,7 +358,8 @@ def _child_thinking(text: str) -> None:
log_prefix=f"[subagent-{task_index}]",
platform=parent_agent.platform,
skip_context_files=True,
- skip_memory=True,
+ skip_memory=False, # Enable memory for subagents (read-only mode enforced in memory_tool.py)
+ subagent_memory_mode=DEFAULT_SUBAGENT_MEMORY_MODE, # Pass read_only/full/none config
clarify_callback=None,
thinking_callback=child_thinking_cb,
session_db=getattr(parent_agent, '_session_db', None),
@@ -313,6 +370,9 @@ def _child_thinking(text: str) -> None:
provider_sort=parent_agent.provider_sort,
tool_progress_callback=child_progress_cb,
iteration_budget=None, # fresh budget per subagent
+ _is_subagent=True, # Mark this agent as a subagent for memory/tool access control
+ # Share parent's memory store (read-only access enforced in memory_tool.py)
+ _shared_memory_store=getattr(parent_agent, '_memory_store', None),
)
child._print_fn = getattr(parent_agent, '_print_fn', None)
# Set delegation depth so children can't spawn grandchildren
@@ -370,7 +430,14 @@ def _run_single_child(
logger.debug("Failed to bind child to leased credential: %s", exc)
try:
- result = child.run_conversation(user_message=goal)
+ child_task_id = getattr(child, "_delegate_task_id", None) or getattr(child, "session_id", None)
+ child_env_overrides = getattr(child, "_delegate_task_env_overrides", None)
+ if child_task_id and child_env_overrides:
+ from tools.terminal_tool import register_task_env_overrides
+
+ register_task_env_overrides(child_task_id, child_env_overrides)
+
+ result = child.run_conversation(user_message=goal, task_id=child_task_id)
# Flush any remaining batched progress to gateway
if child_progress_cb and hasattr(child_progress_cb, '_flush'):
@@ -479,11 +546,26 @@ def _run_single_child(
}
finally:
+ # Release the credential pool lease acquired at the top of the try
+ # block. Without this, every delegated child leaks a lease, and the
+ # pool gradually marks all entries as exhausted. Upstream has this;
+ # the fork's rewrite for workspace env overrides accidentally dropped
+ # it. Restored here.
if child_pool is not None and leased_cred_id is not None:
try:
child_pool.release_lease(leased_cred_id)
- except Exception as exc:
- logger.debug("Failed to release credential lease: %s", exc)
+ except Exception:
+ logger.debug("Failed to release credential lease %s", leased_cred_id, exc_info=True)
+
+ child_task_id = getattr(child, "_delegate_task_id", None) or getattr(child, "session_id", None)
+ child_env_overrides = getattr(child, "_delegate_task_env_overrides", None)
+ if child_task_id and child_env_overrides:
+ try:
+ from tools.terminal_tool import clear_task_env_overrides
+
+ clear_task_env_overrides(child_task_id)
+ except Exception:
+ logger.debug("Failed to clear delegated task env overrides", exc_info=True)
# Restore the parent's tool names so the process-global is correct
# for any subsequent execute_code calls or other consumers.
@@ -511,8 +593,11 @@ def delegate_task(
goal: Optional[str] = None,
context: Optional[str] = None,
toolsets: Optional[List[str]] = None,
+ workspace_visibility: Optional[str] = None,
+ workspace_mappings: Optional[List[Dict[str, Any]]] = None,
tasks: Optional[List[Dict[str, Any]]] = None,
max_iterations: Optional[int] = None,
+ model: Optional[str] = None,
acp_command: Optional[str] = None,
acp_args: Optional[List[str]] = None,
parent_agent=None,
@@ -554,11 +639,31 @@ def delegate_task(
except ValueError as exc:
return tool_error(str(exc))
+ # Per-call model override: the agent can request a specific model or tier
+ # (small/medium/large) for this delegation.
+ if model and isinstance(model, str) and model.strip():
+ creds["model"] = _resolve_model_or_tier(model.strip())
+
# Normalize to task list
+ max_children = _get_max_concurrent_children()
if tasks and isinstance(tasks, list):
- task_list = tasks[:MAX_CONCURRENT_CHILDREN]
+ if len(tasks) > max_children:
+ return tool_error(
+ f"Too many tasks: {len(tasks)} provided, but "
+ f"max_concurrent_children is {max_children}. "
+ f"Either reduce the task count, split into multiple "
+ f"delegate_task calls, or increase "
+ f"delegation.max_concurrent_children in config.yaml."
+ )
+ task_list = tasks
elif goal and isinstance(goal, str) and goal.strip():
- task_list = [{"goal": goal, "context": context, "toolsets": toolsets}]
+ task_list = [{
+ "goal": goal,
+ "context": context,
+ "toolsets": toolsets,
+ "workspace_visibility": workspace_visibility,
+ "workspace_mappings": workspace_mappings,
+ }]
else:
return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).")
@@ -589,9 +694,12 @@ def delegate_task(
children = []
try:
for i, t in enumerate(task_list):
+ # Per-task model override takes precedence over top-level model
+ raw_task_model = str(t.get("model") or "").strip()
+ task_model = _resolve_model_or_tier(raw_task_model) if raw_task_model else creds["model"]
child = _build_child_agent(
task_index=i, goal=t["goal"], context=t.get("context"),
- toolsets=t.get("toolsets") or toolsets, model=creds["model"],
+ toolsets=t.get("toolsets") or toolsets, model=task_model,
max_iterations=effective_max_iter, parent_agent=parent_agent,
override_provider=creds["provider"], override_base_url=creds["base_url"],
override_api_key=creds["api_key"],
@@ -599,9 +707,16 @@ def delegate_task(
override_acp_command=t.get("acp_command") or acp_command,
override_acp_args=t.get("acp_args") or acp_args,
)
+ if "workspace_visibility" not in t and workspace_visibility is not None:
+ t["workspace_visibility"] = workspace_visibility
+ if "workspace_mappings" not in t and workspace_mappings is not None:
+ t["workspace_mappings"] = workspace_mappings
+ _configure_child_workspace(child, i, t, cfg)
# Override with correct parent tool names (before child construction mutated global)
child._delegate_saved_tool_names = _parent_tool_names
children.append((i, t, child))
+ except ValueError as exc:
+ return json.dumps({"error": str(exc)})
finally:
# Authoritative restore: reset global to parent's tool names after all children built
_model_tools._last_resolved_tool_names = _parent_tool_names
@@ -616,7 +731,7 @@ def delegate_task(
completed_count = 0
spinner_ref = getattr(parent_agent, '_delegate_spinner', None)
- with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_CHILDREN) as executor:
+ with ThreadPoolExecutor(max_workers=max_children) as executor:
futures = {}
for i, t, child in children:
future = executor.submit(
@@ -835,6 +950,135 @@ def _load_config() -> dict:
return {}
+# ---------------------------------------------------------------------------
+# Model tier resolution
+# ---------------------------------------------------------------------------
+
+_DEFAULT_TIERS = {"small": None, "medium": None, "large": None}
+
+
+def _load_model_tiers() -> dict:
+ """Load model_tiers from delegation config.
+
+ Returns a dict mapping tier names (small/medium/large) to model names.
+ Falls back to empty tiers when unconfigured.
+ """
+ cfg = _load_config()
+ tiers = cfg.get("model_tiers", {})
+ if not isinstance(tiers, dict):
+ return dict(_DEFAULT_TIERS)
+ result = dict(_DEFAULT_TIERS)
+ for tier in result:
+ val = str(tiers.get(tier) or "").strip()
+ if val:
+ result[tier] = val
+ return result
+
+
+def _resolve_model_or_tier(model_spec: str) -> str:
+ """Resolve a model specification that may be a tier name or a model name.
+
+ If model_spec is 'small', 'medium', or 'large', resolves to the
+ configured model name for that tier. Otherwise returns model_spec
+ as-is (assumed to be a direct model name).
+ """
+ if not model_spec:
+ return model_spec
+ lowered = model_spec.strip().lower()
+ if lowered in ("small", "medium", "large"):
+ tiers = _load_model_tiers()
+ resolved = tiers.get(lowered)
+ if resolved:
+ logger.info("model tier '%s' resolved to '%s'", lowered, resolved)
+ return resolved
+ logger.warning(
+ "model tier '%s' requested but not configured in "
+ "delegation.model_tiers; falling back to default model",
+ lowered,
+ )
+ return "" # empty → inherit default
+ return model_spec
+
+
+def list_models(parent_agent=None) -> str:
+ """Return available models and their delegation tiers as JSON.
+
+ Reads custom_providers from config.yaml to enumerate models, and
+ delegation.model_tiers for tier assignments.
+ """
+ # Load custom providers to enumerate available models
+ models = []
+ try:
+ from cli import CLI_CONFIG
+ full_cfg = CLI_CONFIG
+ except Exception:
+ try:
+ from hermes_cli.config import load_config
+ full_cfg = load_config()
+ except Exception:
+ full_cfg = {}
+
+ # Get the current/default model
+ model_cfg = full_cfg.get("model", {})
+ if isinstance(model_cfg, dict):
+ default_model = model_cfg.get("default", "")
+ else:
+ default_model = str(model_cfg or "")
+
+ # Enumerate models from custom_providers
+ for cp in full_cfg.get("custom_providers", []):
+ provider_name = cp.get("name", "unknown")
+ for model_name, model_info in (cp.get("models") or {}).items():
+ ctx = None
+ if isinstance(model_info, dict):
+ ctx = model_info.get("context_length")
+ models.append({
+ "name": model_name,
+ "provider": provider_name,
+ "context_length": ctx,
+ })
+
+ # Load tiers
+ tiers = _load_model_tiers()
+
+ # Assign tier labels to models
+ tier_by_model = {}
+ for tier_name, tier_model in tiers.items():
+ if tier_model:
+ tier_by_model[tier_model] = tier_name
+
+ for m in models:
+ m["tier"] = tier_by_model.get(m["name"])
+ m["is_default"] = m["name"] == default_model
+
+ return json.dumps({
+ "models": models,
+ "tiers": {k: v for k, v in tiers.items() if v},
+ "default_model": default_model,
+ "usage_hint": (
+ "Use 'small' for simple tasks (summarization, formatting, file listing). "
+ "Use 'medium' (default) for standard work. "
+ "Use 'large' for complex reasoning, peer review, or when you're stuck. "
+ "You can pass tier names ('small', 'medium', 'large') or model names directly "
+ "as the 'model' parameter in delegate_task."
+ ),
+ }, indent=2)
+
+
+LIST_MODELS_SCHEMA = {
+ "name": "list_models",
+ "description": (
+ "List available models for delegation with their tiers (small/medium/large). "
+ "Use this to discover which models are available before delegating tasks. "
+ "Returns model names, providers, context lengths, and tier assignments."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {},
+ },
+}
+
+
# ---------------------------------------------------------------------------
# OpenAI Function-Calling Schema
# ---------------------------------------------------------------------------
@@ -864,7 +1108,18 @@ def _load_config() -> dict:
"- Subagents CANNOT call: delegate_task, clarify, memory, send_message, "
"execute_code.\n"
"- Each subagent gets its own terminal session (separate working directory and state).\n"
- "- Results are always returned as an array, one entry per task."
+ "- workspace_visibility defaults to 'inherit'. Use 'full_ro' or 'mapped' "
+ "when you need stricter filesystem isolation in Docker sandboxes.\n"
+ "- Results are always returned as an array, one entry per task.\n\n"
+ "MODEL SELECTION:\n"
+ "- You can set 'model' to a tier name ('small', 'medium', 'large') "
+ "or a specific model name. Use list_models to see available options.\n"
+ "- 'small': fast/cheap model for simple tasks (file exploration, "
+ "summarization, formatting, lookups)\n"
+ "- 'medium' or omit: default model for standard work\n"
+ "- 'large': most capable model for complex reasoning, peer review, "
+ "or when you're stuck and need to escalate\n"
+ "- Each task in a batch can use a different model."
),
"parameters": {
"type": "object",
@@ -896,6 +1151,53 @@ def _load_config() -> dict:
"full-stack tasks."
),
},
+ "model": {
+ "type": "string",
+ "description": (
+ "Model to use for this subagent. Default: inherits from "
+ "delegation config or parent model. Use a smaller/faster model "
+ "(e.g. 'gemma4-nothink') for simple tasks like summarization, "
+ "formatting, or lookups. Keep the default for complex reasoning, "
+ "debugging, or multi-step implementation."
+ ),
+ },
+ "workspace_visibility": {
+ "type": "string",
+ "enum": ["inherit", "full_rw", "full_ro", "mapped"],
+ "description": (
+ "Filesystem visibility for the child sandbox. "
+ "'inherit' keeps the current backend behavior. "
+ "'full_rw' mounts the full parent workspace at /workspace read-write. "
+ "'full_ro' mounts it read-only. "
+ "'mapped' exposes only the paths listed in workspace_mappings."
+ ),
+ },
+ "workspace_mappings": {
+ "type": "array",
+ "description": (
+ "Used only with workspace_visibility='mapped'. "
+ "Each mapping source must stay inside the parent workspace. "
+ "target defaults under /workspace and read_only defaults to false."
+ ),
+ "items": {
+ "type": "object",
+ "properties": {
+ "source": {
+ "type": "string",
+ "description": "Workspace-relative or absolute path inside the parent workspace.",
+ },
+ "target": {
+ "type": "string",
+ "description": "Path inside the child sandbox under /workspace.",
+ },
+ "read_only": {
+ "type": "boolean",
+ "description": "Mount this mapping read-only.",
+ },
+ },
+ "required": ["source"],
+ },
+ },
"tasks": {
"type": "array",
"items": {
@@ -908,6 +1210,10 @@ def _load_config() -> dict:
"items": {"type": "string"},
"description": "Toolsets for this specific task. Use 'web' for network access, 'terminal' for shell.",
},
+ "model": {
+ "type": "string",
+ "description": "Model override for this task (e.g. 'gemma4-nothink' for simple work).",
+ },
"acp_command": {
"type": "string",
"description": "Per-task ACP command override (e.g. 'claude'). Overrides the top-level acp_command for this task only.",
@@ -917,14 +1223,33 @@ def _load_config() -> dict:
"items": {"type": "string"},
"description": "Per-task ACP args override.",
},
+ "workspace_visibility": {
+ "type": "string",
+ "enum": ["inherit", "full_rw", "full_ro", "mapped"],
+ "description": "Workspace visibility for this task's sandbox.",
+ },
+ "workspace_mappings": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "source": {"type": "string"},
+ "target": {"type": "string"},
+ "read_only": {"type": "boolean"},
+ },
+ "required": ["source"],
+ },
+ "description": "Workspace mappings for this task when workspace_visibility='mapped'.",
+ },
},
"required": ["goal"],
},
"maxItems": 3,
"description": (
- "Batch mode: up to 3 tasks to run in parallel. Each gets "
+ "Batch mode: tasks to run in parallel (limit configurable via delegation.max_concurrent_children, default 3). Each gets "
"its own subagent with isolated context and terminal session. "
- "When provided, top-level goal/context/toolsets are ignored."
+ "When provided, top-level goal/context are ignored. "
+ "Top-level toolsets and workspace visibility settings act as defaults."
),
},
"max_iterations": {
@@ -958,7 +1283,7 @@ def _load_config() -> dict:
# --- Registry ---
-from tools.registry import registry, tool_error
+from tools.registry import registry, tool_error # noqa: E402
registry.register(
name="delegate_task",
@@ -968,11 +1293,22 @@ def _load_config() -> dict:
goal=args.get("goal"),
context=args.get("context"),
toolsets=args.get("toolsets"),
+ workspace_visibility=args.get("workspace_visibility"),
+ workspace_mappings=args.get("workspace_mappings"),
tasks=args.get("tasks"),
max_iterations=args.get("max_iterations"),
+ model=args.get("model"),
acp_command=args.get("acp_command"),
acp_args=args.get("acp_args"),
parent_agent=kw.get("parent_agent")),
check_fn=check_delegate_requirements,
emoji="🔀",
)
+
+registry.register(
+ name="list_models",
+ toolset="delegation",
+ schema=LIST_MODELS_SCHEMA,
+ handler=lambda args, **kw: list_models(parent_agent=kw.get("parent_agent")),
+ emoji="📋",
+)
diff --git a/tools/environments/docker.py b/tools/environments/docker.py
index 59a23779612f..af506bcdeb31 100644
--- a/tools/environments/docker.py
+++ b/tools/environments/docker.py
@@ -11,11 +11,15 @@
import shutil
import subprocess
import sys
+import threading
+import time
+import shlex
import uuid
from typing import Optional
from tools.environments.base import BaseEnvironment, _popen_bash
from tools.environments.local import _HERMES_PROVIDER_ENV_BLOCKLIST
+from tools.interrupt import is_interrupted
logger = logging.getLogger(__name__)
@@ -132,21 +136,97 @@ def find_docker() -> Optional[str]:
# CHOWN/FOWNER - package managers (pip, npm, apt) need to set file ownership
# Block privilege escalation and limit PIDs.
# /tmp is size-limited and nosuid but allows exec (needed by pip/npm builds).
+#
+# Configurable via env vars:
+# SANDBOX_NO_NEW_PRIVS = "true" (default) | "false" — disable to allow sudo
+# SANDBOX_PIDS_LIMIT = integer (default "256") | "0"/"off"/"none" — disable
+#
+# ``--pids-limit`` is added later in the run command (see ``resource_args``)
+# rather than here, so it can be auto-disabled when the ``pids`` cgroup
+# controller is not delegated to this process (typical inside unprivileged
+# LXCs). Hardcoding it caused every container spawn to fail with
+# "controller `pids` is not available" on such hosts.
_SECURITY_ARGS = [
"--cap-drop", "ALL",
"--cap-add", "DAC_OVERRIDE",
"--cap-add", "CHOWN",
"--cap-add", "FOWNER",
- "--security-opt", "no-new-privileges",
- "--pids-limit", "256",
"--tmpfs", "/tmp:rw,nosuid,size=512m",
"--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m",
"--tmpfs", "/run:rw,noexec,nosuid,size=64m",
]
+# Add no-new-privileges unless explicitly disabled
+if os.getenv("SANDBOX_NO_NEW_PRIVS", "true").lower() != "false":
+ _SECURITY_ARGS.extend(["--security-opt", "no-new-privileges"])
+else:
+ logger.warning(
+ "SANDBOX_NO_NEW_PRIVS=false: containers can escalate privileges via sudo. "
+ "Only disable in trusted environments."
+ )
+
_storage_opt_ok: Optional[bool] = None # cached result across instances
+_cgroup_limits_ok: Optional[bool] = None # cached result across instances
+
+
+def _cgroup_limits_available(image: str) -> bool:
+ """Probe whether cgroup resource limits (--cpus/--memory/--pids-limit) work.
+
+ Spawns a throwaway container from *image* (the same sandbox image we are
+ about to use for real, so no extra pull and no dependency on a public
+ registry) with all three flags. The container runs ``sleep 0`` — sleep is
+ guaranteed to be present because the sandbox itself uses ``sleep 2h`` as
+ its long-lived entrypoint. On hosts without cgroup controller delegation
+ (typical inside unprivileged LXCs) these flags cause container startup to
+ fail; we cache the boolean result host-wide so the probe runs at most once.
+ """
+ global _cgroup_limits_ok
+ if _cgroup_limits_ok is not None:
+ return _cgroup_limits_ok
+
+ docker_exe = find_docker()
+ if not docker_exe:
+ _cgroup_limits_ok = False
+ return False
+
+ try:
+ result = subprocess.run(
+ [docker_exe, "run", "--rm",
+ "--cpus", "0.5", "--memory", "64m", "--pids-limit", "32",
+ image, "sleep", "0"],
+ capture_output=True, text=True, timeout=60,
+ )
+ _cgroup_limits_ok = result.returncode == 0
+ if not _cgroup_limits_ok:
+ logger.warning(
+ "Cgroup resource limits (--cpus/--memory/--pids-limit) not "
+ "available in this environment. Containers will run without "
+ "CPU, memory or PID limits. To enable, delegate cgroup "
+ "controllers to this container. Probe stderr: %s",
+ (result.stderr or "").strip()[:500],
+ )
+ except Exception as e:
+ _cgroup_limits_ok = False
+ logger.warning("Cgroup limit probe failed; disabling resource limits: %s", e)
+
+ return _cgroup_limits_ok
+
+
+def _resolve_pids_limit() -> Optional[str]:
+ """Return the configured ``--pids-limit`` value, or None if disabled.
+
+ Honors ``SANDBOX_PIDS_LIMIT``:
+ - unset / empty → default "256"
+ - "0", "off", "none", "false", "disable", "disabled" → None (no limit)
+ - any other value → that value (passed to docker as-is)
+ """
+ raw = os.getenv("SANDBOX_PIDS_LIMIT", "256").strip()
+ if not raw or raw.lower() in {"0", "off", "none", "false", "disable", "disabled"}:
+ return None
+ return raw
+
def _ensure_docker_available() -> None:
"""Best-effort check that the docker CLI is available before use.
@@ -240,17 +320,47 @@ def __init__(
forward_env: list[str] | None = None,
env: dict | None = None,
network: bool = True,
+ docker_network: str | None = None,
host_cwd: str = None,
auto_mount_cwd: bool = False,
+ extra_hosts: list[str] | None = None,
+ env_files: list[str] | None = None,
+ docker_user: str | None = None,
):
- if cwd == "~":
+ # Resolve home directory based on user
+ home_dir = f"/home/{docker_user}" if docker_user and docker_user != "root" else "/root"
+ if cwd in ("~", "/root") and docker_user and docker_user != "root":
+ cwd = home_dir
+ elif cwd == "~":
cwd = "/root"
+ self._docker_user = docker_user
+ self._home_path = home_dir
super().__init__(cwd=cwd, timeout=timeout)
self._base_image = image
self._persistent = persistent_filesystem
self._task_id = task_id
self._forward_env = _normalize_forward_env_names(forward_env)
self._env = _normalize_env_dict(env)
+
+ # Inject env vars from files: format "VAR_NAME:/path/to/file".
+ #
+ # Each entry is parsed once at __init__, the path is canonicalized
+ # via Path.resolve() (no symlink swap mid-task), validated against
+ # an allowlist of safe parent directories, and stored as a tuple of
+ # (var_name, resolved_host_path). The exec path then re-reads the
+ # file on every ``docker exec`` call (see ``_extra_env_for_exec``)
+ # so that rotating credentials propagate to the next tool call
+ # without having to respawn the container. The canonical case is
+ # BW_SESSION from a Bitwarden-unlock sidecar: the file on the host
+ # gets rewritten when the vault is unlocked / re-unlocked, and the
+ # next ``docker exec`` picks up the fresh value automatically.
+ #
+ # The values are *not* baked into ``self._env`` (and therefore not
+ # passed to ``docker run`` either) — the long-lived container's own
+ # environment stays clean, and each exec gets a freshly read copy
+ # for the duration of that exec'd process only.
+ self._env_files: list[tuple[str, str]] = self._parse_env_files(env_files or [])
+
self._container_id: Optional[str] = None
logger.info(f"DockerEnvironment volumes: {volumes}")
# Ensure volumes is a list (config.yaml could be malformed)
@@ -261,12 +371,15 @@ def __init__(
# Fail fast if Docker is not available.
_ensure_docker_available()
- # Build resource limit args
+ # Build resource limit args (gated by cgroup availability probe)
resource_args = []
- if cpu > 0:
+ if cpu > 0 and _cgroup_limits_available(self._base_image):
resource_args.extend(["--cpus", str(cpu)])
- if memory > 0:
+ if memory > 0 and _cgroup_limits_available(self._base_image):
resource_args.extend(["--memory", f"{memory}m"])
+ pids_limit = _resolve_pids_limit()
+ if pids_limit is not None and _cgroup_limits_available(self._base_image):
+ resource_args.extend(["--pids-limit", pids_limit])
if disk > 0 and sys.platform != "darwin":
if self._storage_opt_supported():
resource_args.extend(["--storage-opt", f"size={disk}m"])
@@ -277,6 +390,8 @@ def __init__(
)
if not network:
resource_args.append("--network=none")
+ elif docker_network:
+ resource_args.extend(["--network", docker_network])
# Persistent workspace via bind mounts from a configurable host directory
# (TERMINAL_SANDBOX_DIR, default ~/.hermes/sandboxes/). Non-persistent
@@ -318,7 +433,7 @@ def __init__(
self._home_dir = str(sandbox / "home")
os.makedirs(self._home_dir, exist_ok=True)
writable_args.extend([
- "-v", f"{self._home_dir}:/root",
+ "-v", f"{self._home_dir}:{self._home_path}",
])
if not bind_host_cwd and not workspace_explicitly_mounted:
self._workspace_dir = str(sandbox / "workspace")
@@ -394,12 +509,20 @@ def __init__(
# Explicit environment variables (docker_env config) — set at container
# creation so they're available to all processes (including entrypoint).
+ # Override HOME when running as a non-root user so tools resolve dotfiles correctly.
env_args = []
+ if self._docker_user and self._docker_user != "root":
+ env_args.extend(["-e", f"HOME={self._home_path}"])
for key in sorted(self._env):
env_args.extend(["-e", f"{key}={self._env[key]}"])
+ host_args = []
+ for entry in (extra_hosts or []):
+ host_args.extend(["--add-host", entry])
+
logger.info(f"Docker volume_args: {volume_args}")
- all_run_args = list(_SECURITY_ARGS) + writable_args + resource_args + volume_args + env_args
+ user_args = ["--user", self._docker_user] if self._docker_user else []
+ all_run_args = list(_SECURITY_ARGS) + user_args + writable_args + resource_args + host_args + volume_args + env_args
logger.info(f"Docker run_args: {all_run_args}")
# Resolve the docker executable once so it works even when
@@ -410,11 +533,12 @@ def __init__(
container_name = f"hermes-{uuid.uuid4().hex[:8]}"
run_cmd = [
self._docker_exe, "run", "-d",
+ "--init", # tini as PID 1 — reaps zombie children
"--name", container_name,
"-w", cwd,
*all_run_args,
image,
- "sleep", "2h",
+ "sleep", "infinity", # no fixed lifetime — idle reaper handles cleanup
]
logger.debug(f"Starting container: {' '.join(run_cmd)}")
result = subprocess.run(
@@ -427,68 +551,184 @@ def __init__(
self._container_id = result.stdout.strip()
logger.info(f"Started container {container_name} ({self._container_id[:12]})")
- # Build the init-time env forwarding args (used only by init_session
- # to inject host env vars into the snapshot; subsequent commands get
- # them from the snapshot file).
- self._init_env_args = self._build_init_env_args()
-
- # Initialize session snapshot inside the container
- self.init_session()
-
- def _build_init_env_args(self) -> list[str]:
- """Build -e KEY=VALUE args for injecting host env vars into init_session.
-
- These are used once during init_session() so that export -p captures
- them into the snapshot. Subsequent execute() calls don't need -e flags.
- """
- exec_env: dict[str, str] = dict(self._env)
-
- explicit_forward_keys = set(self._forward_env)
- passthrough_keys: set[str] = set()
+ # Maximum size of a single env_files value. Linux's `execve` accepts at
+ # most ARG_MAX bytes total across all argv + envp; per-entry size is
+ # bounded by the same limit. We cap individual values at 64 KiB so a
+ # buggy or malicious sidecar that writes a huge file fails fast with a
+ # clear log line instead of a confusing E2BIG when the actual exec runs.
+ _ENV_FILES_MAX_SIZE = 64 * 1024
+
+ # Allowlist of safe parent directories for `docker_env_files` paths,
+ # checked at parse time after symlink resolution. The intent is
+ # defense-in-depth against a config that says "X:/etc/shadow" — for the
+ # canonical sidecar use case the file lives in /run/hermes-creds, the
+ # XDG runtime dir, or HERMES_HOME. Operators with unusual layouts can
+ # extend this via TERMINAL_DOCKER_ENV_FILES_ALLOWED_DIRS (colon-
+ # separated). Empty allowlist disables the check entirely (escape hatch
+ # for tests and operators who really know what they're doing).
+ @staticmethod
+ def _env_files_allowed_dirs() -> "list[Path]":
+ from pathlib import Path
+ override = os.getenv("TERMINAL_DOCKER_ENV_FILES_ALLOWED_DIRS")
+ if override is not None:
+ return [Path(p).resolve() for p in override.split(":") if p.strip()]
+ candidates = [
+ "/run/hermes-creds",
+ "/run/secrets",
+ os.getenv("XDG_RUNTIME_DIR") or "",
+ ]
try:
- from tools.env_passthrough import get_all_passthrough
- passthrough_keys = set(get_all_passthrough())
+ from hermes_constants import get_hermes_home
+ candidates.append(str(get_hermes_home()))
except Exception:
pass
- # Explicit docker_forward_env entries are an intentional opt-in and must
- # win over the generic Hermes secret blocklist. Only implicit passthrough
- # keys are filtered.
- forward_keys = explicit_forward_keys | (passthrough_keys - _HERMES_PROVIDER_ENV_BLOCKLIST)
- hermes_env = _load_hermes_env_vars() if forward_keys else {}
- for key in sorted(forward_keys):
- value = os.getenv(key)
- if value is None:
- value = hermes_env.get(key)
- if value is not None:
- exec_env[key] = value
+ return [Path(c).resolve() for c in candidates if c]
+
+ @classmethod
+ def _parse_env_files(cls, entries: list[str]) -> list[tuple[str, str]]:
+ """Parse and validate ``docker_env_files`` config entries.
+
+ Each entry is ``"VAR_NAME:/host/path"``. Returns a list of
+ ``(var_name, resolved_path)`` tuples. Invalid entries are logged
+ and skipped (non-fatal — the agent should still start even if one
+ credential source is misconfigured).
+
+ Validation:
+ - Format must be ``VAR:path`` with at least one ``:``.
+ - Path is resolved via ``Path.resolve()`` (follows symlinks once,
+ canonicalises) so that subsequent rewrites of the symlink target
+ cannot redirect reads at exec time.
+ - Resolved path must be inside one of the allowed parent
+ directories (see ``_env_files_allowed_dirs``). Empty allowlist
+ disables the check.
+ - Path does not need to exist at parse time — sidecar may not have
+ written the file yet. Existence is rechecked at exec time.
+ """
+ from pathlib import Path
+ parsed: list[tuple[str, str]] = []
+ allowed = cls._env_files_allowed_dirs()
+ for entry in entries:
+ try:
+ var_name, raw_path = entry.split(":", 1)
+ except ValueError:
+ logger.warning(
+ "docker_env_files: invalid entry %r, expected 'VAR:path'",
+ entry,
+ )
+ continue
+ var_name = var_name.strip()
+ if not var_name:
+ logger.warning("docker_env_files: empty var name in %r", entry)
+ continue
+ if not raw_path.strip():
+ logger.warning("docker_env_files: empty path for %s", var_name)
+ continue
- args = []
- for key in sorted(exec_env):
- args.extend(["-e", f"{key}={exec_env[key]}"])
- return args
+ # Resolve the path (follows symlinks, canonicalises). strict=False
+ # so missing files don't error — the sidecar may not have written
+ # the file yet at hermes-agent startup time.
+ try:
+ resolved = Path(raw_path).resolve(strict=False)
+ except (OSError, RuntimeError) as e:
+ logger.warning(
+ "docker_env_files: could not resolve %s for %s: %s",
+ raw_path, var_name, e,
+ )
+ continue
- def _run_bash(self, cmd_string: str, *, login: bool = False,
- timeout: int = 120,
- stdin_data: str | None = None) -> subprocess.Popen:
- """Spawn a bash process inside the Docker container."""
- assert self._container_id, "Container not started"
- cmd = [self._docker_exe, "exec"]
- if stdin_data is not None:
- cmd.append("-i")
+ # Check the resolved path is inside an allowed directory.
+ if allowed:
+ ok = any(
+ resolved == d or d in resolved.parents
+ for d in allowed
+ )
+ if not ok:
+ logger.warning(
+ "docker_env_files: rejecting %s (resolves to %s, outside "
+ "allowed dirs %s — set TERMINAL_DOCKER_ENV_FILES_ALLOWED_DIRS "
+ "to override)",
+ var_name, resolved,
+ ", ".join(str(d) for d in allowed),
+ )
+ continue
- # Only inject -e env args during init_session (login=True).
- # Subsequent commands get env vars from the snapshot.
- if login:
- cmd.extend(self._init_env_args)
+ parsed.append((var_name, str(resolved)))
+ logger.info(
+ "docker_env_files: registered %s ← %s", var_name, resolved,
+ )
+ return parsed
- cmd.extend([self._container_id])
+ def _extra_env_for_exec(self) -> dict[str, str]:
+ """Return env vars to overlay onto every ``docker exec`` invocation.
- if login:
- cmd.extend(["bash", "-l", "-c", cmd_string])
- else:
- cmd.extend(["bash", "-c", cmd_string])
+ Hook for per-exec dynamic env injection. The default implementation
+ re-reads the files registered via ``docker_env_files`` so that
+ rotating credentials propagate to the next tool call without
+ requiring the sandbox to respawn.
- return _popen_bash(cmd, stdin_data)
+ Subclasses or sibling subsystems (the credential registry being the
+ canonical example) can override this to inject additional values
+ from any source. Failures are non-fatal: the offending entry is
+ skipped with a warning, the rest still get applied.
+ """
+ out: dict[str, str] = {}
+ # Defensive getattr: tests may construct DockerEnvironment via
+ # __new__ without going through __init__, in which case _env_files
+ # is unset. Treat that as "no entries" rather than crashing.
+ for var_name, file_path in getattr(self, "_env_files", []) or []:
+ value = self._read_env_file_value(var_name, file_path)
+ if value is not None:
+ out[var_name] = value
+ return out
+
+ @classmethod
+ def _read_env_file_value(cls, var_name: str, file_path: str) -> Optional[str]:
+ """Read one credential file with size cap + minimal newline trim.
+
+ Returns the value, or None on any error (logged at WARNING).
+
+ - Caps reads at ``_ENV_FILES_MAX_SIZE`` (64 KiB). Larger files are
+ rejected with an explicit error rather than failing later inside
+ ``execve`` with a confusing E2BIG.
+ - Trims a single trailing newline (and only that — `.strip()` would
+ corrupt PEM bodies, JSON blobs, or any value with significant
+ leading whitespace). The trailing newline trim handles the common
+ ``echo $value > file`` shell pattern.
+ - Does NOT re-resolve symlinks at read time. The path was canonicalized
+ at parse time and stored absolute; reads always go to the resolved
+ target.
+ """
+ try:
+ with open(file_path, "rb") as fh:
+ data = fh.read(cls._ENV_FILES_MAX_SIZE + 1)
+ except OSError as e:
+ logger.warning(
+ "docker_env_files: could not read %s for %s on exec; skipping (%s)",
+ file_path, var_name, e,
+ )
+ return None
+ if len(data) > cls._ENV_FILES_MAX_SIZE:
+ logger.warning(
+ "docker_env_files: %s exceeds %d byte limit (file %s); skipping",
+ var_name, cls._ENV_FILES_MAX_SIZE, file_path,
+ )
+ return None
+ try:
+ value = data.decode("utf-8")
+ except UnicodeDecodeError as e:
+ logger.warning(
+ "docker_env_files: %s is not valid UTF-8 (file %s); skipping (%s)",
+ var_name, file_path, e,
+ )
+ return None
+ # Strip exactly one trailing newline (the common `echo > file` case),
+ # nothing else. PEM bodies, JSON blobs, and base64 with leading
+ # whitespace must round-trip unchanged.
+ if value.endswith("\r\n"):
+ value = value[:-2]
+ elif value.endswith("\n"):
+ value = value[:-1]
+ return value
@staticmethod
def _storage_opt_supported() -> bool:
@@ -530,6 +770,165 @@ def _storage_opt_supported() -> bool:
logger.debug("Docker --storage-opt support: %s", _storage_opt_ok)
return _storage_opt_ok
+ def execute(self, command: str, cwd: str = "", *,
+ timeout: int | None = None,
+ stdin_data: str | None = None) -> dict:
+ exec_command, sudo_stdin = self._prepare_command(command)
+ work_dir = cwd or self.cwd
+ effective_timeout = timeout or self.timeout
+
+ # Merge sudo password (if any) with caller-supplied stdin_data.
+ if sudo_stdin is not None and stdin_data is not None:
+ effective_stdin = sudo_stdin + stdin_data
+ elif sudo_stdin is not None:
+ effective_stdin = sudo_stdin
+ else:
+ effective_stdin = stdin_data
+
+ # docker exec -w doesn't expand ~, so prepend a cd into the command.
+ # Keep ~ unquoted (for shell expansion) and quote only the subpath.
+ if work_dir == "~":
+ exec_command = f"cd ~ && {exec_command}"
+ work_dir = "/"
+ elif work_dir.startswith("~/"):
+ exec_command = f"cd ~/{shlex.quote(work_dir[2:])} && {exec_command}"
+ work_dir = "/"
+
+ assert self._container_id, "Container not started"
+ cmd = [self._docker_exe, "exec"]
+ if effective_stdin is not None:
+ cmd.append("-i")
+ cmd.extend(["-w", work_dir])
+ # Build the per-exec environment: start with explicit docker_env values
+ # (static config), then overlay docker_forward_env / skill env_passthrough
+ # (dynamic from host process). Forward values take precedence.
+ exec_env: dict[str, str] = dict(self._env)
+
+ forward_keys = set(self._forward_env)
+ try:
+ from tools.env_passthrough import get_all_passthrough
+ forward_keys |= get_all_passthrough()
+ except Exception:
+ pass
+ # Strip Hermes-managed secrets so they never leak into the container.
+ forward_keys -= _HERMES_PROVIDER_ENV_BLOCKLIST
+ hermes_env = _load_hermes_env_vars() if forward_keys else {}
+ for key in sorted(forward_keys):
+ value = os.getenv(key)
+ if value is None:
+ value = hermes_env.get(key)
+ if value is not None:
+ exec_env[key] = value
+
+ # Per-exec dynamic env injection — overlay anything the
+ # ``_extra_env_for_exec`` hook returns. The default implementation
+ # re-reads ``docker_env_files`` so rotating credentials (e.g.
+ # BW_SESSION written by a Bitwarden sidecar) propagate to the next
+ # tool call without requiring the sandbox to respawn. Subclasses
+ # and sibling subsystems can override the hook to plug in any
+ # other dynamic source. Failures inside the hook are non-fatal:
+ # offending entries are skipped with a warning, rest are applied.
+ try:
+ extra = self._extra_env_for_exec()
+ except Exception as e:
+ logger.warning("_extra_env_for_exec raised, skipping all dynamic env: %s", e)
+ extra = {}
+ exec_env.update(extra)
+ # Track which keys came from the dynamic-injection hook so the
+ # log-line masker below can redact them by *origin* rather than
+ # by name-keyword heuristic. Anything coming through the hook is
+ # by construction a credential (otherwise why inject it per-exec?),
+ # regardless of whether its name happens to contain "TOKEN" /
+ # "SECRET" / etc.
+ _dynamic_keys = set(extra.keys())
+
+ for key in sorted(exec_env):
+ cmd.extend(["-e", f"{key}={exec_env[key]}"])
+ cmd.extend([self._container_id, "bash", "-lc", exec_command])
+
+ # Log the exact exec command with secret values masked. Two
+ # masking rules:
+ # 1. Origin: anything from `_extra_env_for_exec` is always
+ # masked (the credential registry / docker_env_files path).
+ # 2. Name heuristic: env names whose UPPERCASE form contains a
+ # sensitive token are masked. The list is conservative —
+ # false positives just over-redact a log line, false
+ # negatives leak a credential. Add more aggressively than
+ # reluctantly. SESSION/AUTH/COOKIE/JWT/BEARER/SIGNATURE/PIN
+ # were missing from the original list and are now included.
+ _SENSITIVE = {
+ "TOKEN", "KEY", "SECRET", "PASSWORD", "PASSWD",
+ "CREDENTIAL", "SESSION", "AUTH", "COOKIE",
+ "JWT", "BEARER", "SIGNATURE", "PIN", "PASSPHRASE",
+ "PRIVATE",
+ }
+ def _mask(k, v):
+ if k in _dynamic_keys:
+ return "***"
+ if any(s in k.upper() for s in _SENSITIVE):
+ return "***"
+ return v
+ logged_cmd = []
+ i = 0
+ while i < len(cmd):
+ if cmd[i] == "-e" and i + 1 < len(cmd) and "=" in cmd[i + 1]:
+ k, _, v = cmd[i + 1].partition("=")
+ logged_cmd.extend(["-e", f"{k}={_mask(k, v)}"])
+ i += 2
+ else:
+ logged_cmd.append(cmd[i])
+ i += 1
+ logger.warning("docker exec cmd: %s", " ".join(logged_cmd))
+
+ try:
+ _output_chunks = []
+ proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ stdin=subprocess.PIPE if effective_stdin else subprocess.DEVNULL,
+ text=True,
+ )
+ if effective_stdin:
+ try:
+ proc.stdin.write(effective_stdin)
+ proc.stdin.close()
+ except Exception:
+ pass
+
+ def _drain():
+ try:
+ for line in proc.stdout:
+ _output_chunks.append(line)
+ except Exception:
+ pass
+
+ reader = threading.Thread(target=_drain, daemon=True)
+ reader.start()
+ deadline = time.monotonic() + effective_timeout
+
+ while proc.poll() is None:
+ if is_interrupted():
+ proc.terminate()
+ try:
+ proc.wait(timeout=1)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ reader.join(timeout=2)
+ return {
+ "output": "".join(_output_chunks) + "\n[Command interrupted]",
+ "returncode": 130,
+ }
+ if time.monotonic() > deadline:
+ proc.kill()
+ reader.join(timeout=2)
+ return self._timeout_result(effective_timeout)
+ time.sleep(0.2)
+
+ reader.join(timeout=5)
+ return {"output": "".join(_output_chunks), "returncode": proc.returncode}
+ except Exception as e:
+ return {"output": f"Docker execution error: {e}", "returncode": 1}
+
def cleanup(self):
"""Stop and remove the container. Bind-mount dirs persist if persistent=True."""
if self._container_id:
diff --git a/tools/memory_tool.py b/tools/memory_tool.py
index 1feee269ab51..a78ea26d82c7 100644
--- a/tools/memory_tool.py
+++ b/tools/memory_tool.py
@@ -442,15 +442,38 @@ def memory_tool(
content: str = None,
old_text: str = None,
store: Optional[MemoryStore] = None,
+ is_subagent: bool = False,
+ subagent_memory_mode: str = "read_only",
) -> str:
"""
Single entry point for the memory tool. Dispatches to MemoryStore methods.
-
+
+ Subagent memory access modes:
+ - "read_only": Subagents can read/query memory but not write (add/replace/remove blocked)
+ - "full": Subagents have full read/write access (same as parent)
+ - "none": Subagents cannot access memory at all
+
Returns JSON string with results.
"""
if store is None:
- return tool_error("Memory is not available. It may be disabled in config or this environment.", success=False)
-
+ return json.dumps({"success": False, "error": "Memory is not available. It may be disabled in config or this environment."}, ensure_ascii=False)
+
+ # Enforce subagent memory access mode
+ if is_subagent and subagent_memory_mode != "full":
+ if action in ("add", "replace", "remove"):
+ if subagent_memory_mode == "none":
+ return json.dumps({
+ "success": False,
+ "error": f"Memory tool access denied for subagent (mode: {subagent_memory_mode}). "
+ f"Only parent agent can write to memory."
+ }, ensure_ascii=False)
+ elif subagent_memory_mode == "read_only":
+ return json.dumps({
+ "success": False,
+ "error": f"Memory write operation blocked for subagent in read_only mode. "
+ f"Action '{action}' not permitted. Subagents should return findings to parent agent instead."
+ }, ensure_ascii=False)
+
if target not in ("memory", "user"):
return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False)
@@ -539,7 +562,7 @@ def check_memory_requirements() -> bool:
# --- Registry ---
-from tools.registry import registry, tool_error
+from tools.registry import registry # noqa: E402
registry.register(
name="memory",
diff --git a/tools/self_nudge_tool.py b/tools/self_nudge_tool.py
new file mode 100644
index 000000000000..821ea5fa35c3
--- /dev/null
+++ b/tools/self_nudge_tool.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""One-shot self-nudge tool for gateway sessions.
+
+Lets the agent arm a single in-memory timer that will later inject a hidden
+continuation turn back into the same session. This is lighter and safer than
+creating a cron job for short-lived follow-up work.
+"""
+
+import json
+
+from tools.registry import registry
+
+
+SELF_NUDGE_SCHEMA = {
+ "name": "self_nudge",
+ "description": (
+ "Arm a one-time self-nudge timer for the current session. When the "
+ "timer fires, Hermes injects a hidden follow-up turn back into this "
+ "same session. Use this instead of cron for short-lived reminders or "
+ "follow-up checks. Only one self-nudge is kept per session; arming a "
+ "new one replaces the previous timer."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "delay_seconds": {
+ "type": "integer",
+ "minimum": 1,
+ "description": (
+ "How many seconds to wait before firing the one-time "
+ "hidden follow-up turn."
+ ),
+ },
+ "note": {
+ "type": "string",
+ "description": (
+ "Optional private reminder to inject into the hidden turn. "
+ "Example: 'Check whether the deploy finished and report "
+ "back if it failed.'"
+ ),
+ },
+ },
+ "required": ["delay_seconds"],
+ },
+}
+
+
+def check_self_nudge_requirements() -> bool:
+ """Tool availability is filtered by platform in model_tools."""
+ return True
+
+
+def self_nudge_tool(args, **kwargs):
+ """Stub dispatcher for the registry.
+
+ Real handling happens in run_agent.py because it needs the live session's
+ gateway callback.
+ """
+ return json.dumps(
+ {"error": "self_nudge must be handled by the agent loop"},
+ ensure_ascii=False,
+ )
+
+
+registry.register(
+ name="self_nudge",
+ toolset="user_updates",
+ schema=SELF_NUDGE_SCHEMA,
+ handler=self_nudge_tool,
+ check_fn=check_self_nudge_requirements,
+ description="Arm a one-shot hidden self-reminder for the current gateway session.",
+ emoji="⏰",
+)
diff --git a/tools/send_user_message_tool.py b/tools/send_user_message_tool.py
new file mode 100644
index 000000000000..9f0b9e2cefbf
--- /dev/null
+++ b/tools/send_user_message_tool.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""In-session user messaging tool.
+
+Lets the agent send a natural-language status update to the current user
+without ending its turn. Unlike ``send_message``, this stays inside the
+current session/thread and is handled by the agent loop via a platform
+callback.
+"""
+
+import json
+
+from tools.registry import registry
+
+
+SEND_USER_MESSAGE_SCHEMA = {
+ "name": "send_user_message",
+ "description": (
+ "Send a natural-language message to the current user in the current "
+ "session without ending your turn. Use this for concise progress "
+ "updates before or between tool calls, such as briefly stating your "
+ "plan, reporting what you are doing, or flagging an important status "
+ "change while you continue working. Keep messages short and useful."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string",
+ "description": (
+ "The message to send to the current user in natural "
+ "language. Example: 'I found the relevant files and I am "
+ "patching the config path next.'"
+ ),
+ }
+ },
+ "required": ["message"],
+ },
+}
+
+
+def check_send_user_message_requirements() -> bool:
+ """Tool is schema-available on interactive platforms."""
+ return True
+
+
+def send_user_message_tool(args, **kwargs):
+ """Stub dispatcher for the registry.
+
+ Real handling happens in run_agent.py because it needs the live platform
+ callback for the current session.
+ """
+ return json.dumps(
+ {"error": "send_user_message must be handled by the agent loop"},
+ ensure_ascii=False,
+ )
+
+
+registry.register(
+ name="send_user_message",
+ toolset="user_updates",
+ schema=SEND_USER_MESSAGE_SCHEMA,
+ handler=send_user_message_tool,
+ check_fn=check_send_user_message_requirements,
+ description="Send a short in-session progress update to the current user.",
+ emoji="💬",
+)
diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py
index 97a4bf5aa590..2cd887e6929d 100644
--- a/tools/skill_manager_tool.py
+++ b/tools/skill_manager_tool.py
@@ -204,8 +204,20 @@ def _find_skill(name: str) -> Optional[Dict[str, Any]]:
external dirs configured via skills.external_dirs. Returns
{"path": Path} or None.
"""
- from agent.skill_utils import get_all_skills_dirs
- for skills_dir in get_all_skills_dirs():
+ # Use the module-level SKILLS_DIR first so tests and callers that
+ # monkeypatch it get the expected local-first behavior. Then append any
+ # configured external dirs.
+ all_dirs = [SKILLS_DIR]
+ try:
+ from agent.skill_utils import get_external_skills_dirs
+
+ for ext_dir in get_external_skills_dirs():
+ if ext_dir not in all_dirs:
+ all_dirs.append(ext_dir)
+ except Exception:
+ pass
+
+ for skills_dir in all_dirs:
if not skills_dir.exists():
continue
for skill_md in skills_dir.rglob("SKILL.md"):
diff --git a/tools/subagent_workspace.py b/tools/subagent_workspace.py
new file mode 100644
index 000000000000..2f5b19bc70ae
--- /dev/null
+++ b/tools/subagent_workspace.py
@@ -0,0 +1,222 @@
+"""Workspace visibility policies for delegated subagents.
+
+This module turns high-level child workspace requests into task-specific
+terminal overrides that the Docker backend can honor safely.
+"""
+
+from __future__ import annotations
+
+import os
+import posixpath
+import uuid
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+
+ALLOWED_WORKSPACE_VISIBILITY = frozenset({
+ "inherit",
+ "full_rw",
+ "full_ro",
+ "mapped",
+})
+
+MAX_WORKSPACE_MAPPINGS = 8
+
+
+def resolve_parent_workspace_root() -> Path:
+ """Return the parent workspace root on the host filesystem.
+
+ Tries TERMINAL_CWD first, falls back to HERMES_HOME (~/.hermes by default).
+ TERMINAL_CWD often points to a path inside the sandbox container (e.g.
+ /workspace) that does not exist on the gateway host, so the fallback
+ ensures the feature works in standard deployments.
+ """
+ from hermes_constants import get_hermes_home
+
+ raw = os.getenv("TERMINAL_CWD") or ""
+ if raw:
+ candidate = Path(raw).expanduser().resolve()
+ if candidate.exists() and candidate.is_dir():
+ return candidate
+
+ root = get_hermes_home().resolve()
+ if not root.exists() or not root.is_dir():
+ raise ValueError(
+ f"Cannot use delegated workspace visibility: neither TERMINAL_CWD nor "
+ f"HERMES_HOME ({root}) is a valid directory on this host."
+ )
+ return root
+
+
+def resolve_terminal_backend() -> str:
+ """Resolve the configured terminal backend without importing large surfaces eagerly."""
+ try:
+ from tools.terminal_tool import _get_env_config
+
+ return str(_get_env_config().get("env_type") or "local").strip() or "local"
+ except Exception:
+ return (os.getenv("TERMINAL_ENV") or "local").strip() or "local"
+
+
+def build_workspace_overrides(
+ visibility: Optional[str],
+ mappings: Optional[List[Dict[str, Any]]],
+ workspace_root: Path,
+ child_token: str,
+ backend: str,
+) -> Dict[str, Any]:
+ """Build terminal overrides and prompt text for a delegated child."""
+ mode = str(visibility or "inherit").strip() or "inherit"
+ if mode not in ALLOWED_WORKSPACE_VISIBILITY:
+ allowed = ", ".join(sorted(ALLOWED_WORKSPACE_VISIBILITY))
+ raise ValueError(f"Invalid workspace_visibility '{mode}'. Expected one of: {allowed}.")
+
+ if mode != "mapped" and mappings:
+ raise ValueError("workspace_mappings may only be used when workspace_visibility='mapped'.")
+
+ if mode == "inherit":
+ return {"prompt_note": "", "task_env_overrides": None}
+
+ if backend != "docker":
+ raise ValueError(
+ f"workspace_visibility='{mode}' requires the docker terminal backend; current backend is '{backend}'."
+ )
+
+ workspace_root = workspace_root.resolve()
+ if mode == "full_rw":
+ return _build_mount_override(
+ host_path=workspace_root,
+ container_path="/workspace",
+ read_only=False,
+ prompt_note=(
+ "WORKSPACE VISIBILITY:\n"
+ f"- You can access the parent workspace at /workspace (host root: {workspace_root}).\n"
+ "- The mount is read-write."
+ ),
+ )
+
+ if mode == "full_ro":
+ return _build_mount_override(
+ host_path=workspace_root,
+ container_path="/workspace",
+ read_only=True,
+ prompt_note=(
+ "WORKSPACE VISIBILITY:\n"
+ f"- You can access the parent workspace at /workspace (host root: {workspace_root}).\n"
+ "- The mount is read-only. Copy files elsewhere before editing."
+ ),
+ )
+
+ normalized_mappings = _normalize_mappings(mappings or [], workspace_root)
+ if not normalized_mappings:
+ raise ValueError("workspace_visibility='mapped' requires at least one workspace_mappings entry.")
+
+ volume_specs = []
+ visible_paths = []
+ for item in normalized_mappings:
+ suffix = ":ro" if item["read_only"] else ""
+ volume_specs.append(f"{item['host_path']}:{item['container_path']}{suffix}")
+ visible_paths.append(
+ f"- {item['container_path']} -> {item['host_path'].relative_to(workspace_root)}"
+ + (" (read-only)" if item["read_only"] else "")
+ )
+
+ return {
+ "prompt_note": "WORKSPACE VISIBILITY:\n- You only see these mapped paths:\n" + "\n".join(visible_paths),
+ "task_env_overrides": {
+ "cwd": "/workspace",
+ "host_cwd": None,
+ "docker_mount_cwd_to_workspace": False,
+ "docker_volumes": volume_specs,
+ },
+ }
+
+
+def _build_mount_override(
+ *,
+ host_path: Path,
+ container_path: str,
+ read_only: bool,
+ prompt_note: str,
+) -> Dict[str, Any]:
+ suffix = ":ro" if read_only else ""
+ return {
+ "prompt_note": prompt_note,
+ "task_env_overrides": {
+ "cwd": container_path,
+ "host_cwd": None,
+ "docker_mount_cwd_to_workspace": False,
+ "docker_volumes": [f"{host_path}:{container_path}{suffix}"],
+ },
+ }
+
+
+def _normalize_mappings(
+ mappings: List[Dict[str, Any]],
+ workspace_root: Path,
+) -> List[Dict[str, Any]]:
+ if len(mappings) > MAX_WORKSPACE_MAPPINGS:
+ raise ValueError(
+ f"Too many workspace_mappings entries ({len(mappings)}). Maximum is {MAX_WORKSPACE_MAPPINGS}."
+ )
+
+ normalized = []
+ for index, item in enumerate(mappings):
+ if not isinstance(item, dict):
+ raise ValueError(f"workspace_mappings[{index}] must be an object.")
+
+ source = str(item.get("source") or item.get("host_path") or "").strip()
+ if not source:
+ raise ValueError(f"workspace_mappings[{index}] is missing 'source'.")
+
+ host_path = _resolve_workspace_child_path(source, workspace_root)
+ if not host_path.exists():
+ raise ValueError(
+ f"workspace_mappings[{index}] source does not exist within the workspace: {host_path}"
+ )
+
+ target = str(item.get("target") or item.get("container_path") or "").strip()
+ container_path = _normalize_container_path(target, host_path.name)
+ normalized.append(
+ {
+ "host_path": host_path,
+ "container_path": container_path,
+ "read_only": bool(item.get("read_only", False)),
+ }
+ )
+
+ return normalized
+
+
+def _resolve_workspace_child_path(raw_path: str, workspace_root: Path) -> Path:
+ candidate = Path(raw_path).expanduser()
+ if candidate.is_absolute():
+ resolved = candidate.resolve()
+ else:
+ resolved = (workspace_root / candidate).resolve()
+
+ try:
+ resolved.relative_to(workspace_root)
+ except ValueError as exc:
+ raise ValueError(
+ f"Workspace path escapes the parent workspace: {raw_path}"
+ ) from exc
+
+ return resolved
+
+
+def _normalize_container_path(raw_path: str, fallback_name: str) -> str:
+ target = raw_path or fallback_name
+ if not target:
+ target = f"mapped-{uuid.uuid4().hex[:8]}"
+
+ if target.startswith("/"):
+ normalized = posixpath.normpath(target)
+ else:
+ normalized = posixpath.normpath(posixpath.join("/workspace", target))
+
+ if normalized != "/workspace" and not normalized.startswith("/workspace/"):
+ raise ValueError(
+ f"Mapped container path must stay within /workspace, got '{raw_path}'."
+ )
+ return normalized
diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py
index af35771c8c20..dfcf923ec8cf 100644
--- a/tools/terminal_tool.py
+++ b/tools/terminal_tool.py
@@ -142,10 +142,17 @@ def set_approval_callback(cb):
)
-def _check_all_guards(command: str, env_type: str) -> dict:
+def _check_dangerous_command(command: str, env_type: str) -> dict:
+ """Delegate to the consolidated approval module, passing the CLI callback."""
+ return _check_dangerous_command_impl(command, env_type,
+ approval_callback=_approval_callback)
+
+
+def _check_all_guards(command: str, env_type: str, **kwargs) -> dict:
"""Delegate to consolidated guard (tirith + dangerous cmd) with CLI callback."""
return _check_all_guards_impl(command, env_type,
- approval_callback=_approval_callback)
+ approval_callback=_approval_callback,
+ **kwargs)
# Allowlist: characters that can legitimately appear in directory paths.
@@ -562,6 +569,11 @@ def register_task_env_overrides(task_id: str, overrides: Dict[str, Any]):
- modal_image: str -- Path to Dockerfile or Docker Hub image name
- docker_image: str -- Docker image name
- cwd: str -- Working directory inside the sandbox
+ - host_cwd: str -- Host workspace root for Docker bind mounting
+ - docker_mount_cwd_to_workspace: bool -- Mount host_cwd at /workspace
+ - docker_volumes: list[str] -- Explicit Docker bind mounts for this task
+ - docker_forward_env: list[str] -- Additional env vars to forward
+ - docker_network: str -- Docker network override
Args:
task_id: The rollout's unique task identifier
@@ -600,9 +612,16 @@ def _get_env_config() -> Dict[str, Any]:
"""Get terminal environment configuration from environment variables."""
# Default image with Python and Node.js for maximum compatibility
default_image = "nikolaik/python-nodejs:python3.11-nodejs20"
- env_type = os.getenv("TERMINAL_ENV", "local")
+ # Load config.yaml terminal block to use as fallback before hardcoded defaults
+ try:
+ from hermes_cli.config import load_config
+ cfg = load_config().get("terminal", {})
+ except Exception:
+ cfg = {}
+
+ env_type = os.getenv("TERMINAL_ENV", cfg.get("backend", "local"))
- mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in ("true", "1", "yes")
+ mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", str(cfg.get("docker_mount_cwd_to_workspace", "false"))).lower() in ("true", "1", "yes")
# Default cwd: local uses the host's current directory, everything
# else starts in the user's home (~ resolves to whatever account
@@ -618,7 +637,7 @@ def _get_env_config() -> Dict[str, Any]:
# If Docker cwd passthrough is explicitly enabled, remap the host path to
# /workspace and track the original host path separately. Otherwise keep the
# normal sandbox behavior and discard host paths.
- cwd = os.getenv("TERMINAL_CWD", default_cwd)
+ cwd = os.getenv("TERMINAL_CWD", cfg.get("cwd", default_cwd))
host_cwd = None
host_prefixes = ("/Users/", "/home/", "C:\\", "C:/")
if env_type == "docker" and mount_docker_cwd:
@@ -643,16 +662,20 @@ def _get_env_config() -> Dict[str, Any]:
return {
"env_type": env_type,
"modal_mode": coerce_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")),
- "docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image),
- "docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"),
- "singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
- "modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
- "daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image),
+ "docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", cfg.get("docker_image", default_image)),
+ "docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", json.dumps(cfg.get("docker_forward_env", [])), json.loads, "valid JSON"),
+ "singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", cfg.get("singularity_image", f"docker://{default_image}")),
+ "modal_image": os.getenv("TERMINAL_MODAL_IMAGE", cfg.get("modal_image", default_image)),
+ "daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", cfg.get("daytona_image", default_image)),
"cwd": cwd,
"host_cwd": host_cwd,
"docker_mount_cwd_to_workspace": mount_docker_cwd,
- "timeout": _parse_env_var("TERMINAL_TIMEOUT", "180"),
- "lifetime_seconds": _parse_env_var("TERMINAL_LIFETIME_SECONDS", "300"),
+ "enable_gateway_local": os.getenv(
+ "TERMINAL_ENABLE_GATEWAY_LOCAL",
+ str(cfg.get("enable_gateway_local", "false")),
+ ).lower() in ("true", "1", "yes"),
+ "timeout": _parse_env_var("TERMINAL_TIMEOUT", str(cfg.get("timeout", "180"))),
+ "lifetime_seconds": _parse_env_var("TERMINAL_LIFETIME_SECONDS", str(cfg.get("lifetime_seconds", "300"))),
# SSH-specific config
"ssh_host": os.getenv("TERMINAL_SSH_HOST", ""),
"ssh_user": os.getenv("TERMINAL_SSH_USER", ""),
@@ -663,18 +686,48 @@ def _get_env_config() -> Dict[str, Any]:
# Per-backend env vars override if explicitly set.
"ssh_persistent": os.getenv(
"TERMINAL_SSH_PERSISTENT",
- os.getenv("TERMINAL_PERSISTENT_SHELL", "true"),
+ os.getenv("TERMINAL_PERSISTENT_SHELL", str(cfg.get("persistent_shell", "true"))),
).lower() in ("true", "1", "yes"),
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in ("true", "1", "yes"),
# Container resource config (applies to docker, singularity, modal, daytona -- ignored for local/ssh)
- "container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"),
- "container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB)
- "container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB)
- "container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("true", "1", "yes"),
- "docker_volumes": _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON"),
+ "container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", str(cfg.get("container_cpu", "1")), float, "number"),
+ "container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", str(cfg.get("container_memory", "5120"))), # MB (default 5GB)
+ "container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", str(cfg.get("container_disk", "51200"))), # MB (default 50GB)
+ "container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", str(cfg.get("container_persistent", "true"))).lower() in ("true", "1", "yes"),
+ "docker_volumes": _parse_env_var("TERMINAL_DOCKER_VOLUMES", json.dumps(cfg.get("docker_volumes", [])), json.loads, "valid JSON"),
+ "docker_network": os.getenv("TERMINAL_DOCKER_NETWORK", None),
+ "docker_extra_hosts": _parse_env_var("TERMINAL_DOCKER_EXTRA_HOSTS", json.dumps(cfg.get("docker_extra_hosts", [])), json.loads, "valid JSON"),
+ "docker_env_files": _parse_env_var("TERMINAL_DOCKER_ENV_FILES", json.dumps(cfg.get("docker_env_files", [])), json.loads, "valid JSON"),
+ "docker_env": cfg.get("docker_env", {}),
+ "docker_user": os.getenv("TERMINAL_DOCKER_USER", cfg.get("docker_user", None)),
}
+def can_offer_gateway_local(config: Optional[Dict[str, Any]] = None) -> bool:
+ """Return whether the privileged gateway-local escape hatch may be shown."""
+ cfg = config or _get_env_config()
+ return bool(
+ os.getenv("HERMES_GATEWAY_SESSION")
+ and cfg.get("enable_gateway_local")
+ and cfg.get("env_type") != "local"
+ )
+
+
+def _resolve_gateway_local_cwd() -> str:
+ """Resolve the real gateway-local cwd without sandbox normalization.
+
+ Remote backend config intentionally sanitizes TERMINAL_CWD for container
+ execution. Gateway-local is the opposite: it should execute in the live
+ gateway container/process using the operator-facing workspace context.
+ """
+ base_dir = os.getenv("MESSAGING_CWD") or str(Path.home())
+ raw_cwd = os.getenv("TERMINAL_CWD") or base_dir
+ expanded = os.path.expanduser(raw_cwd)
+ if os.path.isabs(expanded):
+ return expanded
+ return os.path.abspath(os.path.join(base_dir, expanded))
+
+
def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]:
"""Resolve direct vs managed Modal backend selection."""
return resolve_modal_backend_state(
@@ -713,6 +766,10 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
volumes = cc.get("docker_volumes", [])
docker_forward_env = cc.get("docker_forward_env", [])
docker_env = cc.get("docker_env", {})
+ docker_network = cc.get("docker_network", None)
+ docker_extra_hosts = cc.get("docker_extra_hosts", [])
+ docker_env_files = cc.get("docker_env_files", [])
+ docker_user = cc.get("docker_user", None)
if env_type == "local":
return _LocalEnvironment(cwd=cwd, timeout=timeout)
@@ -727,8 +784,12 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
auto_mount_cwd=cc.get("docker_mount_cwd_to_workspace", False),
forward_env=docker_forward_env,
env=docker_env,
+ docker_network=docker_network,
+ extra_hosts=docker_extra_hosts,
+ env_files=docker_env_files,
+ docker_user=docker_user,
)
-
+
elif env_type == "singularity":
return _SingularityEnvironment(
image=image, cwd=cwd, timeout=timeout,
@@ -987,6 +1048,24 @@ def cleanup_all_environments():
return cleaned
+def is_persistent_env(task_id: str) -> bool:
+ """Return True if the active environment for task_id is configured for
+ cross-turn persistence (``persistent_filesystem=True``).
+
+ Used by the agent loop to skip per-turn teardown for backends whose whole
+ point is to survive between turns (docker with ``container_persistent``,
+ daytona, modal, etc.). Non-persistent backends (e.g. Morph) still get torn
+ down at end-of-turn to prevent leakage. The idle reaper
+ (``_cleanup_inactive_envs``) handles persistent envs once they exceed
+ ``terminal.lifetime_seconds``.
+ """
+ with _env_lock:
+ env = _active_environments.get(task_id)
+ if env is None:
+ return False
+ return bool(getattr(env, "_persistent", False))
+
+
def cleanup_vm(task_id: str):
"""Manually clean up a specific environment by task_id."""
# Remove from tracking dicts while holding the lock, but defer the
@@ -1135,8 +1214,9 @@ def terminal_tool(
force: bool = False,
workdir: Optional[str] = None,
check_interval: Optional[int] = None,
- pty: bool = False,
notify_on_complete: bool = False,
+ pty: bool = False,
+ gateway_local: bool = False,
) -> str:
"""
Execute a command in the configured terminal environment.
@@ -1150,7 +1230,9 @@ def terminal_tool(
workdir: Working directory for this command (optional, uses session cwd if not set)
check_interval: Seconds between auto-checks for background processes (gateway only, min 30)
pty: If True, use pseudo-terminal for interactive CLI tools (local backend only)
- notify_on_complete: If True and background=True, auto-notify the agent when the process exits
+ gateway_local: If True, run a one-shot command directly inside the
+ gateway container/process instead of the configured remote sandbox.
+ Gateway-only, disabled by default, and requires explicit approval.
Returns:
str: JSON string with output, exit_code, and error fields
@@ -1184,6 +1266,8 @@ def terminal_tool(
# Get configuration
config = _get_env_config()
env_type = config["env_type"]
+ requested_env_type = env_type
+ pre_exec_notice = ""
# Use task_id for environment isolation
effective_task_id = task_id or "default"
@@ -1207,6 +1291,46 @@ def terminal_tool(
cwd = overrides.get("cwd") or config["cwd"]
default_timeout = config["timeout"]
effective_timeout = timeout or default_timeout
+ use_gateway_local = bool(gateway_local)
+
+ if use_gateway_local:
+ if not config.get("enable_gateway_local"):
+ return json.dumps({
+ "output": "",
+ "exit_code": -1,
+ "error": "gateway_local is disabled. Enable terminal.enable_gateway_local first."
+ }, ensure_ascii=False)
+ if not os.getenv("HERMES_GATEWAY_SESSION"):
+ return json.dumps({
+ "output": "",
+ "exit_code": -1,
+ "error": "gateway_local is only available in gateway sessions."
+ }, ensure_ascii=False)
+ if requested_env_type == "local":
+ return json.dumps({
+ "output": "",
+ "exit_code": -1,
+ "error": "gateway_local is unavailable when terminal.backend is already local."
+ }, ensure_ascii=False)
+ if background:
+ return json.dumps({
+ "output": "",
+ "exit_code": -1,
+ "error": "gateway_local does not support background execution."
+ }, ensure_ascii=False)
+ if pty:
+ return json.dumps({
+ "output": "",
+ "exit_code": -1,
+ "error": "gateway_local does not support PTY mode."
+ }, ensure_ascii=False)
+ env_type = "local"
+ image = ""
+ cwd = _resolve_gateway_local_cwd()
+ pre_exec_notice = (
+ f"Running directly in the Hermes gateway container instead of the "
+ f"configured {requested_env_type} sandbox."
+ )
# Start cleanup thread
_start_cleanup_thread()
@@ -1215,13 +1339,24 @@ def terminal_tool(
# Use a per-task creation lock so concurrent tool calls for the same
# task_id wait for the first one to finish creating the sandbox,
# instead of each creating their own (wasting Modal resources).
- with _env_lock:
- if effective_task_id in _active_environments:
- _last_activity[effective_task_id] = time.time()
- env = _active_environments[effective_task_id]
- needs_creation = False
- else:
- needs_creation = True
+ if use_gateway_local:
+ env = _create_environment(
+ env_type="local",
+ image="",
+ cwd=cwd,
+ timeout=effective_timeout,
+ local_config={"persistent": False},
+ task_id=effective_task_id,
+ )
+ needs_creation = False
+ else:
+ with _env_lock:
+ if effective_task_id in _active_environments:
+ _last_activity[effective_task_id] = time.time()
+ env = _active_environments[effective_task_id]
+ needs_creation = False
+ else:
+ needs_creation = True
if needs_creation:
# Per-task lock: only one thread creates the sandbox, others wait
@@ -1261,8 +1396,35 @@ def terminal_tool(
"container_disk": config.get("container_disk", 51200),
"container_persistent": config.get("container_persistent", True),
"modal_mode": config.get("modal_mode", "auto"),
- "docker_volumes": config.get("docker_volumes", []),
- "docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
+ "docker_volumes": overrides.get("docker_volumes", config.get("docker_volumes", [])),
+ "docker_mount_cwd_to_workspace": overrides.get(
+ "docker_mount_cwd_to_workspace",
+ config.get("docker_mount_cwd_to_workspace", False),
+ ),
+ "docker_forward_env": overrides.get(
+ "docker_forward_env",
+ config.get("docker_forward_env", []),
+ ),
+ "docker_network": overrides.get(
+ "docker_network",
+ config.get("docker_network", None),
+ ),
+ "docker_extra_hosts": overrides.get(
+ "docker_extra_hosts",
+ config.get("docker_extra_hosts", []),
+ ),
+ "docker_env_files": overrides.get(
+ "docker_env_files",
+ config.get("docker_env_files", []),
+ ),
+ "docker_env": overrides.get(
+ "docker_env",
+ config.get("docker_env", {}),
+ ),
+ "docker_user": overrides.get(
+ "docker_user",
+ config.get("docker_user", None),
+ ),
}
local_config = None
@@ -1280,7 +1442,7 @@ def terminal_tool(
container_config=container_config,
local_config=local_config,
task_id=effective_task_id,
- host_cwd=config.get("host_cwd"),
+ host_cwd=overrides.get("host_cwd", config.get("host_cwd")),
)
except ImportError as e:
return json.dumps({
@@ -1300,7 +1462,24 @@ def terminal_tool(
# Skip check if force=True (user has confirmed they want to run it)
approval_note = None
if not force:
- approval = _check_all_guards(command, env_type)
+ extra_warnings = None
+ disable_smart_approval = False
+ if use_gateway_local:
+ extra_warnings = [{
+ "pattern_key": "gateway_local_execution",
+ "description": (
+ "run command directly in the Hermes gateway container "
+ f"instead of the configured {requested_env_type} sandbox"
+ ),
+ "session_only": True,
+ }]
+ disable_smart_approval = True
+ approval = _check_all_guards(
+ command,
+ env_type,
+ extra_warnings=extra_warnings,
+ disable_smart_approval=disable_smart_approval,
+ )
if not approval["approved"]:
# Check if this is an approval_required (gateway ask mode)
if approval.get("status") == "approval_required":
@@ -1323,7 +1502,7 @@ def terminal_tool(
"output": "",
"exit_code": -1,
"error": approval.get("message", fallback_msg),
- "status": "blocked"
+ "status": "blocked"
}, ensure_ascii=False)
# Track whether approval was explicitly granted by the user
if approval.get("user_approved"):
@@ -1332,6 +1511,13 @@ def terminal_tool(
elif approval.get("smart_approved"):
desc = approval.get("description", "flagged as dangerous")
approval_note = f"Command was flagged ({desc}) and auto-approved by smart approval."
+ approval_message = (approval.get("message") or "").strip()
+ if approval_message:
+ pre_exec_notice = (
+ f"{pre_exec_notice}\n\n{approval_message}".strip()
+ if pre_exec_notice
+ else approval_message
+ )
# Validate workdir against shell injection
if workdir:
@@ -1386,13 +1572,18 @@ def terminal_tool(
session_key=session_key,
)
+ result_output = "Background process started"
+ if pre_exec_notice:
+ result_output = f"{pre_exec_notice}\n\n{result_output}"
result_data = {
- "output": "Background process started",
+ "output": result_output,
"session_id": proc_session.id,
"pid": proc_session.pid,
"exit_code": 0,
"error": None,
}
+ if use_gateway_local:
+ result_data["execution_scope"] = "gateway_local"
if approval_note:
result_data["approval"] = approval_note
if pty_disabled_reason:
@@ -1512,6 +1703,8 @@ def terminal_tool(
# Add helpful message for sudo failures in messaging context
output = _handle_sudo_failure(output, env_type)
+ if pre_exec_notice:
+ output = f"{pre_exec_notice}\n\n{output}" if output else pre_exec_notice
# Truncate output if too long, keeping both head and tail
MAX_OUTPUT_CHARS = 50000
@@ -1543,6 +1736,8 @@ def terminal_tool(
"exit_code": returncode,
"error": None,
}
+ if use_gateway_local:
+ result_dict["execution_scope"] = "gateway_local"
if approval_note:
result_dict["approval"] = approval_note
if exit_note:
@@ -1568,6 +1763,9 @@ def check_terminal_requirements() -> bool:
config = _get_env_config()
env_type = config["env_type"]
+ if can_offer_gateway_local(config):
+ return True
+
try:
if env_type == "local":
return True
@@ -1745,14 +1943,19 @@ def check_terminal_requirements() -> bool:
"description": "Seconds between automatic status checks for background processes (gateway/messaging only, minimum 30). When set, I'll proactively report progress.",
"minimum": 30
},
+ "notify_on_complete": {
+ "type": "boolean",
+ "description": "If true (and background=true), auto-notify the agent when the process exits. Great for builds, test suites, deployments — anything that takes more than a minute where you want to keep working and be told when it's done.",
+ "default": False
+ },
"pty": {
"type": "boolean",
"description": "Run in pseudo-terminal (PTY) mode for interactive CLI tools like Codex, Claude Code, or Python REPL. Only works with local and SSH backends. Default: false.",
"default": False
},
- "notify_on_complete": {
+ "gateway_local": {
"type": "boolean",
- "description": "When true (and background=true), you'll be automatically notified when the process finishes — no polling needed. Use this for tasks that take a while (tests, builds, deployments) so you can keep working on other things in the meantime.",
+ "description": "Gateway-only escape hatch: run this one command directly inside the Hermes gateway container instead of the configured remote sandbox. Disabled unless terminal.enable_gateway_local is turned on, unavailable when the backend is already local, and always requires explicit approval. Default: false.",
"default": False
}
},
@@ -1769,8 +1972,9 @@ def _handle_terminal(args, **kw):
task_id=kw.get("task_id"),
workdir=args.get("workdir"),
check_interval=args.get("check_interval"),
- pty=args.get("pty", False),
notify_on_complete=args.get("notify_on_complete", False),
+ pty=args.get("pty", False),
+ gateway_local=args.get("gateway_local", False),
)
diff --git a/toolsets.py b/toolsets.py
index a786ee7c6637..efcef13fcdc6 100644
--- a/toolsets.py
+++ b/toolsets.py
@@ -52,8 +52,12 @@
"session_search",
# Clarifying questions
"clarify",
+ # Natural-language progress updates to the current user
+ "send_user_message",
+ # One-shot hidden follow-up timer for gateway sessions
+ "self_nudge",
# Code execution + delegation
- "execute_code", "delegate_task",
+ "execute_code", "delegate_task", "list_models",
# Cronjob management
"cronjob",
# Cross-platform messaging (gated on gateway running via check_fn)
@@ -179,6 +183,12 @@
"tools": ["clarify"],
"includes": []
},
+
+ "user_updates": {
+ "description": "Send short progress updates to the current user and arm one-shot hidden follow-up nudges",
+ "tools": ["send_user_message", "self_nudge"],
+ "includes": []
+ },
"code_execution": {
"description": "Run Python scripts that call tools programmatically (reduces LLM round trips)",
@@ -188,7 +198,7 @@
"delegation": {
"description": "Spawn subagents with isolated context for complex subtasks",
- "tools": ["delegate_task"],
+ "tools": ["delegate_task", "list_models"],
"includes": []
},
@@ -237,13 +247,14 @@
"browser_vision", "browser_console",
"todo", "memory",
"session_search",
- "execute_code", "delegate_task",
+ "send_user_message",
+ "execute_code", "delegate_task", "list_models",
],
"includes": []
},
"hermes-api-server": {
- "description": "OpenAI-compatible API server — full agent tools accessible via HTTP (no interactive UI tools like clarify or send_message)",
+ "description": "OpenAI-compatible API server — full agent tools accessible via HTTP (no interactive UI tools like clarify, send_user_message, self_nudge, or send_message)",
"tools": [
# Web
"web_search", "web_extract",
@@ -265,7 +276,7 @@
# Session history search
"session_search",
# Code execution + delegation
- "execute_code", "delegate_task",
+ "execute_code", "delegate_task", "list_models",
# Cronjob management
"cronjob",
# Home Assistant smart home control (gated on HASS_TOKEN via check_fn)