diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 228ee3396464..7fb10b3dfbf8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,9 +16,13 @@ on: permissions: contents: read +# Top-level concurrency: do NOT cancel in-flight builds when a new push lands. +# Every commit deserves its own SHA-tagged image in the registry, and we guard +# the :latest tag in a separate job below (with its own concurrency group) so +# a slow run can't clobber :latest with older bits. concurrency: group: docker-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: build-and-push: @@ -26,11 +30,18 @@ jobs: if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest timeout-minutes: 60 + outputs: + pushed_sha_tag: ${{ steps.mark_pushed.outputs.pushed }} steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: recursive + # Fetch enough history to run `git merge-base --is-ancestor` in the + # move-latest job. That job reuses this checkout via its own + # actions/checkout call, but commits reachable from main up to ~1000 + # back are plenty for any realistic race window. + fetch-depth: 1000 - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 @@ -74,7 +85,12 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Push multi-arch image (main branch) + # Always push a per-commit SHA tag on main. This is race-free because + # every commit has a unique SHA — concurrent runs can't clobber each + # other here. We also embed the git SHA as an OCI label so the + # move-latest job (below) can read it back off the registry's `:latest`. + - name: Push multi-arch image with SHA tag (main branch) + id: push_sha if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: @@ -82,10 +98,17 @@ jobs: file: Dockerfile push: true platforms: linux/amd64,linux/arm64 - tags: nousresearch/hermes-agent:latest + tags: nousresearch/hermes-agent:sha-${{ github.sha }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max + - name: Mark SHA tag pushed + id: mark_pushed + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: echo "pushed=true" >> "$GITHUB_OUTPUT" + - name: Push multi-arch image (release) if: github.event_name == 'release' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 @@ -97,3 +120,119 @@ jobs: tags: nousresearch/hermes-agent:${{ github.event.release.tag_name }} cache-from: type=gha cache-to: type=gha,mode=max + + # Second job: moves `:latest` to point at the SHA tag the first job pushed. + # + # Has its own concurrency group with `cancel-in-progress: true`, which + # gives us the serialization we need: if a newer push arrives while an + # older run is mid-way through this job, the older run is cancelled + # before it can clobber `:latest`. Combined with the ancestor check + # below, this means `:latest` only ever moves forward in git history. + move-latest: + if: | + github.repository == 'NousResearch/hermes-agent' + && github.event_name == 'push' + && github.ref == 'refs/heads/main' + && needs.build-and-push.outputs.pushed_sha_tag == 'true' + needs: build-and-push + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: docker-move-latest-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 1000 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Read the git revision label off the current `:latest` manifest, then + # use `git merge-base --is-ancestor` to check whether our commit is a + # descendant of it. If `:latest` doesn't exist yet, or its label is + # missing, we treat that as "safe to publish". If another run already + # advanced `:latest` past us (or diverged), we skip and leave it alone. + - name: Decide whether to move :latest + id: latest_check + run: | + set -euo pipefail + image=nousresearch/hermes-agent + + # Pull the JSON for the linux/amd64 sub-manifest's config and extract + # the OCI revision label with jq — Go template field access can't + # handle dots in map keys, so using json+jq is the robust route. + image_json=$( + docker buildx imagetools inspect "${image}:latest" \ + --format '{{ json (index .Image "linux/amd64") }}' \ + 2>/dev/null || true + ) + + if [ -z "${image_json}" ]; then + echo "No existing :latest (or inspect failed) — safe to publish." + echo "push_latest=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + current_sha=$( + printf '%s' "${image_json}" \ + | jq -r '.config.Labels."org.opencontainers.image.revision" // ""' + ) + + if [ -z "${current_sha}" ]; then + echo "Registry :latest has no revision label — safe to publish." + echo "push_latest=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Registry :latest is at ${current_sha}" + echo "This run is at ${GITHUB_SHA}" + + if [ "${current_sha}" = "${GITHUB_SHA}" ]; then + echo ":latest already points at our SHA — nothing to do." + echo "push_latest=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Make sure we have the :latest commit locally for merge-base. + if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then + git fetch --no-tags --prune origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + || true + fi + + if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then + echo "Registry :latest points at an unknown commit (${current_sha}); refusing to overwrite." + echo "push_latest=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Our SHA must be a descendant of the current :latest to be safe. + if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then + echo "Our commit is a descendant of :latest — safe to advance." + echo "push_latest=true" >> "$GITHUB_OUTPUT" + else + echo "Another run advanced :latest past us (or diverged) — leaving it alone." + echo "push_latest=false" >> "$GITHUB_OUTPUT" + fi + + # Retag the already-pushed SHA manifest as :latest. This is a registry- + # side operation — no rebuild, no layer re-push — so it's quick and + # atomic per-tag. The ancestor check above plus the cancel-in-progress + # concurrency on this job together guarantee we only ever move :latest + # forward in git history. + - name: Move :latest to this SHA + if: steps.latest_check.outputs.push_latest == 'true' + run: | + set -euo pipefail + image=nousresearch/hermes-agent + docker buildx imagetools create \ + --tag "${image}:latest" \ + "${image}:sha-${GITHUB_SHA}" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000000..a724dfef8981 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,151 @@ +name: Lint (ruff + ty) + +# Surface ruff and ty diagnostics as a diff vs the target branch. +# This check is advisory only ATM it always exits zero and never blocks merge. +# It posts a Markdown summary to the workflow run and, for pull requests, +# comments the same summary on the PR. + +on: + push: + branches: [main] + paths-ignore: + - "**/*.md" + - "docs/**" + - "website/**" + pull_request: + branches: [main] + paths-ignore: + - "**/*.md" + - "docs/**" + - "website/**" + +permissions: + contents: read + pull-requests: write # needed to post/update PR comments + +concurrency: + group: lint-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-diff: + name: ruff + ty diff + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 # need full history for merge-base + worktree + + - name: Install uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + + - name: Install ruff + ty + run: | + uv tool install ruff + uv tool install ty + + - name: Determine base ref + id: base + run: | + # For PRs, diff against the merge base with the target branch. + # For pushes to main, diff against the previous commit on main. + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD) + BASE_REF="origin/${{ github.base_ref }}" + else + BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD) + BASE_REF="HEAD~1" + fi + echo "sha=${BASE_SHA}" >> "$GITHUB_OUTPUT" + echo "ref=${BASE_REF}" >> "$GITHUB_OUTPUT" + echo "Base SHA: ${BASE_SHA}" + echo "Base ref: ${BASE_REF}" + + - name: Run ruff + ty on HEAD + run: | + mkdir -p .lint-reports/head + ruff check --output-format json --exit-zero \ + > .lint-reports/head/ruff.json || true + ty check --output-format gitlab --exit-zero \ + > .lint-reports/head/ty.json || true + echo "HEAD ruff: $(wc -c < .lint-reports/head/ruff.json) bytes" + echo "HEAD ty: $(wc -c < .lint-reports/head/ty.json) bytes" + + - name: Run ruff + ty on base (via git worktree) + run: | + mkdir -p .lint-reports/base + # Use a worktree so we don't clobber the main checkout. If the basex + # SHA is identical to HEAD (e.g. first commit), skip and leave the + # base reports empty — the diff script handles missing files. + HEAD_SHA=$(git rev-parse HEAD) + BASE_SHA="${{ steps.base.outputs.sha }}" + if [ "$BASE_SHA" = "$HEAD_SHA" ]; then + echo "Base SHA == HEAD SHA, skipping base scan." + echo '[]' > .lint-reports/base/ruff.json + echo '[]' > .lint-reports/base/ty.json + else + git worktree add --detach /tmp/lint-base "$BASE_SHA" + ( + cd /tmp/lint-base + ruff check --output-format json --exit-zero \ + > "$GITHUB_WORKSPACE/.lint-reports/base/ruff.json" || true + ty check --output-format gitlab --exit-zero \ + > "$GITHUB_WORKSPACE/.lint-reports/base/ty.json" || true + ) + git worktree remove --force /tmp/lint-base + fi + echo "base ruff: $(wc -c < .lint-reports/base/ruff.json) bytes" + echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes" + + - name: Generate diff summary + run: | + python scripts/lint_diff.py \ + --base-ruff .lint-reports/base/ruff.json \ + --head-ruff .lint-reports/head/ruff.json \ + --base-ty .lint-reports/base/ty.json \ + --head-ty .lint-reports/head/ty.json \ + --base-ref "${{ steps.base.outputs.ref }}" \ + --head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \ + --output .lint-reports/summary.md + cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload reports as artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: lint-reports + path: .lint-reports/ + retention-days: 14 + + - name: Post / update PR comment + if: github.event_name == 'pull_request' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('.lint-reports/summary.md', 'utf8'); + const marker = ''; + const fullBody = marker + '\n' + body; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: fullBody, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: fullBody, + }); + } diff --git a/AGENTS.md b/AGENTS.md index b77a1d269990..0c8550d459d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ hermes-agent/ ├── plugins/ # Plugin system (see "Plugins" section below) │ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...) │ ├── context_engine/ # Context-engine plugins +│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...) │ ├── kanban/ # Multi-agent board dispatcher + worker plugin │ ├── hermes-achievements/ # Gamified achievement tracking │ ├── observability/ # Metrics / traces / logs plugin @@ -512,6 +513,31 @@ generic plugin surface (new hook, new ctx method) — never hardcode plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded honcho argparse from `main.py` for exactly this reason. +### Model-provider plugins (`plugins/model-providers//`) + +Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …) +ships as a plugin here. Each plugin's `__init__.py` calls +`providers.register_provider(ProviderProfile(...))` at module load. +`providers/__init__.py._discover_providers()` is a **lazy, separate +discovery system** — scanned on first `get_provider_profile()` or +`list_providers()` call, NOT by the general PluginManager. + +Scan order: +1. Bundled: `/plugins/model-providers//` +2. User: `$HERMES_HOME/plugins/model-providers//` +3. Legacy: `/providers/.py` (back-compat) + +User plugins of the same name override bundled ones — `register_provider()` +is last-writer-wins. This lets third parties swap out any built-in +profile without a repo patch. + +The general PluginManager records `kind: model-provider` manifests but does +NOT import them (would double-instantiate `ProviderProfile`). Plugins +without an explicit `kind:` get auto-coerced via a source-text heuristic +(`register_provider` + `ProviderProfile` in `__init__.py`). + +Full authoring guide: `website/docs/developer-guide/model-provider-plugin.md`. + ### Dashboard / context-engine / image-gen plugin directories `plugins/context_engine/`, `plugins/image_gen/`, `plugins/example-dashboard/`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30d171543bb2..78c608c73a79 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,6 +106,11 @@ hermes chat -q "Hello" ### Run tests ```bash +# Preferred — matches CI (hermetic env, 4 xdist workers); see AGENTS.md +scripts/run_tests.sh + +# Alternative (activate the venv first). The wrapper is still recommended +# for parity with GitHub Actions before you open a PR: pytest tests/ -v ``` @@ -286,16 +291,18 @@ registry.register( ) ``` -Then add the import to `model_tools.py` in the `_modules` list: +**Wire into a toolset (required):** Built-in tools are auto-discovered: any +`tools/*.py` file that contains a top-level `registry.register(...)` call is +imported by `discover_builtin_tools()` in `tools/registry.py` when `model_tools` +loads. There is **no** manual import list in `model_tools.py` to maintain. -```python -_modules = [ - # ... existing modules ... - "tools.my_tool", -] -``` +You must still add the tool name to the appropriate list in `toolsets.py` +(for example `_HERMES_CORE_TOOLS` or a dedicated toolset); otherwise the tool +registers but is never exposed to the agent. If you introduce a new toolset, +add it in `toolsets.py` and wire it into the relevant platform presets. -If it's a new toolset, add it to `toolsets.py` and to the relevant platform presets. +See `AGENTS.md` (section **Adding New Tools**) for profile-aware paths and +plugin vs core guidance. --- @@ -595,7 +602,7 @@ refactor/description # Code restructuring ### Before submitting -1. **Run tests**: `pytest tests/ -v` +1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI) or `pytest tests/ -v` with the project venv activated 2. **Test manually**: Run `hermes` and exercise the code path you changed 3. **Check cross-platform impact**: If you touch file I/O, process management, or terminal handling, consider macOS, Linux, and WSL2 4. **Keep PRs focused**: One logical change per PR. Don't mix a bug fix with a refactor with a new feature. diff --git a/README.md b/README.md index 2674cabe77f4..a28707220e6e 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ uv pip install -e ".[all,dev]" scripts/run_tests.sh ``` -> **RL Training (optional):** The RL/Atropos integration (`environments/`) ships via the `atroposlib` and `tinker` dependencies pulled in by `.[all,dev]` — no submodule setup required. +> **RL Training (optional):** The RL/Atropos integration (`environments/`) — see [`CONTRIBUTING.md`](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#development-setup) for the full setup. --- diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index bb1b33fcc827..eb6b3e79adfa 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -231,33 +231,30 @@ def _supports_fast_mode(model: str) -> bool: return any(v in model for v in _FAST_MODE_SUPPORTED_SUBSTRINGS) -# Beta headers for enhanced features (sent with ALL auth types). -# As of Opus 4.7 (2026-04-16), the first two are GA on Claude 4.6+ — the +# Beta headers for enhanced features that are safe on ordinary/native Anthropic +# requests. As of Opus 4.7 (2026-04-16), these are GA on Claude 4.6+ — the # beta headers are still accepted (harmless no-op) but not required. Kept -# here so older Claude (4.5, 4.1) + third-party Anthropic-compat endpoints -# that still gate on the headers continue to get the enhanced features. +# here so older Claude (4.5, 4.1) + compatible endpoints that still gate on +# the headers continue to get the enhanced features. # -# ``context-1m-2025-08-07`` unlocks the 1M context window on Claude Opus 4.6/4.7 -# and Sonnet 4.6 when served via AWS Bedrock or Azure AI Foundry. 1M is GA on -# native Anthropic (api.anthropic.com) for Opus 4.6+, but Bedrock/Azure still -# gate it behind this beta header as of 2026-04 — without it Bedrock caps Opus -# at 200K even though model_metadata.py advertises 1M. The header is a harmless -# no-op on endpoints where 1M is GA. +# Do NOT include ``context-1m-2025-08-07`` here. Anthropic returns HTTP 400 +# ("long context beta is not yet available for this subscription") for +# accounts without the long-context beta, which breaks normal short auxiliary +# calls like title generation/session summarization. # -# Migration guide: remove these if you no longer support ≤4.5 models or once -# Bedrock/Azure promote 1M to GA. +# ``context-1m-2025-08-07`` is still required to unlock the 1M context window +# on Claude Opus 4.6/4.7 and Sonnet 4.6 when served via AWS Bedrock or Azure +# AI Foundry. Add it only for those endpoint-specific paths below. _COMMON_BETAS = [ "interleaved-thinking-2025-05-14", "fine-grained-tool-streaming-2025-05-14", - "context-1m-2025-08-07", ] # MiniMax's Anthropic-compatible endpoints fail tool-use requests when # the fine-grained tool streaming beta is present. Omit it so tool calls # fall back to the provider's default response path. _TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14" -# 1M context beta — see comment on _COMMON_BETAS above. Stripped for -# Bearer-auth (MiniMax) endpoints since they host their own models and -# unknown Anthropic beta headers risk request rejection. +# 1M context beta. Native Anthropic does not get this by default because some +# subscriptions reject it, but Bedrock/Azure still need it for 1M context. _CONTEXT_1M_BETA = "context-1m-2025-08-07" # Fast mode beta — enables the ``speed: "fast"`` request parameter for @@ -476,6 +473,14 @@ def _requires_bearer_auth(base_url: str | None) -> bool: return normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic")) +def _base_url_needs_context_1m_beta(base_url: str | None) -> bool: + """Return True for endpoints that still gate 1M context behind a beta.""" + normalized = _normalize_base_url_text(base_url).lower() + if not normalized: + return False + return "azure.com" in normalized + + def _common_betas_for_base_url( base_url: str | None, *, @@ -485,27 +490,25 @@ def _common_betas_for_base_url( MiniMax's Anthropic-compatible endpoints (Bearer-auth) reject requests that include Anthropic's ``fine-grained-tool-streaming`` beta — every - tool-use message triggers a connection error. Strip that beta for - Bearer-auth endpoints while keeping all other betas intact. - - The ``context-1m-2025-08-07`` beta is also stripped for Bearer-auth - endpoints — MiniMax hosts its own models, not Claude, so the header is - irrelevant at best and risks request rejection at worst. - - ``drop_context_1m_beta=True`` additionally strips the 1M-context beta on - otherwise-unrelated endpoints. The OAuth retry path flips this flag after - a subscription rejects the beta with - "The long context beta is not yet available for this subscription" so - subsequent requests in the same session don't repeat the probe. See the - reactive recovery loop in ``run_agent.py`` and issue-comment history on - PR #17680 for the full rationale. + tool-use message triggers a connection error. + + The ``context-1m-2025-08-07`` beta is not sent to native Anthropic by + default because some subscriptions reject it. Add it only for endpoint + families that still require it for 1M context, currently Azure AI Foundry. + Bedrock uses its own client helper below and opts in explicitly. + + ``drop_context_1m_beta=True`` strips the 1M-context beta from any path that + would otherwise include it after a subscription/endpoint rejects the beta. """ + betas = list(_COMMON_BETAS) + if _base_url_needs_context_1m_beta(base_url) and not drop_context_1m_beta: + betas.append(_CONTEXT_1M_BETA) if _requires_bearer_auth(base_url): _stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA} - return [b for b in _COMMON_BETAS if b not in _stripped] + return [b for b in betas if b not in _stripped] if drop_context_1m_beta: - return [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] - return _COMMON_BETAS + return [b for b in betas if b != _CONTEXT_1M_BETA] + return betas def build_anthropic_client( @@ -642,7 +645,7 @@ def build_anthropic_bedrock_client(region: str): return _anthropic_sdk.AnthropicBedrock( aws_region=region, timeout=Timeout(timeout=900.0, connect=10.0), - default_headers={"anthropic-beta": ",".join(_COMMON_BETAS)}, + default_headers={"anthropic-beta": ",".join([*_COMMON_BETAS, _CONTEXT_1M_BETA])}, ) diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index c1dc6bb979c0..34eebd73ba8e 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -631,11 +631,18 @@ def normalize_converse_response(response: Dict) -> SimpleNamespace: stop_reason = response.get("stopReason", "end_turn") text_parts = [] + reasoning_parts = [] tool_calls = [] for block in content_blocks: if "text" in block: text_parts.append(block["text"]) + elif "reasoningContent" in block: + reasoning = block["reasoningContent"] + if isinstance(reasoning, dict): + thinking_text = reasoning.get("text", "") + if thinking_text: + reasoning_parts.append(str(thinking_text)) elif "toolUse" in block: tu = block["toolUse"] tool_calls.append(SimpleNamespace( @@ -652,6 +659,7 @@ def normalize_converse_response(response: Dict) -> SimpleNamespace: role="assistant", content="\n".join(text_parts) if text_parts else None, tool_calls=tool_calls if tool_calls else None, + reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None, ) # Build usage stats @@ -732,6 +740,7 @@ def stream_converse_with_callbacks( ``normalize_converse_response()``. """ text_parts: List[str] = [] + reasoning_parts: List[str] = [] tool_calls: List[SimpleNamespace] = [] current_tool: Optional[Dict] = None current_text_buffer: List[str] = [] @@ -777,8 +786,10 @@ def stream_converse_with_callbacks( reasoning = delta["reasoningContent"] if isinstance(reasoning, dict): thinking_text = reasoning.get("text", "") - if thinking_text and on_reasoning_delta: - on_reasoning_delta(thinking_text) + if thinking_text: + reasoning_parts.append(str(thinking_text)) + if on_reasoning_delta: + on_reasoning_delta(thinking_text) elif "contentBlockStop" in event: if current_tool is not None: @@ -817,6 +828,7 @@ def stream_converse_with_callbacks( role="assistant", content="\n".join(text_parts) if text_parts else None, tool_calls=tool_calls if tool_calls else None, + reasoning_content="\n\n".join(reasoning_parts) if reasoning_parts else None, ) usage = SimpleNamespace( diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index 027defa22b9e..457b32b37be7 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -477,8 +477,8 @@ def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | Non proc.stdin.write(json.dumps(payload) + "\n") proc.stdin.flush() - deadline = time.time() + timeout_seconds - while time.time() < deadline: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: if proc.poll() is not None: break try: diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 27a16bd435c9..34c8f6db7718 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -305,14 +305,29 @@ def _iter_custom_providers(config: Optional[dict] = None): yield _normalize_custom_pool_name(name), entry -def get_custom_provider_pool_key(base_url: str) -> Optional[str]: +def get_custom_provider_pool_key(base_url: str, provider_name: Optional[str] = None) -> Optional[str]: """Look up the custom_providers list in config.yaml and return 'custom:' for a matching base_url. + When provider_name is given, prefer matching by name first (solving the case where + multiple custom providers share the same base_url but have different API keys). + Falls back to base_url matching when no name match is found. + Returns None if no match is found. """ if not base_url: return None normalized_url = base_url.strip().rstrip("/") + + # When a provider name is given, try to match by name first. + # This fixes the P1 bug where two custom providers sharing the same + # base_url always resolve to the first one's credentials. + if provider_name: + normalized_name = _normalize_custom_pool_name(provider_name) + for norm_name, entry in _iter_custom_providers(): + if norm_name == normalized_name: + return f"{CUSTOM_POOL_PREFIX}{norm_name}" + + # Fall back to base_url matching (original behavior) for norm_name, entry in _iter_custom_providers(): entry_url = str(entry.get("base_url") or "").strip().rstrip("/") if entry_url and entry_url == normalized_url: diff --git a/agent/display.py b/agent/display.py index 474595d76c06..1dd65c3514f3 100644 --- a/agent/display.py +++ b/agent/display.py @@ -852,13 +852,15 @@ def _trunc(s, n=40): s = str(s) if _tool_preview_max_len == 0: return s # no limit - return (s[:n-3] + "...") if len(s) > n else s + limit = _tool_preview_max_len + return (s[:limit-3] + "...") if len(s) > limit else s def _path(p, n=35): p = str(p) if _tool_preview_max_len == 0: return p # no limit - return ("..." + p[-(n-3):]) if len(p) > n else p + limit = _tool_preview_max_len + return ("..." + p[-(limit-3):]) if len(p) > limit else p def _wrap(line: str) -> str: """Apply skin tool prefix and failure suffix.""" diff --git a/agent/image_routing.py b/agent/image_routing.py index bd2ba83c87ac..0b6687787a08 100644 --- a/agent/image_routing.py +++ b/agent/image_routing.py @@ -144,7 +144,51 @@ def decide_image_input_mode( # it fires, which is cheaper than permanent quality loss. -def _guess_mime(path: Path) -> str: +def _sniff_mime_from_bytes(raw: bytes) -> Optional[str]: + """Detect image MIME from magic bytes. Returns None if unrecognised. + + Filename-based detection (``mimetypes.guess_type``) is unreliable when + upstream platforms lie about content-type. Discord, for example, can + serve a PNG with ``content_type=image/webp`` for proxied/animated + stickers, custom emoji previews, or images uploaded via certain bots. + Anthropic strictly validates that declared media_type matches the + actual bytes and returns HTTP 400 on mismatch, so we sniff to be safe. + """ + if not raw: + return None + # PNG: 89 50 4E 47 0D 0A 1A 0A + if raw.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + # JPEG: FF D8 FF + if raw.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + # GIF87a / GIF89a + if raw[:6] in (b"GIF87a", b"GIF89a"): + return "image/gif" + # WEBP: "RIFF" .... "WEBP" + if len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP": + return "image/webp" + # BMP: "BM" + if raw.startswith(b"BM"): + return "image/bmp" + # HEIC/HEIF: ftypheic / ftypheix / ftypmif1 / ftypmsf1 etc. + if len(raw) >= 12 and raw[4:8] == b"ftyp" and raw[8:12] in ( + b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim", b"heis", + ): + return "image/heic" + return None + + +def _guess_mime(path: Path, raw: Optional[bytes] = None) -> str: + """Return image MIME type for *path*. + + If *raw* bytes are provided, magic-byte sniffing wins (authoritative). + Otherwise we fall back to ``mimetypes`` then suffix-based defaults. + """ + if raw is not None: + sniffed = _sniff_mime_from_bytes(raw) + if sniffed: + return sniffed mime, _ = mimetypes.guess_type(str(path)) if mime and mime.startswith("image/"): return mime @@ -178,7 +222,7 @@ def _file_to_data_url(path: Path) -> Optional[str]: except Exception as exc: logger.warning("image_routing: failed to read %s — %s", path, exc) return None - mime = _guess_mime(path) + mime = _guess_mime(path, raw=raw) b64 = base64.b64encode(raw).decode("ascii") return f"data:{mime};base64,{b64}" @@ -190,24 +234,30 @@ def build_native_content_parts( """Build an OpenAI-style ``content`` list for a user turn. Shape: - [{"type": "text", "text": "..."}, + [{"type": "text", "text": "...\\n\\n[Image attached at: /local/path]"}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}, ...] + The local path of each successfully attached image is appended to the + text part as ``[Image attached at: ]``. The model still sees the + pixels via the ``image_url`` part (full native vision); the path note + just gives it a string handle so MCP/skill tools that take an image + path or URL argument can be invoked on the same image without an + extra round-trip. This parallels the text-mode hint produced by + ``Runner._enrich_message_with_vision`` (``vision_analyze using image_url: + ``) so behaviour is consistent across both image input modes. + Images are attached at their native size. If a provider rejects the request because an image is too large (e.g. Anthropic's 5 MB per-image ceiling), the agent's retry loop transparently shrinks and retries once — see ``run_agent._try_shrink_image_parts_in_messages``. Returns (content_parts, skipped_paths). Skipped paths are files that - couldn't be read from disk. + couldn't be read from disk and are NOT advertised in the path hints. """ - parts: List[Dict[str, Any]] = [] skipped: List[str] = [] - - text = (user_text or "").strip() - if text: - parts.append({"type": "text", "text": text}) + image_parts: List[Dict[str, Any]] = [] + attached_paths: List[str] = [] for raw_path in image_paths: p = Path(raw_path) @@ -218,15 +268,30 @@ def build_native_content_parts( if not data_url: skipped.append(str(raw_path)) continue - parts.append({ + image_parts.append({ "type": "image_url", "image_url": {"url": data_url}, }) + attached_paths.append(str(raw_path)) - # If the text was empty, add a neutral prompt so the turn isn't just images. - if not text and any(p.get("type") == "image_url" for p in parts): - parts.insert(0, {"type": "text", "text": "What do you see in this image?"}) + text = (user_text or "").strip() + # If at least one image attached, build a single text part that combines + # the user's caption (or a neutral default) with one path hint per image. + if attached_paths: + base_text = text or "What do you see in this image?" + path_hints = "\n".join( + f"[Image attached at: {p}]" for p in attached_paths + ) + combined_text = f"{base_text}\n\n{path_hints}" + parts: List[Dict[str, Any]] = [{"type": "text", "text": combined_text}] + parts.extend(image_parts) + return parts, skipped + + # No images successfully attached — fall back to plain text-only behaviour. + parts = [] + if text: + parts.append({"type": "text", "text": text}) return parts, skipped diff --git a/agent/models_dev.py b/agent/models_dev.py index 79cfa90ca952..0ef18f4ce1f9 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -381,14 +381,18 @@ def get_model_capabilities(provider: str, model: str) -> Optional[ModelCapabilit # Extract capability flags (default to False if missing) supports_tools = bool(entry.get("tool_call", False)) - # Vision: check both the `attachment` flag and `modalities.input` for "image". - # Some models (e.g. gemma-4) list image in input modalities but not attachment. + # Vision: prefer explicit `modalities.input` when models.dev provides it. + # The older `attachment` flag can be stale or too broad for image routing; + # fall back to it only when the input modalities are absent/invalid. input_mods = entry.get("modalities", {}) if isinstance(input_mods, dict): - input_mods = input_mods.get("input", []) + input_mods = input_mods.get("input") else: - input_mods = [] - supports_vision = bool(entry.get("attachment", False)) or "image" in input_mods + input_mods = None + if isinstance(input_mods, list): + supports_vision = "image" in input_mods + else: + supports_vision = bool(entry.get("attachment", False)) supports_reasoning = bool(entry.get("reasoning", False)) # Extract limits diff --git a/agent/redact.py b/agent/redact.py index afdee6528881..1ac284cffd44 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -56,12 +56,15 @@ }) # Snapshot at import time so runtime env mutations (e.g. LLM-generated -# `export HERMES_REDACT_SECRETS=true`) cannot enable/disable redaction -# mid-session. OFF by default — user must opt in via -# `security.redact_secrets: true` in config.yaml (bridged to this env var -# in hermes_cli/main.py and gateway/run.py) or `HERMES_REDACT_SECRETS=true` -# in ~/.hermes/.env. -_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("1", "true", "yes", "on") +# `export HERMES_REDACT_SECRETS=false`) cannot disable redaction +# mid-session. ON by default — secure default per issue #17691. Users who +# need raw credential values in tool output (e.g. working on the redactor +# itself) can opt out via `security.redact_secrets: false` in config.yaml +# (bridged to this env var in hermes_cli/main.py, gateway/run.py, and +# cli.py) or `HERMES_REDACT_SECRETS=false` in ~/.hermes/.env. An opt-out +# warning is logged at gateway and CLI startup so operators see the +# downgrade — see `_log_redaction_status()` in gateway/run.py and cli.py. +_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "true").lower() in ("1", "true", "yes", "on") # Known API key prefixes -- match the prefix + contiguous token chars _PREFIX_PATTERNS = [ diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 963268d4ba68..871f45290232 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -875,6 +875,22 @@ display: # Toggle at runtime with /verbose in the CLI tool_progress: all + # Auto-cleanup of temporary progress bubbles after the final response lands. + # On platforms that support message deletion (currently Telegram), this + # removes the tool-progress bubble, "⏳ Still working..." notices, and + # context-pressure status messages once the final reply has been delivered — + # keeping long-running turns visible live, then tidy afterward. Failed runs + # leave the bubbles in place as breadcrumbs. Off by default. + # Per-platform override: display.platforms.telegram.cleanup_progress + # true: Delete tracked progress/status bubbles on successful turn + # false: Leave everything in place (default) + # Example: + # display: + # platforms: + # telegram: + # cleanup_progress: true + cleanup_progress: false + # Gateway-only natural mid-turn assistant updates. # When true, completed assistant status messages are sent as separate chat # messages. This is independent of tool_progress and gateway streaming. diff --git a/cli.py b/cli.py index fcc08ce3784f..16b3bea0726e 100644 --- a/cli.py +++ b/cli.py @@ -1550,7 +1550,21 @@ def _resolve_attachment_path(raw_path: str) -> Path | None: except Exception: resolved = path - if not resolved.exists() or not resolved.is_file(): + # Path.exists() / is_file() invoke os.stat(), which raises OSError when + # the candidate string is structurally invalid as a path — most commonly + # ENAMETOOLONG (errno 63 on macOS, errno 36 on Linux) when the input + # exceeds NAME_MAX (typically 255 bytes). This bites pasted slash + # commands like `/goal ` because `_detect_file_drop()`'s + # `starts_like_path` prefilter accepts any input starting with `/`, + # then this resolver tries to stat it before short-circuiting on the + # slash-command path. Without this guard the OSError propagates up to + # the process_loop catch-all in _interactive_loop and the user input + # is silently lost (the warning ends up in agent.log but the user sees + # nothing — the prompt just hangs). + try: + if not resolved.exists() or not resolved.is_file(): + return None + except OSError: return None return resolved @@ -1760,6 +1774,20 @@ def _strip_leaked_bracketed_paste_wrappers(text: str) -> str: ) +def _bind_prompt_submit_keys(kb, handler) -> None: + """Bind both CR and LF terminal Enter forms to the submit handler.""" + for key in ("enter", "c-j"): + kb.add(key)(handler) + + +def _disable_prompt_toolkit_cpr_warning(app) -> None: + """Let prompt_toolkit fall back from CPR without printing into the prompt.""" + try: + app.renderer.cpr_not_supported_callback = None + except Exception: + pass + + def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]: """Strip leaked terminal control-response sequences from user input. @@ -2543,6 +2571,15 @@ def _status_bar_context_style(self, percent_used: Optional[int]) -> str: return "class:status-bar-warn" return "class:status-bar-good" + @staticmethod + def _compression_count_style(count: int) -> str: + """Return a style class reflecting context compression pressure.""" + if count >= 10: + return "class:status-bar-bad" + if count >= 5: + return "class:status-bar-warn" + return "class:status-bar-dim" + def _build_context_bar(self, percent_used: Optional[int], width: int = 10) -> str: safe_percent = max(0, min(100, percent_used or 0)) filled = round((safe_percent / 100) * width) @@ -2826,6 +2863,9 @@ def _build_status_bar_text(self, width: Optional[int] = None) -> str: return self._trim_status_bar_text(text, width) if width < 76: parts = [f"⚕ {snapshot['model_short']}", percent_label] + compressions = snapshot.get("compressions", 0) + if compressions: + parts.append(f"🗜️ {compressions}") parts.append(duration_label) return self._trim_status_bar_text(" · ".join(parts), width) @@ -2836,7 +2876,10 @@ def _build_status_bar_text(self, width: Optional[int] = None) -> str: else: context_label = "ctx --" + compressions = snapshot.get("compressions", 0) parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label] + if compressions: + parts.append(f"🗜️ {compressions}") parts.append(duration_label) prompt_elapsed = snapshot.get("prompt_elapsed") if prompt_elapsed: @@ -2870,15 +2913,21 @@ def _get_status_bar_fragments(self): percent = snapshot["context_percent"] percent_label = f"{percent}%" if percent is not None else "--" if width < 76: + compressions = snapshot.get("compressions", 0) frags = [ ("class:status-bar", " ⚕ "), ("class:status-bar-strong", snapshot["model_short"]), ("class:status-bar-dim", " · "), (self._status_bar_context_style(percent), percent_label), + ] + if compressions: + frags.append(("class:status-bar-dim", " · ")) + frags.append((self._compression_count_style(compressions), f"🗜️ {compressions}")) + frags.extend([ ("class:status-bar-dim", " · "), ("class:status-bar-dim", duration_label), ("class:status-bar", " "), - ] + ]) else: if snapshot["context_length"]: ctx_total = _format_context_length(snapshot["context_length"]) @@ -2888,6 +2937,7 @@ def _get_status_bar_fragments(self): context_label = "ctx --" bar_style = self._status_bar_context_style(percent) + compressions = snapshot.get("compressions", 0) frags = [ ("class:status-bar", " ⚕ "), ("class:status-bar-strong", snapshot["model_short"]), @@ -2897,9 +2947,14 @@ def _get_status_bar_fragments(self): (bar_style, self._build_context_bar(percent)), ("class:status-bar-dim", " "), (bar_style, percent_label), + ] + if compressions: + frags.append(("class:status-bar-dim", " │ ")) + frags.append((self._compression_count_style(compressions), f"🗜️ {compressions}")) + frags.extend([ ("class:status-bar-dim", " │ "), ("class:status-bar-dim", duration_label), - ] + ]) # Position 7: per-prompt elapsed timer (live or frozen) prompt_elapsed = snapshot.get("prompt_elapsed") if prompt_elapsed: @@ -10185,6 +10240,24 @@ def run(self): _welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands." _welcome_color = "#FFF8DC" self._console_print(f"[{_welcome_color}]{_welcome_text}[/]") + + # Redaction opt-out warning (#17691): ON by default, loud when off. + # The redactor snapshots its state at import time so any toggle now + # won't affect the running process — we just want the operator to + # see that they're running without the safety net. + try: + _redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true") + if _redact_raw.lower() not in ("1", "true", "yes", "on"): + self._console_print( + "[bold red]⚠ Secret redaction is DISABLED[/] " + f"(HERMES_REDACT_SECRETS={_redact_raw}). " + "API keys and tokens may appear verbatim in chat output, " + "session JSONs, and logs. Set " + "[cyan]security.redact_secrets: true[/] in config.yaml " + "to re-enable." + ) + except Exception: + pass # First-time OpenClaw-residue banner — fires once if ~/.openclaw/ exists # after an OpenClaw→Hermes migration (especially migrations done by # OpenClaw's own tool, which doesn't archive the source directory). @@ -10324,7 +10397,6 @@ def run(self): # Key bindings for the input area kb = KeyBindings() - @kb.add('enter') def handle_enter(event): """Handle Enter key - submit input. @@ -10483,17 +10555,14 @@ def handle_enter(event): else: self._pending_input.put(payload) event.app.current_buffer.reset(append_to_history=True) + + _bind_prompt_submit_keys(kb, handle_enter) @kb.add('escape', 'enter') def handle_alt_enter(event): """Alt+Enter inserts a newline for multi-line input.""" event.current_buffer.insert_text('\n') - @kb.add('c-j') - def handle_ctrl_enter(event): - """Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter.""" - event.current_buffer.insert_text('\n') - # VSCode/Cursor bind Ctrl+G to "Find Next" at the editor level, so # the keystroke never reaches the embedded terminal. Alt+G is unbound # in those IDEs and arrives here as ('escape', 'g') — register it as @@ -11092,7 +11161,7 @@ def handle_alt_v(event): def get_prompt(): return cli_ref._get_tui_prompt_fragments() - # Create the input area with multiline (shift+enter), autocomplete, and paste handling + # Create the input area with multiline (Alt+Enter), autocomplete, and paste handling from prompt_toolkit.auto_suggest import AutoSuggestFromHistory @@ -11834,6 +11903,7 @@ def _get_voice_status(): mouse_support=False, **({'cursor': _STEADY_CURSOR} if _STEADY_CURSOR is not None else {}), ) + _disable_prompt_toolkit_cpr_warning(app) self._app = app # Store reference for clarify_callback # ── Fix ghost status-bar lines on terminal resize ────────────── @@ -12456,7 +12526,18 @@ def _signal_handler_q(signum, frame): ): cli.session_id = cli.agent.session_id response = result.get("final_response", "") if isinstance(result, dict) else str(result) - if response: + # Surface backend errors that produced no visible output + # (e.g. invalid model slug → provider 4xx). Mirrors the + # interactive CLI path. Write to stderr so piped stdout + # stays clean for automation wrappers. + if ( + not response + and isinstance(result, dict) + and result.get("error") + and (result.get("failed") or result.get("partial")) + ): + print(f"Error: {result['error']}", file=sys.stderr) + elif response: print(response) # Session ID goes to stderr so piped stdout is clean. print(f"\nsession_id: {cli.session_id}", file=sys.stderr) diff --git a/docker-compose.yml b/docker-compose.yml index bac125c93fc4..910392b25c74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ # keys; exposing it on LAN without auth is unsafe. If you want remote # access, use an SSH tunnel or put it behind a reverse proxy that # adds authentication — do NOT pass --insecure --host 0.0.0.0. +# - If you override entrypoint, keep /opt/hermes/docker/entrypoint.sh in +# the command chain. It drops root to the hermes user before gateway +# files such as gateway.lock are created. # - The gateway's API server is off unless you uncomment API_SERVER_KEY # and API_SERVER_HOST. See docs/user-guide/api-server.md before doing # this on an internet-facing host. diff --git a/gateway/config.py b/gateway/config.py index 2e0e3276b7b2..da370541bbc7 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -271,15 +271,23 @@ class PlatformConfig: # - "first": Only first chunk threads to user's message (default) # - "all": All chunks in multi-part replies thread to user's message reply_to_mode: str = "first" - + + # Whether the gateway is allowed to send "♻️ Gateway online" / + # "♻ Gateway restarted" lifecycle notifications on this platform. + # Default True preserves prior behavior. Set False on platforms used + # by end users (e.g. Slack) where operator-flavored restart pings are + # noise; keep True for back-channels where the operator wants them. + gateway_restart_notification: bool = True + # Platform-specific settings extra: Dict[str, Any] = field(default_factory=dict) - + def to_dict(self) -> Dict[str, Any]: result = { "enabled": self.enabled, "extra": self.extra, "reply_to_mode": self.reply_to_mode, + "gateway_restart_notification": self.gateway_restart_notification, } if self.token: result["token"] = self.token @@ -288,19 +296,22 @@ def to_dict(self) -> Dict[str, Any]: if self.home_channel: result["home_channel"] = self.home_channel.to_dict() return result - + @classmethod def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": home_channel = None if "home_channel" in data: home_channel = HomeChannel.from_dict(data["home_channel"]) - + return cls( enabled=_coerce_bool(data.get("enabled"), False), token=data.get("token"), api_key=data.get("api_key"), home_channel=home_channel, reply_to_mode=data.get("reply_to_mode", "first"), + gateway_restart_notification=_coerce_bool( + data.get("gateway_restart_notification"), True + ), extra=data.get("extra", {}), ) @@ -1141,10 +1152,17 @@ def _apply_env_overrides(config: GatewayConfig) -> None: # WhatsApp (typically uses different auth mechanism) whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes") - if whatsapp_enabled: - if Platform.WHATSAPP not in config.platforms: - config.platforms[Platform.WHATSAPP] = PlatformConfig() - config.platforms[Platform.WHATSAPP].enabled = True + whatsapp_disabled_explicitly = os.getenv("WHATSAPP_ENABLED", "").lower() in ("false", "0", "no") + if Platform.WHATSAPP in config.platforms: + # YAML config exists — respect explicit disable + wa_cfg = config.platforms[Platform.WHATSAPP] + if whatsapp_disabled_explicitly: + wa_cfg.enabled = False + elif whatsapp_enabled: + wa_cfg.enabled = True + # else: keep whatever the YAML set + elif whatsapp_enabled: + config.platforms[Platform.WHATSAPP] = PlatformConfig(enabled=True) whatsapp_home = os.getenv("WHATSAPP_HOME_CHANNEL") if whatsapp_home and Platform.WHATSAPP in config.platforms: config.platforms[Platform.WHATSAPP].home_channel = HomeChannel( diff --git a/gateway/display_config.py b/gateway/display_config.py index 832f5cb2f254..55cc344677ea 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -35,6 +35,12 @@ "show_reasoning": False, "tool_preview_length": 0, "streaming": None, # None = follow top-level streaming config + # When true, delete tool-progress / "Still working..." / status bubbles + # after the final response lands on platforms that support message + # deletion (e.g. Telegram). Off by default — progress is still shown + # live, just cleaned up after success so the chat doesn't fill up with + # stale breadcrumbs. Failed runs leave bubbles in place as breadcrumbs. + "cleanup_progress": False, } # --------------------------------------------------------------------------- @@ -188,6 +194,10 @@ def _normalise(setting: str, value: Any) -> Any: if isinstance(value, str): return value.lower() in ("true", "1", "yes", "on") return bool(value) + if setting == "cleanup_progress": + if isinstance(value, str): + return value.lower() in ("true", "1", "yes", "on") + return bool(value) if setting == "tool_preview_length": try: return int(value) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index ae77100f6aa1..2534cc6bcead 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -917,6 +917,16 @@ async def _handle_capabilities(self, request: "web.Request") -> "web.Response": "type": "bearer", "required": bool(self._api_key), }, + "runtime": { + "mode": "server_agent", + "tool_execution": "server", + "split_runtime": False, + "description": ( + "The API server creates a server-side Hermes AIAgent; " + "tools execute on the API-server host unless a future " + "explicit split-runtime mode is enabled." + ), + }, "features": { "chat_completions": True, "chat_completions_streaming": True, @@ -1888,12 +1898,12 @@ async def _flush_batch() -> None: "output_tokens": usage.get("output_tokens", 0), "total_tokens": usage.get("total_tokens", 0), } - full_history = list(conversation_history) - full_history.append({"role": "user", "content": user_message}) - if isinstance(result, dict) and result.get("messages"): - full_history.extend(result["messages"]) - else: - full_history.append({"role": "assistant", "content": final_response_text}) + full_history = self._build_response_conversation_history( + conversation_history, + user_message, + result, + final_response_text, + ) _persist_response_snapshot( completed_env, conversation_history_snapshot=full_history, @@ -2192,17 +2202,22 @@ async def _compute_response(): # Build the full conversation history for storage # (includes tool calls from the agent run) - full_history = list(conversation_history) - full_history.append({"role": "user", "content": user_message}) - # Add agent's internal messages if available - agent_messages = result.get("messages", []) - if agent_messages: - full_history.extend(agent_messages) - else: - full_history.append({"role": "assistant", "content": final_response}) + full_history = self._build_response_conversation_history( + conversation_history, + user_message, + result, + final_response, + ) - # Build output items (includes tool calls + final message) - output_items = self._extract_output_items(result) + # Build output items from the current turn only. AIAgent returns a + # full transcript in result["messages"], while older/mocked paths may + # return only the current turn suffix. + output_start_index = self._response_messages_turn_start_index( + conversation_history, + user_message, + result, + ) + output_items = self._extract_output_items(result, start_index=output_start_index) response_data = { "id": response_id, @@ -2494,17 +2509,70 @@ async def _handle_run_job(self, request: "web.Request") -> "web.Response": # ------------------------------------------------------------------ @staticmethod - def _extract_output_items(result: Dict[str, Any]) -> List[Dict[str, Any]]: + def _build_response_conversation_history( + conversation_history: List[Dict[str, Any]], + user_message: Any, + result: Dict[str, Any], + final_response: Any, + ) -> List[Dict[str, Any]]: + """Build the stored Responses transcript without duplicating history.""" + prior = list(conversation_history) + current_user = {"role": "user", "content": user_message} + agent_messages = result.get("messages") if isinstance(result, dict) else None + + if isinstance(agent_messages, list) and agent_messages: + turn_start = APIServerAdapter._response_messages_turn_start_index( + conversation_history, + user_message, + result, + ) + if turn_start: + return list(agent_messages) + + full_history = prior + full_history.append(current_user) + full_history.extend(agent_messages) + return full_history + + full_history = prior + full_history.append(current_user) + full_history.append({"role": "assistant", "content": final_response}) + return full_history + + @staticmethod + def _response_messages_turn_start_index( + conversation_history: List[Dict[str, Any]], + user_message: Any, + result: Dict[str, Any], + ) -> int: + """Detect transcript-shaped result["messages"] and return turn start.""" + agent_messages = result.get("messages") if isinstance(result, dict) else None + if not isinstance(agent_messages, list) or not agent_messages: + return 0 + + prior = list(conversation_history) + current_user = {"role": "user", "content": user_message} + expected_prefix = prior + [current_user] + if agent_messages[:len(expected_prefix)] == expected_prefix: + return len(expected_prefix) + if prior and agent_messages[:len(prior)] == prior: + return len(prior) + return 0 + + @staticmethod + def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]: """ - Build the full output item array from the agent's messages. + Build the output item array from the agent's messages. - Walks *result["messages"]* and emits: + Walks *result["messages"]* starting at *start_index* and emits: - ``function_call`` items for each tool_call on assistant messages - ``function_call_output`` items for each tool-role message - a final ``message`` item with the assistant's text reply """ items: List[Dict[str, Any]] = [] messages = result.get("messages", []) + if start_index > 0: + messages = messages[start_index:] for msg in messages: role = msg.get("role") diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 5c2bbf96aa88..80e5e6652664 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1874,23 +1874,38 @@ async def send_image_file( def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: """ Extract MEDIA: tags and [[audio_as_voice]] directives from response text. - + The TTS tool returns responses like: [[audio_as_voice]] MEDIA:/path/to/audio.ogg - + + Skills that produce large/lossless images (e.g. info-graph, where a + rendered JPG is 1-2 MB but Telegram's sendPhoto recompresses to + ~200 KB at 1280px) can use ``[[as_document]]`` to request unmodified + delivery via sendDocument instead of sendPhoto/sendMediaGroup. The + directive is detected at the dispatch sites (which have access to the + original response); this method just strips it so it never leaks into + user-visible text. Per-file granularity is intentionally not exposed — + when an agent emits ``[[as_document]]`` once, every image path in the + same response is delivered as a document, mirroring the all-or-nothing + scope of ``[[audio_as_voice]]``. + Args: content: The response text to scan. - + Returns: Tuple of (list of (path, is_voice) pairs, cleaned content with tags removed). """ media = [] cleaned = content - + # Check for [[audio_as_voice]] directive has_voice_tag = "[[audio_as_voice]]" in content cleaned = cleaned.replace("[[audio_as_voice]]", "") + # Strip [[as_document]] directive — callers inspect the original + # ``content`` for it (so they can still react to it); here we just + # keep it out of the user-visible cleaned text. + cleaned = cleaned.replace("[[as_document]]", "") # Extract MEDIA: tags, allowing optional whitespace after the colon # and quoted/backticked paths for LLM-formatted outputs. @@ -2096,9 +2111,52 @@ def register_post_delivery_callback( ``generation`` lets callers tie the callback to a specific gateway run generation so stale runs cannot clear callbacks owned by a fresher run. + + If a callback for the same ``session_key`` (and generation, when set) + is already registered, the new callback is chained — both fire, in + registration order, with per-callback exception isolation. This lets + independent features (background-review release + temporary-bubble + cleanup) coexist without clobbering each other. Stale-generation + callers never overwrite a fresher generation's slot. """ if not session_key or not callable(callback): return + + existing = self._post_delivery_callbacks.get(session_key) + if existing is not None: + if isinstance(existing, tuple) and len(existing) == 2: + existing_gen, existing_cb = existing + else: + existing_gen, existing_cb = None, existing + # Stale-generation registrations never overwrite a fresher slot. + if ( + existing_gen is not None + and generation is not None + and int(generation) < int(existing_gen) + ): + return + # Same-or-newer generation: chain with the existing callback so + # both fire in registration order. + if callable(existing_cb) and ( + existing_gen is None + or generation is None + or int(existing_gen) == int(generation) + ): + _prev = existing_cb + _new = callback + + def _chained() -> None: + try: + _prev() + except Exception: + logger.debug("Post-delivery callback failed", exc_info=True) + try: + _new() + except Exception: + logger.debug("Post-delivery callback failed", exc_info=True) + + callback = _chained + if generation is None: self._post_delivery_callbacks[session_key] = callback else: @@ -2772,13 +2830,21 @@ async def _stop_typing_task() -> None: if not response: logger.debug("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id) if response: + # Capture [[as_document]] before extract_media strips it, so the + # dispatch partition below can route image-extension files + # through send_document instead of send_multiple_images. Used + # by skills that produce large/lossless images (e.g. info-graph) + # where Telegram's sendPhoto recompression destroys legibility. + force_document_attachments = "[[as_document]]" in response + # Extract MEDIA: tags (from TTS tool) before other processing media_files, response = self.extract_media(response) - + # Extract image URLs and send them as native platform attachments images, text_content = self.extract_images(response) # Strip any remaining internal directives from message body (fixes #1561) text_content = text_content.replace("[[audio_as_voice]]", "").strip() + text_content = text_content.replace("[[as_document]]", "").strip() text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip() if images: logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response)) @@ -2880,19 +2946,26 @@ async def _stop_typing_task() -> None: _IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'} # Partition images out of media_files + local_files so they - # can be sent as a single batch (Signal RPC) + # can be sent as a single batch (Signal RPC). When + # ``[[as_document]]`` was set on the original response, image + # files skip the photo path and route to send_document below + # so they're delivered with original bytes (no Telegram + # sendPhoto recompression). from urllib.parse import quote as _quote _image_paths: list = [] _non_image_media: list = [] for media_path, is_voice in media_files: _ext = Path(media_path).suffix.lower() - if _ext in _IMAGE_EXTS and not is_voice: + if (_ext in _IMAGE_EXTS + and not is_voice + and not force_document_attachments): _image_paths.append(media_path) else: _non_image_media.append((media_path, is_voice)) _non_image_local: list = [] for file_path in local_files: - if Path(file_path).suffix.lower() in _IMAGE_EXTS: + if (Path(file_path).suffix.lower() in _IMAGE_EXTS + and not force_document_attachments): _image_paths.append(file_path) else: _non_image_local.append(file_path) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index e30c4478ef9d..ae107cdfb2b1 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -10,6 +10,8 @@ """ import asyncio +import hashlib +import json import logging import os import struct @@ -24,6 +26,10 @@ VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080} _DISCORD_COMMAND_SYNC_POLICIES = {"safe", "bulk", "off"} +_DISCORD_COMMAND_SYNC_STATE_SUBDIR = "gateway" +_DISCORD_COMMAND_SYNC_STATE_FILENAME = "discord_command_sync_state.json" +_DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5 +_DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0 try: import discord @@ -45,6 +51,7 @@ import re from gateway.platforms.helpers import MessageDeduplicator, ThreadParticipationTracker +from utils import atomic_json_write from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, @@ -470,6 +477,34 @@ def pcm_to_wav(pcm_data: bytes, output_path: str, pass +def _read_dm_role_auth_guild() -> Optional[int]: + """Return the guild ID opted-in for DM role-based auth, or None. + + Reads ``discord.dm_role_auth_guild`` from config.yaml. This is + deliberately a config.yaml-only setting (not an env var): per repo + policy, ``~/.hermes/.env`` is for secrets only, and this is a + behavioral setting. Guild IDs aren't secrets. + + Accepts ints or numeric strings in the config. Anything else + (empty, malformed, None) returns None, which keeps the secure + default (DM role-auth disabled). + """ + try: + from hermes_cli.config import read_raw_config + cfg = read_raw_config() or {} + discord_cfg = cfg.get("discord", {}) or {} + raw = discord_cfg.get("dm_role_auth_guild") + except Exception: + return None + if raw is None or raw == "": + return None + try: + guild_id = int(raw) + except (TypeError, ValueError): + return None + return guild_id if guild_id > 0 else None + + class DiscordAdapter(BasePlatformAdapter): """ Discord bot adapter. @@ -694,7 +729,17 @@ async def on_message(message: DiscordMessage): # human-user allowlist below (bots aren't in it). else: # Non-bot: enforce the configured user/role allowlists. - if not self._is_allowed_user(str(message.author.id), message.author): + # Pass guild + is_dm so role checks are scoped to the + # originating guild (prevents cross-guild DM bypass, see + # _is_allowed_user docstring). + _msg_guild = getattr(message, "guild", None) + _is_dm = isinstance(message.channel, discord.DMChannel) or _msg_guild is None + if not self._is_allowed_user( + str(message.author.id), + message.author, + guild=_msg_guild, + is_dm=_is_dm, + ): return # Multi-agent filtering: if the message mentions specific bots @@ -825,6 +870,167 @@ async def disconnect(self) -> None: logger.info("[%s] Disconnected", self.name) + def _command_sync_state_path(self) -> _Path: + from hermes_constants import get_hermes_home + + directory = get_hermes_home() / _DISCORD_COMMAND_SYNC_STATE_SUBDIR + try: + directory.mkdir(parents=True, exist_ok=True) + except Exception: + pass + return directory / _DISCORD_COMMAND_SYNC_STATE_FILENAME + + def _read_command_sync_state(self) -> dict: + try: + path = self._command_sync_state_path() + if not path.exists(): + return {} + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + def _write_command_sync_state(self, state: dict) -> None: + atomic_json_write( + self._command_sync_state_path(), + state, + indent=None, + separators=(",", ":"), + ) + + def _command_sync_state_key(self, app_id: Any) -> str: + return str(app_id or "unknown") + + def _desired_command_sync_fingerprint(self) -> str: + tree = self._client.tree if self._client else None + desired = [] + if tree is not None: + desired = [ + self._canonicalize_app_command_payload(command.to_dict(tree)) + for command in tree.get_commands() + ] + desired.sort(key=lambda item: (item.get("type", 1), item.get("name", ""))) + payload = json.dumps(desired, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def _command_sync_skip_reason(self, app_id: Any, fingerprint: str) -> Optional[str]: + entry = self._read_command_sync_state().get(self._command_sync_state_key(app_id)) + if not isinstance(entry, dict): + return None + now = time.time() + retry_after_until = float(entry.get("retry_after_until") or 0) + if retry_after_until > now: + remaining = max(1, int(retry_after_until - now)) + return f"Discord asked us to wait before syncing slash commands; retry in {remaining}s" + if entry.get("fingerprint") == fingerprint and entry.get("last_success_at"): + return "same slash-command fingerprint already synced" + return None + + def _record_command_sync_attempt(self, app_id: Any, fingerprint: str) -> None: + state = self._read_command_sync_state() + state[self._command_sync_state_key(app_id)] = { + **( + state.get(self._command_sync_state_key(app_id)) + if isinstance(state.get(self._command_sync_state_key(app_id)), dict) + else {} + ), + "fingerprint": fingerprint, + "last_attempt_at": time.time(), + } + self._write_command_sync_state(state) + + def _record_command_sync_rate_limit(self, app_id: Any, fingerprint: str, retry_after: float) -> None: + retry_after = max(1.0, float(retry_after)) + state = self._read_command_sync_state() + state[self._command_sync_state_key(app_id)] = { + **( + state.get(self._command_sync_state_key(app_id)) + if isinstance(state.get(self._command_sync_state_key(app_id)), dict) + else {} + ), + "fingerprint": fingerprint, + "last_attempt_at": time.time(), + "retry_after_until": time.time() + retry_after, + "retry_after": retry_after, + } + self._write_command_sync_state(state) + + def _record_command_sync_success(self, app_id: Any, fingerprint: str, summary: dict) -> None: + state = self._read_command_sync_state() + state[self._command_sync_state_key(app_id)] = { + "fingerprint": fingerprint, + "last_attempt_at": time.time(), + "last_success_at": time.time(), + "summary": summary, + } + self._write_command_sync_state(state) + + @staticmethod + def _extract_discord_retry_after(exc: BaseException) -> Optional[float]: + value = getattr(exc, "retry_after", None) + if value is not None: + try: + return max(1.0, float(value)) + except (TypeError, ValueError): + return None + response = getattr(exc, "response", None) + headers = getattr(response, "headers", None) + if headers: + for key in ("Retry-After", "X-RateLimit-Reset-After"): + try: + raw = headers.get(key) + except Exception: + raw = None + if raw is None: + continue + try: + return max(1.0, float(raw)) + except (TypeError, ValueError): + continue + return None + + @staticmethod + def _is_discord_rate_limit(exc: BaseException) -> bool: + """True only for exceptions that look like Discord 429 rate limits. + + Narrower than ``hasattr(exc, 'retry_after')``: discord.py's own + ``RateLimited`` exception and any HTTPException with status 429 + qualify. This prevents suppressing unrelated failures that happen + to expose a ``retry_after`` attribute.""" + # discord.py emits RateLimited / HTTPException subclasses for 429s. + # Guard with isinstance-of-class so a mocked ``discord`` module + # (where attrs are MagicMocks, not types) doesn't trip isinstance. + if DISCORD_AVAILABLE and discord is not None: + for attr_name in ("RateLimited", "HTTPException"): + cls = getattr(discord, attr_name, None) + if not isinstance(cls, type): + continue + if isinstance(exc, cls): + if attr_name == "RateLimited": + return True + status = getattr(exc, "status", None) + if status == 429: + return True + # Fallback duck-type: something named like a rate-limit with a + # numeric retry_after. Covers mocked clients in tests and exotic + # transports, without swallowing arbitrary exceptions. + name = type(exc).__name__.lower() + if ("ratelimit" in name or "rate_limit" in name) and getattr(exc, "retry_after", None) is not None: + return True + response = getattr(exc, "response", None) + status = getattr(response, "status", None) or getattr(response, "status_code", None) + if status == 429: + return True + return False + + def _command_sync_mutation_interval_seconds(self) -> float: + return _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS + + async def _sleep_between_command_sync_mutations(self) -> None: + interval = self._command_sync_mutation_interval_seconds() + if interval > 0: + await asyncio.sleep(interval) + async def _run_post_connect_initialization(self) -> None: """Finish non-critical startup work after Discord is connected.""" if not self._client: @@ -840,14 +1046,46 @@ async def _run_post_connect_initialization(self) -> None: logger.info("[%s] Synced %d slash command(s) via bulk tree sync", self.name, len(synced)) return - # Discord's per-app command-management bucket is ~5 writes / 20 s, - # so a mass-prune-plus-upsert reconcile (e.g. 77 orphans + 30 - # desired = 107 writes) takes several minutes of forced waits. - # A flat 30 s budget blew up reliably under bucket pressure and - # left slash commands broken for ~60 min until the bucket fully - # recovered. Use a wide ceiling; the cap still guards against a - # true hang. (#16713) - summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=600) + app_id = getattr(self._client, "application_id", None) or getattr(getattr(self._client, "user", None), "id", None) + fingerprint = self._desired_command_sync_fingerprint() + skip_reason = self._command_sync_skip_reason(app_id, fingerprint) + if skip_reason: + logger.info("[%s] Skipping Discord slash command sync: %s", self.name, skip_reason) + return + self._record_command_sync_attempt(app_id, fingerprint) + + http = getattr(self._client, "http", None) + has_ratelimit_timeout = http is not None and hasattr(http, "max_ratelimit_timeout") + previous_ratelimit_timeout = getattr(http, "max_ratelimit_timeout", None) if has_ratelimit_timeout else None + if has_ratelimit_timeout: + http.max_ratelimit_timeout = _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS + + try: + # Discord's per-app command-management bucket is small, and + # discord.py can otherwise sit inside one long retry sleep + # before surfacing the 429. Keep the whole sync bounded and + # persist Discord's retry-after when it refuses the batch. + summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=600) + except Exception as e: + if not self._is_discord_rate_limit(e): + raise + retry_after = self._extract_discord_retry_after(e) + if retry_after is None: + # Rate-limited but no retry-after signal — back off for a + # conservative default so we don't slam the bucket again. + retry_after = _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS + self._record_command_sync_rate_limit(app_id, fingerprint, retry_after) + logger.warning( + "[%s] Discord rate-limited slash command sync; retrying after %.0fs", + self.name, + retry_after, + ) + return + finally: + if has_ratelimit_timeout: + http.max_ratelimit_timeout = previous_ratelimit_timeout + + self._record_command_sync_success(app_id, fingerprint, summary) logger.info( "[%s] Safely reconciled %d slash command(s): unchanged=%d updated=%d recreated=%d created=%d deleted=%d", self.name, @@ -1009,11 +1247,20 @@ async def _safe_sync_slash_commands(self) -> Dict[str, int]: created = 0 deleted = 0 http = self._client.http + mutation_count = 0 + + async def mutate(call, *args): + nonlocal mutation_count + if mutation_count: + await self._sleep_between_command_sync_mutations() + result = await call(*args) + mutation_count += 1 + return result for key, desired in desired_by_key.items(): current = existing_by_key.pop(key, None) if current is None: - await http.upsert_global_command(app_id, desired) + await mutate(http.upsert_global_command, app_id, desired) created += 1 continue @@ -1025,16 +1272,16 @@ async def _safe_sync_slash_commands(self) -> Dict[str, int]: continue if self._patchable_app_command_payload(current_existing_payload) == self._patchable_app_command_payload(desired): - await http.delete_global_command(app_id, current.id) - await http.upsert_global_command(app_id, desired) + await mutate(http.delete_global_command, app_id, current.id) + await mutate(http.upsert_global_command, app_id, desired) recreated += 1 continue - await http.edit_global_command(app_id, current.id, desired) + await mutate(http.edit_global_command, app_id, current.id, desired) updated += 1 for current in existing_by_key.values(): - await http.delete_global_command(app_id, current.id) + await mutate(http.delete_global_command, app_id, current.id) deleted += 1 return { @@ -1854,8 +2101,16 @@ async def _voice_listen_loop(self, guild_id: int): pass completed = receiver.check_silence() + # Voice inputs always originate from a specific guild + # (guild_id is in scope). Pass it so role checks are + # guild-scoped and not cross-guild. + _vc_guild = self._client.get_guild(guild_id) if self._client is not None else None for user_id, pcm_data in completed: - if not self._is_allowed_user(str(user_id)): + if not self._is_allowed_user( + str(user_id), + guild=_vc_guild, + is_dm=False, + ): continue await self._process_voice_input(guild_id, user_id, pcm_data) except asyncio.CancelledError: @@ -1898,13 +2153,32 @@ async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: byte except OSError: pass - def _is_allowed_user(self, user_id: str, author=None) -> bool: + def _is_allowed_user( + self, + user_id: str, + author=None, + *, + guild=None, + is_dm: bool = False, + ) -> bool: """Check if user is allowed via DISCORD_ALLOWED_USERS or DISCORD_ALLOWED_ROLES. Uses OR semantics: if the user matches EITHER allowlist, they're allowed. If both allowlists are empty, everyone is allowed (backwards compatible). - When author is a Member, checks .roles directly; otherwise falls back - to scanning the bot's mutual guilds for a Member record. + + Role checks are **scoped to the guild the message originated from**. + For DMs (no guild context), role-based auth is disabled by default and + only user-ID allowlist applies. Set ``discord.dm_role_auth_guild`` + in config.yaml to a specific guild ID to opt-in: role membership in + that one guild will authorize DMs. This prevents cross-guild + privilege escalation where a user with the configured role in any + shared public server could DM the bot and pass the allowlist. + + Args: + user_id: Author ID as a string. + author: Optional Member/User object for in-guild role lookup. + guild: The guild the message arrived in (None for DMs). + is_dm: True if the message came from a DM channel. """ # ``getattr`` fallbacks here guard against test fixtures that build # an adapter via ``object.__new__(DiscordAdapter)`` and skip __init__ @@ -1915,31 +2189,54 @@ def _is_allowed_user(self, user_id: str, author=None) -> bool: has_roles = bool(allowed_roles) if not has_users and not has_roles: return True - # Check user ID allowlist + # Check user ID allowlist (works for both DMs and guild messages) if has_users and user_id in allowed_users: return True - # Check role allowlist - if has_roles: - # Try direct role check from Member object - direct_roles = getattr(author, "roles", None) if author is not None else None - if direct_roles: - if any(getattr(r, "id", None) in allowed_roles for r in direct_roles): - return True - # Fallback: scan mutual guilds for member's roles - if self._client is not None: - try: - uid_int = int(user_id) - except (TypeError, ValueError): - uid_int = None - if uid_int is not None: - for guild in self._client.guilds: - m = guild.get_member(uid_int) - if m is None: - continue - m_roles = getattr(m, "roles", None) or [] - if any(getattr(r, "id", None) in allowed_roles for r in m_roles): - return True - return False + # Role allowlist is only consulted when configured. + if not has_roles: + return False + + # DM path: roles require explicit opt-in via + # ``discord.dm_role_auth_guild`` in config.yaml. Without this, a + # user with the configured role in ANY mutual guild could DM the + # bot and bypass the allowlist (cross-guild leakage). + if is_dm or guild is None: + dm_guild_id = _read_dm_role_auth_guild() + if dm_guild_id is None: + return False + if self._client is None: + return False + dm_guild = self._client.get_guild(dm_guild_id) + if dm_guild is None: + return False + try: + uid_int = int(user_id) + except (TypeError, ValueError): + return False + m = dm_guild.get_member(uid_int) + if m is None: + return False + m_roles = getattr(m, "roles", None) or [] + return any(getattr(r, "id", None) in allowed_roles for r in m_roles) + + # Guild path: role check is scoped to THIS guild only. + # 1) Prefer the direct Member object passed in (correct guild by construction). + direct_roles = getattr(author, "roles", None) if author is not None else None + author_guild = getattr(author, "guild", None) + if direct_roles and (author_guild is None or author_guild.id == guild.id): + if any(getattr(r, "id", None) in allowed_roles for r in direct_roles): + return True + # 2) Fallback: resolve the Member in the message's guild only — NEVER + # scan other mutual guilds (that is the cross-guild bypass bug). + try: + uid_int = int(user_id) + except (TypeError, ValueError): + return False + m = guild.get_member(uid_int) + if m is None: + return False + m_roles = getattr(m, "roles", None) or [] + return any(getattr(r, "id", None) in allowed_roles for r in m_roles) # ── Slash command authorization ───────────────────────────────────── # Slash commands (``_run_simple_slash`` and ``_handle_thread_create_slash``) @@ -2036,7 +2333,16 @@ def _evaluate_slash_authorization( return (True, None) user_id = str(user.id) - if not self._is_allowed_user(user_id, author=user): + # Pass guild + is_dm so role check is scoped to the originating + # guild and cross-guild DM bypass (#12136) can't land via the + # slash surface either. + interaction_guild = getattr(interaction, "guild", None) + if not self._is_allowed_user( + user_id, + author=user, + guild=interaction_guild, + is_dm=in_dm, + ): return ( False, "user not in DISCORD_ALLOWED_USERS / DISCORD_ALLOWED_ROLES", diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index e1528b9bca09..cd9504e1da2e 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -4089,15 +4089,18 @@ async def _send_raw_message( reply_to: Optional[str], metadata: Optional[Dict[str, Any]], ) -> Any: + effective_reply_to = reply_to + if not effective_reply_to and metadata and metadata.get("thread_id"): + effective_reply_to = metadata.get("reply_to_message_id") reply_in_thread = bool((metadata or {}).get("thread_id")) - if reply_to: + if effective_reply_to: body = self._build_reply_message_body( content=payload, msg_type=msg_type, reply_in_thread=reply_in_thread, uuid_value=str(uuid.uuid4()), ) - request = self._build_reply_message_request(reply_to, body) + request = self._build_reply_message_request(effective_reply_to, body) return await asyncio.to_thread(self._client.im.v1.message.reply, request) body = self._build_create_message_body( @@ -4588,12 +4591,12 @@ def _poll_registration( Returns dict with app_id, app_secret, domain, open_id on success. Returns None on failure. """ - deadline = time.time() + expire_in + deadline = time.monotonic() + expire_in current_domain = domain domain_switched = False poll_count = 0 - while time.time() < deadline: + while time.monotonic() < deadline: base_url = _accounts_base_url(current_domain) try: res = _post_registration(base_url, { diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 83e81736876b..0f0f568c10be 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -86,6 +86,22 @@ class _MockContextTypes: ) from utils import atomic_replace +_TELEGRAM_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} +_TELEGRAM_IMAGE_MIME_TO_EXT = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/webp": ".webp", + "image/gif": ".gif", +} +_TELEGRAM_IMAGE_EXT_TO_MIME = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", +} + def check_telegram_requirements() -> bool: """Check if Telegram dependencies are available.""" @@ -3239,10 +3255,59 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA _, ext = os.path.splitext(original_filename) ext = ext.lower() + # Normalize mime_type for robust comparisons (some clients send + # uppercase like "IMAGE/PNG"). + doc_mime = (doc.mime_type or "").lower() + # If no extension from filename, reverse-lookup from MIME type - if not ext and doc.mime_type: - mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} - ext = mime_to_ext.get(doc.mime_type, "") + if not ext and doc_mime: + ext = _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, "") + if not ext: + mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} + ext = mime_to_ext.get(doc_mime, "") + + # Check file size early so image documents cannot bypass the + # document size limit by taking the image path. + MAX_DOC_BYTES = 20 * 1024 * 1024 + if not doc.file_size or doc.file_size > MAX_DOC_BYTES: + event.text = ( + "The document is too large or its size could not be verified. " + "Maximum: 20 MB." + ) + logger.info("[Telegram] Document too large: %s bytes", doc.file_size) + await self.handle_message(event) + return + + # Telegram may deliver screenshots/photos as documents. If the + # payload is actually an image, route it through the image cache + # and batching path instead of rejecting it as a document. + if ext in _TELEGRAM_IMAGE_EXTENSIONS or doc_mime.startswith("image/"): + file_obj = await doc.get_file() + image_bytes = await file_obj.download_as_bytearray() + image_ext = ext if ext in _TELEGRAM_IMAGE_EXTENSIONS else _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, ".jpg") + try: + cached_path = cache_image_from_bytes(bytes(image_bytes), ext=image_ext) + except ValueError as e: + logger.warning("[Telegram] Failed to cache image document: %s", e, exc_info=True) + event.text = ( + f"Image document '{original_filename or doc_mime or ext or 'unknown'}' " + "could not be read as an image." + ) + await self.handle_message(event) + return + + event.message_type = MessageType.PHOTO + event.media_urls = [cached_path] + event.media_types = [doc_mime if doc_mime.startswith("image/") else _TELEGRAM_IMAGE_EXT_TO_MIME.get(image_ext, "image/jpeg")] + logger.info("[Telegram] Cached user image-document at %s", cached_path) + + media_group_id = getattr(msg, "media_group_id", None) + if media_group_id: + await self._queue_media_group_event(str(media_group_id), event) + else: + batch_key = self._photo_batch_key(event, msg) + self._enqueue_photo_event(batch_key, event) + return if not ext and doc.mime_type: video_mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()} @@ -3270,17 +3335,6 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA await self.handle_message(event) return - # Check file size (Telegram Bot API limit: 20 MB) - MAX_DOC_BYTES = 20 * 1024 * 1024 - if not doc.file_size or doc.file_size > MAX_DOC_BYTES: - event.text = ( - "The document is too large or its size could not be verified. " - "Maximum: 20 MB." - ) - logger.info("[Telegram] Document too large: %s bytes", doc.file_size) - await self.handle_message(event) - return - # Download and cache file_obj = await doc.get_file() doc_bytes = await file_obj.download_as_bytearray() diff --git a/gateway/platforms/wecom.py b/gateway/platforms/wecom.py index c93a8fe3d65b..769743794dff 100644 --- a/gateway/platforms/wecom.py +++ b/gateway/platforms/wecom.py @@ -37,6 +37,7 @@ import mimetypes import os import re +import time import uuid from datetime import datetime, timezone from pathlib import Path @@ -1562,12 +1563,11 @@ def qr_scan_for_bot_info( print(" Fetching configuration results...", end="", flush=True) # ── Step 3: Poll for result ── - import time - deadline = time.time() + timeout_seconds + deadline = time.monotonic() + timeout_seconds query_url = f"{_QR_QUERY_URL}?scode={urllib.parse.quote(scode)}" poll_count = 0 - while time.time() < deadline: + while time.monotonic() < deadline: try: req = urllib.request.Request(query_url, headers={"User-Agent": "HermesAgent/1.0"}) with urllib.request.urlopen(req, timeout=10) as resp: diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 482692ee7a14..2f9472ecc002 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -548,17 +548,21 @@ async def _upload_ciphertext( Accepts either a constructed CDN URL (from upload_param) or a direct upload_full_url — both use POST with the raw ciphertext as the body. """ - timeout = aiohttp.ClientTimeout(total=120) - async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=timeout) as response: - if response.status == 200: - encrypted_param = response.headers.get("x-encrypted-param") - if encrypted_param: - await response.read() - return encrypted_param + # Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid + # "Timeout context manager should be used inside a task" errors when + # invoked via asyncio.run_coroutine_threadsafe() from cron jobs. + async def _do_upload() -> str: + async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}) as response: + if response.status == 200: + encrypted_param = response.headers.get("x-encrypted-param") + if encrypted_param: + await response.read() + return encrypted_param + raw = await response.text() + raise RuntimeError(f"CDN upload missing x-encrypted-param header: {raw[:200]}") raw = await response.text() - raise RuntimeError(f"CDN upload missing x-encrypted-param header: {raw[:200]}") - raw = await response.text() - raise RuntimeError(f"CDN upload HTTP {response.status}: {raw[:200]}") + raise RuntimeError(f"CDN upload HTTP {response.status}: {raw[:200]}") + return await asyncio.wait_for(_do_upload(), timeout=120) async def _download_bytes( @@ -567,10 +571,13 @@ async def _download_bytes( url: str, timeout_seconds: float = 60.0, ) -> bytes: - timeout = aiohttp.ClientTimeout(total=timeout_seconds) - async with session.get(url, timeout=timeout) as response: - response.raise_for_status() - return await response.read() + # Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid + # "Timeout context manager should be used inside a task" errors. + async def _do_download() -> bytes: + async with session.get(url) as response: + response.raise_for_status() + return await response.read() + return await asyncio.wait_for(_do_download(), timeout=timeout_seconds) _WEIXIN_CDN_ALLOWLIST: frozenset[str] = frozenset( @@ -1037,11 +1044,11 @@ async def qr_login( except Exception as _qr_exc: print(f"(终端二维码渲染失败: {_qr_exc},请直接打开上面的二维码链接)") - deadline = time.time() + timeout_seconds + deadline = time.monotonic() + timeout_seconds current_base_url = ILINK_BASE_URL refresh_count = 0 - while time.time() < deadline: + while time.monotonic() < deadline: try: status_resp = await _api_get( session, @@ -1216,7 +1223,12 @@ async def connect(self) -> bool: logger.debug("[%s] Token lock unavailable (non-fatal): %s", self.name, exc) self._poll_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector()) - self._send_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector()) + # Disable aiohttp's built-in ClientTimeout (total=None) to prevent + # "Timeout context manager should be used inside a task" errors when + # send() is invoked via asyncio.run_coroutine_threadsafe() from cron. + # Timeout is managed externally via asyncio.wait_for() in _api_post/_api_get. + _no_aiohttp_timeout = aiohttp.ClientTimeout(total=None, connect=None, sock_connect=None, sock_read=None) + self._send_session = aiohttp.ClientSession(trust_env=True, connector=_make_ssl_connector(), timeout=_no_aiohttp_timeout) self._token_store.restore(self._account_id) self._poll_task = asyncio.create_task(self._poll_loop(), name="weixin-poll") self._mark_connected() @@ -1824,10 +1836,14 @@ async def _download_remote_media(self, url: str) -> str: raise ValueError(f"Blocked unsafe URL (SSRF protection): {url}") assert self._send_session is not None - async with self._send_session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as response: - response.raise_for_status() - data = await response.read() - suffix = Path(url.split("?", 1)[0]).suffix or ".bin" + # Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid + # "Timeout context manager should be used inside a task" errors. + async def _do_fetch(): + async with self._send_session.get(url) as response: + response.raise_for_status() + return await response.read() + data = await asyncio.wait_for(_do_fetch(), timeout=30) + suffix = Path(url.split("?", 1)[0]).suffix or ".bin" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as handle: handle.write(data) return handle.name diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 921dd70d722e..3aff6bfd3756 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -21,6 +21,7 @@ import os import platform import re +import signal import subprocess _IS_WINDOWS = platform.system() == "Windows" @@ -54,19 +55,77 @@ def _kill_port_process(port: int) -> None: except subprocess.SubprocessError: pass else: - result = subprocess.run( - ["fuser", f"{port}/tcp"], - capture_output=True, timeout=5, - ) - if result.returncode == 0: - subprocess.run( - ["fuser", "-k", f"{port}/tcp"], + # Try fuser first (Linux), fall back to lsof (macOS / WSL2) + killed = False + try: + result = subprocess.run( + ["fuser", f"{port}/tcp"], capture_output=True, timeout=5, ) + if result.returncode == 0: + subprocess.run( + ["fuser", "-k", f"{port}/tcp"], + capture_output=True, timeout=5, + ) + killed = True + except FileNotFoundError: + pass # fuser not installed + + if not killed: + try: + result = subprocess.run( + ["lsof", "-ti", f":{port}"], + capture_output=True, text=True, timeout=5, + ) + for pid_str in result.stdout.strip().splitlines(): + try: + os.kill(int(pid_str), signal.SIGTERM) + except (ValueError, ProcessLookupError, PermissionError): + pass + except FileNotFoundError: + pass # lsof not installed either except Exception: pass +def _kill_stale_bridge_by_pidfile(session_path: Path) -> None: + """Kill a bridge process recorded in a PID file from a previous run. + + The bridge writes ``bridge.pid`` into the session directory when it + starts. If the gateway crashed without a clean shutdown the old bridge + process becomes orphaned — this helper finds and kills it. + """ + pid_file = session_path / "bridge.pid" + if not pid_file.exists(): + return + try: + pid = int(pid_file.read_text().strip()) + except (ValueError, OSError, TypeError): + try: + pid_file.unlink() + except OSError: + pass + return + try: + os.kill(pid, 0) # check existence + os.kill(pid, signal.SIGTERM) + logger.info("[whatsapp] Killed stale bridge PID %d from pidfile", pid) + except (ProcessLookupError, PermissionError, OSError): + pass + try: + pid_file.unlink() + except OSError: + pass + + +def _write_bridge_pidfile(session_path: Path, pid: int) -> None: + """Write the bridge PID to a file for later cleanup.""" + try: + (session_path / "bridge.pid").write_text(str(pid)) + except OSError: + pass + + def _terminate_bridge_process(proc, *, force: bool = False) -> None: """Terminate the bridge process using process-tree semantics where possible.""" if _IS_WINDOWS: @@ -428,6 +487,7 @@ async def connect(self) -> bool: pass # Bridge not running, start a new one # Kill any orphaned bridge from a previous gateway run + _kill_stale_bridge_by_pidfile(self._session_path) _kill_port_process(self._bridge_port) await asyncio.sleep(1) @@ -459,6 +519,7 @@ async def connect(self) -> bool: preexec_fn=None if _IS_WINDOWS else os.setsid, env=bridge_env, ) + _write_bridge_pidfile(self._session_path, self._bridge_process.pid) # Wait for the bridge to connect to WhatsApp. # Phase 1: wait for the HTTP server to come up (up to 15s). @@ -609,6 +670,12 @@ async def disconnect(self) -> None: # Bridge was not started by us, don't kill it print(f"[{self.name}] Disconnecting (external bridge left running)") + # Clean up PID file + try: + (self._session_path / "bridge.pid").unlink(missing_ok=True) + except OSError: + pass + # Cancel the poll task explicitly if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() diff --git a/gateway/run.py b/gateway/run.py index fe2ed84e6cd9..9f792c3e5dd9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -299,6 +299,36 @@ def _restart_notification_pending() -> bool: load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env') +def _reload_runtime_env_preserving_config_authority() -> None: + """Reload .env for fresh credentials without letting stale .env override config. + + Gateway processes are long-lived, so per-turn code reloads ~/.hermes/.env to + pick up rotated API keys. config.yaml remains authoritative for agent budget + settings such as agent.max_turns; otherwise a stale HERMES_MAX_ITERATIONS in + .env can replace the startup bridge on later turns. + """ + load_hermes_dotenv( + hermes_home=_hermes_home, + project_env=Path(__file__).resolve().parents[1] / '.env', + ) + + config_path = _hermes_home / 'config.yaml' + if not config_path.exists(): + return + try: + import yaml as _yaml + with open(config_path, encoding="utf-8") as f: + cfg = _yaml.safe_load(f) or {} + from hermes_cli.config import _expand_env_vars + cfg = _expand_env_vars(cfg) + except Exception: + return + + agent_cfg = cfg.get("agent", {}) + if isinstance(agent_cfg, dict) and "max_turns" in agent_cfg: + os.environ["HERMES_MAX_ITERATIONS"] = str(agent_cfg["max_turns"]) + + _DOCKER_VOLUME_SPEC_RE = re.compile(r"^(?P.+):(?P/[^:]+?)(?::(?P[^:]+))?$") _DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS = {"/output", "/outputs"} @@ -985,6 +1015,26 @@ def _normalize_empty_agent_response( return response +def _should_clear_resume_pending_after_turn(agent_result: dict) -> bool: + """Return True only when a gateway turn really completed successfully. + + Restart recovery uses ``resume_pending`` as a durable marker for sessions + interrupted during gateway drain. A soft interrupt can still bubble out as + a syntactically normal agent result with an empty final response; clearing + the marker in that case loses the recovery signal and startup auto-resume + has nothing to schedule. + """ + if not isinstance(agent_result, dict): + return False + if agent_result.get("interrupted"): + return False + if agent_result.get("failed") or agent_result.get("partial") or agent_result.get("error"): + return False + if agent_result.get("completed") is False: + return False + return True + + class GatewayRunner: """ Main gateway controller. @@ -1066,6 +1116,13 @@ def __init__(self, config: Optional[GatewayConfig] = None): self._pending_native_image_paths_by_session: Dict[str, List[str]] = {} self._busy_ack_ts: Dict[str, float] = {} # last busy-ack timestamp per session (debounce) self._session_run_generation: Dict[str, int] = {} + # LRU cache of live SessionSources keyed by session_key. Used by + # fallback routing paths (shutdown notifications, synthetic + # background-process events) when the persisted origin is missing + # and _parse_session_key can't recover thread_id. Capped so it + # cannot grow unbounded over a long-running gateway lifetime. + self._session_sources: "OrderedDict[str, SessionSource]" = OrderedDict() + self._session_sources_max = 512 # Cache AIAgent instances per session to preserve prompt caching. # Without this, a new AIAgent is created per message, rebuilding the @@ -2431,6 +2488,9 @@ async def _notify_active_sessions_of_shutdown(self) -> None: e, ) + if source is None: + source = self._get_cached_session_source(session_key) + if source is not None: platform_str = source.platform.value chat_id = str(source.chat_id) @@ -2458,6 +2518,14 @@ async def _notify_active_sessions_of_shutdown(self) -> None: if not adapter: continue + platform_cfg = self.config.platforms.get(platform) + if platform_cfg is not None and not platform_cfg.gateway_restart_notification: + logger.info( + "Shutdown notification suppressed for active session: %s has gateway_restart_notification=false", + platform_str, + ) + continue + # Include thread_id if present so the message lands in the # correct forum topic / thread. metadata = {"thread_id": thread_id} if thread_id else None @@ -2483,11 +2551,24 @@ async def _notify_active_sessions_of_shutdown(self) -> None: platform_str, chat_id, e, ) - for platform, adapter in self.adapters.items(): + # Snapshot adapters up front: adapter.send() can hit a fatal error + # path that pops the adapter from self.adapters (see _handle_fatal + # elsewhere), which would otherwise trigger + # ``RuntimeError: dictionary changed size during iteration`` — + # observed in a user report during gateway shutdown. + for platform, adapter in list(self.adapters.items()): home = self.config.get_home_channel(platform) if not home or not home.chat_id: continue + platform_cfg = self.config.platforms.get(platform) + if platform_cfg is not None and not platform_cfg.gateway_restart_notification: + logger.info( + "Shutdown notification suppressed for home channel: %s has gateway_restart_notification=false", + platform.value, + ) + continue + dedup_key = (platform.value, str(home.chat_id), str(home.thread_id) if home.thread_id else None) if dedup_key in notified: continue @@ -2723,6 +2804,83 @@ async def _run_restart() -> None: task.add_done_callback(self._background_tasks.discard) return True + # Drain-timeout reasons set by _stop_impl() when a still-running turn is + # force-interrupted; "restart_interrupted" is set by + # SessionStore.suspend_recently_active() on crash recovery (no + # .clean_shutdown marker). All three mean "the agent was mid-turn and + # we killed it" — eligible for startup auto-resume. + _AUTO_RESUME_REASONS = frozenset( + {"restart_timeout", "shutdown_timeout", "restart_interrupted"} + ) + + def _schedule_resume_pending_sessions(self) -> int: + """Auto-continue fresh restart-interrupted sessions after startup. + + ``resume_pending`` already preserves the transcript AND the existing + ``_is_resume_pending`` branch in ``_handle_message_with_agent`` + injects a reason-aware recovery system note on the next turn. This + method closes the UX gap by synthesizing that next turn once + adapters are back online — the event text is empty so the existing + injection path owns the wording and we never double up. + + Adapters that are not yet ready (adapter missing from + ``self.adapters``) are skipped silently; their sessions stay + ``resume_pending`` and will auto-resume on the next real user + message, or on the next gateway startup. + """ + window = _auto_continue_freshness_window() + try: + with self.session_store._lock: # noqa: SLF001 — snapshot under lock + self.session_store._ensure_loaded_locked() # noqa: SLF001 + candidates = [ + entry for entry in self.session_store._entries.values() # noqa: SLF001 + if entry.resume_pending + and not entry.suspended + and entry.origin is not None + and entry.resume_reason in self._AUTO_RESUME_REASONS + ] + except Exception as exc: + logger.warning("Failed to enumerate resume-pending sessions: %s", exc) + return 0 + + now = datetime.now() + scheduled = 0 + for entry in candidates: + marker = entry.last_resume_marked_at or entry.updated_at + if marker is not None and (now - marker).total_seconds() > window: + continue + + source = entry.origin + adapter = self.adapters.get(source.platform) + if adapter is None: + logger.debug( + "Skipping auto-resume for %s: adapter not ready for %s", + entry.session_key, + getattr(source.platform, "value", source.platform), + ) + continue + + # Empty-text internal event — the _is_resume_pending branch in + # _handle_message_with_agent prepends the proper reason-aware + # system note before the turn runs. + event = MessageEvent( + text="", + message_type=MessageType.TEXT, + source=source, + internal=True, + ) + task = asyncio.create_task(adapter.handle_message(event)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + scheduled += 1 + + if scheduled: + logger.info( + "Scheduled auto-resume for %d restart-interrupted session(s)", + scheduled, + ) + return scheduled + async def start(self) -> bool: """ Start the gateway and all configured platform adapters. @@ -2747,6 +2905,29 @@ async def start(self) -> bool: ) except Exception: pass + # Redaction status: ON by default (#17691). Surface a prominent + # warning if an operator has explicitly opted out so they don't + # forget the downgrade is active — the redactor snapshots its + # state at import time, so this log line is the source of truth + # for this process's lifetime. + try: + _redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true") + _redact_on = _redact_raw.lower() in ("1", "true", "yes", "on") + if _redact_on: + logger.info( + "Secret redaction: ENABLED (tool output, logs, and chat " + "responses are scrubbed before delivery)" + ) + else: + logger.warning( + "Secret redaction: DISABLED (HERMES_REDACT_SECRETS=%s). " + "API keys and tokens may appear verbatim in chat output, " + "session JSONs, and logs. Set security.redact_secrets: true " + "in config.yaml to re-enable.", + _redact_raw, + ) + except Exception: + pass try: from hermes_cli.profiles import get_active_profile_name _profile = get_active_profile_name() @@ -3111,6 +3292,12 @@ async def start(self) -> bool: skip_targets=skip_home_targets, ) + # Automatically continue fresh sessions that were interrupted by the + # previous gateway restart/shutdown. The resume_pending flag is cleared + # by the normal successful-turn path, so a failed auto-resume remains + # visible for manual recovery on the next user message. + self._schedule_resume_pending_sessions() + # Drain any recovered process watchers (from crash recovery checkpoint) try: from tools.process_registry import process_registry @@ -3629,6 +3816,24 @@ async def _kanban_dispatcher_watcher(self) -> None: if max_spawn is not None: logger.info(f"kanban dispatcher: max_spawn={max_spawn}") + raw_failure_limit = kanban_cfg.get("failure_limit", _kb.DEFAULT_FAILURE_LIMIT) + try: + failure_limit = int(raw_failure_limit) + except (TypeError, ValueError): + logger.warning( + "kanban dispatcher: invalid kanban.failure_limit=%r; using default %d", + raw_failure_limit, + _kb.DEFAULT_FAILURE_LIMIT, + ) + failure_limit = _kb.DEFAULT_FAILURE_LIMIT + if failure_limit < 1: + logger.warning( + "kanban dispatcher: kanban.failure_limit=%r is below 1; using default %d", + raw_failure_limit, + _kb.DEFAULT_FAILURE_LIMIT, + ) + failure_limit = _kb.DEFAULT_FAILURE_LIMIT + # Initial delay so the gateway finishes wiring adapters before the # dispatcher spawns workers (those workers may hit gateway notify # subscriptions etc.). Matches the notifier watcher's delay. @@ -3657,7 +3862,12 @@ def _tick_once_for_board(slug: str) -> "Optional[object]": _kb.init_db(board=slug) # idempotent, handles first-run except Exception: pass - return _kb.dispatch_once(conn, board=slug, max_spawn=max_spawn) + return _kb.dispatch_once( + conn, + board=slug, + max_spawn=max_spawn, + failure_limit=failure_limit, + ) except Exception: logger.exception("kanban dispatcher: tick failed on board %s", slug) return None @@ -5741,6 +5951,7 @@ async def _prepare_inbound_message_text( if event.media_urls and event.message_type == MessageType.DOCUMENT: import mimetypes as _mimetypes + from tools.credential_files import to_agent_visible_cache_path _TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"} for i, path in enumerate(event.media_urls): @@ -5761,16 +5972,21 @@ async def _prepare_inbound_message_text( display_name = parts[2] if len(parts) >= 3 else basename display_name = re.sub(r'[^\w.\- ]', '_', display_name) + # Translate host cache path to in-container path if running under Docker backend. + # This ensures the agent receives a path it can open inside its sandbox, as the + # cache directories are auto-mounted at /root/.hermes/cache/* by get_cache_directory_mounts(). + agent_path = to_agent_visible_cache_path(path) + if mtype.startswith("text/"): context_note = ( f"[The user sent a text document: '{display_name}'. " f"Its content has been included below. " - f"The file is also saved at: {path}]" + f"The file is also saved at: {agent_path}]" ) else: context_note = ( f"[The user sent a document: '{display_name}'. " - f"The file is saved at: {path}. " + f"The file is saved at: {agent_path}. " f"Ask the user what they'd like you to do with it.]" ) message_text = f"{context_note}\n\n{message_text}" @@ -5835,6 +6051,41 @@ def _consume_pending_native_image_paths(self, session_key: str) -> List[str]: return [] return list(pending_native.pop(session_key, []) or []) + def _cache_session_source(self, session_key: str, source) -> None: + if not session_key or source is None: + return + cached_sources = getattr(self, "_session_sources", None) + if cached_sources is None: + cached_sources = OrderedDict() + self._session_sources = cached_sources + try: + cached_sources[session_key] = dataclasses.replace(source) + except Exception: + logger.debug("Failed to cache live session source for %s", session_key, exc_info=True) + return + # LRU: mark as most-recently-used and trim to max size. + try: + cached_sources.move_to_end(session_key) + max_size = getattr(self, "_session_sources_max", 512) + while len(cached_sources) > max_size: + cached_sources.popitem(last=False) + except Exception: + pass + + def _get_cached_session_source(self, session_key: str): + if not session_key: + return None + cached_sources = getattr(self, "_session_sources", None) + if not cached_sources: + return None + source = cached_sources.get(session_key) + if source is not None: + try: + cached_sources.move_to_end(session_key) + except Exception: + pass + return source + async def _handle_message_with_agent(self, event, source, _quick_key: str, run_generation: int): """Inner handler that runs under the _running_agents sentinel guard.""" _msg_start_time = time.time() @@ -5849,6 +6100,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # Get or create session session_entry = self.session_store.get_or_create_session(source) session_key = session_entry.session_key + self._cache_session_source(session_key, source) if self._is_telegram_topic_lane(source): try: binding = self._session_db.get_telegram_topic_binding( @@ -6485,7 +6737,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # shutdown) — the turn ran to completion, so recovery # succeeded and subsequent messages should no longer receive # the restart-interruption system note. - if session_key: + if session_key and _should_clear_resume_pending_after_turn(agent_result): self._clear_restart_failure_count(session_key) try: self.session_store.clear_resume_pending(session_key) @@ -8744,6 +8996,12 @@ async def _deliver_media_from_response( from urllib.parse import quote as _quote try: + # Capture [[as_document]] before extract_media strips it, so the + # dispatch partition below can route image-extension files + # through send_document (preserving bytes) instead of + # send_multiple_images (Telegram sendPhoto recompresses to ~1280px). + force_document_attachments = "[[as_document]]" in response + media_files, _ = adapter.extract_media(response) _, cleaned = adapter.extract_images(response) local_files, _ = adapter.extract_local_files(cleaned) @@ -8756,19 +9014,24 @@ async def _deliver_media_from_response( _IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'} # Partition out images so they can be sent as a single batch - # (e.g. Signal's multi-attachment RPC) + # (e.g. Signal's multi-attachment RPC). When [[as_document]] was + # set, image-extension files skip the photo path and route to + # send_document below — preserving original bytes. image_paths: list = [] non_image_media: list = [] for media_path, is_voice in media_files: ext = Path(media_path).suffix.lower() - if ext in _IMAGE_EXTS and not is_voice: + if (ext in _IMAGE_EXTS + and not is_voice + and not force_document_attachments): image_paths.append(media_path) else: non_image_media.append((media_path, is_voice)) non_image_local: list = [] for file_path in local_files: - if Path(file_path).suffix.lower() in _IMAGE_EXTS: + if (Path(file_path).suffix.lower() in _IMAGE_EXTS + and not force_document_attachments): image_paths.append(file_path) else: non_image_local.append(file_path) @@ -11386,6 +11649,14 @@ async def _send_restart_notification(self) -> Optional[tuple[str, str, Optional[ ) return None + platform_cfg = self.config.platforms.get(platform) + if platform_cfg is not None and not platform_cfg.gateway_restart_notification: + logger.info( + "Restart notification suppressed: %s has gateway_restart_notification=false", + platform_str, + ) + return None + metadata = {"thread_id": thread_id} if thread_id else None result = await adapter.send( str(chat_id), @@ -11437,6 +11708,14 @@ async def _send_home_channel_startup_notifications( if not home or not home.chat_id: continue + platform_cfg = self.config.platforms.get(platform) + if platform_cfg is not None and not platform_cfg.gateway_restart_notification: + logger.info( + "Home-channel startup notification suppressed: %s has gateway_restart_notification=false", + platform.value, + ) + continue + target = (platform.value, str(home.chat_id), str(home.thread_id) if home.thread_id else None) if target in skipped or target in delivered: continue @@ -11707,6 +11986,10 @@ def _build_process_event_source(self, evt: dict): exc, ) + cached_source = self._get_cached_session_source(session_key) + if cached_source is not None: + return cached_source + _parsed = _parse_session_key(session_key) if _parsed: derived_platform = _parsed["platform"] @@ -12807,6 +13090,24 @@ def _run_still_current() -> bool: last_tool = [None] # Mutable container for tracking in closure last_progress_msg = [None] # Track last message for dedup repeat_count = [0] # How many times the same message repeated + + # Auto-cleanup of temporary progress bubbles (Telegram + any adapter + # that implements ``delete_message``). When enabled via + # ``display.platforms..cleanup_progress: true``, message IDs + # from the tool-progress / "Still working..." / status-callback bubbles + # are collected here and deleted after the final response lands. + # Failed runs skip cleanup so the bubbles remain as breadcrumbs. + _cleanup_progress = bool( + resolve_display_setting(user_config, platform_key, "cleanup_progress") + ) + _cleanup_adapter = self.adapters.get(source.platform) if _cleanup_progress else None + if _cleanup_adapter is not None and ( + type(_cleanup_adapter).delete_message is BasePlatformAdapter.delete_message + ): + # Adapter doesn't support deletion — silently disable. + _cleanup_progress = False + _cleanup_adapter = None + _cleanup_msg_ids: List[str] = [] # First-touch onboarding latch: fires at most once per run, even if # several tools exceed the threshold. long_tool_hint_fired = [False] @@ -12929,12 +13230,19 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non # - Slack DM threading needs event_message_id fallback (reply thread) # - Telegram uses message_thread_id only for forum topics; passing a # normal DM/group message id as thread_id causes send failures + # - Feishu only honors reply_in_thread when sending a reply, so topic + # progress uses the triggering event message as the reply target # - Other platforms should use explicit source.thread_id only if source.platform == Platform.SLACK: _progress_thread_id = source.thread_id or event_message_id else: _progress_thread_id = source.thread_id _progress_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None + _progress_reply_to = ( + event_message_id + if source.platform == Platform.FEISHU and source.thread_id and event_message_id + else None + ) async def send_progress_messages(): if not progress_queue: @@ -13048,17 +13356,40 @@ async def send_progress_messages(): adapter.name, ) can_edit = False - await adapter.send(chat_id=source.chat_id, content=msg, metadata=_progress_metadata) + _flood_result = await adapter.send( + chat_id=source.chat_id, + content=msg, + reply_to=_progress_reply_to, + metadata=_progress_metadata, + ) + if ( + _cleanup_progress + and getattr(_flood_result, "success", False) + and getattr(_flood_result, "message_id", None) + ): + _cleanup_msg_ids.append(str(_flood_result.message_id)) else: if can_edit: # First tool: send all accumulated text as new message full_text = "\n".join(progress_lines) - result = await adapter.send(chat_id=source.chat_id, content=full_text, metadata=_progress_metadata) + result = await adapter.send( + chat_id=source.chat_id, + content=full_text, + reply_to=_progress_reply_to, + metadata=_progress_metadata, + ) else: # Editing unsupported: send just this line - result = await adapter.send(chat_id=source.chat_id, content=msg, metadata=_progress_metadata) + result = await adapter.send( + chat_id=source.chat_id, + content=msg, + reply_to=_progress_reply_to, + metadata=_progress_metadata, + ) if result.success and result.message_id: progress_msg_id = result.message_id + if _cleanup_progress: + _cleanup_msg_ids.append(str(result.message_id)) _last_edit_ts = time.monotonic() @@ -13156,13 +13487,23 @@ def _step_callback_sync(iteration: int, prev_tools: list) -> None: # Bridge sync status_callback → async adapter.send for context pressure _status_adapter = self.adapters.get(source.platform) _status_chat_id = source.chat_id - _status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None + if source.platform == Platform.FEISHU and source.thread_id and event_message_id: + # Feishu topics only keep messages inside the topic when they are + # sent via the reply API with reply_in_thread=true. Status/interim, + # approval, and stream-consumer paths usually only receive metadata, + # so carry the triggering message id as a Feishu-specific fallback. + _status_thread_metadata: Optional[Dict[str, Any]] = { + "thread_id": _progress_thread_id, + "reply_to_message_id": event_message_id, + } + else: + _status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None def _status_callback_sync(event_type: str, message: str) -> None: if not _status_adapter or not _run_still_current(): return try: - asyncio.run_coroutine_threadsafe( + _fut = asyncio.run_coroutine_threadsafe( _status_adapter.send( _status_chat_id, message, @@ -13170,6 +13511,16 @@ def _status_callback_sync(event_type: str, message: str) -> None: ), _loop_for_step, ) + if _cleanup_progress: + def _track_status_id(fut) -> None: + try: + res = fut.result() + except Exception: + return + mid = getattr(res, "message_id", None) + if getattr(res, "success", False) and mid: + _cleanup_msg_ids.append(str(mid)) + _fut.add_done_callback(_track_status_id) except Exception as _e: logger.debug("status_callback error (%s): %s", event_type, _e) @@ -13203,13 +13554,9 @@ def run_sync(): combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip() # Re-read .env and config for fresh credentials (gateway is long-lived, - # keys may change without restart). - try: - load_dotenv(_env_path, override=True, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(_env_path, override=True, encoding="latin-1") - except Exception: - pass + # keys may change without restart). Keep config.yaml authoritative for + # runtime budget settings bridged into env vars. + _reload_runtime_env_preserving_config_authority() try: model, runtime_kwargs = self._resolve_session_agent_runtime( @@ -13300,7 +13647,7 @@ def run_sync(): adapter=_adapter, chat_id=source.chat_id, config=_consumer_cfg, - metadata={"thread_id": _progress_thread_id} if _progress_thread_id else None, + metadata=_status_thread_metadata, on_new_message=( (lambda: progress_queue.put(("__reset__",))) if progress_queue is not None @@ -13777,6 +14124,11 @@ def _approval_notify_sync(approval_data: dict) -> None: "messages": result.get("messages", []), "api_calls": result.get("api_calls", 0), "failed": result.get("failed", False), + "partial": result.get("partial", False), + "completed": result.get("completed"), + "interrupted": result.get("interrupted", False), + "interrupt_message": result.get("interrupt_message"), + "error": result.get("error"), "compression_exhausted": result.get("compression_exhausted", False), "tools": tools_holder[0] or [], "history_offset": len(agent_history), @@ -13892,6 +14244,11 @@ def _approval_notify_sync(approval_data: dict) -> None: "last_reasoning": result.get("last_reasoning"), "messages": result_holder[0].get("messages", []) if result_holder[0] else [], "api_calls": result_holder[0].get("api_calls", 0) if result_holder[0] else 0, + "completed": result_holder[0].get("completed") if result_holder[0] else None, + "interrupted": result_holder[0].get("interrupted", False) if result_holder[0] else False, + "partial": result_holder[0].get("partial", False) if result_holder[0] else False, + "error": result_holder[0].get("error") if result_holder[0] else None, + "interrupt_message": result_holder[0].get("interrupt_message") if result_holder[0] else None, "tools": tools_holder[0] or [], "history_offset": _effective_history_offset, "last_prompt_tokens": _last_prompt_toks, @@ -14030,11 +14387,17 @@ async def _notify_long_running(): except Exception: pass try: - await _notify_adapter.send( + _notify_res = await _notify_adapter.send( source.chat_id, f"⏳ Still working... ({_elapsed_mins} min elapsed{_status_detail})", metadata=_status_thread_metadata, ) + if ( + _cleanup_progress + and getattr(_notify_res, "success", False) + and getattr(_notify_res, "message_id", None) + ): + _cleanup_msg_ids.append(str(_notify_res.message_id)) except Exception as _ne: logger.debug("Long-running notification error: %s", _ne) @@ -14508,7 +14871,49 @@ async def _notify_long_running(): _previewed, ) response["already_sent"] = True - + + # Schedule deletion of tracked temporary progress bubbles after the + # final response lands. Failed runs skip this so bubbles remain as + # breadcrumbs for the user to see what work happened. Only fires on + # adapters that support ``delete_message`` (see init above); failures + # are swallowed — deletion is best-effort. + if ( + _cleanup_progress + and _cleanup_adapter is not None + and _cleanup_msg_ids + and session_key + and isinstance(response, dict) + and not response.get("failed") + and hasattr(_cleanup_adapter, "register_post_delivery_callback") + ): + _ids_snapshot = list(_cleanup_msg_ids) + _chat_id_snapshot = source.chat_id + _adapter_snapshot = _cleanup_adapter + _loop_snapshot = asyncio.get_running_loop() + + def _cleanup_temp_bubbles() -> None: + async def _delete_all() -> None: + for _mid in _ids_snapshot: + try: + await _adapter_snapshot.delete_message( + _chat_id_snapshot, _mid + ) + except Exception: + pass + try: + asyncio.run_coroutine_threadsafe(_delete_all(), _loop_snapshot) + except Exception: + pass + + try: + _cleanup_adapter.register_post_delivery_callback( + session_key, + _cleanup_temp_bubbles, + generation=run_generation, + ) + except Exception as _rpe: + logger.debug("Post-delivery cleanup registration failed: %s", _rpe) + return response diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6695c9ab9570..3fa726d6a7ed 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -418,7 +418,7 @@ class ProviderConfig: # Auto-extend PROVIDER_REGISTRY with any api-key provider registered in # providers/ that is not already declared above. New providers only need a -# providers/*.py file — no edits to this file required. +# plugins/model-providers// plugin — no edits to this file required. try: from providers import list_providers as _list_providers_for_registry for _pp in _list_providers_for_registry(): @@ -780,42 +780,121 @@ def _auth_file_path() -> Path: return path +def _global_auth_file_path() -> Optional[Path]: + """Return the global-root auth.json when the process is in profile mode. + + Returns ``None`` when the profile and global root resolve to the same + directory (classic mode, or custom HERMES_HOME that is not a profile). + Used by read-only fallback paths so providers authed at the root are + visible to profile processes that haven't configured them locally. + + See issue #18594 follow-up (credential_pool shadowing). + """ + try: + from hermes_constants import get_default_hermes_root + global_root = get_default_hermes_root() + except Exception: + return None + profile_home = get_hermes_home() + try: + if profile_home.resolve(strict=False) == global_root.resolve(strict=False): + return None + except Exception: + if profile_home == global_root: + return None + # No pytest seat belt here: this is a pure read-only path, and + # ``_load_global_auth_store()`` wraps the read in a try/except so an + # unreadable global file can never break the profile process. The + # write-side seat belt still lives on ``_auth_file_path()`` where it + # belongs (that's what protects the real user's auth store from being + # corrupted by a mis-configured test). + return global_root / "auth.json" + + +def _load_global_auth_store() -> Dict[str, Any]: + """Load the global-root auth store (read-only fallback). + + Returns an empty dict when no global fallback exists (classic mode, + or the global auth.json is absent). Never raises on missing file. + + Seat belt: under pytest, refuses to read the real user's + ``~/.hermes/auth.json`` even when HERMES_HOME is set to a profile + path. The hermetic conftest does not redirect ``HOME``, so + ``get_default_hermes_root()`` for a profile-shaped HERMES_HOME can + still resolve to the real user's home on a dev machine. That would + leak real credentials into tests. This guard uses the unmodified + ``HOME`` env var (what ``os.path.expanduser('~')`` would resolve to), + not ``Path.home()``, because ``Path.home`` is sometimes monkeypatched + by fixtures that want to relocate the global root to a tmp path. + """ + global_path = _global_auth_file_path() + if global_path is None or not global_path.exists(): + return {} + if os.environ.get("PYTEST_CURRENT_TEST"): + real_home_env = os.environ.get("HOME", "") + if real_home_env: + real_root = Path(real_home_env) / ".hermes" / "auth.json" + try: + if global_path.resolve(strict=False) == real_root.resolve(strict=False): + return {} + except Exception: + pass + try: + return _load_auth_store(global_path) + except Exception: + # A malformed global store must not break profile reads. The + # profile's own auth store is still authoritative. + return {} + + def _auth_lock_path() -> Path: return _auth_file_path().with_suffix(".lock") _auth_lock_holder = threading.local() + @contextmanager -def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS): - """Cross-process advisory lock for auth.json reads+writes. Reentrant.""" - # Reentrant: if this thread already holds the lock, just yield. - if getattr(_auth_lock_holder, "depth", 0) > 0: - _auth_lock_holder.depth += 1 +def _file_lock( + lock_path: Path, + holder: threading.local, + timeout_seconds: float, + timeout_message: str, +): + """Cross-process advisory flock helper. + + Reentrant per-thread via ``holder.depth``. Falls back to a depth-only + guard when neither ``fcntl`` nor ``msvcrt`` is available (rare). + Callers supply their own ``threading.local`` so independent locks + (e.g. profile auth.json vs shared Nous store) don't share reentrancy + state — that would let one lock's reentrant acquisition silently skip + the other's kernel-level flock. + """ + if getattr(holder, "depth", 0) > 0: + holder.depth += 1 try: yield finally: - _auth_lock_holder.depth -= 1 + holder.depth -= 1 return - lock_path = _auth_lock_path() lock_path.parent.mkdir(parents=True, exist_ok=True) if fcntl is None and msvcrt is None: - _auth_lock_holder.depth = 1 + holder.depth = 1 try: yield finally: - _auth_lock_holder.depth = 0 + holder.depth = 0 return # On Windows, msvcrt.locking needs the file to have content and the - # file pointer at position 0. Ensure the lock file has at least 1 byte. + # file pointer at position 0. Ensure the lock file has at least 1 byte. if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): lock_path.write_text(" ", encoding="utf-8") with lock_path.open("r+" if msvcrt else "a+") as lock_file: - deadline = time.time() + max(1.0, timeout_seconds) + deadline = time.monotonic() + max(1.0, timeout_seconds) while True: try: if fcntl: @@ -825,15 +904,15 @@ def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS): msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) break except (BlockingIOError, OSError, PermissionError): - if time.time() >= deadline: - raise TimeoutError("Timed out waiting for auth store lock") + if time.monotonic() >= deadline: + raise TimeoutError(timeout_message) time.sleep(0.05) - _auth_lock_holder.depth = 1 + holder.depth = 1 try: yield finally: - _auth_lock_holder.depth = 0 + holder.depth = 0 if fcntl: fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) elif msvcrt: @@ -844,6 +923,25 @@ def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS): pass +@contextmanager +def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS): + """Cross-process advisory lock for auth.json reads+writes. Reentrant. + + Lock ordering invariant: when this lock is held together with + ``_nous_shared_store_lock``, acquire ``_auth_store_lock`` FIRST + (outer) and the shared Nous lock SECOND (inner). All runtime + refresh paths follow this order; violating it risks deadlock + against a concurrent import on the shared store. + """ + with _file_lock( + _auth_lock_path(), + _auth_lock_holder, + timeout_seconds, + "Timed out waiting for auth store lock", + ): + yield + + def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: auth_file = auth_file or _auth_file_path() if not auth_file.exists(): @@ -887,12 +985,27 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: def _save_auth_store(auth_store: Dict[str, Any]) -> Path: auth_file = _auth_file_path() auth_file.parent.mkdir(parents=True, exist_ok=True) + # Tighten parent dir to 0o700 so siblings can't traverse to creds. + # No-op on Windows (POSIX mode bits not enforced); ignore failures. + try: + os.chmod(auth_file.parent, 0o700) + except OSError: + pass auth_store["version"] = AUTH_STORE_VERSION auth_store["updated_at"] = datetime.now(timezone.utc).isoformat() payload = json.dumps(auth_store, indent=2) + "\n" tmp_path = auth_file.with_name(f"{auth_file.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") try: - with tmp_path.open("w", encoding="utf-8") as handle: + # Create with 0o600 atomically via os.open(O_EXCL) + fdopen to close + # the TOCTOU window where default umask (often 0o644) briefly exposed + # OAuth tokens to other local users between open() and chmod(). + # Mirrors agent/google_oauth.py (#19673) and tools/mcp_oauth.py (#21148). + fd = os.open( + str(tmp_path), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + stat.S_IRUSR | stat.S_IWUSR, + ) + with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.write(payload) handle.flush() os.fsync(handle.fileno()) @@ -966,15 +1079,50 @@ def get_auth_provider_display_name(provider_id: str) -> str: def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]: - """Return the persisted credential pool, or one provider slice.""" + """Return the persisted credential pool, or one provider slice. + + In profile mode, the profile's credential pool is authoritative. If a + provider has no entries in the profile, entries from the global-root + ``auth.json`` are used as a read-only fallback — so workers spawned in a + profile can see providers that were only authenticated at global scope. + + Profile entries always win: the global fallback only applies per-provider + when the profile has zero entries for that provider. Once the user runs + ``hermes auth add `` inside the profile, profile entries + fully shadow global for that provider on the next read. + + Writes always go to the profile (``write_credential_pool`` is unchanged). + See issue #18594 follow-up. + """ auth_store = _load_auth_store() pool = auth_store.get("credential_pool") if not isinstance(pool, dict): pool = {} + + global_pool: Dict[str, Any] = {} + global_store = _load_global_auth_store() + maybe_global_pool = global_store.get("credential_pool") if global_store else None + if isinstance(maybe_global_pool, dict): + global_pool = maybe_global_pool + if provider_id is None: - return dict(pool) + merged = dict(pool) + for gp_key, gp_entries in global_pool.items(): + if not isinstance(gp_entries, list) or not gp_entries: + continue + # Per-provider shadowing: profile wins whenever it has ANY entries. + existing = merged.get(gp_key) + if isinstance(existing, list) and existing: + continue + merged[gp_key] = list(gp_entries) + return merged + provider_entries = pool.get(provider_id) - return list(provider_entries) if isinstance(provider_entries, list) else [] + if isinstance(provider_entries, list) and provider_entries: + return list(provider_entries) + # Profile has no entries for this provider — fall back to global. + global_entries = global_pool.get(provider_id) + return list(global_entries) if isinstance(global_entries, list) else [] def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path: @@ -1033,9 +1181,25 @@ def unsuppress_credential_source(provider_id: str, source: str) -> bool: def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]: - """Return persisted auth state for a provider, or None.""" + """Return persisted auth state for a provider, or None. + + In profile mode, falls back to the global-root ``auth.json`` when the + profile has no state for this provider. Profile state always wins when + present. Writes (``_save_auth_store`` / ``persist_*_credentials``) are + unchanged — they still target the profile only. This mirrors + ``read_credential_pool``'s per-provider shadowing semantics so that + ``_seed_from_singletons`` can reseed a profile's credential pool from + global-scope provider state (e.g. a globally-authenticated Anthropic + OAuth or Nous device-code session). See issue #18594 follow-up. + """ auth_store = _load_auth_store() - return _load_provider_state(auth_store, provider_id) + state = _load_provider_state(auth_store, provider_id) + if state is not None: + return state + global_store = _load_global_auth_store() + if not global_store: + return None + return _load_provider_state(global_store, provider_id) def get_active_provider() -> Optional[str]: @@ -1229,7 +1393,7 @@ def resolve_provider( "vllm": "custom", "llamacpp": "custom", "llama.cpp": "custom", "llama-cpp": "custom", } - # Extend with aliases declared in providers/*.py that aren't already mapped. + # Extend with aliases declared in plugins/model-providers// that aren't already mapped. # This keeps providers/ as the single source for new aliases while the # hardcoded dict above remains authoritative for existing ones. try: @@ -1405,10 +1569,33 @@ def _read_qwen_cli_tokens() -> Dict[str, Any]: def _save_qwen_cli_tokens(tokens: Dict[str, Any]) -> Path: auth_path = _qwen_cli_auth_path() auth_path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = auth_path.with_suffix(".tmp") - tmp_path.write_text(json.dumps(tokens, indent=2, sort_keys=True) + "\n", encoding="utf-8") - os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) - tmp_path.replace(auth_path) + try: + os.chmod(auth_path.parent, 0o700) + except OSError: + pass + # Per-process random temp suffix avoids collisions between concurrent + # writers and stale leftovers from a crashed prior write. + tmp_path = auth_path.with_name(f"{auth_path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") + # Create with 0o600 atomically via os.open(O_EXCL) — closes the TOCTOU + # window where write_text() + post-write chmod briefly exposed tokens + # at process umask (typically 0o644). See #19673, #21148. + fd = os.open( + str(tmp_path), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + stat.S_IRUSR | stat.S_IWUSR, + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(json.dumps(tokens, indent=2, sort_keys=True) + "\n") + fh.flush() + os.fsync(fh.fileno()) + atomic_replace(tmp_path, auth_path) + finally: + try: + if tmp_path.exists(): + tmp_path.unlink() + except OSError: + pass return auth_path @@ -1825,9 +2012,9 @@ class _ReuseHTTPServer(HTTPServer): thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True) thread.start() - deadline = time.time() + max(5.0, timeout_seconds) + deadline = time.monotonic() + max(5.0, timeout_seconds) try: - while time.time() < deadline: + while time.monotonic() < deadline: if result["code"] or result["error"]: return result time.sleep(0.1) @@ -2590,10 +2777,10 @@ def _poll_for_token( poll_interval: int, ) -> Dict[str, Any]: """Poll the token endpoint until the user approves or the code expires.""" - deadline = time.time() + max(1, expires_in) + deadline = time.monotonic() + max(1, expires_in) current_interval = max(1, min(poll_interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS)) - while time.time() < deadline: + while time.monotonic() < deadline: response = client.post( f"{portal_base_url}/api/oauth/token", data={ @@ -2651,6 +2838,7 @@ def _poll_for_token( # ----------------------------------------------------------------------------- NOUS_SHARED_STORE_FILENAME = "nous_auth.json" +_nous_shared_lock_holder = threading.local() def _nous_shared_auth_dir() -> Path: @@ -2690,6 +2878,69 @@ def _nous_shared_store_path() -> Path: return path +@contextmanager +def _nous_shared_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS): + """Cross-profile lock for the shared Nous OAuth store. + + Lock ordering invariant: if both this and ``_auth_store_lock`` need + to be held, acquire ``_auth_store_lock`` FIRST. All runtime refresh + paths follow this order. The one exception is + ``_try_import_shared_nous_state``, which holds this lock alone for + the entire refresh+mint cycle so concurrent imports on sibling + profiles can't race on the single-use shared refresh token; that + helper must NOT be called with ``_auth_store_lock`` already held. + """ + try: + lock_path = _nous_shared_store_path().with_suffix(".lock") + except RuntimeError: + # No HERMES_HOME yet (pre-setup): fall through without locking. + yield + return + + with _file_lock( + lock_path, + _nous_shared_lock_holder, + timeout_seconds, + "Timed out waiting for shared Nous auth lock", + ): + yield + + +def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool: + """Copy fresher shared OAuth tokens into a profile-local Nous state.""" + shared = _read_shared_nous_state() + if not shared: + return False + + shared_refresh = shared.get("refresh_token") + if not isinstance(shared_refresh, str) or not shared_refresh.strip(): + return False + + local_refresh = state.get("refresh_token") + shared_access_exp = _parse_iso_timestamp(shared.get("expires_at")) or 0.0 + local_access_exp = _parse_iso_timestamp(state.get("expires_at")) or 0.0 + refresh_changed = shared_refresh.strip() != str(local_refresh or "").strip() + fresher_access = shared_access_exp > local_access_exp + if not refresh_changed and not fresher_access: + return False + + for key in ( + "access_token", + "refresh_token", + "token_type", + "scope", + "client_id", + "portal_base_url", + "inference_base_url", + "obtained_at", + "expires_at", + ): + value = shared.get(key) + if value not in (None, ""): + state[key] = value + return True + + def _write_shared_nous_state(state: Dict[str, Any]) -> None: """Persist a minimal copy of the Nous OAuth state to the shared store. @@ -2722,15 +2973,34 @@ def _write_shared_nous_state(state: Dict[str, Any]) -> None: "updated_at": datetime.now(timezone.utc).isoformat(), } try: - path = _nous_shared_store_path() - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(json.dumps(shared, indent=2, sort_keys=True)) - try: - os.chmod(tmp, 0o600) - except OSError: - pass - os.replace(tmp, path) + with _nous_shared_store_lock(): + path = _nous_shared_store_path() + path.parent.mkdir(parents=True, exist_ok=True) + try: + os.chmod(path.parent, 0o700) + except OSError: + pass + tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") + # Create with 0o600 atomically via os.open(O_EXCL) — closes the TOCTOU + # window where write_text() + post-write chmod briefly exposed Nous + # refresh_token at process umask. See #19673, #21148. + fd = os.open( + str(tmp), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + stat.S_IRUSR | stat.S_IWUSR, + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(json.dumps(shared, indent=2, sort_keys=True)) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + finally: + try: + if tmp.exists(): + tmp.unlink() + except OSError: + pass _oauth_trace( "nous_shared_store_written", path=str(path), @@ -2787,36 +3057,38 @@ def _try_import_shared_nous_state( etc.) — caller should then fall through to the normal device-code flow. """ - shared = _read_shared_nous_state() - if not shared: - return None + try: + with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)): + shared = _read_shared_nous_state() + if not shared: + return None - # Build a full state dict so refresh_nous_oauth_from_state has every - # field it needs. force_refresh=True gets us a fresh access_token - # for this profile; force_mint=True gets us a fresh agent_key. - state: Dict[str, Any] = { - "access_token": shared.get("access_token"), - "refresh_token": shared.get("refresh_token"), - "client_id": shared.get("client_id") or DEFAULT_NOUS_CLIENT_ID, - "portal_base_url": shared.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL, - "inference_base_url": shared.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL, - "token_type": shared.get("token_type") or "Bearer", - "scope": shared.get("scope") or DEFAULT_NOUS_SCOPE, - "obtained_at": shared.get("obtained_at"), - "expires_at": shared.get("expires_at"), - "agent_key": None, - "agent_key_expires_at": None, - "tls": {"insecure": False, "ca_bundle": None}, - } + # Build a full state dict so refresh_nous_oauth_from_state has every + # field it needs. force_refresh=True gets us a fresh access_token + # for this profile; force_mint=True gets us a fresh agent_key. + state: Dict[str, Any] = { + "access_token": shared.get("access_token"), + "refresh_token": shared.get("refresh_token"), + "client_id": shared.get("client_id") or DEFAULT_NOUS_CLIENT_ID, + "portal_base_url": shared.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL, + "inference_base_url": shared.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL, + "token_type": shared.get("token_type") or "Bearer", + "scope": shared.get("scope") or DEFAULT_NOUS_SCOPE, + "obtained_at": shared.get("obtained_at"), + "expires_at": shared.get("expires_at"), + "agent_key": None, + "agent_key_expires_at": None, + "tls": {"insecure": False, "ca_bundle": None}, + } - try: - refreshed = refresh_nous_oauth_from_state( - state, - min_key_ttl_seconds=min_key_ttl_seconds, - timeout_seconds=timeout_seconds, - force_refresh=True, - force_mint=True, - ) + refreshed = refresh_nous_oauth_from_state( + state, + min_key_ttl_seconds=min_key_ttl_seconds, + timeout_seconds=timeout_seconds, + force_refresh=True, + force_mint=True, + ) + _write_shared_nous_state(refreshed) except AuthError as exc: _oauth_trace( "nous_shared_import_failed", @@ -3018,59 +3290,65 @@ def resolve_nous_access_token( client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state) - access_token = state.get("access_token") - refresh_token = state.get("refresh_token") - if not isinstance(access_token, str) or not access_token: - raise AuthError( - "No access token found for Nous Portal login.", - provider="nous", - relogin_required=True, - ) + with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)): + merged_shared = _merge_shared_nous_oauth_state(state) + access_token = state.get("access_token") + refresh_token = state.get("refresh_token") + if not isinstance(access_token, str) or not access_token: + raise AuthError( + "No access token found for Nous Portal login.", + provider="nous", + relogin_required=True, + ) - if not _is_expiring(state.get("expires_at"), refresh_skew_seconds): - return access_token + if not _is_expiring(state.get("expires_at"), refresh_skew_seconds): + if merged_shared: + _save_provider_state(auth_store, "nous", state) + _save_auth_store(auth_store) + return access_token - if not isinstance(refresh_token, str) or not refresh_token: - raise AuthError( - "Session expired and no refresh token is available.", - provider="nous", - relogin_required=True, - ) + if not isinstance(refresh_token, str) or not refresh_token: + raise AuthError( + "Session expired and no refresh token is available.", + provider="nous", + relogin_required=True, + ) - timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0) - with httpx.Client( - timeout=timeout, - headers={"Accept": "application/json"}, - verify=verify, - ) as client: - refreshed = _refresh_access_token( - client=client, - portal_base_url=portal_base_url, - client_id=client_id, - refresh_token=refresh_token, - ) + timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0) + with httpx.Client( + timeout=timeout, + headers={"Accept": "application/json"}, + verify=verify, + ) as client: + refreshed = _refresh_access_token( + client=client, + portal_base_url=portal_base_url, + client_id=client_id, + refresh_token=refresh_token, + ) - now = datetime.now(timezone.utc) - access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) - state["access_token"] = refreshed["access_token"] - state["refresh_token"] = refreshed.get("refresh_token") or refresh_token - state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" - state["scope"] = refreshed.get("scope") or state.get("scope") - state["obtained_at"] = now.isoformat() - state["expires_in"] = access_ttl - state["expires_at"] = datetime.fromtimestamp( - now.timestamp() + access_ttl, - tz=timezone.utc, - ).isoformat() - state["portal_base_url"] = portal_base_url - state["client_id"] = client_id - state["tls"] = { - "insecure": verify is False, - "ca_bundle": verify if isinstance(verify, str) else None, - } - _save_provider_state(auth_store, "nous", state) - _save_auth_store(auth_store) - return state["access_token"] + now = datetime.now(timezone.utc) + access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) + state["access_token"] = refreshed["access_token"] + state["refresh_token"] = refreshed.get("refresh_token") or refresh_token + state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" + state["scope"] = refreshed.get("scope") or state.get("scope") + state["obtained_at"] = now.isoformat() + state["expires_in"] = access_ttl + state["expires_at"] = datetime.fromtimestamp( + now.timestamp() + access_ttl, + tz=timezone.utc, + ).isoformat() + state["portal_base_url"] = portal_base_url + state["client_id"] = client_id + state["tls"] = { + "insecure": verify is False, + "ca_bundle": verify if isinstance(verify, str) else None, + } + _save_provider_state(auth_store, "nous", state) + _save_auth_store(auth_store) + _write_shared_nous_state(state) + return state["access_token"] def refresh_nous_oauth_pure( @@ -3338,46 +3616,53 @@ def _persist_state(reason: str) -> None: # Step 1: refresh access token if expiring if _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS): - if not isinstance(refresh_token, str) or not refresh_token: - raise AuthError("Session expired and no refresh token is available.", - provider="nous", relogin_required=True) + with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)): + if _merge_shared_nous_oauth_state(state): + access_token = state.get("access_token") + refresh_token = state.get("refresh_token") + _persist_state("post_shared_merge_access_expiring") - _oauth_trace( - "refresh_start", - sequence_id=sequence_id, - reason="access_expiring", - refresh_token_fp=_token_fingerprint(refresh_token), - ) - refreshed = _refresh_access_token( - client=client, portal_base_url=portal_base_url, - client_id=client_id, refresh_token=refresh_token, - ) - now = datetime.now(timezone.utc) - access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) - previous_refresh_token = refresh_token - state["access_token"] = refreshed["access_token"] - state["refresh_token"] = refreshed.get("refresh_token") or refresh_token - state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" - state["scope"] = refreshed.get("scope") or state.get("scope") - refreshed_url = _optional_base_url(refreshed.get("inference_base_url")) - if refreshed_url: - inference_base_url = refreshed_url - state["obtained_at"] = now.isoformat() - state["expires_in"] = access_ttl - state["expires_at"] = datetime.fromtimestamp( - now.timestamp() + access_ttl, tz=timezone.utc - ).isoformat() - access_token = state["access_token"] - refresh_token = state["refresh_token"] - _oauth_trace( - "refresh_success", - sequence_id=sequence_id, - reason="access_expiring", - previous_refresh_token_fp=_token_fingerprint(previous_refresh_token), - new_refresh_token_fp=_token_fingerprint(refresh_token), - ) - # Persist immediately so downstream mint failures cannot drop rotated refresh tokens. - _persist_state("post_refresh_access_expiring") + if _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS): + if not isinstance(refresh_token, str) or not refresh_token: + raise AuthError("Session expired and no refresh token is available.", + provider="nous", relogin_required=True) + + _oauth_trace( + "refresh_start", + sequence_id=sequence_id, + reason="access_expiring", + refresh_token_fp=_token_fingerprint(refresh_token), + ) + refreshed = _refresh_access_token( + client=client, portal_base_url=portal_base_url, + client_id=client_id, refresh_token=refresh_token, + ) + now = datetime.now(timezone.utc) + access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) + previous_refresh_token = refresh_token + state["access_token"] = refreshed["access_token"] + state["refresh_token"] = refreshed.get("refresh_token") or refresh_token + state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" + state["scope"] = refreshed.get("scope") or state.get("scope") + refreshed_url = _optional_base_url(refreshed.get("inference_base_url")) + if refreshed_url: + inference_base_url = refreshed_url + state["obtained_at"] = now.isoformat() + state["expires_in"] = access_ttl + state["expires_at"] = datetime.fromtimestamp( + now.timestamp() + access_ttl, tz=timezone.utc + ).isoformat() + access_token = state["access_token"] + refresh_token = state["refresh_token"] + _oauth_trace( + "refresh_success", + sequence_id=sequence_id, + reason="access_expiring", + previous_refresh_token_fp=_token_fingerprint(previous_refresh_token), + new_refresh_token_fp=_token_fingerprint(refresh_token), + ) + # Persist immediately so downstream mint failures cannot drop rotated refresh tokens. + _persist_state("post_refresh_access_expiring") # Step 2: mint agent key if missing/expiring used_cached_key = False @@ -3410,41 +3695,47 @@ def _persist_state(reason: str) -> None: and isinstance(latest_refresh_token, str) and latest_refresh_token ): - _oauth_trace( - "refresh_start", - sequence_id=sequence_id, - reason="mint_retry_after_invalid_token", - refresh_token_fp=_token_fingerprint(latest_refresh_token), - ) - refreshed = _refresh_access_token( - client=client, portal_base_url=portal_base_url, - client_id=client_id, refresh_token=latest_refresh_token, - ) - now = datetime.now(timezone.utc) - access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) - state["access_token"] = refreshed["access_token"] - state["refresh_token"] = refreshed.get("refresh_token") or latest_refresh_token - state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" - state["scope"] = refreshed.get("scope") or state.get("scope") - refreshed_url = _optional_base_url(refreshed.get("inference_base_url")) - if refreshed_url: - inference_base_url = refreshed_url - state["obtained_at"] = now.isoformat() - state["expires_in"] = access_ttl - state["expires_at"] = datetime.fromtimestamp( - now.timestamp() + access_ttl, tz=timezone.utc - ).isoformat() - access_token = state["access_token"] - refresh_token = state["refresh_token"] - _oauth_trace( - "refresh_success", - sequence_id=sequence_id, - reason="mint_retry_after_invalid_token", - previous_refresh_token_fp=_token_fingerprint(latest_refresh_token), - new_refresh_token_fp=_token_fingerprint(refresh_token), - ) - # Persist retry refresh immediately for crash safety and cross-process visibility. - _persist_state("post_refresh_mint_retry") + with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)): + if _merge_shared_nous_oauth_state(state): + access_token = state.get("access_token") + latest_refresh_token = state.get("refresh_token") + _persist_state("post_shared_merge_mint_retry") + else: + _oauth_trace( + "refresh_start", + sequence_id=sequence_id, + reason="mint_retry_after_invalid_token", + refresh_token_fp=_token_fingerprint(latest_refresh_token), + ) + refreshed = _refresh_access_token( + client=client, portal_base_url=portal_base_url, + client_id=client_id, refresh_token=latest_refresh_token, + ) + now = datetime.now(timezone.utc) + access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) + state["access_token"] = refreshed["access_token"] + state["refresh_token"] = refreshed.get("refresh_token") or latest_refresh_token + state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" + state["scope"] = refreshed.get("scope") or state.get("scope") + refreshed_url = _optional_base_url(refreshed.get("inference_base_url")) + if refreshed_url: + inference_base_url = refreshed_url + state["obtained_at"] = now.isoformat() + state["expires_in"] = access_ttl + state["expires_at"] = datetime.fromtimestamp( + now.timestamp() + access_ttl, tz=timezone.utc + ).isoformat() + access_token = state["access_token"] + refresh_token = state["refresh_token"] + _oauth_trace( + "refresh_success", + sequence_id=sequence_id, + reason="mint_retry_after_invalid_token", + previous_refresh_token_fp=_token_fingerprint(latest_refresh_token), + new_refresh_token_fp=_token_fingerprint(refresh_token), + ) + # Persist retry refresh immediately for crash safety and cross-process visibility. + _persist_state("post_refresh_mint_retry") mint_payload = _mint_agent_key( client=client, portal_base_url=portal_base_url, @@ -3940,6 +4231,14 @@ def _config_provider_matches(provider_id: Optional[str]) -> bool: return _get_config_provider() == provider_id.strip().lower() +def _should_reset_config_provider_on_logout(provider_id: Optional[str]) -> bool: + """Return True when logout should reset the model provider config.""" + if not provider_id: + return False + normalized = provider_id.strip().lower() + return normalized in PROVIDER_REGISTRY and _config_provider_matches(normalized) + + def _logout_default_provider_from_config() -> Optional[str]: """Fallback logout target when auth.json has no active provider. @@ -5025,15 +5324,18 @@ def logout_command(args) -> None: print("No provider is currently logged in.") return - config_matches = _config_provider_matches(target) + should_reset_config = _should_reset_config_provider_on_logout(target) provider_name = get_auth_provider_display_name(target) - if clear_provider_auth(target) or config_matches: - _reset_config_provider() + if clear_provider_auth(target) or should_reset_config: + if should_reset_config: + _reset_config_provider() print(f"Logged out of {provider_name}.") - if os.getenv("OPENROUTER_API_KEY"): + if should_reset_config and os.getenv("OPENROUTER_API_KEY"): print("Hermes will use OpenRouter for inference.") - else: + elif should_reset_config: print("Run `hermes model` or configure an API key to use Hermes.") + else: + print("Model provider configuration was unchanged.") else: print(f"No auth state found for {provider_name}.") diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 2cf2c3e9f400..6b9f7f92c5e8 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -157,9 +157,9 @@ class CommandDef: CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]", subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), - CommandDef("curator", "Background skill maintenance (status, run, pin, archive)", + CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)", "Tools & Skills", args_hint="[subcommand]", - subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore")), + subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")), CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)", "Tools & Skills", args_hint="[subcommand]", subcommands=("list", "ls", "show", "create", "assign", "link", "unlink", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5f6e915a7b31..aceaecc12d00 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -544,7 +544,13 @@ def _ensure_hermes_home_managed(home: Path): # via TERMINAL_LOCAL_PERSISTENT env var. "persistent_shell": True, }, - + + "web": { + "backend": "", # shared fallback — applies to both search and extract + "search_backend": "", # per-capability override for web_search (e.g. "searxng") + "extract_backend": "", # per-capability override for web_extract (e.g. "native") + }, + "browser": { "inactivity_timeout": 120, "command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.) @@ -1103,6 +1109,12 @@ def _ensure_hermes_home_managed(home: Path): "auto_thread": True, # Auto-create threads on @mention in channels (like Slack) "reactions": True, # Add 👀/✅/❌ reactions to messages during processing "channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads) + # Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES + # authorizes only guild messages in the role's own guild — DMs require + # DISCORD_ALLOWED_USERS. Set dm_role_auth_guild to a guild ID to also + # authorize DMs from members of that one trusted guild holding the + # allowed role. Unset / empty / 0 = secure default (DM role-auth off). + "dm_role_auth_guild": "", # discord / discord_admin tools: restrict which actions the agent may call. # Default (empty) = all actions allowed (subject to bot privileged intents). # Accepts comma-separated string ("list_guilds,list_channels,fetch_messages") @@ -1186,7 +1198,7 @@ def _ensure_hermes_home_managed(home: Path): # Pre-exec security scanning via tirith "security": { "allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs) - "redact_secrets": False, + "redact_secrets": True, "tirith_enabled": True, "tirith_path": "tirith", "tirith_timeout": 5, @@ -1225,6 +1237,10 @@ def _ensure_hermes_home_managed(home: Path): # Seconds between dispatcher ticks (idle or not). Lower = snappier # pickup of newly-ready tasks; higher = less SQL pressure. "dispatch_interval_seconds": 60, + # Auto-block after this many consecutive non-success attempts for the + # same task/profile (spawn_failed, timed_out, or crashed). Reassignment + # resets the streak for the new profile. + "failure_limit": 2, }, # execute_code settings — controls the tool used for programmatic tool calls. @@ -1827,6 +1843,14 @@ def _ensure_hermes_home_managed(home: Path): "password": True, "category": "tool", }, + "SEARXNG_URL": { + "description": "URL of your SearXNG instance for free self-hosted web search", + "prompt": "SearXNG URL (e.g. http://localhost:8080)", + "url": "https://searxng.github.io/searxng/", + "tools": ["web_search"], + "password": False, + "category": "tool", + }, "BROWSERBASE_API_KEY": { "description": "Browserbase API key for cloud browser (optional — local browser works without this)", "prompt": "Browserbase API key", @@ -1945,7 +1969,7 @@ def _ensure_hermes_home_managed(home: Path): "LINEAR_API_KEY": { "description": "Linear personal API key (used by the `linear` skill)", "prompt": "Linear API key", - "url": "https://linear.app/settings/api", + "url": "https://linear.app/settings/account/security", "password": True, "category": "skill", "advanced": True, @@ -3961,10 +3985,10 @@ def load_config() -> Dict[str, Any]: _SECURITY_COMMENT = """ # ── Security ────────────────────────────────────────────────────────── -# Secret redaction is OFF by default — tool output (terminal stdout, -# read_file results, web content) passes through unmodified. Set -# redact_secrets to true to mask strings that look like API keys, tokens, -# and passwords before they enter the model context and logs. +# Secret redaction is ON by default — strings that look like API keys, +# tokens, and passwords are masked in tool output, logs, and chat +# responses before the model or user ever sees them. Set redact_secrets +# to false to disable (e.g. when developing the redactor itself). # tirith pre-exec scanning is enabled by default when the tirith binary # is available. Configure via security.tirith_* keys or env vars # (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN). @@ -4004,8 +4028,8 @@ def load_config() -> Dict[str, Any]: _COMMENTED_SECTIONS = """ # ── Security ────────────────────────────────────────────────────────── -# Secret redaction is OFF by default. Set to true to mask strings that -# look like API keys, tokens, and passwords in tool output and logs. +# Secret redaction is ON by default. Set to false to pass tool output, +# logs, and chat responses through unmodified (e.g. for redactor dev). # # security: # redact_secrets: true diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index 348e4efe83c8..7475f80a2b1d 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -212,9 +212,9 @@ def copilot_device_code_login( print(" Waiting for authorization...", end="", flush=True) # Step 3: Poll for completion - deadline = time.time() + timeout_seconds + deadline = time.monotonic() + timeout_seconds - while time.time() < deadline: + while time.monotonic() < deadline: time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN) poll_data = urllib.parse.urlencode({ diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index 50c297217c5a..318c4a09720d 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -12,6 +12,7 @@ import argparse import sys from datetime import datetime, timezone +from pathlib import Path from typing import Optional @@ -57,7 +58,8 @@ def _cmd_status(args) -> int: print(f" last summary: {summary}") _report = state.get("last_report_path") if _report: - print(f" last report: {_report}") + suffix = "" if Path(_report).exists() else " (missing)" + print(f" last report: {_report}{suffix}") _ih = curator.get_interval_hours() _interval_label = ( f"{_ih // 24}d" if _ih % 24 == 0 and _ih >= 24 @@ -161,6 +163,8 @@ def _cmd_run(args) -> int: return 1 dry = bool(getattr(args, "dry_run", False)) + background = bool(getattr(args, "background", False)) + synchronous = bool(getattr(args, "synchronous", False)) or not background if dry: print("curator: running DRY-RUN (report only, no mutations)...") else: @@ -171,7 +175,7 @@ def _on_summary(msg: str) -> None: result = curator.run_curator_review( on_summary=_on_summary, - synchronous=bool(args.synchronous), + synchronous=synchronous, dry_run=dry, ) auto = result.get("auto_transitions", {}) @@ -188,13 +192,19 @@ def _on_summary(msg: str) -> None: f"archived={auto.get('archived', 0)} " f"reactivated={auto.get('reactivated', 0)}" ) - if not args.synchronous: + if not synchronous: print("llm pass running in background — check `hermes curator status` later") if dry: - print( - "dry-run: no changes applied. When the report lands, read it with " - "`hermes curator status` and run `hermes curator run` (no flag) to apply." - ) + if synchronous: + print( + "dry-run: no changes applied. Read the report with " + "`hermes curator status` and run `hermes curator run` (no flag) to apply." + ) + else: + print( + "dry-run: no changes applied. When the report lands, read it with " + "`hermes curator status` and run `hermes curator run` (no flag) to apply." + ) return 0 @@ -442,6 +452,18 @@ def _cmd_rollback(args) -> int: return 1 +def _cmd_list_archived(args) -> int: + """List archived (recoverable) skills.""" + from tools import skill_usage + names = skill_usage.list_archived_skill_names() + if not names: + print("curator: no archived skills") + return 0 + for name in names: + print(name) + return 0 + + # --------------------------------------------------------------------------- # argparse wiring (called from hermes_cli.main) # --------------------------------------------------------------------------- @@ -461,7 +483,11 @@ def register_cli(parent: argparse.ArgumentParser) -> None: p_run = subs.add_parser("run", help="Trigger a curator review now") p_run.add_argument( "--sync", "--synchronous", dest="synchronous", action="store_true", - help="Wait for the LLM review pass to finish (default: background thread)", + help="Wait for the LLM review pass to finish (default for manual runs)", + ) + p_run.add_argument( + "--background", dest="background", action="store_true", + help="Start the LLM review pass in a background thread and return immediately", ) p_run.add_argument( "--dry-run", dest="dry_run", action="store_true", @@ -488,6 +514,9 @@ def register_cli(parent: argparse.ArgumentParser) -> None: p_restore.add_argument("skill", help="Skill name") p_restore.set_defaults(func=_cmd_restore) + subs.add_parser("list-archived", help="List archived skills") \ + .set_defaults(func=_cmd_list_archived) + p_archive = subs.add_parser( "archive", help="Manually archive a skill (move to .archive/, excluded from prompt)", diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 4940b7fa5a11..4b3ce3b7cf36 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -197,7 +197,7 @@ def _build_apikey_providers_list() -> list: Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint) Base list augmented with any ProviderProfile with auth_type="api_key" not - already present — adding providers/*.py is sufficient to get into doctor. + already present — adding plugins/model-providers// is sufficient to get into doctor. """ _static = [ ("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True), @@ -1225,6 +1225,16 @@ def run_doctor(args): headers=_headers, timeout=10, ) + if ( + _pname == "Alibaba/DashScope" + and not _base + and _resp.status_code == 401 + ): + _resp = httpx.get( + "https://dashscope.aliyuncs.com/compatible-mode/v1/models", + headers=_headers, + timeout=10, + ) if _resp.status_code == 200: print(f"\r {color('✓', Colors.GREEN)} {_label} ") elif _resp.status_code == 401: diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 846736a2cc67..5f95d0c204dd 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -505,6 +505,7 @@ def _read_systemd_unit_properties( "SubState", "Result", "ExecMainStatus", + "MainPID", ), ) -> dict[str, str]: """Return selected ``systemctl show`` properties for the gateway unit.""" @@ -538,6 +539,41 @@ def _read_systemd_unit_properties( return parsed +def _systemd_main_pid_from_props(props: dict[str, str]) -> int | None: + try: + pid = int(props.get("MainPID", "0") or "0") + except (TypeError, ValueError): + return None + return pid if pid > 0 else None + + +def _systemd_main_pid(system: bool = False) -> int | None: + return _systemd_main_pid_from_props(_read_systemd_unit_properties(system=system)) + + +def _read_gateway_runtime_status() -> dict | None: + try: + from gateway.status import read_runtime_status + + state = read_runtime_status() + except Exception: + return None + return state if isinstance(state, dict) else None + + +def _gateway_runtime_status_for_pid(pid: int | None) -> dict | None: + if not pid: + return None + state = _read_gateway_runtime_status() + if not state: + return None + try: + state_pid = int(state.get("pid", 0) or 0) + except (TypeError, ValueError): + return None + return state if state_pid == pid else None + + def _wait_for_systemd_service_restart( *, system: bool = False, @@ -549,9 +585,10 @@ def _wait_for_systemd_service_restart( svc = get_service_name() scope_label = _service_scope_label(system).capitalize() - deadline = time.time() + timeout + deadline = time.monotonic() + timeout + printed_runtime_wait = False - while time.time() < deadline: + while time.monotonic() < deadline: props = _read_systemd_unit_properties(system=system) active_state = props.get("ActiveState", "") sub_state = props.get("SubState", "") @@ -562,19 +599,32 @@ def _wait_for_systemd_service_restart( new_pid = get_running_pid() except Exception: new_pid = None + if not new_pid: + new_pid = _systemd_main_pid_from_props(props) if active_state == "active": if new_pid and (previous_pid is None or new_pid != previous_pid): - print(f"✓ {scope_label} service restarted (PID {new_pid})") - return True - if previous_pid is None: - print(f"✓ {scope_label} service restarted") - return True + runtime_state = _gateway_runtime_status_for_pid(new_pid) + gateway_state = (runtime_state or {}).get("gateway_state") + if gateway_state == "running": + print(f"✓ {scope_label} service restarted (PID {new_pid})") + return True + if gateway_state == "startup_failed": + reason = (runtime_state or {}).get("exit_reason") or "startup failed" + print(f"⚠ {scope_label} service process restarted (PID {new_pid}), but gateway startup failed: {reason}") + return False + if not printed_runtime_wait: + print(f"⏳ {scope_label} service process started (PID {new_pid}); waiting for gateway runtime...") + printed_runtime_wait = True if active_state == "activating" and sub_state == "auto-restart": time.sleep(1) continue + if _systemd_unit_is_start_limited(props): + _print_systemd_start_limit_wait(system=system) + return False + time.sleep(2) print( @@ -585,6 +635,46 @@ def _wait_for_systemd_service_restart( return False +def _systemd_unit_is_start_limited(props: dict[str, str]) -> bool: + result = props.get("Result", "").lower() + sub_state = props.get("SubState", "").lower() + return result == "start-limit-hit" or sub_state == "start-limit-hit" + + +def _systemd_error_indicates_start_limit(exc: subprocess.CalledProcessError) -> bool: + parts: list[str] = [] + for attr in ("stderr", "stdout", "output"): + value = getattr(exc, attr, None) + if not value: + continue + if isinstance(value, bytes): + value = value.decode(errors="replace") + parts.append(str(value)) + text = "\n".join(parts).lower() + return ( + "start-limit-hit" in text + or "start request repeated too quickly" in text + or "start-limit" in text + ) + + +def _systemd_service_is_start_limited(system: bool = False) -> bool: + return _systemd_unit_is_start_limited(_read_systemd_unit_properties(system=system)) + + +def _print_systemd_start_limit_wait(system: bool = False) -> None: + svc = get_service_name() + scope_label = _service_scope_label(system).capitalize() + scope_flag = " --system" if system else "" + systemctl_prefix = "systemctl " if system else "systemctl --user " + journal_prefix = "journalctl " if system else "journalctl --user " + print(f"⏳ {scope_label} service is temporarily rate-limited by systemd.") + print(" systemd is refusing another immediate start after repeated exits.") + print(f" Wait for the start-limit window to expire, then run: {'sudo ' if system else ''}hermes gateway restart{scope_flag}") + print(f" Or clear the failed state manually: {systemctl_prefix}reset-failed {svc}") + print(f" Check logs: {journal_prefix}-u {svc} -l --since '5 min ago'") + + def _recover_pending_systemd_restart(system: bool = False, previous_pid: int | None = None) -> bool: """Recover a planned service restart that is stuck in systemd state.""" props = _read_systemd_unit_properties(system=system) @@ -740,6 +830,46 @@ def _print_other_profiles_gateway_status() -> None: pass +def _gateway_list() -> None: + """List all profiles and their gateway running status. + + Provides a single-command overview of every known profile and whether + its gateway is currently running, so multi-profile users don't have to + check each profile individually. + """ + try: + from hermes_cli.profiles import list_profiles, get_active_profile_name + except Exception: + print("Unable to list profiles.") + return + + profiles = list_profiles() + if not profiles: + print("No profiles found.") + return + + current = get_active_profile_name() + + print("Gateways:") + for prof in profiles: + marker = "✓" if prof.gateway_running else "✗" + label = prof.name + if prof.name == current: + label += " (current)" + parts = [f" {marker} {label:<24s}"] + if prof.gateway_running: + try: + from gateway.status import get_running_pid + pid = get_running_pid(prof.path / "gateway.pid", cleanup_stale=False) + if pid: + parts.append(f"PID {pid}") + except Exception: + pass + else: + parts.append("not running") + print(" — ".join(parts)) + + def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None, all_profiles: bool = False) -> int: """Kill any running gateway processes. Returns count killed. @@ -967,6 +1097,27 @@ class UserSystemdUnavailableError(RuntimeError): """ +class SystemScopeRequiresRootError(RuntimeError): + """Raised when a system-scope gateway operation is attempted as non-root. + + System-scope units live in ``/etc/systemd/system/`` and require root for + install / uninstall / start / stop / restart via ``systemctl``. The + previous behavior was ``sys.exit(1)`` which blew past the wizard's + ``except Exception`` guards and dumped the user at a bare shell prompt + with no guidance. Raising a typed exception lets callers that can + recover (the setup wizard) print actionable remediation instead, while + ``gateway_command`` still exits 1 with the same message for the direct + CLI path. + + ``args[0]`` carries the user-facing message, ``args[1]`` the action name. + ``str(e)`` returns only the message (not the tuple repr) so format + strings like ``f"Failed: {e}"`` render cleanly. + """ + + def __str__(self) -> str: + return self.args[0] if self.args else "" + + def _user_dbus_socket_path() -> Path: """Return the expected per-user D-Bus socket path (regardless of existence).""" xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" @@ -1382,8 +1533,10 @@ def print_systemd_scope_conflict_warning() -> None: def _require_root_for_system_service(action: str) -> None: if os.geteuid() != 0: - print(f"System gateway {action} requires root. Re-run with sudo.") - sys.exit(1) + raise SystemScopeRequiresRootError( + f"System gateway {action} requires root. Re-run with sudo.", + action, + ) def _system_service_identity(run_as_user: str | None = None) -> tuple[str, str, str]: @@ -1930,6 +2083,47 @@ def _select_systemd_scope(system: bool = False) -> bool: return get_systemd_unit_path(system=True).exists() and not get_systemd_unit_path(system=False).exists() +def _system_scope_wizard_would_need_root(system: bool = False) -> bool: + """True when the setup wizard is about to trigger a system-scope operation + as a non-root user. + + Replicates the decision ``_select_systemd_scope`` makes inside + ``systemd_start`` / ``systemd_restart`` / ``systemd_stop`` so the wizard + can detect the dead-end BEFORE prompting, rather than letting + ``SystemScopeRequiresRootError`` propagate out and leave the user + staring at a bare shell. + """ + if os.geteuid() == 0: + return False + return _select_systemd_scope(system=system) + + +def _print_system_scope_remediation(action: str) -> None: + """Print actionable remediation when the wizard skips a system-scope + prompt because the user isn't root. Keeps the wizard flowing instead of + aborting. + """ + svc = get_service_name() + print_warning( + f"Gateway is installed as a system-wide service — " + f"{action} requires root." + ) + print_info(" Options:") + print_info(f" 1. {action.capitalize()} it this time:") + if action == "start": + print_info(f" sudo systemctl start {svc}") + elif action == "stop": + print_info(f" sudo systemctl stop {svc}") + elif action == "restart": + print_info(f" sudo systemctl restart {svc}") + else: + print_info(f" sudo systemctl {action} {svc}") + print_info(" 2. Switch to a per-user service (recommended for personal use):") + print_info(" sudo hermes gateway uninstall --system") + print_info(" hermes gateway install") + print_info(" hermes gateway start") + + def _get_restart_drain_timeout() -> float: """Return the configured gateway restart drain timeout in seconds.""" raw = os.getenv("HERMES_RESTART_DRAIN_TIMEOUT", "").strip() @@ -2071,41 +2265,52 @@ def systemd_restart(system: bool = False): refresh_systemd_unit_if_needed(system=system) from gateway.status import get_running_pid - pid = get_running_pid() - if pid is not None and _request_gateway_self_restart(pid): - import time + pid = get_running_pid() or _systemd_main_pid(system=system) + if pid is not None: scope_label = _service_scope_label(system).capitalize() svc = get_service_name() + drain_timeout = _get_restart_drain_timeout() + + print(f"⏳ {scope_label} service restarting gracefully (PID {pid})...") + if _graceful_restart_via_sigusr1(pid, drain_timeout + 5): + # The gateway exits with code 75 for a planned service restart. + # RestartSec can otherwise delay the relaunch even though the + # operator asked for an immediate restart, so kick the unit once + # the old PID has exited and then wait for the replacement PID. + _run_systemctl( + ["reset-failed", svc], + system=system, + check=False, + timeout=30, + ) + _run_systemctl( + ["restart", svc], + system=system, + check=False, + timeout=90, + ) + if _wait_for_systemd_service_restart(system=system, previous_pid=pid): + return + if _systemd_service_is_start_limited(system=system): + return - # Phase 1: wait for old process to exit (drain + shutdown) - print(f"⏳ {scope_label} service draining active work...") - deadline = time.time() + 90 - while time.time() < deadline: - try: - os.kill(pid, 0) - time.sleep(1) - except (ProcessLookupError, PermissionError): - break # old process is gone - else: - print(f"⚠ Old process (PID {pid}) still alive after 90s") - - # The gateway exits with code 75 for a planned service restart. - # systemd can sit in the RestartSec window or even wedge itself into a - # failed/rate-limited state if the operator asks for another restart in - # the middle of that handoff. Clear any stale failed state and kick the - # unit immediately so `hermes gateway restart` behaves idempotently. + print( + f"⚠ Graceful restart did not complete within {int(drain_timeout + 5)}s; " + "forcing a service restart..." + ) _run_systemctl( ["reset-failed", svc], system=system, check=False, timeout=30, ) - _run_systemctl( - ["start", svc], - system=system, - check=False, - timeout=90, - ) + try: + _run_systemctl(["restart", svc], system=system, check=True, timeout=90) + except subprocess.CalledProcessError as exc: + if _systemd_error_indicates_start_limit(exc) or _systemd_service_is_start_limited(system=system): + _print_systemd_start_limit_wait(system=system) + return + raise _wait_for_systemd_service_restart(system=system, previous_pid=pid) return @@ -2118,8 +2323,14 @@ def systemd_restart(system: bool = False): check=False, timeout=30, ) - _run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90) - print(f"✓ {_service_scope_label(system).capitalize()} service restarted") + try: + _run_systemctl(["restart", get_service_name()], system=system, check=True, timeout=90) + except subprocess.CalledProcessError as exc: + if _systemd_error_indicates_start_limit(exc) or _systemd_service_is_start_limited(system=system): + _print_systemd_start_limit_wait(system=system) + return + raise + _wait_for_systemd_service_restart(system=system, previous_pid=pid) @@ -2191,6 +2402,10 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False) result_code = unit_props.get("Result", "") if active_state == "activating" and sub_state == "auto-restart": print(" ⏳ Restart pending: systemd is waiting to relaunch the gateway") + elif _systemd_unit_is_start_limited(unit_props): + print(" ⏳ Restart pending: systemd is temporarily rate-limiting starts") + print(f" Run after the start-limit window expires: {'sudo ' if system else ''}hermes gateway restart{scope_flag}") + print(f" Or clear it manually: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()}") elif active_state == "failed" and exec_main_status == str(GATEWAY_SERVICE_RESTART_EXIT_CODE): print(" ⚠ Planned restart is stuck in systemd failed state (exit 75)") print(f" Run: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()} && {'sudo ' if system else ''}hermes gateway start{scope_flag}") @@ -2555,6 +2770,42 @@ def launchd_status(deep: bool = False): # Gateway Runner # ============================================================================= +def _truthy_env(value: str | None) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _is_official_docker_checkout() -> bool: + return ( + str(PROJECT_ROOT) == "/opt/hermes" + and (PROJECT_ROOT / "docker" / "entrypoint.sh").is_file() + ) + + +def _guard_official_docker_root_gateway() -> None: + """Refuse gateway startup when the official Docker privilege drop was bypassed.""" + if not hasattr(os, "geteuid") or os.geteuid() != 0: + return + if _truthy_env(os.getenv("HERMES_ALLOW_ROOT_GATEWAY")): + return + if not _is_official_docker_checkout(): + return + + print_error( + "Refusing to run the Hermes gateway as root inside the official Docker image." + ) + print( + " The image entrypoint normally drops privileges to the 'hermes' user. " + "If you override entrypoint in Docker Compose, include " + "/opt/hermes/docker/entrypoint.sh before the Hermes command." + ) + print( + " Running the gateway as root can leave root-owned files in " + "$HERMES_HOME and break later non-root dashboard/gateway runs." + ) + print(" Set HERMES_ALLOW_ROOT_GATEWAY=1 only if you intentionally accept this risk.") + sys.exit(1) + + def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): """Run the gateway in foreground. @@ -2565,6 +2816,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False): This prevents systemd restart loops when the old process hasn't fully exited yet. """ + _guard_official_docker_root_gateway() sys.path.insert(0, str(PROJECT_ROOT)) # Refresh the systemd unit definition on every boot so that restart @@ -4115,7 +4367,9 @@ def gateway_setup(): print_success("Gateway service is installed and running.") elif service_installed: print_warning("Gateway service is installed but not running.") - if prompt_yes_no(" Start it now?", True): + if supports_systemd_services() and _system_scope_wizard_would_need_root(): + _print_system_scope_remediation("start") + elif prompt_yes_no(" Start it now?", True): try: if supports_systemd_services(): systemd_start() @@ -4125,6 +4379,12 @@ def gateway_setup(): print_error(" Failed to start — user systemd not reachable:") for line in str(e).splitlines(): print(f" {line}") + except SystemScopeRequiresRootError as e: + # Defense in depth: the pre-check above should have caught + # this, but handle the race/edge case gracefully instead of + # letting the exception escape the wizard. + print_error(f" Failed to start: {e}") + _print_system_scope_remediation("start") except subprocess.CalledProcessError as e: print_error(f" Failed to start: {e}") else: @@ -4174,7 +4434,9 @@ def _is_progress(status: str) -> bool: service_running = _is_service_running() if service_running: - if prompt_yes_no(" Restart the gateway to pick up changes?", True): + if supports_systemd_services() and _system_scope_wizard_would_need_root(): + _print_system_scope_remediation("restart") + elif prompt_yes_no(" Restart the gateway to pick up changes?", True): try: if supports_systemd_services(): systemd_restart() @@ -4187,10 +4449,15 @@ def _is_progress(status: str) -> bool: print_error(" Restart failed — user systemd not reachable:") for line in str(e).splitlines(): print(f" {line}") + except SystemScopeRequiresRootError as e: + print_error(f" Restart failed: {e}") + _print_system_scope_remediation("restart") except subprocess.CalledProcessError as e: print_error(f" Restart failed: {e}") elif service_installed: - if prompt_yes_no(" Start the gateway service?", True): + if supports_systemd_services() and _system_scope_wizard_would_need_root(): + _print_system_scope_remediation("start") + elif prompt_yes_no(" Start the gateway service?", True): try: if supports_systemd_services(): systemd_start() @@ -4200,6 +4467,9 @@ def _is_progress(status: str) -> bool: print_error(" Start failed — user systemd not reachable:") for line in str(e).splitlines(): print(f" {line}") + except SystemScopeRequiresRootError as e: + print_error(f" Start failed: {e}") + _print_system_scope_remediation("start") except subprocess.CalledProcessError as e: print_error(f" Start failed: {e}") else: @@ -4273,6 +4543,14 @@ def gateway_command(args): for line in str(e).splitlines(): print(f" {line}") sys.exit(1) + except SystemScopeRequiresRootError as e: + # The direct ``hermes gateway install|uninstall|start|stop|restart`` + # path lands here when the user typed a system-scope action without + # sudo. Same exit code as before — just gives the wizard a way to + # intercept the same condition with friendlier guidance before the + # error is raised. + print(str(e)) + sys.exit(1) def _gateway_command_inner(args): @@ -4597,6 +4875,9 @@ def _gateway_command_inner(args): # Show other profiles' gateway status for multi-profile awareness _print_other_profiles_gateway_status() + elif subcmd == "list": + _gateway_list() + elif subcmd == "migrate-legacy": # Stop, disable, and remove legacy Hermes gateway unit files from # pre-rename installs (e.g. hermes.service). Profile units and diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index d8bc47a7d7b9..7301e58b66df 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -443,8 +443,8 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu help="Cap number of spawns this pass") p_disp.add_argument("--failure-limit", type=int, default=kb.DEFAULT_SPAWN_FAILURE_LIMIT, - help=f"Auto-block a task after this many consecutive spawn failures " - f"(default: {kb.DEFAULT_SPAWN_FAILURE_LIMIT})") + help=f"Auto-block a task after this many consecutive non-success attempts " + f"(spawn_failed, timed_out, or crashed; default: {kb.DEFAULT_SPAWN_FAILURE_LIMIT})") p_disp.add_argument("--json", action="store_true") # --- daemon (deprecated) --- @@ -1657,6 +1657,7 @@ def _cmd_daemon(args: argparse.Namespace) -> int: " kanban:\n" " dispatch_in_gateway: true # default\n" " dispatch_interval_seconds: 60\n" + " failure_limit: 2 # consecutive non-success attempts before auto-block\n" "\n" "Running both the gateway AND this standalone daemon will\n" "race for claims. If you truly need the old standalone\n" diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 8440113c25ec..1c97d6beecb7 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -628,11 +628,16 @@ def from_row(cls, row: sqlite3.Row) -> "Task": idempotency_key=row["idempotency_key"] if "idempotency_key" in keys else None, consecutive_failures=( row["consecutive_failures"] if "consecutive_failures" in keys + # Pre-migration fallback: ``_migrate_add_optional_columns`` always + # adds ``consecutive_failures`` now, so this branch is only reachable + # on a DB that was never opened since pre-#20410 code ran. Keep for + # belt-and-suspenders safety; in practice it is dead code post-migration. else (row["spawn_failures"] if "spawn_failures" in keys else 0) ), worker_pid=row["worker_pid"] if "worker_pid" in keys else None, last_failure_error=( row["last_failure_error"] if "last_failure_error" in keys + # Same belt-and-suspenders fallback as consecutive_failures above. else (row["last_spawn_error"] if "last_spawn_error" in keys else None) ), max_runtime_seconds=( @@ -953,31 +958,40 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS idx_tasks_idempotency " "ON tasks(idempotency_key)" ) - # Legacy column rename: ``spawn_failures`` → ``consecutive_failures`` - # and ``last_spawn_error`` → ``last_failure_error``. The counter was - # originally spawn-only; it's now unified across spawn/timeout/ - # crash outcomes. Rename when only the legacy columns exist to - # preserve historical counter values across upgrades. Add fresh - # otherwise. + # Legacy column migration: ``spawn_failures`` → ``consecutive_failures`` + # and ``last_spawn_error`` → ``last_failure_error``. + # + # Avoid ``ALTER TABLE ... RENAME COLUMN`` for two reasons: + # 1. Primary: very old DBs may never have had ``spawn_failures`` at + # all, so RENAME raises OperationalError: no such column (the crash + # reported in issue #20842 after the #20410 update). + # 2. Secondary: SQLite reparses the whole schema on any RENAME, which + # fails if related objects (views, triggers) reference the old name. + # + # ADD-first-then-copy is tolerant of both shapes and preserves + # historical counter values when the legacy columns do exist. + # + # NOTE: ``cols`` reflects the schema at entry to this function and is + # not refreshed between ALTER TABLE calls. Every guard below checks + # the *original* snapshot; this is intentional and safe as long as + # no step depends on a column added by a previous step in the same call. if "consecutive_failures" not in cols: + conn.execute( + "ALTER TABLE tasks ADD COLUMN consecutive_failures " + "INTEGER NOT NULL DEFAULT 0" + ) if "spawn_failures" in cols: conn.execute( - "ALTER TABLE tasks RENAME COLUMN spawn_failures TO consecutive_failures" - ) - else: - conn.execute( - "ALTER TABLE tasks ADD COLUMN consecutive_failures " - "INTEGER NOT NULL DEFAULT 0" + "UPDATE tasks SET consecutive_failures = COALESCE(spawn_failures, 0)" ) if "worker_pid" not in cols: conn.execute("ALTER TABLE tasks ADD COLUMN worker_pid INTEGER") if "last_failure_error" not in cols: + conn.execute("ALTER TABLE tasks ADD COLUMN last_failure_error TEXT") if "last_spawn_error" in cols: conn.execute( - "ALTER TABLE tasks RENAME COLUMN last_spawn_error TO last_failure_error" + "UPDATE tasks SET last_failure_error = last_spawn_error" ) - else: - conn.execute("ALTER TABLE tasks ADD COLUMN last_failure_error TEXT") if "max_runtime_seconds" not in cols: conn.execute("ALTER TABLE tasks ADD COLUMN max_runtime_seconds INTEGER") if "last_heartbeat_at" not in cols: @@ -1366,7 +1380,7 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) profile = _canonical_assignee(profile) with write_txn(conn): row = conn.execute( - "SELECT status, claim_lock FROM tasks WHERE id = ?", (task_id,) + "SELECT status, claim_lock, assignee FROM tasks WHERE id = ?", (task_id,) ).fetchone() if not row: return False @@ -1375,7 +1389,17 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) f"cannot reassign {task_id}: currently running (claimed). " "Wait for completion or reclaim the stale lock first." ) - conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id)) + if row["assignee"] != profile: + # The retry guard is scoped to the task/profile combination. A + # human reassigning the task is an explicit recovery action, so the + # new profile should not inherit the previous profile's streak. + conn.execute( + "UPDATE tasks SET assignee = ?, consecutive_failures = 0, " + "last_failure_error = NULL WHERE id = ?", + (profile, task_id), + ) + else: + conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id)) _append_event(conn, task_id, "assigned", {"assignee": profile}) return True @@ -1845,34 +1869,47 @@ def heartbeat_claim( return False -def release_stale_claims(conn: sqlite3.Connection) -> int: +def release_stale_claims( + conn: sqlite3.Connection, + *, + signal_fn=None, +) -> int: """Reset any ``running`` task whose claim has expired. Returns the number of stale claims reclaimed. Safe to call often. """ now = int(time.time()) reclaimed = 0 - with write_txn(conn): - stale = conn.execute( - "SELECT id, claim_lock FROM tasks " - "WHERE status = 'running' AND claim_expires IS NOT NULL AND claim_expires < ?", - (now,), - ).fetchall() - for row in stale: - conn.execute( + stale = conn.execute( + "SELECT id, claim_lock, worker_pid FROM tasks " + "WHERE status = 'running' AND claim_expires IS NOT NULL AND claim_expires < ?", + (now,), + ).fetchall() + for row in stale: + termination = _terminate_reclaimed_worker( + row["worker_pid"], row["claim_lock"], signal_fn=signal_fn, + ) + with write_txn(conn): + cur = conn.execute( "UPDATE tasks SET status = 'ready', claim_lock = NULL, " "claim_expires = NULL, worker_pid = NULL " - "WHERE id = ? AND status = 'running'", - (row["id"],), + "WHERE id = ? AND status = 'running' AND claim_lock IS ? " + "AND claim_expires IS NOT NULL AND claim_expires < ?", + (row["id"], row["claim_lock"], now), ) + if cur.rowcount != 1: + continue run_id = _end_run( conn, row["id"], outcome="reclaimed", status="reclaimed", error=f"stale_lock={row['claim_lock']}", + metadata=termination, ) + payload = {"stale_lock": row["claim_lock"]} + payload.update(termination) _append_event( conn, row["id"], "reclaimed", - {"stale_lock": row["claim_lock"]}, + payload, run_id=run_id, ) reclaimed += 1 @@ -1884,6 +1921,7 @@ def reclaim_task( task_id: str, *, reason: Optional[str] = None, + signal_fn=None, ) -> bool: """Operator-driven reclaim: release the claim and reset to ``ready``. @@ -1896,24 +1934,29 @@ def reclaim_task( Returns True if a reclaim happened, False if the task isn't in a reclaimable state (not running, or doesn't exist). """ + row = conn.execute( + "SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not row: + return False + if row["status"] != "running" and row["claim_lock"] is None: + # Nothing to reclaim — already ready / blocked / done. + return False + prev_lock = row["claim_lock"] + termination = _terminate_reclaimed_worker( + row["worker_pid"], prev_lock, signal_fn=signal_fn, + ) with write_txn(conn): - row = conn.execute( - "SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?", - (task_id,), - ).fetchone() - if not row: - return False - if row["status"] != "running" and row["claim_lock"] is None: - # Nothing to reclaim — already ready / blocked / done. - return False - prev_lock = row["claim_lock"] - prev_pid = row["worker_pid"] - conn.execute( + cur = conn.execute( "UPDATE tasks SET status = 'ready', claim_lock = NULL, " "claim_expires = NULL, worker_pid = NULL " - "WHERE id = ? AND status IN ('running', 'ready', 'blocked')", - (task_id,), + "WHERE id = ? AND status IN ('running', 'ready', 'blocked') " + "AND claim_lock IS ?", + (task_id, prev_lock), ) + if cur.rowcount != 1: + return False run_id = _end_run( conn, task_id, outcome="reclaimed", status="reclaimed", @@ -1921,15 +1964,17 @@ def reclaim_task( f"manual_reclaim: {reason}" if reason else f"manual_reclaim lock={prev_lock}" ), + metadata=termination, ) + payload = { + "manual": True, + "reason": reason, + "prev_lock": prev_lock, + } + payload.update(termination) _append_event( conn, task_id, "reclaimed", - { - "manual": True, - "reason": reason, - "prev_lock": prev_lock, - "prev_pid": prev_pid, - }, + payload, run_id=run_id, ) # Operator intervention — they've looked at the task, so the @@ -2534,11 +2579,11 @@ def set_workspace_path( # Dispatcher (one-shot pass) # --------------------------------------------------------------------------- -# After this many consecutive `spawn_failed` events on a task, the dispatcher -# stops retrying and parks the task in ``blocked`` with a reason so a human -# can investigate. Prevents the dispatcher from thrashing forever on a task -# whose profile doesn't exist, whose workspace is unmountable, etc. -DEFAULT_FAILURE_LIMIT = 5 +# After this many consecutive non-success attempts on a task/profile, the +# dispatcher stops retrying and parks the task in ``blocked`` with a reason so +# a human can investigate. Prevents retry storms when a worker repeatedly times +# out, crashes, or cannot spawn. +DEFAULT_FAILURE_LIMIT = 2 # Legacy alias — callers / tests still reference the old name. DEFAULT_SPAWN_FAILURE_LIMIT = DEFAULT_FAILURE_LIMIT @@ -2573,6 +2618,77 @@ class DispatchResult: """Task ids whose workers exceeded ``max_runtime_seconds``.""" +# Bounded registry of recently-reaped worker child exits, populated by the +# reap loop at the top of ``dispatch_once`` and consulted by +# ``detect_crashed_workers`` to classify a dead-pid task. +# +# Entry: ``pid -> (raw_wait_status, reaped_at_epoch)``. We keep raw status +# so both ``os.WIFEXITED`` / ``os.WEXITSTATUS`` and ``os.WIFSIGNALED`` can +# be consulted. Entries are trimmed by age (and total size cap as a +# belt-and-braces against unbounded growth on exotic platforms). +_RECENT_WORKER_EXIT_TTL_SECONDS = 600 +_RECENT_WORKER_EXITS_MAX = 4096 +_recent_worker_exits: "dict[int, tuple[int, float]]" = {} + + +def _record_worker_exit(pid: int, raw_status: int) -> None: + """Record a reaped child's exit status for later classification. + + Called from the reap loop in ``dispatch_once``. Safe to call many + times; duplicate pids overwrite (pids can cycle, latest wins). + """ + if not pid or pid <= 0: + return + now = time.time() + _recent_worker_exits[int(pid)] = (int(raw_status), now) + # Age-based trim: drop entries older than the TTL. + if len(_recent_worker_exits) > _RECENT_WORKER_EXITS_MAX // 2: + cutoff = now - _RECENT_WORKER_EXIT_TTL_SECONDS + for _pid in [p for p, (_s, t) in _recent_worker_exits.items() if t < cutoff]: + _recent_worker_exits.pop(_pid, None) + # Size cap as a final guard. + if len(_recent_worker_exits) > _RECENT_WORKER_EXITS_MAX: + # Drop oldest half. + ordered = sorted(_recent_worker_exits.items(), key=lambda kv: kv[1][1]) + for _pid, _ in ordered[: len(ordered) // 2]: + _recent_worker_exits.pop(_pid, None) + + +def _classify_worker_exit(pid: int) -> "tuple[str, Optional[int]]": + """Classify a recently-reaped worker by pid. + + Returns ``(kind, code)`` where ``kind`` is one of: + + * ``"clean_exit"`` — ``WIFEXITED`` with ``WEXITSTATUS == 0``. When the + task is still ``running`` in the DB, this is a protocol violation + (worker exited without calling ``kanban_complete`` / ``kanban_block``) + and should be auto-blocked immediately — retrying will just loop. + * ``"nonzero_exit"`` — ``WIFEXITED`` with non-zero status. Real error. + * ``"signaled"`` — ``WIFSIGNALED`` (OOM killer, SIGKILL, etc). Real crash. + * ``"unknown"`` — pid was not in the reap registry (either reaped by + something else, or died between reap tick and liveness check). Fall + back to existing crashed-counter behavior. + + ``code`` is the exit status (for ``clean_exit`` / ``nonzero_exit``) or + the signal number (for ``signaled``), or ``None`` for ``unknown``. + """ + entry = _recent_worker_exits.get(int(pid)) + if entry is None: + return ("unknown", None) + raw, _ = entry + try: + if os.WIFEXITED(raw): + code = os.WEXITSTATUS(raw) + if code == 0: + return ("clean_exit", 0) + return ("nonzero_exit", code) + if os.WIFSIGNALED(raw): + return ("signaled", os.WTERMSIG(raw)) + except Exception: + pass + return ("unknown", None) + + def _pid_alive(pid: Optional[int]) -> bool: """Return True if ``pid`` is still running on this host. @@ -2638,6 +2754,59 @@ def _pid_alive(pid: Optional[int]) -> bool: return True +def _terminate_reclaimed_worker( + pid: Optional[int], + claim_lock: Optional[str], + *, + signal_fn=None, +) -> dict[str, Any]: + """Best-effort host-local worker termination for reclaim paths.""" + import signal + + info: dict[str, Any] = { + "prev_pid": int(pid) if pid else None, + "host_local": False, + "termination_attempted": False, + "terminated": False, + "sigkill": False, + } + if not pid or pid <= 0 or not claim_lock: + return info + + host_prefix = f"{_claimer_id().split(':', 1)[0]}:" + if not str(claim_lock).startswith(host_prefix): + return info + info["host_local"] = True + + kill = signal_fn if signal_fn is not None else ( + os.kill if hasattr(os, "kill") else None + ) + if kill is None: + return info + + info["termination_attempted"] = True + try: + kill(int(pid), signal.SIGTERM) + except (ProcessLookupError, OSError): + return info + + for _ in range(10): + if not _pid_alive(pid): + info["terminated"] = True + return info + time.sleep(0.5) + + if _pid_alive(pid): + try: + kill(int(pid), signal.SIGKILL) + info["sigkill"] = True + except (ProcessLookupError, OSError): + return info + + info["terminated"] = not _pid_alive(pid) + return info + + def heartbeat_worker( conn: sqlite3.Connection, task_id: str, @@ -2826,12 +2995,22 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]: are meaningless here. The host-local check is enough because ``_default_spawn`` always runs the worker on the same host as the dispatcher (the whole design is single-host). + + When the reap registry shows the worker exited cleanly (rc=0) but + the task was still ``running`` in the DB, treat it as a protocol + violation (worker answered conversationally without calling + ``kanban_complete`` / ``kanban_block``) and trip the circuit breaker + on the first occurrence — retrying a worker whose CLI keeps + returning 0 without a terminal transition just loops forever. """ crashed: list[str] = [] # Per-crash details collected inside the main txn, used after it # closes to run ``_record_task_failure`` (which needs its own - # write_txn so can't nest). - crash_details: list[tuple[str, int, str]] = [] # (task_id, pid, claimer) + # write_txn so can't nest). ``protocol_violation`` flags the + # clean-exit-but-still-running case so we can trip the breaker + # immediately instead of incrementing by 1. + crash_details: list[tuple[str, int, str, bool, str]] = [] + # (task_id, pid, claimer, protocol_violation, error_text) with write_txn(conn): rows = conn.execute( "SELECT id, worker_pid, claim_lock FROM tasks " @@ -2845,6 +3024,39 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]: continue if _pid_alive(row["worker_pid"]): continue + + pid = int(row["worker_pid"]) + kind, code = _classify_worker_exit(pid) + if kind == "clean_exit": + # Worker subprocess returned 0 but its task is still + # ``running`` in the DB — it exited without calling + # ``kanban_complete`` / ``kanban_block``. Retrying won't + # help. + protocol_violation = True + error_text = ( + "worker exited cleanly (rc=0) without calling " + "kanban_complete or kanban_block — protocol violation" + ) + event_kind = "protocol_violation" + event_payload = { + "pid": pid, + "claimer": row["claim_lock"], + "exit_code": code, + } + else: + protocol_violation = False + if kind == "nonzero_exit": + error_text = f"pid {pid} exited with code {code}" + elif kind == "signaled": + error_text = f"pid {pid} killed by signal {code}" + else: + error_text = f"pid {pid} not alive" + event_kind = "crashed" + event_payload = {"pid": pid, "claimer": row["claim_lock"]} + if code is not None and kind != "unknown": + event_payload["exit_kind"] = kind + event_payload["exit_code"] = code + cur = conn.execute( "UPDATE tasks SET status = 'ready', claim_lock = NULL, " "claim_expires = NULL, worker_pid = NULL " @@ -2855,34 +3067,47 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]: run_id = _end_run( conn, row["id"], outcome="crashed", status="crashed", - error=f"pid {int(row['worker_pid'])} not alive", - metadata={ - "pid": int(row["worker_pid"]), - "claimer": row["claim_lock"], - }, + error=error_text, + metadata=dict(event_payload), ) _append_event( - conn, row["id"], "crashed", - {"pid": int(row["worker_pid"]), "claimer": row["claim_lock"]}, + conn, row["id"], event_kind, + event_payload, run_id=run_id, ) crashed.append(row["id"]) crash_details.append( - (row["id"], int(row["worker_pid"]), row["claim_lock"]) + (row["id"], pid, row["claim_lock"], + protocol_violation, error_text) ) # Outside the main txn: increment the unified failure counter for # each crashed task. If the breaker trips, the task transitions # ready → blocked with a ``gave_up`` event on top of the ``crashed`` # event we already emitted. - for tid, pid, claimer in crash_details: - _record_task_failure( + # + # Protocol-violation crashes force an immediate trip (failure_limit=1) + # because clean-exit-without-transition is deterministic: the next + # respawn will do exactly the same thing. Better to surface to a + # human with a clear reason than to loop ``DEFAULT_FAILURE_LIMIT`` + # times first. + auto_blocked: list[str] = [] + for tid, pid, claimer, protocol_violation, error_text in crash_details: + tripped = _record_task_failure( conn, tid, - error=f"pid {pid} not alive", + error=error_text, outcome="crashed", + failure_limit=(1 if protocol_violation else None), release_claim=False, end_run=False, event_payload_extra={"pid": pid, "claimer": claimer}, ) + if tripped: + auto_blocked.append(tid) + # Stash auto-blocked ids on the function for the dispatch loop to pick up. + # Keeps the public return type (``list[str]``) stable for direct callers + # and tests that destructure the result; ``dispatch_once`` reads this + # side-channel attribute to populate ``DispatchResult.auto_blocked``. + detect_crashed_workers._last_auto_blocked = auto_blocked # type: ignore[attr-defined] return crashed @@ -3136,9 +3361,43 @@ def dispatch_once( ``board`` pins workspace/log/db resolution for this tick to a specific board. When omitted, the current-board resolution chain is used. """ + # Reap zombie children from previously spawned workers. + # The gateway-embedded dispatcher is the parent of every worker spawned + # via _default_spawn (start_new_session=True only detaches the + # controlling tty, not the parent). Without an explicit waitpid, each + # completed worker becomes a entry that lingers until gateway + # exit. WNOHANG keeps this non-blocking; ChildProcessError means no + # children to reap. Bounded: at most one tick's worth of completions + # can be in at once. + # + # We also record the exit status keyed by pid, so + # ``detect_crashed_workers`` can distinguish a worker that exited + # cleanly without calling ``kanban_complete`` / ``kanban_block`` + # (protocol violation — auto-block) from a real crash (OOM killer, + # SIGKILL, non-zero exit — existing counter behavior). + try: + while True: + try: + _pid, _status = os.waitpid(-1, os.WNOHANG) + except ChildProcessError: + break + if _pid == 0: + break + _record_worker_exit(_pid, _status) + except Exception: + pass + result = DispatchResult() result.reclaimed = release_stale_claims(conn) result.crashed = detect_crashed_workers(conn) + # detect_crashed_workers stashes protocol-violation auto-blocks on + # itself so the public list-return stays stable. Pull them into the + # DispatchResult here so telemetry / tests see the trip. + _crash_auto_blocked = getattr( + detect_crashed_workers, "_last_auto_blocked", [] + ) + if _crash_auto_blocked: + result.auto_blocked.extend(_crash_auto_blocked) result.timed_out = enforce_max_runtime(conn) result.promoted = recompute_ready(conn) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 19029d720725..1f0ea8dd1d2d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1706,7 +1706,7 @@ def _is_profile_api_key_provider(provider_id: str) -> bool: """Return True when provider_id maps to a profile with auth_type='api_key'. Used as a catch-all in select_provider_and_model() so that new providers - declared in providers/*.py automatically dispatch to _model_flow_api_key_provider + declared in plugins/model-providers// automatically dispatch to _model_flow_api_key_provider without requiring an explicit elif branch here. """ try: @@ -7331,7 +7331,9 @@ def _cmd_update_impl(args, gateway_mode: bool): for p in all_profiles: try: r = seed_profile_skills(p.path, quiet=True) - if r: + if r and r.get("skipped_opt_out"): + status = "opted out (--no-skills)" + elif r: copied = len(r.get("copied", [])) updated = len(r.get("updated", [])) modified = len(r.get("user_modified", [])) @@ -8124,6 +8126,7 @@ def cmd_profile(args): clone = getattr(args, "clone", False) clone_all = getattr(args, "clone_all", False) no_alias = getattr(args, "no_alias", False) + no_skills = getattr(args, "no_skills", False) try: clone_from = getattr(args, "clone_from", None) @@ -8134,6 +8137,7 @@ def cmd_profile(args): clone_all=clone_all, clone_config=clone, no_alias=no_alias, + no_skills=no_skills, ) print(f"\nProfile '{name}' created at {profile_dir}") @@ -8158,10 +8162,17 @@ def cmd_profile(args): except Exception: pass # Honcho plugin not installed or not configured - # Seed bundled skills (skip if --clone-all already copied them) + # Seed bundled skills (skip if --clone-all already copied them, or + # if --no-skills was passed — in which case seed_profile_skills() + # honors the marker file and returns skipped_opt_out=True). if not clone_all: result = seed_profile_skills(profile_dir) - if result: + if result and result.get("skipped_opt_out"): + print( + "No bundled skills seeded (--no-skills). " + "Delete .no-bundled-skills in the profile to opt back in." + ) + elif result: copied = len(result.get("copied", [])) print(f"{copied} bundled skills synced.") else: @@ -8679,6 +8690,9 @@ def main(): help="Target the Linux system-level gateway service", ) + # gateway list + gateway_subparsers.add_parser("list", help="List all profiles and their gateway status") + # gateway setup gateway_subparsers.add_parser("setup", help="Configure messaging platforms") @@ -9996,7 +10010,15 @@ def cmd_tools(args): ) mcp_add_p.add_argument("name", help="Server name (used as config key)") mcp_add_p.add_argument("--url", help="HTTP/SSE endpoint URL") - mcp_add_p.add_argument("--command", help="Stdio command (e.g. npx)") + # dest="mcp_command" so this flag does not clobber the top-level + # subparser's args.command attribute, which the dispatcher reads to + # route to cmd_mcp. Without an explicit dest, argparse derives + # dest="command" from the flag name and sets it to None when the + # flag is omitted, causing `hermes mcp add ...` to fall through to + # interactive chat. + mcp_add_p.add_argument( + "--command", dest="mcp_command", help="Stdio command (e.g. npx)" + ) mcp_add_p.add_argument( "--args", nargs="*", default=[], help="Arguments for stdio command" ) @@ -10523,6 +10545,11 @@ def cmd_acp(args): profile_create.add_argument( "--no-alias", action="store_true", help="Skip wrapper script creation" ) + profile_create.add_argument( + "--no-skills", + action="store_true", + help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)", + ) profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile") profile_delete.add_argument("profile_name", help="Profile to delete") diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 0e01f558dda9..5bc30aaa0c0e 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -221,7 +221,10 @@ def cmd_mcp_add(args): """Add a new MCP server with discovery-first tool selection.""" name = args.name url = getattr(args, "url", None) - command = getattr(args, "command", None) + # Read from `mcp_command` (set by --command via explicit dest) — see + # mcp_add_p.add_argument("--command", dest="mcp_command", ...) in + # hermes_cli/main.py for why the dest is renamed. + command = getattr(args, "mcp_command", None) cmd_args = getattr(args, "args", None) or [] auth_type = getattr(args, "auth", None) preset_name = getattr(args, "preset", None) diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 433e34279643..0e74db718d93 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -393,14 +393,21 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: if provider in _AGGREGATOR_PROVIDERS: return _prepend_vendor(name) - # --- OpenCode Zen: Claude stays hyphenated; other models keep dots --- - if provider == "opencode-zen": - bare = _strip_matching_provider_prefix(name, provider) - if "/" in bare: - return bare - if bare.lower().startswith("claude-"): - return _dots_to_hyphens(bare) - return bare + # --- OpenCode Zen / OpenCode Go: flat-namespace resellers. + # Their /v1/models API returns bare IDs only (no vendor prefix), and + # the inference endpoint rejects vendor-prefixed names with HTTP 401 + # "Model not supported". Strip ANY leading ``vendor/`` so config + # entries like ``minimax/minimax-m2.7`` or ``deepseek/deepseek-v4-flash`` + # — commonly copied from aggregator slugs into fallback_model lists — + # resolve to bare ``minimax-m2.7`` / ``deepseek-v4-flash`` the API + # actually serves. See PR reviewing opencode-go fallback 401s. --- + if provider in {"opencode-zen", "opencode-go"}: + if "/" in name: + _, bare_after_slash = name.split("/", 1) + name = bare_after_slash.strip() or name + if provider == "opencode-zen" and name.lower().startswith("claude-"): + return _dots_to_hyphens(name) + return name # --- Anthropic: strip matching provider prefix, dots -> hyphens --- if provider in _DOT_TO_HYPHEN_PROVIDERS: diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index dfaae1448ad4..dcdd81df4a79 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -799,6 +799,12 @@ def switch_model( ) # --- Step d: Aggregator catalog search --- + # Track whether the live catalog of the CURRENT provider resolved the + # model — if so, step e must not second-guess and switch providers. + # Critical for flat-namespace resellers like opencode-go / opencode-zen + # whose live /v1/models returns bare IDs (e.g. "deepseek-v4-flash") that + # coincidentally match entries in native providers' static catalogs. + resolved_in_current_catalog = False if is_aggregator(target_provider) and not resolved_alias: catalog = list_provider_models(target_provider) if catalog: @@ -806,6 +812,7 @@ def switch_model( for mid in catalog: if mid.lower() == new_model_lower: new_model = mid + resolved_in_current_catalog = True break else: for mid in catalog: @@ -813,6 +820,7 @@ def switch_model( _, bare = mid.split("/", 1) if bare.lower() == new_model_lower: new_model = mid + resolved_in_current_catalog = True break # --- Step e: detect_provider_for_model() as last resort --- @@ -825,6 +833,7 @@ def switch_model( target_provider == current_provider and not is_custom and not resolved_alias + and not resolved_in_current_catalog ): detected = detect_provider_for_model(new_model, current_provider) if detected: @@ -1628,7 +1637,8 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: groups[group_key]["models"].append(m) _section4_emitted_slugs: set = set() - for grp in groups.values(): + for grp_key, grp in groups.items(): + api_url, api_key = grp_key slug = grp["slug"] # If the slug is already claimed by a built-in / overlay / # user-provider row (sections 1-3), skip this custom group @@ -1666,6 +1676,18 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: _grp_url_norm = _pair_key[1] if _grp_url_norm and _grp_url_norm in _builtin_endpoints: continue + # Live model discovery from custom provider endpoints (matches + # Section 3 behavior for user ``providers:`` entries). + if api_url and api_key: + try: + from hermes_cli.models import fetch_api_models + + live_models = fetch_api_models(api_key, api_url) + if live_models: + grp["models"] = live_models + grp["total_models"] = len(live_models) + except Exception: + pass results.append({ "slug": slug, "name": grp["name"], diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 8b00cf5d10af..40a8f3c107e7 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -811,9 +811,9 @@ class ProviderEntry(NamedTuple): ] # Auto-extend CANONICAL_PROVIDERS with any provider registered in providers/ -# that is not already in the list above. Adding providers/*.py is sufficient -# to expose a new provider in the model picker, /model, and all downstream -# consumers — no edits to this file needed. +# that is not already in the list above. Adding plugins/model-providers// +# is sufficient to expose a new provider in the model picker, /model, and all +# downstream consumers — no edits to this file needed. _canonical_slugs = {p.slug for p in CANONICAL_PROVIDERS} try: from providers import list_providers as _list_providers_for_canonical diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index c83844901f1c..be027e85cd1d 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -255,6 +255,10 @@ def get_nous_subscription_features( terminal_cfg = config.get("terminal") if isinstance(config.get("terminal"), dict) else {} web_backend = str(web_cfg.get("backend") or "").strip().lower() + # Per-capability overrides: if set, they determine which backend is active for + # search/extract independently of web.backend. + web_search_backend = str(web_cfg.get("search_backend") or "").strip().lower() + web_extract_backend = str(web_cfg.get("extract_backend") or "").strip().lower() tts_provider = str(tts_cfg.get("provider") or "edge").strip().lower() browser_provider_explicit = "cloud_provider" in browser_cfg browser_provider = normalize_browser_cloud_provider( @@ -280,6 +284,7 @@ def get_nous_subscription_features( direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL")) direct_parallel = bool(get_env_value("PARALLEL_API_KEY")) direct_tavily = bool(get_env_value("TAVILY_API_KEY")) + direct_searxng = bool(get_env_value("SEARXNG_URL")) direct_fal = fal_key_is_configured() direct_openai_tts = bool(resolve_openai_audio_api_key()) direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY")) @@ -323,10 +328,18 @@ def get_nous_subscription_features( or (web_backend == "firecrawl" and direct_firecrawl) or (web_backend == "parallel" and direct_parallel) or (web_backend == "tavily" and direct_tavily) + or (web_backend == "searxng" and direct_searxng) + # Per-capability overrides: search_backend or extract_backend may be set + # without web.backend (using the new split config from #20061) + or (web_search_backend == "searxng" and direct_searxng) + or (web_search_backend == "exa" and direct_exa) + or (web_search_backend == "firecrawl" and direct_firecrawl) + or (web_search_backend == "parallel" and direct_parallel) + or (web_search_backend == "tavily" and direct_tavily) ) ) web_available = bool( - managed_web_available or direct_exa or direct_firecrawl or direct_parallel or direct_tavily + managed_web_available or direct_exa or direct_firecrawl or direct_parallel or direct_tavily or direct_searxng ) image_managed = image_tool_enabled and managed_image_available and not direct_fal @@ -412,8 +425,8 @@ def get_nous_subscription_features( managed_by_nous=web_managed, direct_override=web_active and not web_managed, toolset_enabled=web_tool_enabled, - current_provider=web_backend or "", - explicit_configured=bool(web_backend), + current_provider=web_backend or web_search_backend or "", + explicit_configured=bool(web_backend or web_search_backend), ), "image_gen": NousFeatureState( key="image_gen", diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 5b30e7e7ca1f..12674577376c 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -80,6 +80,10 @@ def get_bundled_plugins_dir() -> Path: "post_tool_call", "transform_terminal_output", "transform_tool_result", + # Transform LLM output before it's returned to the user. + # Plugins return a string to replace the response text, or None/empty to leave unchanged. + # First non-None string wins. Useful for vocabulary/personality transformation. + "transform_llm_output", "pre_llm_call", "post_llm_call", "pre_api_request", diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 10cd36b88c9c..93928364c423 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -71,6 +71,22 @@ "processes.json", ] +# Marker file written by `hermes profile create --no-skills`. When present in +# a profile's root, callers of seed_profile_skills() (fresh-create, `hermes +# update`'s all-profile sync, the web dashboard) skip bundled-skill seeding +# for that profile. The user can still install skills manually via +# `hermes skills install` or drop SKILL.md files into the profile's skills/. +# Delete the marker file to opt back in. +NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills" + + +def has_bundled_skills_opt_out(profile_dir: Path) -> bool: + """Return True if the profile opted out of bundled-skill seeding.""" + try: + return (profile_dir / NO_BUNDLED_SKILLS_MARKER).exists() + except OSError: + return False + def _clone_all_copytree_ignore(source_dir: Path): """Ignore ``profiles/`` at the root of *source_dir* only. @@ -427,6 +443,7 @@ def create_profile( clone_all: bool = False, clone_config: bool = False, no_alias: bool = False, + no_skills: bool = False, ) -> Path: """Create a new profile directory. @@ -444,12 +461,22 @@ def create_profile( skills, and selected profile identity files from the source profile. no_alias: If True, skip wrapper script creation. + no_skills: + If True, create an empty profile with no bundled skills, and write + a marker file so ``hermes update`` skips re-seeding this profile's + skills. Mutually exclusive with ``clone_config``/``clone_all`` (those + explicitly copy skills from the source). Returns ------- Path The newly created profile directory. """ + if no_skills and (clone_config or clone_all): + raise ValueError( + "--no-skills is mutually exclusive with --clone / --clone-all " + "(cloning explicitly copies skills from the source profile)." + ) canon = normalize_profile_name(name) validate_profile_name(canon) @@ -527,6 +554,19 @@ def create_profile( except Exception: pass # best-effort — don't fail profile creation over this + # Write the opt-out marker so seed_profile_skills() and `hermes update`'s + # all-profile sync loop both skip this profile for bundled-skill seeding. + if no_skills: + try: + (profile_dir / NO_BUNDLED_SKILLS_MARKER).write_text( + "This profile opted out of bundled-skill seeding " + "(`hermes profile create --no-skills`).\n" + "Delete this file to re-enable sync on the next `hermes update`.\n", + encoding="utf-8", + ) + except OSError: + pass # best-effort — the feature still works via the empty skills/ dir + return profile_dir @@ -535,7 +575,19 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict Uses subprocess because sync_skills() caches HERMES_HOME at module level. Returns the sync result dict, or None on failure. + + Profiles that opted out of bundled skills (via ``hermes profile create + --no-skills`` — which writes ``.no-bundled-skills`` to the profile root) + are skipped and get an empty-result dict so callers can report + "opted out" instead of "failed". """ + if has_bundled_skills_opt_out(profile_dir): + return { + "copied": [], + "updated": [], + "user_modified": [], + "skipped_opt_out": True, + } project_root = Path(__file__).parent.parent.resolve() try: result = subprocess.run( diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index dfdc9115699e..68c59509f718 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -319,9 +319,10 @@ def _try_resolve_from_custom_pool( base_url: str, provider_label: str, api_mode_override: Optional[str] = None, + provider_name: Optional[str] = None, ) -> Optional[Dict[str, Any]]: """Check if a credential pool exists for a custom endpoint and return a runtime dict if so.""" - pool_key = get_custom_provider_pool_key(base_url) + pool_key = get_custom_provider_pool_key(base_url, provider_name=provider_name) if not pool_key: return None try: @@ -521,7 +522,7 @@ def _resolve_named_custom_runtime( return None # Check if a credential pool exists for this custom endpoint - pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode")) + pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode"), provider_name=custom_provider.get("name")) if pool_result: # Propagate the model name even when using pooled credentials — # the pool doesn't know about the custom_providers model field. @@ -640,8 +641,11 @@ def _resolve_openrouter_runtime( # For custom endpoints, check if a credential pool exists if effective_provider == "custom" and base_url: + # Pass requested_provider so pool lookup prefers name match over base_url, + # fixing credential mix-ups when multiple custom providers share a base_url. pool_result = _try_resolve_from_custom_pool( base_url, effective_provider, _parse_api_mode(model_cfg.get("api_mode")), + provider_name=requested_provider if requested_norm != "custom" else None, ) if pool_result: return pool_result diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 19e9366a202d..f5b8b6c160f3 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -394,7 +394,7 @@ def _print_setup_summary(config: dict, hermes_home): label = f"Web Search & Extract ({subscription_features.web.current_provider})" tool_status.append((label, True, None)) else: - tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, or TAVILY_API_KEY")) + tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, TAVILY_API_KEY, or SEARXNG_URL")) # Browser tools (local Chromium, Camofox, Browserbase, Browser Use, or Firecrawl) browser_provider = subscription_features.browser.current_provider @@ -2462,6 +2462,9 @@ def _is_progress(status: str) -> bool: launchd_start, launchd_restart, UserSystemdUnavailableError, + SystemScopeRequiresRootError, + _system_scope_wizard_would_need_root, + _print_system_scope_remediation, ) service_installed = _is_service_installed() @@ -2479,7 +2482,9 @@ def _is_progress(status: str) -> bool: print() if service_running: - if prompt_yes_no(" Restart the gateway to pick up changes?", True): + if supports_systemd and _system_scope_wizard_would_need_root(): + _print_system_scope_remediation("restart") + elif prompt_yes_no(" Restart the gateway to pick up changes?", True): try: if supports_systemd: systemd_restart() @@ -2489,10 +2494,19 @@ def _is_progress(status: str) -> bool: print_error(" Restart failed — user systemd not reachable:") for line in str(e).splitlines(): print(f" {line}") + except SystemScopeRequiresRootError as e: + # Defense in depth: the pre-check above should have + # caught this, but a race (unit file appearing mid-run) + # could still land here. Previously this exited the + # whole wizard via sys.exit(1). + print_error(f" Restart failed: {e}") + _print_system_scope_remediation("restart") except Exception as e: print_error(f" Restart failed: {e}") elif service_installed: - if prompt_yes_no(" Start the gateway service?", True): + if supports_systemd and _system_scope_wizard_would_need_root(): + _print_system_scope_remediation("start") + elif prompt_yes_no(" Start the gateway service?", True): try: if supports_systemd: systemd_start() @@ -2502,6 +2516,9 @@ def _is_progress(status: str) -> bool: print_error(" Start failed — user systemd not reachable:") for line in str(e).splitlines(): print(f" {line}") + except SystemScopeRequiresRootError as e: + print_error(f" Start failed: {e}") + _print_system_scope_remediation("start") except Exception as e: print_error(f" Start failed: {e}") elif supports_service_manager: @@ -2529,6 +2546,9 @@ def _is_progress(status: str) -> bool: print_error(" Start failed — user systemd not reachable:") for line in str(e).splitlines(): print(f" {line}") + except SystemScopeRequiresRootError as e: + print_error(f" Start failed: {e}") + _print_system_scope_remediation("start") except Exception as e: print_error(f" Start failed: {e}") except Exception as e: diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 6ca6f8adf3d7..0acb41d6878c 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -42,6 +42,7 @@ session_border: "#8B8682" # Session ID dim color status_bar_bg: "#1a1a2e" # TUI status/usage bar background voice_status_bg: "#1a1a2e" # TUI voice status background + selection_bg: "#333355" # TUI mouse-selection highlight background completion_menu_bg: "#1a1a2e" # Completion menu background completion_menu_current_bg: "#333355" # Active completion row background completion_menu_meta_bg: "#1a1a2e" # Completion meta column background diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 14d82caa653d..b258e15998f5 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -299,6 +299,15 @@ def _get_plugin_toolset_keys() -> set: {"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"}, ], }, + { + "name": "SearXNG", + "badge": "free · self-hosted · search only", + "tag": "Privacy-respecting metasearch engine — search only (pair with any extract provider)", + "web_backend": "searxng", + "env_vars": [ + {"key": "SEARXNG_URL", "prompt": "Your SearXNG instance URL (e.g., http://localhost:8080)", "url": "https://searxng.github.io/searxng/"}, + ], + }, ], }, "image_gen": { diff --git a/hermes_cli/voice.py b/hermes_cli/voice.py index f85f30c7bf44..a4ee6a0842d3 100644 --- a/hermes_cli/voice.py +++ b/hermes_cli/voice.py @@ -281,6 +281,8 @@ def _play_beep(frequency: int, count: int = 1) -> None: # ── Continuous (VAD) state ─────────────────────────────────────────── _continuous_lock = threading.Lock() _continuous_active = False +_continuous_stopping = False +_continuous_auto_restart: bool = True _continuous_recorder: Any = None # ── TTS-vs-STT feedback guard ──────────────────────────────────────── @@ -370,32 +372,43 @@ def start_continuous( on_silent_limit: Optional[Callable[[], None]] = None, silence_threshold: int = 200, silence_duration: float = 3.0, -) -> None: + auto_restart: bool = True, +) -> bool: """Start a VAD-driven continuous recording loop. The loop calls ``on_transcript(text)`` each time speech is detected and - transcribed successfully, then auto-restarts. After - ``_CONTINUOUS_NO_SPEECH_LIMIT`` consecutive silent cycles (no speech - picked up at all) the loop stops itself and calls ``on_silent_limit`` - so the UI can reflect "voice off". Idempotent — calling while already - active is a no-op. + transcribed successfully. If ``auto_restart`` is True, it auto-restarts + for the next turn and resets the no-speech counter for that loop. If + ``auto_restart`` is False, the first silence-triggered transcription ends + the loop and reports ``"idle"``; no-speech counts are retained across + starts so a push-to-talk caller can still enforce the three-strikes guard. + After ``_CONTINUOUS_NO_SPEECH_LIMIT`` consecutive silent cycles (no speech + picked up at all) the loop stops itself and calls ``on_silent_limit`` so the + UI can reflect "voice off". Returns False if a previous stop is still + transcribing/cleaning up; otherwise returns True. Idempotent — calling while + already active is a successful no-op. ``on_status`` is called with ``"listening"`` / ``"transcribing"`` / ``"idle"`` so the UI can show a live indicator. """ - global _continuous_active, _continuous_recorder + global _continuous_active, _continuous_recorder, _continuous_auto_restart global _continuous_on_transcript, _continuous_on_status, _continuous_on_silent_limit global _continuous_no_speech_count with _continuous_lock: if _continuous_active: _debug("start_continuous: already active — no-op") - return + return True + if _continuous_stopping: + _debug("start_continuous: stop/transcribe in progress — busy") + return False _continuous_active = True + _continuous_auto_restart = auto_restart _continuous_on_transcript = on_transcript _continuous_on_status = on_status _continuous_on_silent_limit = on_silent_limit - _continuous_no_speech_count = 0 + if auto_restart: + _continuous_no_speech_count = 0 if _continuous_recorder is None: _continuous_recorder = create_audio_recorder() @@ -428,15 +441,18 @@ def start_continuous( except Exception: pass + return True + -def stop_continuous() -> None: +def stop_continuous(force_transcribe: bool = False) -> None: """Stop the active continuous loop and release the microphone. - Idempotent — calling while not active is a no-op. Any in-flight - transcription completes but its result is discarded (the callback - checks ``_continuous_active`` before firing). + Idempotent — calling while not active is a no-op. If ``force_transcribe`` is + True, the recorder stops synchronously, then transcription/cleanup runs on a + background thread before reporting ``"idle"``. Otherwise the buffer is + discarded. """ - global _continuous_active, _continuous_on_transcript + global _continuous_active, _continuous_on_transcript, _continuous_stopping global _continuous_on_status, _continuous_on_silent_limit global _continuous_recorder, _continuous_no_speech_count @@ -446,18 +462,98 @@ def stop_continuous() -> None: _continuous_active = False rec = _continuous_recorder on_status = _continuous_on_status + on_transcript = _continuous_on_transcript + on_silent_limit = _continuous_on_silent_limit + auto_restart = _continuous_auto_restart + track_no_speech = force_transcribe and not auto_restart + _continuous_stopping = rec is not None _continuous_on_transcript = None _continuous_on_status = None _continuous_on_silent_limit = None - _continuous_no_speech_count = 0 + if not track_no_speech: + _continuous_no_speech_count = 0 if rec is not None: - try: - # cancel() (not stop()) discards buffered frames — the loop - # is over, we don't want to transcribe a half-captured turn. - rec.cancel() - except Exception as e: - logger.warning("failed to cancel recorder: %s", e) + if force_transcribe and on_transcript: + if on_status: + try: + on_status("transcribing") + except Exception: + pass + try: + wav_path = rec.stop() + except Exception as e: + logger.warning("failed to stop recorder: %s", e) + try: + rec.cancel() + except Exception as cancel_error: + logger.warning("failed to cancel recorder: %s", cancel_error) + wav_path = None + + def _transcribe_and_cleanup(): + global _continuous_no_speech_count, _continuous_stopping + transcript: Optional[str] = None + should_halt = False + + try: + if wav_path: + try: + result = transcribe_recording(wav_path) + if result.get("success"): + text = (result.get("transcript") or "").strip() + if text and not is_whisper_hallucination(text): + transcript = text + finally: + if os.path.isfile(wav_path): + os.unlink(wav_path) + except Exception as e: + logger.warning("failed to stop/transcribe recorder: %s", e) + finally: + if transcript: + try: + on_transcript(transcript) + except Exception as e: + logger.warning("on_transcript callback raised: %s", e) + + if track_no_speech: + with _continuous_lock: + if transcript: + _continuous_no_speech_count = 0 + else: + _continuous_no_speech_count += 1 + should_halt = ( + _continuous_no_speech_count + >= _CONTINUOUS_NO_SPEECH_LIMIT + ) + if should_halt: + _continuous_no_speech_count = 0 + if should_halt and on_silent_limit: + try: + on_silent_limit() + except Exception: + pass + + _play_beep(frequency=660, count=2) + with _continuous_lock: + _continuous_stopping = False + if on_status: + try: + on_status("idle") + except Exception: + pass + + threading.Thread(target=_transcribe_and_cleanup, daemon=True).start() + return + else: + try: + # cancel() (not stop()) discards buffered frames — the loop + # is over, we don't want to transcribe a half-captured turn. + rec.cancel() + except Exception as e: + logger.warning("failed to cancel recorder: %s", e) + + with _continuous_lock: + _continuous_stopping = False # Audible "recording stopped" cue (CLI parity: same 660 Hz × 2 the # silence-auto-stop path plays). @@ -603,23 +699,39 @@ def _continuous_on_silence() -> None: _debug("_continuous_on_silence: stopped while waiting for TTS") return - # Restart for the next turn. - _debug(f"_continuous_on_silence: restarting loop (no_speech={no_speech})") - _play_beep(frequency=880, count=1) - try: - rec.start(on_silence_stop=_continuous_on_silence) - except Exception as e: - logger.error("failed to restart continuous recording: %s", e) - _debug(f"_continuous_on_silence: restart raised {type(e).__name__}: {e}") + if _continuous_auto_restart: + # Restart for the next turn. + _debug(f"_continuous_on_silence: restarting loop (no_speech={no_speech})") + _play_beep(frequency=880, count=1) + try: + rec.start(on_silence_stop=_continuous_on_silence) + except Exception as e: + logger.error("failed to restart continuous recording: %s", e) + _debug(f"_continuous_on_silence: restart raised {type(e).__name__}: {e}") + with _continuous_lock: + _continuous_active = False + if on_status: + try: + on_status("idle") + except Exception: + pass + return + + if on_status: + try: + on_status("listening") + except Exception: + pass + else: + # Do not auto-restart. Clean up state and notify idle. + _debug("_continuous_on_silence: auto_restart=False, stopping loop") with _continuous_lock: _continuous_active = False - return - - if on_status: - try: - on_status("listening") - except Exception: - pass + if on_status: + try: + on_status("idle") + except Exception: + pass # ── TTS API ────────────────────────────────────────────────────────── diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 97ebf9e29d62..a6af66bc9aa9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1877,8 +1877,8 @@ def _do_nous_device_request(): name=f"oauth-codex-{sid[:6]}", ).start() # Block briefly until the worker has populated the user_code, OR error. - deadline = time.time() + 10 - while time.time() < deadline: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: with _oauth_sessions_lock: s = _oauth_sessions.get(sid) if s and (s.get("user_code") or s["status"] != "pending"): @@ -2012,10 +2012,10 @@ def _codex_full_login_worker(session_id: str) -> None: sess["expires_at"] = time.time() + sess["expires_in"] # Step 2: poll until authorized - deadline = time.time() + sess["expires_in"] + deadline = time.monotonic() + sess["expires_in"] code_resp = None with httpx.Client(timeout=httpx.Timeout(15.0)) as client: - while time.time() < deadline: + while time.monotonic() < deadline: time.sleep(poll_interval) poll = client.post( f"{issuer}/api/accounts/deviceauth/token", @@ -2173,6 +2173,83 @@ async def cancel_oauth_session(session_id: str, request: Request): # --------------------------------------------------------------------------- + +def _session_latest_descendant(session_id: str): + """Resolve a session id to the newest child leaf session. + + /model may create child sessions. Dashboard refresh should continue the + newest child instead of reopening the old parent. + """ + from hermes_state import SessionDB + + def row_get(row, key, index): + if isinstance(row, dict): + return row.get(key) + try: + return row[key] + except Exception: + try: + return row[index] + except Exception: + return None + + db = SessionDB() + try: + sid = db.resolve_session_id(session_id) + if not sid or not db.get_session(sid): + return None, [] + + conn = ( + getattr(db, "conn", None) + or getattr(db, "_conn", None) + or getattr(db, "connection", None) + or getattr(db, "_connection", None) + ) + + rows = [] + if conn is not None: + raw_rows = conn.execute( + "SELECT id, parent_session_id, started_at FROM sessions" + ).fetchall() + for row in raw_rows: + rows.append({ + "id": row_get(row, "id", 0), + "parent_session_id": row_get(row, "parent_session_id", 1), + "started_at": row_get(row, "started_at", 2), + }) + else: + rows = db.list_sessions_rich(limit=10000, offset=0) + + children = {} + for row in rows: + rid = row.get("id") + parent = row.get("parent_session_id") + if rid and parent: + children.setdefault(parent, []).append(row) + + def started(row): + try: + return float(row.get("started_at") or 0) + except Exception: + return 0.0 + + current = sid + path = [sid] + seen = {sid} + + while children.get(current): + candidates = [r for r in children[current] if r.get("id") not in seen] + if not candidates: + break + candidates.sort(key=started, reverse=True) + current = candidates[0]["id"] + path.append(current) + seen.add(current) + + return current, path + finally: + db.close() + @app.get("/api/sessions/{session_id}") async def get_session_detail(session_id: str): from hermes_state import SessionDB @@ -2187,6 +2264,19 @@ async def get_session_detail(session_id: str): db.close() + +@app.get("/api/sessions/{session_id}/latest-descendant") +async def get_session_latest_descendant(session_id: str): + latest, path = _session_latest_descendant(session_id) + if not latest: + raise HTTPException(status_code=404, detail="Session not found") + return { + "requested_session_id": path[0] if path else session_id, + "session_id": latest, + "path": path, + "changed": bool(path and latest != path[0]), + } + @app.get("/api/sessions/{session_id}/messages") async def get_session_messages(session_id: str): from hermes_state import SessionDB @@ -2366,6 +2456,7 @@ async def delete_cron_job(job_id: str): class ProfileCreate(BaseModel): name: str clone_from_default: bool = False + no_skills: bool = False class ProfileRename(BaseModel): @@ -2471,11 +2562,13 @@ async def create_profile_endpoint(body: ProfileCreate): name=body.name, clone_from="default" if body.clone_from_default else None, clone_config=body.clone_from_default, + no_skills=body.no_skills, ) # Match the CLI's profile-create flow: fresh named profiles get the # bundled skills installed. When cloning from default, create_profile() # has already copied the source profile's skills, including any - # user-installed skills. + # user-installed skills. When no_skills=True, create_profile() wrote + # the opt-out marker and seed_profile_skills() will no-op. if not body.clone_from_default: profiles_mod.seed_profile_skills(path, quiet=True) @@ -2946,8 +3039,18 @@ def _resolve_chat_argv( argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False) env = os.environ.copy() env.setdefault("NODE_ENV", "production") + # Browser-embedded chat should prefer stable wheel-based scrollback over + # native terminal mouse tracking. When mouse tracking is enabled, wheel + # events are consumed by the TUI and forwarded as terminal input, which + # makes browser-side transcript scrolling feel broken. Keep the terminal + # build unchanged for native CLI usage; only disable mouse tracking for + # the dashboard PTY path. + env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1") if resume: + latest_resume, _latest_path = _session_latest_descendant(resume) + if latest_resume: + resume = latest_resume env["HERMES_TUI_RESUME"] = resume if sidecar_url: @@ -3260,8 +3363,9 @@ async def serve_spa(full_path: str): # Built-in dashboard themes — label + description only. The actual color # definitions live in the frontend (web/src/themes/presets.ts). _BUILTIN_DASHBOARD_THEMES = [ - {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, - {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, + {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, + {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"}, + {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, diff --git a/model_tools.py b/model_tools.py index 8721e9ee6a77..679a0934c44e 100644 --- a/model_tools.py +++ b/model_tools.py @@ -730,8 +730,8 @@ def handle_function_call( session_id=session_id or "", tool_call_id=tool_call_id or "", ) - except Exception: - pass + except Exception as _hook_err: + logger.debug("pre_tool_call hook error: %s", _hook_err) if block_message is not None: return json.dumps({"error": block_message}, ensure_ascii=False) @@ -782,8 +782,8 @@ def handle_function_call( tool_call_id=tool_call_id or "", duration_ms=duration_ms, ) - except Exception: - pass + except Exception as _hook_err: + logger.debug("post_tool_call hook error: %s", _hook_err) # Generic tool-result canonicalization seam: plugins receive the # final result string (JSON, usually) and may replace it by @@ -807,8 +807,8 @@ def handle_function_call( if isinstance(hook_result, str): result = hook_result break - except Exception: - pass + except Exception as _hook_err: + logger.debug("transform_tool_result hook error: %s", _hook_err) return result diff --git a/optional-skills/finance/3-statement-model/SKILL.md b/optional-skills/finance/3-statement-model/SKILL.md new file mode 100644 index 000000000000..79718c66cd4e --- /dev/null +++ b/optional-skills/finance/3-statement-model/SKILL.md @@ -0,0 +1,432 @@ +--- +name: 3-statement-model +description: Build fully-integrated 3-statement models (IS, BS, CF) in Excel with working capital schedules, D&A roll-forwards, debt schedule, and the plugs that make cash and retained earnings tie. Pairs with excel-author. +version: 1.0.0 +author: Anthropic (adapted by Nous Research) +license: Apache-2.0 +metadata: + hermes: + tags: [finance, three-statement, income-statement, balance-sheet, cash-flow, excel, openpyxl, modeling] + related_skills: [excel-author, pptx-author, dcf-model, lbo-model] +--- + +## Environment + +This skill assumes **headless openpyxl** — you are producing an .xlsx file on disk. +Follow the `excel-author` skill's conventions for cell coloring, formulas, named ranges, and sensitivity tables. +Recalculate before delivery: `python /path/to/excel-author/scripts/recalc.py ./out/model.xlsx`. + +# 3-Statement Financial Model Template Completion + +Complete and populate integrated financial model templates with proper linkages between Income Statement, Balance Sheet, and Cash Flow Statement. + +## ⚠️ CRITICAL PRINCIPLES — Read Before Populating Any Template + +**Formulas over hardcodes (non-negotiable):** +- Every projection cell, roll-forward, linkage, and subtotal MUST be an Excel formula — never a pre-computed value +- When using Python/openpyxl: write formula strings (`ws["D15"] = "=D14*(1+Assumptions!$B$5)"`), NOT computed results (`ws["D15"] = 12500`) +- The ONLY cells that should contain hardcoded numbers are: (1) historical actuals, (2) assumption drivers in the Assumptions tab +- If you find yourself computing a value in Python and writing the result to a cell — STOP. Write the formula instead. +- Why: the model must flex when scenarios toggle or assumptions change. Hardcodes break every downstream integrity check silently. + +**Verify step-by-step with the user:** +1. **After mapping the template** → show the user which tabs/sections you've identified and confirm before touching any cells +2. **After populating historicals** → show the user the historical block and confirm values/periods match source data +3. **After building IS projections** → run the subtotal checks, show the user the projected IS, confirm before moving to BS +4. **After building BS** → show the user the balance check (Assets = L+E) for every period, confirm before moving to CF +5. **After building CF** → show the user the cash tie-out (CF ending cash = BS cash), confirm before finalizing +6. **Do NOT populate the entire model end-to-end and present it complete** — break at each statement, show the work, catch errors early + +## Formatting — Professional Blue/Grey Palette (Default unless template/user specifies otherwise) + +**Keep colors minimal.** Use only blues and greys for cell fills. Do NOT introduce greens, yellows, oranges, or multiple accent colors — a clean model uses restraint. + +| Element | Fill | Font | +|---|---|---| +| Section headers (IS / BS / CF titles) | Dark blue `#1F4E79` | White bold | +| Column headers (FY2024A, FY2025E, etc.) | Light blue `#D9E1F2` | Black bold | +| Input cells (historicals, assumption drivers) | Light grey `#F2F2F2` or white | Blue `#0000FF` | +| Formula cells | White | Black | +| Cross-tab links | White | Green `#008000` | +| Check rows / key totals | Medium blue `#BDD7EE` | Black bold | + +**That's 3 blues + 1 grey + white.** If the template has its own color scheme, follow the template instead. + +Font color signals *what* a cell is (input/formula/link). Fill color signals *where* you are (header/data/check). + +## Model Structure + +### Identifying Template Tab Organization + +Templates vary in their tab naming conventions and organization. Before populating, review all tabs to understand the template's structure. Below are common tab names and their typical contents: + +| Common Tab Names | Contents to Look For | +|------------------|----------------------| +| IS, P&L, Income Statement | Income Statement | +| BS, Balance Sheet | Balance Sheet | +| CF, CFS, Cash Flow | Cash Flow Statement | +| WC, Working Capital | Working Capital Schedule | +| DA, D&A, Depreciation, PP&E | Depreciation & Amortization Schedule | +| Debt, Debt Schedule | Debt Schedule | +| NOL, Tax, DTA | Net Operating Loss Schedule | +| Assumptions, Inputs, Drivers | Driver assumptions and inputs | +| Checks, Audit, Validation | Error-checking dashboard | + +**Template Review Checklist** +- Identify which tabs exist in the template (not all templates include every schedule) +- Note any template-specific tabs not listed above +- Understand tab dependencies (e.g., which schedules feed into the main statements) +- Locate input cells vs. formula cells on each tab + +### Understanding Template Structure + +Before populating a template, familiarize yourself with its existing layout to ensure data is entered in the correct locations and formulas remain intact. + +**Identifying Row Structure** +- Locate the model title at top of each tab +- Identify section headers and their visual separation +- Find the units row indicating $ millions, %, x, etc. +- Note column headers distinguishing Actuals vs. Estimates periods +- Confirm period labels (e.g., FY2024A, FY2025E) +- Identify input cells vs. formula cells (typically distinguished by font color) + +**Identifying Column Structure** +- Confirm line item labels in leftmost column +- Verify historical years precede projection years +- Note the visual border separating historical from projected periods +- Check for consistent column order across all tabs + +**Working with Named Ranges** +Templates often use named ranges for key inputs and outputs. Before entering data: +- Review existing named ranges in the template (Formulas → Name Manager in Excel) +- Common named ranges include: Revenue growth rates, cost percentages, key outputs (Net Income, EBITDA, Total Debt, Cash), scenario selector cell +- Ensure inputs are entered in cells that feed into these named ranges + +### Projection Period +- Templates typically project 5 years forward from last historical year +- Verify historical (A) vs. projected (E) columns are clearly separated +- Confirm columns use fiscal year notation (e.g., FY2024A, FY2025E) + +## Margin Analysis + +**Note: The following margin analysis should only be performed if prompted by the user or if the template explicitly requires it. If no prompt is given, skip this section.** + +Calculate and display profitability margins on the Income Statement (IS) tab to track operational efficiency and enable peer comparison. + +### Core Margins to Include + +| Margin | Formula | What It Measures | +|--------|---------|------------------| +| Gross Margin | Gross Profit / Revenue | Pricing power, production efficiency | +| EBITDA Margin | EBITDA / Revenue | Core operating profitability | +| EBIT Margin | EBIT / Revenue | Operating profitability after D&A | +| Net Income Margin | Net Income / Revenue | Bottom-line profitability | + +### Income Statement Layout with Margins + +Display margin percentages directly below each profit line item: +- Gross Margin % below Gross Profit +- EBIT Margin % below EBIT +- EBITDA Margin % below EBITDA +- Net Income Margin % below Net Income + +## Credit Metrics + +**Note: The following Credit analysis should only be performed if prompted by the user or if the template explicitly requires it. If no prompt is given, skip this section.** + +Calculate and display credit/leverage metrics on the Balance Sheet (BS) tab to assess financial health, debt capacity, and covenant compliance. + +### Core Credit Metrics to Include + +| Metric | Formula | What It Measures | +|--------|---------|------------------| +| Total Debt / EBITDA | Total Debt / LTM EBITDA | Leverage multiple | +| Net Debt / EBITDA | (Total Debt - Cash) / LTM EBITDA | Leverage net of cash | +| Interest Coverage | EBITDA / Interest Expense | Ability to service debt | +| Debt / Total Cap | Total Debt / (Total Debt + Equity) | Capital structure | +| Debt / Equity | Total Debt / Total Equity | Financial leverage | +| Current Ratio | Current Assets / Current Liabilities | Short-term liquidity | +| Quick Ratio | (Current Assets - Inventory) / Current Liabilities | Immediate liquidity | + +### Credit Metric Hierarchy Checks + +Validate that Upside shows strongest credit profile: +- Leverage: Upside < Base < Downside (lower is better) +- Coverage: Upside > Base > Downside (higher is better) +- Liquidity: Upside > Base > Downside (higher is better) + +### Covenant Compliance Tracking + +If debt covenants are known, add explicit compliance checks comparing actual metrics to covenant thresholds. + +## Scenario Analysis (Base / Upside / Downside) + +Use a scenario toggle (dropdown) in the Assumptions tab with CHOOSE or INDEX/MATCH formulas. + +| Scenario | Description | +|----------|-------------| +| Base Case | Management guidance or consensus estimates | +| Upside Case | Above-guidance growth, margin expansion | +| Downside Case | Below-trend growth, margin compression | + +**Key Drivers to Sensitize**: Revenue growth, Gross margin, SG&A %, DSO/DIO/DPO, CapEx %, Interest rate, Tax rate. + +**Scenario Audit Checks**: Toggle switches all statements, BS balances in all scenarios, Cash ties out, Hierarchy holds (Upside > Base > Downside for NI, EBITDA, FCF, margins). + +## SEC Filings Data Extraction + +If the template specifically requires pulling data from SEC filings (10-K, 10-Q), see [references/sec-filings.md](references/sec-filings.md) for detailed extraction guidance. This reference is only needed when populating templates with public company data from regulatory filings. + +## Completing Model Templates + +This section provides general guidance for completing any 3-statement financial model template while preserving existing formulas and ensuring data integrity. + +### Step 1: Analyze the Template Structure + +Before entering any data, thoroughly review the template to understand its architecture: + +**Identify Input vs. Formula Cells** +- Look for visual cues (font color, cell shading) that distinguish input cells from formula cells +- Common conventions: Blue font = inputs, Black font = formulas, Green font = links to other sheets +- Use Excel's Trace Precedents/Dependents (Formulas → Trace Precedents) to understand cell relationships +- Check for named ranges that may control key inputs (Formulas → Name Manager) + +**Map the Template's Flow** +- Identify which tabs feed into others (e.g., Assumptions → IS → BS → CF) +- Note any supporting schedules and their linkages to main statements +- Document the template's specific line items and structure before populating + +### Step 2: Filling in Data Without Breaking Formulas + +**Golden Rules for Data Entry** + +| Rule | Description | +|------|-------------| +| Only edit input cells | Never overwrite cells containing formulas unless intentionally replacing the formula | +| Preserve cell references | When copying data, use Paste Values (Ctrl+Shift+V) to avoid overwriting formulas with source formatting | +| Match the template's units | Verify if template uses thousands, millions, or actual values before entering data | +| Respect sign conventions | Follow the template's existing sign convention (e.g., expenses as positive or negative) | +| Check for circular references | If the template uses iterative calculations, ensure Enable Iterative Calculation is turned on | + +**Safe Data Entry Process** +1. Identify the exact cells designated for input (usually highlighted or labeled) +2. Enter historical data first, then verify formulas are calculating correctly for those periods +3. Enter assumption drivers that feed forecast calculations +4. Review calculated outputs to confirm formulas are working as intended +5. If a formula cell must be modified, document the original formula before making changes + +**Handling Pre-Built Formulas** +- If formulas reference cells you haven't populated yet, expect temporary errors (#REF!, #DIV/0!) until all inputs are complete +- When formulas produce unexpected results, trace precedents to identify missing or incorrect inputs +- Never delete rows/columns without checking for formula dependencies across all tabs + +### Step 3: Validating Formulas + +**Formula Integrity Checks** + +Before relying on template outputs, validate that formulas are functioning correctly: + +| Check Type | Method | +|------------|--------| +| Trace precedents | Select a formula cell → Formulas → Trace Precedents to verify it references correct inputs | +| Trace dependents | Verify key inputs flow to expected output cells | +| Evaluate formula | Use Formulas → Evaluate Formula to step through complex calculations | +| Check for hardcodes | Projection formulas should reference assumptions, not contain hardcoded values | +| Test with known values | Input simple test values to verify formulas produce expected results | +| Cross-tab consistency | Ensure the same formula logic applies across all projection periods | + +**Common Formula Issues to Watch For** +- Mixed absolute/relative references causing incorrect results when copied across periods +- Broken links to external files or deleted ranges (#REF! errors) +- Division by zero in early periods before revenue ramps (#DIV/0! errors) +- Circular reference warnings (may be intentional for interest calculations) +- Inconsistent formulas across projection columns (use Ctrl+\ to find differences) + +**Validating Cross-Tab Linkages** +- Confirm values that appear on multiple tabs are linked (not duplicated) +- Verify schedule totals tie to corresponding line items on main statements +- Check that period labels align across all tabs + +### Step 4: Quality Checks by Sheet + +Perform these validation checks on each sheet after populating the template: + +**Income Statement (IS) Quality Checks** +- Revenue figures match source data for historical periods +- All expense line items sum to reported totals +- Subtotals (Gross Profit, EBIT, EBT, Net Income) calculate correctly +- Tax calculation logic is appropriate (handles losses correctly) +- Forecast drivers reference assumptions tab (no hardcodes) +- Period-over-period changes are directionally reasonable + +**Balance Sheet (BS) Quality Checks** +- Assets = Liabilities + Equity for every period (primary check) +- Cash balance matches Cash Flow Statement ending cash +- Working capital accounts tie to supporting schedules (if applicable) +- Retained Earnings rolls forward correctly: Prior RE + Net Income - Dividends +/- Adjustments = Ending RE +- Debt balances tie to debt schedule (if applicable) +- All balance sheet items have appropriate signs (assets positive, most liabilities positive) + +**Cash Flow Statement (CF) Quality Checks** +- Net Income at top of CFO matches Income Statement Net Income +- Non-cash add-backs (D&A, SBC, etc.) tie to their source schedules/statements +- Working capital changes have correct signs (increase in asset = use of cash = negative) +- CapEx ties to PP&E schedule or fixed asset roll-forward +- Financing activities tie to changes in debt and equity accounts on BS +- Ending Cash matches Balance Sheet Cash +- Beginning Cash equals prior period Ending Cash + +**Supporting Schedule Quality Checks** +- Opening balances equal prior period closing balances +- Roll-forward logic is complete (Beginning + Additions - Deductions = Ending) +- Schedule totals tie to main statement line items +- Assumptions used in calculations match Assumptions tab + +### Step 5: Cross-Statement Integrity Checks + +After validating individual sheets, confirm the three statements are properly integrated: + +| Check | Formula | Expected Result | +|-------|---------|-----------------| +| Balance Sheet Balance | Assets - Liabilities - Equity | = 0 | +| Cash Tie-Out | CF Ending Cash - BS Cash | = 0 | +| Net Income Link | IS Net Income - CF Starting Net Income | = 0 | +| Retained Earnings | Prior RE + NI - Dividends - BS Ending RE | = 0 (adjust for SBC/other items as needed) | + +### Step 6: Final Review + +Before considering the model complete: +- Toggle through all scenarios (if applicable) to verify checks pass in each case +- Review all #REF!, #DIV/0!, #VALUE!, and #NAME? errors and resolve or document +- Confirm all input cells have been populated (search for placeholder values) +- Verify units are consistent across all tabs +- Save a clean version before making any additional modifications + +## Model Validation and Audit + +This section consolidates all validation checks and audit procedures for completed templates. + +### Core Linkages (Must Always Hold) + +See [references/formulas.md](references/formulas.md) for all formula details. + +| Check | Formula | Expected Result | +|-------|---------|-----------------| +| Balance Sheet Balance | Assets - Liabilities - Equity | = 0 | +| Cash Tie-Out | CF Ending Cash - BS Cash | = 0 | +| Cash Monthly vs Annual | Closing Cash (Monthly) - Closing Cash (Annual) | = 0 | +| Net Income Link | IS Net Income - CF Starting Net Income | = 0 | +| Retained Earnings | Prior RE + NI + SBC - Dividends - BS Ending RE | = 0 | +| Equity Financing | ΔCommon Stock/APIC (BS) - Equity Issuance (CFF) | = 0 | +| Year 0 Equity | Equity Raised (Year 0) - Beginning Equity Capital (Year 1) | = 0 | + +### Sign Convention Reference + +| Statement | Item | Sign Convention | +|-----------|------|-----------------| +| CFO | D&A, SBC | Positive (add-back) | +| CFO | ΔAR (increase) | Negative (use of cash) | +| CFO | ΔAP (increase) | Positive (source of cash) | +| CFI | CapEx | Negative | +| CFF | Debt issuance | Positive | +| CFF | Debt repayments | Negative | +| CFF | Dividends | Negative | + +### Circular Reference Handling + +Interest expense creates circularity: Interest → Net Income → Cash → Debt Balance → Interest + +Enable iterative calculation in Excel: File → Options → Formulas → Enable iterative calculation. Set maximum iterations to 100, maximum change to 0.001. Add a circuit breaker toggle in Assumptions tab. + +### Check Categories + +**Section 1: Currency Consistency** +- Currency identified and documented in Assumptions +- All tabs use consistent currency symbol and scale +- Units row matches model currency + +**Section 2: Balance Sheet Integrity** +- Assets = Liabilities + Equity (for each period) +- Formula: Assets - Liabilities - Equity (must = 0) + +**Section 3: Cash Flow Integrity** +- Cash ties to BS (CF Ending Cash = BS Cash) +- Cash Monthly vs Annual: Closing Cash (Monthly) = Closing Cash (Annual) +- NI ties to IS (CF Net Income = IS Net Income) +- D&A ties to schedule +- SBC ties to IS +- ΔAR, ΔInventory, ΔAP tie to WC schedule +- CapEx ties to DA schedule + +**Section 4: Retained Earnings** +- RE roll-forward check: Prior RE + NI + SBC - Dividends = Ending RE +- Show component breakdown for debugging + +**Section 5: Working Capital** +- AR, Inventory, AP tie to BS +- DSO, DIO, DPO reasonability checks (flag if outside normal ranges) + +**Section 6: Debt Schedule** +- Total Debt ties to BS (Current + LT Debt) +- Interest calculation ties to IS + +**Section 6b: Equity Financing** +- Equity issuance proceeds tie to BS Common Stock/APIC increase +- Cash increase from equity = Equity account increase (must balance) +- Equity Raise Tie-Out: ΔCommon Stock/APIC (BS) = Equity Issuance (CFF) (must = 0) +- Year 0 Equity Tie-Out: Equity Raised (Year 0) = Beginning Equity Capital (Year 1) + +**Section 6c: NOL Schedule** +- Beginning NOL (Year 1 / Formation) = 0 (new business starts with zero NOL) +- NOL increases only when EBT < 0 (losses must be realized to generate NOL) +- DTA ties to BS (NOL Schedule DTA = BS Deferred Tax Asset) +- NOL utilization ≤ 80% of EBT (post-2017 federal limitation) +- NOL balance is non-negative (cannot utilize more than available) +- NOL generated only when EBT < 0 +- Tax expense = 0 when taxable income ≤ 0 + +**Section 7: Scenario Hierarchy** +- Absolute metrics: Upside > Base > Downside (NI, EBITDA, FCF) +- Margins: Upside > Base > Downside (GM%, EBITDA%, NI%) +- Credit metrics: Upside < Base < Downside for leverage (inverted) + +**Section 8: Formula Integrity** +- COGS, S&M, G&A, R&D, SBC driven by % of Revenue (no hardcodes) +- Consistent formulas across projection years +- No #REF!, #DIV/0!, #VALUE! errors + +**Section 9: Credit Metric Thresholds** +- Flag metrics as Green/Yellow/Red based on covenant thresholds +- Summary of any red flags + +### Master Check Formula + +Aggregate all section statuses into a single master check: +- If all sections pass → "✓ ALL CHECKS PASS" +- If any section fails → "✗ ERRORS DETECTED - REVIEW BELOW" + +### Quick Debug Workflow + +When Master Status shows errors: +1. Scroll to find red-highlighted sections +2. Identify which check category has failures +3. Navigate to source tab to investigate +4. Fix the underlying issue +5. Return to Checks tab to verify resolution + + +## Data sources — MCP first, web fallback + +Many passages below say "use the S&P Kensho MCP / Daloopa MCP / FactSet MCP". Those are commercial financial-data MCPs from the original Cowork plugin context. In Hermes: + +- **If you have any structured financial-data MCP configured** (Hermes supports MCP — see `native-mcp` skill), prefer it for point-in-time comps, precedent transactions, and filings. +- **Otherwise**, fall back to: + - `web_search` / `web_extract` against SEC EDGAR (`https://www.sec.gov/cgi-bin/browse-edgar`) for US filings + - Company IR pages for press releases, earnings decks + - `browser_navigate` for interactive data portals + - User-provided data (explicitly ask when the context doesn't have it) +- **Never fabricate**. If a multiple, precedent, or filing number can't be sourced, flag the cell as `[UNSOURCED]` and surface it to the user. + +## Attribution + +This skill is adapted from Anthropic's Claude for Financial Services plugin suite (Apache-2.0). The Office-JS / Cowork live-Excel paths have been removed; this version targets headless openpyxl via the `excel-author` skill's conventions. Original: https://github.com/anthropics/financial-services diff --git a/optional-skills/finance/3-statement-model/references/formatting.md b/optional-skills/finance/3-statement-model/references/formatting.md new file mode 100644 index 000000000000..1fbe938c1623 --- /dev/null +++ b/optional-skills/finance/3-statement-model/references/formatting.md @@ -0,0 +1,118 @@ +# Formatting Standards Reference + +| Element | Format | +|---------|--------| +| Hard-coded inputs | Blue font | +| Formulas | Black font | +| Links to other sheets | Green font | +| Check cells | Red if error, green if balanced | +| Negative values | Parentheses, not minus signs | +| Currency | No decimals for large figures, 2 decimals for per-share | +| Percentages | 1 decimal place | +| Headers | Bold, bottom border | +| Units row | Include units row below headers ($ millions, %, etc.) | + +## Visual Separation Guidelines + +- Thin vertical border between historical and projected columns +- Thick bottom border after section totals (e.g., Total Assets) +- Single bottom border for subtotals +- Double bottom border for grand totals + +## Total and Subtotal Row Formatting + +All total and subtotal rows must use **bold font formatting** for their numerical values to clearly distinguish aggregated figures from individual line items. + +### Income Statement (P&L) Tab +| Row | Formatting | +|-----|------------| +| Gross Revenue | Bold | +| Total Cost of Revenue | Bold | +| Gross Profit | Bold | +| Total SG&A | Bold | +| EBITDA | Bold | +| EBIT | Bold | +| EBT | Bold | +| Net Profit After Tax | Bold | + +### Balance Sheet Tab +| Row | Formatting | +|-----|------------| +| Total Current Assets | Bold | +| Total Non-Current Assets | Bold | +| Total Other Assets | Bold | +| Total Assets | Bold | +| Total Current Liabilities | Bold | +| Total Non-Current Liabilities | Bold | +| Total Equity | Bold | +| Total Liabilities and Equity | Bold | + +### Cash Flow Statement Tab +| Row | Formatting | +|-----|------------| +| Cash Generated from Operations Before Working Capital Changes | Bold | +| Total Working Capital Changes | Bold | +| Net Cash Generated from Operations | Bold | +| Net Cash Flow from Investing Activities | Bold | +| Net Cash Flow from Financing Activities | Bold | +| Closing Cash Balance | Bold | + +**Note:** This list is non-exhaustive. Apply bold formatting to any row that represents a total, subtotal, or summary calculation across the model. + +## Balance Sheet Check Row Formatting + +The Balance Sheet check row (below Total Liabilities and Equity) uses conditional number formatting that displays non-zero values in red. When the balance sheet balances correctly (check = 0), the values display in black or standard formatting. + +| Check Value | Font Color | +|-------------|------------| +| = 0 (balanced) | Black (standard) | +| ≠ 0 (error) | Red | + +**Implementation:** Apply custom number format `[Red][<>0]0.00;[Red][<>0](0.00);0.00` or use Excel conditional formatting with the rule "Cell Value ≠ 0" → Red font. + +## Margin Row Formatting + +| Element | Format | +|---------|--------| +| Margin % rows | Indent, italics, 1 decimal place | +| Positive trend | No special formatting (or subtle green) | +| Negative trend | Flag for review (subtle yellow) | +| Below peer average | Consider highlighting for discussion | + +## Credit Metric Formatting + +| Element | Format | +|---------|--------| +| Leverage multiples | 1 decimal with "x" suffix (e.g., 2.5x) | +| Percentages | 1 decimal with "%" suffix | +| Net Debt negative | Parentheses, indicates net cash position | +| Section header | Bold, "CREDIT METRICS" | +| Separator line | Thin border above credit metrics section | + +## Credit Metric Threshold Colors + +| Metric | Green | Yellow | Red | +|--------|-------|--------|-----| +| Total Debt / EBITDA | < 2.5x | 2.5x-4.0x | > 4.0x | +| Net Debt / EBITDA | < 2.0x | 2.0x-3.5x | > 3.5x | +| Interest Coverage | > 4.0x | 2.5x-4.0x | < 2.5x | +| Debt / Total Cap | < 40% | 40%-60% | > 60% | +| Current Ratio | > 1.5x | 1.0x-1.5x | < 1.0x | +| Quick Ratio | > 1.0x | 0.75x-1.0x | < 0.75x | + +## Conditional Formatting for Checks Tab + +- Cell contains pass indicator → Green fill +- Cell contains fail indicator → Red fill +- Cell contains warning → Yellow fill +- Difference cells = 0 → Light green fill +- Difference cells ≠ 0 → Light red fill + +## Margin Reasonability Flags + +- Gross Margin < 0% → ERROR: Review COGS +- Gross Margin > 80% → WARNING: Verify revenue/COGS +- EBITDA Margin < 0% → FLAG: Operating losses +- EBITDA Margin > 50% → WARNING: Unusually high +- Net Margin < 0% → FLAG: Net losses (may be acceptable in growth phase) +- Net Margin > Gross Margin → ERROR: Formula issue diff --git a/optional-skills/finance/3-statement-model/references/formulas.md b/optional-skills/finance/3-statement-model/references/formulas.md new file mode 100644 index 000000000000..db2645727e25 --- /dev/null +++ b/optional-skills/finance/3-statement-model/references/formulas.md @@ -0,0 +1,292 @@ +# Formula Reference + +**IMPORTANT:** Use the formulas outlined in this reference document unless otherwise specified by the user. + +--- + +## Core Linkages + +``` +Balance Sheet: Assets = Liabilities + Equity +Net Income: IS Net Income → CF Operations (starting point) +Cash Flow: ΔCash = CFO + CFI + CFF +Cash Tie-Out: Ending Cash (CF) = Cash (BS Asset) +Cash Monthly/Annual: Closing Cash (Monthly) = Closing Cash (Annual) +Retained Earnings: Prior RE + Net Income - Dividends = Ending RE +Equity Raise: ΔCommon Stock/APIC (BS) = Equity Issuance (CFF) +Year 0 Equity: Equity Raised (Year 0) = Beginning Equity (Year 1) +``` + +## Gross Profit Calculation + +**IMPORTANT:** Gross Profit must be calculated from Net Revenue, not Gross Revenue. + +``` +Net Revenue - Cost of Revenue = Gross Profit +``` + +| Term | Definition | +|------|------------| +| Gross Revenue | Total revenue before any deductions | +| Net Revenue | Gross Revenue - Returns - Allowances - Discounts | +| Cost of Revenue | Direct costs attributable to production of goods/services sold | +| Gross Profit | Net Revenue - Cost of Revenue | + +**Note:** Always use Net Revenue (also called "Net Sales" or simply "Revenue" on most financial statements) as the starting point for profitability calculations. Gross Revenue overstates the true top-line performance. + +## Margin Formulas + +``` +Gross Margin % = Gross Profit / Net Revenue +EBITDA = EBIT + D&A (or = Gross Profit - OpEx) +EBITDA Margin % = EBITDA / Net Revenue +EBIT Margin % = EBIT / Net Revenue +Net Income Margin % = Net Income / Net Revenue +``` + +## Credit Metric Formulas + +``` +Total Debt = Current Portion of Debt + Long-Term Debt +Net Debt = Total Debt - Cash +Total Debt / EBITDA = Total Debt / EBITDA (from IS) +Net Debt / EBITDA = Net Debt / EBITDA (from IS) +Interest Coverage = EBITDA / Interest Expense (from IS) +Net Int Exp % Debt = Net Interest Expense / Long-Term Debt +Debt / Total Cap = Total Debt / (Total Debt + Total Equity) +Debt / Equity = Total Debt / Total Equity +Current Ratio = Total Current Assets / Total Current Liabilities +Quick Ratio = (Total Current Assets - Inventory) / Total Current Liabilities +``` + +## Forecast Formulas (% of Net Revenue Method) + +``` +Cost of Revenue (Forecast) = Net Revenue × Cost of Revenue % Assumption +S&M (Forecast) = Net Revenue × S&M % Assumption +G&A (Forecast) = Net Revenue × G&A % Assumption +R&D (Forecast) = Net Revenue × R&D % Assumption +SBC (Forecast) = Net Revenue × SBC % Assumption +``` + +## Working Capital Formulas + +``` +Accounts Receivable + Prior AR + + Revenue (from IS) + - Cash Collections (plug) + = Ending AR + DSO = (AR / Revenue) × 365 + +Inventory + Prior Inventory + + Purchases (plug) + - COGS (from IS) + = Ending Inventory + DIO = (Inventory / COGS) × 365 + +Accounts Payable + Prior AP + + Purchases (from Inventory calc) + - Cash Payments (plug) + = Ending AP + DPO = (AP / COGS) × 365 + +Net Working Capital = AR + Inventory - AP +ΔWC = Current NWC - Prior NWC +``` + +## D&A Schedule Formulas + +``` +Beginning PP&E (Gross) ++ CapEx += Ending PP&E (Gross) + +Beginning Accumulated Depreciation ++ Depreciation Expense += Ending Accumulated Depreciation + +PP&E (Net) = Gross PP&E - Accumulated Depreciation +``` + +## Debt Schedule Formulas + +``` +Beginning Debt Balance ++ New Borrowings +- Repayments += Ending Debt Balance + +Interest Expense = Avg Debt Balance × Interest Rate + (Use beginning balance to avoid circularity, or iterate if circular refs enabled) +``` + +## Retained Earnings Formula + +``` +Beginning Retained Earnings ++ Net Income (from IS) ++ Stock-Based Compensation (SBC) (from IS) +- Dividends += Ending Retained Earnings +``` + +## NOL (Net Operating Loss) Schedule Formulas + +``` +NOL CARRYFORWARD SCHEDULE + +Beginning NOL Balance (Year 1 / Formation = 0) ++ NOL Generated (if EBT < 0, then ABS(EBT), else 0) +- NOL Utilized (limited by taxable income and utilization cap) += Ending NOL Balance + +STARTING BALANCE RULE + +For a new business or first modeled period: + Beginning NOL Balance = 0 + NOL can only increase through realized losses (EBT < 0) + NOL cannot be created from thin air or assumed + +NOL UTILIZATION CALCULATION + +Pre-Tax Income (EBT) + If EBT > 0: + NOL Available = Beginning NOL Balance + Utilization Limit = EBT × 80% (post-2017 federal limit) + NOL Utilized = MIN(NOL Available, Utilization Limit) + Taxable Income = EBT - NOL Utilized + If EBT ≤ 0: + NOL Utilized = 0 + Taxable Income = 0 + NOL Generated = ABS(EBT) + +TAX CALCULATION WITH NOL + +Taxes Payable = MAX(0, Taxable Income × Tax Rate) + (Taxes cannot be negative; losses create NOL asset instead) + +DEFERRED TAX ASSET (DTA) FOR NOL + +DTA - NOL Carryforward = Ending NOL Balance × Tax Rate +ΔDTA = Current DTA - Prior DTA + (Increase in DTA = non-cash benefit on CF) + (Decrease in DTA = non-cash expense on CF) +``` + +## Balance Sheet Structure + +``` +ASSETS + Cash (from CF ending cash) + Accounts Receivable (from WC) + Inventory (from WC) + Total Current Assets + + PP&E, Net (from DA) + Deferred Tax Asset - NOL (from NOL schedule) + Total Non-Current Assets + Total Assets + +LIABILITIES + Accounts Payable (from WC) + Current Portion of Debt (from Debt) + Total Current Liabilities + + Long-Term Debt (from Debt) + Total Liabilities + +EQUITY + Common Stock + Retained Earnings (from RE schedule) + Total Equity + +CHECK: Assets - Liabilities - Equity = 0 +``` + +## Cash Flow Statement Structure + +``` +CASH FROM OPERATIONS (CFO) + Net Income (LINK: IS) + + D&A (LINK: DA schedule) + + Stock-Based Compensation (SBC) (LINK: IS or Assumptions) + - ΔDTA (Deferred Tax Asset) (LINK: NOL schedule; increase in DTA = use of cash) + - ΔAR (LINK: WC) + - ΔInventory (LINK: WC) + + ΔAP (LINK: WC) + = CFO + +CASH FROM INVESTING (CFI) + - CapEx (LINK: DA schedule) + = CFI + +CASH FROM FINANCING (CFF) + + Debt Issuance (LINK: Debt) + - Debt Repayment (LINK: Debt) + + Equity Issuance (LINK: BS Common Stock/APIC) + - Dividends (LINK: RE schedule) + = CFF + +Net Change in Cash = CFO + CFI + CFF +Beginning Cash ++ Net Change in Cash += Ending Cash (LINK TO: BS Cash) +``` + +## Income Statement Structure + +``` +Net Revenue + Growth % +(-) Cost of Revenue + % of Net Revenue +──────────────── +Gross Profit (= Net Revenue - Cost of Revenue) + Gross Margin % + +(-) S&M + % of Net Revenue +(-) G&A + % of Net Revenue +(-) R&D + % of Net Revenue +(-) D&A +(-) SBC + % of Net Revenue +──────────────── +EBIT + EBIT Margin % + +EBITDA + EBITDA Margin % + +(-) Interest Expense +──────────────── +EBT (Pre-Tax Income) +(-) NOL Utilization (from NOL schedule, reduces taxable income) +──────────────── +Taxable Income +(-) Taxes (Taxable Income × Tax Rate) +──────────────── +Net Income + Net Income Margin % +``` + +## Check Formulas + +``` +BS Balance Check: = Assets - Liabilities - Equity (must = 0) +Cash Tie-Out: = BS Cash - CF Ending Cash (must = 0) +RE Roll-Forward: = Prior RE + NI + SBC - Div - BS RE (must = 0) +DTA Tie-Out: = NOL Schedule DTA - BS DTA (must = 0) +Equity Raise Tie-Out: = ΔCommon Stock/APIC (BS) - Equity Issuance (CFF) (must = 0) +Year 0 Equity Tie-Out: = Equity Raised (Year 0) - Beginning Equity (Year 1) (must = 0) +Cash Monthly vs Annual: = Closing Cash (Monthly) - Closing Cash (Annual) (must = 0) +NOL Utilization Cap: = NOL Utilized ≤ EBT × 80% (must be TRUE for post-2017) +NOL Non-Negative: = Ending NOL Balance ≥ 0 (must be TRUE) +NOL Starting Balance: = Beginning NOL (Year 1) = 0 (must be TRUE for new business) +NOL Accumulation: = NOL increases only when EBT < 0 (losses generate NOL) +``` diff --git a/optional-skills/finance/3-statement-model/references/sec-filings.md b/optional-skills/finance/3-statement-model/references/sec-filings.md new file mode 100644 index 000000000000..e0fa48453a15 --- /dev/null +++ b/optional-skills/finance/3-statement-model/references/sec-filings.md @@ -0,0 +1,125 @@ +# SEC Filings Data Extraction Reference + +**When to Use:** Only reference this file when a model template specifically requires pulling data from SEC filings (10-K, 10-Q). For templates that provide data directly or use other data sources, this reference is not needed. + +--- + +## Extracting Data from SEC Filings (10-K / 10-Q) + +When populating a model template with public company data, extract financials directly from SEC filings. + +### Step 1: Locate the Filing + +1. Use SEC EDGAR: `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=[TICKER]&type=10-K` +2. For quarterly data, use `type=10-Q` + +### Step 2: Identify Filing Currency + +Before extracting data, identify the reporting currency: +- Check the cover page or header for reporting currency +- Look at statement headers (e.g., "in thousands of U.S. dollars") +- Review Note 1 (Summary of Significant Accounting Policies) + +**Common Currency Indicators** + +| Indicator | Currency | +|-----------|----------| +| $, USD | US Dollar | +| €, EUR | Euro | +| £, GBP | British Pound | +| ¥, JPY | Japanese Yen | +| ¥, CNY, RMB | Chinese Yuan | +| CHF | Swiss Franc | +| CAD, C$ | Canadian Dollar | + +Set model currency to match filing; document in Assumptions tab. + +### Step 3: Navigate to Financial Statements + +Within the 10-K or 10-Q, locate: +- **Item 8** (10-K) or **Item 1** (10-Q): Financial Statements +- Key sections to extract: + - Consolidated Statements of Operations (Income Statement) + - Consolidated Balance Sheets + - Consolidated Statements of Cash Flows + - Notes to Financial Statements (for schedule details) + +### Step 4: Data Extraction Mapping + +**Income Statement (from Consolidated Statements of Operations)** + +| Filing Line Item | Model Line Item | +|------------------|-----------------| +| Net revenues / Net sales | Revenue | +| Cost of goods sold | COGS | +| Selling, general and administrative | SG&A | +| Depreciation and amortization | D&A | +| Interest expense, net | Interest Expense | +| Income tax expense | Taxes | +| Net income | Net Income | + +**Balance Sheet (from Consolidated Balance Sheets)** + +| Filing Line Item | Model Line Item | +|------------------|-----------------| +| Cash and cash equivalents | Cash | +| Accounts receivable, net | AR | +| Inventories | Inventory | +| Property, plant and equipment, net | PP&E (Net) | +| Total assets | Total Assets | +| Accounts payable | AP | +| Short-term debt / Current portion of LT debt | Current Debt | +| Long-term debt | LT Debt | +| Retained earnings | Retained Earnings | +| Total stockholders' equity | Total Equity | + +**Cash Flow Statement (from Consolidated Statements of Cash Flows)** + +| Filing Line Item | Model Line Item | +|------------------|-----------------| +| Net income | Net Income | +| Depreciation and amortization | D&A | +| Changes in accounts receivable | ΔAR | +| Changes in inventories | ΔInventory | +| Changes in accounts payable | ΔAP | +| Capital expenditures | CapEx | +| Proceeds from issuance of common stock | Equity Issuance | +| Proceeds from / Repayments of debt | Debt activity | +| Dividends paid | Dividends | + +### Step 5: Extract Supporting Detail from Notes + +For schedules, pull from Notes to Financial Statements: +- **Note: Debt** → Maturity schedule, interest rates, covenants +- **Note: Property, Plant & Equipment** → Gross PP&E, accumulated depreciation, useful lives +- **Note: Revenue** → Segment breakdowns, geographic splits +- **Note: Leases** → Operating vs. finance lease obligations + +### Step 6: Historical Data Requirements + +Extract 3 years of historical data minimum: +- 10-K provides 3 years of IS/CF, 2 years of BS +- For 3rd year BS, pull from prior year's 10-K +- Use 10-Qs to fill in quarterly granularity if needed + +### Data Extraction Checklist + +- Identify reporting currency and scale (thousands, millions) +- 3 years historical Income Statement +- 3 years historical Cash Flow Statement +- 3 years historical Balance Sheet +- Verify IS Net Income = CF starting Net Income (each year) +- Verify BS Cash = CF Ending Cash (each year) +- Extract debt maturity schedule from notes +- Extract D&A detail or useful life assumptions +- Note any non-recurring / one-time items to normalize + +### Handling Common Filing Variations + +| Variation | How to Handle | +|-----------|---------------| +| D&A embedded in COGS/SG&A | Pull D&A from Cash Flow Statement | +| "Other" line items are material | Check notes for breakdown | +| Restatements | Use restated figures, note in assumptions | +| Fiscal year ≠ calendar year | Label with fiscal year end (e.g., FYE Jan 2025) | +| Non-USD reporting currency | Adapt model currency to match filing | diff --git a/optional-skills/finance/comps-analysis/SKILL.md b/optional-skills/finance/comps-analysis/SKILL.md new file mode 100644 index 000000000000..39c968d9af54 --- /dev/null +++ b/optional-skills/finance/comps-analysis/SKILL.md @@ -0,0 +1,661 @@ +--- +name: comps-analysis +description: Build comparable company analysis in Excel — operating metrics, valuation multiples, statistical benchmarking vs peer sets. Pairs with excel-author. Use for public-company valuation, IPO pricing, sector benchmarking, or outlier detection. +version: 1.0.0 +author: Anthropic (adapted by Nous Research) +license: Apache-2.0 +metadata: + hermes: + tags: [finance, valuation, comps, excel, openpyxl, modeling, investment-banking] + related_skills: [excel-author, pptx-author, dcf-model, lbo-model] +--- + +## Environment + +This skill assumes **headless openpyxl** — you are producing an .xlsx file on disk. +Follow the `excel-author` skill's conventions for cell coloring, formulas, named ranges, and sensitivity tables. +Recalculate before delivery: `python /path/to/excel-author/scripts/recalc.py ./out/model.xlsx`. + +# Comparable Company Analysis + +## ⚠️ CRITICAL: Data Source Priority (READ FIRST) + +**ALWAYS follow this data source hierarchy:** + +1. **FIRST: Check for MCP data sources** - If S&P Kensho MCP, FactSet MCP, or Daloopa MCP are available, use them exclusively for financial and trading information +2. **DO NOT use web search** if the above MCP data sources are available +3. **ONLY if MCPs are unavailable:** Then use Bloomberg Terminal, SEC EDGAR filings, or other institutional sources +4. **NEVER use web search as a primary data source** - it lacks the accuracy, audit trails, and reliability required for institutional-grade analysis + +**Why this matters:** MCP sources provide verified, institutional-grade data with proper citations. Web search results can be outdated, inaccurate, or unreliable for financial analysis. + +--- + +## Overview +This skill teaches the agent to build institutional-grade comparable company analyses that combine operating metrics, valuation multiples, and statistical benchmarking. The output is a structured Excel/spreadsheet that enables informed investment decisions through peer comparison. + +**Reference Material & Contextualization:** + +An example comparable company analysis is provided in `examples/comps_example.xlsx`. When using this or other example files in this skill directory, use them intelligently: + +**DO use examples for:** +- Understanding structural hierarchy (how sections flow) +- Grasping the level of rigor expected (statistical depth, documentation standards) +- Learning principles (clear headers, transparent formulas, audit trails) + +**DO NOT use examples for:** +- Exact reproduction of format or metrics +- Copying layout without considering context +- Applying the same visual style regardless of audience + +**ALWAYS ask yourself first:** +1. **"Do you have a preferred format or should I adapt the template style?"** +2. **"Who is the audience?"** (Investment committee, board presentation, quick reference, detailed memo) +3. **"What's the key question?"** (Valuation, growth analysis, competitive positioning, efficiency) +4. **"What's the context?"** (M&A evaluation, investment decision, sector benchmarking, performance review) + +**Adapt based on specifics:** +- **Industry context**: Big tech mega-caps need different metrics than emerging SaaS startups +- **Sector-specific needs**: Add relevant metrics early (e.g., cloud ARR, enterprise customers, developer ecosystem for tech) +- **Company familiarity**: Well-known companies may need less background, more focus on delta analysis +- **Decision type**: M&A requires different emphasis than ongoing portfolio monitoring + +**Core principle:** Use template principles (clear structure, statistical rigor, transparent formulas) but vary execution based on context. The goal is institutional-quality analysis, not institutional-looking templates. + +User-provided examples and explicit preferences always take precedence over defaults. + +## Core Philosophy +**"Build the right structure first, then let the data tell the story."** + +Start with headers that force strategic thinking about what matters, input clean data, build transparent formulas, and let statistics emerge automatically. A good comp should be immediately readable by someone who didn't build it. + +--- + +## ⚠️ CRITICAL: Formulas Over Hardcodes + Step-by-Step Verification + +**Formulas, not hardcodes:** +- Every derived value (margin, multiple, statistic) MUST be an Excel formula referencing input cells — never a pre-computed number pasted in +- When using Python/openpyxl to build the sheet: write `cell.value = "=E7/C7"` (formula string), NOT `cell.value = 0.687` (computed result) +- The only hardcoded values should be raw input data (revenue, EBITDA, share price, etc.) — and every one of those gets a cell comment with its source +- Why: the model must update automatically when an input changes. A hardcoded margin is a silent bug waiting to happen. + +**Verify step-by-step with the user:** +- After setting up the structure → show the user the header layout before filling data +- After entering raw inputs → show the user the input block and confirm sources/periods before building formulas +- After building operating metrics formulas → show the calculated margins and sanity-check with the user before moving to valuation +- After building valuation multiples → show the multiples and confirm they look reasonable before adding statistics +- Do NOT build the entire sheet end-to-end and then present it — catch errors early by confirming each section + +--- + +## Section 1: Document Structure & Setup + +### Header Block (Rows 1-3) +``` +Row 1: [ANALYSIS TITLE] - COMPARABLE COMPANY ANALYSIS +Row 2: [List of Companies with Tickers] • [Company 1 (TICK1)] • [Company 2 (TICK2)] • [Company 3 (TICK3)] +Row 3: As of [Period] | All figures in [USD Millions/Billions] except per-share amounts and ratios +``` + +**Why this matters:** Establishes context immediately. Anyone opening this file knows what they're looking at, when it was created, and how to interpret the numbers. + +### Visual Convention Standards (OPTIONAL - User preferences and uploaded templates always override) + +**IMPORTANT: These are suggested defaults only. Always prioritize:** +1. User's explicit formatting preferences +2. Formatting from any uploaded template files +3. Company/team style guides +4. These defaults (only if no other guidance provided) + +**Suggested Font & Typography:** +- **Font family**: Times New Roman (professional, readable, industry standard) +- **Font size**: 11pt for data cells, 12pt for headers +- **Bold text**: Section headers, company names, statistic labels + +**Default Color & Shading — Professional Blue/Grey Palette (minimal is better):** +- **Keep it restrained** — only blues and greys. Do NOT introduce greens, oranges, reds, or multiple accent colors. A clean comps sheet uses 3-4 colors total. +- **Section headers** (e.g., "OPERATING STATISTICS & FINANCIAL METRICS"): + - Dark blue background (`#1F4E79` or `#17365D` navy) + - White bold text + - Full row shading across all columns +- **Column headers** (e.g., "Company", "Revenue", "Margin"): + - Light blue background (`#D9E1F2` or similar pale blue) + - Black bold text + - Centered alignment +- **Data rows**: + - White background for company data + - Black text for formulas; blue text for hardcoded inputs +- **Statistics rows** (Maximum, 75th Percentile, etc.): + - Light grey background (`#F2F2F2`) + - Black text, left-aligned labels +- **That's the whole palette**: dark blue + light blue + light grey + white. Nothing else unless the user's template says otherwise. + +**Suggested Formatting Conventions:** +- **Decimal precision**: + - Percentages: 1 decimal (12.3%) + - Multiples: 1 decimal (13.5x) + - Dollar amounts: No decimals, thousands separator (69,632) + - Margins shown as percentages: 1 decimal (68.7%) +- **Borders**: No borders (clean, minimal appearance) +- **Alignment**: All metrics center-aligned for clean, uniform appearance +- **Cell dimensions**: All column widths should be uniform/even, all row heights should be consistent (creates clean, professional grid) + +**Note:** If the user provides a template file or specifies different formatting, use that instead. + +--- + +## Section 2: Operating Statistics & Financial Metrics + +### Core Columns (Start with these) +1. **Company** - Names with consistent formatting +2. **Revenue** - Size metric (can be LTM, quarterly, or annual depending on context) +3. **Revenue Growth** - Year-over-year percentage change +4. **Gross Profit** - Revenue minus cost of goods sold +5. **Gross Margin** - GP/Revenue (fundamental profitability) +6. **EBITDA** - Earnings before interest, tax, depreciation, amortization +7. **EBITDA Margin** - EBITDA/Revenue (operating efficiency) + +### Optional Additions (Choose based on industry/purpose) +- **Quarterly vs LTM** - Include both if seasonality matters +- **Free Cash Flow** - For capital-intensive or SaaS businesses +- **FCF Margin** - FCF/Revenue (cash generation efficiency) +- **Net Income** - For mature, profitable companies +- **Operating Income** - For businesses with varying D&A +- **CapEx metrics** - For asset-heavy industries +- **Rule of 40** - Specifically for SaaS (Growth % + Margin %) +- **FCF Conversion** - For quality of earnings analysis (advanced) + +### Formula Examples (Using Row 7 as example) +```excel +// Core ratios - these are always calculated +Gross Margin (F7): =E7/C7 +EBITDA Margin (H7): =G7/C7 + +// Optional ratios - include if relevant +FCF Margin: =[FCF]/[Revenue] +Net Margin: =[Net Income]/[Revenue] +Rule of 40: =[Growth %]+[FCF Margin %] +``` + +**Golden Rule:** Every ratio should be [Something] / [Revenue] or [Something] / [Something from this sheet]. Keep it simple. + +### Statistics Block (After company data) + +**CRITICAL: Add statistics formulas for all comparable metrics (ratios, margins, growth rates, multiples).** + +``` +[Leave one blank row for visual separation] +- Maximum: =MAX(B7:B9) +- 75th Percentile: =QUARTILE(B7:B9,3) +- Median: =MEDIAN(B7:B9) +- 25th Percentile: =QUARTILE(B7:B9,1) +- Minimum: =MIN(B7:B9) +``` + +**Columns that NEED statistics (comparable metrics):** +- Revenue Growth %, Gross Margin %, EBITDA Margin %, EPS +- EV/Revenue, EV/EBITDA, P/E, Dividend Yield %, Beta + +**Columns that DON'T need statistics (size metrics):** +- Revenue, EBITDA, Net Income (absolute size varies by company scale) +- Market Cap, Enterprise Value (not comparable across different-sized companies) + +**Note:** Add one blank row between company data and statistics rows for visual separation. Do NOT add a "SECTOR STATISTICS" or "VALUATION STATISTICS" header row. + +**Why quartiles matter:** They show distribution, not just average. A 75th percentile multiple tells you what "premium" companies trade at. + +--- + +## Section 3: Valuation Multiples & Investment Metrics + +### Core Valuation Columns (Start with these) +1. **Company** - Same order as operating section +2. **Market Cap** - Current market valuation +3. **Enterprise Value** - Market Cap ± Net Debt/Cash +4. **EV/Revenue** - How much market pays per dollar of sales +5. **EV/EBITDA** - How much market pays per dollar of earnings +6. **P/E Ratio** - Price relative to net earnings + +### Optional Valuation Metrics (Choose based on context) +- **FCF Yield** - FCF/Market Cap (for cash-focused analysis) +- **PEG Ratio** - P/E/Growth Rate (for growth companies) +- **Price/Book** - Market value vs. book value (for asset-heavy businesses) +- **ROE/ROA** - Return metrics (for profitability comparison) +- **Revenue/EBITDA CAGR** - Historical growth rates (for trend analysis) +- **Asset Turnover** - Revenue/Assets (for operational efficiency) +- **Debt/Equity** - Leverage (for capital structure analysis) + +**Key Principle:** Include 3-5 core multiples that matter for your industry. Don't include every possible metric just because you can. + +### Formula Examples +```excel +// Core multiples - always include these +EV/Revenue: =[Enterprise Value]/[LTM Revenue] +EV/EBITDA: =[Enterprise Value]/[LTM EBITDA] +P/E Ratio: =[Market Cap]/[Net Income] + +// Optional multiples - include if data available +FCF Yield: =[LTM FCF]/[Market Cap] +PEG Ratio: =[P/E]/[Growth Rate %] +``` + +### Cross-Reference Rule +**CRITICAL:** Valuation multiples MUST reference the operating metrics section. Never input the same raw data twice. If revenue is in C7, then EV/Revenue formula should reference C7. + +### Statistics Block +Same structure as operating section: Max, 75th, Median, 25th, Min for every metric. Add one blank row for visual separation between company data and statistics. Do NOT add a "VALUATION STATISTICS" header row. + +--- + +## Section 4: Notes & Methodology Documentation + +### Required Components + +**Data Sources & Quality:** +- Where did the data come from? (S&P Kensho MCP, FactSet MCP, Daloopa MCP, Bloomberg, SEC filings) +- What period does it cover? (Q4 2024, audited figures) +- How was it verified? (Cross-checked against 10-K/10-Q) +- Note: Prioritize MCP data sources (S&P Kensho, FactSet, Daloopa) if available for better accuracy and traceability + +**Key Definitions:** +- EBITDA calculation method (Gross Profit + D&A, or Operating Income + D&A) +- Free Cash Flow formula (Operating CF - CapEx) +- Special metrics explained (Rule of 40, FCF Conversion) +- Time period definitions (LTM, CAGR calculation periods) + +**Valuation Methodology:** +- How was Enterprise Value calculated? (Market Cap + Net Debt) +- What growth rates were used? (Historical CAGR, forward estimates) +- Any adjustments made? (One-time items excluded, normalized margins) + +**Analysis Framework:** +- What's the investment thesis? (Cloud/SaaS efficiency) +- What metrics matter most? (Cash generation, capital efficiency) +- How should readers interpret the statistics? (Quartiles provide context) + +--- + +## Section 5: Choosing the Right Metrics (Decision Framework) + +### Start with "What question am I answering?" + +**"Which company is undervalued?"** +→ Focus on: EV/Revenue, EV/EBITDA, P/E, Market Cap +→ Skip: Operational details, growth metrics + +**"Which company is most efficient?"** +→ Focus on: Gross Margin, EBITDA Margin, FCF Margin, Asset Turnover +→ Skip: Size metrics, absolute dollar amounts + +**"Which company is growing fastest?"** +→ Focus on: Revenue Growth %, EBITDA CAGR, User/Customer Growth +→ Skip: Margin metrics, leverage ratios + +**"Which is the best cash generator?"** +→ Focus on: FCF, FCF Margin, FCF Conversion, CapEx intensity +→ Skip: EBITDA, P/E ratios + +### Industry-Specific Metric Selection + +**Software/SaaS:** +Must have: Revenue Growth, Gross Margin, Rule of 40 +Optional: ARR, Net Dollar Retention, CAC Payback +Skip: Asset Turnover, Inventory metrics + +**Manufacturing/Industrials:** +Must have: EBITDA Margin, Asset Turnover, CapEx/Revenue +Optional: ROA, Inventory Turns, Backlog +Skip: Rule of 40, SaaS metrics + +**Financial Services:** +Must have: ROE, ROA, Efficiency Ratio, P/E +Optional: Net Interest Margin, Loan Loss Reserves +Skip: Gross Margin, EBITDA (not meaningful for banks) + +**Retail/E-commerce:** +Must have: Revenue Growth, Gross Margin, Inventory Turnover +Optional: Same-Store Sales, Customer Acquisition Cost +Skip: Heavy R&D or CapEx metrics + +### The "5-10 Rule" + +**5 operating metrics** - Revenue, Growth, 2-3 margins/efficiency metrics +**5 valuation metrics** - Market Cap, EV, 3 multiples +**= 10 total columns** - Enough to tell the story, not so many you lose the thread + +If you have more than 15 metrics, you're probably including noise. Edit ruthlessly. + +--- + +## Section 6: Best Practices & Quality Checks + +### Before You Start +1. **Define the peer group** - Companies must be truly comparable (similar business model, scale, geography) +2. **Choose the right period** - LTM smooths seasonality; quarterly shows trends +3. **Standardize units upfront** - Millions vs. billions decision affects everything +4. **Map data sources** - Know where each number comes from + +### As You Build +1. **Input all raw data first** - Complete the blue text before writing formulas +2. **Add cell comments to ALL hard-coded inputs** - Right-click cell → Insert Comment → Document source OR assumption + + **For sourced data, cite exactly where it came from:** + - Example: "Bloomberg Terminal - MSFT Equity DES, accessed 2024-10-02" + - Example: "Q4 2024 10-K filing, page 42, line item 'Total Revenue'" + - Example: "FactSet consensus estimate as of 2024-10-02" + - **Include hyperlinks when possible**: Right-click cell → Link → paste URL to SEC filing, data source, or report + + **For assumptions, explain the reasoning:** + - Example: "Assumed 15% EBITDA margin based on peer median, company does not disclose" + - Example: "Estimated Enterprise Value as Market Cap + $50M net debt (from Q3 balance sheet, Q4 not yet available)" + - Example: "Forward P/E based on street consensus EPS of $3.45 (average of 12 analyst estimates)" + + **Why this matters**: Enables audit trails, data verification, assumption transparency, and future updates +3. **Build formulas row by row** - Test each calculation before moving on +4. **Use absolute references for headers** - $C$6 locks the header row +5. **Format consistently** - Percentages as percentages, not decimals +6. **Add conditional formatting** - Highlight outliers automatically + +### Sanity Checks +- **Margin test**: Gross margin > EBITDA margin > Net margin (always true by definition) +- **Multiple reasonableness**: + - EV/Revenue: typically 0.5-20x (varies widely by industry) + - EV/EBITDA: typically 8-25x (fairly consistent across industries) + - P/E: typically 10-50x (depends on growth rate) +- **Growth-multiple correlation**: Higher growth usually means higher multiples +- **Size-efficiency trade-off**: Larger companies often have better margins (scale benefits) + +### Common Mistakes to Avoid +❌ Mixing market cap and enterprise value in formulas +❌ Using different time periods for numerator and denominator (LTM vs quarterly) +❌ Hardcoding numbers into formulas instead of cell references +❌ **Hard-coded inputs without cell comments citing the source OR explaining the assumption** +❌ Missing hyperlinks to SEC filings or data sources when available +❌ Including too many metrics without clear purpose +❌ Including non-comparable companies (different business models) +❌ Using outdated data without disclosure +❌ Calculating averages of percentages incorrectly (should be median) + +--- + +## Section 6: Advanced Features + +### Dynamic Headers +For columns showing calculations, use clear unit labels: +``` +Revenue Growth (YoY) % | EBITDA Margin | FCF Margin | Rule of 40 +``` + +### Quartile Analysis Benefits +Instead of just mean/median, quartiles show: +- **75th percentile** = "Premium" companies trade here +- **Median** = Typical market valuation +- **25th percentile** = "Discount" territory + +This helps answer: "Is our target company trading rich or cheap vs. peers?" + +### Industry-Specific Modifications + +**Software/SaaS:** +- Add: ARR, Net Dollar Retention, CAC Payback Period +- Emphasize: Rule of 40, FCF margins, gross margins >70% + +**Healthcare:** +- Add: R&D/Revenue, Pipeline value, Regulatory status +- Emphasize: EBITDA margins, growth rates, reimbursement risk + +**Industrials:** +- Add: Backlog, Order book trends, Geographic mix +- Emphasize: ROIC, asset turnover, cyclical adjustments + +**Consumer:** +- Add: Same-store sales, Customer acquisition cost, Brand value +- Emphasize: Revenue growth, gross margins, inventory turns + +--- + +## Section 7: Workflow & Practical Tips + +### Step-by-Step Process +1. **Set up structure** (30 minutes) + - Create all headers + - Format cells (blue for inputs, black for formulas) + - Lock in units and date references + +2. **Gather data** (60-90 minutes) + - Pull from primary sources (S&P Kensho MCP, FactSet MCP, Daloopa MCP if available; otherwise Bloomberg, SEC) + - Input all raw numbers in blue + - Document sources in notes section + +3. **Build formulas** (30 minutes) + - Start with simple ratios (margins) + - Progress to multiples (EV/Revenue) + - Add cross-checks (do margins make sense?) + +4. **Add statistics** (15 minutes) + - Copy formula structure for all columns + - Verify ranges are correct (B7:B9, not B7:B10) + - Check quartile logic + +5. **Quality control** (30 minutes) + - Run sanity checks + - Verify formula references + - Check for #DIV/0! or #REF! errors + - Compare against known benchmarks + +6. **Documentation** (15 minutes) + - Complete notes section + - Add data sources + - Define methodologies + - Date-stamp the analysis + +### Pro Tips +- **Save templates**: Build once, reuse forever +- **Color-code outliers**: Conditional formatting for values >2 standard deviations +- **Link to source files**: Hyperlink to Bloomberg screenshots or SEC filings +- **Version control**: Save as "Comps_v1_2024-12-15" with clear dating +- **Collaborative reviews**: Have someone else check your formulas + +### Excel Formatting Checklist (Optional - adapt to user preferences) +- [ ] Font set to user's preferred style (default: Times New Roman, 11pt data, 12pt headers) +- [ ] Section headers formatted per user's template (default: dark blue #17365D with white bold text) +- [ ] Column headers formatted per user's template (default: light blue/gray #D9E2F3 with black bold text) +- [ ] Statistics rows formatted per user's template (default: light gray #F2F2F2) +- [ ] No borders applied (clean, minimal appearance) +- [ ] **Column widths set to uniform/even width** (creates clean, professional appearance) +- [ ] **Row heights set to consistent height** (typically 20-25pt for data rows) +- [ ] Numbers formatted with proper decimal precision and thousands separators +- [ ] **All metrics center-aligned** for clean, uniform appearance +- [ ] **One blank row for separation between company data and statistics rows** +- [ ] **No separate "SECTOR STATISTICS" or "VALUATION STATISTICS" header rows** +- [ ] **Every hard-coded input cell has a comment with either: (1) exact data source, OR (2) assumption explanation** +- [ ] **Hyperlinks added to cells where applicable** (SEC filings, data provider pages, reports) + +--- + +## Section 8: Example Template Layout + +**Simple Version (Start here):** +``` +┌─────────────────────────────────────────────────────────────┐ +│ TECHNOLOGY - COMPARABLE COMPANY ANALYSIS │ +│ Microsoft • Alphabet • Amazon │ +│ As of Q4 2024 | All figures in USD Millions │ +├─────────────────────────────────────────────────────────────┤ +│ OPERATING METRICS │ +├──────────┬─────────┬─────────┬──────────┬──────────────────┤ +│ Company │ Revenue │ Growth │ Gross │ EBITDA │ EBITDA │ +│ │ (LTM) │ (YoY) │ Margin │ (LTM) │ Margin │ +├──────────┼─────────┼─────────┼──────────┼─────────┼────────┤ +│ MSFT │ 261,400 │ 12.3% │ 68.7% │ 205,100 │ 78.4% │ +│ GOOGL │ 349,800 │ 11.8% │ 57.9% │ 239,300 │ 68.4% │ +│ AMZN │ 638,100 │ 10.5% │ 47.3% │ 152,600 │ 23.9% │ +│ │ │ │ │ │ │ [blank row] +│ Median │ =MEDIAN │ =MEDIAN │ =MEDIAN │ =MEDIAN │=MEDIAN │ +│ 75th % │ =QUART │ =QUART │ =QUART │ =QUART │=QUART │ +│ 25th % │ =QUART │ =QUART │ =QUART │ =QUART │=QUART │ +├─────────────────────────────────────────────────────────────┤ +│ VALUATION MULTIPLES │ +├──────────┬──────────┬──────────┬──────────┬────────────────┤ +│ Company │ Mkt Cap │ EV │ EV/Rev │ EV/EBITDA │ P/E│ +├──────────┼──────────┼──────────┼──────────┼───────────┼────┤ +│ MSFT │3,550,000 │3,530,000 │ 13.5x │ 17.2x │36.0│ +│ GOOGL │2,030,000 │1,960,000 │ 5.6x │ 8.2x │24.5│ +│ AMZN │2,226,000 │2,320,000 │ 3.6x │ 15.2x │58.3│ +│ │ │ │ │ │ │ [blank row] +│ Median │ =MEDIAN │ =MEDIAN │ =MEDIAN │ =MEDIAN │=MED│ +│ 75th % │ =QUART │ =QUART │ =QUART │ =QUART │=QRT│ +│ 25th % │ =QUART │ =QUART │ =QUART │ =QUART │=QRT│ +└──────────┴──────────┴──────────┴──────────┴───────────┴────┘ +``` + +**Add complexity only when needed:** +- Include quarterly AND LTM if seasonality matters +- Add FCF metrics if cash generation is key story +- Include industry-specific metrics (Rule of 40 for SaaS, etc.) +- Add more statistics rows if you have >5 companies + +--- + +## Section 9: Industry-Specific Additions (Optional) + +Only add these if they're critical to your analysis. Most comps work fine with just core metrics. + +**Software/SaaS:** +Add if relevant: ARR, Net Dollar Retention, Rule of 40 + +**Financial Services:** +Add if relevant: ROE, Net Interest Margin, Efficiency Ratio + +**E-commerce:** +Add if relevant: GMV, Take Rate, Active Buyers + +**Healthcare:** +Add if relevant: R&D/Revenue, Pipeline Value, Patent Timeline + +**Manufacturing:** +Add if relevant: Asset Turnover, Inventory Turns, Backlog + +--- + +## Section 10: Red Flags & Warning Signs + +### Data Quality Issues +🚩 Inconsistent time periods (mixing quarterly and annual) +🚩 Missing data without explanation +🚩 Significant differences between data sources (>10% variance) + +### Valuation Red Flags +🚩 Negative EBITDA companies being valued on EBITDA multiples (use revenue multiples instead) +🚩 P/E ratios >100x without hypergrowth story +🚩 Margins that don't make sense for the industry + +### Comparability Issues +🚩 Different fiscal year ends (causes timing problems) +🚩ixing pure-play and conglomerates +🚩 Materially different business models labeled as "comps" + +**When in doubt, exclude the company.** Better to have 3 perfect comps than 6 questionable ones. + +--- + +## Section 11: Formulas Reference Guide + +### Essential Excel Formulas +```excel +// Statistical Functions +=AVERAGE(range) // Simple mean +=MEDIAN(range) // Middle value +=QUARTILE(range, 1) // 25th percentile +=QUARTILE(range, 3) // 75th percentile +=MAX(range) // Maximum value +=MIN(range) // Minimum value +=STDEV.P(range) // Standard deviation + +// Financial Calculations +=B7/C7 // Simple ratio (Margin) +=SUM(B7:B9)/3 // Average of multiple companies +=IF(B7>0, C7/B7, "N/A") // Conditional calculation +=IFERROR(C7/D7, 0) // Handle divide by zero + +// Cross-Sheet References +='Sheet1'!B7 // Reference another sheet +=VLOOKUP(A7, Table1, 2) // Lookup from data table +=INDEX(MATCH()) // Advanced lookup + +// Formatting +=TEXT(B7, "0.0%") // Format as percentage +=TEXT(C7, "#,##0") // Thousands separator +``` + +### Common Ratio Formulas +```excel +Gross Margin = Gross Profit / Revenue +EBITDA Margin = EBITDA / Revenue +FCF Margin = Free Cash Flow / Revenue +FCF Conversion = FCF / Operating Cash Flow +ROE = Net Income / Shareholders' Equity +ROA = Net Income / Total Assets +Asset Turnover = Revenue / Total Assets +Debt/Equity = Total Debt / Shareholders' Equity +``` + +--- + +## Key Principles Summary + +1. **Structure drives insight** - Right headers force right thinking +2. **Less is more** - 5-10 metrics that matter beat 20 that don't +3. **Choose metrics for your question** - Valuation analysis ≠ efficiency analysis +4. **Statistics show patterns** - Median/quartiles reveal more than average +5. **Transparency beats complexity** - Simple formulas everyone understands +6. **Comparability is king** - Better to exclude than force a bad comp +7. **Document your choices** - Explain which metrics and why in notes section + +--- + +## Output Checklist + +Before delivering a comp analysis, verify: +- [ ] All companies are truly comparable +- [ ] Data is from consistent time periods +- [ ] Units are clearly labeled (millions/billions) +- [ ] Formulas reference cells, not hardcoded values +- [ ] **All hard-coded input cells have comments with either: (1) exact data source with citation, OR (2) clear assumption with explanation** +- [ ] **Hyperlinks added where relevant** (SEC EDGAR filings, Bloomberg pages, research reports) +- [ ] Statistics include at least 5 metrics (Max, 75th, Med, 25th, Min) +- [ ] Notes section documents sources and methodology +- [ ] Visual formatting follows conventions (blue = input, black = formula) +- [ ] Sanity checks pass (margins logical, multiples reasonable) +- [ ] Date stamp is current ("As of [Date]") +- [ ] Formula auditing shows no errors (#DIV/0!, #REF!, #N/A) + +--- + +## Continuous Improvement + +After completing a comp analysis, ask: +1. Did the statistics reveal unexpected insights? +2. Were there any data gaps that limited analysis? +3. Did stakeholders ask for metrics you didn't include? +4. How long did it take vs. how long should it take? +5. What would make this more useful next time? + +The best comp analyses evolve with each iteration. Save templates, learn from feedback, and refine the structure based on what decision-makers actually use. + + +## Data sources — MCP first, web fallback + +Many passages below say "use the S&P Kensho MCP / Daloopa MCP / FactSet MCP". Those are commercial financial-data MCPs from the original Cowork plugin context. In Hermes: + +- **If you have any structured financial-data MCP configured** (Hermes supports MCP — see `native-mcp` skill), prefer it for point-in-time comps, precedent transactions, and filings. +- **Otherwise**, fall back to: + - `web_search` / `web_extract` against SEC EDGAR (`https://www.sec.gov/cgi-bin/browse-edgar`) for US filings + - Company IR pages for press releases, earnings decks + - `browser_navigate` for interactive data portals + - User-provided data (explicitly ask when the context doesn't have it) +- **Never fabricate**. If a multiple, precedent, or filing number can't be sourced, flag the cell as `[UNSOURCED]` and surface it to the user. + +## Attribution + +This skill is adapted from Anthropic's Claude for Financial Services plugin suite (Apache-2.0). The Office-JS / Cowork live-Excel paths have been removed; this version targets headless openpyxl via the `excel-author` skill's conventions. Original: https://github.com/anthropics/financial-services diff --git a/optional-skills/finance/dcf-model/SKILL.md b/optional-skills/finance/dcf-model/SKILL.md new file mode 100644 index 000000000000..75a9d7de5f79 --- /dev/null +++ b/optional-skills/finance/dcf-model/SKILL.md @@ -0,0 +1,1269 @@ +--- +name: dcf-model +description: Build institutional-quality DCF valuation models in Excel — revenue projections, FCF build, WACC, terminal value, Bear/Base/Bull scenarios, 5x5 sensitivity tables. Pairs with excel-author. Use for intrinsic-value equity analysis. +version: 1.0.0 +author: Anthropic (adapted by Nous Research) +license: Apache-2.0 +metadata: + hermes: + tags: [finance, valuation, dcf, excel, openpyxl, modeling, investment-banking] + related_skills: [excel-author, pptx-author, comps-analysis, lbo-model, 3-statement-model] +--- + +## Environment + +This skill assumes **headless openpyxl** — you are producing an .xlsx file on disk. +Follow the `excel-author` skill's conventions for cell coloring, formulas, named ranges, and sensitivity tables. +Recalculate before delivery: `python /path/to/excel-author/scripts/recalc.py ./out/model.xlsx`. + +# DCF Model Builder + +## Overview + +This skill creates institutional-quality DCF models for equity valuation following investment banking standards. Each analysis produces a detailed Excel model (with sensitivity analysis included at the bottom of the DCF sheet). + +## Tools + +- Default to using all of the information provided by the user and MCP servers available for data sourcing. + +## Critical Constraints - Read These First + +These constraints apply throughout all DCF model building. Review before starting: + +**Formulas Over Hardcodes (NON-NEGOTIABLE):** +- Every projection, margin, discount factor, PV, and sensitivity cell MUST be a live Excel formula — never a value computed in Python and written as a number +- When using openpyxl: `ws["D20"] = "=D19*(1+$B$8)"` is correct; `ws["D20"] = calculated_revenue` is WRONG +- The only hardcoded numbers permitted are: (1) raw historical inputs, (2) assumption drivers (growth rates, WACC inputs, terminal g), (3) current market data (share price, debt balance) +- If you catch yourself computing something in Python and writing the result — STOP. The model must flex when the user changes an assumption. + +**Verify Step-by-Step With the User (DO NOT build end-to-end):** +- After data retrieval → show the user the raw inputs block (revenue, margins, shares, net debt) and confirm before projecting +- After revenue projections → show the projected top line and growth rates, confirm before building margin build +- After FCF build → show the full FCF schedule, confirm logic before computing WACC +- After WACC → show the calculation and inputs, confirm before discounting +- After terminal value + PV → show the equity bridge (EV → equity value → per share), confirm before sensitivity tables +- Catch errors at each stage — a wrong margin assumption discovered after sensitivity tables are built means rebuilding everything downstream + +**Sensitivity Tables:** +- **Use an ODD number of rows and columns** (standard: 5×5, sometimes 7×7) — this guarantees a true center cell +- **Center cell = base case.** Build the axis values so the middle row header and middle column header exactly equal the model's actual assumptions (e.g., if base WACC = 9.0%, the middle row is 9.0%; if terminal g = 3.0%, the middle column is 3.0%). The center cell's output must therefore equal the model's actual implied share price — this is the sanity check that the table is built correctly. +- **Highlight the center cell** with the medium-blue fill (`#BDD7EE`) + bold font so it's immediately visible which cell is the base case. +- Populate ALL cells (typically 3 tables × 25 cells = 75) with full DCF recalculation formulas +- Use openpyxl loops to write formulas programmatically +- NO placeholder text, NO linear approximations, NO manual steps required +- Each cell must recalculate full DCF for that assumption combination + +**Cell Comments:** +- Add cell comments AS each hardcoded value is created +- Format: "Source: [System/Document], [Date], [Reference], [URL if applicable]" +- Every blue input must have a comment before moving to next section +- Do not defer to end or write "TODO: add source" + +**Model Layout Planning:** +- Define ALL section row positions BEFORE writing any formulas +- Write ALL headers and labels first +- Write ALL section dividers and blank rows second +- THEN write formulas using the locked row positions +- Test formulas immediately after creation + +**Formula Recalculation:** +- Run `python recalc.py model.xlsx 30` before delivery +- Fix ALL errors until status is "success" +- Zero formula errors required (#REF!, #DIV/0!, #VALUE!, etc.) + +**Scenario Blocks:** +- Create separate blocks for Bear/Base/Bull cases +- Show assumptions horizontally across projection years within each block +- Use IF formulas: `=IF($B$6=1,[Bear cell],IF($B$6=2,[Base cell],[Bull cell]))` +- Verify formulas reference correct scenario block cells + +## DCF Process Workflow + +### Step 1: Data Retrieval and Validation + +Fetch data from MCP servers, user provided data, and the web. + +**Data Sources Priority:** +1. **MCP Servers** (if configured) - Structured financial data from providers like Daloopa +2. **User-Provided Data** - Historical financials from their research +3. **Web Search/Fetch** - Current prices, beta, debt and cash when needed + +**Validation Checklist:** +- Verify net debt vs net cash (critical for valuation) +- Confirm diluted shares outstanding (check for recent buybacks/issuances) +- Validate historical margins are consistent with business model +- Cross-check revenue growth rates with industry benchmarks +- Verify tax rate is reasonable (typically 21-28%) + +### Step 2: Historical Analysis (3-5 years) + +Analyze and document: +- **Revenue growth trends**: Calculate CAGR, identify drivers +- **Margin progression**: Track gross margin, EBIT margin, FCF margin +- **Capital intensity**: D&A and CapEx as % of revenue +- **Working capital efficiency**: NWC changes as % of revenue growth +- **Return metrics**: ROIC, ROE trends + +Create summary tables showing: +``` +Historical Metrics (LTM): +Revenue: $X million +Revenue growth: X% CAGR +Gross margin: X% +EBIT margin: X% +D&A % of revenue: X% +CapEx % of revenue: X% +FCF margin: X% +``` + +### Step 3: Build Revenue Projections + +**Methodology:** +1. Start with latest actual revenue (LTM or most recent fiscal year) +2. Apply growth rates for each projection year +3. Show both dollar amounts AND calculated growth % + +**Growth Rate Framework:** +- Year 1-2: Higher growth reflecting near-term visibility +- Year 3-4: Gradual moderation toward industry average +- Year 5+: Approaching terminal growth rate + +**Formula structure:** +- Revenue(Year N) = Revenue(Year N-1) × (1 + Growth Rate) +- Growth %(Year N) = Revenue(Year N) / Revenue(Year N-1) - 1 + +**Three-scenario approach:** +``` +Bear Case: Conservative growth (e.g., 8-12%) +Base Case: Most likely scenario (e.g., 12-16%) +Bull Case: Optimistic growth (e.g., 16-20%) +``` + +### Step 4: Operating Expense Modeling + +**Fixed/Variable Cost Analysis:** + +Operating expenses should model realistic operating leverage: +- **Sales & Marketing**: Typically 15-40% of revenue depending on business model +- **Research & Development**: Typically 10-30% for technology companies +- **General & Administrative**: Typically 8-15% of revenue, shows leverage as company scales + +**Key principles:** +- ALL percentages based on REVENUE, not gross profit +- Model operating leverage: % should decline as revenue scales +- Maintain separate line items for S&M, R&D, G&A +- Calculate EBIT = Gross Profit - Total OpEx + +**Margin expansion framework:** +``` +Current State → Target State (Year 5) +Gross Margin: X% → Y% (justify based on scale, efficiency) +EBIT Margin: X% → Y% (result of revenue growth + opex leverage) +``` + +### Step 5: Free Cash Flow Calculation + +**Build FCF in proper sequence:** + +``` +EBIT +(-) Taxes (EBIT × Tax Rate) += NOPAT (Net Operating Profit After Tax) +(+) D&A (non-cash expense, % of revenue) +(-) CapEx (% of revenue, typically 4-8%) +(-) Δ NWC (change in working capital) += Unlevered Free Cash Flow +``` + +**Working Capital Modeling:** +- Calculate as % of revenue change (delta revenue) +- Typical range: -2% to +2% of revenue change +- Negative number = source of cash (working capital release) +- Positive number = use of cash (working capital build) + +**Maintenance vs Growth CapEx:** +- Maintenance CapEx: Sustains current operations (~2-3% revenue) +- Growth CapEx: Supports expansion (additional 2-5% revenue) +- Total CapEx should align with company's growth strategy + +### Step 6: Cost of Capital (WACC) Research + +**CAPM Methodology for Cost of Equity:** + +``` +Cost of Equity = Risk-Free Rate + Beta × Equity Risk Premium + +Where: +- Risk-Free Rate = Current 10-Year Treasury Yield +- Beta = 5-year monthly stock beta vs market index +- Equity Risk Premium = 5.0-6.0% (market standard) +``` + +**Cost of Debt Calculation:** + +``` +After-Tax Cost of Debt = Pre-Tax Cost of Debt × (1 - Tax Rate) + +Determine Pre-Tax Cost of Debt from: +- Credit rating (if available) +- Current yield on company bonds +- Interest expense / Total Debt from financials +``` + +**Capital Structure Weights:** + +``` +Market Value Equity = Current Stock Price × Shares Outstanding +Net Debt = Total Debt - Cash & Equivalents +Enterprise Value = Market Cap + Net Debt + +Equity Weight = Market Cap / Enterprise Value +Debt Weight = Net Debt / Enterprise Value + +WACC = (Cost of Equity × Equity Weight) + (After-Tax Cost of Debt × Debt Weight) +``` + +**Special Cases:** +- **Net Cash Position**: If Cash > Debt, Net Debt is NEGATIVE + - Debt Weight may be negative + - WACC calculation adjusts accordingly +- **No Debt**: WACC = Cost of Equity + +**Typical WACC Ranges:** +- Large Cap, Stable: 7-9% +- Growth Companies: 9-12% +- High Growth/Risk: 12-15% + +### Step 7: Discount Rate Application (5-10 Year Forecast) + +**Mid-Year Convention:** +- Cash flows assumed to occur mid-year +- Discount Period: 0.5, 1.5, 2.5, 3.5, 4.5, etc. +- Discount Factor = 1 / (1 + WACC)^Period + +**Present Value Calculation:** +``` +For each projection year: +PV of FCF = Unlevered FCF × Discount Factor + +Example (Year 1): +FCF = $1,000 +WACC = 10% +Period = 0.5 +Discount Factor = 1 / (1.10)^0.5 = 0.9535 +PV = $1,000 × 0.9535 = $954 +``` + +**Projection Period Selection:** +- **5 years**: Standard for most analyses +- **7-10 years**: High growth companies with longer runway +- **3 years**: Mature, stable businesses + +### Step 8: Terminal Value Calculation + +**Perpetuity Growth Method (Preferred):** + +``` +Terminal FCF = Final Year FCF × (1 + Terminal Growth Rate) +Terminal Value = Terminal FCF / (WACC - Terminal Growth Rate) + +Critical Constraint: Terminal Growth < WACC (otherwise infinite value) +``` + +**Terminal Growth Rate Selection:** +- Conservative: 2.0-2.5% (GDP growth rate) +- Moderate: 2.5-3.5% +- Aggressive: 3.5-5.0% (only for market leaders) + +**Do not exceed**: Risk-free rate or long-term GDP growth + +**Exit Multiple Method (Alternative):** +``` +Terminal Value = Final Year EBITDA × Exit Multiple + +Where Exit Multiple comes from: +- Industry comparable trading multiples +- Precedent transaction multiples +- Typical range: 8-15x EBITDA +``` + +**Present Value of Terminal Value:** +``` +PV of Terminal Value = Terminal Value / (1 + WACC)^Final Period + +Where Final Period accounts for timing: +5-year model with mid-year convention: Period = 4.5 +``` + +**Terminal Value Sanity Check:** +- Should represent 50-70% of Enterprise Value +- If >75%, model may be over-reliant on terminal assumptions +- If <40%, check if terminal assumptions are too conservative + +### Step 9: Enterprise to Equity Value Bridge + +**Valuation Summary Structure:** + +``` +(+) Sum of PV of Projected FCFs = $X million +(+) PV of Terminal Value = $Y million += Enterprise Value = $Z million + +(-) Net Debt [or + Net Cash if negative] = $A million += Equity Value = $B million + +÷ Diluted Shares Outstanding = C million shares += Implied Price per Share = $XX.XX + +Current Stock Price = $YY.YY +Implied Return = (Implied Price / Current Price) - 1 = XX% +``` + +**Critical Adjustments:** +- **Net Debt = Total Debt - Cash & Equivalents** + - If positive: Subtract from EV (reduces equity value) + - If negative (Net Cash): Add to EV (increases equity value) +- **Use Diluted Shares**: Includes options, RSUs, convertible securities +- **Other adjustments** (if applicable): + - Minority interests + - Pension liabilities + - Operating lease obligations + +**Valuation Output Format:** +```csv +Valuation Component,Amount ($M) +PV Explicit FCFs,X.X +PV Terminal Value,Y.Y +Enterprise Value,Z.Z +(-) Net Debt,A.A +Equity Value,B.B +,, +Shares Outstanding (M),C.C +Implied Price per Share,$XX.XX +Current Share Price,$YY.YY +Implied Upside/(Downside),+XX% +``` + +### Step 10: Sensitivity Analysis + +Build **three sensitivity tables** at the bottom of the DCF sheet showing how valuation changes with different assumptions: + +1. **WACC vs Terminal Growth** - Shows enterprise value sensitivity to discount rate and perpetuity growth +2. **Revenue Growth vs EBIT Margin** - Shows impact of top-line growth and operating leverage +3. **Beta vs Risk-Free Rate** - Shows sensitivity to cost of equity components + +**Implementation**: These are simple 2D grids (NOT Excel's "Data Table" feature) with formulas in each cell. Each cell must contain a full DCF recalculation for that specific assumption combination. See Critical Constraints section for detailed requirements on populating all 75 cells programmatically using openpyxl. + + + +This section contains all the CORRECT patterns to follow when building DCF models. + +### Scenario Block Selection Pattern - Follow This Approach + +**Assumptions are organized in separate blocks for each scenario:** + +**CRITICAL STRUCTURE - Three rows per section header:** + +```csv +BEAR CASE ASSUMPTIONS (section header, merge cells across) +Assumption,FY1,FY2,FY3,FY4,FY5 +Revenue Growth (%),12%,10%,9%,8%,7% +EBIT Margin (%),45%,44%,43%,42%,41% + +BASE CASE ASSUMPTIONS (section header, merge cells across) +Assumption,FY1,FY2,FY3,FY4,FY5 +Revenue Growth (%),16%,14%,12%,10%,9% +EBIT Margin (%),48%,49%,50%,51%,52% + +BULL CASE ASSUMPTIONS (section header, merge cells across) +Assumption,FY1,FY2,FY3,FY4,FY5 +Revenue Growth (%),20%,18%,15%,13%,11% +EBIT Margin (%),50%,51%,52%,53%,54% +``` + +**Each scenario block MUST have a column header row** showing the projection years (FY2025E, FY2026E, etc.) immediately below the section title. Without this, users cannot tell which assumption value corresponds to which year. + +**How to reference assumptions - Create a consolidation column:** +1. Case selector cell (e.g., B6) contains 1=Bear, 2=Base, or 3=Bull +2. Create a consolidation column with INDEX or OFFSET formulas to pull from the correct scenario block +3. Projection formulas reference the consolidation column (clean cell references) +4. Each scenario block contains full set of DCF assumptions across projection years + +**Recommended consolidation column pattern (using INDEX):** +`=INDEX(B10:D10, 1, $B$6)` + +**NOT this - scattered IF statements throughout:** +`=IF($B$6=1,[Bear block cell],IF($B$6=2,[Base block cell],[Bull block cell]))` + +The consolidation column approach centralizes logic and makes the model easier to audit. + +### Correct Revenue Projection Pattern + +**Create a consolidation column with INDEX formulas, then reference it in projections:** + +**Step 1 - Consolidation column for FY1 growth:** +`=INDEX([Bear FY1 growth]:[Bull FY1 growth], 1, $B$6)` + +**Step 2 - Revenue projection references the consolidation column:** +`Revenue Year 1: =D29*(1+$E$10)` + +Where: +- D29 = Prior year revenue +- $E$10 = Consolidation column cell for FY1 growth (contains INDEX formula) +- $B$6 = Case selector (1=Bear, 2=Base, 3=Bull) + +**This approach is cleaner than embedding IF statements in every projection formula** and makes it much easier to audit which scenario assumptions are being used. + +### Correct FCF Formula Pattern + +**Use consolidation columns with INDEX formulas, then reference them in FCF calculations:** + +**Consolidation column approach:** +```csv +Item,Formula,Reference +D&A,=E29*$E$21,$E$21 = consolidation column for D&A % +CapEx,=E29*$E$22,$E$22 = consolidation column for CapEx % +Δ NWC,=(E29-D29)*$E$23,$E$23 = consolidation column for NWC % +Unlevered FCF,=E57+E58-E60-E62,E57=NOPAT E58=D&A E60=CapEx E62=Δ NWC +``` + +**Each consolidation column cell contains an INDEX formula** that pulls from the appropriate scenario block based on case selector. This keeps projection formulas clean and auditable. + +Before writing formulas, confirm scenario block row locations and set up consolidation columns. + +### Correct Cell Comment Format + +**Every hardcoded value needs this format:** + +"Source: [System/Document], [Date], [Reference], [URL if applicable]" + +**Examples:** +```csv +Item,Source Comment +Stock price,Source: Market data script 2025-10-12 Close price +Shares outstanding,Source: 10-K FY2024 Page 45 Note 12 +Historical revenue,Source: 10-K FY2024 Page 32 Consolidated Statements +Beta,Source: Market data script 2025-10-12 5-year monthly beta +Consensus estimates,Source: Management guidance Q3 2024 earnings call +``` + +### Correct Assumption Table Structure + +**CRITICAL: Each scenario block requires THREE structural elements:** + +1. **Section header row** (merged cells): e.g., "BEAR CASE ASSUMPTIONS" +2. **Column header row** showing years - THIS IS REQUIRED, DO NOT SKIP +3. **Data rows** with assumption values + +**Structure:** +```csv +BEAR CASE ASSUMPTIONS (section header - merge across columns A:G) +Assumption,FY1,FY2,FY3,FY4,FY5 +Revenue Growth (%),X%,X%,X%,X%,X% +EBIT Margin (%),X%,X%,X%,X%,X% +Terminal Growth,X%,,,, +WACC,X%,,,, + +BASE CASE ASSUMPTIONS (section header - merge across columns A:G) +Assumption,FY1,FY2,FY3,FY4,FY5 +Revenue Growth (%),X%,X%,X%,X%,X% +EBIT Margin (%),X%,X%,X%,X%,X% +Terminal Growth,X%,,,, +WACC,X%,,,, + +BULL CASE ASSUMPTIONS (section header - merge across columns A:G) +Assumption,FY1,FY2,FY3,FY4,FY5 +Revenue Growth (%),X%,X%,X%,X%,X% +EBIT Margin (%),X%,X%,X%,X%,X% +Terminal Growth,X%,,,, +WACC,X%,,,, +``` + +**WITHOUT the column header row showing projection years (FY2025E, FY2026E, etc.), users cannot tell which assumption value corresponds to which year. This row is MANDATORY.** + +**Then create a consolidation column** (typically the next column to the right) that uses INDEX formulas to pull from the selected scenario block based on the case selector. This consolidation column is what your projection formulas reference. + +### Correct Row Planning Process + +**1. Write ALL headers and labels FIRST:** +```csv +Row,Content +1,[Company Name] DCF Model +2,Ticker | Date | Year End +4,Case Selector +7,KEY ASSUMPTIONS +26,Assumption headers +27-31,Growth assumptions +...,... +``` + +**2. Write ALL section dividers and blank rows** + +**3. THEN write formulas using the locked row positions** + +**4. Test formulas immediately after creation** + +**Think of it like construction:** +- Good: Pour foundation, then build walls (stable structure) +- Bad: Build walls, then pour foundation (walls collapse) + +**Excel version:** +- Good: Add headers, then write formulas (formulas stable) +- Bad: Write formulas, then add headers (formulas break) + +### Correct Sensitivity Table Implementation + +**IMPORTANT**: These are NOT Excel's "Data Table" feature. These are simple grids where you write regular formulas using openpyxl. Yes, this means ~75 formulas total (3 tables × 25 cells each), but this is straightforward and required. + +**Programmatic Population with Formulas:** + +Each sensitivity table must be fully populated with formulas that recalculate the implied share price for each combination of assumptions. **Do not use Excel's Data Table feature** (it requires manual intervention and cannot be automated via openpyxl). + +**Implementation approach - CONCRETE EXAMPLE:** + +**Table Structure — 5×5 grid (ODD dimensions, base case centered):** + +If the model's base WACC = 9.0% and base terminal growth = 3.0%, build the axes symmetrically around those values: + +```csv +WACC vs Terminal Growth, 2.0%, 2.5%, 3.0%, 3.5%, 4.0% + 8.0%, [fml], [fml], [fml], [fml], [fml] + 8.5%, [fml], [fml], [fml], [fml], [fml] + 9.0%, [fml], [fml], [★ ], [fml], [fml] ← middle row = base WACC + 9.5%, [fml], [fml], [fml], [fml], [fml] + 10.0%, [fml], [fml], [fml], [fml], [fml] + ↑ + middle col = base terminal g +``` + +**★ = the center cell.** Its formula output MUST equal the model's actual implied share price (from the valuation summary). Apply the medium-blue fill (`#BDD7EE`) and bold font to this cell so the base case is visually anchored. + +**Rule for axis values:** `axis_values = [base - 2*step, base - step, base, base + step, base + 2*step]` — symmetric around the base, odd count guarantees a center. + +**Formula Pattern - Cell B88 (WACC=8.0%, Terminal Growth=2.0%):** + +The formula in B88 should recalculate the implied price using: +- WACC from row header: `$A88` (8.0%) +- Terminal Growth from column header: `B$87` (2.0%) + +**Recommended approach:** Reference the main DCF calculation but substitute these values. + +**Example formula structure:** +`=([SUM of PV FCFs using $A88 as discount rate] + [Terminal Value using B$87 as growth rate and $A88 as WACC] - [Net Debt]) / [Shares]` + +**CRITICAL - Write a formula for EVERY cell in the 5x5 grid (25 cells per table, 75 cells total).** Use openpyxl to write these formulas programmatically in a loop. Do NOT skip this step or leave placeholder text. + +**Python implementation pattern:** +```python +# Pseudocode for populating sensitivity table +for row_idx, wacc_value in enumerate(wacc_range): + for col_idx, term_growth_value in enumerate(term_growth_range): + # Build formula that uses wacc_value and term_growth_value + formula = f"=" + ws.cell(row=start_row+row_idx, column=start_col+col_idx).value = formula +``` + +**The sensitivity tables must work immediately when the model is opened, with no manual steps required from the user.** + + + + + +This section contains all the WRONG patterns to avoid when building DCF models. + +### WRONG: Simplified Sensitivity Table Approximations or Placeholder Text + +**Don't use linear approximations:** + +``` +// WRONG - Linear approximation +B97: =B88*(1+(0.096-0.116)) // Assumes linear relationship + +// WRONG - Division shortcut +B105: =B88/(1+(E48-0.07)) // Doesn't recalculate full DCF +``` + +**Don't leave placeholder text:** +``` +// WRONG - Placeholder note +"Note: Use Excel Data Table feature (Data → What-If Analysis → Data Table) to populate sensitivity tables." + +// WRONG - Empty cells +[leaving cells blank because "this is complex"] +``` + +**Don't confuse terminology:** +- ❌ "Sensitivity tables need Excel's Data Table feature" (NO - that's a specific Excel tool we can't use) +- ✅ "Sensitivity tables are simple grids with formulas in each cell" (YES - this is what we build) + +**Why these shortcuts are wrong:** +- Linear approximation formulas don't actually recalculate the DCF - they just apply simple math adjustments +- The relationships are not linear, so the results will be inaccurate +- Placeholder text requires manual user intervention +- Model is not immediately usable when delivered +- Not professional or client-ready +- Empty cells = incomplete deliverable + +**Common rationalization to REJECT:** +"Writing 75+ formulas feels complex, so I'll leave a note for the user to complete it manually." + +**Reality:** Writing 75 formulas is straightforward when you use a loop in Python with openpyxl. Each formula follows the same pattern - just substitute the row/column values. This is a required part of the deliverable. + +**Instead:** Populate every sensitivity cell with formulas that recalculate the full DCF for that specific combination of assumptions + +### WRONG: Missing Cell Comments + +**Don't do this:** +- Create all hardcoded inputs without comments +- Think "I'll add them later" +- Write "TODO: add source" +- Leave blue inputs without documentation + +**Why it's wrong:** +- Can't verify where data came from +- Fails xlsx skill requirements +- Not audit-ready +- Wastes time fixing later + +**Instead:** Add cell comment AS EACH hardcoded value is created + +### WRONG: Formula Row References Off + +**Symptom:** +The FCF section references wrong assumption rows: +`D&A: =E29*$E$34 // Should be $E$21, but referencing wrong row` +`CapEx: =E29*$E$41 // Should be $E$22, but row shifted` + +**Why this happens:** +1. Formulas written first +2. Then headers inserted +3. All row references shifted +4. Now formulas point to wrong cells → #REF! errors + +**Instead:** Lock row layout FIRST, then write formulas + +### WRONG: Single Row for Each Assumption Across Scenarios + +**Don't structure assumptions like this:** +```csv +Assumption,Bear,Base,Bull +Revenue Growth FY1,10%,13%,16% +Revenue Growth FY2,9%,12%,15% +``` +This vertical layout makes it hard to see the progression across years within each scenario. + +**Why it's wrong:** +- Makes it difficult to see assumptions evolving across years within each scenario +- Harder to compare scenario assumptions across full projection period +- Less intuitive for reviewing scenario logic + +**Instead:** +- Create separate blocks for each scenario (Bear, Base, Bull) +- Within each block, show assumptions horizontally across projection years +- This makes each scenario's assumptions easier to review as a cohesive set + +### WRONG: No Borders + +**Don't deliver a model without borders:** +- No section delineation +- All cells blend together +- Hard to read and unprofessional + +**Why it's wrong:** +- Not client-ready +- Difficult to navigate +- Looks amateur + +**Instead:** Add borders around all major sections + +### WRONG: Wrong Font Colors or No Font Color Distinction + +**Don't do this:** +- All text is black +- Only use fill colors (no font color changes) +- Mix up which cells are blue vs black + +**Why it's wrong:** +- Can't distinguish inputs from formulas +- Auditing becomes impossible +- Violates xlsx skill requirements + +**Instead:** Blue text for ALL hardcoded inputs, black text for ALL formulas, green for sheet links + +### WRONG: Operating Expenses Based on Gross Profit + +**Don't do this:** +`S&M: =E33*0.15 // E33 = Gross Profit (WRONG)` + +**Why it's wrong:** +- Operating expenses scale with revenue, not gross profit +- Produces unrealistic margin progression +- Not how businesses actually operate + +**Instead:** +`S&M: =E29*0.15 // E29 = Revenue (CORRECT)` + +### TOP 5 ERRORS SUMMARY + +1. **Formula row references off** → Define ALL row positions BEFORE writing formulas +2. **Missing cell comments** → Add comments AS cells are created, not at end +3. **Simplified sensitivity tables** → Populate all cells with full DCF recalc formulas, not approximations +4. **Scenario block references wrong** → Ensure IF formulas pull from correct Bear/Base/Bull blocks +5. **No borders** → Add professional section borders for client-ready appearance + +In addition, be aware of these errors: + +### WACC Calculation Errors +- Mixing book and market values in capital structure +- Using equity beta instead of asset/unlevered beta incorrectly +- Wrong tax rate application to cost of debt +- Incorrect risk-free rate (must use current 10Y Treasury) +- Failure to adjust for net debt vs net cash position + +### Growth Assumption Flaws +- Terminal growth > WACC (creates infinite value) +- Projection growth rates inconsistent with historical performance +- Ignoring industry growth constraints +- Revenue growth not aligned with unit economics +- Margin expansion without operational justification + +### Terminal Value Mistakes +- Using wrong growth method (perpetuity vs exit multiple) +- Terminal value >80% of enterprise value (suggests over-reliance) +- Inconsistent terminal margins with steady state assumptions +- Wrong discount period for terminal value + +### Cash Flow Projection Errors +- Operating expenses based on gross profit instead of revenue +- D&A/CapEx percentages misaligned with business model +- Working capital changes not properly calculated +- Tax rate inconsistency between years +- NOPAT calculation errors + +**These errors are the most common. Re-read this section before starting any DCF build.** + + + +## Excel File Creation + +**This skill uses the `xlsx` skill for all spreadsheet operations.** The xlsx skill provides: +- Standardized formula construction rules +- Number formatting conventions +- Automated formula recalculation via `recalc.py` script +- Comprehensive error checking and validation + +All Excel files created by this skill must follow xlsx skill requirements, including zero formula errors and proper recalculation. + +## Quality Rubric + +Every DCF model must maximize for: +1. **Realistic revenue and margin assumptions** based on historical performance +2. **Appropriate cost of capital calculation** with proper CAPM methodology +3. **Comprehensive sensitivity analysis** showing valuation ranges +4. **Clear terminal value calculation** with supporting rationale +5. **Professional model structure** enabling scenario analysis +6. **Transparent documentation** of all key assumptions + +## Input Requirements + +### Minimum Required Inputs +1. **Company identifier**: Ticker symbol or company name +2. **Growth assumptions**: Revenue growth rates for projection period (or "use consensus") +3. **Optional parameters**: + - Projection period (default: 5 years) + - Scenario cases (Bear/Base/Bull growth and margin assumptions) + - Terminal growth rate (default: 2.5-3.0%) + - Specific WACC inputs if not using CAPM + +## Excel Model Structure + +### Sheet Architecture + +Create **two sheets**: + +1. **DCF** - Main valuation model with sensitivity analysis at bottom +2. **WACC** - Cost of capital calculation + +**CRITICAL**: Sensitivity tables go at the BOTTOM of the DCF sheet (not on a separate sheet). This keeps all valuation outputs together. + +### Formula Recalculation (MANDATORY) + +After creating or modifying the Excel model, **recalculate all formulas** using the `recalc.py` script from the `excel-author` skill: + +```bash +python recalc.py [path_to_excel_file] [timeout_seconds] +``` + +Example: +```bash +python recalc.py AAPL_DCF_Model_2025-10-12.xlsx 30 +``` + +The script will: +- Recalculate all formulas in all sheets using LibreOffice +- Scan ALL cells for Excel errors (#REF!, #DIV/0!, #VALUE!, #NAME?, #NULL!, #NUM!, #N/A) +- Return detailed JSON with error locations and counts + +**Expected output format:** +```json +{ + "status": "success", // or "errors_found" + "total_errors": 0, // Total error count + "total_formulas": 42, // Number of formulas in file + "error_summary": {} // Only present if errors found +} +``` + +**If errors are found**, the output will include details: +```json +{ + "status": "errors_found", + "total_errors": 2, + "total_formulas": 42, + "error_summary": { + "#REF!": { + "count": 2, + "locations": ["DCF!B25", "DCF!C25"] + } + } +} +``` + +**Fix all errors** and re-run recalc.py until status is "success" before delivering the model. + +### Formatting Standards + +**IMPORTANT**: Follow the xlsx skill for formula construction rules and number formatting conventions. The DCF skill adds specific visual presentation standards. + +**Color Scheme - Two Layers**: + +**Layer 1: Font Colors (MANDATORY from xlsx skill)** +- **Blue text (RGB: 0,0,255)**: ALL hardcoded inputs (stock price, shares, historical data, assumptions) +- **Black text (RGB: 0,0,0)**: ALL formulas and calculations +- **Green text (RGB: 0,128,0)**: Links to other sheets (WACC sheet references) + +**Layer 2: Fill Colors — Professional Blue/Grey Palette (Default unless user specifies otherwise)** +- **Keep it minimal** — use only blues and greys for fills. Do NOT introduce greens, yellows, oranges, or multiple accent colors. A model with too many colors looks amateurish. +- **Default fill palette:** + - **Section headers**: Dark blue (RGB: 31,78,121 / `#1F4E79`) background with white bold text + - **Sub-headers/column headers**: Light blue (RGB: 217,225,242 / `#D9E1F2`) background with black bold text + - **Input cells**: Light grey (RGB: 242,242,242 / `#F2F2F2`) background with blue font — or just white with blue font if you want maximum minimalism + - **Calculated cells**: White background with black font + - **Output/summary rows** (per-share value, EV, etc.): Medium blue (RGB: 189,215,238 / `#BDD7EE`) background with black bold font +- **That's it — 3 blues + 1 grey + white.** Resist the urge to add more. +- User-provided templates or explicit color preferences ALWAYS override these defaults. + +**How the layers work together:** +- Input cell: Blue font + light grey fill = "Hardcoded input" +- Formula cell: Black font + white background = "Calculated value" +- Sheet link: Green font + white background = "Reference from another sheet" +- Key output: Black bold font + medium blue fill = "This is the answer" + +**Font color tells you WHAT it is (input/formula/link). Fill color tells you WHERE you are (header/data/output).** + +### Border Standards (REQUIRED for Professional Appearance) + +**Thick borders** (1.5pt) around major sections: +- KEY INPUTS section +- PROJECTION ASSUMPTIONS section +- 5-YEAR CASH FLOW PROJECTION section +- TERMINAL VALUE section +- VALUATION SUMMARY section +- Each SENSITIVITY ANALYSIS table + +**Medium borders** (1pt) between sub-sections: +- Company Details vs Historical Performance +- Growth Assumptions vs EBIT Margin vs FCF Parameters + +**Thin borders** (0.5pt) around data tables: +- Scenario assumption tables (Bear | Base | Bull | Selected) +- Historical vs projected financials matrix + +**No borders:** Individual cells within tables (keep clean, scannable) + +**Borders are mandatory** - models without professional borders are not client-ready. + +**Number Formats** (follows xlsx skill standards): +- **Years**: Format as text strings (e.g., "2024" not "2,024") +- **Percentages**: `0.0%` (one decimal place) +- **Currency**: `$#,##0` for millions; `$#,##0.00` for per-share - ALWAYS specify units in headers ("Revenue ($mm)") +- **Zeros**: Use number formatting to make all zeros "-" (e.g., `$#,##0;($#,##0);-`) +- **Large numbers**: `#,##0` with thousands separator +- **Negative numbers**: `(#,##0)` in parentheses (NOT minus sign) + +**Cell Comments (MANDATORY for all hardcoded inputs)**: + +Per the xlsx skill, ALL hardcoded values must have cell comments documenting the source. Format: "Source: [System/Document], [Date], [Reference], [URL if applicable]" + +**CRITICAL**: Add comments AS CELLS ARE CREATED. Do not defer to the end. + +### DCF Sheet Detailed Structure + +**Section 1: Header** +```csv +Row,Content +1,[Company Name] DCF Model +2,Ticker: [XXX] | Date: [Date] | Year End: [FYE] +3,Blank +4,Case Selector Cell (1=Bear 2=Base 3=Bull) +5,Case Name Display (formula: =IF([Selector]=1"Bear"IF([Selector]=2"Base""Bull"))) +``` + +**Section 2: Market Data (NOT case dependent)** +```csv +Item,Value +Current Stock Price,$XX.XX +Shares Outstanding (M),XX.X +Market Cap ($M),[Formula] +Net Debt ($M),XXX [or Net Cash if negative] +``` + +**Section 3: DCF Scenario Assumptions** + +Create separate assumption blocks for each scenario (Bear, Base, Bull) with DCF-specific assumptions (Revenue Growth %, EBIT Margin %, Tax Rate %, D&A % of Revenue, CapEx % of Revenue, NWC Change % of ΔRev, Terminal Growth Rate, WACC) laid out horizontally across projection years. Each block must include section header, column header row showing the projection years (FY1, FY2, etc.), and data rows. See `` section "Correct Assumption Table Structure" for the exact layout. + +**Section 4: Historical & Projected Financials** + +**Reference a consolidation column (e.g., "Selected Case") that pulls from scenario blocks**, not scattered IF formulas in every projection row. + +```csv +Income Statement ($M),2020A,2021A,2022A,2023A,2024E,2025E,2026E +Revenue,XXX,XXX,XXX,XXX,[=E29*(1+$E$10)],[=F29*(1+$E$11)],[=G29*(1+$E$12)] + % growth,XX%,XX%,XX%,XX%,[=E29/D29-1],[=F29/E29-1],[=G29/F29-1] +,,,,,, +Gross Profit,XXX,XXX,XXX,XXX,[=E29*E33],[=F29*F33],[=G29*G33] + % margin,XX%,XX%,XX%,XX%,[=E33/E29],[=F33/F29],[=G33/G29] +,,,,,, +Operating Expenses:,,,,,,, + S&M,XXX,XXX,XXX,XXX,[=E29*0.15],[=F29*0.14],[=G29*0.13] + R&D,XXX,XXX,XXX,XXX,[=E29*0.12],[=F29*0.11],[=G29*0.10] + G&A,XXX,XXX,XXX,XXX,[=E29*0.08],[=F29*0.07],[=G29*0.07] + Total OpEx,XXX,XXX,XXX,XXX,[=E36+E37+E38],[=F36+F37+F38],[=G36+G37+G38] +,,,,,, +EBIT,XXX,XXX,XXX,XXX,[=E33-E39],[=F33-F39],[=G33-G39] + % margin,XX%,XX%,XX%,XX%,[=E41/E29],[=F41/F29],[=G41/G29] +,,,,,, +Taxes,(XX),(XX),(XX),(XX),[=E41*$E$24],[=F41*$E$24],[=G41*$E$24] + Tax rate,XX%,XX%,XX%,XX%,[=E43/E41],[=F43/F41],[=G43/G41] +,,,,,, +NOPAT,XXX,XXX,XXX,XXX,[=E41-E43],[=F41-F43],[=G41-G43] +``` + +**Key Formula Pattern**: +- Revenue growth: `=E29*(1+$E$10)` where $E$10 is consolidation column for Year 1 growth +- NOT: `=E29*(1+IF($B$6=1,$B$10,IF($B$6=2,$C$10,$D$10)))` + +This approach is cleaner, easier to audit, and prevents formula errors by centralizing the scenario logic. + +**Section 5: Free Cash Flow Build** + +**CRITICAL**: Verify row references point to the CORRECT assumption rows. Test formulas immediately after creation. + +```csv +Cash Flow ($M),2020A,2021A,2022A,2023A,2024E,2025E,2026E +NOPAT,XXX,XXX,XXX,XXX,[=E45],[=F45],[=G45] +(+) D&A,XXX,XXX,XXX,XXX,[=E29*$E$21],[=F29*$E$21],[=G29*$E$21] + % of Rev,XX%,XX%,XX%,XX%,[=E58/E29],[=F58/F29],[=G58/G29] +(-) CapEx,(XX),(XX),(XX),(XX),[=E29*$E$22],[=F29*$E$22],[=G29*$E$22] + % of Rev,XX%,XX%,XX%,XX%,[=E60/E29],[=F60/F29],[=G60/G29] +(-) Δ NWC,(XX),(XX),(XX),(XX),[=(E29-D29)*$E$23],[=(F29-E29)*$E$23],[=(G29-F29)*$E$23] + % of Δ Rev,XX%,XX%,XX%,XX%,[=E62/(E29-D29)],[=F62/(F29-E29)],[=G62/(G29-F29)] +,,,,,, +Unlevered FCF,XXX,XXX,XXX,XXX,[=E57+E58-E60-E62],[=F57+F58-F60-F62],[=G57+G58-G60-G62] +``` + +**Row reference examples** (based on layout planning): +- $E$21 = D&A % assumption (consolidation column, row 21) +- $E$22 = CapEx % assumption (consolidation column, row 22) +- $E$23 = NWC % assumption (consolidation column, row 23) +- E29 = Revenue for year (row 29) +- E45 = NOPAT for year (row 45) + +**Before writing formulas**: Confirm these row numbers match the actual layout. Test one column, then copy across. + +**Section 6: Discounting & Valuation** +```csv +DCF Valuation,2024E,2025E,2026E,2027E,2028E,Terminal +Unlevered FCF ($M),XXX,XXX,XXX,XXX,XXX, +Period,0.5,1.5,2.5,3.5,4.5, +Discount Factor,0.XX,0.XX,0.XX,0.XX,0.XX, +PV of FCF ($M),XXX,XXX,XXX,XXX,XXX, +,,,,,, +Terminal FCF ($M),,,,,,,XXX +Terminal Value ($M),,,,,,,XXX +PV Terminal Value ($M),,,,,,,XXX +,,,,,, +Valuation Summary ($M),,,,,, +Sum of PV FCFs,XXX,,,,, +PV Terminal Value,XXX,,,,, +Enterprise Value,XXX,,,,, +(-) Net Debt,(XX),,,,, +Equity Value,XXX,,,,, +,,,,,, +Shares Outstanding (M),XX.X,,,,, +IMPLIED PRICE PER SHARE,$XX.XX,,,,, +Current Stock Price,$XX.XX,,,,, +Implied Upside/(Downside),XX%,,,,, +``` + +### WACC Sheet Structure + +```csv +COST OF EQUITY CALCULATION,, +Risk-Free Rate (10Y Treasury),X.XX%,[Yellow input] +Beta (5Y monthly),X.XX,[Yellow input] +Equity Risk Premium,X.XX%,[Yellow input] +Cost of Equity,X.XX%,[Calculated blue] +,, +COST OF DEBT CALCULATION,, +Credit Rating,AA-,[Yellow input] +Pre-Tax Cost of Debt,X.XX%,[Yellow input] +Tax Rate,XX.X%,[Link to DCF sheet] +After-Tax Cost of Debt,X.XX%,[Calculated blue] +,, +CAPITAL STRUCTURE,, +Current Stock Price,$XX.XX,[Link to DCF] +Shares Outstanding (M),XX.X,[Link to DCF] +Market Capitalization ($M),"X,XXX",[Calculated] +,, +Total Debt ($M),XXX,[Yellow input] +Cash & Equivalents ($M),XXX,[Yellow input] +Net Debt ($M),XXX,[Calculated] +,, +Enterprise Value ($M),"X,XXX",[Calculated] +,, +WACC CALCULATION,Weight,Cost,Contribution +Equity,XX.X%,X.X%,X.XX% +Debt,XX.X%,X.X%,X.XX% +,, +WEIGHTED AVERAGE COST OF CAPITAL,X.XX%,[Green output] +``` + +**Key WACC Formulas:** +``` +Market Cap = Price × Shares +Net Debt = Total Debt - Cash +Enterprise Value = Market Cap + Net Debt +Equity Weight = Market Cap / EV +Debt Weight = Net Debt / EV +WACC = (Cost of Equity × Equity Weight) + (After-tax Cost of Debt × Debt Weight) +``` + +### Sensitivity Analysis (Bottom of DCF Sheet) + +**TERMINOLOGY REMINDER**: "Sensitivity tables" = simple 2D grids with row headers, column headers, and formulas in each data cell. NOT Excel's "Data Table" feature (Data → What-If Analysis → Data Table). You will use openpyxl to write regular Excel formulas into each cell. + +**Location**: Rows 87+ on DCF sheet (NOT a separate sheet) + +**Three sensitivity tables, vertically stacked:** + +1. **WACC vs Terminal Growth** (rows 87-100) - 5x5 grid = 25 cells with formulas +2. **Revenue Growth vs EBIT Margin** (rows 102-115) - 5x5 grid = 25 cells with formulas +3. **Beta vs Risk-Free Rate** (rows 117-130) - 5x5 grid = 25 cells with formulas + +**Total formulas to write: 75** (this is required, not optional) + +**CRITICAL**: All sensitivity table cells must be populated programmatically with formulas using openpyxl. DO NOT use linear approximation shortcuts. DO NOT leave placeholder text or notes about manual steps. DO NOT rationalize leaving cells empty because "it's complex" - use a Python loop to generate the formulas. + +**Table Setup:** +1. Create table structure with row/column headers (the assumption values to test) +2. Populate EVERY data cell with a formula that: + - Uses the row header value (e.g., WACC = 9.0%) + - Uses the column header value (e.g., Terminal Growth = 3.0%) + - Recalculates the full DCF with those specific assumptions + - Returns the implied share price for that scenario +3. All cells must contain working formulas when delivered +4. Format cells with conditional formatting: Green scale for higher values, red scale for lower values +5. Bold the base case cell +6. Leave 1-2 blank rows between tables + +**No manual intervention required** - the sensitivity tables must be fully functional when the user opens the file. + +## Case Selector Implementation + +**Three-Case Framework:** + +### Bear Case +- Conservative revenue growth (low end of historical range) +- Margin compression or no expansion +- Higher WACC (risk premium increase) +- Lower terminal growth rate +- Higher CapEx assumptions + +### Base Case +- Consensus or management guidance revenue growth +- Moderate margin expansion based on operating leverage +- Current market-implied WACC +- GDP-aligned terminal growth (2.5-3.0%) +- Standard CapEx assumptions + +### Bull Case +- Optimistic revenue growth (high end of projections) +- Significant margin expansion +- Lower WACC (reduced risk premium) +- Higher terminal growth (3.5-5.0%) +- Reduced CapEx intensity + +**Formula Implementation:** + +**DO NOT use nested IF formulas scattered throughout.** Instead, create a consolidation column that uses INDEX or OFFSET formulas to pull from the appropriate scenario block. + +**Recommended pattern (using INDEX):** +`=INDEX(B10:D10, 1, $B$6)` where `B10:D10` = Bear/Base/Bull values, `1` = row offset, `$B$6` = case selector cell (1, 2, or 3) + +**Then reference the consolidation column** in all projections: +`Revenue Year 1: =D29*(1+$E$10)` where $E$10 is the consolidation column value for Year 1 growth. + +This approach centralizes scenario logic, making the model easier to audit and maintain. + +## Deliverables Structure + +**File naming**: `[Ticker]_DCF_Model_[Date].xlsx` + +**Two sheets**: +1. **DCF** - Complete model with Bear/Base/Bull cases + three sensitivity tables at bottom (WACC vs Terminal Growth, Revenue Growth vs EBIT Margin, Beta vs Risk-Free Rate) +2. **WACC** - Cost of capital calculation + +**Key features**: Case selector (1/2/3), consolidation column with INDEX/OFFSET formulas, color-coded cells, cell comments on all inputs, professional borders + +## Best Practices + +### Model Construction +1. **Build incrementally**: Complete each section before moving to next +2. **Test as building**: Enter sample numbers to verify formulas +3. **Use consistent structure**: Similar calculations follow similar patterns +4. **Comment complex formulas**: Add notes for unusual calculations +5. **Build in checks**: Sum checks and balance checks where applicable + +### Documentation +1. **Document all assumptions**: Explain reasoning behind key inputs +2. **Cite data sources**: Note where each data point came from +3. **Explain methodology**: Describe any non-standard approaches +4. **Flag uncertainties**: Highlight areas with limited visibility + +### Quality Control +1. **Cross-check calculations**: Verify math in multiple ways +2. **Stress test assumptions**: Run sensitivity to ensure model is robust +3. **Peer review**: Have someone else check formulas +4. **Version control**: Save versions as work progresses + +## Common Variations + +### High-Growth Technology Companies +- Longer projection period (7-10 years) +- Higher initial growth rates (20-30%) +- Significant margin expansion over time +- Higher WACC (12-15%) +- Model unit economics (users, ARPU, etc.) + +### Mature/Stable Companies +- Shorter projection period (3-5 years) +- Modest growth rates (GDP +1-3%) +- Stable margins +- Lower WACC (7-9%) +- Focus on cash generation and capital allocation + +### Cyclical Companies +- Model through economic cycle +- Normalize margins at mid-cycle +- Consider trough and peak scenarios +- Adjust beta for cyclicality + +### Multi-Segment Companies +- Separate DCFs for each business unit +- Different growth rates and margins by segment +- Sum-of-parts valuation +- Consider synergies + +## Troubleshooting + +**If you encounter errors or unreasonable results, read [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) for detailed debugging guidance.** + +## Workflow Integration + +### At Start of DCF Build + +1. **Gather market data**: + - Check for available MCP servers for current market data + - Use web search/fetch for stock prices, beta, and other market metrics + - Request from user if specific data is needed + +2. **Gather historical financials**: + - Check for available MCP servers (Daloopa, etc.) + - Request from user if not available via MCP + - Manual extraction from 10-Ks if necessary + +3. **Begin model construction** using the DCF methodology detailed in this skill + +### During Model Construction + +1. **Build Excel model** using openpyxl with formulas (not hardcoded values) +2. **Follow xlsx skill conventions** for formula construction and formatting +3. **Apply fill colors only if requested** by user or if specific brand guidelines are provided + +### Before Delivering Model (MANDATORY) + +1. **Verify structure**: + - Scenario blocks for Bear/Base/Bull with assumptions across projection years + - Case selector functional with formulas referencing correct scenario blocks + - Sensitivity tables at bottom of DCF sheet (not separate sheet) + - Font colors: Blue inputs, black formulas, green sheet links + - Cell comments on ALL hardcoded inputs + - Professional borders around major sections + +2. **Recalculate formulas**: Run `python recalc.py model.xlsx 30` + +3. **Check output**: + - If `status` is `"success"` → Continue to step 4 + - If `status` is `"errors_found"` → Check `error_summary` and read [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) for debugging guidance + +4. **Fix errors and re-run recalc.py** until status is "success" + +5. **Spot-check formulas**: + - Test one FCF formula - does it reference the correct assumption rows? + - Change case selector - does the consolidation column update properly? + - Verify revenue formulas reference consolidation column (not nested IF formulas) + +6. **Deliver model** + +### Available Data Sources + +- **MCP servers**: If configured (Daloopa for historical financials) +- **Web search/fetch**: For current stock prices, beta, and market data +- **User-provided data**: Historical financials, consensus estimates +- **Manual extraction**: SEC EDGAR filings as fallback + +## Final Output Checklist + +Before delivering DCF model: + +**Required:** +- Run `python recalc.py model.xlsx 30` until status is "success" (zero formula errors) +- Two sheets: DCF (with sensitivity at bottom), WACC +- Font colors: Blue=inputs, Black=formulas, Green=sheet links +- Cell comments on ALL hardcoded inputs +- Sensitivity tables fully populated with formulas +- Professional borders around major sections + +**Validation:** +- OpEx based on revenue (not gross profit) +- Terminal value 50-70% of EV +- Terminal growth < WACC +- Tax rate 21-28% +- File naming: `[Ticker]_DCF_Model_[Date].xlsx` + +## Data sources — MCP first, web fallback + +Many passages below say "use the S&P Kensho MCP / Daloopa MCP / FactSet MCP". Those are commercial financial-data MCPs from the original Cowork plugin context. In Hermes: + +- **If you have any structured financial-data MCP configured** (Hermes supports MCP — see `native-mcp` skill), prefer it for point-in-time comps, precedent transactions, and filings. +- **Otherwise**, fall back to: + - `web_search` / `web_extract` against SEC EDGAR (`https://www.sec.gov/cgi-bin/browse-edgar`) for US filings + - Company IR pages for press releases, earnings decks + - `browser_navigate` for interactive data portals + - User-provided data (explicitly ask when the context doesn't have it) +- **Never fabricate**. If a multiple, precedent, or filing number can't be sourced, flag the cell as `[UNSOURCED]` and surface it to the user. + +## Attribution + +This skill is adapted from Anthropic's Claude for Financial Services plugin suite (Apache-2.0). The Office-JS / Cowork live-Excel paths have been removed; this version targets headless openpyxl via the `excel-author` skill's conventions. Original: https://github.com/anthropics/financial-services diff --git a/optional-skills/finance/dcf-model/TROUBLESHOOTING.md b/optional-skills/finance/dcf-model/TROUBLESHOOTING.md new file mode 100644 index 000000000000..eb46365ca1a4 --- /dev/null +++ b/optional-skills/finance/dcf-model/TROUBLESHOOTING.md @@ -0,0 +1,40 @@ +# DCF Model Troubleshooting Guide + +**When to read this file:** If recalc.py shows errors OR valuation results seem unreasonable OR case selector not working properly. + +## Model Returns Error Values + +### #REF! Errors +- Usually caused by formulas referencing wrong rows after headers were inserted +- Solution: Rebuild with correct row references, or start over following layout planning +- Prevention: Define all row positions BEFORE writing formulas + +### #DIV/0! Errors +- Division by zero or empty cells +- Solution: Add IF statements to handle zeros: `=IF([Divisor]=0,0,[Numerator]/[Divisor])` + +### #VALUE! Errors +- Wrong data type in calculation (text instead of number) +- Solution: Verify all inputs are formatted as numbers + +## Valuation Seems Unreasonable + +### Implied price far too high +- Check terminal value isn't >80% of EV +- Verify terminal growth < WACC +- Review if growth assumptions are realistic +- Consider if margins are too optimistic + +### Implied price far too low +- Verify net debt vs net cash is correct +- Check if WACC is too high +- Review if projections are too conservative +- Consider if terminal growth is too low + +## Case Selector Not Working + +### Consolidation column not updating when switching scenarios +- Verify case selector cell contains 1, 2, or 3 +- Check INDEX/OFFSET formulas reference correct row range and selector cell +- Ensure absolute references ($B$6) are used for selector +- Test by manually changing the selector cell and verifying projection values update diff --git a/optional-skills/finance/dcf-model/requirements.txt b/optional-skills/finance/dcf-model/requirements.txt new file mode 100644 index 000000000000..0040dc4ada7b --- /dev/null +++ b/optional-skills/finance/dcf-model/requirements.txt @@ -0,0 +1,7 @@ +# DCF Model Builder - Python Dependencies + +# Excel file handling +openpyxl>=3.0.0 + +# HTTP requests +requests>=2.28.0 diff --git a/optional-skills/finance/dcf-model/scripts/validate_dcf.py b/optional-skills/finance/dcf-model/scripts/validate_dcf.py new file mode 100755 index 000000000000..6c8172cf8cf8 --- /dev/null +++ b/optional-skills/finance/dcf-model/scripts/validate_dcf.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +""" +DCF Model Validation Script +Validates Excel DCF models for formula errors and common DCF mistakes +""" + +import sys +import json +from pathlib import Path +from typing import Optional + + +class DCFModelValidator: + """Validates DCF models for errors and quality issues""" + + def __init__(self, excel_path: str): + try: + import openpyxl + except ImportError: + raise ImportError("openpyxl not installed. Run: pip install openpyxl") + + self.excel_path = excel_path + self.openpyxl = openpyxl + + if not Path(excel_path).exists(): + raise FileNotFoundError(f"File not found: {excel_path}") + + self.workbook_formulas = openpyxl.load_workbook(excel_path, data_only=False) + self.workbook_values = openpyxl.load_workbook(excel_path, data_only=True) + self.errors = [] + self.warnings = [] + self.info = [] + + def validate_all(self) -> dict: + """ + Run all validation checks + + Returns: + Dict with validation results + """ + from datetime import datetime + + self.check_sheet_structure() + self.check_formula_errors() + self.check_dcf_logic() + + results = { + 'file': self.excel_path, + 'validation_date': datetime.now().isoformat(), + 'status': 'PASS' if len(self.errors) == 0 else 'FAIL', + 'error_count': len(self.errors), + 'warning_count': len(self.warnings), + 'errors': self.errors, + 'warnings': self.warnings, + 'info': self.info + } + + return results + + def check_sheet_structure(self): + """Verify required sheets exist""" + required_sheets = ['DCF', 'WACC', 'Sensitivity'] + sheet_names = self.workbook_values.sheetnames + + for sheet in required_sheets: + if sheet not in sheet_names: + self.warnings.append(f"Recommended sheet missing: {sheet}") + else: + self.info.append(f"Found sheet: {sheet}") + + def check_formula_errors(self): + """Check for Excel formula errors in all sheets""" + excel_errors = ['#VALUE!', '#DIV/0!', '#REF!', '#NAME?', '#NULL!', '#NUM!', '#N/A'] + error_details = {err: [] for err in excel_errors} + total_errors = 0 + total_formulas = 0 + + for sheet_name in self.workbook_values.sheetnames: + ws_values = self.workbook_values[sheet_name] + ws_formulas = self.workbook_formulas[sheet_name] + + for row in ws_values.iter_rows(): + for cell in row: + formula_cell = ws_formulas[cell.coordinate] + + # Count formulas + if formula_cell.value and isinstance(formula_cell.value, str) and formula_cell.value.startswith('='): + total_formulas += 1 + + # Check for errors + if cell.value is not None and isinstance(cell.value, str): + for err in excel_errors: + if err in cell.value: + location = f"{sheet_name}!{cell.coordinate}" + error_details[err].append(location) + total_errors += 1 + self.errors.append(f"{err} at {location}") + break + + # Add summary info + self.info.append(f"Total formulas: {total_formulas}") + if total_errors == 0: + self.info.append("✓ No formula errors found") + else: + self.errors.append(f"Total formula errors: {total_errors}") + + return error_details, total_errors + + def check_dcf_logic(self): + """Validate DCF-specific logic and calculations""" + self._check_terminal_growth_vs_wacc() + self._check_wacc_range() + self._check_terminal_value_proportion() + + def _check_terminal_growth_vs_wacc(self): + """Critical check: Terminal growth must be less than WACC""" + try: + dcf_sheet = self.workbook_values['DCF'] + + terminal_growth = None + wacc = None + + # Search for terminal growth and WACC values + for row in dcf_sheet.iter_rows(max_row=100, max_col=20): + for cell in row: + if cell.value and isinstance(cell.value, str): + cell_str = cell.value.lower() + if 'terminal' in cell_str and 'growth' in cell_str: + # Look for value in adjacent cells + for offset in range(1, 5): + adjacent = dcf_sheet.cell(cell.row, cell.column + offset).value + if isinstance(adjacent, (int, float)) and 0 < adjacent < 1: + terminal_growth = adjacent + break + if 'wacc' in cell_str and wacc is None: + for offset in range(1, 5): + adjacent = dcf_sheet.cell(cell.row, cell.column + offset).value + if isinstance(adjacent, (int, float)) and 0 < adjacent < 1: + wacc = adjacent + break + + if terminal_growth is not None and wacc is not None: + if terminal_growth >= wacc: + self.errors.append( + f"CRITICAL: Terminal growth ({terminal_growth:.2%}) >= WACC ({wacc:.2%}). " + "This creates infinite value and is mathematically invalid." + ) + else: + self.info.append( + f"✓ Terminal growth ({terminal_growth:.2%}) < WACC ({wacc:.2%})" + ) + else: + self.warnings.append("Could not locate terminal growth and WACC values") + + except KeyError: + self.warnings.append("DCF sheet not found") + except Exception as e: + self.warnings.append(f"Could not validate terminal growth vs WACC: {str(e)}") + + def _check_wacc_range(self): + """Check if WACC is in reasonable range""" + try: + wacc_sheet = self.workbook_values.get('WACC') or self.workbook_values['DCF'] + wacc = None + + for row in wacc_sheet.iter_rows(max_row=100, max_col=20): + for cell in row: + if cell.value and isinstance(cell.value, str): + if 'wacc' in cell.value.lower(): + for offset in range(1, 5): + adjacent = wacc_sheet.cell(cell.row, cell.column + offset).value + if isinstance(adjacent, (int, float)) and 0 < adjacent < 1: + wacc = adjacent + break + + if wacc is not None: + if wacc < 0.05 or wacc > 0.20: + self.warnings.append( + f"WACC ({wacc:.2%}) is outside typical range (5%-20%). Verify calculation." + ) + else: + self.info.append(f"✓ WACC ({wacc:.2%}) in reasonable range") + else: + self.warnings.append("Could not locate WACC value") + + except Exception as e: + self.warnings.append(f"Could not validate WACC range: {str(e)}") + + def _check_terminal_value_proportion(self): + """Check if terminal value is reasonable proportion of enterprise value""" + try: + dcf_sheet = self.workbook_values['DCF'] + + terminal_value = None + enterprise_value = None + + for row in dcf_sheet.iter_rows(max_row=200, max_col=20): + for cell in row: + if cell.value and isinstance(cell.value, str): + cell_str = cell.value.lower() + if 'terminal' in cell_str and 'value' in cell_str and 'pv' in cell_str: + for offset in range(1, 5): + adjacent = dcf_sheet.cell(cell.row, cell.column + offset).value + if isinstance(adjacent, (int, float)) and adjacent > 0: + terminal_value = adjacent + break + if 'enterprise' in cell_str and 'value' in cell_str: + for offset in range(1, 5): + adjacent = dcf_sheet.cell(cell.row, cell.column + offset).value + if isinstance(adjacent, (int, float)) and adjacent > 0: + enterprise_value = adjacent + break + + if terminal_value is not None and enterprise_value is not None and enterprise_value > 0: + proportion = terminal_value / enterprise_value + if proportion > 0.80: + self.warnings.append( + f"Terminal value is {proportion:.1%} of EV (typically should be 50-70%). " + "Model may be over-reliant on terminal assumptions." + ) + elif proportion < 0.40: + self.warnings.append( + f"Terminal value is {proportion:.1%} of EV (typically should be 50-70%). " + "Check if terminal assumptions are too conservative." + ) + else: + self.info.append(f"✓ Terminal value is {proportion:.1%} of EV") + else: + self.warnings.append("Could not locate terminal value and enterprise value") + + except Exception as e: + self.warnings.append(f"Could not validate terminal value proportion: {str(e)}") + + + +def validate_dcf_model(excel_path: str) -> dict: + """ + Validate a DCF model Excel file + + Args: + excel_path: Path to Excel DCF model + + Returns: + Dict with validation results + """ + validator = DCFModelValidator(excel_path) + return validator.validate_all() + + +def main(): + """Command-line interface""" + if len(sys.argv) < 2: + print("Usage: python validate_dcf.py [output.json]") + print("\nValidates DCF model for:") + print(" - Formula errors (#REF!, #DIV/0!, etc.)") + print(" - Terminal growth < WACC (critical)") + print(" - WACC in reasonable range (5-20%)") + print(" - Terminal value proportion of EV (40-80%)") + print("\nReturns JSON with errors, warnings, and info") + print("\nExample: python validate_dcf.py model.xlsx") + print("Example: python validate_dcf.py model.xlsx results.json") + sys.exit(1) + + excel_file = sys.argv[1] + output_file = sys.argv[2] if len(sys.argv) > 2 else None + + try: + results = validate_dcf_model(excel_file) + + # Print results + print(json.dumps(results, indent=2)) + + # Save to file if requested + if output_file: + with open(output_file, 'w') as f: + json.dump(results, f, indent=2) + + # Exit with error code if validation failed + sys.exit(0 if results['status'] == 'PASS' else 1) + + except Exception as e: + error_result = { + 'file': excel_file, + 'status': 'ERROR', + 'error': str(e) + } + print(json.dumps(error_result, indent=2)) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/optional-skills/finance/excel-author/SKILL.md b/optional-skills/finance/excel-author/SKILL.md new file mode 100644 index 000000000000..1a46b4093930 --- /dev/null +++ b/optional-skills/finance/excel-author/SKILL.md @@ -0,0 +1,243 @@ +--- +name: excel-author +description: Build auditable Excel workbooks headless with openpyxl — blue/black/green cell conventions, formulas over hardcodes, named ranges, balance checks, sensitivity tables. Use for financial models, audit outputs, reconciliations. +version: 1.0.0 +author: Anthropic (adapted by Nous Research) +license: Apache-2.0 +metadata: + hermes: + tags: [excel, openpyxl, finance, spreadsheet, modeling] + related_skills: [pptx-author, dcf-model, comps-analysis, lbo-model, 3-statement-model] +--- + +# excel-author + +Produce an .xlsx file on disk using `openpyxl`. Follow the banker-grade conventions below so the model is auditable, flexible, and reviewable by someone other than the person who built it. + +Adapted from Anthropic's `xlsx-author` and `audit-xls` skills in the [anthropics/financial-services](https://github.com/anthropics/financial-services) repo. The MCP / Office-JS / Cowork-specific branches of the originals are dropped — this skill assumes headless Python. + +## Output contract + +- Write to `./out/.xlsx`. Create `./out/` if it does not exist. +- Return the relative path in your final message so downstream tools can pick it up. +- One logical model per file. Do not append to an existing workbook unless explicitly asked. + +## Setup + +```bash +pip install "openpyxl>=3.0" +``` + +## Core conventions (non-negotiable) + +### Blue / black / green cell color +- **Blue** (`Font(color="0000FF")`) — hardcoded input a human entered. Revenue drivers, WACC inputs, terminal growth, market data. +- **Black** (default) — formula. Every derived cell is a live Excel formula. +- **Green** (`Font(color="006100")`) — link to another sheet or external file. + +A reviewer can then scan the sheet and immediately see what's an assumption vs. what's computed. + +### Formulas over hardcodes +Every calculation cell MUST be a formula string, never a number computed in Python and pasted as a value. + +```python +# WRONG — silent bug waiting to happen +ws["D20"] = revenue_prior_year * (1 + growth) + +# CORRECT — flexes when the user changes the assumption +ws["D20"] = "=D19*(1+$B$8)" +``` + +The only hardcoded numbers permitted: +1. Raw historical inputs (actual revenues, reported EBITDA, etc.) +2. Assumption drivers the user is meant to flex (growth rates, WACC inputs, terminal g) +3. Current market data (share price, debt balance) — with a cell comment documenting source + date + +If you catch yourself computing a value in Python and writing the result, stop. + +### Named ranges for cross-sheet references +Use named ranges for any figure referenced from another sheet, a deck, or a memo. + +```python +from openpyxl.workbook.defined_name import DefinedName +wb.defined_names["WACC"] = DefinedName("WACC", attr_text="Inputs!$C$8") +# then elsewhere: +calc["D30"] = "=D29/WACC" +``` + +### Balance checks tab +Include a `Checks` tab that ties everything and surfaces TRUE/FALSE: +- Balance sheet balances (assets = liabilities + equity) +- Cash flow ties to period-over-period cash change on the BS +- Sum-of-parts ties to consolidated totals +- No rogue hardcodes inside calc ranges + +Example: +```python +checks = wb.create_sheet("Checks") +checks["A2"] = "BS balances" +checks["B2"] = "=IS!D20-IS!D21-IS!D22" +checks["C2"] = "=ABS(B2)<0.01" # TRUE/FALSE +``` + +### Cell comments on every hardcoded input +Add the comment AS you create the cell, not later. + +```python +from openpyxl.comments import Comment +ws["C2"] = 1_250_000_000 +ws["C2"].font = Font(color="0000FF") +ws["C2"].comment = Comment("Source: 10-K FY2024, p.47, revenue line", "analyst") +``` + +Format: `Source: [System/Document], [Date], [Reference], [URL if applicable]`. + +Never defer sourcing. Never write `TODO: add source`. + +## Skeleton: typical financial model + +```python +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.comments import Comment +from openpyxl.utils import get_column_letter +from pathlib import Path + +BLUE = Font(color="0000FF") +BLACK = Font(color="000000") +GREEN = Font(color="006100") +BOLD = Font(bold=True) +HEADER_FILL = PatternFill("solid", fgColor="1F4E79") +HEADER_FONT = Font(color="FFFFFF", bold=True) + +wb = Workbook() + +# --- Inputs tab --- +inp = wb.active +inp.title = "Inputs" +inp["A1"] = "MARKET DATA & KEY INPUTS" +inp["A1"].font = HEADER_FONT +inp["A1"].fill = HEADER_FILL +inp.merge_cells("A1:C1") + +inp["B3"] = "Revenue FY2024" +inp["C3"] = 1_250_000_000 +inp["C3"].font = BLUE +inp["C3"].comment = Comment("Source: 10-K FY2024 p.47", "model") + +inp["B4"] = "Growth Rate" +inp["C4"] = 0.12 +inp["C4"].font = BLUE + +# --- Calc tab --- +calc = wb.create_sheet("DCF") +calc["B2"] = "Projected Revenue" +calc["C2"] = "=Inputs!C3*(1+Inputs!C4)" # formula, black + +# --- Checks tab --- +chk = wb.create_sheet("Checks") +chk["A2"] = "BS balances" +chk["B2"] = "=ABS(BS!D20-BS!D21-BS!D22)<0.01" + +Path("./out").mkdir(exist_ok=True) +wb.save("./out/model.xlsx") +``` + +## Section headers with merged cells + +openpyxl quirk: when you merge, set the value on the top-left cell and style the full range separately. + +```python +ws["A7"] = "CASH FLOW PROJECTION" +ws["A7"].font = HEADER_FONT +ws.merge_cells("A7:H7") +for col in range(1, 9): # A..H + ws.cell(row=7, column=col).fill = HEADER_FILL +``` + +## Sensitivity tables + +Build with loops, not hardcoded formulas per cell. Rules: + +- **Odd number of rows/cols** (5×5 or 7×7) — guarantees a true center cell. +- **Center cell = base case.** The middle row/col header must equal the model's actual WACC and terminal g so the center output equals the base-case implied share price. That's the sanity check. +- **Highlight the center cell** with medium-blue fill (`"BDD7EE"`) and bold. +- Populate every cell with a full recalculation formula — never an approximation. + +```python +# 5x5 WACC (rows) x terminal growth (cols) sensitivity +wacc_axis = [0.08, 0.085, 0.09, 0.095, 0.10] # center row = base 9.0% +term_axis = [0.02, 0.025, 0.03, 0.035, 0.04] # center col = base 3.0% + +start_row = 40 +ws.cell(row=start_row, column=1).value = "Implied Share Price ($)" +ws.cell(row=start_row, column=1).font = BOLD + +for j, g in enumerate(term_axis): + ws.cell(row=start_row+1, column=2+j).value = g + ws.cell(row=start_row+1, column=2+j).font = BLUE + +for i, w in enumerate(wacc_axis): + r = start_row + 2 + i + ws.cell(row=r, column=1).value = w + ws.cell(row=r, column=1).font = BLUE + for j, g in enumerate(term_axis): + c = 2 + j + # Full DCF recalc formula (simplified for illustration). + # In a real model this references the full projection block. + ws.cell(row=r, column=c).value = ( + f"=SUMPRODUCT(FCF_range,1/(1+{w})^year_offset) + " + f"FCF_terminal*(1+{g})/({w}-{g})/(1+{w})^terminal_year" + ) + +# Highlight center cell (base case) +center = ws.cell(row=start_row+2+len(wacc_axis)//2, + column=2+len(term_axis)//2) +center.fill = PatternFill("solid", fgColor="BDD7EE") +center.font = BOLD +``` + +## Recalculating before delivery + +openpyxl writes formula strings but does not compute them. Excel recalculates on open, but downstream consumers (auto-check scripts, CI) need computed values. + +Run LibreOffice or a dedicated recalc step before delivery: + +```bash +# LibreOffice headless recalc +libreoffice --headless --calc --convert-to xlsx ./out/model.xlsx --outdir ./out/ +``` + +Or use a Python recalc helper (see `scripts/recalc.py` in this skill). + +## Model layout planning + +Before writing any formula: +1. Define ALL section row positions +2. Write ALL headers and labels +3. Write ALL section dividers and blank rows +4. THEN write formulas using the locked row positions + +This prevents the cascading-formula-breakage pattern where inserting a header row after formulas are written shifts every downstream reference. + +## Verify step-by-step with the user + +For large models (DCFs, 3-statement, LBO), stop and show the user intermediate artifacts before continuing. Catching a wrong margin assumption before you've built downstream sensitivity tables saves an hour. + +Checkpoint pattern: +- After Inputs block → show raw inputs, confirm before projecting +- After Revenue projections → confirm top line + growth +- After FCF build → confirm the full schedule +- After WACC → confirm inputs +- After valuation → confirm the equity bridge +- THEN build sensitivity tables + +## When NOT to use this skill + +- Users in a live Excel session with an Office MCP available — drive their live workbook instead. +- Pure tabular data export with no formulas — `csv` or `pandas.to_excel` is simpler. +- Dashboards / charts with heavy interactivity — use a real BI tool. + +## Attribution + +Conventions (blue/black/green, formulas-over-hardcodes, named ranges, sensitivity rules) adapted from Anthropic's Claude for Financial Services plugin suite, Apache-2.0 licensed. Original: https://github.com/anthropics/financial-services/tree/main/plugins/vertical-plugins/financial-analysis/skills/xlsx-author diff --git a/optional-skills/finance/excel-author/scripts/recalc.py b/optional-skills/finance/excel-author/scripts/recalc.py new file mode 100644 index 000000000000..a329dbe72465 --- /dev/null +++ b/optional-skills/finance/excel-author/scripts/recalc.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Recalculate an .xlsx file's formulas using LibreOffice headless. + +Usage: python recalc.py [timeout_seconds] + +openpyxl writes formula strings but does not compute them. Downstream scripts +that open the file with data_only=True get None for every formula cell until +something has actually calculated the workbook. Excel does this on open; +headless pipelines need LibreOffice (or similar) to do it explicitly. + +Exits 0 on success (workbook recomputed and resaved in place), non-zero on +failure. Writes status JSON to stdout either way. +""" + +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +def find_libreoffice() -> str | None: + for cmd in ("libreoffice", "soffice"): + path = shutil.which(cmd) + if path: + return path + return None + + +def recalc(xlsx_path: str, timeout: int = 60) -> dict: + src = Path(xlsx_path).resolve() + if not src.exists(): + return {"status": "error", "error": f"File not found: {src}"} + + lo = find_libreoffice() + if lo is None: + return { + "status": "error", + "error": "libreoffice not found on PATH — install it or recalc in a real Excel session", + } + + with tempfile.TemporaryDirectory() as td: + try: + subprocess.run( + [ + lo, + "--headless", + "--calc", + "--convert-to", + "xlsx", + str(src), + "--outdir", + td, + ], + check=True, + capture_output=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return {"status": "error", "error": f"libreoffice timed out after {timeout}s"} + except subprocess.CalledProcessError as e: + return { + "status": "error", + "error": f"libreoffice exited {e.returncode}: {e.stderr.decode(errors='replace')[:500]}", + } + + produced = Path(td) / src.name + if not produced.exists(): + return {"status": "error", "error": "libreoffice did not produce output file"} + + shutil.copy(produced, src) + + return {"status": "success", "file": str(src)} + + +def main(): + if len(sys.argv) < 2: + print("Usage: python recalc.py [timeout_seconds]", file=sys.stderr) + sys.exit(2) + timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 60 + result = recalc(sys.argv[1], timeout=timeout) + print(json.dumps(result, indent=2)) + sys.exit(0 if result["status"] == "success" else 1) + + +if __name__ == "__main__": + main() diff --git a/optional-skills/finance/lbo-model/SKILL.md b/optional-skills/finance/lbo-model/SKILL.md new file mode 100644 index 000000000000..03fd0cbe56ca --- /dev/null +++ b/optional-skills/finance/lbo-model/SKILL.md @@ -0,0 +1,290 @@ +--- +name: lbo-model +description: Build leveraged buyout models in Excel — sources & uses, debt schedule, cash sweep, exit multiple, IRR/MOIC sensitivity. Pairs with excel-author. Use for PE screening, sponsor-case valuation, or illustrative LBO in a pitch. +version: 1.0.0 +author: Anthropic (adapted by Nous Research) +license: Apache-2.0 +metadata: + hermes: + tags: [finance, valuation, lbo, private-equity, excel, openpyxl, modeling] + related_skills: [excel-author, pptx-author, dcf-model, 3-statement-model] +--- + +## Environment + +This skill assumes **headless openpyxl** — you are producing an .xlsx file on disk. +Follow the `excel-author` skill's conventions for cell coloring, formulas, named ranges, and sensitivity tables. +Recalculate before delivery: `python /path/to/excel-author/scripts/recalc.py ./out/model.xlsx`. + +--- + +## TEMPLATE REQUIREMENT + +**This skill uses templates for LBO models. Always check for an attached template file first.** + +Before starting any LBO model: +1. **If a template file is attached/provided**: Use that template's structure exactly - copy it and populate with the user's data +2. **If no template is attached**: Ask the user: *"Do you have a specific LBO template you'd like me to use? If not, I can use the standard template which includes Sources & Uses, Operating Model, Debt Schedule, and Returns Analysis."* +3. **If using the standard template**: Copy `examples/LBO_Model.xlsx` as your starting point and populate it with the user's assumptions + +**IMPORTANT**: When a file like `LBO_Model.xlsx` is attached, you MUST use it as your template - do not build from scratch. Even if the template seems complex or has more features than needed, copy it and adapt it to the user's requirements. Never decide to "build from scratch" when a template is provided. + +--- + +## CRITICAL INSTRUCTIONS — READ FIRST + +Use Python/openpyxl. Write formula strings (`ws["D20"] = "=B5*B6"`), then run the `excel-author` skill's `recalc.py` helper before delivery. + +### Core Principles +* **Every calculation must be an Excel formula** - NEVER compute values in Python and hardcode results into cells. When using openpyxl, write `cell.value = "=B5*B6"` (formula string), NOT `cell.value = 1250` (computed result). The model must be dynamic and update when inputs change. +* **Use the template structure** - Follow the organization in `examples/LBO_Model.xlsx` or the user's provided template. Do not invent your own layout. +* **Use proper cell references** - All formulas should reference the appropriate cells. Never type numbers that should come from other cells. +* **Maintain sign convention consistency** - Follow whatever sign convention the template uses (some use negative for outflows, some use positive). Be consistent throughout. +* **Work section by section, verify with user at each step** - Complete one section fully, show the user what was built, run the section's verification checks, and get confirmation BEFORE moving to the next section. Do NOT build the entire model end-to-end and then present it — later sections depend on earlier ones, so catching a mistake in Sources & Uses after the returns are already built means rework everywhere. + +### Formula Color Conventions +* **Blue (0000FF)**: Hardcoded inputs - typed numbers that don't reference other cells +* **Black (000000)**: Formulas with calculations - any formula using operators or functions (`=B4*B5`, `=SUM()`, `=-MAX(0,B4)`) +* **Purple (800080)**: Links to cells on the **same tab** - direct references with no calculation (`=B9`, `=B45`) +* **Green (008000)**: Links to cells on **different tabs** - cross-sheet references (`=Assumptions!B5`, `='Operating Model'!C10`) + +### Fill Color Palette — Professional Blues & Greys (Default unless user/template specifies otherwise) +* **Keep it minimal** — only use blues and greys for cell fills. Do NOT introduce greens, yellows, reds, or multiple accents. A professional LBO model uses restraint. +* **Default fill palette:** + * **Section headers** (Sources & Uses, Operating Model, etc.): Dark blue `#1F4E79` with white bold text + * **Column headers** (Year 1, Year 2, etc.): Light blue `#D9E1F2` with black bold text + * **Input cells**: Light grey `#F2F2F2` (or just white) — the blue *font* is the signal, fill is secondary + * **Formula/calculated cells**: White, no fill + * **Key outputs** (IRR, MOIC, Exit Equity): Medium blue `#BDD7EE` with black bold text +* **That's the whole palette.** 3 blues + 1 grey + white. If the template uses its own colors, follow the template instead. +* Note: The blue/black/purple/green **font** colors above are for distinguishing inputs vs formulas vs links. Those are separate from the **fill** palette here — both work together. + +### Number Formatting Standards +* **Currency**: `$#,##0;($#,##0);"-"` or `$#,##0.0` depending on template +* **Percentages**: `0.0%` (one decimal) +* **Multiples**: `0.0"x"` (one decimal) +* **MOIC/Detailed Ratios**: `0.00"x"` (two decimals for precision) +* **All numeric cells**: Right-aligned + +--- + +### Clarify Requirements First + +Before filling any formulas: + +* **Examine the template structure** - Identify all sections, understand the timeline (which columns are which periods), note any existing formulas +* **Ask the user if anything is unclear** - If the template structure, calculation methods, or requirements are ambiguous, ask before proceeding +* **Confirm key assumptions** - Any key inputs, calculation preferences, or specific requirements +* **ONLY AFTER understanding the template**, proceed to fill in formulas + +--- + +## TEMPLATE ANALYSIS PHASE - DO THIS FIRST + +Before filling any formulas, examine the template thoroughly: + +1. **Map the structure** - Identify where each section lives and how they relate to each other. Note which sections feed into others. + +2. **Understand the timeline** - Which columns represent which periods? Is there a "Closing" or "Pro Forma" column? Where does the projection period start? + +3. **Identify input vs formula cells** - Templates often use color coding, borders, or shading to indicate which cells need inputs vs formulas. Respect these conventions. + +4. **Read existing labels carefully** - The row labels tell you exactly what calculation is expected. Don't assume - read what the template is asking for. + +5. **Check for existing formulas** - Some templates come partially filled. Don't overwrite working formulas unless specifically asked. + +6. **Note template-specific conventions** - Sign conventions, subtotal structures, how sections are organized, whether there are separate tabs for different components, etc. + +--- + +## FILLING FORMULAS - GENERAL APPROACH + +For each cell that needs a formula, follow this hierarchy: + +### Step 1: Check the Template +* Does the cell already have a formula? If yes, verify it's correct and move on. +* Is there a comment or note indicating the expected calculation? +* Does the row/column label make the calculation obvious? +* Do neighboring cells show a pattern you should follow? + +### Step 2: Check the User's Instructions +* Did the user specify a particular calculation method? +* Are there stated assumptions that affect this formula? +* Any special requirements mentioned? + +### Step 3: Apply Standard Practice +* If neither template nor user specifies, use standard LBO modeling conventions +* Document any assumptions you make +* If genuinely uncertain, ask the user + +--- + +## COMMON PROBLEM AREAS + +The following calculation patterns frequently cause issues across LBO models. Pay special attention when you encounter these: + +### Balancing Sections +* When two sections must equal (e.g., Sources = Uses), one item is typically the "plug" (balancing figure) +* Identify which item is the plug and calculate it as the difference + +### Tax Calculations +* Tax formulas should only reference the relevant income line and tax rate +* Should NOT reference unrelated sections (e.g., debt schedules) +* Consider whether losses create tax shields or are simply ignored + +### Interest and Circular References +* Interest calculations can create circularity if they reference balances affected by cash flows +* Use **Beginning Balance** (not average or ending) to break circular references +* Pattern: Interest → Cash Flow → Paydown → Ending Balance (if interest uses ending balance, this circles back) + +### Debt Paydown / Cash Sweeps +* When multiple debt tranches exist, there's usually a priority order +* Cash sweep should respect the priority waterfall +* Balances cannot go negative - use MAX or MIN functions appropriately + +### Returns Calculations (IRR/MOIC) +* Cash flows must have correct signs: Investment = negative, Proceeds = positive +* If using XIRR, need corresponding dates +* If using IRR, cash flows should be in consecutive periods +* MOIC = Total Proceeds / Total Investment + +### Sensitivity Tables +* **Use ODD dimensions** (5×5 or 7×7) — never 4×4 or 6×6. Odd dimensions guarantee a true center cell. +* **Center cell = base case.** Build the row and column axis values symmetrically around the model's actual assumptions (e.g., if base entry multiple = 10.0x, axis = `[8.0x, 9.0x, 10.0x, 11.0x, 12.0x]`). The center cell's IRR/MOIC MUST then equal the model's actual IRR/MOIC output — this is the proof the table is wired correctly. +* **Highlight the center cell** — medium-blue fill (`#BDD7EE`) + bold font so the base case is visually anchored. +* Excel's DATA TABLE function may not work with openpyxl — instead write explicit formulas that reference row/column headers +* Each cell should show a DIFFERENT value — if all same, formulas aren't varying correctly +* Use mixed references (e.g., `$A5` for row input, `B$4` for column input) + +--- + +## VERIFICATION CHECKLIST - RUN AFTER COMPLETION + +### Run Formula Validation +```bash +python /path/to/excel-author/scripts/recalc.py model.xlsx +``` +Must return success with zero errors. + +### Section Balancing +- [ ] Any sections that must balance (Sources/Uses, Assets/Liabilities) balance exactly +- [ ] Plug items are calculated correctly as the balancing figure +- [ ] Amounts that should match across sections are consistent + +### Income/Operating Projections +- [ ] Revenue/top-line builds correctly from drivers or growth rates +- [ ] All cost and expense items calculated appropriately +- [ ] Subtotals and totals sum correctly +- [ ] Margins and ratios are reasonable +- [ ] Links to assumptions are correct + +### Balance Sheet (if applicable) +- [ ] Assets = Liabilities + Equity (must balance) +- [ ] All items link to appropriate schedules or roll-forwards +- [ ] Beginning balances = prior period ending balances +- [ ] Check row included and shows zero + +### Cash Flow (if applicable) +- [ ] Starts with correct income figure +- [ ] Non-cash items added/subtracted appropriately +- [ ] Working capital changes have correct signs +- [ ] Ending Cash = Beginning Cash + Net Cash Flow +- [ ] Cash balances are consistent across statements + +### Supporting Schedules +- [ ] Roll-forward schedules balance (Beginning + Changes = Ending) +- [ ] Schedules link correctly to main statements +- [ ] Calculated items use appropriate drivers +- [ ] All periods are calculated consistently + +### Debt/Financing Schedules (if applicable) +- [ ] Beginning balances tie to sources or prior period +- [ ] Interest calculated on appropriate balance (typically beginning) +- [ ] Paydowns respect cash availability and priority +- [ ] Ending balances cannot be negative +- [ ] Totals sum tranches correctly + +### Returns/Output Analysis +- [ ] Exit/terminal values calculated correctly +- [ ] All relevant adjustments included +- [ ] Cash flow signs are correct (negative for investment, positive for proceeds) +- [ ] IRR/MOIC formulas reference complete ranges +- [ ] Results are reasonable for the scenario + +### Sensitivity Tables (if applicable) +- [ ] Grid dimensions are ODD (5×5 or 7×7) — there is a true center cell +- [ ] Row and column axis values are symmetric around the base case (`[base-2Δ, base-Δ, base, base+Δ, base+2Δ]`) +- [ ] Center cell output equals the model's actual IRR/MOIC — confirms the table is wired correctly +- [ ] Center cell is highlighted (medium-blue fill `#BDD7EE`, bold font) +- [ ] Row and column headers contain appropriate input values +- [ ] Each data cell contains a formula (not hardcoded) +- [ ] Each data cell shows a DIFFERENT value +- [ ] Values move in expected directions (higher exit multiple → higher IRR, etc.) + +### Formatting +- [ ] Hardcoded inputs are blue (0000FF) +- [ ] Calculated formulas are black (000000) +- [ ] Same-tab links are purple (800080) +- [ ] Cross-tab links are green (008000) +- [ ] All numbers are right-aligned +- [ ] Appropriate number formats applied throughout +- [ ] No cells show error values (#REF!, #DIV/0!, #VALUE!, #NAME?) + +### Logical Sanity Checks +- [ ] Numbers are reasonable order of magnitude +- [ ] Trends make sense (growth, decline, stabilization as expected) +- [ ] No obviously wrong values (negative where should be positive, impossible percentages, etc.) +- [ ] Key outputs are within reasonable ranges for the type of analysis + +--- + +## COMMON ERRORS TO AVOID + +| Error | What Goes Wrong | How to Fix | +|-------|-----------------|------------| +| Hardcoding calculated values | Model doesn't update when inputs change | Always use formulas that reference source cells | +| Wrong cell references after copying | Formulas point to wrong cells | Verify all links, use appropriate $ anchoring | +| Circular reference errors | Model can't calculate | Use beginning balances for interest-type calcs, break the circle | +| Sections don't balance | Totals that should match don't | Ensure one item is the plug (calculated as difference) | +| Negative balances where impossible | Paying/using more than available | Use MAX(0, ...) or MIN functions appropriately | +| IRR/return errors | Wrong signs or incomplete ranges | Check cash flow signs and ensure formula covers all periods | +| Sensitivity table shows same value | Formula not varying with inputs | Check cell references - need mixed references ($A5, B$4) | +| Roll-forwards don't tie | Beginning ≠ prior ending | Verify links between periods | +| Inconsistent sign conventions | Additions become subtractions or vice versa | Follow template's convention consistently throughout | + +--- + +## WORKING WITH THE USER — SECTION-BY-SECTION CHECKPOINTS + +* **If the template structure is unclear**, ask before proceeding +* **If the user's requirements conflict with the template**, confirm their preference +* **After completing each major section**, STOP and verify with the user before continuing: + - **After Sources & Uses** → show the balanced table, confirm the plug is correct, get sign-off before building the operating model + - **After Operating Model / Projections** → show the projected P&L, confirm growth rates and margins look right, get sign-off before the debt schedule + - **After Debt Schedule** → show beginning/ending balances and interest, confirm the waterfall logic, get sign-off before returns + - **After Returns (IRR/MOIC)** → show the cash flow series and outputs, confirm signs and ranges, get sign-off before sensitivity tables + - **After Sensitivity Tables** → show that each cell varies, confirm the base case lands where expected +* **If errors are found during verification**, fix them before moving to the next section +* **Show your work** - explain key formulas or assumptions when helpful +* **Never present a completed model without having checked in at each section** — it's faster to catch a wrong cell reference at the source than to trace it backwards from a broken IRR + +--- + +**This skill produces investment banking-quality LBO models by filling templates with correct formulas, proper formatting, and validated calculations. The skill adapts to any template structure while ensuring financial accuracy and professional presentation standards.** + + +## Data sources — MCP first, web fallback + +Many passages below say "use the S&P Kensho MCP / Daloopa MCP / FactSet MCP". Those are commercial financial-data MCPs from the original Cowork plugin context. In Hermes: + +- **If you have any structured financial-data MCP configured** (Hermes supports MCP — see `native-mcp` skill), prefer it for point-in-time comps, precedent transactions, and filings. +- **Otherwise**, fall back to: + - `web_search` / `web_extract` against SEC EDGAR (`https://www.sec.gov/cgi-bin/browse-edgar`) for US filings + - Company IR pages for press releases, earnings decks + - `browser_navigate` for interactive data portals + - User-provided data (explicitly ask when the context doesn't have it) +- **Never fabricate**. If a multiple, precedent, or filing number can't be sourced, flag the cell as `[UNSOURCED]` and surface it to the user. + +## Attribution + +This skill is adapted from Anthropic's Claude for Financial Services plugin suite (Apache-2.0). The Office-JS / Cowork live-Excel paths have been removed; this version targets headless openpyxl via the `excel-author` skill's conventions. Original: https://github.com/anthropics/financial-services diff --git a/optional-skills/finance/merger-model/SKILL.md b/optional-skills/finance/merger-model/SKILL.md new file mode 100644 index 000000000000..b2e2f88bc35d --- /dev/null +++ b/optional-skills/finance/merger-model/SKILL.md @@ -0,0 +1,143 @@ +--- +name: merger-model +description: Build accretion/dilution (merger) models in Excel — pro-forma P&L, synergies, financing mix, EPS impact. Pairs with excel-author. Use for M&A pitches, board materials, or deal evaluation. +version: 1.0.0 +author: Anthropic (adapted by Nous Research) +license: Apache-2.0 +metadata: + hermes: + tags: [finance, m-and-a, merger, accretion-dilution, excel, openpyxl, modeling, investment-banking] + related_skills: [excel-author, pptx-author, dcf-model, 3-statement-model] +--- + +## Environment + +This skill assumes **headless openpyxl** — you are producing an .xlsx file on disk. +Follow the `excel-author` skill's conventions for cell coloring, formulas, named ranges, and sensitivity tables. +Recalculate before delivery: `python /path/to/excel-author/scripts/recalc.py ./out/model.xlsx`. + +# Merger Model + +Build accretion/dilution analysis for M&A transactions. Models pro forma EPS impact, synergy sensitivities, and purchase price allocation. Use when evaluating a potential acquisition, preparing merger consequences analysis for a pitch, or advising on deal terms. + +## Workflow + +### Step 1: Gather Inputs + +**Acquirer:** +- Company name, current share price, shares outstanding +- LTM and NTM EPS (GAAP and adjusted) +- P/E multiple +- Pre-tax cost of debt, tax rate +- Cash on balance sheet, existing debt + +**Target:** +- Company name, current share price, shares outstanding (if public) +- LTM and NTM EPS or net income +- Enterprise value or equity value + +**Deal Terms:** +- Offer price per share (or premium to current) +- Consideration mix: % cash vs. % stock +- New debt raised to fund cash portion +- Expected synergies (revenue and cost) and phase-in timeline +- Transaction fees and financing costs +- Expected close date + +### Step 2: Purchase Price Analysis + +| Item | Value | +|------|-------| +| Offer price per share | | +| Premium to current | | +| Equity value | | +| Plus: net debt assumed | | +| Enterprise value | | +| EV / EBITDA implied | | +| P/E implied | | + +### Step 3: Sources & Uses + +| Sources | $ | Uses | $ | +|---------|---|------|---| +| New debt | | Equity purchase price | | +| Cash on hand | | Refinance target debt | | +| New equity issued | | Transaction fees | | +| | | Financing fees | | +| **Total** | | **Total** | | + +### Step 4: Pro Forma EPS (Accretion / Dilution) + +Calculate year-by-year (Year 1-3): + +| | Standalone | Pro Forma | Accretion/(Dilution) | +|---|-----------|-----------|---------------------| +| Acquirer net income | | | | +| Target net income | | | | +| Synergies (after tax) | | | | +| Foregone interest on cash (after tax) | | | | +| New debt interest (after tax) | | | | +| Intangible amortization (after tax) | | | | +| Pro forma net income | | | | +| Pro forma shares | | | | +| **Pro forma EPS** | | | | +| **Accretion / (Dilution) %** | | | | + +### Step 5: Sensitivity Analysis + +**Accretion/Dilution vs. Synergies and Offer Premium:** + +| | $0M syn | $25M syn | $50M syn | $75M syn | $100M syn | +|---|---------|----------|----------|----------|-----------| +| 15% premium | | | | | | +| 20% premium | | | | | | +| 25% premium | | | | | | +| 30% premium | | | | | | + +**Accretion/Dilution vs. Cash/Stock Mix:** + +| | 100% cash | 75/25 | 50/50 | 25/75 | 100% stock | +|---|-----------|-------|-------|-------|------------| +| Year 1 | | | | | | +| Year 2 | | | | | | + +### Step 6: Breakeven Synergies + +Calculate the minimum synergies needed for the deal to be EPS-neutral in Year 1. + +### Step 7: Output + +- Excel workbook with: + - Assumptions tab + - Sources & uses + - Pro forma income statement + - Accretion/dilution summary + - Sensitivity tables + - Breakeven analysis +- One-page merger consequences summary for pitch book + +## Important Notes + +- Always show both GAAP and adjusted (cash) EPS where relevant +- Stock deals: use acquirer's current price for exchange ratio, note dilution from new shares +- Include purchase price allocation — goodwill and intangible amortization matter for GAAP EPS +- Synergy phase-in is critical — Year 1 is often only 25-50% of run-rate synergies +- Don't forget foregone interest income on cash used and new interest expense on debt raised +- Tax rate on synergies and interest adjustments should match the acquirer's marginal rate + + +## Data sources — MCP first, web fallback + +Many passages below say "use the S&P Kensho MCP / Daloopa MCP / FactSet MCP". Those are commercial financial-data MCPs from the original Cowork plugin context. In Hermes: + +- **If you have any structured financial-data MCP configured** (Hermes supports MCP — see `native-mcp` skill), prefer it for point-in-time comps, precedent transactions, and filings. +- **Otherwise**, fall back to: + - `web_search` / `web_extract` against SEC EDGAR (`https://www.sec.gov/cgi-bin/browse-edgar`) for US filings + - Company IR pages for press releases, earnings decks + - `browser_navigate` for interactive data portals + - User-provided data (explicitly ask when the context doesn't have it) +- **Never fabricate**. If a multiple, precedent, or filing number can't be sourced, flag the cell as `[UNSOURCED]` and surface it to the user. + +## Attribution + +This skill is adapted from Anthropic's Claude for Financial Services plugin suite (Apache-2.0). The Office-JS / Cowork live-Excel paths have been removed; this version targets headless openpyxl via the `excel-author` skill's conventions. Original: https://github.com/anthropics/financial-services diff --git a/optional-skills/finance/pptx-author/SKILL.md b/optional-skills/finance/pptx-author/SKILL.md new file mode 100644 index 000000000000..b52f99297584 --- /dev/null +++ b/optional-skills/finance/pptx-author/SKILL.md @@ -0,0 +1,172 @@ +--- +name: pptx-author +description: Build PowerPoint decks headless with python-pptx. Pairs with excel-author for model-backed decks where every number traces to a workbook cell. Use for pitch decks, IC memos, earnings notes. +version: 1.0.0 +author: Anthropic (adapted by Nous Research) +license: Apache-2.0 +metadata: + hermes: + tags: [powerpoint, pptx, python-pptx, presentation, finance] + related_skills: [excel-author, powerpoint] +--- + +# pptx-author + +Produce a .pptx file on disk using `python-pptx`. Use when you need to deliver a deck as a file artifact, not drive a live PowerPoint session. + +Adapted from Anthropic's `pptx-author` and `pitch-deck` skills in [anthropics/financial-services](https://github.com/anthropics/financial-services). The MCP / Office-JS branches of the originals are dropped — this assumes headless Python. + +For the broader, already-shipped PowerPoint authoring skill (slides, speaker notes, embeds, media), see the built-in `powerpoint` skill. This skill is a lighter-weight pattern tuned for model-backed decks (pitch decks, IC memos, earnings notes) where every number must trace to a source workbook. + +## Output contract + +- Write to `./out/.pptx`. Create `./out/` if it does not exist. +- Return the relative path in your final message. + +## Setup + +```bash +pip install "python-pptx>=0.6" +``` + +## Core conventions + +### One idea per slide +Title states the takeaway; body supports it. A slide titled "Q3 Revenue" is weak; "Revenue growth accelerated to 14% Y/Y in Q3" is strong. + +### Every number traces to the model +If a figure on a slide came from `./out/model.xlsx`, footnote the sheet and cell. + +``` +Revenue: $1,250M (Source: model.xlsx, Inputs!C3) +``` + +Never transcribe numbers from memory or from a summary — open the workbook, read the named range, and bind the deck value to it programmatically when you can. + +### Use the firm template when one is mounted +If `./templates/firm-template.pptx` exists, load it so the deck inherits branded colors, fonts, and master layouts. + +```python +from pptx import Presentation +from pathlib import Path + +template = Path("./templates/firm-template.pptx") +prs = Presentation(str(template)) if template.exists() else Presentation() +``` + +### Charts: PNG-from-model beats native pptx charts +When fidelity matters (the model's chart styling must match the deck exactly), render the chart to PNG from the source workbook and embed the image. Native `pptx.chart` charts are fragile and often don't match firm conventions. + +```python +from pptx.util import Inches +slide.shapes.add_picture("./out/charts/football_field.png", + Inches(1), Inches(2), + width=Inches(8)) +``` + +### No external sends +This skill writes a file. It never emails, uploads, or posts. Orchestration layers handle delivery. + +## Skeleton + +```python +from pptx import Presentation +from pptx.util import Inches, Pt +from pptx.dml.color import RGBColor +from pathlib import Path + +template = Path("./templates/firm-template.pptx") +prs = Presentation(str(template)) if template.exists() else Presentation() + +# Title slide +slide = prs.slides.add_slide(prs.slide_layouts[0]) +slide.shapes.title.text = "Project Aurora — Strategic Alternatives" +slide.placeholders[1].text = "Preliminary Discussion Materials" + +# Valuation summary slide (title-only layout) +slide = prs.slides.add_slide(prs.slide_layouts[5]) +slide.shapes.title.text = "Valuation implies $38–$52 per share across methodologies" + +# Add a table bound to model outputs +rows, cols = 5, 4 +tbl_shape = slide.shapes.add_table(rows, cols, + Inches(0.5), Inches(1.5), + Inches(9), Inches(3)) +tbl = tbl_shape.table +headers = ["Methodology", "Low ($)", "Mid ($)", "High ($)"] +for c, h in enumerate(headers): + tbl.cell(0, c).text = h + +# In a real deck, read these from the model workbook with openpyxl +data = [ + ("Trading comps", "35", "41", "48"), + ("Precedent M&A", "39", "45", "52"), + ("DCF (base)", "36", "43", "51"), + ("LBO (10% IRR)", "33", "38", "44"), +] +for r, row in enumerate(data, start=1): + for c, val in enumerate(row): + tbl.cell(r, c).text = val + +# Embed a chart rendered from the model +slide = prs.slides.add_slide(prs.slide_layouts[5]) +slide.shapes.title.text = "Football field — current price $42" +slide.shapes.add_picture("./out/charts/football_field.png", + Inches(1), Inches(1.8), width=Inches(8)) + +Path("./out").mkdir(exist_ok=True) +prs.save("./out/pitch-aurora.pptx") +``` + +## Binding deck numbers to the source workbook + +Read named ranges or specific cells from your Excel model so deck numbers never drift. + +```python +from openpyxl import load_workbook + +wb = load_workbook("./out/model.xlsx", data_only=True) +def nr(name): + """Resolve a named range to its current computed value.""" + rng = wb.defined_names[name] + sheet, coord = next(rng.destinations) + return wb[sheet][coord].value + +revenue_fy24 = nr("RevenueFY24") +implied_mid = nr("ImpliedSharePriceBase") +``` + +Then build deck content using those values: +```python +slide.shapes.title.text = f"Implied share price of ${implied_mid:.2f} (base case)" +``` + +Remember to recalculate the workbook before reading it — openpyxl only sees computed values if something has already calculated the sheet. Run the recalc helper in the `excel-author` skill first, or open/save through a real Excel session. + +## Slide-type checklist for pitch decks + +A typical banking pitch deck follows this structure. Not prescriptive, but useful as a starting skeleton: + +1. Cover / title +2. Disclaimer +3. Table of contents +4. Situation overview +5. Company snapshot (the target) +6. Market / sector context +7. Valuation summary (football field) — the money slide +8. Trading comps detail +9. Precedent transactions detail +10. DCF summary +11. Illustrative LBO / sponsor case +12. Process considerations +13. Appendix + +## When NOT to use this skill + +- Users in a live PowerPoint session with an Office MCP available — drive their live doc instead. +- Non-financial slideware (quarterly all-hands, marketing decks) — use the broader `powerpoint` skill. +- Decks with heavy animation, transitions, or speaker notes — use the broader `powerpoint` skill. + +## Attribution + +Conventions adapted from Anthropic's Claude for Financial Services plugin suite, Apache-2.0 licensed. Original: https://github.com/anthropics/financial-services/tree/main/plugins/agent-plugins/pitch-agent/skills/pptx-author diff --git a/optional-skills/research/searxng-search/SKILL.md b/optional-skills/research/searxng-search/SKILL.md new file mode 100644 index 000000000000..c2d170591b64 --- /dev/null +++ b/optional-skills/research/searxng-search/SKILL.md @@ -0,0 +1,211 @@ +--- +name: searxng-search +description: Free meta-search via SearXNG — aggregates results from 70+ search engines. Self-hosted or use a public instance. No API key needed. Falls back automatically when the web search toolset is unavailable. +version: 1.0.0 +author: hermes-agent +license: MIT +metadata: + hermes: + tags: [search, searxng, meta-search, self-hosted, free, fallback] + related_skills: [duckduckgo-search, domain-intel] + fallback_for_toolsets: [web] +--- + +# SearXNG Search + +Free meta-search using [SearXNG](https://searxng.org/) — a privacy-respecting, self-hosted search aggregator that queries 70+ search engines simultaneously. + +**No API key required** when using a public instance. Can also be self-hosted for full control. Automatically appears as a fallback when the main web search toolset (`FIRECRAWL_API_KEY`) is not configured. + +## Configuration + +SearXNG requires a `SEARXNG_URL` environment variable pointing to your SearXNG instance: + +```bash +# Public instances (no setup required) +SEARXNG_URL=https://searxng.example.com + +# Self-hosted SearXNG +SEARXNG_URL=http://localhost:8888 +``` + +If no instance is configured, this skill is unavailable and the agent falls back to other search options. + +## Detection Flow + +Check what is actually available before choosing an approach: + +```bash +# Check if SEARXNG_URL is set and the instance is reachable +curl -s --max-time 5 "${SEARXNG_URL}/search?q=test&format=json" | head -c 200 +``` + +Decision tree: +1. If `SEARXNG_URL` is set and the instance responds, use SearXNG +2. If `SEARXNG_URL` is unset or unreachable, fall back to other available search tools +3. If the user wants SearXNG specifically, help them set up an instance or find a public one + +## Method 1: CLI via curl (Preferred) + +Use `curl` via `terminal` to call the SearXNG JSON API. This avoids assuming any particular Python package is installed. + +```bash +# Text search (JSON output) +curl -s --max-time 10 \ + "${SEARXNG_URL}/search?q=python+async+programming&format=json&engines=google,bing&limit=10" + +# With Safesearch off +curl -s --max-time 10 \ + "${SEARXNG_URL}/search?q=example&format=json&safesearch=0" + +# Specific categories (general, news, science, etc.) +curl -s --max-time 10 \ + "${SEARXNG_URL}/search?q=AI+news&format=json&categories=news" +``` + +### Common CLI Flags + +| Flag | Description | Example | +|------|-------------|---------| +| `q` | Query string (URL-encoded) | `q=python+async` | +| `format` | Output format: `json`, `csv`, `rss` | `format=json` | +| `engines` | Comma-separated engine names | `engines=google,bing,ddg` | +| `limit` | Max results per engine (default 10) | `limit=5` | +| `categories` | Filter by category | `categories=news,science` | +| `safesearch` | 0=none, 1=moderate, 2=strict | `safesearch=0` | +| `time_range` | Filter: `day`, `week`, `month`, `year` | `time_range=week` | + +### Parsing JSON Results + +```bash +# Extract titles and URLs from JSON +curl -s --max-time 10 "${SEARXNG_URL}/search?q=fastapi&format=json&limit=5" \ + | python3 -c " +import json, sys +data = json.load(sys.stdin) +for r in data.get('results', []): + print(r.get('title','')) + print(r.get('url','')) + print(r.get('content','')[:200]) + print() +" +``` + +Returns per result: `title`, `url`, `content` (snippet), `engine`, `parsed_url`, `img_src`, `thumbnail`, `author`, `published_date` + +## Method 2: Python API via `requests` + +Use the SearXNG REST API directly from Python with the `requests` library: + +```python +import os, requests, urllib.parse + +base_url = os.environ.get("SEARXNG_URL", "") +if not base_url: + raise RuntimeError("SEARXNG_URL is not set") + +query = "fastapi deployment guide" +params = { + "q": query, + "format": "json", + "limit": 5, + "engines": "google,bing", +} + +resp = requests.get(f"{base_url}/search", params=params, timeout=10) +resp.raise_for_status() +data = resp.json() + +for r in data.get("results", []): + print(r["title"]) + print(r["url"]) + print(r.get("content", "")[:200]) + print() +``` + +## Method 3: searxng-data Python Package + +For more structured access, install the `searxng-data` package: + +```bash +pip install searxng-data +``` + +```python +from searxng_data import engines + +# List available engines +print(engines.list_engines()) +``` + +Note: This package only provides engine metadata, not the search API itself. + +## Self-Hosting SearXNG + +To run your own SearXNG instance: + +```bash +# Using Docker +docker run -d -p 8888:8080 \ + -v $(pwd)/searxng:/etc/searxng \ + searxng/searxng:latest + +# Then set +SEARXNG_URL=http://localhost:8888 +``` + +Or install via pip: +```bash +pip install searxng +# Edit /etc/searxng/settings.yml +searxng-run +``` + +Public SearXNG instances are available at: +- `https://searxng.example.com` (replace with any public instance) + +## Workflow: Search then Extract + +SearXNG returns titles, URLs, and snippets — not full page content. To get full page content, search first and then extract the most relevant URL with `web_extract`, browser tools, or `curl`. + +```bash +# Search for relevant pages +curl -s "${SEARXNG_URL}/search?q=fastapi+deployment&format=json&limit=3" +# Output: list of results with titles and URLs + +# Then extract the best URL with web_extract +``` + +## Limitations + +- **Instance availability**: If the SearXNG instance is down or unreachable, search fails. Always check `SEARXNG_URL` is set and the instance is reachable. +- **No content extraction**: SearXNG returns snippets, not full page content. Use `web_extract`, browser tools, or `curl` for full articles. +- **Rate limiting**: Some public instances limit requests. Self-hosting avoids this. +- **Engine coverage**: Available engines depend on the SearXNG instance configuration. Some engines may be disabled. +- **Results freshness**: Meta-search aggregates external engines — result freshness depends on those engines. + +## Troubleshooting + +| Problem | Likely Cause | What To Do | +|---------|--------------|------------| +| `SEARXNG_URL` not set | No instance configured | Use a public SearXNG instance or set up your own | +| Connection refused | Instance not running or wrong URL | Check the URL is correct and the instance is running | +| Empty results | Instance blocks the query | Try a different instance or self-host | +| Slow responses | Public instance under load | Self-host or use a less-loaded public instance | +| `json` format not supported | Old SearXNG version | Try `format=rss` or upgrade SearXNG | + +## Pitfalls + +- **Always set `SEARXNG_URL`**: Without it, the skill cannot function. +- **URL-encode queries**: Spaces and special characters must be URL-encoded in curl, or use `urllib.parse.quote()` in Python. +- **Use `format=json`**: The default format may not be machine-readable. Always request JSON explicitly. +- **Set a timeout**: Always use `--max-time` or `timeout=` to avoid hanging on unreachable instances. +- **Self-hosting is best**: Public instances may go down, rate-limit, or block. A self-hosted instance is reliable. + +## Instance Discovery + +If `SEARXNG_URL` is not set and the user asks about SearXNG, help them either: +1. Find a public SearXNG instance (search for "public searxng instance") +2. Set up their own with Docker or pip + +Public instances are listed at: https://searxng.org/ diff --git a/optional-skills/research/searxng-search/scripts/searxng.sh b/optional-skills/research/searxng-search/scripts/searxng.sh new file mode 100755 index 000000000000..12fe792d09c4 --- /dev/null +++ b/optional-skills/research/searxng-search/scripts/searxng.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Usage: ./searxng.sh [max_results] [engines] +# Example: ./searxng.sh "python async" 10 "google,bing" + +QUERY="${1:-}" +MAX="${2:-5}" +ENGINES="${3:-google,bing}" + +if [ -z "$SEARXNG_URL" ]; then + echo "Error: SEARXNG_URL is not set" + exit 1 +fi + +if [ -z "$QUERY" ]; then + echo "Usage: $0 [max_results] [engines]" + exit 1 +fi + +ENCODED_QUERY=$(echo "$QUERY" | sed 's/ /+/g') + +curl -s --max-time 10 \ + "${SEARXNG_URL}/search?q=${ENCODED_QUERY}&format=json&limit=${MAX}&engines=${ENGINES}" diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index b4d85432d838..cc8e3a22251b 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -112,17 +112,30 @@ function writeSelectedBoard(slug) { try { - if (slug && slug !== "default") window.localStorage.setItem(LS_BOARD_KEY, slug); + // Persist the user's dashboard-side board pin even for "default". + // Previously this stripped "default" to keep localStorage empty, + // but the fetch layer read that absence as "no opinion" and fell + // through to the server-side ``current`` file — which the board + // switcher also writes. Result: selecting the default tab after + // creating a new board with "switch" checked showed the new + // board's (wrong) data because the URL omitted ``?board=`` and + // the backend happily returned whichever board was "current". + // Persisting every selection keeps the dashboard's board opinion + // independent of the CLI's active board, which was the original + // design intent. Regression: #20879. + if (slug) window.localStorage.setItem(LS_BOARD_KEY, slug); else window.localStorage.removeItem(LS_BOARD_KEY); } catch (_e) { /* ignore quota / private mode */ } } function withBoard(url, board) { - // Append ?board= when a non-default board is active. Omitted - // for default so the URL stays clean and the backend falls through - // to its own resolution chain (env var → ``current`` file → - // default) which is already correct. - if (!board || board === "default") return url; + // Always append ?board= when we have one picked — including + // "default". Omitting the param would fall through to the backend's + // resolution chain (env var → ``current`` file → default), which + // means the dashboard's tab selection gets silently overridden by + // whatever board the CLI or "switch" checkbox last activated. + // Regression: #20879. + if (!board) return url; const sep = url.indexOf("?") >= 0 ? "&" : "?"; return `${url}${sep}board=${encodeURIComponent(board)}`; } @@ -447,9 +460,11 @@ token: token, }; // Pin the WS stream to the currently-selected board so events - // from other boards don't bleed in. Only set for non-default so - // single-board installs keep the cleaner URL. - if (board && board !== "default") qsParams.board = board; + // from other boards don't bleed in. Includes "default" so the + // dashboard's own board pin always wins over the server-side + // ``current`` file — same rationale as ``withBoard()`` above. + // Regression: #20879. + if (board) qsParams.board = board; const qs = new URLSearchParams(qsParams); const url = `${proto}//${window.location.host}${API}/events?${qs}`; let ws; diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 3176737a8cac..f7dfd91a7d5b 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -1521,6 +1521,13 @@ def _fetch_new(cursor_val: int) -> tuple[int, list[dict]]: await asyncio.sleep(_EVENT_POLL_SECONDS) except WebSocketDisconnect: return + except asyncio.CancelledError: + # Normal shutdown path: dashboard process exit (Ctrl-C) cancels the + # websocket task while it is sleeping in the poll loop. + # CancelledError is a BaseException in 3.8+ so the bare Exception + # handler below would not catch it; without this clause Uvicorn + # surfaces the cancellation as an application traceback. Quiet it. + return except Exception as exc: # defensive: never crash the dashboard worker log.warning("Kanban event stream error: %s", exc) try: diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 8ea4a4bedcca..c9cbfcad4b59 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -27,9 +27,16 @@ import atexit import json import logging +import mimetypes import os +import tempfile import threading +import uuid +import zipfile +from pathlib import Path from typing import Any, Dict, List, Optional +from urllib.parse import urlparse +from urllib.request import url2pathname from agent.memory_provider import MemoryProvider from tools.registry import tool_error @@ -38,6 +45,7 @@ _DEFAULT_ENDPOINT = "http://127.0.0.1:1933" _TIMEOUT = 30.0 +_REMOTE_RESOURCE_PREFIXES = ("http://", "https://", "git@", "ssh://", "git://") # --------------------------------------------------------------------------- @@ -92,38 +100,94 @@ def __init__(self, endpoint: str, api_key: str = "", raise ImportError("httpx is required for OpenViking: pip install httpx") def _headers(self) -> dict: + # Only send tenant headers when the user actually configured them. + # Legacy installs had account/user defaulted to the literal string + # "default" — treat that as unset so authenticated remote servers + # that derive tenancy from the Bearer key aren't overridden by a + # bogus tenant value. h = { "Content-Type": "application/json", - "X-OpenViking-Account": self._account, - "X-OpenViking-User": self._user, "X-OpenViking-Agent": self._agent, } + if self._account and self._account != "default": + h["X-OpenViking-Account"] = self._account + if self._user and self._user != "default": + h["X-OpenViking-User"] = self._user if self._api_key: h["X-API-Key"] = self._api_key + h["Authorization"] = "Bearer " + self._api_key return h def _url(self, path: str) -> str: return f"{self._endpoint}{path}" + def _multipart_headers(self) -> dict: + headers = self._headers() + headers.pop("Content-Type", None) + return headers + + def _parse_response(self, resp) -> dict: + try: + data = resp.json() + except Exception: + data = None + + if resp.status_code >= 400: + if isinstance(data, dict): + error = data.get("error") + if isinstance(error, dict): + code = error.get("code", "HTTP_ERROR") + message = error.get("message", resp.text) + raise RuntimeError(f"{code}: {message}") + if data.get("status") == "error": + raise RuntimeError(str(data)) + resp.raise_for_status() + + if isinstance(data, dict) and data.get("status") == "error": + error = data.get("error") + if isinstance(error, dict): + code = error.get("code", "OPENVIKING_ERROR") + message = error.get("message", "") + raise RuntimeError(f"{code}: {message}") + raise RuntimeError(str(data)) + + if data is None: + return {} + return data + def get(self, path: str, **kwargs) -> dict: resp = self._httpx.get( self._url(path), headers=self._headers(), timeout=_TIMEOUT, **kwargs ) - resp.raise_for_status() - return resp.json() + return self._parse_response(resp) def post(self, path: str, payload: dict = None, **kwargs) -> dict: resp = self._httpx.post( self._url(path), json=payload or {}, headers=self._headers(), timeout=_TIMEOUT, **kwargs ) - resp.raise_for_status() - return resp.json() + return self._parse_response(resp) + + def upload_temp_file(self, file_path: Path) -> str: + mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream" + with file_path.open("rb") as f: + resp = self._httpx.post( + self._url("/api/v1/resources/temp_upload"), + files={"file": (file_path.name, f, mime_type)}, + headers=self._multipart_headers(), + timeout=_TIMEOUT, + ) + data = self._parse_response(resp) + result = data.get("result", {}) + temp_file_id = result.get("temp_file_id", "") + if not temp_file_id: + raise RuntimeError("OpenViking temp upload did not return temp_file_id") + return temp_file_id def health(self) -> bool: try: resp = self._httpx.get( - self._url("/health"), timeout=3.0 + self._url("/health"), headers=self._headers(), timeout=3.0 ) return resp.status_code == 200 except Exception: @@ -230,24 +294,90 @@ def health(self) -> bool: ADD_RESOURCE_SCHEMA = { "name": "viking_add_resource", "description": ( - "Add a URL or document to the OpenViking knowledge base. " - "Supports web pages, GitHub repos, PDFs, markdown, code files. " + "Add a remote URL or local file/directory to the OpenViking knowledge base. " + "Remote resources must be public http(s), git, or ssh URLs. " + "Local files are uploaded first using OpenViking temp_upload. " "The system automatically parses, indexes, and generates summaries." ), "parameters": { "type": "object", "properties": { - "url": {"type": "string", "description": "URL or path of the resource to add."}, + "url": {"type": "string", "description": "Remote URL or local file/directory path to add."}, "reason": { "type": "string", "description": "Why this resource is relevant (improves search).", }, + "to": { + "type": "string", + "description": "Optional target viking:// URI for the resource.", + }, + "parent": { + "type": "string", + "description": "Optional parent viking:// URI. Cannot be used with to.", + }, + "instruction": { + "type": "string", + "description": "Optional processing instruction for semantic extraction.", + }, + "wait": { + "type": "boolean", + "description": "Whether to wait for processing to complete.", + }, + "timeout": { + "type": "number", + "description": "Timeout in seconds when wait is true.", + }, }, "required": ["url"], }, } +def _zip_directory(dir_path: Path) -> Path: + """Create a temporary zip file containing a directory tree.""" + zip_path = Path(tempfile.gettempdir()) / f"openviking_upload_{uuid.uuid4().hex}.zip" + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: + for file_path in dir_path.rglob("*"): + if file_path.is_file(): + arcname = str(file_path.relative_to(dir_path)).replace("\\", "/") + zipf.write(file_path, arcname=arcname) + return zip_path + + +def _is_windows_absolute_path(value: str) -> bool: + return ( + len(value) >= 3 + and value[0].isalpha() + and value[1] == ":" + and value[2] in ("/", "\\") + ) + + +def _is_remote_resource_source(value: str) -> bool: + return value.startswith(_REMOTE_RESOURCE_PREFIXES) + + +def _is_local_path_reference(value: str) -> bool: + if not value or "\n" in value or "\r" in value: + return False + if _is_remote_resource_source(value): + return False + if _is_windows_absolute_path(value): + return True + return ( + value.startswith(("/", "./", "../", "~/", ".\\", "..\\", "~\\")) + or "/" in value + or "\\" in value + ) + + +def _path_from_file_uri(uri: str) -> Path | str: + parsed = urlparse(uri) + if parsed.netloc not in ("", "localhost"): + return f"Unsupported non-local file URI: {uri}" + return Path(url2pathname(parsed.path)).expanduser() + + # --------------------------------------------------------------------------- # MemoryProvider implementation # --------------------------------------------------------------------------- @@ -744,12 +874,52 @@ def _tool_add_resource(self, args: dict) -> str: if not url: return tool_error("url is required") - payload: Dict[str, Any] = {"path": url} - if args.get("reason"): - payload["reason"] = args["reason"] + if args.get("to") and args.get("parent"): + return tool_error("Cannot specify both 'to' and 'parent'") + + payload: Dict[str, Any] = {} + for key in ("reason", "to", "parent", "instruction", "wait", "timeout"): + if key in args and args[key] not in (None, ""): + payload[key] = args[key] + + parsed_url = urlparse(url) + if _is_remote_resource_source(url): + source_path = None + elif parsed_url.scheme == "file": + source_path = _path_from_file_uri(url) + if isinstance(source_path, str): + return tool_error(source_path) + elif parsed_url.scheme and not _is_windows_absolute_path(url): + source_path = None + else: + source_path = Path(url).expanduser() - resp = self._client.post("/api/v1/resources", payload) - result = resp.get("result", {}) + cleanup_path: Optional[Path] = None + try: + if source_path is not None: + if source_path.exists(): + if source_path.is_dir(): + payload["source_name"] = source_path.name + cleanup_path = _zip_directory(source_path) + upload_path = cleanup_path + elif source_path.is_file(): + payload["source_name"] = source_path.name + upload_path = source_path + else: + return tool_error(f"Unsupported local resource path: {url}") + payload["temp_file_id"] = self._client.upload_temp_file(upload_path) + elif _is_local_path_reference(url): + return tool_error(f"Local resource path does not exist: {url}") + else: + payload["path"] = url + else: + payload["path"] = url + + resp = self._client.post("/api/v1/resources", payload) + result = resp.get("result", {}) + finally: + if cleanup_path: + cleanup_path.unlink(missing_ok=True) return json.dumps({ "status": "added", diff --git a/pyproject.toml b/pyproject.toml index 6c1cd9d45903..7717e167ac68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,19 +159,8 @@ unknown-argument = "warn" redundant-cast = "ignore" [tool.ty.src] -exclude = ["**"] - -[[tool.ty.overrides]] -include = ["**"] - -[tool.ty.overrides.rules] -unresolved-import = "ignore" -invalid-method-override = "ignore" -invalid-assignment = "ignore" -not-iterable = "ignore" +exclude = ["tinker-atropos"] [tool.ruff] -exclude = ["*"] - -[tool.uv] -exclude-newer = "7 days" +exclude = ["tinker-atropos"] +select = [] # disable all lints for now, until we've wrangled typechecks a bit more :3 diff --git a/run_agent.py b/run_agent.py index d8f5d2376d82..9db4b69cf5c2 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3793,11 +3793,24 @@ def _persist_session(self, messages: List[Dict], conversation_history: List[Dict Ensures conversations are never lost, even on errors or early returns. """ + self._drop_trailing_empty_response_scaffolding(messages) self._apply_persist_user_message_override(messages) self._session_messages = messages self._save_session_log(messages) self._flush_messages_to_session_db(messages, conversation_history) + def _drop_trailing_empty_response_scaffolding(self, messages: List[Dict]) -> None: + """Remove private empty-response retry/failure scaffolding from transcript tails.""" + while ( + messages + and isinstance(messages[-1], dict) + and ( + messages[-1].get("_empty_recovery_synthetic") + or messages[-1].get("_empty_terminal_sentinel") + ) + ): + messages.pop() + def _flush_messages_to_session_db(self, messages: List[Dict], conversation_history: List[Dict] = None): """Persist any un-flushed messages to the SQLite session store. @@ -13712,6 +13725,7 @@ def _stop_spinner(): # APIs reject as an invalid sequence. _nudge_msg = self._build_assistant_message(assistant_message, finish_reason) _nudge_msg["content"] = "(empty)" + _nudge_msg["_empty_recovery_synthetic"] = True messages.append(_nudge_msg) messages.append({ "role": "user", @@ -13720,6 +13734,7 @@ def _stop_spinner(): "empty response. Please process the tool " "results above and continue with the task." ), + "_empty_recovery_synthetic": True, }) continue @@ -13824,8 +13839,15 @@ def _stop_spinner(): # "(empty)" terminal. _turn_exit_reason = "empty_response_exhausted" reasoning_text = self._extract_reasoning(assistant_message) + self._drop_trailing_empty_response_scaffolding(messages) assistant_msg = self._build_assistant_message(assistant_message, finish_reason) assistant_msg["content"] = "(empty)" + # This is a user-facing failure sentinel for the gateway, + # not real assistant content. Persisting it makes later + # "continue" turns replay assistant("(empty)") as if it + # were a meaningful model response, which can keep long + # tool-heavy sessions stuck in empty-response loops. + assistant_msg["_empty_terminal_sentinel"] = True messages.append(assistant_msg) if reasoning_text: @@ -13900,14 +13922,18 @@ def _stop_spinner(): final_msg = self._build_assistant_message(assistant_message, finish_reason) - # Pop thinking-only prefill message(s) before appending - # the final response. This avoids consecutive assistant - # messages which break strict-alternation providers - # (Anthropic Messages API) and keeps history clean. + # Pop thinking-only prefill and empty-response retry + # scaffolding before appending the final response. These + # internal turns are only for the next API retry and should + # not become durable transcript context. while ( messages and isinstance(messages[-1], dict) - and messages[-1].get("_thinking_prefill") + and ( + messages[-1].get("_thinking_prefill") + or messages[-1].get("_empty_recovery_synthetic") + or messages[-1].get("_empty_terminal_sentinel") + ) ): messages.pop() @@ -13998,7 +14024,11 @@ def _stop_spinner(): # Clean up VM and browser for this task after conversation completes self._cleanup_task_resources(effective_task_id) - # Persist session to both JSON log and SQLite + # Persist session to both JSON log and SQLite only after private retry + # scaffolding has been removed. Otherwise a later user "continue" turn + # can replay assistant("(empty)") / recovery nudges and fall into the + # same empty-response loop again. + self._drop_trailing_empty_response_scaffolding(messages) self._persist_session(messages, conversation_history) # ── Turn-exit diagnostic log ───────────────────────────────────── @@ -14045,6 +14075,27 @@ def _stop_spinner(): else: logger.info(_diag_msg, *_diag_args) + # Plugin hook: transform_llm_output + # Fired once per turn after the tool-calling loop completes. + # Plugins can transform the LLM's output text before it's returned. + # First hook to return a string wins; None/empty return leaves text unchanged. + if final_response and not interrupted: + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _transform_results = _invoke_hook( + "transform_llm_output", + response_text=final_response, + session_id=self.session_id or "", + model=self.model, + platform=getattr(self, "platform", None) or "", + ) + for _hook_result in _transform_results: + if isinstance(_hook_result, str) and _hook_result: + final_response = _hook_result + break # First non-empty string wins + except Exception as exc: + logger.warning("transform_llm_output hook failed: %s", exc) + # Plugin hook: post_llm_call # Fired once per turn after the tool-calling loop completes. # Plugins can use this to persist conversation data (e.g. sync diff --git a/scripts/lint_diff.py b/scripts/lint_diff.py new file mode 100755 index 000000000000..a84156fc8e2d --- /dev/null +++ b/scripts/lint_diff.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Diff ruff + ty diagnostic reports between two git refs. + +Produces a Markdown summary suitable for `$GITHUB_STEP_SUMMARY` and for PR +comments. Compares issues by a stable key (file, rule, line) so line-only +shifts from unrelated edits are treated as the same issue. + +Usage: + lint_diff.py \\ + --base-ruff base/ruff.json --head-ruff head/ruff.json \\ + --base-ty base/ty.json --head-ty head/ty.json \\ + [--base-ref origin/main] [--head-ref HEAD] + +Any of the four --{base,head}-{ruff,ty} files may be missing or empty; in that +case the tool treats it as "0 diagnostics" (e.g. if base/main doesn't have the +config yet, or a tool crashed). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections import Counter +from pathlib import Path + + +def _load_json(path: Path | None) -> list[dict]: + if path is None or not path.exists() or path.stat().st_size == 0: + return [] + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + print(f"warning: could not parse {path}: {exc}", file=sys.stderr) + return [] + if not isinstance(data, list): + return [] + return data + + +def _normalize_ruff(entries: list[dict]) -> list[dict]: + """Ruff JSON: {code, filename, location.row, message}.""" + out: list[dict] = [] + for e in entries: + code = e.get("code") or "unknown" + # ruff emits absolute paths; relativize to repo root if possible + filename = e.get("filename", "") + try: + filename = os.path.relpath(filename) + except ValueError: + pass + line = (e.get("location") or {}).get("row", 0) + out.append( + { + "tool": "ruff", + "rule": code, + "path": filename, + "line": line, + "message": e.get("message", ""), + } + ) + return out + + +def _normalize_ty(entries: list[dict]) -> list[dict]: + """ty gitlab JSON: {check_name, location.path, location.positions.begin.line, description}.""" + out: list[dict] = [] + for e in entries: + loc = e.get("location") or {} + begin = (loc.get("positions") or {}).get("begin") or {} + out.append( + { + "tool": "ty", + "rule": e.get("check_name", "unknown"), + "path": loc.get("path", ""), + "line": begin.get("line", 0), + "message": e.get("description", ""), + } + ) + return out + + +def _key(d: dict) -> tuple[str, str, str]: + """Stable diagnostic identity across commits: (path, rule, message).""" + # Intentionally omit line so unrelated edits above an issue don't flag it + # as "new". Same file + same rule + same message = same issue. + return (d["path"], d["rule"], d["message"]) + + +def _diff(base: list[dict], head: list[dict]) -> tuple[list[dict], list[dict], list[dict]]: + base_map = {_key(d): d for d in base} + head_map = {_key(d): d for d in head} + base_keys = set(base_map) + head_keys = set(head_map) + new_keys = head_keys - base_keys + fixed_keys = base_keys - head_keys + unchanged_keys = base_keys & head_keys + # Return head entries for new (current line numbers), base entries for fixed + return ( + [head_map[k] for k in new_keys], + [base_map[k] for k in fixed_keys], + [head_map[k] for k in unchanged_keys], + ) + + +def _rule_counts(entries: list[dict]) -> list[tuple[str, int]]: + return Counter(e["rule"] for e in entries).most_common() + + +def _section(title: str, entries: list[dict], limit: int = 25) -> str: + if not entries: + return f"**{title}:** none\n" + lines = [f"**{title} ({len(entries)}):**\n"] + # Group by rule for readability + counts = _rule_counts(entries) + lines.append("| Rule | Count |") + lines.append("| --- | ---: |") + for rule, count in counts[:15]: + lines.append(f"| `{rule}` | {count} |") + if len(counts) > 15: + lines.append(f"| _+{len(counts) - 15} more rules_ | |") + lines.append("") + lines.append("
First entries\n") + lines.append("```") + for e in entries[:limit]: + lines.append(f"{e['path']}:{e['line']}: [{e['rule']}] {e['message']}") + if len(entries) > limit: + lines.append(f"... and {len(entries) - limit} more") + lines.append("```") + lines.append("
\n") + return "\n".join(lines) + + +def _tool_report( + tool_name: str, + base: list[dict], + head: list[dict], + base_available: bool, +) -> str: + new, fixed, unchanged = _diff(base, head) + delta = len(head) - len(base) + delta_str = f"+{delta}" if delta > 0 else str(delta) + emoji = "🆕" if delta > 0 else ("✅" if delta < 0 else "➖") + + lines = [f"## {tool_name}\n"] + if not base_available: + lines.append( + "_Base report unavailable (likely main has no config for this tool yet); " + "treating all head diagnostics as new._\n" + ) + lines.append( + f"**Total:** {len(head)} on HEAD, {len(base)} on base " + f"({emoji} {delta_str})\n" + ) + lines.append(_section("🆕 New issues", new)) + lines.append(_section("✅ Fixed issues", fixed)) + lines.append( + f"**Unchanged:** {len(unchanged)} pre-existing issues carried over.\n" + ) + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--base-ruff", type=Path, required=True) + ap.add_argument("--head-ruff", type=Path, required=True) + ap.add_argument("--base-ty", type=Path, required=True) + ap.add_argument("--head-ty", type=Path, required=True) + ap.add_argument("--base-ref", default="base") + ap.add_argument("--head-ref", default="HEAD") + ap.add_argument( + "--output", type=Path, help="Write summary to this file instead of stdout" + ) + args = ap.parse_args() + + base_ruff_raw = _load_json(args.base_ruff) + head_ruff_raw = _load_json(args.head_ruff) + base_ty_raw = _load_json(args.base_ty) + head_ty_raw = _load_json(args.head_ty) + + base_ruff = _normalize_ruff(base_ruff_raw) + head_ruff = _normalize_ruff(head_ruff_raw) + base_ty = _normalize_ty(base_ty_raw) + head_ty = _normalize_ty(head_ty_raw) + + base_ruff_avail = args.base_ruff.exists() and args.base_ruff.stat().st_size > 0 + base_ty_avail = args.base_ty.exists() and args.base_ty.stat().st_size > 0 + + buf: list[str] = [] + buf.append(f"# 🔎 Lint report: `{args.head_ref}` vs `{args.base_ref}`\n") + buf.append(_tool_report("ruff", base_ruff, head_ruff, base_ruff_avail)) + buf.append(_tool_report("ty (type checker)", base_ty, head_ty, base_ty_avail)) + buf.append( + "_Diagnostics are surfaced as warnings — this check never fails the build._\n" + ) + + summary = "\n".join(buf) + if args.output: + args.output.write_text(summary) + else: + print(summary) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release.py b/scripts/release.py index a136b49441b2..6320b23a3920 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -41,17 +41,27 @@ AUTHOR_MAP = { # teknium (multiple emails) "teknium1@gmail.com": "teknium1", + "0x.badfriend@gmail.com": "discodirector", + "altriatree@gmail.com": "TruaShamu", "m@mobrienv.dev": "mikeyobrien", "qiyin.zuo@pcitc.com": "qiyin-code", "oleksii.lisikh@gmail.com": "olisikh", "leone.parise@gmail.com": "leoneparise", "teknium@nousresearch.com": "teknium1", + "piyushvp1@gmail.com": "thelumiereguy", + "harish.kukreja@gmail.com": "counterposition", + "cleo@edaphic.xyz": "curiouscleo", "127238744+teknium1@users.noreply.github.com": "teknium1", + "128259593+Gutslabs@users.noreply.github.com": "Gutslabs", + "50326054+nocturnum91@users.noreply.github.com": "nocturnum91", "159539633+MottledShadow@users.noreply.github.com": "MottledShadow", "aludwin+gh@gmail.com": "adamludwin", "ngusev@astralinux.ru": "NikolayGusev-astra", "liuguangyong201@hellobike.com": "liuguangyong93", "2093036+exiao@users.noreply.github.com": "exiao", + "thunderggnn@gmail.com": "ggnnggez", + "haozhe4547@gmail.com": "ehz0ah", + "kevyan1998@gmail.com": "kyan12", "rylen.anil@gmail.com": "rylena", "godnanijatin@gmail.com": "jatingodnani", "252811164+adybag14-cyber@users.noreply.github.com": "adybag14-cyber", @@ -67,6 +77,10 @@ "wysie@users.noreply.github.com": "wysie", "jkausel@gmail.com": "jkausel-ai", "e.silacandmr@gmail.com": "Es1la", + "51599529+stephen0110@users.noreply.github.com": "stephen0110", + "265632032+sonic-netizen@users.noreply.github.com": "sonic-netizen", + "82531659+mwnickerson@users.noreply.github.com": "mwnickerson", + "sandrohub013@gmail.com": "SandroHub013", "154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43", "zjtan1@gmail.com": "zeejaytan", "asslaenn5@gmail.com": "Aslaaen", @@ -86,6 +100,7 @@ "happy5318@users.noreply.github.com": "happy5318", "chengoak@users.noreply.github.com": "chengoak", "mrhanoi@outlook.com": "qxxaa", + "guillaume.meyer@outlook.com": "guillaumemeyer", "emelyanenko.kirill@gmail.com": "EmelyanenkoK", "lazycat.manatee@gmail.com": "manateelazycat", "bzarnitz13@gmail.com": "Beandon13", @@ -115,6 +130,8 @@ "heathley@Heathley-MacBook-Air.local": "heathley", "vlad19@gmail.com": "dandaka", "adamrummer@gmail.com": "cyclingwithelephants", + # Temporary tool-progress cleanup salvage (May 2026) + "Mrcharlesiv@gmail.com": "mrcharlesiv", "nbot@liizfq.top": "liizfq", "274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi", "dejie.guo@gmail.com": "JayGwod", @@ -248,6 +265,7 @@ "36056348+sirEven@users.noreply.github.com": "sirEven", "70424851+insecurejezza@users.noreply.github.com": "insecurejezza", "jezzahehn@gmail.com": "JezzaHehn", + "barnacleboy.jezzahehn@agentmail.to": "JezzaHehn", "254021826+dodo-reach@users.noreply.github.com": "dodo-reach", "259807879+Bartok9@users.noreply.github.com": "Bartok9", "270082434+crayfish-ai@users.noreply.github.com": "crayfish-ai", @@ -437,6 +455,10 @@ "xowiekk@gmail.com": "Xowiek", "1243352777@qq.com": "zons-zhaozhy", "e.silacandmr@gmail.com": "Es1la", + "51599529+stephen0110@users.noreply.github.com": "stephen0110", + "265632032+sonic-netizen@users.noreply.github.com": "sonic-netizen", + "82531659+mwnickerson@users.noreply.github.com": "mwnickerson", + "sandrohub013@gmail.com": "SandroHub013", "h3057183414@gmail.com": "CoreyNoDream", "franksong2702@gmail.com": "franksong2702", "673088860@qq.com": "ambition0802", @@ -770,6 +792,7 @@ "steven_chanin@alum.mit.edu": "stevenchanin", "fiver@example.com": "halmisen", "mayq0422@gmail.com": "yuqianma", + "yuqian@zmetasoft.com": "yuqianma", "scott@bubble.local": "bassings", "highland0971@users.noreply.github.com": "highland0971", "sudolewis@gmail.com": "lewislulu", @@ -841,6 +864,14 @@ "charliekerfoot@gmail.com": "CharlieKerfoot", # PR #18951 # Debug share upload-time redaction (May 2026) "dhuysamen@gmail.com": "GodsBoy", # PR #19318 + "mrcoferland@gmail.com": "mrcoferland", # PR #19023 + "chenlinfeng@ruije.com.cn": "noOne-list", # PR #19050 + "briansu@Mac-mini.attlocal.net": "likejudy", # PR #19052 + "leosma@gmail.com": "leon7609", # PR #19069 + "nouseman666@gmail.com": "nouseman666", # PR #19088 + "ginwu05@gmail.com": "GinWU05", # PR #19093 + "shashwatgokhe2@gmail.com": "shashwatgokhe", # PR #19196 + "lxl694522264@gmail.com": "EvilDrag0n", # PR #20651 } diff --git a/skills/productivity/linear/SKILL.md b/skills/productivity/linear/SKILL.md index b7c23ca64120..88db1167e4c4 100644 --- a/skills/productivity/linear/SKILL.md +++ b/skills/productivity/linear/SKILL.md @@ -18,7 +18,7 @@ Manage Linear issues, projects, and teams directly via the GraphQL API using `cu ## Setup -1. Get a personal API key from **Linear Settings > API > Personal API keys** +1. Get a personal API key from **Linear Settings > Account > Security & access > Personal API keys** (URL: https://linear.app/settings/account/security). Note: the org-level *Settings > API* page only shows OAuth apps and workspace-member keys, not personal keys. 2. Set `LINEAR_API_KEY` in your environment (via `hermes setup` or your env config) ## API Basics @@ -36,6 +36,24 @@ curl -s -X POST https://api.linear.app/graphql \ -d '{"query": "{ viewer { id name } }"}' | python3 -m json.tool ``` +## Python helper script (ergonomic alternative) + +For faster one-liners that don't need hand-written GraphQL, this skill ships a stdlib Python CLI at `scripts/linear_api.py`. Zero dependencies. Same auth (reads `LINEAR_API_KEY`). + +```bash +SCRIPT=$(dirname "$(find ~/.hermes -path '*skills/productivity/linear/scripts/linear_api.py' 2>/dev/null | head -1)")/linear_api.py + +python3 "$SCRIPT" whoami +python3 "$SCRIPT" list-teams +python3 "$SCRIPT" get-issue ENG-42 +python3 "$SCRIPT" get-document 38359beef67c # fetch a doc by slugId from the URL +python3 "$SCRIPT" raw 'query { viewer { name } }' +``` + +All subcommands: `whoami`, `list-teams`, `list-projects`, `list-states`, `list-issues`, `get-issue`, `search-issues`, `create-issue`, `update-issue`, `update-status`, `add-comment`, `list-documents`, `get-document`, `search-documents`, `raw`. Run with `--help` for flags. + +Use the script when: you want a quick answer without crafting GraphQL. Use curl when: you need a query the script doesn't wrap, or you want to compose filters inline. + ## Workflow States Linear uses `WorkflowState` objects with a `type` field. **6 state types:** @@ -245,6 +263,70 @@ curl -s -X POST https://api.linear.app/graphql \ }' | python3 -m json.tool ``` +## Documents + +Linear **Documents** are prose docs (RFCs, specs, notes) stored alongside issues. They have their own `documents` root query and `document(id:)` single-fetch. + +### Document URLs and `slugId` + +Document URLs look like: +``` +https://linear.app//document/- +``` + +The trailing hex segment is the `slugId`. Example: `https://linear.app/nousresearch/document/rfc-hermes-permission-gateway-discord-38359beef67c` → `slugId` is `38359beef67c`. + +**Important schema detail:** the Markdown body is in the `content` field. The ProseMirror JSON is in `contentState` (not `contentData` — that field does not exist and the API returns 400). + +### Fetch a document by slugId + +`document(id:)` only accepts UUIDs. To fetch by the URL's hex slug, filter the collection: + +```bash +curl -s -X POST https://api.linear.app/graphql \ + -H "Authorization: $LINEAR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query": "query($s: String!) { documents(filter: { slugId: { eq: $s } }, first: 1) { nodes { id title content contentState slugId url creator { name } project { name } updatedAt } } }", "variables": {"s": "38359beef67c"}}' \ + | python3 -m json.tool +``` + +Or via the Python helper: +```bash +python3 scripts/linear_api.py get-document 38359beef67c +``` + +### Fetch a document by UUID + +```bash +curl -s -X POST https://api.linear.app/graphql \ + -H "Authorization: $LINEAR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query": "{ document(id: \"11700cff-b514-4db3-afcc-3ed1afacba1c\") { title content url } }"}' \ + | python3 -m json.tool +``` + +### List recent documents + +```bash +curl -s -X POST https://api.linear.app/graphql \ + -H "Authorization: $LINEAR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query": "{ documents(first: 25, orderBy: updatedAt) { nodes { id title slugId url updatedAt project { name } } } }"}' \ + | python3 -m json.tool +``` + +### Search documents by title + +Linear's schema has no `searchDocuments` root. Use a title-substring filter instead: + +```bash +curl -s -X POST https://api.linear.app/graphql \ + -H "Authorization: $LINEAR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query": "{ documents(filter: { title: { containsIgnoreCase: \"RFC\" } }, first: 25) { nodes { title slugId url } } }"}' \ + | python3 -m json.tool +``` + ## Pagination Linear uses Relay-style cursor pagination: diff --git a/skills/productivity/linear/scripts/linear_api.py b/skills/productivity/linear/scripts/linear_api.py new file mode 100644 index 000000000000..cb8c5d846dd0 --- /dev/null +++ b/skills/productivity/linear/scripts/linear_api.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +"""Linear GraphQL API CLI — zero dependencies, stdlib only. + +Usage: + linear_api.py [args...] + +Commands: + whoami Show authenticated user + list-teams List all teams + list-projects [--team KEY] List projects (optionally filter by team) + list-states [--team KEY] List workflow states + list-issues [filters] List issues + --team KEY Filter by team key (e.g. ENG) + --status NAME Filter by workflow state name + --assignee NAME Filter by assignee name (exact) + --label NAME Filter by label name + --limit N Max results (default: 25) + get-issue Full issue details (e.g. ENG-42) + search-issues Full-text search across issues + create-issue [options] Create a new issue + --title TITLE Required + --team KEY Required + --description DESC + --priority 0-4 0=none, 1=urgent, 4=low + --label NAME + --assignee NAME + --parent IDENTIFIER Parent issue ID for sub-issues + update-issue [options] Update existing issue (same options as create) + update-status Move issue to workflow state (by state name) + add-comment Add comment to issue + + list-documents [--limit N] List documents (docs, not issues) + get-document Fetch a document by slugId (from URL) or UUID + search-documents Search documents by title + + raw [variables_json] Run an arbitrary GraphQL query + Use --vars '{"key":"value"}' for variables + +Auth: + Set LINEAR_API_KEY environment variable (from Linear Settings -> API). + Uses the personal API key header format: `Authorization: ` (no Bearer prefix). + +Output: + JSON to stdout. Errors to stderr with non-zero exit code. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from typing import Any + +API_URL = "https://api.linear.app/graphql" + + +def _get_key() -> str: + key = os.environ.get("LINEAR_API_KEY", "").strip() + if not key: + sys.stderr.write( + "ERROR: LINEAR_API_KEY not set.\n" + "Create one at https://linear.app/settings/api and export it,\n" + "or add `LINEAR_API_KEY=lin_api_...` to ~/.hermes/.env\n" + ) + sys.exit(2) + return key + + +def gql(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + """Execute a GraphQL query against Linear. Raises on HTTP error or GraphQL errors.""" + key = _get_key() + payload = {"query": query} + if variables: + payload["variables"] = variables + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + API_URL, + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": key, # Personal API key — NO `Bearer` prefix + "User-Agent": "hermes-agent-linear-skill/1.0", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + body = resp.read().decode("utf-8") + except urllib.error.HTTPError as e: + sys.stderr.write(f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')}\n") + sys.exit(1) + except urllib.error.URLError as e: + sys.stderr.write(f"Network error: {e}\n") + sys.exit(1) + + result = json.loads(body) + if "errors" in result and result["errors"]: + sys.stderr.write(f"GraphQL errors: {json.dumps(result['errors'], indent=2)}\n") + # Still return data if partial success; let caller decide + if not result.get("data"): + sys.exit(1) + return result.get("data", {}) or {} + + +def emit(obj: Any) -> None: + print(json.dumps(obj, indent=2, default=str)) + + +# ---------- Commands ---------- + +def cmd_whoami(_args: argparse.Namespace) -> None: + q = "query { viewer { id name email displayName } }" + emit(gql(q).get("viewer")) + + +def cmd_list_teams(_args: argparse.Namespace) -> None: + q = "query { teams(first: 100) { nodes { id key name description } } }" + emit(gql(q).get("teams", {}).get("nodes", [])) + + +def _resolve_team_id(key_or_name: str) -> str | None: + """Map a team key (ENG) or name to UUID.""" + q = "query { teams(first: 100) { nodes { id key name } } }" + teams = gql(q).get("teams", {}).get("nodes", []) + kl = key_or_name.lower() + for t in teams: + if t["key"].lower() == kl or t["name"].lower() == kl: + return t["id"] + return None + + +def cmd_list_projects(args: argparse.Namespace) -> None: + if args.team: + tid = _resolve_team_id(args.team) + if not tid: + sys.stderr.write(f"Team not found: {args.team}\n") + sys.exit(1) + q = """query($id: String!) { + team(id: $id) { projects(first: 100) { nodes { id name description state } } } + }""" + data = gql(q, {"id": tid}) + emit(data.get("team", {}).get("projects", {}).get("nodes", [])) + else: + q = "query { projects(first: 100) { nodes { id name description state } } }" + emit(gql(q).get("projects", {}).get("nodes", [])) + + +def cmd_list_states(args: argparse.Namespace) -> None: + if args.team: + tid = _resolve_team_id(args.team) + if not tid: + sys.stderr.write(f"Team not found: {args.team}\n") + sys.exit(1) + q = """query($id: String!) { + team(id: $id) { states(first: 100) { nodes { id name type color } } } + }""" + emit(gql(q, {"id": tid}).get("team", {}).get("states", {}).get("nodes", [])) + else: + q = "query { workflowStates(first: 200) { nodes { id name type team { key } } } }" + emit(gql(q).get("workflowStates", {}).get("nodes", [])) + + +def cmd_list_issues(args: argparse.Namespace) -> None: + filt: dict[str, Any] = {} + if args.team: + filt["team"] = {"key": {"eq": args.team}} + if args.status: + filt["state"] = {"name": {"eq": args.status}} + if args.assignee: + filt["assignee"] = {"name": {"eq": args.assignee}} + if args.label: + filt["labels"] = {"name": {"eq": args.label}} + + q = """query($filter: IssueFilter, $first: Int!) { + issues(filter: $filter, first: $first, orderBy: updatedAt) { + nodes { + id identifier title + state { name } priority + assignee { name } + team { key } + updatedAt url + } + } + }""" + data = gql(q, {"filter": filt or None, "first": args.limit}) + emit(data.get("issues", {}).get("nodes", [])) + + +def cmd_get_issue(args: argparse.Namespace) -> None: + q = """query($id: String!) { + issue(id: $id) { + id identifier title description + state { name type } + priority priorityLabel + assignee { name email } + creator { name } + team { key name } + project { name } + labels { nodes { name } } + parent { identifier title } + children { nodes { identifier title state { name } } } + comments { nodes { user { name } body createdAt } } + createdAt updatedAt url + } + }""" + emit(gql(q, {"id": args.identifier}).get("issue")) + + +def cmd_search_issues(args: argparse.Namespace) -> None: + q = """query($term: String!, $first: Int!) { + searchIssues(term: $term, first: $first) { + nodes { id identifier title state { name } url } + } + }""" + emit(gql(q, {"term": args.query, "first": args.limit}).get("searchIssues", {}).get("nodes", [])) + + +def cmd_create_issue(args: argparse.Namespace) -> None: + tid = _resolve_team_id(args.team) + if not tid: + sys.stderr.write(f"Team not found: {args.team}\n") + sys.exit(1) + inp: dict[str, Any] = {"title": args.title, "teamId": tid} + if args.description: + inp["description"] = args.description + if args.priority is not None: + inp["priority"] = args.priority + if args.parent: + inp["parentId"] = args.parent + # TODO: label + assignee name->id lookup (omitted for v1 brevity) + + q = """mutation($input: IssueCreateInput!) { + issueCreate(input: $input) { + success issue { id identifier title url } + } + }""" + emit(gql(q, {"input": inp}).get("issueCreate")) + + +def cmd_update_issue(args: argparse.Namespace) -> None: + inp: dict[str, Any] = {} + if args.title: + inp["title"] = args.title + if args.description: + inp["description"] = args.description + if args.priority is not None: + inp["priority"] = args.priority + if not inp: + sys.stderr.write("No update fields provided.\n") + sys.exit(1) + q = """mutation($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success issue { identifier title url } + } + }""" + emit(gql(q, {"id": args.identifier, "input": inp}).get("issueUpdate")) + + +def cmd_update_status(args: argparse.Namespace) -> None: + # Resolve state name -> id within the issue's team + get_q = """query($id: String!) { + issue(id: $id) { team { id states(first: 100) { nodes { id name } } } } + }""" + issue = gql(get_q, {"id": args.identifier}).get("issue") + if not issue: + sys.stderr.write(f"Issue not found: {args.identifier}\n") + sys.exit(1) + sl = args.state.lower() + match = next((s for s in issue["team"]["states"]["nodes"] if s["name"].lower() == sl), None) + if not match: + sys.stderr.write( + f"State '{args.state}' not found. Available: " + f"{[s['name'] for s in issue['team']['states']['nodes']]}\n" + ) + sys.exit(1) + + q = """mutation($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { + success issue { identifier state { name } url } + } + }""" + emit(gql(q, {"id": args.identifier, "stateId": match["id"]}).get("issueUpdate")) + + +def cmd_add_comment(args: argparse.Namespace) -> None: + q = """mutation($input: CommentCreateInput!) { + commentCreate(input: $input) { + success comment { id body createdAt } + } + }""" + emit(gql(q, {"input": {"issueId": args.identifier, "body": args.body}}).get("commentCreate")) + + +# ---- Documents ---- + +def cmd_list_documents(args: argparse.Namespace) -> None: + q = """query($first: Int!) { + documents(first: $first, orderBy: updatedAt) { + nodes { id title slugId updatedAt url project { name } creator { name } } + } + }""" + emit(gql(q, {"first": args.limit}).get("documents", {}).get("nodes", [])) + + +def cmd_get_document(args: argparse.Namespace) -> None: + """Fetch a document by slugId (from URL) OR full UUID. + + Linear document URLs look like: + https://linear.app//document/- + The part we want is the final hex segment (the slugId). + """ + ref = args.ref + # If it looks like a UUID, query by id. Otherwise, assume slugId. + is_uuid = len(ref) == 36 and ref.count("-") == 4 + if is_uuid: + q = """query($id: String!) { + document(id: $id) { + id title content contentState slugId + createdAt updatedAt url + creator { name } project { name } + } + }""" + emit(gql(q, {"id": ref}).get("document")) + else: + # Query the collection and filter by slugId — the doc() query only accepts UUIDs. + q = """query($slug: String!) { + documents(filter: { slugId: { eq: $slug } }, first: 1) { + nodes { + id title content contentState slugId + createdAt updatedAt url + creator { name } project { name } + } + } + }""" + nodes = gql(q, {"slug": ref}).get("documents", {}).get("nodes", []) + emit(nodes[0] if nodes else None) + + +def cmd_search_documents(args: argparse.Namespace) -> None: + # Linear doesn't have a first-class searchDocuments — use title filter as a fallback. + q = """query($term: String!, $first: Int!) { + documents(filter: { title: { containsIgnoreCase: $term } }, first: $first) { + nodes { id title slugId url updatedAt } + } + }""" + emit(gql(q, {"term": args.query, "first": args.limit}).get("documents", {}).get("nodes", [])) + + +def cmd_raw(args: argparse.Namespace) -> None: + variables = json.loads(args.vars) if args.vars else None + emit(gql(args.query, variables)) + + +# ---------- Arg parsing ---------- + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="linear_api.py", description="Linear GraphQL CLI") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("whoami").set_defaults(func=cmd_whoami) + sub.add_parser("list-teams").set_defaults(func=cmd_list_teams) + + lp = sub.add_parser("list-projects") + lp.add_argument("--team") + lp.set_defaults(func=cmd_list_projects) + + ls = sub.add_parser("list-states") + ls.add_argument("--team") + ls.set_defaults(func=cmd_list_states) + + li = sub.add_parser("list-issues") + li.add_argument("--team") + li.add_argument("--status") + li.add_argument("--assignee") + li.add_argument("--label") + li.add_argument("--limit", type=int, default=25) + li.set_defaults(func=cmd_list_issues) + + gi = sub.add_parser("get-issue") + gi.add_argument("identifier") + gi.set_defaults(func=cmd_get_issue) + + si = sub.add_parser("search-issues") + si.add_argument("query") + si.add_argument("--limit", type=int, default=25) + si.set_defaults(func=cmd_search_issues) + + ci = sub.add_parser("create-issue") + ci.add_argument("--title", required=True) + ci.add_argument("--team", required=True) + ci.add_argument("--description") + ci.add_argument("--priority", type=int, choices=[0, 1, 2, 3, 4]) + ci.add_argument("--label") + ci.add_argument("--assignee") + ci.add_argument("--parent") + ci.set_defaults(func=cmd_create_issue) + + ui = sub.add_parser("update-issue") + ui.add_argument("identifier") + ui.add_argument("--title") + ui.add_argument("--description") + ui.add_argument("--priority", type=int, choices=[0, 1, 2, 3, 4]) + ui.set_defaults(func=cmd_update_issue) + + us = sub.add_parser("update-status") + us.add_argument("identifier") + us.add_argument("state") + us.set_defaults(func=cmd_update_status) + + ac = sub.add_parser("add-comment") + ac.add_argument("identifier") + ac.add_argument("body") + ac.set_defaults(func=cmd_add_comment) + + ld = sub.add_parser("list-documents") + ld.add_argument("--limit", type=int, default=50) + ld.set_defaults(func=cmd_list_documents) + + gd = sub.add_parser("get-document") + gd.add_argument("ref", help="slugId (hex suffix from URL) or full UUID") + gd.set_defaults(func=cmd_get_document) + + sd = sub.add_parser("search-documents") + sd.add_argument("query") + sd.add_argument("--limit", type=int, default=25) + sd.set_defaults(func=cmd_search_documents) + + r = sub.add_parser("raw") + r.add_argument("query") + r.add_argument("--vars", help="JSON string of variables") + r.set_defaults(func=cmd_raw) + + return p + + +def main(argv: list[str] | None = None) -> None: + parser = build_parser() + args = parser.parse_args(argv) + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 0bb607d7412b..0ba2ba29f51b 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -14,6 +14,7 @@ _to_plain_data, _write_claude_code_credentials, build_anthropic_client, + build_anthropic_bedrock_client, build_anthropic_kwargs, convert_messages_to_anthropic, convert_tools_to_anthropic, @@ -66,11 +67,9 @@ def test_setup_token_uses_auth_token(self): assert "claude-code-20250219" in betas assert "interleaved-thinking-2025-05-14" in betas assert "fine-grained-tool-streaming-2025-05-14" in betas - # Default: 1M-context beta stays IN for OAuth so 1M-capable - # subscriptions keep full context. The reactive recovery path - # in run_agent.py flips it off only after a subscription - # actually rejects the beta. - assert "context-1m-2025-08-07" in betas + # Native Anthropic does not get context-1m by default; accounts + # without that beta reject even short auxiliary requests. + assert "context-1m-2025-08-07" not in betas assert "api_key" not in kwargs def test_oauth_drop_context_1m_beta_strips_only_1m(self): @@ -99,7 +98,7 @@ def test_api_key_uses_api_key(self): # API key auth should still get common betas betas = kwargs["default_headers"]["anthropic-beta"] assert "interleaved-thinking-2025-05-14" in betas - assert "context-1m-2025-08-07" in betas + assert "context-1m-2025-08-07" not in betas assert "oauth-2025-04-20" not in betas # OAuth-only beta NOT present assert "claude-code-20250219" not in betas # OAuth-only beta NOT present @@ -109,9 +108,27 @@ def test_custom_base_url(self): kwargs = mock_sdk.Anthropic.call_args[1] assert kwargs["base_url"] == "https://custom.api.com" assert kwargs["default_headers"] == { - "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-1m-2025-08-07" + "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" } + def test_azure_anthropic_endpoint_keeps_context_1m_beta(self): + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client( + "azure-key", + base_url="https://example.services.ai.azure.com/models/anthropic", + ) + kwargs = mock_sdk.Anthropic.call_args[1] + betas = kwargs["default_headers"]["anthropic-beta"] + assert "context-1m-2025-08-07" in betas + + def test_bedrock_client_keeps_context_1m_beta(self): + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + mock_sdk.AnthropicBedrock = MagicMock() + build_anthropic_bedrock_client("us-east-1") + kwargs = mock_sdk.AnthropicBedrock.call_args[1] + betas = kwargs["default_headers"]["anthropic-beta"] + assert "context-1m-2025-08-07" in betas + def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self): with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: build_anthropic_client( @@ -986,8 +1003,8 @@ def test_strips_anthropic_prefix(self): ) assert kwargs["model"] == "claude-sonnet-4-20250514" - def test_fast_mode_oauth_default_keeps_context_1m_beta(self): - """Default OAuth fast-mode requests still carry context-1m-2025-08-07.""" + def test_fast_mode_oauth_default_omits_context_1m_beta(self): + """Default OAuth fast-mode avoids context-1m for subscriptions without it.""" kwargs = build_anthropic_kwargs( model="claude-opus-4-6", messages=[{"role": "user", "content": "Hi"}], @@ -1000,7 +1017,7 @@ def test_fast_mode_oauth_default_keeps_context_1m_beta(self): betas = kwargs["extra_headers"]["anthropic-beta"] assert "fast-mode-2026-02-01" in betas assert "oauth-2025-04-20" in betas - assert "context-1m-2025-08-07" in betas + assert "context-1m-2025-08-07" not in betas def test_fast_mode_oauth_drop_context_1m_beta_strips_only_1m(self): """drop_context_1m_beta=True strips context-1m from fast-mode diff --git a/tests/agent/test_bedrock_adapter.py b/tests/agent/test_bedrock_adapter.py index 27c55cb1e9bc..6c51288461e0 100644 --- a/tests/agent/test_bedrock_adapter.py +++ b/tests/agent/test_bedrock_adapter.py @@ -994,6 +994,7 @@ def test_reasoning_delta_callback(self): events, on_reasoning_delta=lambda t: reasoning.append(t), ) assert reasoning == ["Let me think..."] + assert result.choices[0].message.reasoning_content == "Let me think..." # --------------------------------------------------------------------------- diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index abc93eca0296..e656a3e0b31f 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -924,6 +924,43 @@ def test_get_custom_provider_pool_key(tmp_path, monkeypatch): assert get_custom_provider_pool_key("") is None +def test_get_custom_provider_pool_key_prefers_name_over_base_url(tmp_path, monkeypatch): + """When two custom providers share the same base_url, provider_name resolves to the correct one.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + import yaml + config_path = tmp_path / "hermes" / "config.yaml" + config_path.write_text(yaml.dump({ + "custom_providers": [ + { + "name": "provider-a", + "base_url": "http://gateway:8080/v1", + "api_key": "sk-aaa", + }, + { + "name": "provider-b", + "base_url": "http://gateway:8080/v1", + "api_key": "sk-bbb", + }, + ] + })) + + from agent.credential_pool import get_custom_provider_pool_key + + # Without provider_name, first match wins (backward compatible) + assert get_custom_provider_pool_key("http://gateway:8080/v1") == "custom:provider-a" + + # With provider_name, exact name match wins regardless of order + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-b") == "custom:provider-b" + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="provider-a") == "custom:provider-a" + + # Name match with non-matching base_url still works via fallback + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="nonexistent") == "custom:provider-a" + + # Empty provider_name is same as None (backward compatible) + assert get_custom_provider_pool_key("http://gateway:8080/v1", provider_name="") == "custom:provider-a" + + def test_list_custom_pool_providers(tmp_path, monkeypatch): """list_custom_pool_providers returns custom: pool keys from auth.json.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) diff --git a/tests/agent/test_display.py b/tests/agent/test_display.py index 4c1309a44cdc..c6ad837af973 100644 --- a/tests/agent/test_display.py +++ b/tests/agent/test_display.py @@ -8,12 +8,21 @@ build_tool_preview, capture_local_edit_snapshot, extract_edit_diff, + get_cute_tool_message, + set_tool_preview_max_len, _render_inline_unified_diff, _summarize_rendered_diff_sections, render_edit_diff_with_delta, ) +@pytest.fixture(autouse=True) +def reset_tool_preview_max_len(): + set_tool_preview_max_len(0) + yield + set_tool_preview_max_len(0) + + class TestBuildToolPreview: """Tests for build_tool_preview defensive handling and normal operation.""" @@ -102,6 +111,45 @@ def test_false_like_args_zero(self): assert build_tool_preview("terminal", []) is None +class TestCuteToolMessagePreviewLength: + def test_terminal_preview_unlimited_when_config_is_zero(self): + set_tool_preview_max_len(0) + command = "curl -s http://localhost:9222/json/list | jq -r '.[] | select(.type==\"page\")' | head -5" + + line = get_cute_tool_message("terminal", {"command": command}, 0.1) + + assert command in line + assert "..." not in line + + def test_terminal_preview_uses_positive_configured_limit(self): + set_tool_preview_max_len(80) + command = "curl -s http://localhost:9222/json/list | jq -r '.[] | select(.type==\"page\")' | head -5" + + line = get_cute_tool_message("terminal", {"command": command}, 0.1) + + assert command[:77] in line + assert "..." in line + assert "head -5" not in line + + def test_search_files_preview_uses_positive_configured_limit_not_default(self): + set_tool_preview_max_len(80) + pattern = "function.formatToolCall.context.preview.compactPreview.maxLength.truncate" + + line = get_cute_tool_message("search_files", {"pattern": pattern}, 0.1) + + assert pattern in line + assert "..." not in line + + def test_path_preview_uses_positive_configured_limit_not_default(self): + set_tool_preview_max_len(80) + path = "/tmp/hermes-test-preview-length/deeply/nested/path/test-output.txt" + + line = get_cute_tool_message("read_file", {"path": path}, 0.1) + + assert path in line + assert "..." not in line + + class TestEditDiffPreview: def test_extract_edit_diff_for_patch(self): diff = extract_edit_diff("patch", '{"success": true, "diff": "--- a/x\\n+++ b/x\\n"}') diff --git a/tests/agent/test_image_routing.py b/tests/agent/test_image_routing.py index 9fd02eeecc9c..75f842b47116 100644 --- a/tests/agent/test_image_routing.py +++ b/tests/agent/test_image_routing.py @@ -109,6 +109,21 @@ def test_invalid_mode_coerces_to_auto(self): with patch("agent.image_routing._lookup_supports_vision", return_value=True): assert decide_image_input_mode("anthropic", "claude-sonnet-4", cfg) == "native" + def test_auto_uses_text_for_text_only_modalities_even_with_attachment_flag(self): + registry = { + "xiaomi": { + "models": { + "mimo-v2.5-pro": { + "attachment": True, + "modalities": {"input": ["text"]}, + "tool_call": True, + }, + }, + }, + } + with patch("agent.models_dev.fetch_models_dev", return_value=registry): + assert decide_image_input_mode("xiaomi", "mimo-v2.5-pro", {}) == "text" + # ─── build_native_content_parts ────────────────────────────────────────────── @@ -127,7 +142,11 @@ def test_text_then_image(self, tmp_path: Path): parts, skipped = build_native_content_parts("hello", [str(img)]) assert skipped == [] assert len(parts) == 2 - assert parts[0] == {"type": "text", "text": "hello"} + assert parts[0]["type"] == "text" + # User caption is preserved and a per-image path hint is appended so + # the model can use the local path as a string argument for tools + # that take ``image_url: str`` (issue #18960). + assert parts[0]["text"] == f"hello\n\n[Image attached at: {img}]" assert parts[1]["type"] == "image_url" assert parts[1]["image_url"]["url"].startswith("data:image/png;base64,") @@ -137,17 +156,51 @@ def test_empty_text_inserts_default_prompt(self, tmp_path: Path): parts, skipped = build_native_content_parts("", [str(img)]) assert skipped == [] # Even with empty user text, we insert a neutral prompt so the turn - # isn't just pixels. + # isn't just pixels, and the path hint is appended after. assert parts[0]["type"] == "text" - assert parts[0]["text"] == "What do you see in this image?" + assert parts[0]["text"] == ( + f"What do you see in this image?\n\n[Image attached at: {img}]" + ) assert parts[1]["type"] == "image_url" def test_missing_file_is_skipped(self, tmp_path: Path): parts, skipped = build_native_content_parts("hi", [str(tmp_path / "missing.png")]) assert skipped == [str(tmp_path / "missing.png")] - # Only text remains. + # Skipped paths are NOT advertised in the path hints — the model + # would otherwise be told a non-existent file is attached. assert parts == [{"type": "text", "text": "hi"}] + def test_path_hint_appended(self, tmp_path: Path): + """The local path of each attached image is appended to the user + text part so MCP/skill tools that take ``image_url: str`` can be + invoked on the same image (issue #18960). Mirrors text-mode + behaviour (`Runner._enrich_message_with_vision`). + """ + img = tmp_path / "scan.png" + img.write_bytes(_png_bytes()) + parts, _ = build_native_content_parts("attach this", [str(img)]) + text_part = next(p for p in parts if p.get("type") == "text") + assert "[Image attached at:" in text_part["text"] + assert str(img) in text_part["text"] + # User caption is preserved verbatim ahead of the hint. + assert text_part["text"].startswith("attach this") + + def test_path_hint_one_per_attached_image(self, tmp_path: Path): + """Each successfully attached image gets its own path hint line; + skipped images do NOT appear in the hints. + """ + good = tmp_path / "good.png" + good.write_bytes(_png_bytes()) + missing = tmp_path / "missing.png" # never created + parts, skipped = build_native_content_parts( + "see attached", [str(good), str(missing)] + ) + assert skipped == [str(missing)] + text_part = next(p for p in parts if p.get("type") == "text") + assert text_part["text"].count("[Image attached at:") == 1 + assert str(good) in text_part["text"] + assert str(missing) not in text_part["text"] + def test_multiple_images(self, tmp_path: Path): img1 = tmp_path / "a.png" img2 = tmp_path / "b.png" @@ -157,21 +210,41 @@ def test_multiple_images(self, tmp_path: Path): assert skipped == [] image_parts = [p for p in parts if p.get("type") == "image_url"] assert len(image_parts) == 2 + # Both paths surface in the text part, one per line. + text_part = next(p for p in parts if p.get("type") == "text") + assert text_part["text"].count("[Image attached at:") == 2 + assert str(img1) in text_part["text"] + assert str(img2) in text_part["text"] def test_mime_inference_jpg(self, tmp_path: Path): + # Real JPEG bytes (SOI marker FF D8 FF): sniffing now wins over suffix. img = tmp_path / "photo.jpg" - img.write_bytes(_png_bytes()) # bytes are PNG but extension is jpg + img.write_bytes(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01" + b"\x00" * 32) parts, _ = build_native_content_parts("x", [str(img)]) url = parts[1]["image_url"]["url"] assert url.startswith("data:image/jpeg;base64,") def test_mime_inference_webp(self, tmp_path: Path): + # Real WEBP bytes (RIFF....WEBP): sniffing now wins over suffix. img = tmp_path / "pic.webp" - img.write_bytes(_png_bytes()) + img.write_bytes(b"RIFF\x24\x00\x00\x00WEBPVP8 " + b"\x00" * 32) parts, _ = build_native_content_parts("", [str(img)]) url = parts[1]["image_url"]["url"] assert url.startswith("data:image/webp;base64,") + def test_mime_sniff_overrides_misleading_extension(self, tmp_path: Path): + """Discord-style bug: file is named .webp but contains PNG bytes. + Anthropic rejects on MIME mismatch (HTTP 400) so we MUST sniff. + Regression guard for the user-reported Discord PNG-as-WEBP failure. + """ + img = tmp_path / "discord_cached.webp" + img.write_bytes(_png_bytes()) # bytes are PNG, suffix lies + parts, _ = build_native_content_parts("", [str(img)]) + url = parts[1]["image_url"]["url"] + assert url.startswith("data:image/png;base64,"), ( + f"Expected MIME sniffing to detect PNG bytes regardless of .webp suffix, got: {url[:60]}" + ) + # ─── Oversize handling ─────────────────────────────────────────────────────── diff --git a/tests/agent/test_models_dev.py b/tests/agent/test_models_dev.py index c2a214018693..4eac2bd5616e 100644 --- a/tests/agent/test_models_dev.py +++ b/tests/agent/test_models_dev.py @@ -223,6 +223,13 @@ def test_in_memory_cache_used(self, mock_get): "tool_call": True, "limit": {"context": 32000, "output": 8192}, }, + "text-only-with-stale-attachment": { + "id": "text-only-with-stale-attachment", + "attachment": True, + "tool_call": True, + "modalities": {"input": ["text"]}, + "limit": {"context": 128000, "output": 8192}, + }, }, }, "anthropic": { @@ -243,7 +250,7 @@ class TestGetModelCapabilities: """Tests for get_model_capabilities vision detection.""" def test_vision_from_attachment_flag(self): - """Models with attachment=True should report supports_vision=True.""" + """Models with attachment=True and no modalities should report supports_vision=True.""" with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY): caps = get_model_capabilities("anthropic", "claude-sonnet-4") assert caps is not None @@ -257,6 +264,13 @@ def test_vision_from_modalities_input_image(self): assert caps is not None assert caps.supports_vision is True + def test_text_only_modalities_override_stale_attachment_flag(self): + """Text-only modalities must win over stale attachment=True metadata.""" + with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY): + caps = get_model_capabilities("google", "text-only-with-stale-attachment") + assert caps is not None + assert caps.supports_vision is False + def test_no_vision_without_attachment_or_modalities(self): """Models with neither attachment nor image modality should be non-vision.""" with patch("agent.models_dev.fetch_models_dev", return_value=CAPS_REGISTRY): diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index bdea17385cfc..bbecd5c43f61 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -177,6 +177,137 @@ def _disabled_skills(): assert "/telegram-only" not in telegram_again assert "/discord-only" in telegram_again + def test_get_skill_commands_rescans_when_session_platform_changes(self, tmp_path): + """``HERMES_SESSION_PLATFORM`` from the gateway session context must + also trigger a rescan, not just ``HERMES_PLATFORM`` (#14536). + + Exercises the real ContextVar path: the gateway sets the active + adapter via ``set_session_vars(platform=...)`` and the resolver + reads it via ``get_session_env``. Setting ``HERMES_SESSION_PLATFORM`` + in ``os.environ`` would only test ``get_session_env``'s legacy + env-var fallback — a regression that swapped ``get_session_env`` + for plain ``os.getenv`` would still pass while breaking concurrent + gateway sessions, which is the bug the ContextVar plumbing exists + to prevent in the first place. + """ + import agent.skill_commands as sc_mod + from agent.skill_commands import get_skill_commands + from gateway.session_context import ( + clear_session_vars, + get_session_env, + set_session_vars, + ) + + def _disabled_skills(): + platform = ( + os.getenv("HERMES_PLATFORM") + or get_session_env("HERMES_SESSION_PLATFORM") + ) + if platform == "telegram": + return {"telegram-only"} + if platform == "discord": + return {"discord-only"} + return set() + + with ( + patch("tools.skills_tool.SKILLS_DIR", tmp_path), + patch("tools.skills_tool._get_disabled_skill_names", side_effect=_disabled_skills), + patch.object(sc_mod, "_skill_commands", {}), + patch.object(sc_mod, "_skill_commands_platform", None), + ): + _make_skill(tmp_path, "shared") + _make_skill(tmp_path, "telegram-only") + _make_skill(tmp_path, "discord-only") + + # First simulated gateway request: telegram handler. + tokens = set_session_vars(platform="telegram") + try: + telegram_commands = dict(get_skill_commands()) + finally: + clear_session_vars(tokens) + + assert "/shared" in telegram_commands + assert "/discord-only" in telegram_commands + assert "/telegram-only" not in telegram_commands + + # Second simulated gateway request: discord handler. The cache + # was just populated for telegram; the rescan trigger must fire + # off the ContextVar change, not just an env-var change. + tokens = set_session_vars(platform="discord") + try: + discord_commands = dict(get_skill_commands()) + finally: + clear_session_vars(tokens) + + assert "/shared" in discord_commands + assert "/telegram-only" in discord_commands + assert "/discord-only" not in discord_commands + + def test_get_skill_commands_rescans_when_leaving_platform_scope(self, tmp_path, monkeypatch): + """Returning to no-platform-scope (CLI / cron / RL) after a gateway + session must rescan so the unfiltered view is repopulated (#14536). + + A long-lived process running both gateway sessions and bare CLI + invocations would otherwise stay stuck on whichever platform's + filter was last applied. + """ + import agent.skill_commands as sc_mod + from agent.skill_commands import get_skill_commands + + def _disabled_skills(): + if os.getenv("HERMES_PLATFORM") == "telegram": + return {"telegram-only"} + return set() + + with ( + patch("tools.skills_tool.SKILLS_DIR", tmp_path), + patch("tools.skills_tool._get_disabled_skill_names", side_effect=_disabled_skills), + patch.object(sc_mod, "_skill_commands", {}), + patch.object(sc_mod, "_skill_commands_platform", None), + ): + _make_skill(tmp_path, "shared") + _make_skill(tmp_path, "telegram-only") + + monkeypatch.setenv("HERMES_PLATFORM", "telegram") + telegram_commands = dict(get_skill_commands()) + assert "/telegram-only" not in telegram_commands + + # Drop back to no platform scope — bare CLI / cron / RL rollouts. + monkeypatch.delenv("HERMES_PLATFORM", raising=False) + bare_commands = dict(get_skill_commands()) + + assert "/telegram-only" in bare_commands + assert sc_mod._skill_commands_platform is None + + def test_get_skill_commands_does_not_rescan_when_platform_unchanged(self, tmp_path): + """Same-platform back-to-back calls must hit the cache, not rescan. + + The rescan trigger is *change* in platform scope, not "always + re-resolve." A gateway serving consecutive telegram requests must + not pay the scan cost for each one. + """ + import agent.skill_commands as sc_mod + from agent.skill_commands import get_skill_commands + + with ( + patch("tools.skills_tool.SKILLS_DIR", tmp_path), + patch.object(sc_mod, "_skill_commands", {}), + patch.object(sc_mod, "_skill_commands_platform", None), + patch.dict(os.environ, {"HERMES_PLATFORM": "telegram"}), + ): + _make_skill(tmp_path, "shared") + # Prime the cache. + get_skill_commands() + # Spy on rescans during the subsequent same-platform calls. + with patch( + "agent.skill_commands.scan_skill_commands", + wraps=sc_mod.scan_skill_commands, + ) as scan_spy: + get_skill_commands() + get_skill_commands() + get_skill_commands() + assert scan_spy.call_count == 0 + def test_special_chars_stripped_from_cmd_key(self, tmp_path): """Skill names with +, /, or other special chars produce clean cmd keys.""" diff --git a/tests/agent/transports/test_bedrock_transport.py b/tests/agent/transports/test_bedrock_transport.py index f9d78a31ce1d..7a5301d84fc1 100644 --- a/tests/agent/transports/test_bedrock_transport.py +++ b/tests/agent/transports/test_bedrock_transport.py @@ -142,6 +142,24 @@ def test_tool_call_response(self, transport): assert len(nr.tool_calls) == 1 assert nr.tool_calls[0].name == "terminal" + def test_raw_reasoning_content_response(self, transport): + raw = { + "output": { + "message": { + "role": "assistant", + "content": [ + {"reasoningContent": {"text": "Let me think..."}}, + {"text": "Answer."}, + ], + } + }, + "stopReason": "end_turn", + "usage": {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15}, + } + nr = transport.normalize_response(raw) + assert nr.reasoning == "Let me think..." + assert nr.content == "Answer." + def test_already_normalized_response(self, transport): """Test normalize_response handles already-normalized SimpleNamespace (from dispatch site).""" pre_normalized = SimpleNamespace( diff --git a/tests/cli/test_cli_file_drop.py b/tests/cli/test_cli_file_drop.py index fa6aac1ed16b..a7a8c42e2da0 100644 --- a/tests/cli/test_cli_file_drop.py +++ b/tests/cli/test_cli_file_drop.py @@ -68,6 +68,37 @@ def test_directory_not_file(self, tmp_path): """A directory path should not be treated as a file drop.""" assert _detect_file_drop(str(tmp_path)) is None + def test_long_slash_command_does_not_raise(self): + """Regression: long pasted slash commands like `/goal ` + used to raise OSError(ENAMETOOLONG, errno 63 macOS / 36 Linux) + from `Path.exists()` inside `_resolve_attachment_path`, which + propagated up to `process_loop`'s catch-all and silently lost + the user's input. The fix wraps the stat call in a try/except + OSError and returns None, letting the slash-command dispatch + path handle the input downstream. + + Reproducer: paste a `/goal` followed by ~430 chars of prose. + Without the fix this triggers ENAMETOOLONG; with the fix it + cleanly returns None (file-drop = no), so `_looks_like_slash_command` + gets a chance to dispatch it. + """ + # 430-char `/goal` payload — well above NAME_MAX (255 bytes) on + # all common filesystems. + long_goal = ( + "/goal " + ("Drive the board: triage triage-status items, " + "unblock spillover tasks where work is shipped, " + "advance P1 items by decomposing where needed. ") * 4 + ) + assert len(long_goal) > 255 # confirms it would have triggered ENAMETOOLONG + assert _detect_file_drop(long_goal) is None + + def test_path_longer_than_namemax_does_not_raise(self): + """Defensive: a single token longer than NAME_MAX should return + None, not raise. Could happen with absurdly long synthetic inputs + from prompt-injection attempts or fuzzers.""" + very_long_path = "/" + ("a" * 300) + assert _detect_file_drop(very_long_path) is None + # --------------------------------------------------------------------------- # Tests: image file detection diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index bf1f347e500f..c9ecf2c7df5f 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -3,6 +3,7 @@ import os import sys +from types import SimpleNamespace from unittest.mock import MagicMock, patch sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -161,6 +162,35 @@ def test_interrupt_mode_routes_busy_enter_to_interrupt(self): assert cli._pending_input.empty() +class TestPromptToolkitTerminalCompatibility: + def test_lf_enter_binds_to_submit_handler(self): + """Some thin PTYs deliver Enter as LF/c-j instead of CR/enter.""" + from prompt_toolkit.key_binding import KeyBindings + + from cli import _bind_prompt_submit_keys + + kb = KeyBindings() + + def submit_handler(event): + return None + + _bind_prompt_submit_keys(kb, submit_handler) + + bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings} + assert bindings[("c-m",)] is submit_handler + assert bindings[("c-j",)] is submit_handler + + def test_cpr_warning_callback_is_disabled(self): + from cli import _disable_prompt_toolkit_cpr_warning + + renderer = SimpleNamespace(cpr_not_supported_callback=lambda: None) + app = SimpleNamespace(renderer=renderer) + + _disable_prompt_toolkit_cpr_warning(app) + + assert renderer.cpr_not_supported_callback is None + + class TestSingleQueryState: def test_voice_and_interrupt_state_initialized_before_run(self): """Single-query mode calls chat() without going through run().""" diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index ff99856a8936..16e6699aaac1 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -207,6 +207,118 @@ def test_build_status_bar_text_handles_missing_agent(self): assert "⚕" in text assert "claude-sonnet-4-20250514" in text + def test_compression_count_shown_in_wide_status_bar(self): + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_230, + completion_tokens=2_220, + total_tokens=12_450, + api_calls=7, + context_tokens=12_450, + context_length=200_000, + compressions=3, + ) + + text = cli_obj._build_status_bar_text(width=120) + + assert "🗜️ 3" in text + + def test_compression_count_hidden_when_zero(self): + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_230, + completion_tokens=2_220, + total_tokens=12_450, + api_calls=7, + context_tokens=12_450, + context_length=200_000, + compressions=0, + ) + + text = cli_obj._build_status_bar_text(width=120) + + assert "🗜️" not in text + + def test_compression_count_shown_in_medium_status_bar(self): + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_000, + completion_tokens=2_400, + total_tokens=12_400, + api_calls=7, + context_tokens=12_400, + context_length=200_000, + compressions=2, + ) + + text = cli_obj._build_status_bar_text(width=60) + + assert "🗜️ 2" in text + + def test_compression_count_hidden_in_narrow_status_bar(self): + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_000, + completion_tokens=2_400, + total_tokens=12_400, + api_calls=7, + context_tokens=12_400, + context_length=200_000, + compressions=5, + ) + + text = cli_obj._build_status_bar_text(width=50) + + assert "🗜️" not in text + + def test_compression_count_style_thresholds(self): + cli_obj = _make_cli() + + assert cli_obj._compression_count_style(1) == "class:status-bar-dim" + assert cli_obj._compression_count_style(4) == "class:status-bar-dim" + assert cli_obj._compression_count_style(5) == "class:status-bar-warn" + assert cli_obj._compression_count_style(9) == "class:status-bar-warn" + assert cli_obj._compression_count_style(10) == "class:status-bar-bad" + assert cli_obj._compression_count_style(25) == "class:status-bar-bad" + + def test_compression_count_in_wide_fragments(self): + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_230, + completion_tokens=2_220, + total_tokens=12_450, + api_calls=7, + context_tokens=12_450, + context_length=200_000, + compressions=7, + ) + cli_obj._status_bar_visible = True + + frags = cli_obj._get_status_bar_fragments() + frag_texts = [text for _, text in frags] + + assert "🗜️ 7" in frag_texts + frag_styles = {text: style for style, text in frags} + assert frag_styles["🗜️ 7"] == "class:status-bar-warn" + + def test_compression_count_absent_from_fragments_when_zero(self): + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_230, + completion_tokens=2_220, + total_tokens=12_450, + api_calls=7, + context_tokens=12_450, + context_length=200_000, + compressions=0, + ) + cli_obj._status_bar_visible = True + + frags = cli_obj._get_status_bar_fragments() + frag_texts = [text for _, text in frags] + + assert not any("🗜️" in t for t in frag_texts) + def test_minimal_tui_chrome_threshold(self): cli_obj = _make_cli() diff --git a/tests/gateway/restart_test_helpers.py b/tests/gateway/restart_test_helpers.py index 4c5dab9960bd..213c46cbad88 100644 --- a/tests/gateway/restart_test_helpers.py +++ b/tests/gateway/restart_test_helpers.py @@ -1,4 +1,5 @@ import asyncio +from collections import OrderedDict from unittest.mock import AsyncMock, MagicMock from gateway.config import GatewayConfig, Platform, PlatformConfig @@ -74,6 +75,8 @@ def make_restart_runner( runner._update_prompt_pending = {} runner._voice_mode = {} runner._session_model_overrides = {} + runner._session_sources = OrderedDict() + runner._session_sources_max = 512 runner._shutdown_all_gateway_honcho = lambda: None runner._update_runtime_status = MagicMock() runner._queue_or_replace_pending_event = GatewayRunner._queue_or_replace_pending_event.__get__( @@ -115,6 +118,12 @@ def make_restart_runner( runner._notify_active_sessions_of_shutdown = ( GatewayRunner._notify_active_sessions_of_shutdown.__get__(runner, GatewayRunner) ) + runner._cache_session_source = GatewayRunner._cache_session_source.__get__( + runner, GatewayRunner + ) + runner._get_cached_session_source = GatewayRunner._get_cached_session_source.__get__( + runner, GatewayRunner + ) runner._launch_detached_restart_command = GatewayRunner._launch_detached_restart_command.__get__( runner, GatewayRunner ) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 2bf539041e94..5170a1736a9a 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -587,6 +587,10 @@ async def test_capabilities_advertises_plugin_safe_contract(self, adapter): assert data["model"] == "hermes-agent" assert data["auth"]["type"] == "bearer" assert data["auth"]["required"] is False + assert data["runtime"]["mode"] == "server_agent" + assert data["runtime"]["tool_execution"] == "server" + assert data["runtime"]["split_runtime"] is False + assert "API-server host" in data["runtime"]["description"] assert data["features"]["chat_completions"] is True assert data["features"]["run_status"] is True assert data["features"]["run_events_sse"] is True @@ -1360,6 +1364,146 @@ async def test_previous_response_id_chaining(self, adapter): assert len(call_kwargs["conversation_history"]) > 0 assert call_kwargs["user_message"] == "Now add 1 more" + @pytest.mark.asyncio + async def test_previous_response_id_stores_full_agent_transcript_once(self, adapter): + """Chained Responses storage must not append result["messages"] twice.""" + first_history = [ + {"role": "user", "content": "What is 1+1?"}, + {"role": "assistant", "content": "2"}, + ] + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = ( + { + "final_response": "2", + "messages": list(first_history), + "api_calls": 1, + }, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) + resp1 = await cli.post( + "/v1/responses", + json={"model": "hermes-agent", "input": "What is 1+1?"}, + ) + + assert resp1.status == 200 + resp1_data = await resp1.json() + stored_first = adapter._response_store.get(resp1_data["id"]) + assert stored_first["conversation_history"] == first_history + + second_history = first_history + [ + {"role": "user", "content": "Now add 1 more"}, + {"role": "assistant", "content": "3"}, + ] + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = ( + { + "final_response": "3", + "messages": list(second_history), + "api_calls": 1, + }, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) + resp2 = await cli.post( + "/v1/responses", + json={ + "model": "hermes-agent", + "input": "Now add 1 more", + "previous_response_id": resp1_data["id"], + }, + ) + + assert resp2.status == 200 + resp2_data = await resp2.json() + stored_second = adapter._response_store.get(resp2_data["id"]) + stored_history = stored_second["conversation_history"] + assert stored_history == second_history + assert stored_history.count(first_history[0]) == 1 + assert stored_history.count({"role": "user", "content": "Now add 1 more"}) == 1 + + @pytest.mark.asyncio + async def test_previous_response_id_outputs_only_current_turn_items(self, adapter): + """Response output must not replay previous tool artifacts.""" + prior_history = [ + {"role": "user", "content": "Read old file"}, + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_old", + "function": { + "name": "read_file", + "arguments": '{"path":"old.txt"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_old", + "content": '{"content":"old"}', + }, + {"role": "assistant", "content": "old"}, + ] + adapter._response_store.put( + "resp_prev", + { + "response": {"id": "resp_prev", "status": "completed"}, + "conversation_history": list(prior_history), + "session_id": "api-test-session", + }, + ) + full_agent_transcript = prior_history + [ + {"role": "user", "content": "Read new file"}, + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_new", + "function": { + "name": "read_file", + "arguments": '{"path":"new.txt"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_new", + "content": '{"content":"new"}', + }, + {"role": "assistant", "content": "new"}, + ] + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = ( + { + "final_response": "new", + "messages": list(full_agent_transcript), + "api_calls": 1, + }, + {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + ) + resp = await cli.post( + "/v1/responses", + json={ + "model": "hermes-agent", + "input": "Read new file", + "previous_response_id": "resp_prev", + }, + ) + assert resp.status == 200 + data = await resp.json() + + output_json = json.dumps(data["output"]) + assert "call_new" in output_json + assert "call_old" not in output_json + assert "old.txt" not in output_json + @pytest.mark.asyncio async def test_previous_response_id_preserves_session(self, adapter): """Chained responses via previous_response_id reuse the same session_id.""" @@ -1627,6 +1771,71 @@ async def _mock_run_agent(**kwargs): assert data["status"] == "completed" assert data["output"][-1]["content"][0]["text"] == "Stored response" + @pytest.mark.asyncio + async def test_streamed_previous_response_id_stores_full_agent_transcript_once(self, adapter): + prior_history = [ + {"role": "user", "content": "What is 1+1?"}, + {"role": "assistant", "content": "2"}, + ] + adapter._response_store.put( + "resp_prev", + { + "response": {"id": "resp_prev", "status": "completed"}, + "conversation_history": list(prior_history), + "session_id": "api-test-session", + }, + ) + + expected_history = prior_history + [ + {"role": "user", "content": "Now add 1 more"}, + {"role": "assistant", "content": "3"}, + ] + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + async def _mock_run_agent(**kwargs): + cb = kwargs.get("stream_delta_callback") + if cb: + cb("3") + return ( + { + "final_response": "3", + "messages": list(expected_history), + "api_calls": 1, + }, + {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + ) + + with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent): + resp = await cli.post( + "/v1/responses", + json={ + "model": "hermes-agent", + "input": "Now add 1 more", + "previous_response_id": "resp_prev", + "stream": True, + }, + ) + body = await resp.text() + + assert resp.status == 200 + response_id = None + for line in body.splitlines(): + if line.startswith("data: "): + try: + payload = json.loads(line[len("data: "):]) + except json.JSONDecodeError: + continue + if payload.get("type") == "response.completed": + response_id = payload["response"]["id"] + break + + assert response_id + stored_history = adapter._response_store.get(response_id)["conversation_history"] + assert stored_history == expected_history + assert stored_history.count(prior_history[0]) == 1 + assert stored_history.count({"role": "user", "content": "Now add 1 more"}) == 1 + @pytest.mark.asyncio async def test_stream_cancelled_persists_incomplete_snapshot(self, adapter): """Server-side asyncio.CancelledError (shutdown, request timeout) must diff --git a/tests/gateway/test_background_process_notifications.py b/tests/gateway/test_background_process_notifications.py index 7351854a2c4d..77bf7bcc18c4 100644 --- a/tests/gateway/test_background_process_notifications.py +++ b/tests/gateway/test_background_process_notifications.py @@ -304,6 +304,40 @@ def test_build_process_event_source_falls_back_to_session_key_chat_type(monkeypa assert source.user_name == "Emiliyan" +def test_build_process_event_source_uses_cached_live_source_before_session_key_parse( + monkeypatch, tmp_path +): + from gateway.session import SessionSource + + runner = _build_runner(monkeypatch, tmp_path, "all") + runner._cache_session_source( + "agent:main:telegram:group:-100:42", + SessionSource( + platform=Platform.TELEGRAM, + chat_id="-100", + chat_type="group", + thread_id="42", + user_id="proc_owner", + user_name="alice", + ), + ) + + source = runner._build_process_event_source( + { + "session_id": "proc_watch", + "session_key": "agent:main:telegram:group:-100:42", + } + ) + + assert source is not None + assert source.platform == Platform.TELEGRAM + assert source.chat_id == "-100" + assert source.chat_type == "group" + assert source.thread_id == "42" + assert source.user_id == "proc_owner" + assert source.user_name == "alice" + + @pytest.mark.asyncio async def test_inject_watch_notification_ignores_foreground_event_source(monkeypatch, tmp_path): """Negative test: watch notification must NOT route to the foreground thread.""" diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 3df2a7d50b9d..c53e34b757e1 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -57,6 +57,19 @@ def test_from_dict_coerces_quoted_false_enabled(self): restored = PlatformConfig.from_dict({"enabled": "false"}) assert restored.enabled is False + def test_gateway_restart_notification_defaults_true(self): + assert PlatformConfig().gateway_restart_notification is True + assert PlatformConfig.from_dict({}).gateway_restart_notification is True + + def test_gateway_restart_notification_roundtrip_false(self): + pc = PlatformConfig(enabled=True, gateway_restart_notification=False) + restored = PlatformConfig.from_dict(pc.to_dict()) + assert restored.gateway_restart_notification is False + + def test_gateway_restart_notification_coerces_quoted_false(self): + restored = PlatformConfig.from_dict({"gateway_restart_notification": "false"}) + assert restored.gateway_restart_notification is False + class TestGetConnectedPlatforms: def test_returns_enabled_with_token(self): diff --git a/tests/gateway/test_discord_connect.py b/tests/gateway/test_discord_connect.py index dd49e78e1829..43f88bcf9dad 100644 --- a/tests/gateway/test_discord_connect.py +++ b/tests/gateway/test_discord_connect.py @@ -1,4 +1,5 @@ import asyncio +import json import sys from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -70,6 +71,15 @@ def _ensure_discord_mock(): from gateway.platforms.discord import DiscordAdapter # noqa: E402 +@pytest.fixture(autouse=True) +def _speed_up_command_sync_mutation_pacing(monkeypatch): + monkeypatch.setattr( + DiscordAdapter, + "_command_sync_mutation_interval_seconds", + lambda self: 0.0, + ) + + class FakeTree: def __init__(self): self.sync = AsyncMock(return_value=[]) @@ -536,6 +546,183 @@ async def test_post_connect_initialization_skips_sync_when_policy_off(monkeypatc fake_tree.sync.assert_not_called() +@pytest.mark.asyncio +async def test_post_connect_initialization_skips_same_fingerprint_after_success(tmp_path, monkeypatch): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + + class _DesiredCommand: + def to_dict(self, tree): + return { + "name": "status", + "description": "Show Hermes status", + "type": 1, + "options": [], + } + + fake_tree = SimpleNamespace( + get_commands=lambda: [_DesiredCommand()], + fetch_commands=AsyncMock(return_value=[]), + ) + fake_http = SimpleNamespace( + upsert_global_command=AsyncMock(), + edit_global_command=AsyncMock(), + delete_global_command=AsyncMock(), + ) + adapter._client = SimpleNamespace( + tree=fake_tree, + http=fake_http, + application_id=999, + user=SimpleNamespace(id=999), + ) + + await adapter._run_post_connect_initialization() + await adapter._run_post_connect_initialization() + + fake_tree.fetch_commands.assert_awaited_once() + fake_http.upsert_global_command.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_post_connect_initialization_respects_discord_retry_after(tmp_path, monkeypatch): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + + class _DesiredCommand: + def to_dict(self, tree): + return { + "name": "status", + "description": "Show Hermes status", + "type": 1, + "options": [], + } + + adapter._client = SimpleNamespace( + tree=SimpleNamespace(get_commands=lambda: [_DesiredCommand()]), + application_id=999, + user=SimpleNamespace(id=999), + ) + class _DiscordRateLimit(RuntimeError): + retry_after = 123.0 + + sync = AsyncMock(side_effect=_DiscordRateLimit("discord rate limited")) + monkeypatch.setattr(adapter, "_safe_sync_slash_commands", sync) + + await adapter._run_post_connect_initialization() + await adapter._run_post_connect_initialization() + + sync.assert_awaited_once() + state_path = ( + tmp_path + / discord_platform._DISCORD_COMMAND_SYNC_STATE_SUBDIR + / discord_platform._DISCORD_COMMAND_SYNC_STATE_FILENAME + ) + state = json.loads(state_path.read_text()) + entry = state["999"] + assert entry["retry_after"] == 123.0 + assert entry["retry_after_until"] > entry["last_attempt_at"] + + +@pytest.mark.asyncio +async def test_post_connect_initialization_reraises_non_rate_limit_exceptions(tmp_path, monkeypatch): + """Arbitrary failures during sync must surface, not be swallowed as rate-limits.""" + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + + class _DesiredCommand: + def to_dict(self, tree): + return {"name": "status", "description": "Show Hermes status", "type": 1, "options": []} + + adapter._client = SimpleNamespace( + tree=SimpleNamespace(get_commands=lambda: [_DesiredCommand()]), + application_id=4242, + user=SimpleNamespace(id=4242), + ) + + # Unrelated failure that happens to expose retry_after. Must NOT be + # caught by the rate-limit handler — it has nothing to do with 429s. + class _UnrelatedError(RuntimeError): + retry_after = 999.0 + + sync = AsyncMock(side_effect=_UnrelatedError("database is down")) + monkeypatch.setattr(adapter, "_safe_sync_slash_commands", sync) + + # The outer _run_post_connect_initialization has a broad except Exception + # that logs defensively — so we assert on state NOT being written. + await adapter._run_post_connect_initialization() + + sync.assert_awaited_once() + state_path = ( + tmp_path + / discord_platform._DISCORD_COMMAND_SYNC_STATE_SUBDIR + / discord_platform._DISCORD_COMMAND_SYNC_STATE_FILENAME + ) + state = json.loads(state_path.read_text()) if state_path.exists() else {} + entry = state.get("4242", {}) + # Attempt was recorded before the sync call, but no rate-limit cooldown + # should have been persisted from the unrelated exception. + assert "retry_after_until" not in entry + assert "retry_after" not in entry + + +@pytest.mark.asyncio +async def test_safe_sync_slash_commands_paces_mutation_writes(monkeypatch): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + monkeypatch.setattr( + DiscordAdapter, + "_command_sync_mutation_interval_seconds", + lambda self: 1.25, + ) + sleeps = [] + + async def fake_sleep(delay): + sleeps.append(delay) + + monkeypatch.setattr(discord_platform.asyncio, "sleep", fake_sleep) + + class _DesiredCommand: + def __init__(self, payload): + self._payload = payload + + def to_dict(self, tree): + assert tree is not None + return dict(self._payload) + + desired_one = { + "name": "status", + "description": "Show Hermes status", + "type": 1, + "options": [], + } + desired_two = { + "name": "debug", + "description": "Generate a debug report", + "type": 1, + "options": [], + } + fake_tree = SimpleNamespace( + get_commands=lambda: [_DesiredCommand(desired_one), _DesiredCommand(desired_two)], + fetch_commands=AsyncMock(return_value=[]), + ) + fake_http = SimpleNamespace( + upsert_global_command=AsyncMock(), + edit_global_command=AsyncMock(), + delete_global_command=AsyncMock(), + ) + adapter._client = SimpleNamespace( + tree=fake_tree, + http=fake_http, + application_id=999, + user=SimpleNamespace(id=999), + ) + + summary = await adapter._safe_sync_slash_commands() + + assert summary["created"] == 2 + assert fake_http.upsert_global_command.await_count == 2 + assert sleeps == [1.25] + + @pytest.mark.asyncio async def test_safe_sync_reads_permission_attrs_from_existing_command(): """Regression: AppCommand.to_dict() in discord.py does NOT include diff --git a/tests/gateway/test_discord_roles_dm_scope.py b/tests/gateway/test_discord_roles_dm_scope.py new file mode 100644 index 000000000000..0f10ba79ae1f --- /dev/null +++ b/tests/gateway/test_discord_roles_dm_scope.py @@ -0,0 +1,355 @@ +"""Regression guard: DISCORD_ALLOWED_ROLES must be guild-scoped, not global. + +Prior to this fix, ``_is_allowed_user`` iterated ``self._client.guilds`` and +returned True if the user held any allowed role in ANY mutual guild. This +allowed a cross-guild DM bypass: + +1. Bot is in both a large public server A and a private trusted server B. +2. User has role ``R`` in public server A. ``DISCORD_ALLOWED_ROLES`` is + configured with ``R`` intending it to authorize server B members. +3. User DMs the bot. The role check scans every mutual guild, finds ``R`` + in public server A, and authorizes the DM. + +The fix scopes role checks to the originating guild and disables role-based +auth on DMs unless ``discord.dm_role_auth_guild`` in config.yaml explicitly +opts into a single trusted guild. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from gateway.platforms.discord import DiscordAdapter + + +def _set_dm_role_auth_guild(monkeypatch, guild_id=None): + """Stub ``hermes_cli.config.read_raw_config`` so ``_read_dm_role_auth_guild`` + resolves to ``guild_id`` (or None for the opt-out default). + """ + cfg = {"discord": {"dm_role_auth_guild": guild_id if guild_id is not None else ""}} + # Patch the attribute ``hermes_cli.config.read_raw_config`` — that's + # what ``_read_dm_role_auth_guild`` imports at call time. + import hermes_cli.config as _cfg_mod + monkeypatch.setattr(_cfg_mod, "read_raw_config", lambda: cfg, raising=True) + + +def _make_adapter(allowed_users=None, allowed_roles=None, guilds=None): + """Build a minimal DiscordAdapter without running __init__.""" + adapter = object.__new__(DiscordAdapter) + adapter._allowed_user_ids = set(allowed_users or []) + adapter._allowed_role_ids = set(allowed_roles or []) + + client = MagicMock() + client.guilds = guilds or [] + client.get_guild = lambda gid: next( + (g for g in (guilds or []) if getattr(g, "id", None) == gid), + None, + ) + adapter._client = client + return adapter + + +def _role(role_id): + return SimpleNamespace(id=role_id) + + +def _guild_with_member(guild_id, member_id, role_ids): + """Build a fake guild that holds one member with the given roles.""" + member = SimpleNamespace( + id=member_id, + roles=[_role(rid) for rid in role_ids], + guild=None, # filled below + ) + guild = SimpleNamespace( + id=guild_id, + get_member=lambda uid: member if uid == member_id else None, + ) + member.guild = guild + return guild, member + + +# --------------------------------------------------------------------------- +# Cross-guild DM bypass — MUST be rejected +# --------------------------------------------------------------------------- + + +def test_dm_rejects_role_held_in_other_guild(monkeypatch): + """A user with an allowed role in a DIFFERENT guild must NOT pass a DM. + + Regression guard for the cross-guild DM bypass in the initial + DISCORD_ALLOWED_ROLES implementation. + """ + _set_dm_role_auth_guild(monkeypatch) + + public_guild, _ = _guild_with_member( + guild_id=111111, + member_id=42, + role_ids=[5555], # allowed role, but in the wrong guild + ) + trusted_guild = SimpleNamespace(id=222222, get_member=lambda uid: None) + + adapter = _make_adapter( + allowed_roles=[5555], + guilds=[public_guild, trusted_guild], + ) + + # DM from user 42: role check must NOT scan other guilds. + assert ( + adapter._is_allowed_user("42", author=None, guild=None, is_dm=True) + is False + ) + + +def test_dm_role_auth_requires_explicit_guild_optin(monkeypatch): + """With dm_role_auth_guild set, only that specific guild counts. + + The user has the role in the opted-in guild — allowed. + """ + trusted_guild, _ = _guild_with_member( + guild_id=222222, + member_id=42, + role_ids=[5555], + ) + other_guild = SimpleNamespace(id=333333, get_member=lambda uid: None) + + adapter = _make_adapter( + allowed_roles=[5555], + guilds=[other_guild, trusted_guild], + ) + _set_dm_role_auth_guild(monkeypatch, 222222) + + assert ( + adapter._is_allowed_user("42", author=None, guild=None, is_dm=True) + is True + ) + + +def test_dm_role_auth_optin_rejects_when_not_member(monkeypatch): + """dm_role_auth_guild set but user isn't a member → reject.""" + trusted_guild = SimpleNamespace( + id=222222, + get_member=lambda uid: None, # user not in trusted guild + ) + public_guild, _ = _guild_with_member( + guild_id=111111, + member_id=42, + role_ids=[5555], + ) + adapter = _make_adapter( + allowed_roles=[5555], + guilds=[public_guild, trusted_guild], + ) + _set_dm_role_auth_guild(monkeypatch, 222222) + + assert ( + adapter._is_allowed_user("42", author=None, guild=None, is_dm=True) + is False + ) + + +# --------------------------------------------------------------------------- +# Guild messages — role check must be scoped to THIS guild only +# --------------------------------------------------------------------------- + + +def test_guild_message_role_check_scoped_to_originating_guild(monkeypatch): + """A user with the role in a DIFFERENT guild than the message origin + must NOT be authorized, even when both guilds are mutual. + """ + _set_dm_role_auth_guild(monkeypatch) + + public_guild, _ = _guild_with_member( + guild_id=111111, + member_id=42, + role_ids=[5555], # allowed role in public guild only + ) + # Message arrives in trusted_guild where user 42 has NO role + trusted_guild = SimpleNamespace(id=222222, get_member=lambda uid: None) + + adapter = _make_adapter( + allowed_roles=[5555], + guilds=[public_guild, trusted_guild], + ) + + # No author object passed → falls through to guild.get_member path + assert ( + adapter._is_allowed_user( + "42", author=None, guild=trusted_guild, is_dm=False + ) + is False + ) + + +def test_guild_message_role_check_allows_when_role_in_same_guild(monkeypatch): + """Positive path: user has the role IN the message's guild → allowed.""" + _set_dm_role_auth_guild(monkeypatch) + + trusted_guild, _ = _guild_with_member( + guild_id=222222, + member_id=42, + role_ids=[5555], + ) + adapter = _make_adapter( + allowed_roles=[5555], + guilds=[trusted_guild], + ) + + assert ( + adapter._is_allowed_user( + "42", author=None, guild=trusted_guild, is_dm=False + ) + is True + ) + + +def test_guild_message_rejects_author_roles_from_different_guild(monkeypatch): + """If an author Member object comes from a different guild than the + message, the cached .roles on it must NOT be trusted — rely on the + current guild's Member lookup instead. + """ + _set_dm_role_auth_guild(monkeypatch) + + # Author is a Member of a DIFFERENT guild with the allowed role + foreign_guild = SimpleNamespace(id=999, get_member=lambda uid: None) + foreign_author = SimpleNamespace( + id=42, + roles=[_role(5555)], + guild=foreign_guild, + ) + # Message arrives in this_guild where user 42 has NO role + this_guild = SimpleNamespace(id=222222, get_member=lambda uid: None) + + adapter = _make_adapter( + allowed_roles=[5555], + guilds=[foreign_guild, this_guild], + ) + + assert ( + adapter._is_allowed_user( + "42", author=foreign_author, guild=this_guild, is_dm=False + ) + is False + ) + + +# --------------------------------------------------------------------------- +# Backwards-compatibility — user-ID allowlist still works in both contexts +# --------------------------------------------------------------------------- + + +def test_user_id_allowlist_works_in_dm(): + adapter = _make_adapter(allowed_users=["42"]) + assert ( + adapter._is_allowed_user("42", author=None, guild=None, is_dm=True) + is True + ) + + +def test_user_id_allowlist_works_in_guild(): + adapter = _make_adapter(allowed_users=["42"]) + some_guild = SimpleNamespace(id=111, get_member=lambda uid: None) + assert ( + adapter._is_allowed_user( + "42", author=None, guild=some_guild, is_dm=False + ) + is True + ) + + +def test_empty_allowlists_allow_everyone(): + adapter = _make_adapter() + assert ( + adapter._is_allowed_user("42", author=None, guild=None, is_dm=True) + is True + ) + + +# --------------------------------------------------------------------------- +# Slash-surface sibling site: _evaluate_slash_authorization must pass +# guild/is_dm through so the cross-guild bypass can't land via slash either. +# --------------------------------------------------------------------------- + + +def test_slash_authorization_rejects_cross_guild_role_dm(monkeypatch): + """Slash interaction in a DM must not be authorized by a role held in + any mutual guild (parallel to the on_message cross-guild bypass).""" + import discord as _discord # type: ignore + _set_dm_role_auth_guild(monkeypatch) + + public_guild, _ = _guild_with_member( + guild_id=111111, + member_id=42, + role_ids=[5555], + ) + adapter = _make_adapter( + allowed_roles=[5555], + guilds=[public_guild], + ) + + # Fake a DM interaction: user is Member-like, channel is DMChannel, + # interaction.guild is None. + interaction = SimpleNamespace( + user=SimpleNamespace(id=42), + channel=MagicMock(spec=_discord.DMChannel), + channel_id=None, + guild=None, + ) + + allowed, reason = adapter._evaluate_slash_authorization(interaction) + assert allowed is False + assert "ALLOWED" in (reason or "") + + +def test_slash_authorization_rejects_cross_guild_role_in_guild(monkeypatch): + """Slash in guild B must not be authorized by a role held in guild A.""" + _set_dm_role_auth_guild(monkeypatch) + + public_guild, _ = _guild_with_member( + guild_id=111111, + member_id=42, + role_ids=[5555], + ) + # Interaction arrives in trusted_guild where user 42 has no role + trusted_guild = SimpleNamespace(id=222222, get_member=lambda uid: None) + adapter = _make_adapter( + allowed_roles=[5555], + guilds=[public_guild, trusted_guild], + ) + + interaction = SimpleNamespace( + user=SimpleNamespace(id=42), + channel=SimpleNamespace(id=9999), # not a DMChannel instance + channel_id=9999, + guild=trusted_guild, + ) + + allowed, reason = adapter._evaluate_slash_authorization(interaction) + assert allowed is False + assert "ALLOWED" in (reason or "") + + +def test_slash_authorization_allows_in_scope_guild_role(monkeypatch): + """Positive control: slash in guild B, user has role in guild B → allowed.""" + _set_dm_role_auth_guild(monkeypatch) + + trusted_guild, _ = _guild_with_member( + guild_id=222222, + member_id=42, + role_ids=[5555], + ) + adapter = _make_adapter( + allowed_roles=[5555], + guilds=[trusted_guild], + ) + + interaction = SimpleNamespace( + user=SimpleNamespace(id=42), + channel=SimpleNamespace(id=9999), + channel_id=9999, + guild=trusted_guild, + ) + + allowed, reason = adapter._evaluate_slash_authorization(interaction) + assert allowed is True + assert reason is None diff --git a/tests/gateway/test_discord_slash_auth.py b/tests/gateway/test_discord_slash_auth.py index a52ee1fd7e6a..e51f240e3aa5 100644 --- a/tests/gateway/test_discord_slash_auth.py +++ b/tests/gateway/test_discord_slash_auth.py @@ -158,7 +158,11 @@ def _make_interaction( return SimpleNamespace( user=user_obj, - guild=SimpleNamespace(owner_id=999), + # `get_member` needed for the guild-scoped role fallback path in + # _is_allowed_user after the #12136 cross-guild fix. Fixture guild + # has no members by default — tests exercising positive role paths + # assign their own Member via user.roles + matching allowed_role_ids. + guild=SimpleNamespace(owner_id=999, id=guild_id, get_member=lambda uid: None), guild_id=guild_id, channel_id=channel_id, channel=channel, diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py index 07d5c82a5f83..c702d3121db8 100644 --- a/tests/gateway/test_display_config.py +++ b/tests/gateway/test_display_config.py @@ -333,3 +333,64 @@ def test_explicit_true_enables(self): } } assert resolve_display_setting(config, "email", "streaming") is True + + +# --------------------------------------------------------------------------- +# cleanup_progress — opt-in deletion of temporary progress bubbles +# --------------------------------------------------------------------------- + +class TestCleanupProgress: + """``cleanup_progress`` is off by default and resolvable per-platform.""" + + def test_default_off_for_all_platforms(self): + """No config set → cleanup_progress resolves to False everywhere.""" + from gateway.display_config import resolve_display_setting + + for plat in ("telegram", "discord", "slack", "email"): + assert resolve_display_setting({}, plat, "cleanup_progress") is False + + def test_global_true_applies_to_all_platforms(self): + """display.cleanup_progress=true opts in globally.""" + from gateway.display_config import resolve_display_setting + + config = {"display": {"cleanup_progress": True}} + assert resolve_display_setting(config, "telegram", "cleanup_progress") is True + assert resolve_display_setting(config, "discord", "cleanup_progress") is True + + def test_per_platform_override_wins(self): + """display.platforms..cleanup_progress beats the global value.""" + from gateway.display_config import resolve_display_setting + + config = { + "display": { + "cleanup_progress": False, + "platforms": { + "telegram": {"cleanup_progress": True}, + }, + } + } + assert resolve_display_setting(config, "telegram", "cleanup_progress") is True + assert resolve_display_setting(config, "discord", "cleanup_progress") is False + + def test_yaml_off_string_normalises_to_false(self): + """YAML 1.1 bare ``off`` becomes string 'off' — treat as False.""" + from gateway.display_config import resolve_display_setting + + config = { + "display": { + "platforms": {"telegram": {"cleanup_progress": "off"}}, + } + } + assert resolve_display_setting(config, "telegram", "cleanup_progress") is False + + def test_yaml_true_string_normalises_to_true(self): + """String 'true'/'yes'/'on' all resolve to True.""" + from gateway.display_config import resolve_display_setting + + for val in ("true", "yes", "on", "1"): + config = { + "display": { + "platforms": {"telegram": {"cleanup_progress": val}}, + } + } + assert resolve_display_setting(config, "telegram", "cleanup_progress") is True, val diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index f4ac80f2e16b..63287d88cb4b 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -1962,6 +1962,45 @@ async def _direct(func, *args, **kwargs): self.assertEqual(result.message_id, "om_reply") self.assertTrue(captured["request"].request_body.reply_in_thread) + @patch.dict(os.environ, {}, clear=True) + def test_send_uses_metadata_reply_target_for_threaded_feishu_topic(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = {} + + class _MessageAPI: + def reply(self, request): + captured["request"] = request + return SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="om_reply"), + ) + + adapter._client = SimpleNamespace( + im=SimpleNamespace(v1=SimpleNamespace(message=_MessageAPI())) + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + result = asyncio.run( + adapter.send( + chat_id="oc_chat", + content="status update", + metadata={ + "thread_id": "omt-thread", + "reply_to_message_id": "om_trigger", + }, + ) + ) + + self.assertTrue(result.success) + self.assertEqual(captured["request"].message_id, "om_trigger") + self.assertTrue(captured["request"].request_body.reply_in_thread) + @patch.dict(os.environ, {}, clear=True) def test_send_retries_transient_failure(self): from gateway.config import PlatformConfig diff --git a/tests/gateway/test_feishu_onboard.py b/tests/gateway/test_feishu_onboard.py index 1ba1a64aa3fa..80a9c826031b 100644 --- a/tests/gateway/test_feishu_onboard.py +++ b/tests/gateway/test_feishu_onboard.py @@ -127,7 +127,7 @@ class TestPollRegistration: def test_poll_returns_credentials_on_success(self, mock_urlopen_fn, mock_time): from gateway.platforms.feishu import _poll_registration - mock_time.time.side_effect = [0, 1] + mock_time.monotonic.side_effect = [0, 1] mock_time.sleep = MagicMock() mock_urlopen_fn.return_value = _mock_urlopen({ @@ -149,7 +149,7 @@ def test_poll_returns_credentials_on_success(self, mock_urlopen_fn, mock_time): def test_poll_switches_domain_on_lark_tenant_brand(self, mock_urlopen_fn, mock_time): from gateway.platforms.feishu import _poll_registration - mock_time.time.side_effect = [0, 1, 2] + mock_time.monotonic.side_effect = [0, 1, 2] mock_time.sleep = MagicMock() pending_resp = _mock_urlopen({ @@ -175,7 +175,7 @@ def test_poll_success_with_lark_brand_in_same_response(self, mock_urlopen_fn, mo """Credentials and lark tenant_brand in one response must not be discarded.""" from gateway.platforms.feishu import _poll_registration - mock_time.time.side_effect = [0, 1] + mock_time.monotonic.side_effect = [0, 1] mock_time.sleep = MagicMock() mock_urlopen_fn.return_value = _mock_urlopen({ @@ -196,7 +196,7 @@ def test_poll_success_with_lark_brand_in_same_response(self, mock_urlopen_fn, mo def test_poll_returns_none_on_access_denied(self, mock_urlopen_fn, mock_time): from gateway.platforms.feishu import _poll_registration - mock_time.time.side_effect = [0, 1] + mock_time.monotonic.side_effect = [0, 1] mock_time.sleep = MagicMock() mock_urlopen_fn.return_value = _mock_urlopen({ @@ -212,7 +212,7 @@ def test_poll_returns_none_on_access_denied(self, mock_urlopen_fn, mock_time): def test_poll_returns_none_on_timeout(self, mock_urlopen_fn, mock_time): from gateway.platforms.feishu import _poll_registration - mock_time.time.side_effect = [0, 999] + mock_time.monotonic.side_effect = [0, 999] mock_time.sleep = MagicMock() mock_urlopen_fn.return_value = _mock_urlopen({ @@ -223,6 +223,25 @@ def test_poll_returns_none_on_timeout(self, mock_urlopen_fn, mock_time): ) assert result is None + @patch("gateway.platforms.feishu.time") + @patch("gateway.platforms.feishu.urlopen") + def test_poll_timeout_uses_monotonic_clock(self, mock_urlopen_fn, mock_time): + from gateway.platforms.feishu import _poll_registration + + mock_time.monotonic.side_effect = [1000, 1000.2, 1001.1] + mock_time.time.side_effect = [1000, 900, 901, 902] + mock_time.sleep = MagicMock() + + mock_urlopen_fn.return_value = _mock_urlopen({ + "error": "authorization_pending", + }) + result = _poll_registration( + device_code="dc_123", interval=1, expire_in=1, domain="feishu" + ) + + assert result is None + mock_urlopen_fn.assert_called_once() + class TestRenderQr: """Tests for QR code terminal rendering.""" diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 84f3b7239fb8..23646545bfcd 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -329,6 +329,37 @@ def test_media_tag_supports_unquoted_flac_paths_with_spaces(self): assert media == [("/tmp/Jane Doe/speech.flac", False)] assert cleaned == "" + def test_as_document_directive_stripped_from_cleaned_text(self): + """[[as_document]] is a routing directive — strip it from + user-visible text just like [[audio_as_voice]]. Callers detect the + directive on the original content (before extract_media).""" + content = "Here is your infographic:\n[[as_document]]\nMEDIA:/tmp/x.jpg" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [("/tmp/x.jpg", False)] + assert "[[as_document]]" not in cleaned + assert "Here is your infographic" in cleaned + + def test_as_document_directive_alone_does_not_attach_voice_flag(self): + """[[as_document]] is independent of [[audio_as_voice]] — combining + them in the same response should not entangle the flags.""" + content = "[[as_document]]\nMEDIA:/tmp/x.jpg" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [("/tmp/x.jpg", False)] # voice flag stays False + assert "[[as_document]]" not in cleaned + + def test_both_directives_can_coexist(self): + """A response could (rarely) contain both [[audio_as_voice]] for an + ogg file AND [[as_document]] for an attached image. The voice flag + propagates per-tuple; [[as_document]] is detected at dispatch.""" + content = "[[audio_as_voice]]\n[[as_document]]\nMEDIA:/tmp/x.ogg" + media, cleaned = BasePlatformAdapter.extract_media(content) + # Voice flag is propagated to every media tuple (this matches the + # existing extract_media contract) + assert media == [("/tmp/x.ogg", True)] + # Both directives stripped from cleaned text + assert "[[audio_as_voice]]" not in cleaned + assert "[[as_document]]" not in cleaned + # --------------------------------------------------------------------------- # should_send_media_as_audio diff --git a/tests/gateway/test_post_delivery_callback_chaining.py b/tests/gateway/test_post_delivery_callback_chaining.py new file mode 100644 index 000000000000..38c1978f0fc0 --- /dev/null +++ b/tests/gateway/test_post_delivery_callback_chaining.py @@ -0,0 +1,113 @@ +"""Tests for ``BasePlatformAdapter.register_post_delivery_callback`` chaining. + +When two features want to run after the final response lands on the same +session (e.g. background-review release + temporary-progress cleanup), the +registration API chains them rather than clobbering. Per-callback +exceptions are swallowed so one bad callback can't sabotage the others. +Stale-generation registrations are rejected. +""" +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import BasePlatformAdapter, SendResult + + +class _MinAdapter(BasePlatformAdapter): + async def connect(self) -> bool: + return True + + async def disconnect(self) -> None: + return None + + async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult: + return SendResult(success=True, message_id="1") + + async def get_chat_info(self, chat_id): + return {"id": chat_id} + + +@pytest.fixture +def adapter(): + return _MinAdapter(PlatformConfig(enabled=True), Platform.TELEGRAM) + + +class TestPostDeliveryCallbackChaining: + def test_single_callback_fires(self, adapter): + fired = [] + adapter.register_post_delivery_callback("s", lambda: fired.append("A")) + cb = adapter.pop_post_delivery_callback("s") + cb() + assert fired == ["A"] + + def test_two_callbacks_chain_in_order(self, adapter): + fired = [] + adapter.register_post_delivery_callback("s", lambda: fired.append("A")) + adapter.register_post_delivery_callback("s", lambda: fired.append("B")) + cb = adapter.pop_post_delivery_callback("s") + cb() + assert fired == ["A", "B"] + + def test_three_callbacks_chain_in_order(self, adapter): + """Chain composes over an already-chained callback.""" + fired = [] + for label in ("A", "B", "C"): + adapter.register_post_delivery_callback( + "s", lambda x=label: fired.append(x) + ) + cb = adapter.pop_post_delivery_callback("s") + cb() + assert fired == ["A", "B", "C"] + + def test_exception_in_one_callback_does_not_block_next(self, adapter): + fired = [] + + def boom(): + raise ValueError("boom") + + adapter.register_post_delivery_callback("s", boom) + adapter.register_post_delivery_callback("s", lambda: fired.append("survived")) + cb = adapter.pop_post_delivery_callback("s") + cb() + assert fired == ["survived"] + + def test_same_generation_chains(self, adapter): + fired = [] + adapter.register_post_delivery_callback( + "s", lambda: fired.append("A"), generation=5 + ) + adapter.register_post_delivery_callback( + "s", lambda: fired.append("B"), generation=5 + ) + cb = adapter.pop_post_delivery_callback("s", generation=5) + cb() + assert fired == ["A", "B"] + + def test_stale_generation_registration_rejected(self, adapter): + """A registration with an older generation than the existing + entry is rejected — it doesn't clobber the newer run's slot.""" + fired = [] + adapter.register_post_delivery_callback( + "s", lambda: fired.append("gen7"), generation=7 + ) + adapter.register_post_delivery_callback( + "s", lambda: fired.append("stale_gen3"), generation=3 + ) + cb = adapter.pop_post_delivery_callback("s", generation=7) + cb() + assert fired == ["gen7"] + + def test_pop_at_wrong_generation_returns_none(self, adapter): + adapter.register_post_delivery_callback( + "s", lambda: None, generation=5 + ) + assert adapter.pop_post_delivery_callback("s", generation=99) is None + # Correct generation still finds it. + assert adapter.pop_post_delivery_callback("s", generation=5) is not None + + def test_empty_session_key_is_noop(self, adapter): + adapter.register_post_delivery_callback("", lambda: None) + assert adapter._post_delivery_callbacks == {} + + def test_non_callable_is_noop(self, adapter): + adapter.register_post_delivery_callback("s", "not-callable") # type: ignore[arg-type] + assert adapter._post_delivery_callbacks == {} diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index 3aca6d64057b..55de5a45544c 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -257,6 +257,40 @@ async def test_shutdown_notification_send_failure_does_not_block(): await runner._notify_active_sessions_of_shutdown() +@pytest.mark.asyncio +async def test_shutdown_notification_suppressed_when_flag_disabled(): + """Active-session ping is muted when gateway_restart_notification=False on the platform.""" + from gateway.config import Platform + + runner, adapter = make_restart_runner() + runner._restart_requested = True + runner.config.platforms[Platform.TELEGRAM].gateway_restart_notification = False + session_key = "agent:main:telegram:dm:999" + runner._running_agents[session_key] = MagicMock() + + await runner._notify_active_sessions_of_shutdown() + + assert adapter.sent == [] + + +@pytest.mark.asyncio +async def test_shutdown_notification_home_channel_suppressed_when_flag_disabled(): + """Home-channel ping during shutdown is muted when the flag is False.""" + from gateway.config import HomeChannel, Platform + + runner, adapter = make_restart_runner() + runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( + platform=Platform.TELEGRAM, + chat_id="home-42", + name="Ops Home", + ) + runner.config.platforms[Platform.TELEGRAM].gateway_restart_notification = False + + await runner._notify_active_sessions_of_shutdown() + + assert adapter.sent == [] + + @pytest.mark.asyncio async def test_shutdown_notification_uses_persisted_origin_for_colon_ids(): """Shutdown notifications should route from persisted origin, not reparsed keys.""" diff --git a/tests/gateway/test_restart_notification.py b/tests/gateway/test_restart_notification.py index e97216072a4a..3d5d5ee95577 100644 --- a/tests/gateway/test_restart_notification.py +++ b/tests/gateway/test_restart_notification.py @@ -496,6 +496,82 @@ async def test_send_restart_notification_logs_warning_on_sendresult_failure( assert not notify_path.exists() +@pytest.mark.asyncio +async def test_send_home_channel_startup_notification_skipped_when_flag_disabled( + tmp_path, monkeypatch +): + """Per-platform opt-out: gateway_restart_notification=False mutes the home-channel ping.""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + runner, adapter = make_restart_runner() + runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( + platform=Platform.TELEGRAM, + chat_id="home-42", + name="Ops Home", + ) + runner.config.platforms[Platform.TELEGRAM].gateway_restart_notification = False + adapter.send = AsyncMock() + + delivered = await runner._send_home_channel_startup_notifications() + + assert delivered == set() + adapter.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_home_channel_startup_notification_default_flag_true( + tmp_path, monkeypatch +): + """Default behavior is unchanged: missing flag means notifications still fire.""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + runner, adapter = make_restart_runner() + # Sanity-check the dataclass default — guards against future refactors + # silently flipping the default to False. + assert runner.config.platforms[Platform.TELEGRAM].gateway_restart_notification is True + + runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( + platform=Platform.TELEGRAM, + chat_id="home-42", + name="Ops Home", + ) + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="home")) + + delivered = await runner._send_home_channel_startup_notifications() + + assert delivered == {("telegram", "home-42", None)} + adapter.send.assert_called_once() + + +@pytest.mark.asyncio +async def test_send_restart_notification_skipped_when_flag_disabled( + tmp_path, monkeypatch +): + """The /restart originator's notification also honors the per-platform flag. + + Slack used by end users → flag off → no "Gateway restarted" message even + when an end user accidentally triggers /restart. The marker file is still + cleaned up so the notification doesn't leak into the next boot. + """ + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + notify_path = tmp_path / ".restart_notify.json" + notify_path.write_text(json.dumps({ + "platform": "telegram", + "chat_id": "42", + })) + + runner, adapter = make_restart_runner() + runner.config.platforms[Platform.TELEGRAM].gateway_restart_notification = False + adapter.send = AsyncMock() + + delivered_target = await runner._send_restart_notification() + + assert delivered_target is None + adapter.send.assert_not_called() + assert not notify_path.exists() + + @pytest.mark.asyncio async def test_send_restart_notification_logs_info_on_sendresult_success( tmp_path, monkeypatch, caplog @@ -527,3 +603,23 @@ async def test_send_restart_notification_logs_info_on_sendresult_success( f"got records: {[(r.levelname, r.getMessage()) for r in caplog.records]}" ) assert not notify_path.exists() + + +@pytest.mark.asyncio +async def test_shutdown_notifications_use_cached_live_thread_source_when_origin_missing(): + runner, adapter = make_restart_runner() + source = make_restart_source(chat_id="parent-42", chat_type="group", thread_id="topic-7") + session_key = build_session_key(source) + + runner._running_agents[session_key] = object() + runner.session_store._entries[session_key] = MagicMock(origin=None) + runner._cache_session_source(session_key, source) + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="shutdown")) + + await runner._notify_active_sessions_of_shutdown() + + adapter.send.assert_awaited_once_with( + "parent-42", + "⚠️ Gateway shutting down — Your current task will be interrupted.", + metadata={"thread_id": "topic-7"}, + ) diff --git a/tests/gateway/test_restart_resume_pending.py b/tests/gateway/test_restart_resume_pending.py index 0b9e7c894d3e..13ef2f6f99ec 100644 --- a/tests/gateway/test_restart_resume_pending.py +++ b/tests/gateway/test_restart_resume_pending.py @@ -33,12 +33,13 @@ import pytest from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig -from gateway.platforms.base import SendResult +from gateway.platforms.base import MessageEvent, MessageType, SendResult from gateway.run import ( _auto_continue_freshness_window, _coerce_gateway_timestamp, _is_fresh_gateway_interruption, _last_transcript_timestamp, + _should_clear_resume_pending_after_turn, ) from gateway.session import SessionEntry, SessionSource, SessionStore from tests.gateway.restart_test_helpers import ( @@ -52,6 +53,23 @@ # --------------------------------------------------------------------------- +def test_resume_pending_is_cleared_only_after_successful_turn(): + """Interrupted/failed drain results must keep the restart recovery marker. + + Regression for dogfood failure: during gateway restart the interrupted run + returned an empty final response and was normalized into a user-facing + fallback, but the gateway cleared ``resume_pending`` before startup could + auto-resume it. + """ + assert _should_clear_resume_pending_after_turn({"final_response": "done"}) is True + assert _should_clear_resume_pending_after_turn({"completed": True}) is True + assert _should_clear_resume_pending_after_turn({"interrupted": True}) is False + assert _should_clear_resume_pending_after_turn({"completed": False}) is False + assert _should_clear_resume_pending_after_turn({"failed": True}) is False + assert _should_clear_resume_pending_after_turn({"partial": True}) is False + assert _should_clear_resume_pending_after_turn({"error": "boom"}) is False + + def _make_source(platform=Platform.TELEGRAM, chat_id="123", user_id="u1"): return SessionSource(platform=platform, chat_id=chat_id, user_id=user_id) @@ -910,6 +928,212 @@ async def test_drain_timeout_skips_pending_sentinel_sessions(): assert marked == {session_key_real} +# --------------------------------------------------------------------------- +# Gateway startup auto-resume +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_startup_auto_resume_schedules_fresh_pending_sessions(): + """Fresh resume_pending sessions should continue automatically after startup. + + This closes the UX gap where restart recovery only happened if the user sent + another message after the gateway came back. + """ + runner, adapter = make_restart_runner() + source = make_restart_source(chat_id="resume-chat", thread_id="topic-1") + pending_entry = SessionEntry( + session_key="agent:main:telegram:group:resume-chat:topic-1", + session_id="sid", + created_at=datetime.now(), + updated_at=datetime.now(), + origin=source, + platform=Platform.TELEGRAM, + chat_type="group", + resume_pending=True, + resume_reason="restart_timeout", + last_resume_marked_at=datetime.now(), + ) + runner.session_store._entries = {pending_entry.session_key: pending_entry} + adapter.handle_message = AsyncMock() + + scheduled = runner._schedule_resume_pending_sessions() + await asyncio.sleep(0) + + assert scheduled == 1 + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert isinstance(event, MessageEvent) + assert event.internal is True + assert event.message_type == MessageType.TEXT + assert event.source == source + # Text is empty — the existing _is_resume_pending branch in + # _handle_message_with_agent owns the system-note injection so we don't + # double it up. + assert event.text == "" + + +@pytest.mark.asyncio +async def test_startup_auto_resume_includes_crash_recovery(): + """Crash-recovered sessions (reason=restart_interrupted) are also auto-resumed. + + suspend_recently_active() marks in-flight sessions with resume_reason + "restart_interrupted" when the previous gateway exit was not clean + (crash/SIGKILL/OOM). These should get the same magic continuation as + drain-timeout interruptions. + """ + runner, adapter = make_restart_runner() + source = make_restart_source(chat_id="crash-chat") + pending_entry = SessionEntry( + session_key="agent:main:telegram:dm:crash-chat", + session_id="sid", + created_at=datetime.now(), + updated_at=datetime.now(), + origin=source, + platform=Platform.TELEGRAM, + chat_type="dm", + resume_pending=True, + resume_reason="restart_interrupted", + last_resume_marked_at=datetime.now(), + ) + runner.session_store._entries = {pending_entry.session_key: pending_entry} + adapter.handle_message = AsyncMock() + + scheduled = runner._schedule_resume_pending_sessions() + await asyncio.sleep(0) + + assert scheduled == 1 + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_startup_auto_resume_skips_stale_entries(): + """Entries older than the freshness window must not be auto-resumed.""" + runner, adapter = make_restart_runner() + source = make_restart_source(chat_id="stale-chat") + stale_marker = datetime.now() - timedelta( + seconds=_auto_continue_freshness_window() + 60 + ) + stale_entry = SessionEntry( + session_key="agent:main:telegram:dm:stale-chat", + session_id="sid", + created_at=stale_marker, + updated_at=stale_marker, + origin=source, + platform=Platform.TELEGRAM, + chat_type="dm", + resume_pending=True, + resume_reason="restart_timeout", + last_resume_marked_at=stale_marker, + ) + runner.session_store._entries = {stale_entry.session_key: stale_entry} + adapter.handle_message = AsyncMock() + + scheduled = runner._schedule_resume_pending_sessions() + + assert scheduled == 0 + adapter.handle_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_startup_auto_resume_skips_suspended_and_originless(): + """suspended entries and entries with no origin are excluded.""" + runner, adapter = make_restart_runner() + source = make_restart_source(chat_id="ok") + suspended_entry = SessionEntry( + session_key="agent:main:telegram:dm:suspended", + session_id="sid-s", + created_at=datetime.now(), + updated_at=datetime.now(), + origin=source, + platform=Platform.TELEGRAM, + chat_type="dm", + resume_pending=True, + resume_reason="restart_timeout", + suspended=True, + last_resume_marked_at=datetime.now(), + ) + originless = SessionEntry( + session_key="agent:main:telegram:dm:originless", + session_id="sid-o", + created_at=datetime.now(), + updated_at=datetime.now(), + origin=None, + platform=Platform.TELEGRAM, + chat_type="dm", + resume_pending=True, + resume_reason="restart_timeout", + last_resume_marked_at=datetime.now(), + ) + runner.session_store._entries = { + suspended_entry.session_key: suspended_entry, + originless.session_key: originless, + } + adapter.handle_message = AsyncMock() + + scheduled = runner._schedule_resume_pending_sessions() + + assert scheduled == 0 + adapter.handle_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_startup_auto_resume_skips_disallowed_reasons(): + """Reasons outside the auto-resume set (e.g. a future custom reason) are skipped. + + These sessions still auto-resume on the next real user message via the + existing _is_resume_pending branch — we just don't synthesize a turn + for them at startup. + """ + runner, adapter = make_restart_runner() + source = make_restart_source(chat_id="other") + other_entry = SessionEntry( + session_key="agent:main:telegram:dm:other", + session_id="sid", + created_at=datetime.now(), + updated_at=datetime.now(), + origin=source, + platform=Platform.TELEGRAM, + chat_type="dm", + resume_pending=True, + resume_reason="manual_resume_request", + last_resume_marked_at=datetime.now(), + ) + runner.session_store._entries = {other_entry.session_key: other_entry} + adapter.handle_message = AsyncMock() + + scheduled = runner._schedule_resume_pending_sessions() + + assert scheduled == 0 + adapter.handle_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_startup_auto_resume_skips_when_adapter_unavailable(): + runner, adapter = make_restart_runner() + source = make_restart_source(chat_id="resume-chat") + pending_entry = SessionEntry( + session_key="agent:main:telegram:dm:resume-chat", + session_id="sid", + created_at=datetime.now(), + updated_at=datetime.now(), + origin=source, + platform=Platform.TELEGRAM, + chat_type="dm", + resume_pending=True, + resume_reason="restart_timeout", + last_resume_marked_at=datetime.now(), + ) + runner.session_store._entries = {pending_entry.session_key: pending_entry} + runner.adapters = {} + adapter.handle_message = AsyncMock() + + scheduled = runner._schedule_resume_pending_sessions() + + assert scheduled == 0 + adapter.handle_message.assert_not_called() + + # --------------------------------------------------------------------------- # Shutdown banner wording # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_run_cleanup_progress.py b/tests/gateway/test_run_cleanup_progress.py new file mode 100644 index 000000000000..3e1439cc0df9 --- /dev/null +++ b/tests/gateway/test_run_cleanup_progress.py @@ -0,0 +1,367 @@ +"""Tests for opt-in cleanup of temporary progress bubbles. + +When ``display.platforms..cleanup_progress: true`` is set for a +platform whose adapter supports message deletion (e.g. Telegram), the +tool-progress bubble, "⏳ Still working..." notices, and status-callback +messages sent during a run are deleted after the final response is +delivered. + +Failed runs skip cleanup so the bubbles remain as breadcrumbs. +Adapters without ``delete_message`` silently no-op. +""" + +import asyncio +import importlib +import sys +import time +import types +from types import SimpleNamespace + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import BasePlatformAdapter, SendResult +from gateway.session import SessionSource + + +# --------------------------------------------------------------------------- +# Test fakes — mirror those in test_run_progress_topics.py but add a +# delete_message implementation that records ids instead of hitting a bot. +# --------------------------------------------------------------------------- + + +class CleanupCaptureAdapter(BasePlatformAdapter): + """Adapter that records every delete_message call for inspection.""" + + _next_mid = 100 + + def __init__(self, platform=Platform.TELEGRAM): + super().__init__(PlatformConfig(enabled=True, token="***"), platform) + self.sent = [] + self.edits = [] + self.deleted = [] + + async def connect(self) -> bool: + return True + + async def disconnect(self) -> None: + return None + + def _mint_id(self) -> str: + CleanupCaptureAdapter._next_mid += 1 + return str(CleanupCaptureAdapter._next_mid) + + async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult: + mid = self._mint_id() + self.sent.append( + {"chat_id": chat_id, "content": content, "message_id": mid, "metadata": metadata} + ) + return SendResult(success=True, message_id=mid) + + async def edit_message(self, chat_id, message_id, content) -> SendResult: + self.edits.append({"chat_id": chat_id, "message_id": message_id, "content": content}) + return SendResult(success=True, message_id=message_id) + + async def delete_message(self, chat_id, message_id) -> bool: + self.deleted.append({"chat_id": chat_id, "message_id": str(message_id)}) + return True + + async def send_typing(self, chat_id, metadata=None) -> None: + return None + + async def stop_typing(self, chat_id) -> None: + return None + + async def get_chat_info(self, chat_id: str): + return {"id": chat_id} + + +class NoDeleteAdapter(CleanupCaptureAdapter): + """Adapter that inherits the base no-op delete_message (used to prove + the cleanup path skips adapters without deletion support).""" + + async def delete_message(self, chat_id, message_id) -> bool: # type: ignore[override] + # Pretend to be an adapter whose platform doesn't support deletion: + # match the base class behavior exactly. gateway/run.py checks + # ``type(adapter).delete_message is BasePlatformAdapter.delete_message`` + # to detect this, so we re-assign at class body level below. + raise AssertionError("should not be called — cleanup must skip this adapter") + + +# Re-bind so the class's delete_message identity equals the base's. +NoDeleteAdapter.delete_message = BasePlatformAdapter.delete_message + + +class ProgressAgent: + """Emits two tool-progress events and returns a normal final response.""" + + def __init__(self, **kwargs): + self.tool_progress_callback = kwargs.get("tool_progress_callback") + self.tools = [] + + def run_conversation(self, message, conversation_history=None, task_id=None): + cb = self.tool_progress_callback + if cb is not None: + cb("tool.started", "terminal", "pwd", {}) + time.sleep(0.25) + cb("tool.started", "terminal", "ls", {}) + time.sleep(0.25) + return {"final_response": "done", "messages": [], "api_calls": 1} + + +class FailingAgent: + def __init__(self, **kwargs): + self.tool_progress_callback = kwargs.get("tool_progress_callback") + self.tools = [] + + def run_conversation(self, message, conversation_history=None, task_id=None): + cb = self.tool_progress_callback + if cb is not None: + cb("tool.started", "terminal", "pwd", {}) + time.sleep(0.25) + # Empty final_response + failed=True is the shape the gateway + # actually returns on provider errors (see gateway/run.py where + # failed keys are only propagated when final_response is empty). + return { + "final_response": "", + "messages": [], + "api_calls": 1, + "failed": True, + "error": "simulated provider failure", + } + + +def _make_runner(adapter): + gateway_run = importlib.import_module("gateway.run") + GatewayRunner = gateway_run.GatewayRunner + runner = object.__new__(GatewayRunner) + runner.adapters = {adapter.platform: adapter} + runner._voice_mode = {} + runner._prefill_messages = [] + runner._ephemeral_system_prompt = "" + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._session_db = None + runner._running_agents = {} + runner._session_run_generation = {} + runner.hooks = SimpleNamespace(loaded_hooks=False) + runner.config = SimpleNamespace( + thread_sessions_per_user=False, + group_sessions_per_user=False, + stt_enabled=False, + ) + return runner + + +def _install_fakes(monkeypatch, agent_cls, *, cleanup_on: bool): + """Wire up the module stubs every _run_agent test needs.""" + monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") + + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *a, **k: None + monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) + + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = agent_cls + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + import tools.terminal_tool # noqa: F401 — register tool emoji + + gateway_run = importlib.import_module("gateway.run") + monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"}) + + # Wire the per-platform cleanup_progress flag via the config loader the + # gateway actually reads (``_load_gateway_config`` returns user config). + cfg = { + "display": { + "platforms": { + "telegram": {"cleanup_progress": True}, + } + } + } if cleanup_on else {} + monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: cfg) + return gateway_run + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_cleanup_off_by_default_leaves_bubbles(monkeypatch, tmp_path): + """Without ``cleanup_progress: true``, firing whatever callback is + registered never reaches delete_message.""" + adapter = CleanupCaptureAdapter() + runner = _make_runner(adapter) + gateway_run = _install_fakes(monkeypatch, ProgressAgent, cleanup_on=False) + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + source = SessionSource(platform=Platform.TELEGRAM, chat_id="-1001") + session_key = "agent:main:telegram:group:-1001" + + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-1", + session_key=session_key, + ) + + assert result["final_response"] == "done" + # Even if an unrelated callback got registered (background-review + # release lives in the same slot) firing it should never cause any + # delete_message calls when cleanup is off. + cb = adapter.pop_post_delivery_callback(session_key) + if cb is not None: + cb() + for _ in range(10): + await asyncio.sleep(0.01) + assert adapter.deleted == [] + + +@pytest.mark.asyncio +async def test_cleanup_registers_callback_and_deletes_on_success(monkeypatch, tmp_path): + """With the flag on, the cleanup callback deletes the progress bubble.""" + adapter = CleanupCaptureAdapter() + runner = _make_runner(adapter) + gateway_run = _install_fakes(monkeypatch, ProgressAgent, cleanup_on=True) + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + source = SessionSource(platform=Platform.TELEGRAM, chat_id="-1001") + session_key = "agent:main:telegram:group:-1001" + + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-1", + session_key=session_key, + ) + + assert result["final_response"] == "done" + # The cleanup callback should be registered for this session. + cb = adapter.pop_post_delivery_callback(session_key) + assert callable(cb) + + # Fire it (base.py does this in _process_message_background's finally) + # and let the scheduled coroutine run to completion. + cb() + # delete_message is scheduled via run_coroutine_threadsafe → give the + # loop a couple of ticks to drain. + for _ in range(20): + await asyncio.sleep(0.01) + if adapter.deleted: + break + + # At least the first tool-progress bubble should have been deleted. + assert len(adapter.deleted) >= 1, f"deleted={adapter.deleted} sent={adapter.sent}" + for entry in adapter.deleted: + assert entry["chat_id"] == "-1001" + + +@pytest.mark.asyncio +async def test_cleanup_skipped_on_failed_run(monkeypatch, tmp_path): + """Failed runs skip cleanup registration — breadcrumbs stay.""" + adapter = CleanupCaptureAdapter() + runner = _make_runner(adapter) + gateway_run = _install_fakes(monkeypatch, FailingAgent, cleanup_on=True) + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + source = SessionSource(platform=Platform.TELEGRAM, chat_id="-1001") + session_key = "agent:main:telegram:group:-1001" + + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-1", + session_key=session_key, + ) + + assert result.get("failed") is True + # Whatever callback is registered should not trigger any deletion — + # the cleanup callback is skipped on failed runs. + cb = adapter.pop_post_delivery_callback(session_key) + if cb is not None: + cb() + for _ in range(10): + await asyncio.sleep(0.01) + assert adapter.deleted == [] + + +@pytest.mark.asyncio +async def test_cleanup_noop_on_adapter_without_delete_support(monkeypatch, tmp_path): + """Adapters that inherit the base-class delete_message no-op are + detected up front — the cleanup path never registers its callback so + a stray bg-review callback (if present) can fire harmlessly.""" + adapter = NoDeleteAdapter() + runner = _make_runner(adapter) + gateway_run = _install_fakes(monkeypatch, ProgressAgent, cleanup_on=True) + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + source = SessionSource(platform=Platform.TELEGRAM, chat_id="-1001") + session_key = "agent:main:telegram:group:-1001" + + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-1", + session_key=session_key, + ) + + assert result["final_response"] == "done" + # No deletion attempts on an adapter without delete_message support. + # (The NoDeleteAdapter.delete_message would raise AssertionError if + # the cleanup closure had somehow captured a reference to it.) + assert adapter.deleted == [] + + +@pytest.mark.asyncio +async def test_cleanup_chains_with_existing_callback(monkeypatch, tmp_path): + """When a bg-review-style callback is already registered, the cleanup + callback chains with it — both fire, neither clobbers the other.""" + adapter = CleanupCaptureAdapter() + runner = _make_runner(adapter) + gateway_run = _install_fakes(monkeypatch, ProgressAgent, cleanup_on=True) + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + source = SessionSource(platform=Platform.TELEGRAM, chat_id="-1001") + session_key = "agent:main:telegram:group:-1001" + + pre_existing_fired = [] + + def _preexisting_callback() -> None: + pre_existing_fired.append(True) + + # Pre-register a callback with the same generation the run will use + # (run_generation=None in this test path — matches the default slot). + adapter.register_post_delivery_callback(session_key, _preexisting_callback) + + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-1", + session_key=session_key, + ) + + assert result["final_response"] == "done" + cb = adapter.pop_post_delivery_callback(session_key) + assert callable(cb) + cb() + for _ in range(20): + await asyncio.sleep(0.01) + if adapter.deleted: + break + + # Both effects land: the pre-existing callback fires AND the cleanup + # deletes at least one progress bubble. + assert pre_existing_fired == [True] + assert len(adapter.deleted) >= 1 diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 478a9e2773fa..fb52e1e5863d 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -303,6 +303,50 @@ async def test_run_agent_progress_uses_event_message_id_for_slack_dm(monkeypatch assert all(call["metadata"] == {"thread_id": "1234567890.000001"} for call in adapter.typing) +@pytest.mark.asyncio +async def test_run_agent_feishu_progress_replies_inside_existing_thread(monkeypatch, tmp_path): + """Feishu needs reply_to plus reply_in_thread metadata for topic-scoped progress.""" + monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") + + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *args, **kwargs: None + monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) + + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = FakeAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + + adapter = ProgressCaptureAdapter(platform=Platform.FEISHU) + runner = _make_runner(adapter) + gateway_run = importlib.import_module("gateway.run") + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}) + + source = SessionSource( + platform=Platform.FEISHU, + chat_id="oc_chat", + chat_type="group", + thread_id="topic_17585", + ) + + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-feishu-progress", + session_key="agent:main:feishu:group:oc_chat:topic_17585", + event_message_id="om_triggering_user_message", + ) + + assert result["final_response"] == "done" + assert adapter.sent + assert adapter.sent[0]["reply_to"] == "om_triggering_user_message" + assert adapter.sent[0]["metadata"] == {"thread_id": "topic_17585"} + assert adapter.edits + assert adapter.edits[0]["message_id"] == "progress-1" + + # --------------------------------------------------------------------------- # Preview truncation tests (all/new mode respects tool_preview_length) # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_runtime_env_reload_config_authority.py b/tests/gateway/test_runtime_env_reload_config_authority.py new file mode 100644 index 000000000000..92d54b8863ce --- /dev/null +++ b/tests/gateway/test_runtime_env_reload_config_authority.py @@ -0,0 +1,53 @@ +"""Regression tests for gateway per-turn env reload preserving config authority. + +Issue #19158: startup bridges config.yaml agent.max_turns into +HERMES_MAX_ITERATIONS, but a later per-turn load_dotenv(..., override=True) +can restore a stale .env HERMES_MAX_ITERATIONS value before the next turn. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import yaml + +from gateway import run as gateway_run + + +def test_reload_runtime_env_preserves_config_max_turns(tmp_path: Path, monkeypatch) -> None: + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"agent": {"max_turns": 9000}}), + encoding="utf-8", + ) + (hermes_home / ".env").write_text( + "HERMES_MAX_ITERATIONS=90\nOPENROUTER_API_KEY=fresh-key\n", + encoding="utf-8", + ) + + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + monkeypatch.setenv("HERMES_MAX_ITERATIONS", "9000") + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + gateway_run._reload_runtime_env_preserving_config_authority() + + assert os.environ["OPENROUTER_API_KEY"] == "fresh-key" + assert os.environ["HERMES_MAX_ITERATIONS"] == "9000" + + +def test_reload_runtime_env_keeps_env_max_iterations_when_config_omits_key( + tmp_path: Path, monkeypatch +) -> None: + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text(yaml.safe_dump({"agent": {}}), encoding="utf-8") + (hermes_home / ".env").write_text("HERMES_MAX_ITERATIONS=123\n", encoding="utf-8") + + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + monkeypatch.delenv("HERMES_MAX_ITERATIONS", raising=False) + + gateway_run._reload_runtime_env_preserving_config_authority() + + assert os.environ["HERMES_MAX_ITERATIONS"] == "123" diff --git a/tests/gateway/test_telegram_documents.py b/tests/gateway/test_telegram_documents.py index 4b3e58f459e8..136856afb8f9 100644 --- a/tests/gateway/test_telegram_documents.py +++ b/tests/gateway/test_telegram_documents.py @@ -257,6 +257,43 @@ async def test_zip_document_cached(self, adapter): assert event.media_urls and event.media_urls[0].endswith("archive.zip") assert event.media_types == ["application/zip"] + @pytest.mark.asyncio + async def test_png_document_is_routed_as_image(self, adapter): + """Telegram documents that are really PNGs should use the image path.""" + file_obj = _make_file_obj(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16) + doc = _make_document(file_name="screenshot.png", mime_type="image/png", file_size=9, file_obj=file_obj) + msg = _make_message(document=doc) + update = _make_update(msg) + + with patch.object(adapter, "_photo_batch_key", return_value="batch-1"), patch.object( + adapter, "_enqueue_photo_event" + ) as enqueue_mock: + await adapter._handle_media_message(update, MagicMock()) + + enqueue_mock.assert_called_once() + event = enqueue_mock.call_args.args[1] + assert event.message_type == MessageType.PHOTO + assert event.media_urls and event.media_urls[0].endswith(".png") + assert event.media_types == ["image/png"] + assert adapter.handle_message.call_count == 0 + + @pytest.mark.asyncio + async def test_spoofed_png_document_falls_back_with_error(self, adapter): + """A .png filename with non-image bytes should fail clearly, not disappear.""" + file_obj = _make_file_obj(b"not-a-real-image") + doc = _make_document(file_name="spoofed.png", mime_type="image/png", file_size=16, file_obj=file_obj) + msg = _make_message(document=doc) + update = _make_update(msg) + + with patch.object(adapter, "_photo_batch_key", return_value="batch-2"), patch.object( + adapter, "_enqueue_photo_event" + ) as enqueue_mock: + await adapter._handle_media_message(update, MagicMock()) + + enqueue_mock.assert_not_called() + event = adapter.handle_message.call_args[0][0] + assert "could not be read as an image" in event.text + @pytest.mark.asyncio async def test_oversized_file_rejected(self, adapter): doc = _make_document(file_name="huge.pdf", file_size=25 * 1024 * 1024) diff --git a/tests/gateway/test_wecom.py b/tests/gateway/test_wecom.py index 18de405e3931..7bf56f9d319e 100644 --- a/tests/gateway/test_wecom.py +++ b/tests/gateway/test_wecom.py @@ -4,7 +4,7 @@ import os from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -122,6 +122,48 @@ async def aclose(self): assert "invalid secret" in (adapter.fatal_error_message or "") +class TestWeComQrScan: + @patch("gateway.platforms.wecom.time") + @patch("gateway.platforms.wecom.json.loads") + @patch("gateway.platforms.wecom.logger") + @patch("urllib.request.urlopen") + @patch("urllib.request.Request") + def test_qr_scan_timeout_uses_monotonic_clock( + self, + mock_request, + mock_urlopen, + _mock_logger, + mock_json_loads, + mock_time, + ): + from gateway.platforms.wecom import qr_scan_for_bot_info + + generate_resp = MagicMock() + generate_resp.read.return_value = b'{"data":{"scode":"abc","auth_url":"https://example.com/qr"}}' + generate_resp.__enter__.return_value = generate_resp + generate_resp.__exit__.return_value = False + + poll_resp = MagicMock() + poll_resp.read.return_value = b'{"data":{"status":"pending"}}' + poll_resp.__enter__.return_value = poll_resp + poll_resp.__exit__.return_value = False + + mock_urlopen.side_effect = [generate_resp, poll_resp] + mock_json_loads.side_effect = [ + {"data": {"scode": "abc", "auth_url": "https://example.com/qr"}}, + {"data": {"status": "pending"}}, + ] + mock_time.monotonic.side_effect = [1000, 1000.2, 1001.1] + mock_time.time.side_effect = [1000, 900, 901, 902] + mock_time.sleep = MagicMock() + + with patch("builtins.print"), patch.dict("sys.modules", {"qrcode": None}): + result = qr_scan_for_bot_info(timeout_seconds=1) + + assert result is None + assert mock_urlopen.call_count == 2 + + class TestWeComReplyMode: @pytest.mark.asyncio async def test_send_uses_passive_reply_markdown_when_reply_context_exists(self): diff --git a/tests/gateway/test_weixin.py b/tests/gateway/test_weixin.py index 8deccf18cb78..68dfa76841db 100644 --- a/tests/gateway/test_weixin.py +++ b/tests/gateway/test_weixin.py @@ -7,6 +7,8 @@ from pathlib import Path from unittest.mock import AsyncMock, Mock, patch +import pytest + from gateway.config import PlatformConfig from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides from gateway.platforms.base import SendResult @@ -279,6 +281,35 @@ def _boom(_src, _dst): assert json.loads(sync_path.read_text(encoding="utf-8")) == {"get_updates_buf": "old-sync"} +class TestWeixinQrLogin: + @pytest.mark.asyncio + async def test_qr_login_timeout_uses_monotonic_clock(self, tmp_path): + first_qr = { + "qrcode": "qr-1", + "qrcode_img_content": "https://example.com/qr-1", + } + pending = {"status": "wait"} + + with patch("gateway.platforms.weixin._api_get", new_callable=AsyncMock) as api_get_mock, \ + patch("gateway.platforms.weixin.time") as mock_time, \ + patch("gateway.platforms.weixin.AIOHTTP_AVAILABLE", True), \ + patch("gateway.platforms.weixin.aiohttp.ClientSession", create=True) as session_cls, \ + patch("builtins.print"): + api_get_mock.side_effect = [first_qr, pending] + mock_time.monotonic.side_effect = [1000, 1000.2, 1001.1] + mock_time.time.side_effect = [1000, 900, 901, 902] + + session = AsyncMock() + session.__aenter__.return_value = session + session.__aexit__.return_value = False + session_cls.return_value = session + + result = await weixin.qr_login(str(tmp_path), timeout_seconds=1) + + assert result is None + assert api_get_mock.await_count == 2 + + class TestWeixinSendMessageIntegration: def test_parse_target_ref_accepts_weixin_ids(self): assert _parse_target_ref("weixin", "wxid_test123") == ("wxid_test123", None, True) @@ -461,7 +492,9 @@ def put(self, *_args, **_kwargs): assert upload_url == "https://upload.example.com/media" assert upload_kwargs["headers"] == {"Content-Type": "application/octet-stream"} assert upload_kwargs["data"] - assert upload_kwargs["timeout"].total == 120 + # Timeout is now enforced externally via asyncio.wait_for() rather than + # aiohttp.ClientTimeout, so it no longer appears as a post() kwarg. + assert "timeout" not in upload_kwargs payload = api_post_mock.await_args.kwargs["payload"] media = payload["msg"]["item_list"][0]["image_item"]["media"] assert media["encrypt_query_param"] == "enc-param" diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index d0e24aeaabeb..136265c7e483 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -1179,3 +1179,87 @@ def _fake_refresh(state, **kwargs): shared_after = auth_mod._read_shared_nous_state() assert shared_after is not None assert shared_after["refresh_token"] == "b-refresh-tok" + + +def test_runtime_refresh_uses_newer_shared_token_before_local_stale_token( + tmp_path, monkeypatch, shared_store_env, +): + """A sibling profile may rotate the single-use Nous refresh token. + + When this profile later wakes with an expired local token, runtime + resolution must adopt the shared token before refreshing. Otherwise it + can submit the stale local refresh token and trigger portal reuse + revocation for the whole shared session. + """ + from hermes_cli import auth as auth_mod + + profile_b = tmp_path / "profile_b" + _setup_nous_auth( + profile_b, + access_token="local-expired-access", + refresh_token="local-stale-refresh", + ) + monkeypatch.setenv("HERMES_HOME", str(profile_b)) + + shared_state = _full_state_fixture() + shared_state["access_token"] = "shared-fresh-access" + shared_state["refresh_token"] = "shared-fresh-refresh" + shared_state["expires_at"] = "2099-01-01T00:00:00+00:00" + auth_mod._write_shared_nous_state(shared_state) + + def _refresh_should_not_happen(**_kwargs): + raise AssertionError("stale profile-local refresh token was used") + + minted_with: list[str] = [] + + def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): + minted_with.append(access_token) + return _mint_payload(api_key="agent-key-from-shared-token") + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _refresh_should_not_happen) + monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) + + creds = auth_mod.resolve_nous_runtime_credentials( + min_key_ttl_seconds=300, + force_mint=True, + ) + + assert creds["api_key"] == "agent-key-from-shared-token" + assert minted_with == ["shared-fresh-access"] + + profile_state = auth_mod.get_provider_auth_state("nous") + assert profile_state is not None + assert profile_state["refresh_token"] == "shared-fresh-refresh" + assert profile_state["access_token"] == "shared-fresh-access" + + +def test_managed_gateway_access_token_uses_newer_shared_token( + tmp_path, monkeypatch, shared_store_env, +): + """Managed-tool token reads share the same stale-refresh-token hazard.""" + from hermes_cli import auth as auth_mod + + profile_b = tmp_path / "profile_b" + _setup_nous_auth( + profile_b, + access_token="local-expired-access", + refresh_token="local-stale-refresh", + ) + monkeypatch.setenv("HERMES_HOME", str(profile_b)) + + shared_state = _full_state_fixture() + shared_state["access_token"] = "shared-fresh-access" + shared_state["refresh_token"] = "shared-fresh-refresh" + shared_state["expires_at"] = "2099-01-01T00:00:00+00:00" + auth_mod._write_shared_nous_state(shared_state) + + def _refresh_should_not_happen(**_kwargs): + raise AssertionError("stale profile-local refresh token was used") + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _refresh_should_not_happen) + + assert auth_mod.resolve_nous_access_token() == "shared-fresh-access" + + profile_state = auth_mod.get_provider_auth_state("nous") + assert profile_state is not None + assert profile_state["refresh_token"] == "shared-fresh-refresh" diff --git a/tests/hermes_cli/test_auth_profile_fallback.py b/tests/hermes_cli/test_auth_profile_fallback.py new file mode 100644 index 000000000000..2063517d28ca --- /dev/null +++ b/tests/hermes_cli/test_auth_profile_fallback.py @@ -0,0 +1,360 @@ +"""Tests for cross-profile auth fallback. + +When ``HERMES_HOME`` points to a named profile, ``read_credential_pool()`` +and ``get_provider_auth_state()`` fall back to the global-root +``auth.json`` per-provider when the profile has no entries for that +provider. Writes still target the profile only. + +See the #18594 follow-up report: profile workers couldn't see providers +authenticated only at the global root. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def _make_auth_store(pool: dict | None = None, providers: dict | None = None) -> dict: + store: dict = {"version": 1} + if pool is not None: + store["credential_pool"] = pool + if providers is not None: + store["providers"] = providers + return store + + +@pytest.fixture() +def profile_env(tmp_path, monkeypatch): + """Set up a global root + an active profile under Path.home()/.hermes/profiles/coder. + + * Path.home() -> tmp_path + * Global root -> tmp_path/.hermes (has its own auth.json fixture) + * Profile -> tmp_path/.hermes/profiles/coder (active, HERMES_HOME points here) + + This mirrors the real "named profile mounted under the default root" + layout that profile users actually have on disk. + """ + monkeypatch.setattr(Path, "home", lambda: tmp_path) + global_root = tmp_path / ".hermes" + global_root.mkdir() + profile_dir = global_root / "profiles" / "coder" + profile_dir.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(profile_dir)) + return {"global": global_root, "profile": profile_dir} + + +def _write(path: Path, payload: dict) -> None: + path.write_text(json.dumps(payload, indent=2)) + + +# --------------------------------------------------------------------------- +# read_credential_pool — provider-slice reads +# --------------------------------------------------------------------------- + + +def test_profile_with_zero_entries_falls_back_to_global(profile_env): + """Empty profile pool inherits the global-root entries for that provider.""" + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-1", + "label": "global-key", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + })) + # Profile auth.json: exists but has no openrouter entries. + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={})) + + entries = read_credential_pool("openrouter") + assert len(entries) == 1 + assert entries[0]["id"] == "glob-1" + assert entries[0]["access_token"] == "sk-or-global" + + +def test_profile_with_entries_fully_shadows_global(profile_env): + """Once the profile has any entries for a provider, global is ignored.""" + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-1", + "label": "global-key", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "prof-1", + "label": "profile-key", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-profile", + }], + })) + + entries = read_credential_pool("openrouter") + assert len(entries) == 1 + assert entries[0]["id"] == "prof-1" + assert entries[0]["access_token"] == "sk-or-profile" + + +def test_per_provider_shadowing_is_independent(profile_env): + """Profile can override one provider while inheriting another from global.""" + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-or", + "label": "global-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + "anthropic": [{ + "id": "glob-ant", + "label": "global-ant", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-ant-global", + }], + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + # Profile has openrouter only — anthropic should still fall back. + "openrouter": [{ + "id": "prof-or", + "label": "profile-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-profile", + }], + })) + + or_entries = read_credential_pool("openrouter") + ant_entries = read_credential_pool("anthropic") + assert [e["id"] for e in or_entries] == ["prof-or"] + assert [e["id"] for e in ant_entries] == ["glob-ant"] + + +def test_missing_global_auth_file_is_safe(profile_env): + """Profile processes that never had a global auth.json still work.""" + from hermes_cli.auth import read_credential_pool + + # No global auth.json written at all. + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "prof-1", + "label": "profile", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-profile", + }], + })) + + assert read_credential_pool("openrouter")[0]["id"] == "prof-1" + assert read_credential_pool("anthropic") == [] + + +def test_malformed_global_auth_file_does_not_break_profile_read(profile_env): + (profile_env["global"] / "auth.json").write_text("{not valid json") + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "prof-1", + "label": "profile", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-profile", + }], + })) + + from hermes_cli.auth import read_credential_pool + + # Profile reads still work; malformed global is silently ignored. + assert read_credential_pool("openrouter")[0]["id"] == "prof-1" + # And no fallback for anthropic since global is unreadable. + assert read_credential_pool("anthropic") == [] + + +# --------------------------------------------------------------------------- +# read_credential_pool — whole-pool reads (provider_id=None) +# --------------------------------------------------------------------------- + + +def test_whole_pool_merges_global_providers_when_missing_locally(profile_env): + from hermes_cli.auth import read_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-or", + "label": "global-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-global", + }], + "anthropic": [{ + "id": "glob-ant", + "label": "global-ant", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-ant-global", + }], + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "prof-or", + "label": "profile-or", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-or-profile", + }], + })) + + pool = read_credential_pool(None) + # Profile wins for openrouter, global fills in anthropic. + assert [e["id"] for e in pool["openrouter"]] == ["prof-or"] + assert [e["id"] for e in pool["anthropic"]] == ["glob-ant"] + + +# --------------------------------------------------------------------------- +# get_provider_auth_state — singleton fallback +# --------------------------------------------------------------------------- + + +def test_provider_auth_state_falls_back_to_global_when_profile_has_none(profile_env): + from hermes_cli.auth import get_provider_auth_state + + _write(profile_env["global"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "nous-global", "refresh_token": "rt-global"}, + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) + + state = get_provider_auth_state("nous") + assert state is not None + assert state["access_token"] == "nous-global" + + +def test_provider_auth_state_profile_wins_when_present(profile_env): + from hermes_cli.auth import get_provider_auth_state + + _write(profile_env["global"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "nous-global"}, + })) + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={ + "nous": {"access_token": "nous-profile"}, + })) + + state = get_provider_auth_state("nous") + assert state is not None + assert state["access_token"] == "nous-profile" + + +def test_provider_auth_state_returns_none_when_neither_has_it(profile_env): + from hermes_cli.auth import get_provider_auth_state + + _write(profile_env["global"] / "auth.json", _make_auth_store(providers={})) + _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) + + assert get_provider_auth_state("nous") is None + + +# --------------------------------------------------------------------------- +# Classic mode — no fallback path should ever trigger +# --------------------------------------------------------------------------- + + +def test_classic_mode_does_not_double_read_same_file(tmp_path, monkeypatch): + """In classic mode (HERMES_HOME == global root), no fallback path runs. + + This guards against the merge accidentally duplicating entries when the + profile and global resolve to the same directory. + """ + # Put Path.home() under a subdir so the seat belt in _auth_file_path() + # sees tmp_path/home/.hermes as the "real home" — which is NOT equal + # to the HERMES_HOME we set (tmp_path/classic), so the guard passes. + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + hermes_home = tmp_path / "classic" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + _write(hermes_home / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "only", + "label": "classic", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-classic", + }], + })) + + from hermes_cli.auth import read_credential_pool, _global_auth_file_path + + # Classic mode: HERMES_HOME is set to a custom path that is NOT under + # ~/.hermes/profiles/ — get_default_hermes_root() returns HERMES_HOME + # itself, so the profile root and global root are the same directory, + # and the helper correctly returns None (no fallback). + assert _global_auth_file_path() is None + # And the read should return exactly one entry (not two). + entries = read_credential_pool("openrouter") + assert len(entries) == 1 + assert entries[0]["id"] == "only" + + +# --------------------------------------------------------------------------- +# Writes stay scoped to the profile +# --------------------------------------------------------------------------- + + +def test_write_credential_pool_targets_profile_not_global(profile_env): + from hermes_cli.auth import read_credential_pool, write_credential_pool + + _write(profile_env["global"] / "auth.json", _make_auth_store(pool={ + "openrouter": [{ + "id": "glob-1", + "label": "global", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-global", + }], + })) + + write_credential_pool("openrouter", [{ + "id": "prof-new", + "label": "profile-new", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-profile-new", + }]) + + # Global auth.json unchanged. + global_data = json.loads((profile_env["global"] / "auth.json").read_text()) + assert global_data["credential_pool"]["openrouter"][0]["id"] == "glob-1" + + # Profile auth.json holds the new entry. + profile_data = json.loads((profile_env["profile"] / "auth.json").read_text()) + assert profile_data["credential_pool"]["openrouter"][0]["id"] == "prof-new" + + # Subsequent read returns profile (shadows global). + assert [e["id"] for e in read_credential_pool("openrouter")] == ["prof-new"] diff --git a/tests/hermes_cli/test_auth_toctou_file_modes.py b/tests/hermes_cli/test_auth_toctou_file_modes.py new file mode 100644 index 000000000000..c89bafebfefa --- /dev/null +++ b/tests/hermes_cli/test_auth_toctou_file_modes.py @@ -0,0 +1,198 @@ +"""Regression tests for TOCTOU-safe credential file writers in ``hermes_cli.auth``. + +Background +========== +The three writers below used to create a temp file via ``Path.write_text`` / +``Path.open('w')`` and only ``chmod``'d it to ``0o600`` afterward. Between +create and chmod the file existed at the process umask (typically ``0o644``), +briefly exposing OAuth tokens to other local users on multi-user hosts. The +fix switches them to ``os.open(O_EXCL, mode=0o600)`` + ``os.fdopen`` + +``fsync`` so the file is atomic at ``0o600`` on creation. Mirrors the fixes +shipped for ``agent/google_oauth.py`` (#19673) and ``tools/mcp_oauth.py`` +(#21148). + +These tests stay green only while the token file and its parent directory +end up at ``0o600`` / ``0o700`` after every write. POSIX-only — the mode-bit +enforcement does not exist on Windows. +""" + +from __future__ import annotations + +import json +import os +import stat +import sys +from unittest.mock import patch + +import pytest + + +pytestmark = pytest.mark.skipif( + sys.platform.startswith("win"), + reason="POSIX mode bits not enforced on Windows", +) + + +# --------------------------------------------------------------------------- +# _save_auth_store (~/.hermes/auth.json — every native OAuth provider) +# --------------------------------------------------------------------------- + + +def test_save_auth_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch): + """``_save_auth_store`` must land ``auth.json`` at 0o600 and parent at 0o700.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + old_umask = os.umask(0o022) # make the race observable if it regresses + try: + from hermes_cli import auth as auth_mod + + auth_store = { + "version": auth_mod.AUTH_STORE_VERSION, + "providers": {"openai-codex": {"tokens": {"access_token": "secret-x"}}}, + "active_provider": "openai-codex", + } + auth_path = auth_mod._save_auth_store(auth_store) + finally: + os.umask(old_umask) + + mode = stat.S_IMODE(auth_path.stat().st_mode) + parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode) + + assert mode == 0o600, ( + f"auth.json mode 0o{mode:o} != 0o600 — TOCTOU race regressed" + ) + assert parent_mode == 0o700, ( + f"auth.json parent dir mode 0o{parent_mode:o} != 0o700 — siblings can traverse" + ) + + # Content survived the rewrite + data = json.loads(auth_path.read_text()) + assert data["providers"]["openai-codex"]["tokens"]["access_token"] == "secret-x" + + +# --------------------------------------------------------------------------- +# _save_qwen_cli_tokens (Qwen CLI OAuth tokens) +# --------------------------------------------------------------------------- + + +def test_save_qwen_cli_tokens_writes_0o600_with_0o700_parent(tmp_path, monkeypatch): + """``_save_qwen_cli_tokens`` must land the token file at 0o600 and parent at 0o700.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + # The Qwen CLI auth path lives under $HOME/.qwen by default — isolate it. + monkeypatch.setenv("HOME", str(tmp_path)) + old_umask = os.umask(0o022) + try: + from hermes_cli import auth as auth_mod + + tokens = { + "access_token": "qwen-secret", + "refresh_token": "qwen-refresh", + "token_type": "Bearer", + "expiry_date": 123, + } + auth_path = auth_mod._save_qwen_cli_tokens(tokens) + finally: + os.umask(old_umask) + + mode = stat.S_IMODE(auth_path.stat().st_mode) + parent_mode = stat.S_IMODE(auth_path.parent.stat().st_mode) + + assert mode == 0o600, ( + f"Qwen token file mode 0o{mode:o} != 0o600 — TOCTOU race regressed" + ) + assert parent_mode == 0o700, ( + f"Qwen token parent dir mode 0o{parent_mode:o} != 0o700" + ) + + data = json.loads(auth_path.read_text()) + assert data["access_token"] == "qwen-secret" + + +# --------------------------------------------------------------------------- +# Nous shared-credential store write (inside _write_shared_nous_state) +# --------------------------------------------------------------------------- + + +def test_shared_nous_store_writes_0o600_with_0o700_parent(tmp_path, monkeypatch): + """The Nous shared-credential store must land at 0o600 / parent 0o700.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + # _nous_shared_store_path() refuses to touch the real shared store during + # pytest runs; redirect it into tmp_path explicitly. + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared")) + old_umask = os.umask(0o022) + try: + from hermes_cli import auth as auth_mod + + state = { + "access_token": "nous-access-xxx", + "refresh_token": "nous-refresh-xxx", + "token_type": "Bearer", + "scope": "openid profile", + "client_id": "test-client", + "obtained_at": "2026-01-01T00:00:00Z", + "expires_at": "2026-01-01T01:00:00Z", + } + auth_mod._write_shared_nous_state(state) + path = auth_mod._nous_shared_store_path() + finally: + os.umask(old_umask) + + assert path.exists(), "shared Nous store was not written" + mode = stat.S_IMODE(path.stat().st_mode) + parent_mode = stat.S_IMODE(path.parent.stat().st_mode) + + assert mode == 0o600, ( + f"Nous shared store mode 0o{mode:o} != 0o600 — TOCTOU race regressed" + ) + assert parent_mode == 0o700, ( + f"Nous shared store parent dir mode 0o{parent_mode:o} != 0o700" + ) + + data = json.loads(path.read_text()) + assert data["refresh_token"] == "nous-refresh-xxx" + + +# --------------------------------------------------------------------------- +# Atomicity: verify ``os.open`` is called with an explicit 0o600 mode. +# --------------------------------------------------------------------------- + + +def test_save_auth_store_uses_os_open_with_0o600_mode(tmp_path, monkeypatch): + """Regression: the writer must call ``os.open`` with an explicit restricted + mode so the file is created at 0o600 atomically — closing the TOCTOU + window the previous ``Path.open('w')`` left open (fd inherited process + umask and was briefly 0o644 before post-write chmod).""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + observed_opens: list[tuple[str, int, int]] = [] + real_os_open = os.open + + def spying_os_open(path, flags, mode=0o777, *args, **kwargs): + observed_opens.append((str(path), flags, mode)) + return real_os_open(path, flags, mode, *args, **kwargs) + + with patch.object(os, "open", spying_os_open): + from hermes_cli import auth as auth_mod + + auth_mod._save_auth_store( + {"version": auth_mod.AUTH_STORE_VERSION, "providers": {}} + ) + + auth_tmp_opens = [ + (p, fl, m) for (p, fl, m) in observed_opens if "auth.json.tmp" in p + ] + assert auth_tmp_opens, ( + f"os.open was never called for the auth.json temp file; " + f"observed={observed_opens!r}" + ) + for path, flags, mode in auth_tmp_opens: + assert flags & os.O_CREAT, f"auth.json temp open missing O_CREAT: path={path}" + assert flags & os.O_EXCL, ( + f"auth.json temp open missing O_EXCL — TOCTOU-safe pattern regressed: " + f"path={path}, flags={flags}" + ) + # Must be exactly S_IRUSR | S_IWUSR (0o600) — no group/other bits. + expected = stat.S_IRUSR | stat.S_IWUSR + assert mode == expected, ( + f"auth.json temp open mode 0o{mode:o} != 0o{expected:o} — " + f"umask would apply and potentially expose tokens" + ) diff --git a/tests/hermes_cli/test_curator_run.py b/tests/hermes_cli/test_curator_run.py new file mode 100644 index 000000000000..2e0b3fbd939f --- /dev/null +++ b/tests/hermes_cli/test_curator_run.py @@ -0,0 +1,87 @@ +"""Tests for `hermes curator run` CLI behavior.""" + +from __future__ import annotations + +from types import SimpleNamespace + + +def _args(**kwargs): + values = { + "dry_run": False, + "synchronous": False, + "background": False, + } + values.update(kwargs) + return SimpleNamespace(**values) + + +def test_run_defaults_to_synchronous(monkeypatch, capsys): + import agent.curator as curator_state + import hermes_cli.curator as curator_cli + + calls = [] + monkeypatch.setattr(curator_state, "is_enabled", lambda: True) + monkeypatch.setattr( + curator_state, + "run_curator_review", + lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}}, + ) + + assert curator_cli._cmd_run(_args()) == 0 + + assert calls[0]["synchronous"] is True + assert calls[0]["dry_run"] is False + assert "background" not in capsys.readouterr().out + + +def test_run_background_opts_into_async(monkeypatch, capsys): + import agent.curator as curator_state + import hermes_cli.curator as curator_cli + + calls = [] + monkeypatch.setattr(curator_state, "is_enabled", lambda: True) + monkeypatch.setattr( + curator_state, + "run_curator_review", + lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}}, + ) + + assert curator_cli._cmd_run(_args(background=True)) == 0 + + assert calls[0]["synchronous"] is False + assert "llm pass running in background" in capsys.readouterr().out + + +def test_run_sync_wins_over_background(monkeypatch): + import agent.curator as curator_state + import hermes_cli.curator as curator_cli + + calls = [] + monkeypatch.setattr(curator_state, "is_enabled", lambda: True) + monkeypatch.setattr( + curator_state, + "run_curator_review", + lambda **kwargs: calls.append(kwargs) or {"auto_transitions": {}}, + ) + + assert curator_cli._cmd_run(_args(synchronous=True, background=True)) == 0 + + assert calls[0]["synchronous"] is True + + +def test_dry_run_default_reports_synchronous_wording(monkeypatch, capsys): + import agent.curator as curator_state + import hermes_cli.curator as curator_cli + + monkeypatch.setattr(curator_state, "is_enabled", lambda: True) + monkeypatch.setattr( + curator_state, + "run_curator_review", + lambda **kwargs: {"auto_transitions": {}}, + ) + + assert curator_cli._cmd_run(_args(dry_run=True)) == 0 + + out = capsys.readouterr().out + assert "When the report lands" not in out + assert "Read the report with `hermes curator status`" in out diff --git a/tests/hermes_cli/test_curator_status.py b/tests/hermes_cli/test_curator_status.py index b4c3548c4280..2075ebc2b690 100644 --- a/tests/hermes_cli/test_curator_status.py +++ b/tests/hermes_cli/test_curator_status.py @@ -175,3 +175,28 @@ def test_status_no_skills_produces_clean_empty_output(curator_status_env): # None of the ranking sections render assert "most active" not in out assert "least active" not in out + + +def test_status_marks_missing_last_report_path(monkeypatch, capsys, tmp_path): + import agent.curator as curator_state + import hermes_cli.curator as curator_cli + import tools.skill_usage as skill_usage + + missing_report = tmp_path / "stale-report" + monkeypatch.setattr(curator_state, "load_state", lambda: { + "paused": False, + "last_run_at": None, + "last_run_summary": "auto: no changes", + "run_count": 1, + "last_report_path": str(missing_report), + }) + monkeypatch.setattr(curator_state, "is_enabled", lambda: True) + monkeypatch.setattr(curator_state, "get_interval_hours", lambda: 168) + monkeypatch.setattr(curator_state, "get_stale_after_days", lambda: 30) + monkeypatch.setattr(curator_state, "get_archive_after_days", lambda: 90) + monkeypatch.setattr(skill_usage, "agent_created_report", lambda: []) + + assert curator_cli._cmd_status(SimpleNamespace()) == 0 + + out = capsys.readouterr().out + assert f"last report: {missing_report} (missing)" in out diff --git a/tests/hermes_cli/test_debug.py b/tests/hermes_cli/test_debug.py index b83023a76a41..1996e7fce989 100644 --- a/tests/hermes_cli/test_debug.py +++ b/tests/hermes_cli/test_debug.py @@ -291,9 +291,11 @@ def hermes_home_with_secret(self, tmp_path, monkeypatch): home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) - # Critical: ensure the user has NOT opted in to redaction. The whole - # point of this PR is that share-time redaction works for users who - # never set this env var. + # Baseline fixture: no explicit env-var opinion. With the post-#17691 + # default of ON, the default-path tests below exercise the + # secure-default behaviour. The `force=True` regression test + # setenvs to "false" inline to prove force=True works even when + # the runtime flag is disabled. monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False) logs_dir = home / "logs" @@ -324,21 +326,26 @@ def test_redact_false_passes_through(self, hermes_home_with_secret): assert _REDACT_FIXTURE_TOKEN in snap.tail_text assert _REDACT_FIXTURE_TOKEN in (snap.full_text or "") - def test_force_true_overrides_unset_env_var(self, hermes_home_with_secret): + def test_force_true_works_when_redaction_disabled( + self, hermes_home_with_secret, monkeypatch + ): """Regression test: redact_sensitive_text short-circuits without force=True. If a future refactor drops `force=True` from `_redact_log_text`, this test fails immediately. Without `force=True`, the redactor returns the - input unchanged when HERMES_REDACT_SECRETS is unset, and the feature - ships silently broken for its target audience. + input unchanged when HERMES_REDACT_SECRETS=false, and the share-time + redaction feature ships silently broken for users who opted out of + runtime redaction (e.g. developers working on the redactor itself). """ import os + # Force the runtime flag off so we're exercising the force=True path, + # not the default-on path. + monkeypatch.setenv("HERMES_REDACT_SECRETS", "false") + from hermes_cli.debug import _capture_log_snapshot - # Belt-and-suspenders: confirm the env var is genuinely unset for this - # test so we know we're exercising the force=True path. - assert os.environ.get("HERMES_REDACT_SECRETS", "") == "" + assert os.environ.get("HERMES_REDACT_SECRETS", "") == "false" snap = _capture_log_snapshot("agent", tail_lines=10) diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 374ef2dea4a5..abf5f4858548 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -652,6 +652,60 @@ def fake_get(url, headers=None, timeout=None): assert any(url == "https://api.moonshot.cn/v1/models" for url, _, _ in calls) +def test_run_doctor_dashscope_retries_china_endpoint_after_intl_unauthorized(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + (home / ".env").write_text("DASHSCOPE_API_KEY=sk-test\n", encoding="utf-8") + project = tmp_path / "project" + project.mkdir(exist_ok=True) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test") + monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + try: + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + except ImportError: + pass + + calls = [] + + def fake_get(url, headers=None, timeout=None): + calls.append((url, headers, timeout)) + status = 200 if "dashscope.aliyuncs.com" in url else 401 + return types.SimpleNamespace(status_code=status) + + import httpx + monkeypatch.setattr(httpx, "get", fake_get) + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + out = buf.getvalue() + + assert "Alibaba/DashScope" in out + assert "invalid API key" not in out + assert any( + url == "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models" + for url, _, _ in calls + ) + assert any( + url == "https://dashscope.aliyuncs.com/compatible-mode/v1/models" + for url, _, _ in calls + ) + + @pytest.mark.parametrize("base_url", [None, "https://opencode.ai/zen/go/v1"]) def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path, base_url): home = tmp_path / ".hermes" diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index 6dfbd636f4c7..9d16ad10a711 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/tests/hermes_cli/test_gateway.py @@ -53,6 +53,43 @@ def fake_start_gateway(*, replace, verbosity): assert calls == [(True, None)] +def test_run_gateway_refuses_root_in_official_docker(monkeypatch, tmp_path, capsys): + project_root = tmp_path / "opt" / "hermes" + (project_root / "docker").mkdir(parents=True) + (project_root / "docker" / "entrypoint.sh").write_text("#!/bin/sh\n") + + monkeypatch.setattr(gateway, "PROJECT_ROOT", project_root) + monkeypatch.setattr(gateway.os, "geteuid", lambda: 0) + monkeypatch.delenv("HERMES_ALLOW_ROOT_GATEWAY", raising=False) + monkeypatch.setattr(gateway, "_is_official_docker_checkout", lambda: True) + + with pytest.raises(SystemExit) as exc_info: + gateway.run_gateway() + + assert exc_info.value.code == 1 + out = capsys.readouterr().out + assert "Refusing to run the Hermes gateway as root" in out + assert "/opt/hermes/docker/entrypoint.sh" in out + + +def test_run_gateway_root_guard_has_escape_hatch(monkeypatch): + calls = [] + + def fake_start_gateway(*, replace, verbosity): + calls.append((replace, verbosity)) + return object() + + _install_fake_gateway_run(monkeypatch, fake_start_gateway) + monkeypatch.setattr(gateway.asyncio, "run", lambda coro: True) + monkeypatch.setattr(gateway.os, "geteuid", lambda: 0) + monkeypatch.setattr(gateway, "_is_official_docker_checkout", lambda: True) + monkeypatch.setenv("HERMES_ALLOW_ROOT_GATEWAY", "1") + + gateway.run_gateway(verbose=2, replace=True) + + assert calls == [(True, 2)] + + class TestSystemdLingerStatus: def test_reports_enabled(self, monkeypatch): monkeypatch.setattr(gateway, "is_linux", lambda: True) diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 994e8d028467..15968f798edf 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -2,6 +2,7 @@ import os import pwd +import subprocess from pathlib import Path from types import SimpleNamespace @@ -90,6 +91,13 @@ def test_systemd_restart_refreshes_outdated_unit(self, tmp_path, monkeypatch): monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") calls = [] + monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) + monkeypatch.setattr(gateway_cli, "_recover_pending_systemd_restart", lambda system=False, previous_pid=None: False) + monkeypatch.setattr( + gateway_cli, + "_wait_for_systemd_service_restart", + lambda system=False, previous_pid=None: calls.append(("wait", system, previous_pid)) or True, + ) def fake_run(cmd, check=True, **kwargs): calls.append(cmd) @@ -100,11 +108,12 @@ def fake_run(cmd, check=True, **kwargs): gateway_cli.systemd_restart() assert unit_path.read_text(encoding="utf-8") == "new unit\n" - assert calls[:4] == [ + assert calls[:5] == [ ["systemctl", "--user", "daemon-reload"], - ["systemctl", "--user", "show", gateway_cli.get_service_name(), "--no-pager", "--property", "ActiveState,SubState,Result,ExecMainStatus"], + ["systemctl", "--user", "show", gateway_cli.get_service_name(), "--no-pager", "--property", "ActiveState,SubState,Result,ExecMainStatus,MainPID"], ["systemctl", "--user", "reset-failed", gateway_cli.get_service_name()], - ["systemctl", "--user", "reload-or-restart", gateway_cli.get_service_name()], + ["systemctl", "--user", "restart", gateway_cli.get_service_name()], + ("wait", False, None), ] def test_systemd_stop_marks_running_gateway_as_planned_stop(self, monkeypatch): @@ -611,62 +620,141 @@ def fake_run(*args, **kwargs): assert gateway_cli._is_service_running() is False class TestGatewaySystemServiceRouting: - def test_systemd_restart_self_requests_graceful_restart_and_waits(self, monkeypatch, capsys): + def test_systemd_restart_gracefully_restarts_running_service_and_waits(self, monkeypatch, capsys): calls = [] monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: calls.append(("refresh", system))) + monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 12.0) monkeypatch.setattr( "gateway.status.get_running_pid", lambda: 654, ) monkeypatch.setattr( gateway_cli, - "_request_gateway_self_restart", - lambda pid: calls.append(("self", pid)) or True, + "_graceful_restart_via_sigusr1", + lambda pid, timeout: calls.append(("graceful", pid, timeout)) or True, ) - # Simulate: old process dies immediately, new process becomes active - kill_call_count = [0] - def fake_kill(pid, sig): - kill_call_count[0] += 1 - if kill_call_count[0] >= 2: # first call checks, second = dead - raise ProcessLookupError() - monkeypatch.setattr(os, "kill", fake_kill) - - # Simulate systemctl reset-failed/start followed by an active unit - new_pid = [None] + # Simulate systemctl reset-failed/restart followed by an active unit. + # A plain start does not break systemd's auto-restart timer once the + # old gateway has exited with the planned restart code. def fake_subprocess_run(cmd, **kwargs): if "reset-failed" in cmd: calls.append(("reset-failed", cmd)) return SimpleNamespace(stdout="", returncode=0) - if "start" in cmd: - calls.append(("start", cmd)) + if "restart" in cmd: + calls.append(("restart", cmd)) return SimpleNamespace(stdout="", returncode=0) - if "show" in cmd: - new_pid[0] = 999 - return SimpleNamespace( - stdout="ActiveState=active\nSubState=running\nResult=success\nExecMainStatus=0\n", - returncode=0, - ) raise AssertionError(f"Unexpected systemctl call: {cmd}") monkeypatch.setattr(gateway_cli.subprocess, "run", fake_subprocess_run) - # get_running_pid returns new PID after restart - pid_calls = [0] - def fake_get_pid(): - pid_calls[0] += 1 - return 999 if pid_calls[0] > 1 else 654 - monkeypatch.setattr("gateway.status.get_running_pid", fake_get_pid) + monkeypatch.setattr( + gateway_cli, + "_wait_for_systemd_service_restart", + lambda system=False, previous_pid=None: calls.append(("wait", system, previous_pid)) or True, + ) gateway_cli.systemd_restart() - assert ("self", 654) in calls + assert ("graceful", 654, 17.0) in calls assert any(call[0] == "reset-failed" for call in calls) - assert any(call[0] == "start" for call in calls) + assert any(call[0] == "restart" for call in calls) + assert ("wait", False, 654) in calls out = capsys.readouterr().out.lower() - assert "restarted" in out + assert "restarting gracefully" in out + + def test_systemd_restart_uses_systemd_main_pid_when_pid_file_is_missing(self, monkeypatch, capsys): + calls = [] + + monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) + monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) + monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None) + monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 10.0) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) + monkeypatch.setattr( + gateway_cli, + "_read_systemd_unit_properties", + lambda system=False: { + "ActiveState": "active", + "SubState": "running", + "Result": "success", + "ExecMainStatus": "0", + "MainPID": "777", + }, + ) + monkeypatch.setattr( + gateway_cli, + "_graceful_restart_via_sigusr1", + lambda pid, timeout: calls.append(("graceful", pid, timeout)) or True, + ) + monkeypatch.setattr(gateway_cli, "_run_systemctl", lambda args, **kwargs: calls.append(args) or SimpleNamespace(stdout="", returncode=0)) + monkeypatch.setattr( + gateway_cli, + "_wait_for_systemd_service_restart", + lambda system=False, previous_pid=None: calls.append(("wait", system, previous_pid)) or True, + ) + + gateway_cli.systemd_restart() + + assert ("graceful", 777, 15.0) in calls + assert ("wait", False, 777) in calls + assert "restarting gracefully (pid 777)" in capsys.readouterr().out.lower() + + def test_wait_for_systemd_restart_waits_for_runtime_running(self, monkeypatch, capsys): + monkeypatch.setattr( + gateway_cli, + "_read_systemd_unit_properties", + lambda system=False: { + "ActiveState": "active", + "SubState": "running", + "Result": "success", + "ExecMainStatus": "0", + "MainPID": "999", + }, + ) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) + monkeypatch.setattr( + gateway_cli, + "_gateway_runtime_status_for_pid", + lambda pid: {"pid": pid, "gateway_state": "running"}, + ) + + assert gateway_cli._wait_for_systemd_service_restart(previous_pid=777, timeout=0.1) is True + assert "restarted (pid 999)" in capsys.readouterr().out.lower() + + def test_systemd_restart_reports_start_limit_hit(self, monkeypatch, capsys): + calls = [] + + monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) + monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) + monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None) + monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) + monkeypatch.setattr(gateway_cli, "_recover_pending_systemd_restart", lambda system=False, previous_pid=None: False) + + def fake_run_systemctl(args, **kwargs): + calls.append(args) + if args[0] == "show": + return SimpleNamespace(stdout="ActiveState=inactive\nSubState=dead\nResult=success\nExecMainStatus=0\nMainPID=0\n", stderr="", returncode=0) + if args[0] == "reset-failed": + return SimpleNamespace(stdout="", stderr="", returncode=0) + if args[0] == "restart": + raise subprocess.CalledProcessError( + 1, + ["systemctl", "--user", *args], + stderr="Job failed. See result 'start-limit-hit'.", + ) + raise AssertionError(f"Unexpected args: {args}") + + monkeypatch.setattr(gateway_cli, "_run_systemctl", fake_run_systemctl) + + gateway_cli.systemd_restart() + + assert ["restart", gateway_cli.get_service_name()] in calls + out = capsys.readouterr().out.lower() + assert "rate-limited by systemd" in out + assert "reset-failed" in out def test_systemd_restart_recovers_failed_planned_restart(self, monkeypatch, capsys): monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) @@ -711,6 +799,11 @@ def fake_subprocess_run(cmd, **kwargs): "gateway.status.get_running_pid", lambda: 999 if started["value"] else None, ) + monkeypatch.setattr( + gateway_cli, + "_gateway_runtime_status_for_pid", + lambda pid: {"pid": pid, "gateway_state": "running"}, + ) gateway_cli.systemd_restart() @@ -2177,3 +2270,171 @@ def fake_remove(interactive=True, dry_run=False): assert prompt_called["count"] == 0 assert remove_called["invoked"] is False + + +class TestSystemScopeRequiresRootError: + """Tests for the SystemScopeRequiresRootError replacement of sys.exit(1). + + Before this change, ``_require_root_for_system_service`` called + ``sys.exit(1)`` when non-root code tried a system-scope systemd + operation. The wizard's ``except Exception`` guards don't catch + ``SystemExit`` (it's a ``BaseException`` subclass), so the user was + dumped at a bare shell prompt mid-setup. The fix raises a typed + exception instead, which the wizard intercepts and handles with + actionable remediation. + """ + + def test_require_root_raises_when_non_root(self, monkeypatch): + monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000) + + with pytest.raises(gateway_cli.SystemScopeRequiresRootError) as excinfo: + gateway_cli._require_root_for_system_service("start") + + assert excinfo.value.args[0] == "System gateway start requires root. Re-run with sudo." + assert excinfo.value.args[1] == "start" + # str(e) renders only the message, not the tuple repr, so that + # wizard format strings like f"Failed: {e}" print cleanly. + assert str(excinfo.value) == "System gateway start requires root. Re-run with sudo." + assert f"Failed: {excinfo.value}" == "Failed: System gateway start requires root. Re-run with sudo." + + def test_require_root_noop_when_root(self, monkeypatch): + monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 0) + + # Should not raise, should not exit + gateway_cli._require_root_for_system_service("start") + + def test_error_is_runtime_error_subclass(self): + """Wizards use ``except Exception`` guards — the error must be a + ``RuntimeError`` (catchable by ``Exception``), NOT a ``SystemExit`` + (``BaseException``), so the wizard can recover from it. + """ + err = gateway_cli.SystemScopeRequiresRootError("msg", "start") + assert isinstance(err, RuntimeError) + assert isinstance(err, Exception) + assert not isinstance(err, SystemExit) + + +class TestSystemScopeWizardPreCheck: + """Tests for _system_scope_wizard_would_need_root — the guard the + wizard uses to detect the dead-end BEFORE prompting the user to start + a service that will fail without sudo. + """ + + @staticmethod + def _setup_units(tmp_path, monkeypatch, system_present: bool, user_present: bool): + sys_dir = tmp_path / "sys" + usr_dir = tmp_path / "usr" + sys_dir.mkdir() + usr_dir.mkdir() + if system_present: + (sys_dir / "hermes-gateway.service").write_text("[Unit]\n") + if user_present: + (usr_dir / "hermes-gateway.service").write_text("[Unit]\n") + monkeypatch.setattr( + gateway_cli, + "get_systemd_unit_path", + lambda system=False: (sys_dir if system else usr_dir) / "hermes-gateway.service", + ) + + def test_non_root_with_only_system_unit_returns_true(self, tmp_path, monkeypatch): + self._setup_units(tmp_path, monkeypatch, system_present=True, user_present=False) + monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000) + + assert gateway_cli._system_scope_wizard_would_need_root() is True + + def test_root_never_needs_root(self, tmp_path, monkeypatch): + self._setup_units(tmp_path, monkeypatch, system_present=True, user_present=False) + monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 0) + + assert gateway_cli._system_scope_wizard_would_need_root() is False + + def test_non_root_with_user_unit_present_returns_false(self, tmp_path, monkeypatch): + # User-scope unit present — user can start it themselves, no sudo needed. + self._setup_units(tmp_path, monkeypatch, system_present=True, user_present=True) + monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000) + + assert gateway_cli._system_scope_wizard_would_need_root() is False + + def test_non_root_with_no_units_returns_false(self, tmp_path, monkeypatch): + self._setup_units(tmp_path, monkeypatch, system_present=False, user_present=False) + monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000) + + assert gateway_cli._system_scope_wizard_would_need_root() is False + + def test_non_root_with_explicit_system_arg_returns_true(self, tmp_path, monkeypatch): + # Caller passed system=True explicitly (e.g. ``hermes gateway start --system``). + self._setup_units(tmp_path, monkeypatch, system_present=False, user_present=False) + monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000) + + assert gateway_cli._system_scope_wizard_would_need_root(system=True) is True + + +class TestSystemScopeRemediationOutput: + """Tests for _print_system_scope_remediation — the actionable guidance + shown when the wizard detects a system-scope-only setup as non-root. + """ + + def test_start_remediation_mentions_sudo_systemctl_and_uninstall(self, capsys, monkeypatch): + monkeypatch.setattr(gateway_cli, "get_service_name", lambda: "hermes-gateway") + + gateway_cli._print_system_scope_remediation("start") + out = capsys.readouterr().out + + assert "system-wide service" in out + assert "start requires root" in out + assert "sudo systemctl start hermes-gateway" in out + assert "sudo hermes gateway uninstall --system" in out + assert "hermes gateway install" in out + + def test_restart_remediation_uses_systemctl_restart(self, capsys, monkeypatch): + monkeypatch.setattr(gateway_cli, "get_service_name", lambda: "hermes-gateway") + + gateway_cli._print_system_scope_remediation("restart") + out = capsys.readouterr().out + + assert "restart requires root" in out + assert "sudo systemctl restart hermes-gateway" in out + + def test_stop_remediation_uses_systemctl_stop(self, capsys, monkeypatch): + monkeypatch.setattr(gateway_cli, "get_service_name", lambda: "hermes-gateway") + + gateway_cli._print_system_scope_remediation("stop") + out = capsys.readouterr().out + + assert "stop requires root" in out + assert "sudo systemctl stop hermes-gateway" in out + + +class TestGatewayCommandCatchesSystemScopeError: + """The direct CLI path (``hermes gateway start --system`` etc.) must + still exit 1 with a clean message when non-root. The top-level + ``gateway_command`` catches ``SystemScopeRequiresRootError`` and + converts it back to ``sys.exit(1)``, preserving existing CLI behavior. + """ + + def test_non_root_system_start_exits_one_with_clean_message(self, tmp_path, monkeypatch, capsys): + sys_dir = tmp_path / "sys" + usr_dir = tmp_path / "usr" + sys_dir.mkdir() + usr_dir.mkdir() + (sys_dir / "hermes-gateway.service").write_text("[Unit]\n") + monkeypatch.setattr( + gateway_cli, + "get_systemd_unit_path", + lambda system=False: (sys_dir if system else usr_dir) / "hermes-gateway.service", + ) + monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 1000) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + monkeypatch.setattr(gateway_cli, "kill_gateway_processes", lambda **kw: 0) + + args = SimpleNamespace(gateway_command="start", system=True, all=False) + + with pytest.raises(SystemExit) as excinfo: + gateway_cli.gateway_command(args) + + assert excinfo.value.code == 1 + out = capsys.readouterr().out + # Renders the message, NOT the ``('msg', 'action')`` tuple repr + assert "System gateway start requires root. Re-run with sudo." in out + assert "('" not in out # no tuple repr leaking through diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 95dfdae82dc4..306112c64a37 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -90,22 +90,20 @@ def _bad_spawn(task, ws): conn = kb.connect() try: tid = kb.create_task(conn, title="x", assignee="worker") - # Three ticks below the default limit (5) → still ready, counter grows. - for i in range(3): - res = kb.dispatch_once(conn, spawn_fn=_bad_spawn, failure_limit=5) - assert tid not in res.auto_blocked + assert kb.DEFAULT_FAILURE_LIMIT == 2 + # One default-limit failure → still ready, counter grows. + res1 = kb.dispatch_once(conn, spawn_fn=_bad_spawn) + assert tid not in res1.auto_blocked task = kb.get_task(conn, tid) assert task.status == "ready" - assert task.consecutive_failures == 3 + assert task.consecutive_failures == 1 - # Two more ticks → fifth failure exceeds the limit. - res1 = kb.dispatch_once(conn, spawn_fn=_bad_spawn, failure_limit=5) - assert tid not in res1.auto_blocked - res2 = kb.dispatch_once(conn, spawn_fn=_bad_spawn, failure_limit=5) + # Second default-limit failure trips the guard. + res2 = kb.dispatch_once(conn, spawn_fn=_bad_spawn) assert tid in res2.auto_blocked task = kb.get_task(conn, tid) assert task.status == "blocked" - assert task.consecutive_failures >= 5 + assert task.consecutive_failures >= 2 assert task.last_failure_error and "no PATH" in task.last_failure_error finally: conn.close() @@ -170,6 +168,27 @@ def test_successful_completion_resets_failure_counter(kanban_home, all_assignees conn.close() +def test_reassign_resets_failure_counter_for_new_profile(kanban_home, all_assignees_spawnable): + """Retry streaks are scoped to a task/profile pair; reassigning is a + human recovery action and gives the new profile a fresh budget.""" + conn = kb.connect() + try: + tid = kb.create_task(conn, title="x", assignee="worker") + with kb.write_txn(conn): + conn.execute( + "UPDATE tasks SET consecutive_failures = 1, " + "last_failure_error = 'timed out' WHERE id = ?", + (tid,), + ) + assert kb.assign_task(conn, tid, "reviewer") is True + task = kb.get_task(conn, tid) + assert task.assignee == "reviewer" + assert task.consecutive_failures == 0 + assert task.last_failure_error is None + finally: + conn.close() + + def test_workspace_resolution_failure_also_counts(kanban_home, all_assignees_spawnable): """`dir:` workspace with no path should fail workspace resolution AND count against the failure budget — not just crash the tick.""" @@ -719,6 +738,48 @@ def _signal_fn(pid, sig): _kb._pid_alive = original_alive +def test_repeated_timeouts_auto_block_at_default_limit(kanban_home): + """Two timed_out outcomes on the same task/profile trip the retry guard.""" + import hermes_cli.kanban_db as _kb + original_alive = _kb._pid_alive + _kb._pid_alive = lambda pid: False + + def _age_active_run(conn, tid): + old_started = int(time.time()) - 30 + with kb.write_txn(conn): + conn.execute( + "UPDATE task_runs SET started_at = ? " + "WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)", + (old_started, tid), + ) + + try: + conn = kb.connect() + try: + tid = kb.create_task( + conn, title="long job", assignee="worker", + max_runtime_seconds=1, + ) + for expected_failures in (1, 2): + kb.claim_task(conn, tid) + kb._set_worker_pid(conn, tid, os.getpid()) + _age_active_run(conn, tid) + timed_out = kb.enforce_max_runtime(conn, signal_fn=lambda pid, sig: None) + assert tid in timed_out + task = kb.get_task(conn, tid) + assert task.consecutive_failures == expected_failures + task = kb.get_task(conn, tid) + assert task.status == "blocked" + events = kb.list_events(conn, tid) + assert [e.kind for e in events].count("timed_out") == 2 + gave_up = [e for e in events if e.kind == "gave_up"] + assert gave_up and gave_up[-1].payload["trigger_outcome"] == "timed_out" + finally: + conn.close() + finally: + _kb._pid_alive = original_alive + + def test_max_runtime_none_means_no_cap(kanban_home): """A task with max_runtime_seconds=None is never timed out regardless of how long it runs.""" @@ -2648,6 +2709,203 @@ def test_legacy_db_without_skills_column_migrates(tmp_path): conn.close() +def test_legacy_spawn_failure_columns_are_copied_not_renamed(tmp_path): + """Legacy failure counters survive migration without fragile column renames.""" + import sqlite3 + db_path = tmp_path / "legacy-failures.db" + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + conn.execute(""" + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + body TEXT, + assignee TEXT, + status TEXT NOT NULL, + priority INTEGER DEFAULT 0, + created_by TEXT, + created_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER, + workspace_kind TEXT NOT NULL DEFAULT 'scratch', + workspace_path TEXT, + claim_lock TEXT, + claim_expires INTEGER, + tenant TEXT, + result TEXT, + idempotency_key TEXT, + spawn_failures INTEGER NOT NULL DEFAULT 0, + worker_pid INTEGER, + last_spawn_error TEXT + ) + """) + conn.execute(""" + CREATE TABLE task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT, + created_at INTEGER NOT NULL + ) + """) + # task_events is required: _migrate_add_optional_columns also runs a + # PRAGMA on it to back-fill the run_id column and raises + # OperationalError if the table is absent. + conn.execute( + "INSERT INTO tasks " + "(id, title, body, assignee, status, priority, created_by, created_at, " + "started_at, completed_at, workspace_kind, workspace_path, claim_lock, " + "claim_expires, tenant, result, idempotency_key, spawn_failures, " + "worker_pid, last_spawn_error) " + "VALUES ('legacy', 'old task', NULL, 'default', 'ready', 0, NULL, 1, " + "NULL, NULL, 'scratch', NULL, NULL, NULL, NULL, NULL, NULL, 4, NULL, " + "'missing profile')" + ) + conn.commit() + + kb._migrate_add_optional_columns(conn) + cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)")} + assert "spawn_failures" in cols + assert "consecutive_failures" in cols + assert "last_spawn_error" in cols + assert "last_failure_error" in cols + + row = conn.execute("SELECT * FROM tasks WHERE id = 'legacy'").fetchone() + assert row["consecutive_failures"] == 4 + assert row["last_failure_error"] == "missing profile" + task = kb.Task.from_row(row) + assert task.consecutive_failures == 4 + assert task.last_failure_error == "missing profile" + + kb._migrate_add_optional_columns(conn) + row_again = conn.execute("SELECT * FROM tasks WHERE id = 'legacy'").fetchone() + assert row_again["consecutive_failures"] == 4 + assert row_again["last_failure_error"] == "missing profile" + conn.close() + + +def test_legacy_migration_no_legacy_columns_at_all(tmp_path): + """Scenario A: DB has neither spawn_failures nor consecutive_failures. + + This is the exact crash scenario from issue #20842 — a very old DB that + predates the spawn_failures column entirely. The old RENAME COLUMN path + raised ``sqlite3.OperationalError: no such column: spawn_failures``. + The ADD-first approach adds consecutive_failures with default 0. + """ + import sqlite3 + + db_path = tmp_path / "ancient.db" + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + conn.execute(""" + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """) + # task_events is required: _migrate_add_optional_columns also runs a + # PRAGMA on it to back-fill the run_id column and raises + # OperationalError if the table is absent. + conn.execute(""" + CREATE TABLE task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT, + created_at INTEGER NOT NULL + ) + """) + conn.execute( + "INSERT INTO tasks (id, title, status, created_at) " + "VALUES ('t1', 'ancient task', 'ready', 1)" + ) + conn.commit() + + # Must not raise (this was the crash before this fix). + kb._migrate_add_optional_columns(conn) + + cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)")} + assert "consecutive_failures" in cols, "migration must add consecutive_failures" + assert "last_failure_error" in cols, "migration must add last_failure_error" + assert "spawn_failures" not in cols, "no legacy column should be synthesised" + + row = conn.execute("SELECT * FROM tasks WHERE id = 't1'").fetchone() + assert row["consecutive_failures"] == 0 + assert row["last_failure_error"] is None + + # Idempotent second run must not raise either. + kb._migrate_add_optional_columns(conn) + row_again = conn.execute("SELECT * FROM tasks WHERE id = 't1'").fetchone() + assert row_again["consecutive_failures"] == 0 + assert row_again["last_failure_error"] is None + conn.close() + + +def test_legacy_migration_both_columns_already_present(tmp_path): + """Scenario D: DB already has both spawn_failures AND consecutive_failures. + + Represents a partially-migrated DB (e.g. user recovered manually after the + #20842 crash). The migration must be a complete no-op and must not + zero-out the existing counter. + """ + import sqlite3 + + db_path = tmp_path / "partial.db" + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + conn.execute(""" + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + spawn_failures INTEGER NOT NULL DEFAULT 0, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + last_spawn_error TEXT, + last_failure_error TEXT + ) + """) + # task_events required for the run_id back-fill PRAGMA inside the migrator. + conn.execute(""" + CREATE TABLE task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT, + created_at INTEGER NOT NULL + ) + """) + conn.execute( + "INSERT INTO tasks (id, title, status, created_at, spawn_failures, " + "consecutive_failures, last_spawn_error, last_failure_error) " + "VALUES ('t2', 'partial task', 'ready', 1, 2, 3, 'old error', 'new error')" + ) + conn.commit() + + kb._migrate_add_optional_columns(conn) + + row = conn.execute("SELECT * FROM tasks WHERE id = 't2'").fetchone() + # consecutive_failures must not be reset by the migration. + assert row["consecutive_failures"] == 3, "migration must not overwrite existing counter" + assert row["last_failure_error"] == "new error", "migration must not overwrite existing error" + # Legacy column is preserved harmlessly. + assert row["spawn_failures"] == 2 + + # Schema must be unchanged — no spurious ADD or DROP. + cols_after = {r[1] for r in conn.execute("PRAGMA table_info(tasks)")} + assert "consecutive_failures" in cols_after + assert "last_failure_error" in cols_after + assert "spawn_failures" in cols_after # legacy preserved + + # Idempotent second run must not modify values or raise. + kb._migrate_add_optional_columns(conn) + row_again = conn.execute("SELECT * FROM tasks WHERE id = 't2'").fetchone() + assert row_again["consecutive_failures"] == 3 + assert row_again["last_failure_error"] == "new error" + conn.close() + # --------------------------------------------------------------------------- # Gateway-embedded dispatcher: config, CLI warnings, daemon deprecation stub @@ -3086,17 +3344,28 @@ def test_complete_prose_scan_ignores_existing_ids(kanban_home): # Recovery helpers (reclaim + reassign) # --------------------------------------------------------------------------- -def test_reclaim_task_resets_running_to_ready(kanban_home): +def test_reclaim_task_resets_running_to_ready(kanban_home, monkeypatch): """Manual reclaim releases the claim, resets status, and emits a ``reclaimed`` event even when claim_expires has not passed.""" + import signal import time import secrets + import hermes_cli.kanban_db as _kb conn = kb.connect() try: t = kb.create_task(conn, title="stuck", assignee="broken") # Simulate a live claim (not expired). - lock = secrets.token_hex(8) + lock = f"{_kb._claimer_id().split(':', 1)[0]}:{secrets.token_hex(8)}" future = int(time.time()) + 3600 + killed: list[int] = [] + state = {"alive": True} + + def _signal(pid, sig): + killed.append(sig) + if sig == signal.SIGTERM: + state["alive"] = False + + monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: state["alive"]) conn.execute( "UPDATE tasks SET status='running', claim_lock=?, claim_expires=?, " "worker_pid=? WHERE id=?", @@ -3115,7 +3384,7 @@ def test_reclaim_task_resets_running_to_ready(kanban_home): assert kb.release_stale_claims(conn) == 0 # reclaim_task should work immediately. - assert kb.reclaim_task(conn, t, reason="test reason") is True + assert kb.reclaim_task(conn, t, reason="test reason", signal_fn=_signal) is True row = conn.execute( "SELECT status, claim_lock, worker_pid FROM tasks WHERE id=?", @@ -3136,6 +3405,9 @@ def test_reclaim_task_resets_running_to_ready(kanban_home): assert len(reclaim_evs) == 1 assert reclaim_evs[0].get("manual") is True assert reclaim_evs[0].get("reason") == "test reason" + assert reclaim_evs[0].get("termination_attempted") is True + assert reclaim_evs[0].get("terminated") is True + assert killed == [signal.SIGTERM] finally: conn.close() @@ -3364,6 +3636,100 @@ def test_detect_crashed_workers_increments_counter(kanban_home): conn.close() +def test_detect_crashed_workers_protocol_violation_auto_blocks(kanban_home): + """A worker that exited rc=0 while its task was still ``running`` + is a protocol violation (agent answered conversationally without + calling kanban_complete / kanban_block). Retrying will just loop, + so auto-block immediately instead of waiting for the breaker to + trip at ``DEFAULT_FAILURE_LIMIT``. + + Regression test for the respawn-loop-after-completion bug reported + against small local models (gemma4-e2b q4) where the model writes + the answer as plain text and the CLI exits rc=0 cleanly. + """ + import hermes_cli.kanban_db as _kb + conn = kb.connect() + try: + tid = kb.create_task(conn, title="quiet", assignee="worker") + host_prefix = _kb._claimer_id().split(":", 1)[0] + lock = f"{host_prefix}:mock" + kb.claim_task(conn, tid, claimer=lock) + fake_pid = 999998 + kb._set_worker_pid(conn, tid, fake_pid) + + # Simulate the reap loop having recorded a clean exit for this pid. + # os.W_EXITCODE(status=0, signal=0) == 0 on POSIX. + _kb._record_worker_exit(fake_pid, 0) + # Force liveness check to say "dead" for the fake pid. + original_alive = _kb._pid_alive + _kb._pid_alive = lambda p: False + try: + result_crashed = kb.detect_crashed_workers(conn) + finally: + _kb._pid_alive = original_alive + + assert tid in result_crashed, "should be detected as crashed" + task = kb.get_task(conn, tid) + assert task.status == "blocked", ( + f"protocol violation should auto-block on first occurrence, " + f"got status={task.status}" + ) + assert "kanban_complete" in (task.last_failure_error or ""), ( + f"expected protocol-violation message, got {task.last_failure_error!r}" + ) + + events = kb.list_events(conn, tid) + kinds = [e.kind for e in events] + assert "protocol_violation" in kinds, ( + f"expected 'protocol_violation' event, got {kinds}" + ) + # The ``crashed`` event would be misleading here — the worker + # didn't crash, it returned 0. + assert "crashed" not in kinds, ( + f"should NOT emit 'crashed' event on clean exit, got {kinds}" + ) + assert "gave_up" in kinds, ( + f"breaker should trip, expected 'gave_up' event, got {kinds}" + ) + finally: + conn.close() + + +def test_detect_crashed_workers_nonzero_exit_uses_default_limit(kanban_home): + """A worker that exited non-zero (real error / crash) uses the + normal counter path — one failure doesn't trip the breaker. + """ + import hermes_cli.kanban_db as _kb + conn = kb.connect() + try: + tid = kb.create_task(conn, title="crashy", assignee="worker") + host_prefix = _kb._claimer_id().split(":", 1)[0] + kb.claim_task(conn, tid, claimer=f"{host_prefix}:mock") + fake_pid = 999997 + kb._set_worker_pid(conn, tid, fake_pid) + + # W_EXITCODE(1, 0) == 256 — WIFEXITED True, WEXITSTATUS == 1. + _kb._record_worker_exit(fake_pid, 256) + original_alive = _kb._pid_alive + _kb._pid_alive = lambda p: False + try: + kb.detect_crashed_workers(conn) + finally: + _kb._pid_alive = original_alive + + task = kb.get_task(conn, tid) + assert task.status == "ready", ( + f"single non-zero crash shouldn't auto-block, got {task.status}" + ) + assert task.consecutive_failures == 1 + events = kb.list_events(conn, tid) + kinds = [e.kind for e in events] + assert "crashed" in kinds + assert "protocol_violation" not in kinds + finally: + conn.close() + + def test_reclaim_task_clears_failure_counter(kanban_home): """Operator reclaim wipes the counter so the next retry gets a fresh budget.""" diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 7068e773d1b0..d6266662c4ac 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -168,18 +168,79 @@ def test_claim_fails_on_non_ready(kanban_home): assert kb.claim_task(conn, t) is None -def test_stale_claim_reclaimed(kanban_home): +def test_stale_claim_reclaimed(kanban_home, monkeypatch): + import signal + import hermes_cli.kanban_db as _kb + with kb.connect() as conn: t = kb.create_task(conn, title="x", assignee="a") - kb.claim_task(conn, t) + host = _kb._claimer_id().split(":", 1)[0] + kb.claim_task(conn, t, claimer=f"{host}:worker") + killed: list[int] = [] + state = {"alive": True} + + def _signal(pid, sig): + killed.append(sig) + if sig == signal.SIGTERM: + state["alive"] = False + + kb._set_worker_pid(conn, t, 12345) # Rewind claim_expires so it looks stale. conn.execute( "UPDATE tasks SET claim_expires = ? WHERE id = ?", (int(time.time()) - 3600, t), ) - reclaimed = kb.release_stale_claims(conn) + monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: state["alive"]) + reclaimed = kb.release_stale_claims(conn, signal_fn=_signal) assert reclaimed == 1 assert kb.get_task(conn, t).status == "ready" + assert killed == [signal.SIGTERM] + + +def test_max_runtime_uses_current_run_start_after_retry(kanban_home): + """A retry should get a fresh max-runtime window. + + ``tasks.started_at`` intentionally records the first time the task ever + started. Runtime enforcement must therefore use the active + ``task_runs.started_at`` row; otherwise every retry of an old task is + immediately timed out again. + """ + with kb.connect() as conn: + host = kb._claimer_id().split(":", 1)[0] + t = kb.create_task( + conn, title="retry", assignee="a", max_runtime_seconds=10, + ) + + kb.claim_task(conn, t, claimer=f"{host}:first") + first_run_id = kb.latest_run(conn, t).id + old_started = int(time.time()) - 20 + conn.execute( + "UPDATE tasks SET started_at = ?, worker_pid = ? WHERE id = ?", + (old_started, 999999, t), + ) + conn.execute( + "UPDATE task_runs SET started_at = ?, worker_pid = ? WHERE id = ?", + (old_started, 999999, first_run_id), + ) + + timed_out = kb.enforce_max_runtime(conn, signal_fn=lambda _pid, _sig: None) + assert timed_out == [t] + assert kb.get_task(conn, t).status == "ready" + + kb.claim_task(conn, t, claimer=f"{host}:retry") + retry_run = kb.latest_run(conn, t) + conn.execute( + "UPDATE tasks SET worker_pid = ? WHERE id = ?", + (999999, t), + ) + conn.execute( + "UPDATE task_runs SET worker_pid = ? WHERE id = ?", + (999999, retry_run.id), + ) + + timed_out = kb.enforce_max_runtime(conn, signal_fn=lambda _pid, _sig: None) + assert timed_out == [] + assert kb.get_task(conn, t).status == "running" def test_max_runtime_uses_current_run_start_after_retry(kanban_home): diff --git a/tests/hermes_cli/test_mcp_add_command_dest.py b/tests/hermes_cli/test_mcp_add_command_dest.py new file mode 100644 index 000000000000..09e47df95a7e --- /dev/null +++ b/tests/hermes_cli/test_mcp_add_command_dest.py @@ -0,0 +1,87 @@ +"""Regression test: ``hermes mcp add --command`` must not clobber the +top-level ``args.command`` subparser dest. + +The top-level argparse parser uses ``dest="command"`` for its subparsers +(``hermes_cli/_parser.py``). The dispatcher in ``hermes_cli/main.py`` +reads ``args.command`` to decide which command to run; if it is ``None`` +it falls through to interactive chat. + +The ``mcp add`` subparser exposes a ``--command`` flag (the stdio command +for an MCP server, e.g. ``npx``). Without an explicit ``dest=``, argparse +derives the dest from the flag name and writes ``args.command = None`` +when the flag is omitted, overwriting the top-level ``"mcp"`` value. As a +result, ``hermes mcp add foo --url ...`` silently launches chat instead +of registering an MCP server. + +The fix: declare the flag with ``dest="mcp_command"``. The CLI flag name +is unchanged; only the in-memory attribute moves. + +We replicate the relevant parser shape here rather than importing the +real builder, mirroring ``test_argparse_flag_propagation.py`` and +``test_subparser_routing_fallback.py``. +""" + +import argparse + + +def _build_parser(): + """Minimal replica of the slice of the hermes parser that exhibits + the bug: top-level subparsers (dest="command") and ``mcp add`` with + its ``--command`` flag. + """ + parser = argparse.ArgumentParser(prog="hermes") + subparsers = parser.add_subparsers(dest="command") + + subparsers.add_parser("chat") + + mcp_p = subparsers.add_parser("mcp") + mcp_sub = mcp_p.add_subparsers(dest="mcp_action") + + mcp_add = mcp_sub.add_parser("add") + mcp_add.add_argument("name") + mcp_add.add_argument("--url") + mcp_add.add_argument("--command", dest="mcp_command") + + return parser + + +class TestMcpAddCommandDest: + def test_url_invocation_preserves_top_level_command(self): + """`hermes mcp add foo --url ...` must keep args.command == "mcp". + + Before the dest fix this was clobbered to None, sending the + dispatcher into the chat fallback. + """ + parser = _build_parser() + args = parser.parse_args( + ["mcp", "add", "foo", "--url", "https://example.com/mcp"] + ) + + assert args.command == "mcp" + assert args.mcp_action == "add" + assert args.name == "foo" + assert args.url == "https://example.com/mcp" + assert args.mcp_command is None + + def test_command_flag_writes_to_mcp_command_dest(self): + """`--command npx` must populate args.mcp_command, not args.command.""" + parser = _build_parser() + args = parser.parse_args( + ["mcp", "add", "github", "--command", "npx"] + ) + + assert args.command == "mcp" + assert args.mcp_command == "npx" + + def test_bare_mcp_add_does_not_clobber_command(self): + """Even without --url or --command, args.command stays "mcp". + + Catches the regression at the parser layer regardless of which + transport flag the user passes. + """ + parser = _build_parser() + args = parser.parse_args(["mcp", "add", "foo"]) + + assert args.command == "mcp" + assert args.mcp_command is None + assert args.url is None diff --git a/tests/hermes_cli/test_mcp_config.py b/tests/hermes_cli/test_mcp_config.py index 979108a951c3..e136f1b3c0fc 100644 --- a/tests/hermes_cli/test_mcp_config.py +++ b/tests/hermes_cli/test_mcp_config.py @@ -43,7 +43,7 @@ def _make_args(**kwargs): defaults = { "name": "test-server", "url": None, - "command": None, + "mcp_command": None, "args": None, "auth": None, "preset": None, @@ -233,7 +233,7 @@ def mock_probe(name, config, **kw): cmd_mcp_add(_make_args( name="github", - command="npx", + mcp_command="npx", args=["@mcp/github"], )) out = capsys.readouterr().out @@ -291,7 +291,7 @@ def mock_probe(name, config, **kw): cmd_mcp_add(_make_args( name="github", - command="npx", + mcp_command="npx", args=["@mcp/github"], env=["MY_API_KEY=secret123", "DEBUG=true"], )) @@ -313,7 +313,7 @@ def test_add_stdio_server_rejects_invalid_env_name(self, capsys): cmd_mcp_add(_make_args( name="github", - command="npx", + mcp_command="npx", args=["@mcp/github"], env=["BAD-NAME=value"], )) @@ -390,7 +390,7 @@ def mock_probe(name, config, **kw): cmd_mcp_add(_make_args( name="custom", preset="testmcp", - command="uvx", + mcp_command="uvx", args=["custom-server"], )) out = capsys.readouterr().out diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 624cba9c9938..84734e622d5f 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -506,3 +506,64 @@ def _fake_fetch(api_key=None, base_url=None, timeout=5.0): ) assert "base_url" not in captured + + +def test_custom_providers_uses_live_models_for_multi_model_endpoint(monkeypatch): + """Custom providers with api_key + base_url should prefer live /models. + + Custom providers (section 4 of list_authenticated_providers) point at + gateways like Bifrost that expose hundreds of models. Reading only the + static ``models:`` dict from config.yaml leaves the /model picker with + a stale subset. Live discovery fills the picker with all available + models from the endpoint. + """ + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {}) + + calls = [] + + def fake_fetch_api_models(api_key, base_url): + calls.append((api_key, base_url)) + return ["gateway-model-a", "gateway-model-b", "gateway-model-c"] + + monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models) + + custom_providers = [ + { + "name": "my-gateway", + "api_key": "sk-gateway-key", + "base_url": "https://gateway.example.com/v1", + "model": "gateway-model-a", + "models": { + "gateway-model-a": {"context_length": 128000}, + "gateway-model-b": {"context_length": 128000}, + }, + } + ] + + providers = list_authenticated_providers( + current_provider="openrouter", + current_base_url="https://openrouter.ai/api/v1", + custom_providers=custom_providers, + max_models=50, + ) + + gateway_prov = next( + ( + p + for p in providers + if p.get("api_url") == "https://gateway.example.com/v1" + ), + None, + ) + + assert gateway_prov is not None, "Custom provider group not found in results" + assert calls == [("sk-gateway-key", "https://gateway.example.com/v1")], ( + "fetch_api_models must be called with the custom provider's credentials" + ) + assert gateway_prov["models"] == [ + "gateway-model-a", + "gateway-model-b", + "gateway-model-c", + ], "Live models must replace the static subset" + assert gateway_prov["total_models"] == 3 diff --git a/tests/hermes_cli/test_opencode_go_flat_namespace.py b/tests/hermes_cli/test_opencode_go_flat_namespace.py new file mode 100644 index 000000000000..86500be3e91a --- /dev/null +++ b/tests/hermes_cli/test_opencode_go_flat_namespace.py @@ -0,0 +1,159 @@ +"""Tests for opencode-go / opencode-zen flat-namespace model handling. + +OpenCode Go is NOT a vendor/model aggregator like OpenRouter — its +``/v1/models`` endpoint returns bare IDs (``minimax-m2.7``, ``deepseek-v4-flash``) +and the inference API rejects vendor-prefixed names with HTTP 401 +"Model not supported". + +Two bugs this exercises: + +1. ``switch_model('deepseek-v4-flash', current_provider='opencode-go')`` used + to silently switch the user off opencode-go to native ``deepseek`` because + ``detect_provider_for_model`` matched the bare name against the static + deepseek catalog. Fix: once step d matches the model in the current + aggregator's live catalog, skip ``detect_provider_for_model``. + +2. ``normalize_model_for_provider('minimax/minimax-m2.7', 'opencode-go')`` + used to pass the ``minimax/`` prefix through unchanged. When user configs + contained prefixed fallback entries (commonly copied from aggregator slugs), + the fallback activation path sent ``minimax/minimax-m2.7`` to opencode-go + which returned HTTP 401. Fix: opencode-go/opencode-zen strip ANY leading + ``vendor/`` prefix because their APIs are flat-namespace. +""" + +from unittest.mock import patch + +from hermes_cli.model_normalize import normalize_model_for_provider +from hermes_cli.model_switch import switch_model + + +# Live catalog opencode-go currently returns from /v1/models (snapshot). +_OPENCODE_GO_LIVE = [ + "minimax-m2.7", "minimax-m2.5", + "kimi-k2.6", "kimi-k2.5", + "glm-5.1", "glm-5", + "deepseek-v4-pro", "deepseek-v4-flash", + "qwen3.6-plus", "qwen3.5-plus", + "mimo-v2-pro", "mimo-v2-omni", "mimo-v2.5-pro", "mimo-v2.5", +] + + +# --------------------------------------------------------------------------- +# normalize_model_for_provider: strip vendor prefix for flat-namespace providers +# --------------------------------------------------------------------------- + + +def test_opencode_go_strips_deepseek_prefix(): + assert normalize_model_for_provider( + "deepseek/deepseek-v4-flash", "opencode-go" + ) == "deepseek-v4-flash" + + +def test_opencode_go_strips_minimax_prefix(): + assert normalize_model_for_provider( + "minimax/minimax-m2.7", "opencode-go" + ) == "minimax-m2.7" + + +def test_opencode_go_strips_moonshotai_prefix(): + # Moonshot's aggregator vendor is `moonshotai/...` — a common copy-paste + # from OpenRouter slugs. opencode-go serves it bare as `kimi-k2.6`. + assert normalize_model_for_provider( + "moonshotai/kimi-k2.6", "opencode-go" + ) == "kimi-k2.6" + + +def test_opencode_go_bare_name_unchanged(): + assert normalize_model_for_provider( + "kimi-k2.6", "opencode-go" + ) == "kimi-k2.6" + + +def test_opencode_go_preserves_dot_versioning(): + # opencode-go uses dot-versioned IDs (`mimo-v2.5-pro`, not hyphen). + assert normalize_model_for_provider( + "xiaomi/mimo-v2.5-pro", "opencode-go" + ) == "mimo-v2.5-pro" + + +def test_opencode_zen_still_hyphenates_claude(): + # Regression: opencode-zen's Claude hyphen conversion must still work. + assert normalize_model_for_provider( + "anthropic/claude-sonnet-4.6", "opencode-zen" + ) == "claude-sonnet-4-6" + + +def test_opencode_zen_bare_claude_hyphenated(): + assert normalize_model_for_provider( + "claude-sonnet-4.6", "opencode-zen" + ) == "claude-sonnet-4-6" + + +def test_opencode_zen_strips_arbitrary_vendor_prefix(): + assert normalize_model_for_provider( + "minimax/minimax-m2.5-free", "opencode-zen" + ) == "minimax-m2.5-free" + + +def test_openrouter_still_prepends_vendor(): + # Regression: real aggregators must still get vendor/model format. + assert normalize_model_for_provider( + "claude-sonnet-4.6", "openrouter" + ) == "anthropic/claude-sonnet-4.6" + + +# --------------------------------------------------------------------------- +# switch_model: live-catalog match on opencode-go must not trigger +# cross-provider auto-switch via detect_provider_for_model +# --------------------------------------------------------------------------- + + +def _run_switch(raw_input: str, **extra): + """Call switch_model with opencode-go as current provider, mocking the + live catalog so the test doesn't hit the network.""" + defaults = dict( + current_provider="opencode-go", + current_model="kimi-k2.6", + current_base_url="https://opencode.ai/zen/go/v1", + current_api_key="sk-test-opencode-go", + is_global=False, + ) + defaults.update(extra) + + def fake_list_provider_models(provider: str): + if provider == "opencode-go": + return list(_OPENCODE_GO_LIVE) + # For other providers, return empty so tests don't depend on them. + return [] + + with patch( + "hermes_cli.model_switch.list_provider_models", + side_effect=fake_list_provider_models, + ): + return switch_model(raw_input=raw_input, **defaults) + + +def test_deepseek_v4_flash_stays_on_opencode_go(): + """Regression: ``/model deepseek-v4-flash`` while on opencode-go must + NOT switch to native deepseek just because deepseek's static catalog + also contains that name.""" + result = _run_switch("deepseek-v4-flash") + assert result.target_provider == "opencode-go", ( + f"Expected to stay on opencode-go, got {result.target_provider}. " + f"detect_provider_for_model hijacked the bare name." + ) + assert result.new_model == "deepseek-v4-flash" + + +def test_deepseek_v4_pro_stays_on_opencode_go(): + """Same bug class as the flash variant.""" + result = _run_switch("deepseek-v4-pro") + assert result.target_provider == "opencode-go" + assert result.new_model == "deepseek-v4-pro" + + +def test_kimi_k2_6_stays_on_opencode_go(): + """Regression guard: this path was always working, keep it working.""" + result = _run_switch("kimi-k2.6", current_model="deepseek-v4-pro") + assert result.target_provider == "opencode-go" + assert result.new_model == "kimi-k2.6" diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 0c2a4a884259..84e8404a8f25 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -330,6 +330,7 @@ def test_valid_hooks_include_request_scoped_api_hooks(self): assert "post_api_request" in VALID_HOOKS assert "transform_terminal_output" in VALID_HOOKS assert "transform_tool_result" in VALID_HOOKS + assert "transform_llm_output" in VALID_HOOKS def test_valid_hooks_include_pre_gateway_dispatch(self): assert "pre_gateway_dispatch" in VALID_HOOKS diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 7ddb8fd20a86..130b1c39e40b 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -33,6 +33,9 @@ generate_zsh_completion, _get_profiles_root, _get_default_hermes_home, + seed_profile_skills, + has_bundled_skills_opt_out, + NO_BUNDLED_SKILLS_MARKER, ) @@ -243,6 +246,116 @@ def test_clone_config_missing_files_skipped(self, profile_env): assert (profile_dir / "SOUL.md").exists() +# =================================================================== +# TestNoSkillsOptOut +# =================================================================== + +class TestNoSkillsOptOut: + """Tests for `hermes profile create --no-skills` and the opt-out marker.""" + + def test_no_skills_writes_marker_and_skips_seeding(self, profile_env): + profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True) + + # Marker file is present + marker = profile_dir / NO_BUNDLED_SKILLS_MARKER + assert marker.is_file(), "expected .no-bundled-skills marker in profile root" + assert "--no-skills" in marker.read_text() + + # has_bundled_skills_opt_out() agrees + assert has_bundled_skills_opt_out(profile_dir) is True + + # skills/ dir exists (profile bootstrapping still creates the dir) but + # contains nothing yet because create_profile itself doesn't seed. + assert (profile_dir / "skills").is_dir() + assert list((profile_dir / "skills").iterdir()) == [] + + def test_no_skills_conflicts_with_clone(self, profile_env): + with pytest.raises(ValueError, match="mutually exclusive"): + create_profile( + "orchestrator", + no_alias=True, + no_skills=True, + clone_config=True, + ) + + def test_no_skills_conflicts_with_clone_all(self, profile_env): + with pytest.raises(ValueError, match="mutually exclusive"): + create_profile( + "orchestrator", + no_alias=True, + no_skills=True, + clone_all=True, + ) + + def test_seed_profile_skills_respects_marker(self, profile_env): + """seed_profile_skills() must no-op on opted-out profiles even when + called directly (e.g. by `hermes update`'s all-profile sync loop).""" + profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True) + + # Call seed_profile_skills() directly — it should NOT invoke subprocess, + # NOT modify the skills/ dir, and return a dict with skipped_opt_out=True. + result = seed_profile_skills(profile_dir, quiet=True) + + assert result is not None + assert result.get("skipped_opt_out") is True + assert result.get("copied") == [] + # skills/ stays empty — no subprocess ran + assert list((profile_dir / "skills").iterdir()) == [] + + def test_default_profile_gets_skills_seeded(self, profile_env, monkeypatch): + """Sanity: without --no-skills, seed_profile_skills() runs the real + subprocess path. Mock the subprocess so the test is hermetic, and + just confirm the marker is NOT checked in the non-opt-out case.""" + import subprocess as _sp + + profile_dir = create_profile("coder", no_alias=True) + # No marker — not opted out + assert not (profile_dir / NO_BUNDLED_SKILLS_MARKER).exists() + assert has_bundled_skills_opt_out(profile_dir) is False + + # Mock subprocess.run to avoid actually running skill sync in tests + calls = [] + + def fake_run(*args, **kwargs): + calls.append(args) + return _sp.CompletedProcess( + args=args, returncode=0, stdout='{"copied": ["x"]}', stderr="" + ) + + monkeypatch.setattr("subprocess.run", fake_run) + result = seed_profile_skills(profile_dir, quiet=True) + + # Subprocess was invoked (the opt-out branch did NOT short-circuit) + assert len(calls) == 1 + assert result == {"copied": ["x"]} + + def test_delete_marker_re_enables_seeding(self, profile_env, monkeypatch): + """Deleting .no-bundled-skills opts the profile back in.""" + import subprocess as _sp + + profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True) + assert has_bundled_skills_opt_out(profile_dir) is True + + # First call: opted out, returns skipped dict without touching subprocess + called = [] + monkeypatch.setattr( + "subprocess.run", + lambda *a, **kw: (called.append(a), _sp.CompletedProcess( + args=a, returncode=0, stdout='{"copied": []}', stderr="" + ))[1], + ) + r1 = seed_profile_skills(profile_dir, quiet=True) + assert r1.get("skipped_opt_out") is True + assert called == [] + + # Delete marker → next call runs the real path + (profile_dir / NO_BUNDLED_SKILLS_MARKER).unlink() + assert has_bundled_skills_opt_out(profile_dir) is False + r2 = seed_profile_skills(profile_dir, quiet=True) + assert r2 == {"copied": []} + assert len(called) == 1 + + # =================================================================== # TestDeleteProfile # =================================================================== diff --git a/tests/hermes_cli/test_redact_config_bridge.py b/tests/hermes_cli/test_redact_config_bridge.py index cf759e053842..00dac40b2115 100644 --- a/tests/hermes_cli/test_redact_config_bridge.py +++ b/tests/hermes_cli/test_redact_config_bridge.py @@ -72,11 +72,13 @@ def test_redact_secrets_false_in_config_yaml_is_honored(tmp_path): assert "ENV_VAR=false" in result.stdout -def test_redact_secrets_default_false_when_unset(tmp_path): - """Without the config key, redaction stays OFF by default. +def test_redact_secrets_default_true_when_unset(tmp_path): + """Without the config key or env var, redaction is ON by default (#17691). - Secret redaction is opt-in — users who want it must set - `security.redact_secrets: true` explicitly (or HERMES_REDACT_SECRETS=true). + Secret redaction is a secure default — users who need raw credential + values in tool output (e.g. working on the redactor itself) must set + `security.redact_secrets: false` explicitly (or + `HERMES_REDACT_SECRETS=false`). """ hermes_home = tmp_path / ".hermes" hermes_home.mkdir() @@ -107,7 +109,7 @@ def test_redact_secrets_default_false_when_unset(tmp_path): timeout=30, ) assert result.returncode == 0, f"probe failed: {result.stderr}" - assert "REDACT_ENABLED=False" in result.stdout + assert "REDACT_ENABLED=True" in result.stdout def test_redact_secrets_true_in_config_yaml_is_honored(tmp_path): diff --git a/tests/hermes_cli/test_spotify_auth.py b/tests/hermes_cli/test_spotify_auth.py index ca9c975601b4..e5cd548d4248 100644 --- a/tests/hermes_cli/test_spotify_auth.py +++ b/tests/hermes_cli/test_spotify_auth.py @@ -88,6 +88,51 @@ def test_auth_spotify_status_command_reports_logged_in(capsys, monkeypatch: pyte assert "client_id: spotify-client" in output +def test_spotify_logout_does_not_reset_model_provider( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + capsys, +) -> None: + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config_path = tmp_path / "config.yaml" + config_path.write_text( + "model:\n" + " default: gemini-3-flash\n" + " provider: custom:local\n" + " base_url: http://localhost:11434/v1\n" + " api_key: ${LOCAL_API_KEY}\n", + encoding="utf-8", + ) + + with auth_mod._auth_store_lock(): + store = auth_mod._load_auth_store() + auth_mod._store_provider_state( + store, + "spotify", + { + "client_id": "spotify-client", + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_at": "2099-01-01T00:00:00+00:00", + }, + set_active=False, + ) + auth_mod._save_auth_store(store) + + auth_mod.logout_command(SimpleNamespace(provider="spotify")) + + output = capsys.readouterr().out + assert "Logged out of Spotify." in output + assert "Model provider configuration was unchanged." in output + assert auth_mod.get_provider_auth_state("spotify") is None + assert config_path.read_text(encoding="utf-8") == ( + "model:\n" + " default: gemini-3-flash\n" + " provider: custom:local\n" + " base_url: http://localhost:11434/v1\n" + " api_key: ${LOCAL_API_KEY}\n" + ) + def test_spotify_interactive_setup_persists_client_id( tmp_path, diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index 721149ddefce..aa43acd9e16b 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -415,7 +415,13 @@ def test_update_restarts_profile_manual_gateways( pid=12345, ) - with patch.object(gateway_cli, "find_gateway_pids", return_value=[12345]), \ + # ``find_gateway_pids`` is invoked twice: once to enumerate manual + # PIDs to restart, then again ~3s later by the post-restart survivor + # sweep (#17648). Return the live PID first, then an empty list to + # simulate the process actually exiting after the graceful restart + # — otherwise the sweep would SIGKILL pid 12345 even though graceful + # drain succeeded, and ``kill.assert_not_called()`` would fire. + with patch.object(gateway_cli, "find_gateway_pids", side_effect=[[12345], []]), \ patch.object(gateway_cli, "find_profile_gateway_processes", return_value=[process]), \ patch.object(gateway_cli, "launch_detached_profile_gateway_restart", return_value=True) as restart, \ patch.object(gateway_cli, "_graceful_restart_via_sigusr1", return_value=True) as graceful, \ @@ -453,7 +459,11 @@ def test_update_profile_manual_gateway_falls_back_to_sigterm( pid=12345, ) - with patch.object(gateway_cli, "find_gateway_pids", return_value=[12345]), \ + # See note in ``test_update_restarts_profile_manual_gateways``: the + # post-restart survivor sweep (#17648) re-queries ``find_gateway_pids`` + # ~3s after the restart attempt. Return ``[]`` on the second call so + # the SIGTERM fallback isn't escalated to SIGKILL by the sweep. + with patch.object(gateway_cli, "find_gateway_pids", side_effect=[[12345], []]), \ patch.object(gateway_cli, "find_profile_gateway_processes", return_value=[process]), \ patch.object(gateway_cli, "launch_detached_profile_gateway_restart", return_value=True) as restart, \ patch.object(gateway_cli, "_graceful_restart_via_sigusr1", return_value=False) as graceful, \ @@ -872,15 +882,25 @@ def test_update_kills_manual_pid_but_not_service_pid( launchctl_loaded=True, ) + # Survivor sweep (#17648) re-queries ``find_gateway_pids`` after + # SIGTERM. ``os.kill`` is mocked, so the PID never "dies" — track + # the killed-via-SIGTERM PIDs ourselves and exclude them on later + # calls to simulate the OS reaping the process. Without this the + # sweep escalates with SIGKILL and ``manual_kills == 2`` instead of 1. + _killed_pids: set[int] = set() + def fake_find(exclude_pids=None, all_profiles=False): - _exclude = exclude_pids or set() + _exclude = (exclude_pids or set()) | _killed_pids return [p for p in [SERVICE_PID, MANUAL_PID] if p not in _exclude] + def fake_kill(pid, _sig): + _killed_pids.add(pid) + with patch.object( gateway_cli, "_get_service_pids", return_value={SERVICE_PID} ), patch.object( gateway_cli, "find_gateway_pids", side_effect=fake_find, - ), patch("os.kill") as mock_kill: + ), patch("os.kill", side_effect=fake_kill) as mock_kill: cmd_update(mock_args) captured = capsys.readouterr().out diff --git a/tests/hermes_cli/test_update_yes_flag.py b/tests/hermes_cli/test_update_yes_flag.py index e36cc5142ef7..66060b10aa88 100644 --- a/tests/hermes_cli/test_update_yes_flag.py +++ b/tests/hermes_cli/test_update_yes_flag.py @@ -113,11 +113,18 @@ def test_no_yes_flag_still_prompts_in_tty( args = SimpleNamespace(yes=False) - with patch("builtins.input", return_value="n") as mock_input, patch( - "hermes_cli.main.sys" - ) as mock_sys: - mock_sys.stdin.isatty.return_value = True - mock_sys.stdout.isatty.return_value = True + # Patch ``sys.stdin.isatty`` and ``sys.stdout.isatty`` directly on the + # real ``sys`` module instead of replacing ``hermes_cli.main.sys`` with + # a MagicMock. The MagicMock approach was flaky under ``pytest-xdist`` + # — a sibling test that imported ``hermes_cli.main`` first could leave + # a different ``sys`` reference resolved inside the function and the + # mock would never be consulted, with CI then taking the + # "Non-interactive session" branch instead of prompting. + import sys as _sys + + with patch("builtins.input", return_value="n") as mock_input, patch.object( + _sys.stdin, "isatty", return_value=True + ), patch.object(_sys.stdout, "isatty", return_value=True): cmd_update(args) # The user was actually prompted. assert mock_input.called @@ -156,7 +163,16 @@ def test_yes_restores_stash_without_prompting( args = SimpleNamespace(yes=True) - cmd_update(args) + # Force a TTY-shaped session so the autostash-restore branch is + # reachable in CI workers regardless of inherited stdio (matches the + # isatty patching strategy in ``test_no_yes_flag_still_prompts_in_tty`` + # — ``patch.object`` on the real streams is robust under xdist). + import sys as _sys + + with patch.object(_sys.stdin, "isatty", return_value=True), patch.object( + _sys.stdout, "isatty", return_value=True + ): + cmd_update(args) # _restore_stashed_changes was called, and called with prompt_user=False # every time (so the user never sees "Restore local changes now?"). diff --git a/tests/hermes_cli/test_voice_wrapper.py b/tests/hermes_cli/test_voice_wrapper.py index 3caacf4313c6..c744c08d5b80 100644 --- a/tests/hermes_cli/test_voice_wrapper.py +++ b/tests/hermes_cli/test_voice_wrapper.py @@ -309,6 +309,7 @@ def test_not_active_by_default(self, monkeypatch): # Isolate from any state left behind by other tests in the session. monkeypatch.setattr(voice, "_continuous_active", False) + monkeypatch.setattr(voice, "_continuous_stopping", False, raising=False) monkeypatch.setattr(voice, "_continuous_recorder", None) assert voice.is_continuous_active() is False @@ -343,11 +344,20 @@ def cancel(self): monkeypatch.setattr(voice, "_continuous_recorder", FakeRecorder()) - voice.start_continuous(on_transcript=lambda _t: None) + started = voice.start_continuous(on_transcript=lambda _t: None) # The guard inside start_continuous short-circuits before rec.start() + assert started is True assert called["n"] == 0 + def test_start_returns_false_while_stopping(self, monkeypatch): + import hermes_cli.voice as voice + + monkeypatch.setattr(voice, "_continuous_active", False) + monkeypatch.setattr(voice, "_continuous_stopping", True, raising=False) + + assert voice.start_continuous(on_transcript=lambda _t: None) is False + class TestContinuousLoopSimulation: """End-to-end simulation of the VAD loop with a fake recorder. @@ -368,6 +378,8 @@ def fake_recorder(self, monkeypatch): monkeypatch.setattr(voice, "_continuous_on_transcript", None) monkeypatch.setattr(voice, "_continuous_on_status", None) monkeypatch.setattr(voice, "_continuous_on_silent_limit", None) + monkeypatch.setattr(voice, "_continuous_auto_restart", True, raising=False) + monkeypatch.setattr(voice, "_play_beep", lambda *_, **__: None) class FakeRecorder: _silence_threshold = 200 @@ -381,13 +393,20 @@ def __init__(self): self.cancelled = 0 # Preset WAV path returned by stop() self.next_stop_wav = "/tmp/fake.wav" + self.fail_stop = False + self.fail_next_start = False def start(self, on_silence_stop=None): + if self.fail_next_start: + self.fail_next_start = False + raise RuntimeError("boom") self.start_calls += 1 self.last_callback = on_silence_stop self.is_recording = True def stop(self): + if self.fail_stop: + raise RuntimeError("stop failed") self.stopped += 1 self.is_recording = False return self.next_stop_wav @@ -433,6 +452,204 @@ def test_loop_auto_restarts_after_transcript(self, fake_recorder, monkeypatch): voice.stop_continuous() + def test_auto_restart_false_stops_after_first_transcript(self, fake_recorder, monkeypatch): + import hermes_cli.voice as voice + + monkeypatch.setattr( + voice, + "transcribe_recording", + lambda _p: {"success": True, "transcript": "single shot"}, + ) + monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False) + + transcripts = [] + statuses = [] + + voice.start_continuous( + on_transcript=lambda t: transcripts.append(t), + on_status=lambda s: statuses.append(s), + auto_restart=False, + ) + fake_recorder.last_callback() + + assert transcripts == ["single shot"] + assert fake_recorder.start_calls == 1 + assert statuses == ["listening", "transcribing", "idle"] + assert voice.is_continuous_active() is False + + def test_auto_restart_false_retains_silent_strikes_across_starts( + self, fake_recorder, monkeypatch + ): + import hermes_cli.voice as voice + + monkeypatch.setattr( + voice, + "transcribe_recording", + lambda _p: {"success": True, "transcript": ""}, + ) + monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False) + + silent_limit_fired = [] + + for _ in range(3): + voice.start_continuous( + on_transcript=lambda _t: None, + on_silent_limit=lambda: silent_limit_fired.append(True), + auto_restart=False, + ) + fake_recorder.last_callback() + + assert silent_limit_fired == [True] + assert voice.is_continuous_active() is False + assert fake_recorder.start_calls == 3 + + def test_force_transcribe_stop_delivers_current_buffer(self, fake_recorder, monkeypatch): + import hermes_cli.voice as voice + + class ImmediateThread: + def __init__(self, target, daemon=False): + self.target = target + + def start(self): + self.target() + + monkeypatch.setattr(voice.threading, "Thread", ImmediateThread) + monkeypatch.setattr( + voice, + "transcribe_recording", + lambda _p: {"success": True, "transcript": "manual stop"}, + ) + monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False) + + transcripts = [] + statuses = [] + + voice.start_continuous( + on_transcript=lambda t: transcripts.append(t), + on_status=lambda s: statuses.append(s), + ) + voice.stop_continuous(force_transcribe=True) + + assert fake_recorder.stopped == 1 + assert transcripts == ["manual stop"] + assert statuses == ["listening", "transcribing", "idle"] + assert voice.is_continuous_active() is False + + def test_force_transcribe_empty_single_shots_hit_silent_limit( + self, fake_recorder, monkeypatch + ): + import hermes_cli.voice as voice + + class ImmediateThread: + def __init__(self, target, daemon=False): + self.target = target + + def start(self): + self.target() + + monkeypatch.setattr(voice.threading, "Thread", ImmediateThread) + monkeypatch.setattr( + voice, + "transcribe_recording", + lambda _p: {"success": True, "transcript": ""}, + ) + monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False) + + silent_limit_fired = [] + + for _ in range(3): + voice.start_continuous( + on_transcript=lambda _t: None, + on_silent_limit=lambda: silent_limit_fired.append(True), + auto_restart=False, + ) + voice.stop_continuous(force_transcribe=True) + + assert silent_limit_fired == [True] + assert fake_recorder.stopped == 3 + assert voice._continuous_no_speech_count == 0 + + def test_force_transcribe_valid_single_shot_resets_silent_strikes( + self, fake_recorder, monkeypatch + ): + import hermes_cli.voice as voice + + class ImmediateThread: + def __init__(self, target, daemon=False): + self.target = target + + def start(self): + self.target() + + monkeypatch.setattr(voice.threading, "Thread", ImmediateThread) + monkeypatch.setattr(voice, "_continuous_no_speech_count", 2) + monkeypatch.setattr( + voice, + "transcribe_recording", + lambda _p: {"success": True, "transcript": "manual stop"}, + ) + monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False) + + transcripts = [] + silent_limit_fired = [] + + voice.start_continuous( + on_transcript=lambda t: transcripts.append(t), + on_silent_limit=lambda: silent_limit_fired.append(True), + auto_restart=False, + ) + voice.stop_continuous(force_transcribe=True) + + assert transcripts == ["manual stop"] + assert silent_limit_fired == [] + assert voice._continuous_no_speech_count == 0 + + def test_force_transcribe_stop_failure_cancels_and_clears_stopping( + self, fake_recorder, monkeypatch + ): + import hermes_cli.voice as voice + + class ImmediateThread: + def __init__(self, target, daemon=False): + self.target = target + + def start(self): + self.target() + + monkeypatch.setattr(voice.threading, "Thread", ImmediateThread) + fake_recorder.fail_stop = True + + statuses = [] + voice.start_continuous( + on_transcript=lambda _t: None, + on_status=lambda s: statuses.append(s), + ) + voice.stop_continuous(force_transcribe=True) + + assert fake_recorder.cancelled == 1 + assert statuses == ["listening", "transcribing", "idle"] + assert voice.is_continuous_active() is False + assert voice._continuous_stopping is False + + def test_restart_failure_reports_idle(self, fake_recorder, monkeypatch): + import hermes_cli.voice as voice + + monkeypatch.setattr( + voice, + "transcribe_recording", + lambda _p: {"success": True, "transcript": "hello world"}, + ) + monkeypatch.setattr(voice, "is_whisper_hallucination", lambda _t: False) + + statuses = [] + voice.start_continuous(on_transcript=lambda _t: None, on_status=statuses.append) + + fake_recorder.fail_next_start = True + fake_recorder.last_callback() + + assert statuses == ["listening", "transcribing", "idle"] + assert voice.is_continuous_active() is False + def test_silent_limit_halts_loop_after_three_strikes(self, fake_recorder, monkeypatch): import hermes_cli.voice as voice diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py index c2408f0ae74a..76d69224e356 100644 --- a/tests/plugins/memory/test_openviking_provider.py +++ b/tests/plugins/memory/test_openviking_provider.py @@ -1,7 +1,10 @@ import json +from types import SimpleNamespace from unittest.mock import MagicMock -from plugins.memory.openviking import OpenVikingMemoryProvider +import pytest + +from plugins.memory.openviking import OpenVikingMemoryProvider, _VikingClient def test_tool_search_sorts_by_raw_score_across_buckets(): @@ -60,3 +63,319 @@ def test_tool_search_sorts_missing_raw_score_after_negative_scores(): ] assert [entry["score"] for entry in result["results"]] == [0.1, 0.0, -0.25] assert result["total"] == 3 + + +def test_tool_add_resource_uploads_existing_local_file(tmp_path): + sample = tmp_path / "sample.md" + sample.write_text("# Local resource\n", encoding="utf-8") + provider = OpenVikingMemoryProvider() + provider._client = MagicMock() + provider._client.upload_temp_file.return_value = "upload_sample.md" + provider._client.post.return_value = { + "status": "ok", + "result": {"root_uri": "viking://resources/sample"}, + } + + result = json.loads(provider._tool_add_resource({ + "url": str(sample), + "reason": "local test", + "wait": True, + })) + + provider._client.upload_temp_file.assert_called_once_with(sample) + provider._client.post.assert_called_once_with("/api/v1/resources", { + "reason": "local test", + "wait": True, + "source_name": "sample.md", + "temp_file_id": "upload_sample.md", + }) + assert result["status"] == "added" + assert result["root_uri"] == "viking://resources/sample" + + +def test_tool_add_resource_uploads_file_uri(tmp_path): + sample = tmp_path / "sample.md" + sample.write_text("# Local resource\n", encoding="utf-8") + provider = OpenVikingMemoryProvider() + provider._client = MagicMock() + provider._client.upload_temp_file.return_value = "upload_sample.md" + provider._client.post.return_value = { + "status": "ok", + "result": {"root_uri": "viking://resources/sample"}, + } + + result = json.loads(provider._tool_add_resource({ + "url": sample.as_uri(), + "reason": "file uri test", + })) + + provider._client.upload_temp_file.assert_called_once_with(sample) + provider._client.post.assert_called_once_with("/api/v1/resources", { + "reason": "file uri test", + "source_name": "sample.md", + "temp_file_id": "upload_sample.md", + }) + assert result["status"] == "added" + assert result["root_uri"] == "viking://resources/sample" + + +def test_tool_add_resource_uploads_existing_local_directory_and_cleans_zip(tmp_path): + docs = tmp_path / "docs" + docs.mkdir() + (docs / "guide.md").write_text("# Guide\n", encoding="utf-8") + nested = docs / "nested" + nested.mkdir() + (nested / "api.md").write_text("# API\n", encoding="utf-8") + provider = OpenVikingMemoryProvider() + provider._client = MagicMock() + uploaded_paths = [] + provider._client.upload_temp_file.side_effect = ( + lambda path: uploaded_paths.append(path) or "upload_docs.zip" + ) + provider._client.post.return_value = { + "status": "ok", + "result": {"root_uri": "viking://resources/docs"}, + } + + result = json.loads(provider._tool_add_resource({ + "url": str(docs), + "reason": "directory test", + "wait": True, + })) + + assert uploaded_paths + assert uploaded_paths[0].suffix == ".zip" + assert not uploaded_paths[0].exists() + provider._client.post.assert_called_once_with("/api/v1/resources", { + "reason": "directory test", + "wait": True, + "source_name": "docs", + "temp_file_id": "upload_docs.zip", + }) + assert result["status"] == "added" + assert result["root_uri"] == "viking://resources/docs" + + +def test_tool_add_resource_cleans_local_directory_zip_when_add_fails(tmp_path): + docs = tmp_path / "docs" + docs.mkdir() + (docs / "guide.md").write_text("# Guide\n", encoding="utf-8") + provider = OpenVikingMemoryProvider() + provider._client = MagicMock() + uploaded_paths = [] + provider._client.upload_temp_file.side_effect = ( + lambda path: uploaded_paths.append(path) or "upload_docs.zip" + ) + provider._client.post.side_effect = RuntimeError("add failed") + + with pytest.raises(RuntimeError, match="add failed"): + provider._tool_add_resource({"url": str(docs)}) + + assert uploaded_paths + assert not uploaded_paths[0].exists() + + +def test_tool_add_resource_cleans_local_directory_zip_when_upload_fails(tmp_path): + docs = tmp_path / "docs" + docs.mkdir() + (docs / "guide.md").write_text("# Guide\n", encoding="utf-8") + provider = OpenVikingMemoryProvider() + provider._client = MagicMock() + uploaded_paths = [] + + def fail_upload(path): + uploaded_paths.append(path) + raise RuntimeError("upload failed") + + provider._client.upload_temp_file.side_effect = fail_upload + + with pytest.raises(RuntimeError, match="upload failed"): + provider._tool_add_resource({"url": str(docs)}) + + assert uploaded_paths + assert not uploaded_paths[0].exists() + provider._client.post.assert_not_called() + + +def test_tool_add_resource_rejects_missing_local_path(tmp_path): + missing = tmp_path / "missing.md" + provider = OpenVikingMemoryProvider() + provider._client = MagicMock() + + result = json.loads(provider._tool_add_resource({"url": str(missing)})) + + assert result["error"] == f"Local resource path does not exist: {missing}" + provider._client.upload_temp_file.assert_not_called() + provider._client.post.assert_not_called() + + +def test_tool_add_resource_sends_remote_url_as_path(): + provider = OpenVikingMemoryProvider() + provider._client = MagicMock() + provider._client.post.return_value = { + "status": "ok", + "result": {"root_uri": "viking://resources/remote"}, + } + + provider._tool_add_resource({"url": "https://example.com/doc.md"}) + + provider._client.upload_temp_file.assert_not_called() + provider._client.post.assert_called_once_with("/api/v1/resources", { + "path": "https://example.com/doc.md", + }) + + +@pytest.mark.parametrize("url", [ + "git@github.com:org/repo.git", + "git@ssh.dev.azure.com:v3/org/project/repo", + "ssh://git@github.com/org/repo.git", + "git://github.com/org/repo.git", +]) +def test_tool_add_resource_sends_git_remote_sources_as_path(url): + provider = OpenVikingMemoryProvider() + provider._client = MagicMock() + provider._client.post.return_value = { + "status": "ok", + "result": {"root_uri": "viking://resources/repo"}, + } + + provider._tool_add_resource({"url": url}) + + provider._client.upload_temp_file.assert_not_called() + provider._client.post.assert_called_once_with("/api/v1/resources", { + "path": url, + }) + + +def test_viking_client_upload_temp_file_uses_multipart_identity_headers(tmp_path, monkeypatch): + sample = tmp_path / "sample.md" + sample.write_text("# Local resource\n", encoding="utf-8") + client = _VikingClient( + "https://example.com", + api_key="test-key", + account="test-account", + user="test-user", + agent="test-agent", + ) + captured_kwargs = {} + + def capture_httpx_post(url, **kwargs): + captured_kwargs.update(kwargs) + return SimpleNamespace( + status_code=200, + text="", + json=lambda: {"status": "ok", "result": {"temp_file_id": "upload_sample.md"}}, + raise_for_status=lambda: None, + ) + + monkeypatch.setattr(client._httpx, "post", capture_httpx_post) + + assert client.upload_temp_file(sample) == "upload_sample.md" + + assert "files" in captured_kwargs + assert "json" not in captured_kwargs + headers = captured_kwargs["headers"] + assert headers["X-OpenViking-Account"] == "test-account" + assert headers["X-OpenViking-User"] == "test-user" + assert headers["X-OpenViking-Agent"] == "test-agent" + assert headers["X-API-Key"] == "test-key" + assert "Content-Type" not in headers + + +def test_viking_client_raises_structured_server_error(): + client = _VikingClient.__new__(_VikingClient) + response = SimpleNamespace( + status_code=403, + text='{"status":"error"}', + json=lambda: { + "status": "error", + "error": { + "code": "PERMISSION_DENIED", + "message": "direct host filesystem paths are not allowed", + }, + }, + raise_for_status=lambda: None, + ) + + with pytest.raises(RuntimeError, match="PERMISSION_DENIED"): + client._parse_response(response) + + +def test_viking_client_headers_include_bearer_when_api_key_set(): + client = _VikingClient( + "https://example.com", + api_key="test-key", + account="acct", + user="usr", + agent="hermes", + ) + headers = client._headers() + assert headers["X-API-Key"] == "test-key" + assert headers["Authorization"] == "Bearer test-key" + + +def test_viking_client_headers_omit_tenant_when_legacy_default(): + # Existing installs have account/user set to the literal string "default". + # Those should NOT be sent as headers — the server would interpret that + # as a real tenant override and reject/misroute requests. + client = _VikingClient( + "https://example.com", + api_key="test-key", + account="default", + user="default", + agent="hermes", + ) + headers = client._headers() + assert "X-OpenViking-Account" not in headers + assert "X-OpenViking-User" not in headers + assert headers["X-OpenViking-Agent"] == "hermes" + assert headers["Authorization"] == "Bearer test-key" + + +def test_viking_client_headers_omit_tenant_when_empty(): + client = _VikingClient( + "https://example.com", + api_key="", + account="", + user="", + agent="hermes", + ) + headers = client._headers() + assert "X-OpenViking-Account" not in headers + assert "X-OpenViking-User" not in headers + assert "Authorization" not in headers + assert "X-API-Key" not in headers + + +def test_viking_client_headers_sent_with_real_tenant_values(): + client = _VikingClient( + "https://example.com", + api_key="test-key", + account="real-account", + user="real-user", + agent="hermes", + ) + headers = client._headers() + assert headers["X-OpenViking-Account"] == "real-account" + assert headers["X-OpenViking-User"] == "real-user" + + +def test_viking_client_health_sends_auth_headers(monkeypatch): + client = _VikingClient( + "https://example.com", + api_key="test-key", + account="", + user="", + agent="hermes", + ) + captured = {} + + def capture_get(url, **kwargs): + captured["url"] = url + captured["headers"] = kwargs.get("headers") or {} + return SimpleNamespace(status_code=200) + + monkeypatch.setattr(client._httpx, "get", capture_get) + assert client.health() is True + assert captured["url"] == "https://example.com/health" + assert captured["headers"]["Authorization"] == "Bearer test-key" diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index b266f0914e50..fae035b2669d 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -553,6 +553,67 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch): assert ws is not None # handshake succeeded +def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch): + """``asyncio.CancelledError`` while sleeping in the poll loop is the + normal uvicorn-shutdown path (``BaseException``, so the bare + ``except Exception:`` does NOT catch it). Without the explicit + clause the cancellation surfaces as an application traceback. + + Regression test for #20790 (fix in #20938). Drives the coroutine + directly (rather than through FastAPI TestClient) so we can observe + the cancellation outcome deterministically. + """ + import asyncio + import types + import sys as _sys + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + + # Short-circuit the token check — this test is about the cancellation + # path, not auth. + import plugins.kanban.dashboard.plugin_api as pa + monkeypatch.setattr(pa, "_check_ws_token", lambda t: True) + + class _FakeWS: + def __init__(self): + self.query_params = {"token": "x", "since": "0"} + self.accepted = False + self.closed = False + + async def accept(self): + self.accepted = True + + async def send_json(self, data): + pass + + async def close(self, code=None): + self.closed = True + + async def _run(): + ws = _FakeWS() + task = asyncio.create_task(pa.stream_events(ws)) + # Give the handler a tick to accept + start polling. + await asyncio.sleep(0.05) + assert ws.accepted is True + task.cancel() + # stream_events should swallow CancelledError and return cleanly. + # If it doesn't, this await re-raises the CancelledError. + result = await task + return result, ws + + result, ws = asyncio.run(_run()) + assert result is None, ( + f"stream_events should return cleanly after cancellation, got {result!r}" + ) + # The bug symptom was a traceback; we don't assert on stderr because + # capturing asyncio's internal "exception was never retrieved" logging + # is flaky. The assertion that matters is: no CancelledError escaped. + + # --------------------------------------------------------------------------- # Bulk actions # --------------------------------------------------------------------------- diff --git a/tests/run_agent/test_empty_response_recovery_persistence.py b/tests/run_agent/test_empty_response_recovery_persistence.py new file mode 100644 index 000000000000..d31a1ff8d2a8 --- /dev/null +++ b/tests/run_agent/test_empty_response_recovery_persistence.py @@ -0,0 +1,84 @@ +"""Regression tests for empty-response recovery transcript persistence.""" + +from run_agent import AIAgent + + +def _agent_with_stubbed_persistence(): + agent = AIAgent.__new__(AIAgent) + agent._persist_user_message_idx = None + agent._persist_user_message_override = None + agent._session_db = None + agent._session_messages = [] + agent.saved_session_logs = [] + agent.flushed_session_db_messages = [] + agent._save_session_log = lambda messages: agent.saved_session_logs.append( + [m.copy() for m in messages] + ) + agent._flush_messages_to_session_db = lambda messages, conversation_history=None: ( + agent.flushed_session_db_messages.append([m.copy() for m in messages]) + ) + return agent + + +def test_persist_session_strips_trailing_empty_recovery_scaffolding(): + agent = _agent_with_stubbed_persistence() + messages = [ + {"role": "user", "content": "run the task"}, + {"role": "tool", "content": "{}", "tool_call_id": "call_1"}, + { + "role": "assistant", + "content": "(empty)", + "_empty_recovery_synthetic": True, + }, + { + "role": "user", + "content": ( + "You just executed tool calls but returned an empty response. " + "Please process the tool results above and continue with the task." + ), + "_empty_recovery_synthetic": True, + }, + ] + + AIAgent._persist_session(agent, messages, conversation_history=[]) + + assert messages == [ + {"role": "user", "content": "run the task"}, + {"role": "tool", "content": "{}", "tool_call_id": "call_1"}, + ] + assert agent.saved_session_logs[-1] == messages + assert all(not msg.get("_empty_recovery_synthetic") for msg in messages) + + +def test_persist_session_keeps_unmarked_terminal_empty_response(): + agent = _agent_with_stubbed_persistence() + messages = [ + {"role": "user", "content": "run the task"}, + {"role": "assistant", "content": "(empty)"}, + ] + + AIAgent._persist_session(agent, messages, conversation_history=[]) + + assert messages == [ + {"role": "user", "content": "run the task"}, + {"role": "assistant", "content": "(empty)"}, + ] + assert agent.saved_session_logs[-1] == messages + + +def test_persist_session_strips_marked_terminal_empty_sentinel(): + agent = _agent_with_stubbed_persistence() + messages = [ + {"role": "user", "content": "continue"}, + { + "role": "assistant", + "content": "(empty)", + "_empty_terminal_sentinel": True, + }, + ] + + AIAgent._persist_session(agent, messages, conversation_history=[]) + + assert messages == [{"role": "user", "content": "continue"}] + assert agent.saved_session_logs[-1] == messages + assert all(not msg.get("_empty_terminal_sentinel") for msg in messages) diff --git a/tests/test_transform_llm_output_hook.py b/tests/test_transform_llm_output_hook.py new file mode 100644 index 000000000000..489f70d8c4c3 --- /dev/null +++ b/tests/test_transform_llm_output_hook.py @@ -0,0 +1,159 @@ +"""Tests for the ``transform_llm_output`` plugin hook. + +The hook fires inside ``AIAgent.run_conversation`` once the tool-calling +loop has produced a final response. Driving the full agent loop from a +unit test would be prohibitively heavy, so these tests exercise the +invoke_hook dispatch semantics that the wiring in ``run_agent.py`` +depends on: + + for _hook_result in _transform_results: + if isinstance(_hook_result, str) and _hook_result: + final_response = _hook_result + break # First non-empty string wins + +Mirrors ``test_transform_tool_result_hook.py`` which tests the equivalent +contract for the generic tool-result seam. +""" + +from pathlib import Path + +import yaml + +import hermes_cli.plugins as plugins_mod +from hermes_cli.plugins import PluginManager, VALID_HOOKS + + +def _make_enabled_plugin(hermes_home: Path, name: str, register_body: str) -> Path: + """Create a plugin under /plugins/ and opt it in.""" + plugin_dir = hermes_home / "plugins" / name + plugin_dir.mkdir(parents=True) + (plugin_dir / "plugin.yaml").write_text( + yaml.safe_dump({"name": name, "version": "0.1.0"}), encoding="utf-8", + ) + (plugin_dir / "__init__.py").write_text( + "def register(ctx):\n" + f" {register_body}\n", + encoding="utf-8", + ) + cfg_path = hermes_home / "config.yaml" + cfg = {} + if cfg_path.exists(): + cfg = yaml.safe_load(cfg_path.read_text()) or {} + cfg.setdefault("plugins", {}).setdefault("enabled", []).append(name) + cfg_path.write_text(yaml.safe_dump(cfg), encoding="utf-8") + return plugin_dir + + +def test_transform_llm_output_in_valid_hooks(): + assert "transform_llm_output" in VALID_HOOKS + + +def test_hook_receives_expected_kwargs(tmp_path, monkeypatch): + """Hook callback should see response_text + session_id + model + platform.""" + hermes_home = tmp_path / "hermes_test" + hermes_home.mkdir(exist_ok=True) + _make_enabled_plugin( + hermes_home, "capture_hook", + register_body=( + 'ctx.register_hook("transform_llm_output", ' + 'lambda **kw: f"{kw[\'response_text\']}|{kw[\'session_id\']}|' + '{kw[\'model\']}|{kw[\'platform\']}")' + ), + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + mgr = PluginManager() + mgr.discover_and_load() + + results = mgr.invoke_hook( + "transform_llm_output", + response_text="hello world", + session_id="s1", + model="anthropic/claude-sonnet-4.6", + platform="cli", + ) + assert results == ["hello world|s1|anthropic/claude-sonnet-4.6|cli"] + + +def test_first_non_empty_string_wins_semantics(): + """Simulate the run_agent.py loop: first non-empty string replaces text.""" + # The dispatch contract: invoke_hook returns a list; the caller walks + # it and stops at the first isinstance(_, str) and _. + hook_returns = [None, "", {"bad": True}, 123, "first-winner", "second"] + + final_response = "original" + for _hook_result in hook_returns: + if isinstance(_hook_result, str) and _hook_result: + final_response = _hook_result + break + + assert final_response == "first-winner" + + +def test_empty_string_return_leaves_response_unchanged(): + """Empty string must not replace the response (pass-through signal).""" + hook_returns = [""] + + final_response = "original" + for _hook_result in hook_returns: + if isinstance(_hook_result, str) and _hook_result: + final_response = _hook_result + break + + assert final_response == "original" + + +def test_hook_exception_does_not_replace_response(tmp_path, monkeypatch): + """A plugin raising an exception must not break hook dispatch. + + PluginManager.invoke_hook catches per-callback exceptions, logs a + warning, and continues — so a raising plugin contributes no entry + to the results list, and the walk in run_agent.py finds nothing to + replace with. + """ + hermes_home = tmp_path / "hermes_test" + hermes_home.mkdir(exist_ok=True) + _make_enabled_plugin( + hermes_home, "raising_hook", + register_body=( + 'def _boom(**kw):\n' + ' raise RuntimeError("boom")\n' + ' ctx.register_hook("transform_llm_output", _boom)' + ), + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + mgr = PluginManager() + mgr.discover_and_load() + + results = mgr.invoke_hook( + "transform_llm_output", + response_text="keep me", + session_id="s1", + model="m", + platform="cli", + ) + + final_response = "keep me" + for _hook_result in results: + if isinstance(_hook_result, str) and _hook_result: + final_response = _hook_result + break + + assert final_response == "keep me" + + +def test_no_plugins_returns_empty_results(tmp_path, monkeypatch): + """With no plugins loaded, invoke_hook returns [] and the response is unchanged.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_empty")) + plugins_mod._plugin_manager = PluginManager() + + mgr = plugins_mod._plugin_manager + results = mgr.invoke_hook( + "transform_llm_output", + response_text="unchanged", + session_id="", + model="m", + platform="", + ) + assert results == [] diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 5a25a306ba0e..f7d70f92a9ea 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -204,6 +204,7 @@ def fake_start_continuous(**kwargs): assert resp["result"]["status"] == "recording" assert captured["silence_threshold"] == 200 assert captured["silence_duration"] == 3.0 + assert captured["auto_restart"] is False # Round-12 Copilot review regression on #19835: ``bool`` is a subclass # of ``int``, so the naive ``isinstance(threshold, (int, float))`` @@ -232,6 +233,80 @@ def fake_start_continuous(**kwargs): assert ( captured["silence_duration"] == 3.0 ), f"bool silence_duration leaked through for {bad_bool_cfg!r}" + assert captured["auto_restart"] is False + + +def test_voice_record_stop_forces_transcription(monkeypatch): + captured: dict = {} + + def fake_stop_continuous(**kwargs): + captured.update(kwargs) + + monkeypatch.setitem( + sys.modules, + "hermes_cli.voice", + types.SimpleNamespace( + start_continuous=lambda **_kwargs: None, + stop_continuous=fake_stop_continuous, + ), + ) + + resp = server.dispatch( + { + "id": "voice-record-stop", + "method": "voice.record", + "params": {"action": "stop"}, + } + ) + + assert resp["result"]["status"] == "stopped" + assert captured["force_transcribe"] is True + + +def test_voice_record_stop_updates_event_session_id(monkeypatch): + monkeypatch.setitem( + sys.modules, + "hermes_cli.voice", + types.SimpleNamespace( + start_continuous=lambda **_kwargs: True, + stop_continuous=lambda **_kwargs: None, + ), + ) + monkeypatch.setattr(server, "_voice_event_sid", "old-session") + + resp = server.dispatch( + { + "id": "voice-record-stop-session", + "method": "voice.record", + "params": {"action": "stop", "session_id": "new-session"}, + } + ) + + assert resp["result"]["status"] == "stopped" + assert server._voice_event_sid == "new-session" + + +def test_voice_record_start_reports_busy_when_stop_is_in_progress(monkeypatch): + monkeypatch.setitem( + sys.modules, + "hermes_cli.voice", + types.SimpleNamespace( + start_continuous=lambda **_kwargs: False, + stop_continuous=lambda **_kwargs: None, + ), + ) + monkeypatch.setenv("HERMES_VOICE", "1") + monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {}}) + + resp = server.dispatch( + { + "id": "voice-record-busy", + "method": "voice.record", + "params": {"action": "start"}, + } + ) + + assert resp["result"]["status"] == "busy" def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch): @@ -3528,6 +3603,100 @@ def run_conversation( mock_title.assert_not_called() +def test_prompt_submit_surfaces_backend_error_as_visible_text(monkeypatch): + """When the backend fails with no visible response (e.g. invalid model slug + → provider 4xx), the TUI must surface result['error'] as visible text + instead of emitting a blank message.complete turn.""" + + class _Agent: + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): + return { + "final_response": None, + "messages": [], + "api_calls": 0, + "completed": False, + "failed": True, + "error": "HTTP 400: invalid model id 'kimi-k2.6'", + } + + server._sessions["sid"] = _session(agent=_Agent()) + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + + emitted: list[tuple[str, str, dict]] = [] + monkeypatch.setattr( + server, + "_emit", + lambda event, sid, payload=None: emitted.append((event, sid, payload or {})), + ) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + monkeypatch.setattr(server, "_get_db", lambda: None) + + server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "hello"}, + } + ) + + complete_events = [e for e in emitted if e[0] == "message.complete"] + assert complete_events, "expected message.complete to be emitted" + payload = complete_events[-1][2] + assert payload.get("status") == "error" + assert payload.get("text", "").startswith("Error:") + assert "kimi-k2.6" in payload.get("text", "") + + +def test_prompt_submit_preserves_empty_response_without_error(monkeypatch): + """An empty final_response with NO backend error must stay empty — do not + synthesize an error string. Preserves the existing None/empty-sentinel + semantics owned by downstream handlers.""" + + class _Agent: + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None + ): + return { + "final_response": None, + "messages": [], + "api_calls": 1, + "completed": True, + } + + server._sessions["sid"] = _session(agent=_Agent()) + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + + emitted: list[tuple[str, str, dict]] = [] + monkeypatch.setattr( + server, + "_emit", + lambda event, sid, payload=None: emitted.append((event, sid, payload or {})), + ) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + monkeypatch.setattr(server, "_get_db", lambda: None) + + server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": {"session_id": "sid", "text": "hello"}, + } + ) + + complete_events = [e for e in emitted if e[0] == "message.complete"] + assert complete_events, "expected message.complete to be emitted" + payload = complete_events[-1][2] + # Status stays "complete" because no error flag was set + assert payload.get("status") == "complete" + # Text stays empty — we did NOT fabricate an "Error:" string + text = payload.get("text", "") + assert text in ("", None), f"expected empty text, got {text!r}" + + # ── session.most_recent ────────────────────────────────────────────── diff --git a/tests/tools/test_browser_ssrf_local.py b/tests/tools/test_browser_ssrf_local.py index b3b8bd227188..691f9256f2bb 100644 --- a/tests/tools/test_browser_ssrf_local.py +++ b/tests/tools/test_browser_ssrf_local.py @@ -106,6 +106,62 @@ def test_local_allows_public_url(self, monkeypatch, _common_patches): assert result["success"] is True + # -- Always-blocked floor: hybrid routing bypass regression (#16234) ------- + + # Hybrid-routing feature flips auto_local_this_nav=True for private URLs, + # which previously short-circuited _is_safe_url() entirely. An agent + # running on EC2/GCP/Azure could navigate to 169.254.169.254 via the + # spawned local Chromium sidecar and read IAM credentials via + # browser_snapshot. The always-blocked floor must fire regardless of + # routing. + IMDS_URLS = [ + "http://169.254.169.254/latest/meta-data/", # AWS / GCP / Azure / DO / Oracle + "http://169.254.169.253/metadata/instance", # Azure IMDS wire server + "http://169.254.170.2/v2/credentials", # AWS ECS task metadata + "http://100.100.100.200/latest/meta-data/", # Alibaba Cloud + "http://metadata.google.internal/computeMetadata/v1/", # GCP hostname + ] + + @pytest.mark.parametrize("imds_url", IMDS_URLS) + def test_cloud_blocks_imds_even_when_routing_to_local_sidecar( + self, monkeypatch, _common_patches, imds_url + ): + """Hybrid routing must not let cloud metadata endpoints through.""" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False) + monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False) + # Simulate hybrid routing kicking in for this URL (what happens on + # main pre-fix — cloud provider configured, _url_is_private → True, + # so the session key routes to a local Chromium sidecar). + monkeypatch.setattr(browser_tool, "_is_local_sidecar_key", lambda key: True) + # _is_safe_url would catch IMDS, but pre-fix it never ran. Force + # it to return True here so the test is specifically pinning the + # always-blocked floor as an independent gate. + monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True) + + result = json.loads(browser_tool.browser_navigate(imds_url)) + + assert result["success"] is False + assert "cloud metadata endpoint" in result["error"] + + def test_cloud_allows_ordinary_private_url_via_sidecar( + self, monkeypatch, _common_patches + ): + """Hybrid routing still works for ordinary private URLs — floor + must be narrow enough to not break the PR #16136 feature.""" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False) + monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False) + monkeypatch.setattr(browser_tool, "_is_local_sidecar_key", lambda key: True) + monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: False) + + for private in ( + "http://127.0.0.1:8080/dashboard", + "http://192.168.1.1/admin", + "http://10.0.0.5/", + "http://myservice.local/", + ): + result = json.loads(browser_tool.browser_navigate(private)) + assert result["success"] is True, f"Unexpected block for {private}: {result}" + # --------------------------------------------------------------------------- # _is_local_backend() unit tests @@ -236,6 +292,32 @@ def test_cloud_allows_redirect_to_public(self, monkeypatch, _common_patches): assert result["success"] is True assert result["url"] == final + # -- Always-blocked floor: redirect to IMDS via hybrid sidecar (#16234) ---- + + def test_cloud_blocks_redirect_to_imds_even_via_sidecar( + self, monkeypatch, _common_patches + ): + """Redirect to a cloud metadata endpoint is blocked regardless of + routing — even the hybrid local sidecar path can't return IMDS + content to the agent.""" + imds_final = "http://169.254.169.254/latest/meta-data/" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False) + monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False) + monkeypatch.setattr(browser_tool, "_is_local_sidecar_key", lambda key: True) + # _is_safe_url would catch it on main; force True to pin the + # always-blocked floor as an independent gate. + monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True) + monkeypatch.setattr( + browser_tool, + "_run_browser_command", + lambda *a, **kw: _make_browser_result(url=imds_final), + ) + + result = json.loads(browser_tool.browser_navigate(self.PUBLIC_URL)) + + assert result["success"] is False + assert "cloud metadata endpoint" in result["error"] + class TestAllowPrivateUrlsConfig: @pytest.fixture(autouse=True) diff --git a/tests/tools/test_discord_tool.py b/tests/tools/test_discord_tool.py index 51226f070234..41d2cc957be1 100644 --- a/tests/tools/test_discord_tool.py +++ b/tests/tools/test_discord_tool.py @@ -175,6 +175,12 @@ def test_missing_required_channel_id(self, monkeypatch): assert "error" in result assert "channel_id" in result["error"] + def test_missing_required_message_id_for_delete(self, monkeypatch): + monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") + result = json.loads(discord_admin_handler(action="delete_message", channel_id="11")) + assert "error" in result + assert "message_id" in result["error"] + def test_missing_multiple_params(self, monkeypatch): monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") result = json.loads(discord_admin_handler(action="add_role")) @@ -407,10 +413,10 @@ def test_list_pins(self, mock_req, monkeypatch): # --------------------------------------------------------------------------- -# Actions: pin_message / unpin_message +# Actions: pin_message / unpin_message / delete_message # --------------------------------------------------------------------------- -class TestPinUnpin: +class TestPinUnpinDelete: @patch("tools.discord_tool._discord_request") def test_pin_message(self, mock_req, monkeypatch): monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") @@ -425,6 +431,16 @@ def test_unpin_message(self, mock_req, monkeypatch): mock_req.return_value = None result = json.loads(discord_admin_handler(action="unpin_message", channel_id="11", message_id="500")) assert result["success"] is True + mock_req.assert_called_once_with("DELETE", "/channels/11/pins/500", "test-token") + + @patch("tools.discord_tool._discord_request") + def test_delete_message(self, mock_req, monkeypatch): + monkeypatch.setenv("DISCORD_BOT_TOKEN", "test-token") + mock_req.return_value = None + result = json.loads(discord_admin_handler(action="delete_message", channel_id="11", message_id="500")) + assert result["success"] is True + assert "deleted" in result["message"] + mock_req.assert_called_once_with("DELETE", "/channels/11/messages/500", "test-token") # --------------------------------------------------------------------------- @@ -586,6 +602,7 @@ def test_admin_schema_description(self): desc = entry.schema["description"] assert "list_guilds()" in desc assert "add_role(guild_id, user_id, role_id)" in desc + assert "delete_message(channel_id, message_id)" in desc # Core actions should NOT be in admin description assert "fetch_messages(" not in desc assert "create_thread(" not in desc diff --git a/tests/tools/test_dockerfile_pid1_reaping.py b/tests/tools/test_dockerfile_pid1_reaping.py index 52532a78dd2d..e578d8a69fd9 100644 --- a/tests/tools/test_dockerfile_pid1_reaping.py +++ b/tests/tools/test_dockerfile_pid1_reaping.py @@ -106,8 +106,15 @@ def test_dockerfile_entrypoint_routes_through_the_init(dockerfile_text): def test_dockerfile_installs_tui_dependencies(dockerfile_text): + # The TUI workspace manifests must be present so ``npm install`` can + # resolve dependencies. The bundled ``hermes-ink`` workspace package is + # now COPIED into the image as a whole tree (not just its lockfile) + # because it's referenced as a ``file:`` workspace dependency from + # ``ui-tui/package.json`` — copying the tree avoids npm stopping at a + # bare ``package.json`` shell. assert "ui-tui/package.json" in dockerfile_text - assert "ui-tui/packages/hermes-ink/package-lock.json" in dockerfile_text + assert "ui-tui/package-lock.json" in dockerfile_text + assert "ui-tui/packages/hermes-ink/" in dockerfile_text assert any( "ui-tui" in step and "npm" in step and (" install" in step or " ci" in step) for step in _run_steps(dockerfile_text) @@ -122,16 +129,17 @@ def test_dockerfile_builds_tui_assets(dockerfile_text): def test_dockerfile_materializes_local_tui_ink_package(dockerfile_text): - assert any( - "ui-tui" in step - and "node_modules/@hermes/ink" in step - and "packages/hermes-ink" in step - and "rm -rf packages/hermes-ink/node_modules" in step - and "npm install --omit=dev" in step - and "--prefix node_modules/@hermes/ink" in step - and "rm -rf node_modules/@hermes/ink/node_modules/react" in step - and "await import('@hermes/ink')" in step - for step in _run_steps(dockerfile_text) + # ``hermes-ink`` is a bundled workspace package referenced from + # ``ui-tui/package.json`` via ``file:`` — not pulled from the npm + # registry. The contract this test pins is just that the image + # actually carries the package source so ``await import('@hermes/ink')`` + # can resolve at runtime; the previous, much pickier assertion (manual + # ``rm -rf`` + ``npm install --omit=dev --prefix node_modules/@hermes/ink``) + # baked in implementation details of an older materialisation flow that + # was simplified once npm workspaces handled the resolution natively. + assert "ui-tui/packages/hermes-ink/" in dockerfile_text, ( + "Dockerfile must COPY the bundled hermes-ink workspace package " + "so ``await import('@hermes/ink')`` resolves at runtime." ) diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index f00a33d544b8..aa7168da6cb1 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -214,6 +214,61 @@ def test_heartbeat_without_note(worker_env): assert d["ok"] is True +def test_heartbeat_extends_claim_expires(worker_env): + """The kanban_heartbeat tool MUST extend claim_expires, not just + update last_heartbeat_at — otherwise long-running workers loop the + heartbeat tool diligently and still get reclaimed by + release_stale_claims at DEFAULT_CLAIM_TTL_SECONDS. + + Regression test for the bug where _handle_heartbeat called + heartbeat_worker but never heartbeat_claim, so claim_expires sat + static while last_heartbeat_at advanced. + """ + import time as _time + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + # Rewind claim_expires into the past so any forward movement is + # unambiguous (avoids time.sleep flakiness). + conn = kb.connect() + try: + conn.execute( + "UPDATE tasks SET claim_expires = ? WHERE id = ?", + (1, worker_env), + ) + conn.commit() + before = conn.execute( + "SELECT claim_expires FROM tasks WHERE id = ?", (worker_env,) + ).fetchone()["claim_expires"] + finally: + conn.close() + assert before == 1 + + out = kt._handle_heartbeat({"note": "still alive"}) + assert json.loads(out).get("ok") is True + + conn = kb.connect() + try: + after = conn.execute( + "SELECT claim_expires FROM tasks WHERE id = ?", (worker_env,) + ).fetchone()["claim_expires"] + finally: + conn.close() + + now = int(_time.time()) + # claim_expires should be roughly now + DEFAULT_CLAIM_TTL_SECONDS. + # We assert a generous floor (now + half the default TTL) to keep the + # test stable against future TTL changes. + assert after > before, ( + f"claim_expires did not advance ({before} -> {after}); workers " + f"would be reclaimed at TTL despite heartbeating" + ) + assert after >= now + (kb.DEFAULT_CLAIM_TTL_SECONDS // 2), ( + f"claim_expires={after} is suspiciously close to now={now}; " + f"expected at least now + {kb.DEFAULT_CLAIM_TTL_SECONDS // 2}" + ) + + def test_comment_happy_path(worker_env): from tools import kanban_tools as kt out = kt._handle_comment({ diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py index 319620e4127c..2dfebd80b9cd 100644 --- a/tests/tools/test_mcp_oauth.py +++ b/tests/tools/test_mcp_oauth.py @@ -2,6 +2,8 @@ import json import os +import stat +import sys from io import BytesIO from pathlib import Path from unittest.mock import patch, MagicMock, AsyncMock @@ -50,6 +52,37 @@ def test_roundtrip_tokens(self, tmp_path, monkeypatch): data = json.loads(token_path.read_text()) assert data["access_token"] == "abc123" + @pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX mode bits not enforced on Windows") + def test_token_file_created_with_0o600(self, tmp_path, monkeypatch): + """Tokens must land on disk at 0o600 with no umask-default exposure window. + + Regression for the TOCTOU race where ``write_text`` + post-write + ``chmod`` briefly left credentials at the process umask (commonly + 0o644 = world-readable) before tightening to owner-only. Mirrors + the fix shipped for ``agent/google_oauth.py`` in #19673. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + storage = HermesTokenStorage("perm-test-server") + + import asyncio + mock_token = MagicMock() + mock_token.model_dump.return_value = { + "access_token": "secret-abc", + "token_type": "Bearer", + "refresh_token": "secret-ref", + } + asyncio.run(storage.set_tokens(mock_token)) + + token_path = tmp_path / "mcp-tokens" / "perm-test-server.json" + assert token_path.exists() + mode = stat.S_IMODE(token_path.stat().st_mode) + assert mode == 0o600, f"token file mode {oct(mode)} != 0o600 — TOCTOU race regressed" + + parent_mode = stat.S_IMODE(token_path.parent.stat().st_mode) + assert parent_mode == 0o700, ( + f"token parent dir mode {oct(parent_mode)} != 0o700 — siblings can traverse" + ) + def test_roundtrip_client_info(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) storage = HermesTokenStorage("test-server") diff --git a/tests/tools/test_mcp_oauth_metadata.py b/tests/tools/test_mcp_oauth_metadata.py new file mode 100644 index 000000000000..5d161075e63d --- /dev/null +++ b/tests/tools/test_mcp_oauth_metadata.py @@ -0,0 +1,213 @@ +"""Tests for OAuth server metadata persistence across process restarts. + +Covers: +- :class:`HermesTokenStorage` ``.meta.json`` roundtrip (save / load / remove) +- The production manager provider + (:class:`tools.mcp_oauth_manager.HermesMCPOAuthProvider`) restoring metadata + on cold-load init and persisting metadata at the end of ``async_auth_flow``. + +Context +======= +The MCP SDK discovers OAuth server metadata (``token_endpoint``, etc.) +on-demand and keeps it in memory only. Without disk persistence a restart +forces the SDK to fall back to guessing ``{server_url}/token``, which returns +404 on most real providers and triggers a full browser re-auth even when the +refresh token is still valid. These tests lock in the disk persistence +layer so refresh across restarts stays quiet. +""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from mcp.shared.auth import OAuthMetadata + +from tools.mcp_oauth import HermesTokenStorage +from tools.mcp_oauth_manager import _HERMES_PROVIDER_CLS + + +def _make_metadata(token_endpoint: str = "https://auth.example.com/oauth/token") -> OAuthMetadata: + return OAuthMetadata.model_validate( + { + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/oauth/authorize", + "token_endpoint": token_endpoint, + "response_types_supported": ["code"], + } + ) + + +# --------------------------------------------------------------------------- +# HermesTokenStorage metadata roundtrip +# --------------------------------------------------------------------------- + + +class TestMetadataStorage: + def test_save_and_load_roundtrip(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + storage = HermesTokenStorage("example-server") + + meta = _make_metadata() + storage.save_oauth_metadata(meta) + + meta_path = tmp_path / "mcp-tokens" / "example-server.meta.json" + assert meta_path.exists() + + loaded = storage.load_oauth_metadata() + assert loaded is not None + assert str(loaded.token_endpoint) == "https://auth.example.com/oauth/token" + assert str(loaded.issuer).rstrip("/") == "https://auth.example.com" + + def test_load_missing_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + storage = HermesTokenStorage("nonexistent") + assert storage.load_oauth_metadata() is None + + def test_load_corrupt_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + storage = HermesTokenStorage("corrupt-server") + + # Write something that doesn't validate as OAuthMetadata + meta_path = storage._meta_path() + meta_path.parent.mkdir(parents=True, exist_ok=True) + meta_path.write_text(json.dumps({"issuer": "not-a-url", "wrong_field": 123})) + + assert storage.load_oauth_metadata() is None + + def test_remove_deletes_meta_file(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + storage = HermesTokenStorage("cleanup-server") + + storage.save_oauth_metadata(_make_metadata()) + assert storage._meta_path().exists() + + storage.remove() + assert not storage._meta_path().exists() + + +# --------------------------------------------------------------------------- +# Manager-path provider (HermesMCPOAuthProvider) — production code path +# --------------------------------------------------------------------------- + + +def _manager_provider_with_context(storage: HermesTokenStorage, **context_attrs): + """Build an uninitialized manager provider with a mocked context. + + Bypasses the full OAuthClientProvider init so we can exercise the + override logic in isolation. + """ + if _HERMES_PROVIDER_CLS is None: + pytest.skip("MCP SDK auth not available") + provider = _HERMES_PROVIDER_CLS.__new__(_HERMES_PROVIDER_CLS) + provider._hermes_server_name = context_attrs.get("server_name", "srv") + context = MagicMock() + context.storage = storage + context.oauth_metadata = context_attrs.get("oauth_metadata") + context.current_tokens = context_attrs.get("current_tokens") + context.server_url = context_attrs.get("server_url", "https://example.com") + context.update_token_expiry = MagicMock() + provider.context = context + return provider + + +class TestManagerOAuthProviderMetadata: + def test_initialize_restores_metadata_from_disk(self, tmp_path, monkeypatch): + """Cold-load: if we have no in-memory metadata but disk has some, restore it.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + storage = HermesTokenStorage("mgr-srv") + storage.save_oauth_metadata(_make_metadata("https://mgr.example.com/token")) + provider = _manager_provider_with_context(storage, oauth_metadata=None) + + with patch.object( + _HERMES_PROVIDER_CLS.__bases__[0], "_initialize", new=AsyncMock() + ): + asyncio.run(provider._initialize()) + + assert provider.context.oauth_metadata is not None + assert str(provider.context.oauth_metadata.token_endpoint) == \ + "https://mgr.example.com/token" + + def test_initialize_skips_restore_when_in_memory_present(self, tmp_path, monkeypatch): + """If SDK already has metadata in memory, don't overwrite from disk.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + storage = HermesTokenStorage("mgr-srv2") + storage.save_oauth_metadata(_make_metadata("https://disk.example.com/token")) + in_memory = _make_metadata("https://memory.example.com/token") + + provider = _manager_provider_with_context(storage, oauth_metadata=in_memory) + + with patch.object( + _HERMES_PROVIDER_CLS.__bases__[0], "_initialize", new=AsyncMock() + ): + asyncio.run(provider._initialize()) + + assert str(provider.context.oauth_metadata.token_endpoint) == \ + "https://memory.example.com/token" + + def test_persist_metadata_if_changed_writes_on_first_discover(self, tmp_path, monkeypatch): + """When nothing on disk yet, persist what the SDK discovered in-memory.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + storage = HermesTokenStorage("persist-srv") + assert storage.load_oauth_metadata() is None + + discovered = _make_metadata("https://discovered.example.com/token") + provider = _manager_provider_with_context(storage, oauth_metadata=discovered) + + provider._persist_oauth_metadata_if_changed() + + loaded = storage.load_oauth_metadata() + assert loaded is not None + assert str(loaded.token_endpoint) == "https://discovered.example.com/token" + + def test_persist_metadata_noop_when_unchanged(self, tmp_path, monkeypatch): + """No-op write when disk already matches in-memory metadata.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + storage = HermesTokenStorage("noop-srv") + meta = _make_metadata("https://same.example.com/token") + storage.save_oauth_metadata(meta) + + provider = _manager_provider_with_context(storage, oauth_metadata=meta) + + with patch.object( + HermesTokenStorage, "save_oauth_metadata" + ) as save_spy: + provider._persist_oauth_metadata_if_changed() + save_spy.assert_not_called() + + def test_async_auth_flow_persists_on_completion(self, tmp_path, monkeypatch): + """End-to-end: running the wrapped auth_flow persists discovered metadata.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + storage = HermesTokenStorage("flow-srv") + provider = _manager_provider_with_context( + storage, + oauth_metadata=_make_metadata("https://flow.example.com/token"), + server_name="flow-srv", + ) + + async def fake_parent_flow(self, request): + if False: + yield # pragma: no cover -- make this an async generator + return + + manager = MagicMock() + manager.invalidate_if_disk_changed = AsyncMock(return_value=False) + + with patch.object( + _HERMES_PROVIDER_CLS.__bases__[0], + "async_auth_flow", + new=fake_parent_flow, + ), patch("tools.mcp_oauth_manager.get_manager", return_value=manager): + async def drive(): + gen = provider.async_auth_flow(MagicMock()) + async for _ in gen: + pass + + asyncio.run(drive()) + + loaded = storage.load_oauth_metadata() + assert loaded is not None + assert str(loaded.token_endpoint) == "https://flow.example.com/token" diff --git a/tests/tools/test_memory_tool_schema.py b/tests/tools/test_memory_tool_schema.py new file mode 100644 index 000000000000..ea5ebdea5e1c --- /dev/null +++ b/tests/tools/test_memory_tool_schema.py @@ -0,0 +1,39 @@ +import json +from tools.memory_tool import MEMORY_SCHEMA + + +def test_memory_schema_requires_content_and_old_text_for_replace_action(): + schema = MEMORY_SCHEMA["parameters"] + assert schema["required"] == ["action", "target"] + + all_of = schema.get("allOf") + assert all_of, "memory schema should use conditional requirements" + + replace_requirements = [ + branch["then"].get("required", []) + for branch in all_of + if branch.get("if", {}).get("properties", {}).get("action", {}).get("const") == "replace" + ] + assert replace_requirements == [["old_text", "content"]] + + +def test_memory_schema_requires_content_for_add_action(): + add_requirements = [ + branch["then"].get("required", []) + for branch in MEMORY_SCHEMA["parameters"].get("allOf", []) + if branch.get("if", {}).get("properties", {}).get("action", {}).get("const") == "add" + ] + assert add_requirements == [["content"]] + + +def test_memory_schema_requires_old_text_for_remove_action(): + remove_requirements = [ + branch["then"].get("required", []) + for branch in MEMORY_SCHEMA["parameters"].get("allOf", []) + if branch.get("if", {}).get("properties", {}).get("action", {}).get("const") == "remove" + ] + assert remove_requirements == [["old_text"]] + + +def test_memory_schema_is_json_serializable(): + json.dumps(MEMORY_SCHEMA) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 48bf2568aca5..3b2c0899158c 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -140,6 +140,7 @@ def test_resolved_telegram_topic_name_preserves_thread_id(self): "hello", thread_id="17585", media_files=[], + force_document=False, ) def test_display_label_target_resolves_via_channel_directory(self, tmp_path): @@ -178,6 +179,7 @@ def test_display_label_target_resolves_via_channel_directory(self, tmp_path): "hello", thread_id="17585", media_files=[], + force_document=False, ) def test_mirror_receives_current_session_user_id(self): @@ -483,7 +485,7 @@ def test_telegram_media_attaches_to_last_chunk(self): sent_calls = [] - async def fake_send(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False): + async def fake_send(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False, force_document=False): sent_calls.append(media_files or []) return {"success": True, "platform": "telegram", "chat_id": chat_id, "message_id": str(len(sent_calls))} diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index 12b5b92ac574..38d27d40af3c 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -5,6 +5,7 @@ from tools.url_safety import ( is_safe_url, + is_always_blocked_url, _is_blocked_ip, _global_allow_private_urls, _reset_allow_private_cache, @@ -407,3 +408,69 @@ def test_empty_url_still_blocked_with_toggle(self, monkeypatch): """Empty URLs are still blocked.""" monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true") assert is_safe_url("") is False + + +class TestIsAlwaysBlockedUrl: + """The always-blocked floor — cloud metadata only, narrower than is_safe_url.""" + + # -- The sentinel set that must always block -------------------------------- + + @pytest.mark.parametrize("url", [ + "http://169.254.169.254/latest/meta-data/", # AWS / GCP / Azure / DO / Oracle + "http://169.254.169.253/metadata/instance", # Azure IMDS wire server + "http://169.254.170.2/v2/credentials", # AWS ECS task metadata + "http://100.100.100.200/latest/meta-data/", # Alibaba Cloud + "http://169.254.42.1/", # Any /16 link-local + ]) + def test_literal_imds_ips_always_blocked(self, url): + """Literal IMDS IPs and the /16 link-local range always block.""" + assert is_always_blocked_url(url) is True + + def test_gcp_metadata_hostname_always_blocked_even_without_dns(self): + """metadata.google.internal blocks by hostname, no DNS needed.""" + with patch("socket.getaddrinfo", side_effect=socket.gaierror("nope")): + assert is_always_blocked_url("http://metadata.google.internal/") is True + + def test_hostname_resolving_to_imds_always_blocked(self): + """Attacker-controlled hostname resolving to IMDS still blocks.""" + with patch("socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("169.254.169.254", 0)), + ]): + assert is_always_blocked_url("http://attacker-controlled.example.com/") is True + + # -- Things the floor must NOT block ---------------------------------------- + + def test_public_url_not_blocked(self): + assert is_always_blocked_url("https://example.com/path") is False + + @pytest.mark.parametrize("url", [ + "http://127.0.0.1:8080/", + "http://192.168.1.1/", + "http://10.0.0.5/", + "http://172.16.0.1/", + "http://100.64.0.1/", # CGNAT — blocked by is_safe_url but not by the floor + ]) + def test_ordinary_private_urls_not_in_floor(self, url): + """Floor is narrower than is_safe_url — ordinary private URLs pass.""" + assert is_always_blocked_url(url) is False + + def test_dns_failure_not_in_floor(self): + """DNS failure on a non-sentinel hostname = not always-blocked. + + Caller's ordinary fail-closed path (is_safe_url) handles that case. + """ + with patch("socket.getaddrinfo", side_effect=socket.gaierror("fail")): + assert is_always_blocked_url("http://nonexistent.example.com/") is False + + def test_empty_url_not_in_floor(self): + """Empty URL falls through — caller decides what to do with a malformed URL.""" + assert is_always_blocked_url("") is False + + def test_malformed_url_not_in_floor(self): + """Parse errors don't claim always-blocked status.""" + assert is_always_blocked_url("not a url at all") is False + + def test_floor_ignores_allow_private_urls_toggle(self, monkeypatch): + """security.allow_private_urls can NOT unblock cloud metadata.""" + monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true") + assert is_always_blocked_url("http://169.254.169.254/") is True diff --git a/tests/tools/test_web_providers.py b/tests/tools/test_web_providers.py new file mode 100644 index 000000000000..3c0abb307b06 --- /dev/null +++ b/tests/tools/test_web_providers.py @@ -0,0 +1,194 @@ +"""Tests for the web tools provider architecture. + +Covers: +- WebSearchProvider / WebExtractProvider ABC enforcement +- Per-capability backend selection (_get_search_backend, _get_extract_backend) +- Backward compatibility (web.backend still works as shared fallback) +- Config keys merge correctly via DEFAULT_CONFIG +""" +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import pytest + + +# --------------------------------------------------------------------------- +# ABC enforcement +# --------------------------------------------------------------------------- + + +class TestWebProviderABCs: + """The ABCs enforce the interface contract.""" + + def test_cannot_instantiate_search_provider(self): + from tools.web_providers.base import WebSearchProvider + + with pytest.raises(TypeError): + WebSearchProvider() # type: ignore[abstract] + + def test_cannot_instantiate_extract_provider(self): + from tools.web_providers.base import WebExtractProvider + + with pytest.raises(TypeError): + WebExtractProvider() # type: ignore[abstract] + + def test_concrete_search_provider_works(self): + from tools.web_providers.base import WebSearchProvider + + class Dummy(WebSearchProvider): + def provider_name(self) -> str: + return "dummy" + def is_configured(self) -> bool: + return True + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + return {"success": True, "data": {"web": []}} + + d = Dummy() + assert d.provider_name() == "dummy" + assert d.is_configured() is True + assert d.search("test")["success"] is True + + def test_concrete_extract_provider_works(self): + from tools.web_providers.base import WebExtractProvider + + class Dummy(WebExtractProvider): + def provider_name(self) -> str: + return "dummy" + def is_configured(self) -> bool: + return True + def extract(self, urls: List[str], **kwargs) -> Dict[str, Any]: + return {"success": True, "data": [{"url": urls[0], "content": "x"}]} + + d = Dummy() + assert d.provider_name() == "dummy" + assert d.extract(["https://example.com"])["success"] is True + + +# --------------------------------------------------------------------------- +# Per-capability backend selection +# --------------------------------------------------------------------------- + + +class TestPerCapabilityBackendSelection: + """_get_search_backend and _get_extract_backend read per-capability config.""" + + def test_search_backend_overrides_generic(self, monkeypatch): + from tools import web_tools + + monkeypatch.setattr(web_tools, "_load_web_config", lambda: { + "backend": "firecrawl", + "search_backend": "tavily", + }) + monkeypatch.setenv("TAVILY_API_KEY", "test-key") + assert web_tools._get_search_backend() == "tavily" + + def test_extract_backend_overrides_generic(self, monkeypatch): + from tools import web_tools + + monkeypatch.setattr(web_tools, "_load_web_config", lambda: { + "backend": "tavily", + "extract_backend": "exa", + }) + monkeypatch.setenv("EXA_API_KEY", "test-key") + assert web_tools._get_extract_backend() == "exa" + + def test_falls_back_to_generic_backend_when_search_backend_empty(self, monkeypatch): + from tools import web_tools + + monkeypatch.setattr(web_tools, "_load_web_config", lambda: { + "backend": "tavily", + "search_backend": "", + }) + monkeypatch.setenv("TAVILY_API_KEY", "test-key") + assert web_tools._get_search_backend() == "tavily" + + def test_falls_back_to_generic_backend_when_extract_backend_empty(self, monkeypatch): + from tools import web_tools + + monkeypatch.setattr(web_tools, "_load_web_config", lambda: { + "backend": "parallel", + "extract_backend": "", + }) + monkeypatch.setenv("PARALLEL_API_KEY", "test-key") + assert web_tools._get_extract_backend() == "parallel" + + def test_search_backend_ignored_when_not_available(self, monkeypatch): + from tools import web_tools + + monkeypatch.setattr(web_tools, "_load_web_config", lambda: { + "backend": "firecrawl", + "search_backend": "exa", # set but no EXA_API_KEY + }) + monkeypatch.delenv("EXA_API_KEY", raising=False) + monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-key") + # Should fall back to firecrawl since exa isn't configured + assert web_tools._get_search_backend() == "firecrawl" + + def test_fully_backward_compatible_with_web_backend_only(self, monkeypatch): + from tools import web_tools + + monkeypatch.setattr(web_tools, "_load_web_config", lambda: { + "backend": "tavily", + }) + monkeypatch.setenv("TAVILY_API_KEY", "test-key") + # No search_backend or extract_backend set — both fall through + assert web_tools._get_search_backend() == "tavily" + assert web_tools._get_extract_backend() == "tavily" + + +# --------------------------------------------------------------------------- +# Config key presence in DEFAULT_CONFIG +# --------------------------------------------------------------------------- + + +class TestDefaultConfig: + """The web section exists in DEFAULT_CONFIG with per-capability keys.""" + + def test_web_section_in_default_config(self): + from hermes_cli.config import DEFAULT_CONFIG + + assert "web" in DEFAULT_CONFIG + web = DEFAULT_CONFIG["web"] + assert "backend" in web + assert "search_backend" in web + assert "extract_backend" in web + # All empty string by default (no override) + assert web["backend"] == "" + assert web["search_backend"] == "" + assert web["extract_backend"] == "" + + +# --------------------------------------------------------------------------- +# web_search_tool uses _get_search_backend +# --------------------------------------------------------------------------- + + +class TestWebSearchUsesSearchBackend: + """web_search_tool dispatches through _get_search_backend not _get_backend.""" + + def test_search_tool_calls_search_backend(self, monkeypatch): + from tools import web_tools + + called_with = [] + original_get_search = web_tools._get_search_backend + + def tracking_get_search(): + result = original_get_search() + called_with.append(("search", result)) + return result + + monkeypatch.setattr(web_tools, "_get_search_backend", tracking_get_search) + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "firecrawl"}) + monkeypatch.setenv("FIRECRAWL_API_KEY", "fake") + + # The function will fail at Firecrawl client level but we just + # need to verify _get_search_backend was called + try: + web_tools.web_search_tool("test", 1) + except Exception: + pass + + assert len(called_with) > 0 + assert called_with[0][0] == "search" diff --git a/tests/tools/test_web_providers_searxng.py b/tests/tools/test_web_providers_searxng.py new file mode 100644 index 000000000000..4779ed6ce6ea --- /dev/null +++ b/tests/tools/test_web_providers_searxng.py @@ -0,0 +1,337 @@ +"""Tests for the SearXNG web search provider. + +Covers: +- SearXNGSearchProvider.is_configured() env var gating +- SearXNGSearchProvider.search() — happy path, HTTP error, request error, bad JSON +- Result normalization (title, url, description, position) +- Score-based sorting and limit truncation +- _is_backend_available("searxng") integration +- _get_backend() recognizes "searxng" as a valid configured backend +- check_web_api_key() includes searxng in availability check +""" +from __future__ import annotations + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# SearXNGSearchProvider unit tests +# --------------------------------------------------------------------------- + + +class TestSearXNGSearchProviderIsConfigured: + def test_configured_when_url_set(self, monkeypatch): + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + from tools.web_providers.searxng import SearXNGSearchProvider + assert SearXNGSearchProvider().is_configured() is True + + def test_not_configured_when_url_missing(self, monkeypatch): + monkeypatch.delenv("SEARXNG_URL", raising=False) + from tools.web_providers.searxng import SearXNGSearchProvider + assert SearXNGSearchProvider().is_configured() is False + + def test_not_configured_when_url_empty_string(self, monkeypatch): + monkeypatch.setenv("SEARXNG_URL", " ") + from tools.web_providers.searxng import SearXNGSearchProvider + assert SearXNGSearchProvider().is_configured() is False + + def test_provider_name(self): + from tools.web_providers.searxng import SearXNGSearchProvider + assert SearXNGSearchProvider().provider_name() == "searxng" + + def test_implements_web_search_provider(self): + from tools.web_providers.base import WebSearchProvider + from tools.web_providers.searxng import SearXNGSearchProvider + assert issubclass(SearXNGSearchProvider, WebSearchProvider) + + +class TestSearXNGSearchProviderSearch: + """Happy path and error handling for SearXNGSearchProvider.search().""" + + _SAMPLE_RESPONSE = { + "results": [ + {"title": "Result A", "url": "https://a.example.com", "content": "Desc A", "score": 0.9}, + {"title": "Result B", "url": "https://b.example.com", "content": "Desc B", "score": 0.7}, + {"title": "Result C", "url": "https://c.example.com", "content": "Desc C", "score": 0.5}, + ] + } + + def _make_mock_response(self, json_data, status_code=200): + mock_resp = MagicMock() + mock_resp.status_code = status_code + mock_resp.json.return_value = json_data + mock_resp.raise_for_status = MagicMock() + return mock_resp + + def test_happy_path_returns_normalized_results(self, monkeypatch): + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + from tools.web_providers.searxng import SearXNGSearchProvider + mock_resp = self._make_mock_response(self._SAMPLE_RESPONSE) + + with patch("httpx.get", return_value=mock_resp): + result = SearXNGSearchProvider().search("test query", limit=5) + + assert result["success"] is True + web = result["data"]["web"] + assert len(web) == 3 + assert web[0]["title"] == "Result A" + assert web[0]["url"] == "https://a.example.com" + assert web[0]["description"] == "Desc A" + assert web[0]["position"] == 1 + + def test_results_sorted_by_score_descending(self, monkeypatch): + """Results should be sorted by score before limit is applied.""" + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + from tools.web_providers.searxng import SearXNGSearchProvider + unordered = { + "results": [ + {"title": "Low", "url": "https://low.example.com", "content": "", "score": 0.1}, + {"title": "High", "url": "https://high.example.com", "content": "", "score": 0.99}, + {"title": "Mid", "url": "https://mid.example.com", "content": "", "score": 0.5}, + ] + } + mock_resp = self._make_mock_response(unordered) + + with patch("httpx.get", return_value=mock_resp): + result = SearXNGSearchProvider().search("query", limit=5) + + assert result["success"] is True + assert result["data"]["web"][0]["title"] == "High" + assert result["data"]["web"][1]["title"] == "Mid" + assert result["data"]["web"][2]["title"] == "Low" + + def test_limit_is_respected(self, monkeypatch): + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + from tools.web_providers.searxng import SearXNGSearchProvider + mock_resp = self._make_mock_response(self._SAMPLE_RESPONSE) + + with patch("httpx.get", return_value=mock_resp): + result = SearXNGSearchProvider().search("query", limit=2) + + assert result["success"] is True + assert len(result["data"]["web"]) == 2 + + def test_position_is_one_indexed(self, monkeypatch): + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + from tools.web_providers.searxng import SearXNGSearchProvider + mock_resp = self._make_mock_response(self._SAMPLE_RESPONSE) + + with patch("httpx.get", return_value=mock_resp): + result = SearXNGSearchProvider().search("query", limit=5) + + positions = [r["position"] for r in result["data"]["web"]] + assert positions == [1, 2, 3] + + def test_empty_results(self, monkeypatch): + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + from tools.web_providers.searxng import SearXNGSearchProvider + mock_resp = self._make_mock_response({"results": []}) + + with patch("httpx.get", return_value=mock_resp): + result = SearXNGSearchProvider().search("nothing", limit=5) + + assert result["success"] is True + assert result["data"]["web"] == [] + + def test_missing_score_falls_back_to_zero(self, monkeypatch): + """Results without a score field should sort to the bottom.""" + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + from tools.web_providers.searxng import SearXNGSearchProvider + data = { + "results": [ + {"title": "No score", "url": "https://noscore.example.com", "content": ""}, + {"title": "Has score", "url": "https://scored.example.com", "content": "", "score": 0.8}, + ] + } + mock_resp = self._make_mock_response(data) + + with patch("httpx.get", return_value=mock_resp): + result = SearXNGSearchProvider().search("query", limit=5) + + assert result["success"] is True + # Has score should sort first (0.8 > 0) + assert result["data"]["web"][0]["title"] == "Has score" + + def test_http_error_returns_failure(self, monkeypatch): + import httpx + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + from tools.web_providers.searxng import SearXNGSearchProvider + + mock_resp = MagicMock() + mock_resp.status_code = 500 + http_err = httpx.HTTPStatusError("500", request=MagicMock(), response=mock_resp) + + with patch("httpx.get", side_effect=http_err): + result = SearXNGSearchProvider().search("query", limit=5) + + assert result["success"] is False + assert "500" in result["error"] + + def test_request_error_returns_failure(self, monkeypatch): + import httpx + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + from tools.web_providers.searxng import SearXNGSearchProvider + + with patch("httpx.get", side_effect=httpx.RequestError("connection refused")): + result = SearXNGSearchProvider().search("query", limit=5) + + assert result["success"] is False + assert "localhost:8080" in result["error"] or "connection" in result["error"].lower() + + def test_missing_url_returns_failure(self, monkeypatch): + monkeypatch.delenv("SEARXNG_URL", raising=False) + from tools.web_providers.searxng import SearXNGSearchProvider + + result = SearXNGSearchProvider().search("query", limit=5) + assert result["success"] is False + assert "SEARXNG_URL" in result["error"] + + def test_trailing_slash_stripped_from_url(self, monkeypatch): + """Base URL trailing slash should not produce double-slash in endpoint.""" + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080/") + from tools.web_providers.searxng import SearXNGSearchProvider + mock_resp = self._make_mock_response({"results": []}) + + calls = [] + def capture_get(url, **kwargs): + calls.append(url) + return mock_resp + + with patch("httpx.get", side_effect=capture_get): + SearXNGSearchProvider().search("query", limit=5) + + assert calls[0] == "http://localhost:8080/search", f"Got: {calls[0]}" + + +# --------------------------------------------------------------------------- +# Integration: _is_backend_available recognizes "searxng" +# --------------------------------------------------------------------------- + + +class TestIsBackendAvailable: + def test_searxng_available_when_url_set(self, monkeypatch): + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + from tools.web_tools import _is_backend_available + assert _is_backend_available("searxng") is True + + def test_searxng_unavailable_when_url_missing(self, monkeypatch): + monkeypatch.delenv("SEARXNG_URL", raising=False) + from tools.web_tools import _is_backend_available + assert _is_backend_available("searxng") is False + + def test_unknown_backend_still_false(self): + from tools.web_tools import _is_backend_available + assert _is_backend_available("unknownbackend") is False + + +# --------------------------------------------------------------------------- +# Integration: _get_backend() accepts "searxng" as configured value +# --------------------------------------------------------------------------- + + +class TestGetBackendSearXNG: + def test_configured_searxng_returns_searxng(self, monkeypatch): + from tools import web_tools + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "searxng"}) + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + assert web_tools._get_backend() == "searxng" + + def test_auto_detect_picks_searxng_when_only_url_set(self, monkeypatch): + """When no backend is configured but SEARXNG_URL is set, auto-detect returns it.""" + from tools import web_tools + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {}) + monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False) + monkeypatch.delenv("FIRECRAWL_API_URL", raising=False) + monkeypatch.delenv("PARALLEL_API_KEY", raising=False) + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("EXA_API_KEY", raising=False) + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + # Suppress tool gateway + monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) + assert web_tools._get_backend() == "searxng" + + def test_searxng_does_not_override_higher_priority_provider(self, monkeypatch): + """Tavily (higher priority than searxng) should win in auto-detect.""" + from tools import web_tools + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {}) + monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False) + monkeypatch.delenv("FIRECRAWL_API_URL", raising=False) + monkeypatch.delenv("PARALLEL_API_KEY", raising=False) + monkeypatch.setenv("TAVILY_API_KEY", "tvly-key") + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) + assert web_tools._get_backend() == "tavily" + + +# --------------------------------------------------------------------------- +# Integration: check_web_api_key includes searxng +# --------------------------------------------------------------------------- + + +class TestCheckWebApiKey: + def test_searxng_satisfies_check_web_api_key(self, monkeypatch): + from tools import web_tools + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "searxng"}) + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + assert web_tools.check_web_api_key() is True + + def test_no_credentials_fails(self, monkeypatch): + from tools import web_tools + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {}) + monkeypatch.delenv("FIRECRAWL_API_KEY", raising=False) + monkeypatch.delenv("FIRECRAWL_API_URL", raising=False) + monkeypatch.delenv("PARALLEL_API_KEY", raising=False) + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + monkeypatch.delenv("EXA_API_KEY", raising=False) + monkeypatch.delenv("SEARXNG_URL", raising=False) + monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) + monkeypatch.setattr(web_tools, "check_firecrawl_api_key", lambda: False) + assert web_tools.check_web_api_key() is False + + +# --------------------------------------------------------------------------- +# searxng-only: web_extract and web_crawl return clear errors +# --------------------------------------------------------------------------- + + +class TestSearXNGOnlyExtractCrawlErrors: + """When searxng is the active backend, extract/crawl must return clear errors.""" + + def test_web_crawl_searxng_returns_clear_error(self, monkeypatch): + import asyncio + from tools import web_tools + + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "searxng"}) + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) + monkeypatch.setattr(web_tools, "check_firecrawl_api_key", lambda: False) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) + + import json + result_str = asyncio.get_event_loop().run_until_complete( + web_tools.web_crawl_tool("https://example.com") + ) + result = json.loads(result_str) + assert result["success"] is False + assert "search-only" in result["error"].lower() or "SearXNG" in result["error"] + + def test_web_extract_searxng_returns_clear_error(self, monkeypatch): + import asyncio + from tools import web_tools + + monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "searxng"}) + monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") + monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) + + import json + result_str = asyncio.get_event_loop().run_until_complete( + web_tools.web_extract_tool(["https://example.com"]) + ) + result = json.loads(result_str) + assert result["success"] is False + assert "search-only" in result["error"].lower() or "SearXNG" in result["error"] diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 049565d638ad..c8cdedcf0b1f 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -76,9 +76,13 @@ check_website_access = lambda url: None # noqa: E731 — fail-open if policy module unavailable try: - from tools.url_safety import is_safe_url as _is_safe_url + from tools.url_safety import ( + is_safe_url as _is_safe_url, + is_always_blocked_url as _is_always_blocked_url, + ) except Exception: _is_safe_url = lambda url: False # noqa: E731 — fail-closed: block all if safety module unavailable + _is_always_blocked_url = lambda url: True # noqa: E731 — fail-closed on the floor too from tools.browser_providers.base import CloudBrowserProvider from tools.browser_providers.browserbase import BrowserbaseProvider from tools.browser_providers.browser_use import BrowserUseProvider @@ -837,6 +841,10 @@ def _url_is_private(url: str) -> bool: ip.is_private or ip.is_loopback or ip.is_link_local + # 172.16.0.0/12: only covered by ip.is_private on Python + # ≥3.11 (bpo-40791). Explicit check keeps 3.10 runtimes + # routing these to the local sidecar correctly. + or ip in ipaddress.ip_network("172.16.0.0/12") or ip in ipaddress.ip_network("100.64.0.0/10") ) except ValueError: @@ -2081,6 +2089,18 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: nav_session_key = _navigation_session_key(effective_task_id, url) auto_local_this_nav = _is_local_sidecar_key(nav_session_key) + # Always-blocked floor: cloud metadata / IMDS endpoints are denied + # regardless of backend, hybrid routing, or allow_private_urls. + # There's no legitimate agent use case for navigating to + # 169.254.169.254 / metadata.google.internal / ECS task metadata + # via a browser, and routing those to a local Chromium sidecar + # on an EC2/GCP/Azure host exfiltrates IAM credentials (#16234). + if not _is_local_backend() and _is_always_blocked_url(url): + return json.dumps({ + "success": False, + "error": "Blocked: URL targets a cloud metadata endpoint", + }) + if ( not _is_local_backend() and not auto_local_this_nav @@ -2143,6 +2163,21 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: # Skipped for local backends (same rationale as the pre-nav check), # and for the hybrid local sidecar (we're already on a local browser # hitting a private URL by design). + # Always-blocked floor (cloud metadata / IMDS) is enforced even + # when auto_local_this_nav is true — see pre-nav check for + # rationale (#16234). + if ( + not _is_local_backend() + and final_url + and final_url != url + and _is_always_blocked_url(final_url) + ): + _run_browser_command(nav_session_key, "open", ["about:blank"], timeout=10) + return json.dumps({ + "success": False, + "error": "Blocked: redirect landed on a cloud metadata endpoint", + }) + if ( not _is_local_backend() and not auto_local_this_nav diff --git a/tools/credential_files.py b/tools/credential_files.py index 2372950cfede..9026c679166a 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -374,6 +374,34 @@ def get_cache_directory_mounts( return mounts +def to_agent_visible_cache_path( + host_path: str, + container_base: str = "/root/.hermes", +) -> str: + """Translate a host cache path to its mounted path inside the sandbox. + + Returns the input unchanged if it is not under any auto-mounted cache + directory, or if the active terminal backend does not require path + translation (only Docker for now). + """ + # Only Docker backend requires translation at this time. Other backends + # (Modal, Daytona, Vercel) use different mount semantics and will be + # addressed separately if needed. Backend is identified by TERMINAL_ENV + # (same env var tools/terminal_tool.py reads in _get_environment_config). + if os.environ.get("TERMINAL_ENV", "local") != "docker": + return host_path + + path = Path(host_path) + for mount in get_cache_directory_mounts(container_base=container_base): + host_dir = Path(mount["host_path"]) + try: + rel = path.relative_to(host_dir) + return str(Path(mount["container_path"]) / rel) + except ValueError: + continue + return host_path + + def iter_cache_files( container_base: str = "/root/.hermes", ) -> List[Dict[str, str]]: diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 5c7c431b253a..7b4595cb710f 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2479,7 +2479,7 @@ def _load_config() -> dict: }, "acp_command": { "type": "string", - "description": "Per-task ACP command override (e.g. 'claude'). Overrides the top-level acp_command for this task only.", + "description": "Per-task ACP command override (e.g. 'copilot'). Overrides the top-level acp_command for this task only.", }, "acp_args": { "type": "array", @@ -2519,10 +2519,11 @@ def _load_config() -> dict: "acp_command": { "type": "string", "description": ( - "Override ACP command for child agents (e.g. 'claude', 'copilot'). " + "Override ACP command for child agents (e.g. 'copilot'). " "When set, children use ACP subprocess transport instead of inheriting " - "the parent's transport. Enables spawning Claude Code (claude --acp --stdio) " - "or other ACP-capable agents from any parent, including Discord/Telegram/CLI." + "the parent's transport. Requires an ACP-compatible CLI " + "(currently GitHub Copilot CLI via 'copilot --acp --stdio'). " + "See agent/copilot_acp_client.py for the implementation." ), }, "acp_args": { @@ -2530,7 +2531,7 @@ def _load_config() -> dict: "items": {"type": "string"}, "description": ( "Arguments for the ACP command (default: ['--acp', '--stdio']). " - "Only used when acp_command is set. Example: ['--acp', '--stdio', '--model', 'claude-opus-4-6']" + "Only used when acp_command is set." ), }, }, diff --git a/tools/discord_tool.py b/tools/discord_tool.py index 589b7022289e..1da43ac9140e 100644 --- a/tools/discord_tool.py +++ b/tools/discord_tool.py @@ -418,6 +418,12 @@ def _unpin_message(token: str, channel_id: str, message_id: str, **_kwargs: Any) return json.dumps({"success": True, "message": f"Message {message_id} unpinned."}) +def _delete_message(token: str, channel_id: str, message_id: str, **_kwargs: Any) -> str: + """Delete a message from a channel or thread.""" + _discord_request("DELETE", f"/channels/{channel_id}/messages/{message_id}", token) + return json.dumps({"success": True, "message": f"Message {message_id} deleted."}) + + def _create_thread( token: str, channel_id: str, name: str, message_id: Optional[str] = None, @@ -476,6 +482,7 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg "list_pins": _list_pins, "pin_message": _pin_message, "unpin_message": _unpin_message, + "delete_message": _delete_message, "create_thread": _create_thread, "add_role": _add_role, "remove_role": _remove_role, @@ -502,6 +509,7 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg ("list_pins", "(channel_id)", "pinned messages in a channel"), ("pin_message", "(channel_id, message_id)", "pin a message"), ("unpin_message", "(channel_id, message_id)", "unpin a message"), + ("delete_message", "(channel_id, message_id)", "delete a message"), ("create_thread", "(channel_id, name)", "create a public thread; optional message_id anchor"), ("add_role", "(guild_id, user_id, role_id)", "assign a role"), ("remove_role", "(guild_id, user_id, role_id)", "remove a role"), @@ -522,6 +530,7 @@ def _remove_role(token: str, guild_id: str, user_id: str, role_id: str, **_kwarg "list_pins": ["channel_id"], "pin_message": ["channel_id", "message_id"], "unpin_message": ["channel_id", "message_id"], + "delete_message": ["channel_id", "message_id"], "create_thread": ["channel_id", "name"], "add_role": ["guild_id", "user_id", "role_id"], "remove_role": ["guild_id", "user_id", "role_id"], @@ -758,6 +767,9 @@ def get_dynamic_schema() -> Optional[Dict[str, Any]]: "unpin_message": ( "Bot lacks MANAGE_MESSAGES permission in this channel." ), + "delete_message": ( + "Bot lacks MANAGE_MESSAGES permission in this channel, or cannot view the channel/message." + ), "create_thread": ( "Bot lacks CREATE_PUBLIC_THREADS in this channel, or cannot view it." ), diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 2f40b3f0de1f..2326895554fe 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -315,7 +315,15 @@ def _handle_block(args: dict, **kw) -> str: def _handle_heartbeat(args: dict, **kw) -> str: - """Signal that the worker is still alive during a long operation.""" + """Signal that the worker is still alive during a long operation. + + Extends the claim TTL via ``heartbeat_claim`` AND records a heartbeat + event via ``heartbeat_worker``. Without the ``heartbeat_claim`` half, + a diligent worker that loops this tool while a single tool call + blocks the agent for >DEFAULT_CLAIM_TTL_SECONDS still gets reclaimed + by ``release_stale_claims`` — which is exactly the trap that + ``heartbeat_claim``'s docstring warns against. + """ tid = _default_task_id(args.get("task_id")) if not tid: return tool_error( @@ -328,6 +336,14 @@ def _handle_heartbeat(args: dict, **kw) -> str: try: kb, conn = _connect() try: + # Extend the claim TTL first. The dispatcher pins + # HERMES_KANBAN_CLAIM_LOCK in the worker env at spawn time + # (see _default_spawn in kanban_db.py); falling back to the + # default _claimer_id() covers locally-driven workers that + # never went through the dispatcher path. + claim_lock = os.environ.get("HERMES_KANBAN_CLAIM_LOCK") + kb.heartbeat_claim(conn, tid, claimer=claim_lock) + ok = kb.heartbeat_worker( conn, tid, diff --git a/tools/mcp_oauth.py b/tools/mcp_oauth.py index 80dacdc420c0..d7bf135da47f 100644 --- a/tools/mcp_oauth.py +++ b/tools/mcp_oauth.py @@ -37,7 +37,9 @@ import logging import os import re +import secrets import socket +import stat import sys import threading import time @@ -59,6 +61,7 @@ from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, + OAuthMetadata, OAuthToken, ) @@ -160,15 +163,41 @@ def _read_json(path: Path) -> dict | None: def _write_json(path: Path, data: dict) -> None: - """Write a dict as JSON with restricted permissions (0o600).""" + """Write a dict as JSON with restricted permissions (0o600). + + Uses ``os.open`` with ``O_EXCL`` and an explicit mode so the file is + created atomically at 0o600. The previous ``write_text`` + post-write + ``chmod`` opened a TOCTOU window where the temp file briefly inherited + the process umask (commonly 0o644 = world-readable), exposing OAuth + tokens to other local users between create and chmod. Mirrors the fix + in ``agent/google_oauth.py`` (#19673). + """ path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(".tmp") + # Tighten parent dir to 0o700 so siblings can't traverse to the creds. + # No-op on Windows (POSIX mode bits aren't enforced); ignore failures. + try: + os.chmod(path.parent, 0o700) + except OSError: + pass + # Per-process random suffix avoids collisions between concurrent + # writers and stale leftovers from a prior crashed write. + tmp = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}") try: - tmp.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8") - os.chmod(tmp, 0o600) - tmp.rename(path) + fd = os.open( + str(tmp), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + stat.S_IRUSR | stat.S_IWUSR, + ) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, default=str) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) except OSError: - tmp.unlink(missing_ok=True) + try: + tmp.unlink(missing_ok=True) + except OSError: + pass raise @@ -184,6 +213,7 @@ class HermesTokenStorage: HERMES_HOME/mcp-tokens/.json -- tokens HERMES_HOME/mcp-tokens/.client.json -- client info + HERMES_HOME/mcp-tokens/.meta.json -- oauth server metadata """ def __init__(self, server_name: str): @@ -195,6 +225,9 @@ def _tokens_path(self) -> Path: def _client_info_path(self) -> Path: return _get_token_dir() / f"{self._server_name}.client.json" + def _meta_path(self) -> Path: + return _get_token_dir() / f"{self._server_name}.meta.json" + # -- tokens ------------------------------------------------------------ async def get_tokens(self) -> "OAuthToken | None": @@ -272,11 +305,33 @@ async def set_client_info(self, client_info: "OAuthClientInformationFull") -> No _write_json(self._client_info_path(), client_info.model_dump(mode="json", exclude_none=True)) logger.debug("OAuth client info saved for %s", self._server_name) + # -- oauth server metadata -------------------------------------------- + # The MCP SDK keeps discovered ``OAuthMetadata`` (token endpoint URL, + # etc.) in memory only. Persisting it here lets a restarted process + # refresh tokens without re-running metadata discovery. Without this, + # cold-start refresh requests fall back to the SDK's guessed + # ``{server_url}/token`` which returns 404 on most real providers and + # forces a full browser re-authorization. + + def save_oauth_metadata(self, metadata: "OAuthMetadata") -> None: + _write_json(self._meta_path(), metadata.model_dump(exclude_none=True, mode="json")) + logger.debug("OAuth metadata saved for %s", self._server_name) + + def load_oauth_metadata(self) -> "OAuthMetadata | None": + data = _read_json(self._meta_path()) + if data is None: + return None + try: + return OAuthMetadata.model_validate(data) + except (ValueError, TypeError, KeyError) as exc: + logger.warning("Corrupt OAuth metadata at %s -- ignoring: %s", self._meta_path(), exc) + return None + # -- cleanup ----------------------------------------------------------- def remove(self) -> None: """Delete all stored OAuth state for this server.""" - for p in (self._tokens_path(), self._client_info_path()): + for p in (self._tokens_path(), self._client_info_path(), self._meta_path()): p.unlink(missing_ok=True) def has_cached_tokens(self) -> bool: diff --git a/tools/mcp_oauth_manager.py b/tools/mcp_oauth_manager.py index dbe2fc3e06aa..6a4573a8677d 100644 --- a/tools/mcp_oauth_manager.py +++ b/tools/mcp_oauth_manager.py @@ -148,6 +148,27 @@ async def _initialize(self) -> None: if tokens is not None and tokens.expires_in is not None: self.context.update_token_expiry(tokens) + # Cold-load: restore OAuth server metadata from disk before any + # refresh attempt. Without this, a restarted process with cached + # tokens but no in-memory metadata would fall back to the SDK's + # guessed ``{server_url}/token`` path (returns 404 on most real + # providers) and require a full browser re-authorization. + storage = self.context.storage + from tools.mcp_oauth import HermesTokenStorage + if ( + isinstance(storage, HermesTokenStorage) + and self.context.oauth_metadata is None + ): + meta = storage.load_oauth_metadata() + if meta is not None: + self.context.oauth_metadata = meta + logger.debug( + "MCP OAuth '%s': restored metadata from disk " + "(token_endpoint=%s)", + self._hermes_server_name, + meta.token_endpoint, + ) + # Pre-flight OAuth AS discovery so ``_refresh_token`` has a # correct ``token_endpoint`` before the first refresh attempt. # Only runs when we have tokens on cold-load but no cached @@ -229,6 +250,12 @@ async def _prefetch_oauth_metadata(self) -> None: break if asm: self.context.oauth_metadata = asm + # Persist immediately so a subsequent cold-load can + # skip discovery entirely. + storage = self.context.storage + from tools.mcp_oauth import HermesTokenStorage + if isinstance(storage, HermesTokenStorage): + storage.save_oauth_metadata(asm) logger.debug( "MCP OAuth '%s': pre-flight ASM discovered " "token_endpoint=%s", @@ -236,6 +263,27 @@ async def _prefetch_oauth_metadata(self) -> None: ) break + def _persist_oauth_metadata_if_changed(self) -> None: + """Persist discovered OAuth metadata for future process restarts. + + Called after the SDK's normal 401-branch auth flow completes so + metadata discovered via the lazy path (not pre-flight) is also + saved. No-op when nothing to persist or metadata hasn't changed. + """ + meta = self.context.oauth_metadata + if meta is None: + return + storage = self.context.storage + from tools.mcp_oauth import HermesTokenStorage + if not isinstance(storage, HermesTokenStorage): + return + existing = storage.load_oauth_metadata() + if ( + existing is None + or str(existing.token_endpoint) != str(meta.token_endpoint) + ): + storage.save_oauth_metadata(meta) + async def async_auth_flow(self, request): # type: ignore[override] # Pre-flow hook: ask the manager to refresh from disk if needed. # Any failure here is non-fatal — we just log and proceed with @@ -271,6 +319,9 @@ async def async_auth_flow(self, request): # type: ignore[override] incoming = yield outgoing outgoing = await inner.asend(incoming) except StopAsyncIteration: + # Persist any metadata the SDK discovered lazily during the + # 401 branch so a subsequent cold-load skips discovery. + self._persist_oauth_metadata_if_changed() return return HermesMCPOAuthProvider diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 9ed8ac75d0f6..c3d88475f531 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -2,9 +2,9 @@ """ MCP (Model Context Protocol) Client Support -Connects to external MCP servers via stdio or HTTP/StreamableHTTP transport, -discovers their tools, and registers them into the hermes-agent tool registry -so the agent can call them like any built-in tool. +Connects to external MCP servers via stdio, HTTP/StreamableHTTP, or SSE +transport, discovers their tools, and registers them into the hermes-agent +tool registry so the agent can call them like any built-in tool. Configuration is read from ~/.hermes/config.yaml under the ``mcp_servers`` key. The ``mcp`` Python package is optional -- if not installed, this module is a @@ -29,7 +29,11 @@ headers: Authorization: "Bearer sk-..." timeout: 180 - analysis: + searxng: + url: "http://localhost:8000/sse" + transport: sse # use SSE transport instead of Streamable HTTP + timeout: 180 + connect_timeout: 10 command: "npx" args: ["-y", "analysis-server"] sampling: # server-initiated LLM requests @@ -44,6 +48,7 @@ Features: - Stdio transport (command + args) and HTTP/StreamableHTTP transport (url) + - SSE transport (transport: sse) for MCP servers using the SSE protocol - Automatic reconnection with exponential backoff (up to 5 retries) - Environment variable filtering for stdio subprocesses (security) - Credential stripping in error messages returned to the LLM @@ -191,6 +196,12 @@ def _write_stderr_log_header(server_name: str) -> None: from mcp.types import LATEST_PROTOCOL_VERSION except ImportError: logger.debug("mcp.types.LATEST_PROTOCOL_VERSION not available -- using fallback protocol version") + # SSE transport client (for MCP servers using SSE transport instead of Streamable HTTP) + try: + from mcp.client.sse import sse_client + except ImportError: + sse_client = None + logger.debug("mcp.client.sse.sse_client not available -- SSE transport disabled") # Sampling types -- separated so older SDK versions don't break MCP support try: from mcp.types import ( @@ -1210,6 +1221,37 @@ async def _run_http(self, config: dict): if _MCP_NOTIFICATION_TYPES and _MCP_MESSAGE_HANDLER_SUPPORTED: sampling_kwargs["message_handler"] = self._make_message_handler() + # SSE transport (for MCP servers that implement the SSE transport protocol + # rather than Streamable HTTP). Configure with ``transport: sse`` in the + # mcp_servers entry in config.yaml. + if config.get("transport") == "sse": + if sse_client is None: + raise ImportError( + f"MCP server '{self.name}' requires SSE transport but " + "mcp.client.sse.sse_client is not available. " + "Upgrade the mcp package to get SSE support." + ) + async with sse_client( + url=url, + headers=headers or None, + timeout=float(connect_timeout), + sse_read_timeout=float(config.get("timeout", _DEFAULT_TOOL_TIMEOUT)), + ) as (read_stream, write_stream): + async with ClientSession( + read_stream, write_stream, **sampling_kwargs + ) as session: + await session.initialize() + self.session = session + await self._discover_tools() + self._ready.set() + reason = await self._wait_for_lifecycle_event() + if reason == "reconnect": + logger.info( + "MCP server '%s': reconnect requested — " + "tearing down SSE session", self.name, + ) + return + if _MCP_NEW_HTTP: # New API (mcp >= 1.24.0): build an explicit httpx.AsyncClient # matching the SDK's own create_mcp_http_client defaults. @@ -2965,7 +3007,7 @@ def get_mcp_status() -> List[dict]: active_servers = dict(_servers) for name, cfg in configured.items(): - transport = "http" if "url" in cfg else "stdio" + transport = cfg.get("transport", "http") if "url" in cfg else "stdio" server = active_servers.get(name) if server and server.session is not None: entry = { diff --git a/tools/memory_tool.py b/tools/memory_tool.py index 0de12a64f383..8dc9b20ab391 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -560,6 +560,29 @@ def check_memory_requirements() -> bool: }, }, "required": ["action", "target"], + "allOf": [ + { + "if": { + "properties": {"action": {"const": "add"}}, + "required": ["action"], + }, + "then": {"required": ["content"]}, + }, + { + "if": { + "properties": {"action": {"const": "replace"}}, + "required": ["action"], + }, + "then": {"required": ["old_text", "content"]}, + }, + { + "if": { + "properties": {"action": {"const": "remove"}}, + "required": ["action"], + }, + "then": {"required": ["old_text"]}, + }, + ], }, } diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 938cb977b6a4..380208d429e9 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -242,6 +242,12 @@ def _handle_send(args): from gateway.platforms.base import BasePlatformAdapter + # Capture [[as_document]] directive before extract_media strips it. + # Image-extension files in this batch will route through send_document + # instead of send_photo so the original bytes survive (e.g. info-graph + # JPGs where Telegram's sendPhoto recompresses to 1280px). + force_document_attachments = "[[as_document]]" in message + media_files, cleaned_message = BasePlatformAdapter.extract_media(message) mirror_text = cleaned_message.strip() or _describe_media_for_mirror(media_files) @@ -277,6 +283,7 @@ def _handle_send(args): cleaned_message, thread_id=thread_id, media_files=media_files, + force_document=force_document_attachments, ) ) if used_home_channel and isinstance(result, dict) and result.get("success"): @@ -437,7 +444,7 @@ async def _send_via_adapter(platform, pconfig, chat_id, chunk): return {"error": f"No live adapter for platform '{platform.value}'. Is the gateway running with this platform connected?"} -async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None): +async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None, force_document=False): """Route a message to the appropriate platform sender. Long messages are automatically chunked to fit within platform limits @@ -514,6 +521,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=media_files if is_last else [], thread_id=thread_id, disable_link_previews=disable_link_previews, + force_document=force_document, ) if isinstance(result, dict) and result.get("error"): return result @@ -667,7 +675,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, return last_result -async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False): +async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False, force_document=False): """Send via Telegram Bot API (one-shot, no polling needed). Applies markdown→MarkdownV2 formatting (same as the gateway adapter) @@ -750,7 +758,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No ext = os.path.splitext(media_path)[1].lower() try: with open(media_path, "rb") as f: - if ext in _IMAGE_EXTS: + if ext in _IMAGE_EXTS and not force_document: last_msg = await bot.send_photo( chat_id=int_chat_id, photo=f, **thread_kwargs ) diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index ed4cb3f1038f..d253cd2a7cd6 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -283,11 +283,13 @@ 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 + from agent.skill_utils import EXCLUDED_SKILL_DIRS, get_all_skills_dirs for skills_dir in get_all_skills_dirs(): if not skills_dir.exists(): continue for skill_md in skills_dir.rglob("SKILL.md"): + if any(part in EXCLUDED_SKILL_DIRS for part in skill_md.parts): + continue if skill_md.parent.name == name: return {"path": skill_md.parent} return None diff --git a/tools/skill_usage.py b/tools/skill_usage.py index 053f27b224c9..9b94ca9a0531 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -205,6 +205,19 @@ def list_agent_created_skill_names() -> List[str]: return sorted(set(names)) +def list_archived_skill_names() -> List[str]: + """Enumerate skills in ``~/.hermes/skills/.archive/``. + + Archive layout is flat (``.archive//``) as set by ``archive_skill``, + so the directory name is the skill name. Used by ``hermes curator + list-archived`` to help users pass a name to ``hermes curator restore``. + """ + archive_root = _archive_dir() + if not archive_root.exists(): + return [] + return sorted({p.name for p in archive_root.iterdir() if p.is_dir()}) + + def _read_skill_name(skill_md: Path, fallback: str) -> str: """Parse the `name:` field from a SKILL.md YAML frontmatter.""" try: diff --git a/tools/url_safety.py b/tools/url_safety.py index 860d4d9dfa40..723b1b0c7c36 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -147,6 +147,102 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: return False +def is_always_blocked_url(url: str) -> bool: + """Return True when the URL targets an always-blocked endpoint. + + This is the security floor — cloud metadata IPs / hostnames + (169.254.169.254, metadata.google.internal, ECS task metadata, etc.) + that have no legitimate agent use regardless of backend, routing, or + the ``allow_private_urls`` toggle. Used by callers that bypass the + full ``is_safe_url`` check for their own reasons (e.g. hybrid cloud + browser routing to a local Chromium sidecar for private URLs) and + still need to enforce the non-negotiable floor before letting the + request proceed. + + Returns True (= blocked) on: + - Hostnames in ``_BLOCKED_HOSTNAMES`` + - IPs / networks in ``_ALWAYS_BLOCKED_IPS`` / ``_ALWAYS_BLOCKED_NETWORKS`` + - URLs whose hostname resolves to any of the above + + Returns False (= not in the always-blocked floor) on: + - Benign public / private / loopback URLs (whether or not they'd + be blocked by the ordinary SSRF check) + - DNS-resolution failures for non-sentinel hostnames (these are + someone else's problem — the caller's ordinary fail-closed path + will catch them if applicable) + - Parse errors (caller decides fail-open vs fail-closed) + + Intentionally narrower than ``is_safe_url``: only blocks the sentinel + set, not ordinary private addresses. Callers that want the full + SSRF check should still use ``is_safe_url``. + """ + try: + parsed = urlparse(url) + hostname = (parsed.hostname or "").strip().lower().rstrip(".") + if not hostname: + return False + + # Blocked-hostname check fires regardless of DNS resolution + if hostname in _BLOCKED_HOSTNAMES: + logger.warning( + "Blocked request to internal hostname (always-blocked floor): %s", + hostname, + ) + return True + + # Literal IP → check directly against the always-blocked set + try: + ip = ipaddress.ip_address(hostname) + except ValueError: + ip = None + + if ip is not None: + if ip in _ALWAYS_BLOCKED_IPS or any( + ip in net for net in _ALWAYS_BLOCKED_NETWORKS + ): + logger.warning( + "Blocked request to cloud metadata address " + "(always-blocked floor): %s", + hostname, + ) + return True + return False + + # Hostname → resolve and check every answer. DNS failure is NOT + # always-blocked (caller's ordinary path handles that). + try: + addr_info = socket.getaddrinfo( + hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM + ) + except socket.gaierror: + return False + + for _family, _, _, _, sockaddr in addr_info: + ip_str = sockaddr[0] + try: + resolved = ipaddress.ip_address(ip_str) + except ValueError: + continue + if resolved in _ALWAYS_BLOCKED_IPS or any( + resolved in net for net in _ALWAYS_BLOCKED_NETWORKS + ): + logger.warning( + "Blocked request to cloud metadata address " + "(always-blocked floor): %s -> %s", + hostname, + ip_str, + ) + return True + + return False + + except Exception as exc: + # Parse failures or unexpected errors — don't claim the URL is + # always-blocked. Caller decides what to do with a malformed URL. + logger.debug("is_always_blocked_url error for %s: %s", url, exc) + return False + + def _allows_private_ip_resolution(hostname: str, scheme: str) -> bool: """Return True when a trusted HTTPS hostname may bypass IP-class blocking.""" return scheme == "https" and hostname in _TRUSTED_PRIVATE_IP_HOSTS diff --git a/tools/web_providers/ARCHITECTURE.md b/tools/web_providers/ARCHITECTURE.md new file mode 100644 index 000000000000..f4a7b335e87e --- /dev/null +++ b/tools/web_providers/ARCHITECTURE.md @@ -0,0 +1,73 @@ +# Web Tools Provider Architecture + +## Overview + +Web tools (`web_search`, `web_extract`) use a **per-capability backend selection** system that allows different providers for search and extract independently. + +## Config Keys + +```yaml +web: + backend: "firecrawl" # Shared fallback — applies to both if specific keys not set + search_backend: "" # Per-capability override for web_search + extract_backend: "" # Per-capability override for web_extract +``` + +**Selection priority (per capability):** +1. `web.search_backend` / `web.extract_backend` (explicit per-capability) +2. `web.backend` (shared fallback) +3. Auto-detect from environment variables + +When per-capability keys are empty (default), behavior is identical to the legacy single-backend selection. + +## Architecture + +``` +web_search_tool() + └─ _get_search_backend() + ├─ web.search_backend (if set + available) + └─ _get_backend() fallback + +web_extract_tool() + └─ _get_extract_backend() + ├─ web.extract_backend (if set + available) + └─ _get_backend() fallback +``` + +## Provider ABCs + +New providers implement these interfaces in `tools/web_providers/`: + +```python +from tools.web_providers.base import WebSearchProvider, WebExtractProvider + +class MySearchProvider(WebSearchProvider): + def provider_name(self) -> str: ... + def is_configured(self) -> bool: ... + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: ... + +class MyExtractProvider(WebExtractProvider): + def provider_name(self) -> str: ... + def is_configured(self) -> bool: ... + def extract(self, urls: List[str], **kwargs) -> Dict[str, Any]: ... +``` + +## Adding a New Search Provider + +1. Create `tools/web_providers/your_provider.py` implementing `WebSearchProvider` +2. Add availability check to `_is_backend_available()` in `web_tools.py` +3. Add dispatch branch in `web_search_tool()` +4. Add provider to `hermes tools` picker in `tools_config.py` +5. Add env var to `OPTIONAL_ENV_VARS` in `config.py` (if needed) +6. Write tests in `tests/tools/` + +Search-only providers (like SearXNG) don't need to implement `WebExtractProvider`. +Extract-only providers don't need to implement `WebSearchProvider`. + +## hermes tools UX + +The provider picker uses **progressive disclosure**: +- **Default path** (90% of users): Pick one provider → sets `web.backend` for both. One selection, done. +- **Advanced path**: "Configure separately" option at bottom → two-step sub-picker for search + extract independently. + +See `.hermes/plans/2026-05-03-web-tools-provider-architecture.md` for the full UX flow diagram. diff --git a/tools/web_providers/__init__.py b/tools/web_providers/__init__.py new file mode 100644 index 000000000000..15134175d213 --- /dev/null +++ b/tools/web_providers/__init__.py @@ -0,0 +1,6 @@ +"""Web capability providers — search, extract, crawl. + +Each capability has an ABC in ``base.py`` and vendor implementations in +sibling modules. Provider registries in ``web_tools.py`` map config names +to provider classes. +""" diff --git a/tools/web_providers/base.py b/tools/web_providers/base.py new file mode 100644 index 000000000000..217721891911 --- /dev/null +++ b/tools/web_providers/base.py @@ -0,0 +1,89 @@ +"""Abstract base classes for web capability providers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, List + + +class WebSearchProvider(ABC): + """Interface for web search backends (Firecrawl, Tavily, Exa, etc.). + + Implementations live in sibling modules. The user selects a provider + via ``hermes tools``; the choice is persisted as + ``config["web"]["search_backend"]`` (falling back to + ``config["web"]["backend"]``). + + Search providers return results in a normalized format:: + + { + "success": True, + "data": { + "web": [ + {"title": str, "url": str, "description": str, "position": int}, + ... + ] + } + } + + On failure:: + + {"success": False, "error": str} + """ + + @abstractmethod + def provider_name(self) -> str: + """Short, human-readable name shown in logs and diagnostics.""" + + @abstractmethod + def is_configured(self) -> bool: + """Return True when all required env vars / credentials are present. + + Called at tool-registration time to gate availability. + Must be cheap — no network calls. + """ + + @abstractmethod + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a web search and return normalized results.""" + + +class WebExtractProvider(ABC): + """Interface for web content extraction backends. + + Implementations live in sibling modules. The user selects a provider + via ``hermes tools``; the choice is persisted as + ``config["web"]["extract_backend"]`` (falling back to + ``config["web"]["backend"]``). + + Extract providers return results in a normalized format:: + + { + "success": True, + "data": [ + {"url": str, "title": str, "content": str, + "raw_content": str, "metadata": dict}, + ... + ] + } + + On failure:: + + {"success": False, "error": str} + """ + + @abstractmethod + def provider_name(self) -> str: + """Short, human-readable name shown in logs and diagnostics.""" + + @abstractmethod + def is_configured(self) -> bool: + """Return True when all required env vars / credentials are present. + + Called at tool-registration time to gate availability. + Must be cheap — no network calls. + """ + + @abstractmethod + def extract(self, urls: List[str], **kwargs) -> Dict[str, Any]: + """Extract content from the given URLs and return normalized results.""" diff --git a/tools/web_providers/searxng.py b/tools/web_providers/searxng.py new file mode 100644 index 000000000000..59ddcb8d5123 --- /dev/null +++ b/tools/web_providers/searxng.py @@ -0,0 +1,131 @@ +"""SearXNG web search provider. + +SearXNG is a free, self-hosted, privacy-respecting metasearch engine. +It implements ``WebSearchProvider`` only — there is no extract capability. + +Configuration:: + + # ~/.hermes/config.yaml (SEARXNG_URL is a URL, not a secret — use config.yaml not .env) + SEARXNG_URL: http://localhost:8080 + + # Use SearXNG for search, pair with any extract provider: + web: + search_backend: "searxng" + extract_backend: "firecrawl" + +Public SearXNG instances are listed at https://searx.space/ but self-hosting +is recommended for production use (rate limits and availability vary per +public instance). +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Dict + +from tools.web_providers.base import WebSearchProvider + +logger = logging.getLogger(__name__) + + +class SearXNGSearchProvider(WebSearchProvider): + """Search via a SearXNG instance. + + Requires ``SEARXNG_URL`` to be set (e.g. ``http://localhost:8080``). + No API key needed — SearXNG is open-source and self-hosted. + + Uses the SearXNG JSON API (``/search?format=json``). Results are + sorted by SearXNG's own score and truncated to *limit*. + """ + + def provider_name(self) -> str: + return "searxng" + + def is_configured(self) -> bool: + """Return True when ``SEARXNG_URL`` is set to a non-empty value.""" + return bool(os.getenv("SEARXNG_URL", "").strip()) + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a search against the configured SearXNG instance. + + Returns normalized results:: + + { + "success": True, + "data": { + "web": [ + { + "title": str, + "url": str, + "description": str, + "position": int, + }, + ... + ] + } + } + + On failure returns ``{"success": False, "error": str}``. + """ + import httpx + + base_url = os.getenv("SEARXNG_URL", "").strip().rstrip("/") + if not base_url: + return {"success": False, "error": "SEARXNG_URL is not set"} + + params: Dict[str, Any] = { + "q": query, + "format": "json", + "pageno": 1, + } + + try: + resp = httpx.get( + f"{base_url}/search", + params=params, + timeout=15, + headers={"Accept": "application/json"}, + ) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + logger.warning("SearXNG HTTP error: %s", exc) + return {"success": False, "error": f"SearXNG returned HTTP {exc.response.status_code}"} + except httpx.RequestError as exc: + logger.warning("SearXNG request error: %s", exc) + return {"success": False, "error": f"Could not reach SearXNG at {base_url}: {exc}"} + + try: + data = resp.json() + except Exception as exc: # noqa: BLE001 + logger.warning("SearXNG response parse error: %s", exc) + return {"success": False, "error": "Could not parse SearXNG response as JSON"} + + raw_results = data.get("results", []) + + # SearXNG may return a score field; sort descending and cap to limit. + sorted_results = sorted( + raw_results, + key=lambda r: float(r.get("score", 0)), + reverse=True, + )[:limit] + + web_results = [ + { + "title": str(r.get("title", "")), + "url": str(r.get("url", "")), + "description": str(r.get("content", "")), + "position": i + 1, + } + for i, r in enumerate(sorted_results) + ] + + logger.info( + "SearXNG search '%s': %d results (from %d raw, limit %d)", + query, + len(web_results), + len(raw_results), + limit, + ) + + return {"success": True, "data": {"web": web_results}} diff --git a/tools/web_tools.py b/tools/web_tools.py index e24ace2f8727..e3268ac381ac 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -119,14 +119,14 @@ def _load_web_config() -> dict: return {} def _get_backend() -> str: - """Determine which web backend to use. + """Determine which web backend to use (shared fallback). Reads ``web.backend`` from config.yaml (set by ``hermes tools``). Falls back to whichever API key is present for users who configured keys manually without running setup. """ configured = (_load_web_config().get("backend") or "").lower().strip() - if configured in ("parallel", "firecrawl", "tavily", "exa"): + if configured in ("parallel", "firecrawl", "tavily", "exa", "searxng"): return configured # Fallback for manual / legacy config — pick the highest-priority @@ -137,6 +137,7 @@ def _get_backend() -> str: ("parallel", _has_env("PARALLEL_API_KEY")), ("tavily", _has_env("TAVILY_API_KEY")), ("exa", _has_env("EXA_API_KEY")), + ("searxng", _has_env("SEARXNG_URL")), ) for backend, available in backend_candidates: if available: @@ -145,6 +146,44 @@ def _get_backend() -> str: return "firecrawl" # default (backward compat) +def _get_search_backend() -> str: + """Determine which backend to use for web_search specifically. + + Selection priority: + 1. ``web.search_backend`` (per-capability override) + 2. ``web.backend`` (shared fallback — existing behavior) + 3. Auto-detect from env vars + + This enables using different providers for search vs extract + (e.g. SearXNG for search + Firecrawl for extract). + """ + return _get_capability_backend("search") + + +def _get_extract_backend() -> str: + """Determine which backend to use for web_extract specifically. + + Selection priority: + 1. ``web.extract_backend`` (per-capability override) + 2. ``web.backend`` (shared fallback — existing behavior) + 3. Auto-detect from env vars + """ + return _get_capability_backend("extract") + + +def _get_capability_backend(capability: str) -> str: + """Shared helper for per-capability backend selection. + + Reads ``web.{capability}_backend`` from config; if set and available, + uses it. Otherwise falls through to the shared ``_get_backend()``. + """ + cfg = _load_web_config() + specific = (cfg.get(f"{capability}_backend") or "").lower().strip() + if specific and _is_backend_available(specific): + return specific + return _get_backend() + + def _is_backend_available(backend: str) -> bool: """Return True when the selected backend is currently usable.""" if backend == "exa": @@ -155,6 +194,8 @@ def _is_backend_available(backend: str) -> bool: return check_firecrawl_api_key() if backend == "tavily": return _has_env("TAVILY_API_KEY") + if backend == "searxng": + return _has_env("SEARXNG_URL") return False # ─── Firecrawl Client ──────────────────────────────────────────────────────── @@ -1129,8 +1170,8 @@ def web_search_tool(query: str, limit: int = 5) -> str: if is_interrupted(): return tool_error("Interrupted", success=False) - # Dispatch to the configured backend - backend = _get_backend() + # Dispatch to the configured search backend + backend = _get_search_backend() if backend == "parallel": response_data = _parallel_search(query, limit) debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) @@ -1149,6 +1190,16 @@ def web_search_tool(query: str, limit: int = 5) -> str: _debug.save() return result_json + if backend == "searxng": + from tools.web_providers.searxng import SearXNGSearchProvider + response_data = SearXNGSearchProvider().search(query, limit) + debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", [])) + result_json = json.dumps(response_data, indent=2, ensure_ascii=False) + debug_call_data["final_response_size"] = len(result_json) + _debug.log_call("web_search_tool", debug_call_data) + _debug.save() + return result_json + if backend == "tavily": logger.info("Tavily search: '%s' (limit: %d)", query, limit) raw = _tavily_request("search", { @@ -1286,7 +1337,7 @@ async def web_extract_tool( if not safe_urls: results = [] else: - backend = _get_backend() + backend = _get_extract_backend() if backend == "parallel": results = await _parallel_extract(safe_urls) @@ -1299,6 +1350,13 @@ async def web_extract_tool( "include_images": False, }) results = _normalize_tavily_documents(raw, fallback_url=safe_urls[0] if safe_urls else "") + elif backend == "searxng": + # SearXNG is search-only — it cannot extract URL content + return json.dumps({ + "success": False, + "error": "SearXNG is a search-only backend and cannot extract URL content. " + "Set web.extract_backend to firecrawl, tavily, exa, or parallel.", + }, ensure_ascii=False) else: # ── Firecrawl extraction ── # Determine requested formats for Firecrawl v2 @@ -1674,6 +1732,14 @@ async def _process_tavily_crawl(result): _debug.save() return cleaned_result + # SearXNG is search-only — it cannot crawl + if backend == "searxng": + return json.dumps({ + "error": "SearXNG is a search-only backend and cannot crawl URLs. " + "Set FIRECRAWL_API_KEY for crawling, or use web_search instead.", + "success": False, + }, ensure_ascii=False) + # web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API if not check_firecrawl_api_key(): return json.dumps({ @@ -1969,9 +2035,9 @@ def check_firecrawl_api_key() -> bool: def check_web_api_key() -> bool: """Check whether the configured web backend is available.""" configured = _load_web_config().get("backend", "").lower().strip() - if configured in ("exa", "parallel", "firecrawl", "tavily"): + if configured in ("exa", "parallel", "firecrawl", "tavily", "searxng"): return _is_backend_available(configured) - return any(_is_backend_available(backend) for backend in ("exa", "parallel", "firecrawl", "tavily")) + return any(_is_backend_available(backend) for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng")) def check_auxiliary_model() -> bool: @@ -2006,6 +2072,8 @@ def check_auxiliary_model() -> bool: print(" Using Parallel API (https://parallel.ai)") elif backend == "tavily": print(" Using Tavily API (https://tavily.com)") + elif backend == "searxng": + print(f" Using SearXNG (search only): {os.getenv('SEARXNG_URL', '').strip()}") else: if firecrawl_url_available: print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index b618c5bd56df..ca378bb72848 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3137,6 +3137,18 @@ def _stream(delta): if result.get("interrupted") else "error" if result.get("error") else "complete" ) + # When the backend produced no visible response AND reported a + # real error (e.g. invalid model slug → provider 4xx), surface + # that error as the visible text instead of shipping an empty + # turn to Ink. Mirrors classic CLI behavior at cli.py where + # (failed|partial) + no final_response → "Error: ". + # Leaves the None-with-no-error path untouched: an empty + # successful turn still renders as empty, and the existing + # "(empty)" sentinel handling stays in its own lane. + if (not raw) and result.get("error") and ( + result.get("failed") or result.get("partial") + ): + raw = f"Error: {result.get('error')}" lr = result.get("last_reasoning") if isinstance(lr, str) and lr.strip(): last_reasoning = lr.strip() @@ -5619,14 +5631,13 @@ def _(rid, params: dict) -> dict: @method("voice.record") def _(rid, params: dict) -> dict: - """VAD-driven continuous record loop, CLI-parity. - - ``start`` turns on a VAD loop that emits ``voice.transcript`` events - for each detected utterance and auto-restarts for the next turn. - ``stop`` halts the loop (manual stop; matches cli.py's Ctrl+B-while- - recording branch clearing ``_voice_continuous``). Three consecutive - silent cycles stop the loop automatically and emit a - ``voice.transcript`` with ``no_speech_limit=True``. + """VAD-bounded push-to-talk capture, CLI-parity. + + ``start`` begins one VAD-bounded capture and emits ``voice.transcript`` + after silence stops the recorder. ``stop`` forces transcription of the + active buffer, matching classic CLI push-to-talk. The voice wrapper retains + no-speech counts across single-shot starts, so three consecutive silent + captures emit ``voice.transcript`` with ``no_speech_limit=True``. """ action = params.get("action", "start") @@ -5665,7 +5676,7 @@ def _(rid, params: dict) -> dict: if isinstance(duration, (int, float)) and not isinstance(duration, bool) else 3.0 ) - start_continuous( + started = start_continuous( on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}), on_status=lambda s: _voice_emit("voice.status", {"state": s}), on_silent_limit=lambda: _voice_emit( @@ -5673,13 +5684,19 @@ def _(rid, params: dict) -> dict: ), silence_threshold=safe_threshold, silence_duration=safe_duration, + auto_restart=False, ) + if started is False: + return _ok(rid, {"status": "busy"}) return _ok(rid, {"status": "recording"}) # action == "stop" + with _voice_sid_lock: + _voice_event_sid = params.get("session_id") or _voice_event_sid + from hermes_cli.voice import stop_continuous - stop_continuous() + stop_continuous(force_transcribe=True) return _ok(rid, {"status": "stopped"}) except ImportError: return _err( diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 3cfc419942ea..64aa83274a98 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -279,64 +279,6 @@ describe('createSlashHandler', () => { expect(ctx.voice.setVoiceRecordKey).not.toHaveBeenCalled() }) - // Regressions from Copilot review on #19835: /voice output + frontend - // binding state must both track the gateway's fresh ``record_key`` on - // every response, or a config edit shows the new shortcut in text - // while push-to-talk still fires the old one until the next mtime - // poll (~5s). - it('/voice status renders the gateway record_key and pushes it into frontend state', async () => { - const rpc = vi.fn(() => Promise.resolve({ enabled: true, record_key: 'ctrl+space', tts: false })) - const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) - - expect(createSlashHandler(ctx)('/voice status')).toBe(true) - await vi.waitFor(() => { - expect(ctx.transcript.sys).toHaveBeenCalledWith(' Record key: Ctrl+Space') - }) - expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith( - expect.objectContaining({ ch: 'space', mod: 'ctrl', named: 'space' }) - ) - }) - - it('/voice on renders the configured binding for the start/stop hint', async () => { - const rpc = vi.fn(() => Promise.resolve({ enabled: true, record_key: 'alt+r', tts: false })) - const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) - - expect(createSlashHandler(ctx)('/voice on')).toBe(true) - await vi.waitFor(() => { - expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice mode enabled') - expect(ctx.transcript.sys).toHaveBeenCalledWith(' Alt+R to start/stop recording') - }) - expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith( - expect.objectContaining({ ch: 'r', mod: 'alt' }) - ) - }) - - it('/voice falls back to Ctrl+B when the gateway response omits record_key', async () => { - const rpc = vi.fn(() => Promise.resolve({ enabled: false, tts: false })) - const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) - - expect(createSlashHandler(ctx)('/voice status')).toBe(true) - await vi.waitFor(() => { - expect(ctx.transcript.sys).toHaveBeenCalledWith(' Record key: Ctrl+B') - }) - }) - - // Round-2 Copilot review on #19835: a response missing ``record_key`` - // (e.g. the old tts branch, or any future branch that forgets to - // include it) MUST NOT clobber the user's cached binding back to - // Ctrl+B. The label still renders the default for display; the - // frontend state keeps whatever was last authoritatively set. - it('/voice tts without record_key does not clobber cached frontend binding', async () => { - const rpc = vi.fn(() => Promise.resolve({ enabled: true, tts: true })) - const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } }) - - expect(createSlashHandler(ctx)('/voice tts')).toBe(true) - await vi.waitFor(() => { - expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice TTS enabled.') - }) - expect(ctx.voice.setVoiceRecordKey).not.toHaveBeenCalled() - }) - it('cycles details mode and persists it', async () => { const ctx = buildCtx() diff --git a/ui-tui/src/__tests__/precisionWheel.test.ts b/ui-tui/src/__tests__/precisionWheel.test.ts new file mode 100644 index 000000000000..13567521799c --- /dev/null +++ b/ui-tui/src/__tests__/precisionWheel.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js' + +describe('precisionWheel', () => { + it('passes the first modifier-held wheel event', () => { + const s = initPrecisionWheel() + + expect(computePrecisionWheelStep(s, 1, true, 1000)).toEqual({ active: true, entered: true, rows: 1 }) + }) + + it('coalesces same-frame events without throttling line-by-line scroll', () => { + const s = initPrecisionWheel() + + computePrecisionWheelStep(s, 1, true, 1000) + + expect(computePrecisionWheelStep(s, 1, true, 1008).rows).toBe(0) + expect(computePrecisionWheelStep(s, 1, true, 1016).rows).toBe(1) + }) + + it('keeps queued momentum in precision mode briefly after modifier release', () => { + const s = initPrecisionWheel() + + computePrecisionWheelStep(s, 1, true, 1000) + + expect(computePrecisionWheelStep(s, 1, false, 1050)).toMatchObject({ active: true, rows: 1 }) + }) + + it('leaves precision mode once modifier-free momentum goes idle', () => { + const s = initPrecisionWheel() + + computePrecisionWheelStep(s, 1, true, 1000) + + expect(computePrecisionWheelStep(s, 1, false, 1100)).toEqual({ active: false, entered: false, rows: 0 }) + }) + + it('does not coalesce immediate reversals', () => { + const s = initPrecisionWheel() + + computePrecisionWheelStep(s, 1, true, 1000) + + expect(computePrecisionWheelStep(s, -1, true, 1008).rows).toBe(1) + }) +}) diff --git a/ui-tui/src/__tests__/scroll.test.ts b/ui-tui/src/__tests__/scroll.test.ts index 652cca0973ac..b9bbdb5feade 100644 --- a/ui-tui/src/__tests__/scroll.test.ts +++ b/ui-tui/src/__tests__/scroll.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it, vi } from 'vitest' import { scrollWithSelectionBy } from '../app/scroll.js' function makeScroll(overrides: Partial> = {}) { + const getScrollHeight = (overrides.getScrollHeight as (() => number) | undefined) ?? vi.fn(() => 100) + return { + getFreshScrollHeight: vi.fn(() => getScrollHeight()), getPendingDelta: vi.fn(() => 0), - getScrollHeight: vi.fn(() => 100), + getScrollHeight, getScrollTop: vi.fn(() => 10), getViewportHeight: vi.fn(() => 20), getViewportTop: vi.fn(() => 0), @@ -34,6 +37,47 @@ describe('scrollWithSelectionBy', () => { expect(s.scrollBy).toHaveBeenCalledWith(1) }) + it('uses fresh scroll height when cached height would swallow a down-scroll at a fake bottom', () => { + const s = makeScroll({ + getFreshScrollHeight: vi.fn(() => 34), + getScrollHeight: vi.fn(() => 30), + getScrollTop: vi.fn(() => 10), + getViewportHeight: vi.fn(() => 20) + }) + + const selection = { + captureScrolledRows: vi.fn(), + getState: vi.fn(() => null), + shiftAnchor: vi.fn(), + shiftSelection: vi.fn() + } + + scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection }) + + expect(s.scrollBy).toHaveBeenCalledWith(4) + }) + + it('uses fresh height when pending down-scroll reaches the cached fake bottom', () => { + const s = makeScroll({ + getFreshScrollHeight: vi.fn(() => 38), + getPendingDelta: vi.fn(() => 2), + getScrollHeight: vi.fn(() => 32), + getScrollTop: vi.fn(() => 10), + getViewportHeight: vi.fn(() => 20) + }) + + const selection = { + captureScrolledRows: vi.fn(), + getState: vi.fn(() => null), + shiftAnchor: vi.fn(), + shiftSelection: vi.fn() + } + + scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection }) + + expect(s.scrollBy).toHaveBeenCalledWith(6) + }) + it('does nothing at the edge instead of queueing dead pending deltas', () => { const s = makeScroll({ getScrollHeight: vi.fn(() => 30), diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 30a047df6618..d45576698dd5 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -209,6 +209,34 @@ describe('fromSkin', () => { expect(theme.color.completionCurrentBg).toBe('#bfbfbf') }) + it('uses active completion color as the selection highlight fallback', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + + const theme = fromSkin({ completion_menu_current_bg: '#123456' }, {}) + + expect(theme.color.selectionBg).toBe('#123456') + }) + + it('maps completion meta background colors from skins', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + + const theme = fromSkin({ + completion_menu_meta_bg: '#111111', + completion_menu_meta_current_bg: '#222222' + }, {}) + + expect(theme.color.completionMetaBg).toBe('#111111') + expect(theme.color.completionMetaCurrentBg).toBe('#222222') + }) + + it('lets selection_bg override completion highlight colors', async () => { + const { fromSkin } = await importThemeWithCleanEnv() + + const theme = fromSkin({ completion_menu_current_bg: '#123456', selection_bg: '#654321' }, {}) + + expect(theme.color.selectionBg).toBe('#654321') + }) + it('overrides branding', async () => { const { fromSkin } = await importThemeWithCleanEnv() const { brand } = fromSkin({}, { agent_name: 'TestBot', prompt_symbol: '$' }) diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts new file mode 100644 index 000000000000..066292abfa5f --- /dev/null +++ b/ui-tui/src/__tests__/useInputHandlers.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' + +import { applyVoiceRecordResponse } from '../app/useInputHandlers.js' + +describe('applyVoiceRecordResponse', () => { + it('reverts optimistic REC state when the gateway reports voice busy', () => { + const setProcessing = vi.fn() + const setRecording = vi.fn() + const sys = vi.fn() + + applyVoiceRecordResponse({ status: 'busy' }, true, { setProcessing, setRecording }, sys) + + expect(setRecording).toHaveBeenCalledWith(false) + expect(setProcessing).toHaveBeenCalledWith(true) + expect(sys).toHaveBeenCalledWith('voice: still transcribing; try again shortly') + }) + + it('keeps optimistic REC state for successful recording starts', () => { + const setProcessing = vi.fn() + const setRecording = vi.fn() + + applyVoiceRecordResponse({ status: 'recording' }, true, { setProcessing, setRecording }, vi.fn()) + + expect(setRecording).not.toHaveBeenCalled() + expect(setProcessing).not.toHaveBeenCalled() + }) + + it('reverts optimistic REC state when the gateway returns null', () => { + const setProcessing = vi.fn() + const setRecording = vi.fn() + + applyVoiceRecordResponse(null, true, { setProcessing, setRecording }, vi.fn()) + + expect(setRecording).toHaveBeenCalledWith(false) + expect(setProcessing).toHaveBeenCalledWith(false) + }) +}) diff --git a/ui-tui/src/__tests__/viewportStore.test.ts b/ui-tui/src/__tests__/viewportStore.test.ts index 7889b65cdea1..2d37127e546a 100644 --- a/ui-tui/src/__tests__/viewportStore.test.ts +++ b/ui-tui/src/__tests__/viewportStore.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { getViewportSnapshot, viewportSnapshotKey } from '../lib/viewportStore.js' +import { getScrollbarSnapshot, getViewportSnapshot, scrollbarSnapshotKey, viewportSnapshotKey } from '../lib/viewportStore.js' describe('viewportStore', () => { it('normalizes absent scroll handles', () => { @@ -51,4 +51,35 @@ describe('viewportStore', () => { expect(snap.atBottom).toBe(true) expect(snap.scrollHeight).toBe(20) }) + + it('keeps scrollbar position tied to committed scrollTop, not pending target', () => { + const handle = { + getPendingDelta: () => 24, + getScrollHeight: () => 100, + getScrollTop: () => 10, + getViewportHeight: () => 20, + isSticky: () => false + } + + const viewport = getViewportSnapshot(handle as any) + const scrollbar = getScrollbarSnapshot(handle as any) + + expect(viewport.top).toBe(34) + expect(scrollbar).toEqual({ + scrollHeight: 100, + top: 10, + viewportHeight: 20 + }) + expect(scrollbarSnapshotKey(scrollbar)).toBe('10:20:100') + }) + + it('clamps scrollbar position to committed scroll bounds', () => { + const handle = { + getScrollHeight: () => 30, + getScrollTop: () => 50, + getViewportHeight: () => 20 + } + + expect(getScrollbarSnapshot(handle as any).top).toBe(10) + }) }) diff --git a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts new file mode 100644 index 000000000000..5a3e8cd0976f --- /dev/null +++ b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts @@ -0,0 +1,155 @@ +import { PassThrough } from 'stream' + +import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink' +import React, { useLayoutEffect, useRef } from 'react' +import { describe, expect, it } from 'vitest' + +import { useVirtualHistory } from '../hooks/useVirtualHistory.js' + +interface Item { + height: number + key: string +} + +interface Exposed { + scroll: ScrollBoxHandle | null + virtualHistory: ReturnType +} + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +const makeStreams = () => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + + Object.assign(stdout, { columns: 80, isTTY: false, rows: 20 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', () => {}) + + return { stderr, stdin, stdout } +} + +const mountedSpan = (items: readonly Item[], virtualHistory: ReturnType) => { + let height = 0 + + for (let index = virtualHistory.start; index < virtualHistory.end; index++) { + height += items[index]?.height ?? 0 + } + + return { bottom: virtualHistory.topSpacer + height, top: virtualHistory.topSpacer } +} + +const viewportIsMounted = (items: readonly Item[], virtualHistory: ReturnType, scroll: ScrollBoxHandle) => { + const span = mountedSpan(items, virtualHistory) + const top = scroll.getScrollTop() + const bottom = top + scroll.getViewportHeight() + + return top >= span.top && bottom <= span.bottom +} + +function Harness({ expose, items }: { expose: React.MutableRefObject; items: readonly Item[] }) { + const scrollRef = useRef(null) + + const virtualHistory = useVirtualHistory(scrollRef, items, 80, { + coldStartCount: 16, + estimateHeight: index => items[index]?.height ?? 1, + maxMounted: 16, + overscan: 2 + }) + + useLayoutEffect(() => { + expose.current = { scroll: scrollRef.current, virtualHistory } + }) + + return React.createElement( + ScrollBox, + { flexDirection: 'column', height: 10, ref: scrollRef, stickyScroll: true }, + React.createElement( + Box, + { flexDirection: 'column', width: '100%' }, + virtualHistory.topSpacer > 0 ? React.createElement(Box, { height: virtualHistory.topSpacer }) : null, + ...items + .slice(virtualHistory.start, virtualHistory.end) + .map(item => + React.createElement( + Box, + { height: item.height, key: item.key, ref: virtualHistory.measureRef(item.key) }, + React.createElement(Text, null, item.key) + ) + ), + virtualHistory.bottomSpacer > 0 ? React.createElement(Box, { height: virtualHistory.bottomSpacer }) : null + ) + ) +} + +describe('useVirtualHistory offset cache reuse', () => { + it('recomputes offsets after a mounted row height changes', async () => { + const tall = [ + { height: 6, key: 'a' }, + { height: 6, key: 'b' }, + { height: 6, key: 'c' } + ] + + const short = tall.map(item => ({ ...item, height: 2 })) + const expose = { current: null as Exposed | null } + const streams = makeStreams() + + const instance = renderSync(React.createElement(Harness, { expose, items: tall }), { + patchConsole: false, + stderr: streams.stderr as NodeJS.WriteStream, + stdin: streams.stdin as NodeJS.ReadStream, + stdout: streams.stdout as NodeJS.WriteStream + }) + + try { + await delay(20) + expect(expose.current!.virtualHistory.offsets[tall.length]).toBe(18) + + instance.rerender(React.createElement(Harness, { expose, items: short })) + await delay(40) + + expect(expose.current!.virtualHistory.offsets[short.length]).toBe(6) + expect(expose.current!.virtualHistory.bottomSpacer).toBe(0) + } finally { + instance.unmount() + instance.cleanup() + } + }) + + it('ignores stale reused offset-array entries after the item count shrinks', async () => { + const beforeShrink = Array.from({ length: 1400 }, (_, index) => ({ height: 1, key: `old${index}` })) + const afterShrink = Array.from({ length: 800 }, (_, index) => ({ height: 7, key: `new${index}` })) + const expose = { current: null as Exposed | null } + const streams = makeStreams() + + const instance = renderSync(React.createElement(Harness, { expose, items: beforeShrink }), { + patchConsole: false, + stderr: streams.stderr as NodeJS.WriteStream, + stdin: streams.stdin as NodeJS.ReadStream, + stdout: streams.stdout as NodeJS.WriteStream + }) + + try { + await delay(20) + instance.rerender(React.createElement(Harness, { expose, items: afterShrink })) + await delay(20) + + const scroll = expose.current!.scroll! + const transcriptHeight = expose.current!.virtualHistory.offsets[afterShrink.length] ?? 0 + + expect(transcriptHeight).toBe(5600) + expect(scroll.getScrollTop()).toBe(transcriptHeight - scroll.getViewportHeight()) + + scroll.scrollBy(-1) + await delay(80) + + expect(scroll.getPendingDelta()).toBe(0) + expect(viewportIsMounted(afterShrink, expose.current!.virtualHistory, scroll)).toBe(true) + } finally { + instance.unmount() + instance.cleanup() + } + }) +}) diff --git a/ui-tui/src/app/scroll.ts b/ui-tui/src/app/scroll.ts index 0d736d2c87b5..e3a53734a389 100644 --- a/ui-tui/src/app/scroll.ts +++ b/ui-tui/src/app/scroll.ts @@ -13,6 +13,23 @@ export interface ScrollWithSelectionOptions { readonly selection: SelectionApi } +function scrollBoundsForDelta(s: ScrollBoxHandle, cur: number, delta: number) { + const viewport = Math.max(0, s.getViewportHeight()) + const cachedHeight = Math.max(viewport, s.getScrollHeight()) + let max = Math.max(0, cachedHeight - viewport) + + // getScrollHeight() is render-time cached. After the streaming tail is + // committed into virtual history, the Yoga height can be fresher than the + // cached value; if we clamp only against the cached fake bottom, wheel-down + // becomes a no-op and no render is scheduled to reveal the real tail. + if (delta > 0 && cur + delta >= max - 1) { + const freshHeight = Math.max(viewport, s.getFreshScrollHeight()) + max = Math.max(0, freshHeight - viewport) + } + + return { max, viewport } +} + export function scrollWithSelectionBy(delta: number, { scrollRef, selection }: ScrollWithSelectionOptions): void { const s = scrollRef.current @@ -21,8 +38,7 @@ export function scrollWithSelectionBy(delta: number, { scrollRef, selection }: S } const cur = s.getScrollTop() + s.getPendingDelta() - const viewport = Math.max(0, s.getViewportHeight()) - const max = Math.max(0, s.getScrollHeight() - viewport) + const { max, viewport } = scrollBoundsForDelta(s, cur, delta) const actual = Math.max(0, Math.min(max, cur + delta)) - cur if (actual === 0) { diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 20e9b087a4b2..ce25af70edde 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -11,6 +11,7 @@ import type { VoiceRecordResponse } from '../gatewayTypes.js' import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js' +import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js' import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' import { getInputSelection } from './inputSelectionStore.js' @@ -21,8 +22,26 @@ import { patchTurnState } from './turnStore.js' import { getUiState } from './uiStore.js' const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target -const PRECISION_WHEEL_MIN_GAP_MS = 80 -const PRECISION_WHEEL_STICKY_MS = 80 + +export function applyVoiceRecordResponse( + response: null | VoiceRecordResponse, + starting: boolean, + voice: Pick, + sys: (text: string) => void +) { + if (!starting || response?.status === 'recording') { + return + } + + voice.setRecording(false) + + if (response?.status === 'busy') { + voice.setProcessing(true) + sys('voice: still transcribing; try again shortly') + } else { + voice.setProcessing(false) + } +} export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const { actions, composer, gateway, terminal, voice, wheelStep } = ctx @@ -38,9 +57,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { // rows = wheelStep × accelMult. State mutates in place across renders. const wheelAccelRef = useRef(initWheelAccelForHost()) - const precisionWheelRef = useRef<{ active: boolean; dir: 0 | -1 | 1; lastEventAtMs: number; lastScrollAtMs: number }>( - { active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 } - ) + const precisionWheelRef = useRef(initPrecisionWheel()) useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), []) @@ -160,11 +177,12 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } } - // CLI parity: Ctrl+B toggles the VAD-driven continuous recording loop + // CLI parity: Ctrl+B toggles a VAD-bounded push-to-talk capture // (NOT the voice-mode umbrella bit). The mode is enabled via /voice on; // Ctrl+B while the mode is off sys-nudges the user. While the mode is - // on, the first press starts a continuous loop (gateway → start_continuous, - // VAD auto-stop → transcribe → auto-restart), a subsequent press stops it. + // on, the first press starts a single VAD-bounded capture + // (gateway -> start_continuous(auto_restart=false), VAD auto-stop -> + // transcribe -> idle), a subsequent press stops and transcribes it. // The gateway publishes voice.status + voice.transcript events that // createGatewayEventHandler turns into UI badges and composer injection. const voiceRecordToggle = () => { @@ -185,14 +203,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { voice.setProcessing(false) } - gateway.rpc('voice.record', { action }).catch((e: Error) => { - // Revert optimistic UI on failure. - if (starting) { - voice.setRecording(false) - } + gateway + .rpc('voice.record', { action, session_id: getUiState().sid }) + .then(r => applyVoiceRecordResponse(r, starting, voice, actions.sys)) + .catch((e: Error) => { + // Revert optimistic UI on failure. + if (starting) { + voice.setRecording(false) + } - actions.sys(`voice error: ${e.message}`) - }) + actions.sys(`voice error: ${e.message}`) + }) } useInput((ch, key) => { @@ -291,40 +312,26 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (key.wheelUp || key.wheelDown) { const dir: -1 | 1 = key.wheelUp ? -1 : 1 const now = Date.now() - // Modifier-held wheel = precision mode: at most one wheelStep per short - // interval. Smooth mice / trackpads emit many raw wheel events for one - // intended line step, so raw 1:1 still moves too far. + // Modifier-held wheel = precision mode: one row per frame, no accel. + // Smooth mice / trackpads emit tiny same-frame bursts; coalesce those + // without the old 80ms throttle that made opt-scroll feel stepped. // SGR/X10 mouse encoding only carries shift/meta/ctrl bits; Cmd on // macOS is intercepted by the terminal, so we honor Option (meta) on // Mac / Alt (meta) on Win+Linux / Ctrl as a portable fallback. Shift // is reserved for selection extension. const hasModifier = key.meta || key.ctrl - const precision = precisionWheelRef.current - // Keep precision active through the current wheel burst after the - // modifier is released. Otherwise a stream of queued/momentum wheel - // events can hand off mid-burst into the accelerated path and jump. - const precisionSticky = now - precision.lastEventAtMs < PRECISION_WHEEL_STICKY_MS - - if (hasModifier || precisionSticky) { - if (!precision.active) { - precision.active = true - wheelAccelRef.current = initWheelAccelForHost() - } - - precision.lastEventAtMs = now + const precision = computePrecisionWheelStep(precisionWheelRef.current, dir, hasModifier, now) - if (dir === precision.dir && now - precision.lastScrollAtMs < PRECISION_WHEEL_MIN_GAP_MS) { - return + if (precision.active) { + // Entering precision mode must discard any accelerated wheel state; + // otherwise the next normal wheel event inherits stale momentum. + if (precision.entered) { + wheelAccelRef.current = initWheelAccelForHost() } - precision.lastScrollAtMs = now - precision.dir = dir - - return scrollTranscript(dir * wheelStep) + return precision.rows ? scrollTranscript(dir * wheelStep) : undefined } - precision.active = false - // 0 = direction-flip bounce deferred; skip the no-op scroll. const rows = computeWheelStep(wheelAccelRef.current, dir, now) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 29e663a47fea..e5724c99baa2 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,6 +1,6 @@ import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react' +import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react' import unicodeSpinners from 'unicode-animations' import { $delegationState } from '../app/delegationStore.js' @@ -13,7 +13,7 @@ import { fmtDuration } from '../domain/messages.js' import { stickyPromptFromViewport } from '../domain/viewport.js' import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js' import { fmtK } from '../lib/text.js' -import { useViewportSnapshot } from '../lib/viewportStore.js' +import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js' import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' @@ -325,6 +325,14 @@ export function StatusRule({ ) : null} + {typeof usage.compressions === 'number' && usage.compressions > 0 ? ( + + {' │ '} + = 10 ? t.color.error : usage.compressions >= 5 ? t.color.warn : t.color.muted}> + cmp {usage.compressions} + + + ) : null} {voiceLabel ? ( (null) - const { scrollHeight: total, top: pos, viewportHeight: vp } = useViewportSnapshot(scrollRef) + const grabRef = useRef(null) + const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef) if (!vp) { return @@ -405,15 +414,20 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) onMouseDown={(e: { localRow?: number }) => { const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0)) const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2) + + grabRef.current = off setGrab(off) jump(row, off) }} onMouseDrag={(e: { localRow?: number }) => - jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2)) + jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grabRef.current ?? Math.floor(thumb / 2)) } onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} - onMouseUp={() => setGrab(null)} + onMouseUp={() => { + grabRef.current = null + setGrab(null) + }} width={1} > {!scrollable ? ( diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index e4a80ba816d1..c12624a4bf8c 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -182,7 +182,7 @@ export function FloatingOverlays({ return ( - {item.meta ? {item.meta} : null} + {item.meta ? ( + + {' '} + {item.meta} + + ) : null} ) })} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 0dacd790f069..8c5cb18b23d8 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -295,7 +295,7 @@ export interface VoiceToggleResponse { } export interface VoiceRecordResponse { - status?: string + status?: 'busy' | 'recording' | 'stopped' text?: string } diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 19c3692bf129..ef96ae1078cb 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -51,9 +51,9 @@ const SLIDE_STEP = 12 const NOOP = () => {} -const upperBound = (arr: ArrayLike, target: number) => { +const upperBound = (arr: ArrayLike, target: number, length = arr.length) => { let lo = 0 - let hi = arr.length + let hi = length while (lo < hi) { const mid = (lo + hi) >> 1 @@ -130,6 +130,9 @@ export function useVirtualHistory( }) const [hasScrollRef, setHasScrollRef] = useState(false) + // Height cache writes happen in layout effects; bump once so offsets and + // clamp bounds rebuild without waiting for the next scroll/input event. + const [measuredHeightVersion, bumpMeasuredHeightVersion] = useState(0) const metrics = useRef({ sticky: true, top: 0, vp: 0 }) const lastScrollTopRef = useRef(0) @@ -282,8 +285,8 @@ export function useVirtualHistory( // Binary search — offsets is monotone. Linear walk was O(n) at n=10k+, // ~2ms per render during scroll. - start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo) - 1)) - end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi))) + start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo, n + 1) - 1)) + end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi, n + 1))) } } @@ -434,6 +437,7 @@ export function useVirtualHistory( useLayoutEffect(() => { const s = scrollRef.current let dirty = false + let heightDirty = false // Give the renderer the mounted-row coverage for passive scroll clamping. // Clamp MUST use the EFFECTIVE (deferred) range, not the immediate one. @@ -474,6 +478,7 @@ export function useVirtualHistory( if (h > 0 && heights.current.get(k) !== h) { heights.current.set(k, h) dirty = true + heightDirty = true } } } @@ -499,7 +504,11 @@ export function useVirtualHistory( offsetVersion.current++ onHeightsChangeRef.current?.(heights.current) } - }) + + if (heightDirty) { + bumpMeasuredHeightVersion(n => n + 1) + } + }, [effEnd, effStart, items, liveTailActive, measuredHeightVersion, n, offsets, scrollRef, sticky, total, vp]) return { bottomSpacer: Math.max(0, total - (offsets[effEnd] ?? total)), diff --git a/ui-tui/src/lib/precisionWheel.ts b/ui-tui/src/lib/precisionWheel.ts new file mode 100644 index 000000000000..4ddb447abf03 --- /dev/null +++ b/ui-tui/src/lib/precisionWheel.ts @@ -0,0 +1,48 @@ +const PRECISION_WHEEL_FRAME_MS = 16 +const PRECISION_WHEEL_STICKY_MS = 80 + +export type PrecisionWheelState = { + active: boolean + dir: 0 | -1 | 1 + lastEventAtMs: number + lastScrollAtMs: number +} + +export type PrecisionWheelStep = { + active: boolean + entered: boolean + rows: 0 | 1 +} + +export function initPrecisionWheel(): PrecisionWheelState { + return { active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 } +} + +export function computePrecisionWheelStep( + state: PrecisionWheelState, + dir: -1 | 1, + hasModifier: boolean, + now: number +): PrecisionWheelStep { + const active = hasModifier || now - state.lastEventAtMs < PRECISION_WHEEL_STICKY_MS + + if (!active) { + state.active = false + + return { active: false, entered: false, rows: 0 } + } + + const entered = !state.active + + state.active = true + state.lastEventAtMs = now + + if (dir === state.dir && now - state.lastScrollAtMs < PRECISION_WHEEL_FRAME_MS) { + return { active: true, entered, rows: 0 } + } + + state.dir = dir + state.lastScrollAtMs = now + + return { active: true, entered, rows: 1 } +} diff --git a/ui-tui/src/lib/viewportStore.ts b/ui-tui/src/lib/viewportStore.ts index b25ef581f47a..25acbd8bebcd 100644 --- a/ui-tui/src/lib/viewportStore.ts +++ b/ui-tui/src/lib/viewportStore.ts @@ -11,6 +11,12 @@ export interface ViewportSnapshot { viewportHeight: number } +export interface ScrollbarSnapshot { + scrollHeight: number + top: number + viewportHeight: number +} + const EMPTY: ViewportSnapshot = { atBottom: true, bottom: 0, @@ -20,6 +26,12 @@ const EMPTY: ViewportSnapshot = { viewportHeight: 0 } +const EMPTY_SCROLLBAR: ScrollbarSnapshot = { + scrollHeight: 0, + top: 0, + viewportHeight: 0 +} + export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot { if (!s) { return EMPTY @@ -52,6 +64,26 @@ export function viewportSnapshotKey(v: ViewportSnapshot) { return `${v.atBottom ? 1 : 0}:${Math.ceil(v.top / 8) * 8}:${v.viewportHeight}:${Math.ceil(v.scrollHeight / 8) * 8}:${v.pending}` } +export function getScrollbarSnapshot(s?: ScrollBoxHandle | null): ScrollbarSnapshot { + if (!s) { + return EMPTY_SCROLLBAR + } + + const viewportHeight = Math.max(0, s.getViewportHeight()) + const scrollHeight = Math.max(viewportHeight, s.getScrollHeight()) + const maxTop = Math.max(0, scrollHeight - viewportHeight) + + return { + scrollHeight, + top: Math.max(0, Math.min(maxTop, s.getScrollTop())), + viewportHeight + } +} + +export function scrollbarSnapshotKey(v: ScrollbarSnapshot) { + return `${v.top}:${v.viewportHeight}:${v.scrollHeight}` +} + export function useViewportSnapshot(scrollRef: RefObject): ViewportSnapshot { const key = useSyncExternalStore( useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), @@ -72,3 +104,21 @@ export function useViewportSnapshot(scrollRef: RefObject } }, [key]) } + +export function useScrollbarSnapshot(scrollRef: RefObject): ScrollbarSnapshot { + const key = useSyncExternalStore( + useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), + () => scrollbarSnapshotKey(getScrollbarSnapshot(scrollRef.current)), + () => scrollbarSnapshotKey(EMPTY_SCROLLBAR) + ) + + return useMemo(() => { + const [top = '0', viewportHeight = '0', scrollHeight = '0'] = key.split(':') + + return { + scrollHeight: Number(scrollHeight), + top: Number(top), + viewportHeight: Number(viewportHeight) + } + }, [key]) +} diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 2a5570903665..6d7426caed43 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -6,6 +6,8 @@ export interface ThemeColors { muted: string completionBg: string completionCurrentBg: string + completionMetaBg: string + completionMetaCurrentBg: string label: string ok: string @@ -264,8 +266,10 @@ export const DARK_THEME: Theme = { // new value sits ~60% luminance — readable without losing the "muted / // secondary" semantic. Field labels still use `label` (65%) which // stays brighter so hierarchy holds. - completionBg: '#FFFFFF', - completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25), + completionBg: '#1a1a2e', + completionCurrentBg: '#333355', + completionMetaBg: '#1a1a2e', + completionMetaCurrentBg: '#333355', label: '#DAA520', ok: '#4caf50', @@ -312,6 +316,8 @@ export const LIGHT_THEME: Theme = { muted: '#7A5A0F', completionBg: '#F5F5F5', completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25), + completionMetaBg: '#F5F5F5', + completionMetaCurrentBg: mix('#F5F5F5', '#A0651C', 0.25), label: '#7A5A0F', ok: '#2E7D32', @@ -517,12 +523,20 @@ export function fromSkin( ): Theme { const d = DEFAULT_THEME const c = (k: string) => colors[k] + const hasSkinColors = Object.keys(colors).length > 0 const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent const muted = c('banner_dim') ?? d.color.muted const completionBg = c('completion_menu_bg') ?? d.color.completionBg + const completionCurrentBg = + c('completion_menu_current_bg') ?? + (hasSkinColors ? mix(completionBg, bannerAccent, 0.25) : d.color.completionCurrentBg) + + const completionMetaBg = c('completion_menu_meta_bg') ?? completionBg + const completionMetaCurrentBg = c('completion_menu_meta_current_bg') ?? completionCurrentBg + return normalizeThemeForAnsiLightTerminal({ color: { primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary, @@ -531,7 +545,9 @@ export function fromSkin( text: c('ui_text') ?? c('banner_text') ?? d.color.text, muted, completionBg, - completionCurrentBg: c('completion_menu_current_bg') ?? mix(completionBg, bannerAccent, 0.25), + completionCurrentBg, + completionMetaBg, + completionMetaCurrentBg, label: c('ui_label') ?? d.color.label, ok: c('ui_ok') ?? d.color.ok, @@ -548,7 +564,7 @@ export function fromSkin( statusWarn: c('ui_warn') ?? d.color.statusWarn, statusBad: d.color.statusBad, statusCritical: d.color.statusCritical, - selectionBg: c('selection_bg') ?? d.color.selectionBg, + selectionBg: c('selection_bg') ?? c('completion_menu_current_bg') ?? (hasSkinColors ? completionCurrentBg : d.color.selectionBg), diffAdded: d.color.diffAdded, diffRemoved: d.color.diffRemoved, diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 9153cfb2978c..fb37a1826c2f 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -160,6 +160,7 @@ export interface SessionInfo { export interface Usage { calls: number + compressions?: number context_max?: number context_percent?: number context_used?: number diff --git a/uv.lock b/uv.lock index 6910c1ec75cb..ba59f44e6259 100644 --- a/uv.lock +++ b/uv.lock @@ -8,10 +8,6 @@ resolution-markers = [ "python_full_version < '3.12'", ] -[options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P7D" - [[package]] name = "agent-client-protocol" version = "0.9.0" diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 1c9231128891..38f1cf80abd8 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -303,7 +303,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { return (