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)