Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14e3bb1f27 | ||
|
|
ba936039be | ||
|
|
2c0d648397 | ||
|
|
3c1d066a8a | ||
|
|
15cb4e2279 | ||
|
|
0269eca7e1 | ||
|
|
81dd43a8eb | ||
|
|
272c2f30aa | ||
|
|
bd8e2ec1a6 | ||
|
|
40ae170647 | ||
|
|
1495f0cc38 | ||
|
|
a5aecf26fa | ||
|
|
c35ede789f | ||
|
|
a26a12ad07 | ||
|
|
043350dfd3 | ||
|
|
21f55af769 | ||
|
|
72e82f88c0 | ||
|
|
fa3b06b035 | ||
|
|
f768e75ecf | ||
|
|
34468ed0d4 | ||
|
|
fc995634cc | ||
|
|
f24b7ed9d9 | ||
|
|
59510d7b44 | ||
|
|
0cd5867bbb | ||
|
|
d4b533de4e | ||
|
|
64f7f36713 | ||
|
|
c914e4a371 | ||
|
|
fabca0bdd8 | ||
|
|
f7a3509b25 | ||
|
|
7d51cd7516 | ||
|
|
13a2350c8d | ||
|
|
f600352e43 | ||
|
|
8104b20269 | ||
|
|
eee32cdd52 | ||
|
|
899e8b9067 | ||
|
|
abe0e19c0a | ||
|
|
d967e74427 | ||
|
|
05022066ea | ||
|
|
e25b2a6e18 | ||
|
|
9cb7d40d8d | ||
|
|
85b65e29f0 | ||
|
|
ddc22866a3 | ||
|
|
1d9aacbd00 | ||
|
|
2f171743b7 | ||
|
|
162c7856ca | ||
|
|
1d7a1c00b4 | ||
|
|
e59b815c04 | ||
|
|
4f7fe9bcff | ||
|
|
3a8d643d37 | ||
|
|
765790a216 | ||
|
|
566669013f | ||
|
|
a1f76ba7e9 | ||
|
|
8bf498c21d | ||
|
|
92273e4f57 | ||
|
|
0fdab53ef0 | ||
|
|
53f598e7a2 | ||
|
|
7527e7aeac | ||
|
|
c45593ceae | ||
|
|
128da68823 | ||
|
|
ac0cce5f3f | ||
|
|
75f478750c | ||
|
|
eadfeef60e | ||
|
|
f90777a6b8 | ||
|
|
c79b80a8a5 | ||
|
|
16047655b5 | ||
|
|
2564760d7a | ||
|
|
4bc7296042 | ||
|
|
f1237aa95b | ||
|
|
32032e1e2d | ||
|
|
e946f49ab5 | ||
|
|
1ffa22ee6b | ||
|
|
b9646276fd | ||
|
|
a5371b3e68 | ||
|
|
ef3a650f05 | ||
|
|
4e9d886d9d | ||
|
|
8f2931e3ee |
@@ -26,6 +26,10 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed so the arm64 job can push/pull its registry-backed build cache
|
||||
# to ghcr.io (cache-to/cache-from type=registry). See the build-arm64
|
||||
# job for why registry cache replaced the gha cache on that arch.
|
||||
packages: write
|
||||
|
||||
# Concurrency: push/release runs are NEVER cancelled so every merge gets
|
||||
# its own image. PR runs reuse a PR-scoped group with
|
||||
@@ -196,11 +200,34 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Build once, load into the local daemon for smoke testing. PR arm64
|
||||
# builds deliberately avoid the gha cache: cold-cache arm64 builds can
|
||||
# outlive GitHub's short-lived Azure cache SAS token, then fail while
|
||||
# reading or writing cache blobs before the smoke test can run.
|
||||
- name: Build image (arm64, smoke test, uncached PR)
|
||||
# Log in to ghcr.io so the registry-backed build cache below can be
|
||||
# read (cache-from) on every event and written (cache-to) on
|
||||
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
|
||||
# the whole job — unlike the gha cache backend's short-lived Azure SAS
|
||||
# token, which expired mid-build on slow cold-cache arm64 runs and
|
||||
# crashed the build before the smoke test (the reason the gha cache
|
||||
# was removed from arm64 PRs in the first place).
|
||||
- name: Log in to ghcr.io (build cache)
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Build once, load into the local daemon for smoke testing.
|
||||
#
|
||||
# PR builds use the registry-backed cache READ-ONLY (cache-from only):
|
||||
# they pull warm layers pushed by the most recent main build but never
|
||||
# write, so rapid PR pushes don't race on cache writes or pollute the
|
||||
# cache ref. This restores warm-cache speed to arm64 PR builds (which
|
||||
# were running fully uncached and were ~45% slower than amd64, making
|
||||
# them the job most often cancelled on supersede).
|
||||
#
|
||||
# Registry cache (type=registry on ghcr.io) is used instead of the gha
|
||||
# cache that previously broke here: its credential is the job-lifetime
|
||||
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
|
||||
# token failure mode cannot recur.
|
||||
- name: Build image (arm64, smoke test, cache read-only PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
@@ -211,9 +238,11 @@ jobs:
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
|
||||
# Main/release builds still use the per-arch gha cache so the digest
|
||||
# push below can reuse layers from this smoke-test build.
|
||||
# Main/release builds read AND write the registry cache so the digest
|
||||
# push below reuses layers from this smoke-test build, and so the next
|
||||
# PR/main build starts warm.
|
||||
- name: Build image (arm64, smoke test, cached publish)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
@@ -225,8 +254,8 @@ jobs:
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
|
||||
- name: Smoke test image
|
||||
uses: ./.github/actions/hermes-smoke-test
|
||||
@@ -253,8 +282,8 @@ jobs:
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
|
||||
@@ -308,11 +308,14 @@ def compress_context(
|
||||
# The check itself sets ``agent._compression_warning`` so the
|
||||
# status-callback replay machinery still emits the warning to the user
|
||||
# the first time it would matter.
|
||||
if not getattr(agent, "_compression_feasibility_checked", True):
|
||||
try:
|
||||
check_compression_model_feasibility(agent)
|
||||
finally:
|
||||
agent._compression_feasibility_checked = True
|
||||
if not getattr(agent, "_compression_feasibility_checked", False):
|
||||
# Mark as checked only after the probe completes. If the check
|
||||
# raises (e.g. a fatal aux-context ValueError that aborts the
|
||||
# session), leaving the flag unset is harmless; a non-fatal
|
||||
# transient failure is swallowed inside the function so the flag
|
||||
# is set normally on the next successful pass.
|
||||
check_compression_model_feasibility(agent)
|
||||
agent._compression_feasibility_checked = True
|
||||
|
||||
_pre_msg_count = len(messages)
|
||||
logger.info(
|
||||
|
||||
@@ -451,3 +451,190 @@ def get_cross_profile_warning(path: str) -> Optional[str]:
|
||||
f"``cross_profile=True``. (Defense-in-depth — not a security "
|
||||
f"boundary; the terminal tool can still bypass.)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox-mirror write guard (#32049)
|
||||
#
|
||||
# Non-local terminal backends (Docker, Daytona, etc.) bind a sandbox-local
|
||||
# directory to the container's ``$HOME``. The on-disk layout looks like
|
||||
#
|
||||
# <HERMES_HOME>/profiles/<name>/sandboxes/<backend>/<task>/home/.hermes/...
|
||||
#
|
||||
# When the agent (running host-side) speculates that authoritative profile
|
||||
# state lives at one of those sandbox-mirror paths, the write lands on the
|
||||
# mirror — never read by the host process — while the host file is left
|
||||
# untouched. The agent reports success, the user sees no change, and on
|
||||
# disk two divergent copies accumulate. See #32049 for evidence.
|
||||
#
|
||||
# This guard is path-shape-only: it detects the
|
||||
# ``…/sandboxes/<backend>/<task>/home/.hermes/…`` segment and warns
|
||||
# regardless of which Hermes profile is active. It does NOT cover the
|
||||
# inner-container case where the bind mount strips the ``sandboxes/`` prefix
|
||||
# (the agent's view inside the container is plain ``/root/.hermes/...``);
|
||||
# that case needs a separate dispatch-layer or host-side ``profile_state``
|
||||
# tool.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_sandbox_mirror_segments(parts: tuple) -> Optional[int]:
|
||||
"""Return the index of the inner ``.hermes`` part in a sandbox-mirror path.
|
||||
|
||||
Matches ``…/sandboxes/<backend>/<task>/home/.hermes/…`` and returns the
|
||||
index where the inner Hermes-state portion starts. Returns ``None`` for
|
||||
paths that do not contain the sandbox-mirror shape.
|
||||
"""
|
||||
for i, part in enumerate(parts):
|
||||
if part != "sandboxes":
|
||||
continue
|
||||
# Need at least: sandboxes / <backend> / <task> / home / .hermes / <thing>
|
||||
if i + 5 >= len(parts):
|
||||
continue
|
||||
if parts[i + 3] == "home" and parts[i + 4] == ".hermes":
|
||||
return i + 4
|
||||
return None
|
||||
|
||||
|
||||
def classify_sandbox_mirror_target(path: str) -> Optional[dict]:
|
||||
"""Classify a write target as a sandbox-mirror of authoritative Hermes state.
|
||||
|
||||
Returns ``None`` when the path does not match the sandbox-mirror shape.
|
||||
Otherwise returns a dict with:
|
||||
|
||||
* ``target_path``: the resolved path string
|
||||
* ``mirror_root``: the ``…/sandboxes/<backend>/<task>/home/.hermes``
|
||||
prefix (so callers can show users which sandbox owns the mirror)
|
||||
* ``inner_path``: the portion under the mirror's ``.hermes`` (what the
|
||||
agent likely meant to address on the host)
|
||||
|
||||
Detection is path-shape-only — does not require any Hermes resolver to
|
||||
succeed, so it works correctly even when called from contexts where
|
||||
HERMES_HOME resolution would be ambiguous.
|
||||
"""
|
||||
try:
|
||||
target = Path(os.path.expanduser(str(path))).resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
parts = target.parts
|
||||
inner_idx = _find_sandbox_mirror_segments(parts)
|
||||
if inner_idx is None:
|
||||
return None
|
||||
|
||||
mirror_root = str(Path(*parts[: inner_idx + 1]))
|
||||
inner_path = str(Path(*parts[inner_idx + 1 :])) if inner_idx + 1 < len(parts) else ""
|
||||
|
||||
return {
|
||||
"target_path": str(target),
|
||||
"mirror_root": mirror_root,
|
||||
"inner_path": inner_path,
|
||||
}
|
||||
|
||||
|
||||
def get_sandbox_mirror_warning(path: str) -> Optional[str]:
|
||||
"""Return a model-facing warning when ``path`` lands in a sandbox mirror.
|
||||
|
||||
Returns ``None`` when the path is not a sandbox-mirror target. Caller
|
||||
is expected to surface the warning to the agent as a tool-result
|
||||
error. The bypass kwarg (``cross_profile=True``) is shared with the
|
||||
cross-profile guard: both are soft "I know what I'm doing" overrides
|
||||
a user can authorise.
|
||||
|
||||
Defense-in-depth, NOT a security boundary: the terminal tool runs as
|
||||
the same OS user and can write the mirror path directly. The guard
|
||||
exists to surface the misclassification before the silent-success +
|
||||
divergent-copy footgun in #32049 fires.
|
||||
"""
|
||||
info = classify_sandbox_mirror_target(path)
|
||||
if info is None:
|
||||
return None
|
||||
return (
|
||||
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
|
||||
f"sits under {info['mirror_root']!r}, which is a per-task mirror "
|
||||
f"created by a non-local terminal backend (docker/daytona/etc.). "
|
||||
f"Writes here land on a copy that the host Hermes process never "
|
||||
f"reads — the authoritative file is likely {info['inner_path']!r} "
|
||||
f"under the real HERMES_HOME. Use the host-side tool for "
|
||||
f"authoritative state (e.g. ``memory`` for memories), or address "
|
||||
f"the host path directly. To bypass this guard after explicit "
|
||||
f"user direction, retry the call with ``cross_profile=True``. "
|
||||
f"(Defense-in-depth — not a security boundary; the terminal tool "
|
||||
f"can still bypass.)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Container-context mirror guard (inner-container case — #32049 follow-up)
|
||||
#
|
||||
# Brian's shape-based detector (#32213) catches paths that still carry the
|
||||
# full ``…/sandboxes/<backend>/<task>/home/.hermes/…`` prefix on the host.
|
||||
# But when file tools execute *inside* the container the bind-mount strips
|
||||
# that prefix: the agent sees plain ``/root/.hermes/…``. The root:root
|
||||
# ownership on the divergent SOUL.md in #32049 confirms this is the primary
|
||||
# failure mode.
|
||||
#
|
||||
# Fix: file_tools passes the active Docker mirror prefix when the terminal
|
||||
# backend is docker + persistent. This catches the very first file-tool call,
|
||||
# before a DockerEnvironment object necessarily exists.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def classify_container_mirror_target(
|
||||
path: str,
|
||||
mirror_prefix: str | None = None,
|
||||
) -> Optional[dict]:
|
||||
"""Classify a write target as a container-side sandbox mirror.
|
||||
|
||||
``mirror_prefix`` must be supplied by the caller after it has established
|
||||
that file tools are executing in a container whose home is a sandbox
|
||||
mirror. Returns ``None`` when no such context is active or the path is not
|
||||
under the mirror prefix. Otherwise returns:
|
||||
|
||||
* ``target_path``: resolved path string
|
||||
* ``mirror_root``: the declared container mirror prefix
|
||||
* ``inner_path``: portion under the mirror root (what the agent
|
||||
likely meant to address in the host HERMES_HOME)
|
||||
"""
|
||||
if not mirror_prefix:
|
||||
return None
|
||||
try:
|
||||
target = Path(os.path.expanduser(str(path))).resolve()
|
||||
mirror = Path(os.path.expanduser(mirror_prefix)).resolve()
|
||||
inner = target.relative_to(mirror)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"target_path": str(target),
|
||||
"mirror_root": str(mirror),
|
||||
"inner_path": inner.as_posix(),
|
||||
}
|
||||
|
||||
|
||||
def get_container_mirror_warning(
|
||||
path: str,
|
||||
mirror_prefix: str | None = None,
|
||||
) -> Optional[str]:
|
||||
"""Return a model-facing warning when *path* lands in the container's
|
||||
sandbox mirror of authoritative Hermes state.
|
||||
|
||||
The caller supplies ``mirror_prefix`` only when the current file-tool
|
||||
backend is known to execute inside a Docker sandbox. Same contract as
|
||||
``get_cross_profile_warning``: soft guard, returns ``None`` for
|
||||
non-mirror paths, caller surfaces as a tool-result error. Bypass via
|
||||
``cross_profile=True`` after explicit user direction.
|
||||
"""
|
||||
info = classify_container_mirror_target(path, mirror_prefix)
|
||||
if info is None:
|
||||
return None
|
||||
return (
|
||||
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
|
||||
f"sits under {info['mirror_root']!r}, which is the container's "
|
||||
f"bind-mounted home — a per-task mirror that the host Hermes "
|
||||
f"process never reads. The authoritative file is "
|
||||
f"{info['inner_path']!r} under the real HERMES_HOME. Use the "
|
||||
f"host-side tool for authoritative state (e.g. ``memory`` for "
|
||||
f"memories), or address the host path directly. To bypass after "
|
||||
f"explicit user direction, retry with ``cross_profile=True``. "
|
||||
f"(Defense-in-depth — not a security boundary; the terminal tool "
|
||||
f"can still bypass.)"
|
||||
)
|
||||
|
||||
@@ -1128,6 +1128,18 @@ def _model_name_suggests_kimi(model: str) -> bool:
|
||||
return lower.startswith("kimi") or "moonshot" in lower
|
||||
|
||||
|
||||
def _model_name_suggests_minimax_m3(model: str) -> bool:
|
||||
"""Return True if the model name looks like MiniMax M3.
|
||||
|
||||
Catches ``MiniMax-M3``, ``minimax/minimax-m3``, and similar variants
|
||||
across surfaces (native MiniMax-M3, OpenRouter/Nous minimax/minimax-m3).
|
||||
Used as a guard against stale cache entries seeded by pre-catalog builds
|
||||
that resolved M3 via the generic ``minimax`` catch-all (204,800) before
|
||||
the ``minimax-m3`` (1M) entry existed in DEFAULT_CONTEXT_LENGTHS.
|
||||
"""
|
||||
return "minimax-m3" in model.lower()
|
||||
|
||||
|
||||
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
|
||||
"""Query a local server for the model's context length."""
|
||||
import httpx
|
||||
@@ -1539,6 +1551,19 @@ def get_model_context_length(
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Invalidate stale ≤204,800 cache entries for MiniMax-M3. Pre-catalog
|
||||
# builds resolved M3 via the generic ``minimax`` catch-all (204,800)
|
||||
# and persisted it before the ``minimax-m3`` (1M) entry existed; that
|
||||
# stale value would otherwise stick forever here at step 1. M3 is 1M,
|
||||
# so any sub-256K cached value for an M3 slug is a leftover — drop it
|
||||
# and fall through to the hardcoded default.
|
||||
elif cached <= 204_800 and _model_name_suggests_minimax_m3(model):
|
||||
logger.info(
|
||||
"Dropping stale MiniMax-M3 cache entry %s@%s -> %s (pre-catalog value); "
|
||||
"re-resolving via hardcoded defaults",
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Nous Portal: the portal /v1/models endpoint is authoritative.
|
||||
# Bypass the persistent cache so step 5b can always reconcile
|
||||
# against it — this corrects pre-fix entries seeded from the
|
||||
|
||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
|
||||
from typing import Optional
|
||||
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
from agent.skill_utils import (
|
||||
extract_skill_conditions,
|
||||
extract_skill_description,
|
||||
@@ -802,7 +803,7 @@ def build_environment_hints() -> str:
|
||||
|
||||
host_lines.append(f"User home directory: {os.path.expanduser('~')}")
|
||||
try:
|
||||
host_lines.append(f"Current working directory: {os.getcwd()}")
|
||||
host_lines.append(f"Current working directory: {resolve_agent_cwd()}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Single source of truth for the agent working directory.
|
||||
|
||||
`TERMINAL_CWD` is the runtime carrier for the configured working directory
|
||||
(design #19214/#19242: `terminal.cwd` is bridged once to `TERMINAL_CWD` at
|
||||
gateway/cron startup). The local-CLI backend deliberately leaves it unset and
|
||||
relies on the launch dir. Reading it in one place keeps the system prompt, the
|
||||
tool surfaces, and context-file discovery agreeing on where the agent lives.
|
||||
|
||||
The #29531 per-session extension point is this function: a future PR adds a
|
||||
contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
|
||||
`set_session_vars` seam — by design, not a reopening hazard.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_agent_cwd() -> Path:
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
if p.is_dir():
|
||||
return p
|
||||
return Path(os.getcwd())
|
||||
|
||||
|
||||
def resolve_context_cwd() -> Path | None:
|
||||
# None means "no configured cwd": build_context_files_prompt then falls back
|
||||
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
|
||||
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
|
||||
# No getcwd arm here: that fallback is owned by the caller, not this resolver.
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
return Path(raw).expanduser() if raw else None
|
||||
@@ -24,7 +24,6 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.prompt_builder import (
|
||||
@@ -41,6 +40,7 @@ from agent.prompt_builder import (
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_MODELS,
|
||||
)
|
||||
from agent.runtime_cwd import resolve_context_cwd
|
||||
|
||||
|
||||
def _ra():
|
||||
@@ -288,13 +288,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
context_parts.append(system_message)
|
||||
|
||||
if not agent.skip_context_files:
|
||||
# Use TERMINAL_CWD for context file discovery when set (gateway
|
||||
# mode). The gateway process runs from the hermes-agent install
|
||||
# dir, so os.getcwd() would pick up the repo's AGENTS.md and
|
||||
# other dev files — inflating token usage by ~10k for no benefit.
|
||||
_context_cwd = os.getenv("TERMINAL_CWD") or None
|
||||
# Prefer the configured TERMINAL_CWD (gateway mode). When unset (local
|
||||
# CLI), None lets build_context_files_prompt fall back to the launch
|
||||
# dir — the user's real cwd there, but the install dir for the gateway
|
||||
# daemon, which is why the gateway sets TERMINAL_CWD.
|
||||
context_files_prompt = _r.build_context_files_prompt(
|
||||
cwd=_context_cwd, skip_soul=_soul_loaded)
|
||||
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
|
||||
if context_files_prompt:
|
||||
context_parts.append(context_files_prompt)
|
||||
|
||||
|
||||
@@ -8,18 +8,24 @@ fn main() {
|
||||
// `option_env!()` macro to default the install-script reference.
|
||||
// Precedence (matches install.ps1's own arg precedence): commit > branch.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. Env var override at build time (HERMES_BUILD_PIN_COMMIT, etc.).
|
||||
// Useful for CI builds that want to pin to a tagged release SHA
|
||||
// rather than whatever the checkout's HEAD happens to be.
|
||||
// 2. `git rev-parse HEAD` + `git rev-parse --abbrev-ref HEAD` against
|
||||
// the repo this build.rs lives in. Default for `cargo tauri build`
|
||||
// from a dev machine — pins the produced .exe to your current
|
||||
// checkout state.
|
||||
// 3. Last-resort fallback: hardcoded `main` branch, no commit. The
|
||||
// installer will fetch HEAD-of-main at runtime. Used when the
|
||||
// build is happening outside a git checkout (e.g. cargo install
|
||||
// from a packaged crate, unlikely for this binary but defensive).
|
||||
// The COMMIT pin is opt-in. By default a dev build pins ONLY the branch,
|
||||
// so the produced installer follows that branch's HEAD at install time
|
||||
// (tolerant of fast-forwards/new commits, and never references a SHA the
|
||||
// local checkout hasn't pushed). Set HERMES_BUILD_PIN_COMMIT to bake an
|
||||
// immutable commit pin for reproducible/release installers.
|
||||
//
|
||||
// Commit pin resolution:
|
||||
// - HERMES_BUILD_PIN_COMMIT, if set and non-empty. Accepts a SHA, tag,
|
||||
// or branch name; resolved to an immutable SHA via `git rev-parse`
|
||||
// when possible, else used verbatim if it already looks like a SHA.
|
||||
// - Otherwise: NO commit pin (branch-follow is the default).
|
||||
//
|
||||
// Branch pin resolution:
|
||||
// 1. HERMES_BUILD_PIN_BRANCH, if set and non-empty.
|
||||
// 2. `git rev-parse --abbrev-ref HEAD` of the checkout this build.rs
|
||||
// lives in — the current branch. (None on a detached HEAD.)
|
||||
// 3. Last-resort fallback handled below: if neither commit nor branch
|
||||
// resolves, warn — the binary needs a runtime arg or dev-repo env.
|
||||
//
|
||||
// Build script reruns on git HEAD change so a new commit triggers
|
||||
// a rebuild without `cargo clean`.
|
||||
@@ -30,11 +36,20 @@ fn main() {
|
||||
|
||||
if let Some(c) = &commit {
|
||||
println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}");
|
||||
println!("cargo:warning=hermes-bootstrap: pinning to commit {}", short(c));
|
||||
println!(
|
||||
"cargo:warning=hermes-bootstrap: pinning to commit {}",
|
||||
short(c)
|
||||
);
|
||||
}
|
||||
if let Some(b) = &branch {
|
||||
println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}");
|
||||
println!("cargo:warning=hermes-bootstrap: pinning to branch {b}");
|
||||
match &commit {
|
||||
Some(_) => println!("cargo:warning=hermes-bootstrap: pinning to branch {b}"),
|
||||
None => println!(
|
||||
"cargo:warning=hermes-bootstrap: following branch {b} HEAD (no commit pin; \
|
||||
set HERMES_BUILD_PIN_COMMIT for an immutable pin)"
|
||||
),
|
||||
}
|
||||
}
|
||||
if commit.is_none() && branch.is_none() {
|
||||
// Fail loudly rather than silently produce a binary that errors
|
||||
@@ -46,8 +61,11 @@ fn main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Rerun build.rs when HEAD moves so successive builds pick up new
|
||||
// commits without needing `cargo clean`. .git/HEAD changes on every
|
||||
// Rerun build.rs when HEAD moves. With branch-follow as the default the
|
||||
// baked commit no longer changes per-commit, but a branch *switch* changes
|
||||
// the detected branch name, so we still re-trigger. When an explicit
|
||||
// HERMES_BUILD_PIN_COMMIT resolves a moving ref (tag/branch) to a SHA, a
|
||||
// HEAD move can also change that resolution. .git/HEAD changes on every
|
||||
// commit / branch switch / rebase.
|
||||
let git_dir = locate_git_dir();
|
||||
if let Some(gd) = &git_dir {
|
||||
@@ -83,24 +101,46 @@ fn main() {
|
||||
}
|
||||
|
||||
fn resolve_commit_pin() -> Option<String> {
|
||||
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") {
|
||||
if !v.trim().is_empty() {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
// Commit pinning is OPT-IN. Only bake a commit when the caller explicitly
|
||||
// asks for one via HERMES_BUILD_PIN_COMMIT. With no env var, we return
|
||||
// None and the installer follows the branch HEAD at install time.
|
||||
let requested = std::env::var("HERMES_BUILD_PIN_COMMIT").ok()?;
|
||||
let requested = requested.trim();
|
||||
if requested.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
// Resolve the request (which may be a SHA, tag, or branch name) to an
|
||||
// immutable commit SHA so the baked pin is reproducible. `^{commit}`
|
||||
// dereferences tags to the commit they point at.
|
||||
if let Ok(out) = Command::new("git")
|
||||
.args(["rev-parse", "--verify", &format!("{requested}^{{commit}}")])
|
||||
.output()
|
||||
{
|
||||
if out.status.success() {
|
||||
if let Ok(s) = String::from_utf8(out.stdout) {
|
||||
let s = s.trim().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Couldn't resolve via git (e.g. building outside a checkout). Accept the
|
||||
// literal value only if it already looks like a SHA; otherwise fail loud
|
||||
// rather than bake an unresolvable ref into the binary.
|
||||
if is_sha(requested) {
|
||||
return Some(requested.to_string());
|
||||
}
|
||||
panic!(
|
||||
"HERMES_BUILD_PIN_COMMIT={requested:?} could not be resolved to a commit \
|
||||
(git rev-parse failed and it is not a valid SHA)"
|
||||
);
|
||||
}
|
||||
|
||||
/// True if `s` looks like an abbreviated-or-full git SHA (7..=40 hex chars).
|
||||
fn is_sha(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn resolve_branch_pin() -> Option<String> {
|
||||
|
||||
@@ -8,6 +8,8 @@ const {
|
||||
ipcMain,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
net: electronNet,
|
||||
protocol,
|
||||
safeStorage,
|
||||
session,
|
||||
shell,
|
||||
@@ -364,6 +366,66 @@ app.setAboutPanelOptions({
|
||||
copyright: 'Copyright © 2026 Nous Research'
|
||||
})
|
||||
|
||||
// Custom scheme for streaming local media (video/audio) into the renderer.
|
||||
// Reading large media through `readFileDataUrl` failed: it base64-loads the
|
||||
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
|
||||
// so any non-trivial video silently refused to load. Streaming via a protocol
|
||||
// handler removes the size cap and gives the <video> element seekable,
|
||||
// range-aware playback. Must be registered before the app is ready.
|
||||
const MEDIA_PROTOCOL = 'hermes-media'
|
||||
// Only audio/video may be streamed. Without this the handler would read any
|
||||
// non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`.
|
||||
const STREAMABLE_MEDIA_EXTS = new Set([
|
||||
'.avi',
|
||||
'.flac',
|
||||
'.m4a',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.ogg',
|
||||
'.opus',
|
||||
'.wav',
|
||||
'.webm'
|
||||
])
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: MEDIA_PROTOCOL,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
stream: true,
|
||||
supportFetchAPI: true
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
function registerMediaProtocol() {
|
||||
protocol.handle(MEDIA_PROTOCOL, async request => {
|
||||
let resolvedPath
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, ''))
|
||||
;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' }))
|
||||
} catch {
|
||||
return new Response('Media not found', { status: 404 })
|
||||
}
|
||||
|
||||
if (!STREAMABLE_MEDIA_EXTS.has(path.extname(resolvedPath).toLowerCase())) {
|
||||
return new Response('Unsupported media type', { status: 415 })
|
||||
}
|
||||
|
||||
// Delegate to Electron's net stack on a file:// URL — it resolves the
|
||||
// content-type and honors Range requests so seeking works. Forward the
|
||||
// renderer's headers (notably Range) and skip custom-protocol re-entry.
|
||||
return electronNet.fetch(pathToFileURL(resolvedPath).toString(), {
|
||||
bypassCustomProtocolHandlers: true,
|
||||
headers: request.headers
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
@@ -3654,6 +3716,7 @@ app.whenReady().then(() => {
|
||||
Menu.setApplicationMenu(null)
|
||||
}
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
createWindow()
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ function ChatHeader({
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
|
||||
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
|
||||
|
||||
return (
|
||||
|
||||
@@ -67,7 +67,12 @@ import { VirtualSessionList } from './virtual-session-list'
|
||||
const VIRTUALIZE_THRESHOLD = 25
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{ id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
|
||||
{
|
||||
id: 'new-session',
|
||||
label: 'New session',
|
||||
icon: props => <Codicon name="robot" {...props} />,
|
||||
action: 'new-session'
|
||||
},
|
||||
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
|
||||
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
||||
@@ -149,6 +154,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
onLoadMoreSessions: () => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onNewSessionInWorkspace: (path: null | string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
@@ -156,7 +163,9 @@ export function ChatSidebar({
|
||||
onNavigate,
|
||||
onLoadMoreSessions,
|
||||
onResumeSession,
|
||||
onDeleteSession
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
}: ChatSidebarProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
@@ -328,6 +337,7 @@ export function ChatSidebar({
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label="Pinned"
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
@@ -361,9 +371,9 @@ export function ChatSidebar({
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
@@ -372,15 +382,17 @@ export function ChatSidebar({
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
}
|
||||
label="Agents"
|
||||
label="Sessions"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onNewSessionInWorkspace={onNewSessionInWorkspace}
|
||||
onReorder={handleAgentDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
||||
@@ -472,7 +484,9 @@ interface SidebarSessionsSectionProps {
|
||||
workingSessionIdSet: Set<string>
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
onNewSessionInWorkspace?: (path: null | string) => void
|
||||
pinned: boolean
|
||||
rootClassName?: string
|
||||
contentClassName?: string
|
||||
@@ -496,7 +510,9 @@ function SidebarSessionsSection({
|
||||
workingSessionIdSet,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onTogglePin,
|
||||
onNewSessionInWorkspace,
|
||||
pinned,
|
||||
rootClassName,
|
||||
contentClassName,
|
||||
@@ -518,6 +534,7 @@ function SidebarSessionsSection({
|
||||
isPinned: pinned,
|
||||
isSelected: session.id === activeSessionId,
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onArchive: () => onArchiveSession(session.id),
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(session.id),
|
||||
onResume: () => onResumeSession(session.id),
|
||||
@@ -551,9 +568,19 @@ function SidebarSessionsSection({
|
||||
} else if (groups?.length) {
|
||||
const groupNodes = groups.map(group =>
|
||||
dndActive ? (
|
||||
<SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
<SortableSidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderSessionList}
|
||||
/>
|
||||
) : (
|
||||
<SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
<SidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderSessionList}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -568,6 +595,7 @@ function SidebarSessionsSection({
|
||||
inner = (
|
||||
<VirtualSessionList
|
||||
activeSessionId={activeSessionId}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onTogglePin={onTogglePin}
|
||||
@@ -610,6 +638,7 @@ function SidebarSessionsSection({
|
||||
interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
reorderable?: boolean
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
@@ -618,6 +647,7 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
|
||||
function SidebarWorkspaceGroup({
|
||||
group,
|
||||
renderRows,
|
||||
onNewSession,
|
||||
reorderable = false,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
@@ -634,18 +664,31 @@ function SidebarWorkspaceGroup({
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
|
||||
<button
|
||||
className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
|
||||
<button
|
||||
className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
{onNewSession && (
|
||||
<button
|
||||
aria-label={`New session in ${group.label}`}
|
||||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
onClick={() => onNewSession(group.path)}
|
||||
title={`New session in ${group.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
{reorderable && (
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
@@ -663,7 +706,7 @@ function SidebarWorkspaceGroup({
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<>
|
||||
{renderRows(visibleSessions)}
|
||||
@@ -687,6 +730,7 @@ function SidebarWorkspaceGroup({
|
||||
interface SortableWorkspaceProps {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
}
|
||||
|
||||
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
|
||||
@@ -702,6 +746,7 @@ interface SortableSessionRowProps {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
|
||||
@@ -26,6 +26,7 @@ interface SessionActions {
|
||||
title: string
|
||||
pinned?: boolean
|
||||
onPin?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
@@ -40,7 +41,7 @@ interface ItemSpec {
|
||||
variant?: 'destructive'
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) {
|
||||
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
@@ -81,6 +82,15 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }
|
||||
setRenameOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: 'Archive',
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
@@ -45,6 +46,7 @@ export function SidebarSessionRow({
|
||||
isPinned,
|
||||
isSelected,
|
||||
isWorking,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onPin,
|
||||
onResume,
|
||||
@@ -61,7 +63,14 @@ export function SidebarSessionRow({
|
||||
const handleLabel = `Reorder ${title}`
|
||||
|
||||
return (
|
||||
<SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
|
||||
<SessionContextMenu
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
|
||||
@@ -88,6 +97,15 @@ export function SidebarSessionRow({
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onArchive()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onResume()
|
||||
}}
|
||||
type="button"
|
||||
@@ -127,7 +145,14 @@ export function SidebarSessionRow({
|
||||
{age}
|
||||
</span>
|
||||
)}
|
||||
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
|
||||
<SessionActionsMenu
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
|
||||
@@ -12,6 +12,7 @@ interface SessionRowCommonProps {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
@@ -20,6 +21,7 @@ interface SessionRowCommonProps {
|
||||
interface VirtualSessionListProps {
|
||||
activeSessionId: null | string
|
||||
className?: string
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
@@ -35,6 +37,7 @@ const OVERSCAN_ROWS = 12
|
||||
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
activeSessionId,
|
||||
className,
|
||||
onArchiveSession,
|
||||
onDeleteSession,
|
||||
onResumeSession,
|
||||
onTogglePin,
|
||||
@@ -72,6 +75,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
isPinned: pinned,
|
||||
isSelected: session.id === activeSessionId,
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onArchive: () => onArchiveSession(session.id),
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(session.id),
|
||||
onResume: () => onResumeSession(session.id)
|
||||
|
||||
@@ -113,7 +113,7 @@ interface SectionSearchEntry {
|
||||
}
|
||||
|
||||
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 're
|
||||
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
|
||||
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
|
||||
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
|
||||
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
$selectedStoredSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setMessages,
|
||||
@@ -122,6 +125,7 @@ export function DesktopController() {
|
||||
settingsOpen,
|
||||
toggleCommandCenter
|
||||
} = useOverlayRouting()
|
||||
|
||||
const terminalTakeoverActive = chatOpen && terminalTakeover
|
||||
|
||||
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
||||
@@ -192,7 +196,10 @@ export function DesktopController() {
|
||||
|
||||
try {
|
||||
const limit = $sessionsLimit.get()
|
||||
const result = await listSessions(limit)
|
||||
// Require at least one message so abandoned/empty "Untitled" drafts (one
|
||||
// was created per TUI/desktop launch before the lazy-create fix) don't
|
||||
// clutter the sidebar.
|
||||
const result = await listSessions(limit, 1)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(result.sessions)
|
||||
@@ -324,6 +331,7 @@ export function DesktopController() {
|
||||
})
|
||||
|
||||
const {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
createBackendSessionForSend,
|
||||
openSettings,
|
||||
@@ -392,6 +400,29 @@ export function DesktopController() {
|
||||
[branchCurrentSession, refreshSessions]
|
||||
)
|
||||
|
||||
const startSessionInWorkspace = useCallback(
|
||||
(path: null | string) => {
|
||||
startFreshSessionDraft()
|
||||
|
||||
const target = path?.trim()
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
// The next message creates the backend session in $currentCwd, so seed
|
||||
// it (and the branch) from the workspace the user clicked the + on.
|
||||
setCurrentCwd(target)
|
||||
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
.then(info => {
|
||||
setCurrentCwd(info.cwd || target)
|
||||
setCurrentBranch(info.branch || '')
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
[requestGateway, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
const handleSkinCommand = useSkinCommand()
|
||||
|
||||
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
|
||||
@@ -461,9 +492,11 @@ export function DesktopController() {
|
||||
const sidebar = (
|
||||
<ChatSidebar
|
||||
currentView={currentView}
|
||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreSessions={loadMoreSessions}
|
||||
onNavigate={selectSidebarItem}
|
||||
onNewSessionInWorkspace={startSessionInWorkspace}
|
||||
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
)
|
||||
@@ -485,6 +518,7 @@ export function DesktopController() {
|
||||
/>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
|
||||
{settingsOpen && (
|
||||
@@ -575,10 +609,10 @@ export function DesktopController() {
|
||||
titlebarTools={titlebarToolGroups.flat.right}
|
||||
>
|
||||
<Pane
|
||||
disabled={terminalTakeoverActive}
|
||||
id="chat-sidebar"
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
disabled={terminalTakeoverActive}
|
||||
resizable
|
||||
side="left"
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSessionMessages } from '@/hermes'
|
||||
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
@@ -751,7 +751,39 @@ export function useSessionActions({
|
||||
]
|
||||
)
|
||||
|
||||
const archiveSession = useCallback(
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const archived = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
if (wasSelected) {
|
||||
startFreshSessionDraft(true)
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionArchived(storedSessionId, true)
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
notifyError(err, 'Archive failed')
|
||||
}
|
||||
},
|
||||
[selectedStoredSessionId, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
return {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
closeSettings,
|
||||
createBackendSessionForSend,
|
||||
|
||||
@@ -311,11 +311,15 @@ export const MODE_OPTIONS: ModeOption[] = [
|
||||
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
|
||||
]
|
||||
|
||||
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = {
|
||||
export const SEARCH_PLACEHOLDER: Record<
|
||||
'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools',
|
||||
string
|
||||
> = {
|
||||
about: 'About Hermes Desktop',
|
||||
config: 'Search settings...',
|
||||
gateway: 'Gateway connection...',
|
||||
keys: 'Search API keys...',
|
||||
mcp: 'Search MCP servers...',
|
||||
sessions: 'Search archived sessions...',
|
||||
tools: 'Search skills and tools...'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
|
||||
import { Archive, Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -19,6 +19,7 @@ import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import { ToolsSettings } from './tools-settings'
|
||||
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
@@ -27,6 +28,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'sessions',
|
||||
'tools',
|
||||
'about'
|
||||
]
|
||||
@@ -40,6 +42,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
gateway: '',
|
||||
keys: '',
|
||||
mcp: '',
|
||||
sessions: '',
|
||||
tools: ''
|
||||
})
|
||||
|
||||
@@ -149,6 +152,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
label="MCP"
|
||||
onClick={() => setActiveView('mcp')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'sessions'}
|
||||
icon={Archive}
|
||||
label="Archived Chats"
|
||||
onClick={() => setActiveView('sessions')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'about'}
|
||||
@@ -200,6 +209,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
<KeysSettings query={queries.keys} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
|
||||
) : activeView === 'sessions' ? (
|
||||
<SessionsSettings query={queries.sessions} />
|
||||
) : (
|
||||
<ToolsSettings query={queries.tools} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
const ARCHIVED_FETCH_LIMIT = 200
|
||||
|
||||
function workspaceLabel(cwd: null | string | undefined): string {
|
||||
const path = cwd?.trim()
|
||||
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
path
|
||||
.replace(/[/\\]+$/, '')
|
||||
.split(/[/\\]/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? path
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionsSettings({ query }: SearchProps) {
|
||||
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
setLocalSessions(result.sessions)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not load archived sessions')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const unarchive = useCallback(async (session: SessionInfo) => {
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await setSessionArchived(session.id, false)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
// Surface it again in the sidebar without waiting for a full refresh.
|
||||
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
|
||||
triggerHaptic('selection')
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Restored' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Unarchive failed')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remove = useCallback(async (session: SessionInfo) => {
|
||||
if (!window.confirm(`Permanently delete "${sessionTitle(session)}"? This cannot be undone.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await deleteSession(session.id)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
triggerHaptic('warning')
|
||||
} catch (err) {
|
||||
notifyError(err, 'Delete failed')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
if (!needle) {
|
||||
return sessions
|
||||
}
|
||||
|
||||
return sessions.filter(session =>
|
||||
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
|
||||
)
|
||||
}, [query, sessions])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label="Loading archived sessions…" />
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading
|
||||
icon={Archive}
|
||||
meta={sessions.length ? String(sessions.length) : undefined}
|
||||
title="Archived sessions"
|
||||
/>
|
||||
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to
|
||||
archive it.
|
||||
</p>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
|
||||
title="Nothing archived"
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{filtered.map(session => {
|
||||
const label = workspaceLabel(session.cwd)
|
||||
const busy = busyId === session.id
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void unarchive(session)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
||||
<span>Unarchive</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={session.preview || undefined}
|
||||
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
|
||||
key={session.id}
|
||||
title={sessionTitle(session)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools'
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools'
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
filePathFromMediaPath,
|
||||
mediaExternalUrl,
|
||||
mediaKind,
|
||||
mediaMime,
|
||||
mediaName,
|
||||
mediaPathFromMarkdownHref
|
||||
mediaPathFromMarkdownHref,
|
||||
mediaStreamUrl
|
||||
} from '@/lib/media'
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -40,24 +40,22 @@ import { cn } from '@/lib/utils'
|
||||
// LLM convention). The default false-setting only accepts `$$...$$`.
|
||||
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
|
||||
|
||||
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
|
||||
const blob = await fetch(dataUrl).then(response => response.blob())
|
||||
|
||||
return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))
|
||||
}
|
||||
|
||||
async function mediaSrc(path: string): Promise<string> {
|
||||
if (/^(?:https?|data):/i.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
// Stream audio/video through the custom protocol: data URLs are capped and
|
||||
// load the whole file into memory, which broke playback for larger videos.
|
||||
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
|
||||
return mediaStreamUrl(path)
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.readFileDataUrl) {
|
||||
return mediaExternalUrl(path)
|
||||
}
|
||||
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
|
||||
return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
|
||||
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
}
|
||||
|
||||
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
|
||||
@@ -278,10 +276,7 @@ const MarkdownTextImpl = () => {
|
||||
// render, which churns Streamdown's outer memo + propagates new prop
|
||||
// identities into every Block. The plugin set really only varies on
|
||||
// `isStreaming`, so memoize on that.
|
||||
const plugins = useMemo(
|
||||
() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }),
|
||||
[isStreaming]
|
||||
)
|
||||
const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming])
|
||||
|
||||
const components = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at
|
||||
// the render level means no timer logic (even a stale HMR one) can ever
|
||||
// scramble "CONN".
|
||||
const PREFIX = 'CONN'
|
||||
const TAIL = 'ECTING'
|
||||
// Even-weight mono ascii so cycling glyphs don't jump width (matches the
|
||||
// nousnet-web download-button decode effect).
|
||||
const SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
|
||||
const TICK_MS = 45
|
||||
|
||||
// Exit choreography (ms): text fades down + out, hold, then the overlay fades.
|
||||
const TEXT_OUT_MS = 360
|
||||
const POST_TEXT_HOLD_MS = 300
|
||||
const OVERLAY_OUT_MS = 520
|
||||
// Preview-only: how long to "connect" for, and the pause before replaying.
|
||||
const PREVIEW_CONNECT_MS = 2600
|
||||
const PREVIEW_REPLAY_MS = 1100
|
||||
|
||||
type Phase = 'live' | 'text-out' | 'overlay-out' | 'gone'
|
||||
|
||||
// Dev affordance: a warm Cmd+R reconnects almost instantly, so the overlay
|
||||
// only flashes. Load with `?connecting=1` to force a looping preview.
|
||||
function forcedPreview(): boolean {
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get('connecting') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function scrambledTail(resolvedCount: number): string {
|
||||
return Array.from(TAIL, (ch, i) =>
|
||||
i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0]
|
||||
).join('')
|
||||
}
|
||||
|
||||
export function GatewayConnectingOverlay() {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const boot = useStore($desktopBoot)
|
||||
const [previewing] = useState(forcedPreview)
|
||||
const [tail, setTail] = useState(TAIL)
|
||||
const [phase, setPhase] = useState<Phase>('live')
|
||||
|
||||
const connecting = gatewayState !== 'open' && !boot.error
|
||||
// Latches once we've actually shown the overlay, so the brief frame where
|
||||
// gatewayState flips to "open" (connecting -> false) before the exit phase
|
||||
// kicks in doesn't unmount us and cause a flash.
|
||||
const shownRef = useRef(false)
|
||||
|
||||
if (previewing || connecting) {
|
||||
shownRef.current = true
|
||||
}
|
||||
|
||||
// Decode loop — only while live (freeze the resolved word during the exit).
|
||||
useEffect(() => {
|
||||
if (phase !== 'live' || (!previewing && !connecting)) {
|
||||
return
|
||||
}
|
||||
|
||||
let resolved = 0
|
||||
let hold = 0
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
if (resolved >= TAIL.length) {
|
||||
hold += 1
|
||||
|
||||
if (hold > 16) {
|
||||
resolved = 0
|
||||
hold = 0
|
||||
}
|
||||
|
||||
setTail(TAIL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resolved += 0.5
|
||||
setTail(scrambledTail(Math.floor(resolved)))
|
||||
}, TICK_MS)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [phase, previewing, connecting])
|
||||
|
||||
// Kick off the exit when connected: real connect, or a faked timer in preview.
|
||||
useEffect(() => {
|
||||
if (phase !== 'live') {
|
||||
return
|
||||
}
|
||||
|
||||
if (previewing) {
|
||||
const id = window.setTimeout(() => {
|
||||
setTail(TAIL)
|
||||
setPhase('text-out')
|
||||
}, PREVIEW_CONNECT_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
if (gatewayState === 'open' && shownRef.current) {
|
||||
setTail(TAIL)
|
||||
setPhase('text-out')
|
||||
}
|
||||
}, [phase, previewing, gatewayState])
|
||||
|
||||
// Advance the exit choreography: text-out -> overlay-out -> gone.
|
||||
useEffect(() => {
|
||||
if (phase === 'text-out') {
|
||||
const id = window.setTimeout(() => setPhase('overlay-out'), TEXT_OUT_MS + POST_TEXT_HOLD_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
if (phase === 'overlay-out') {
|
||||
const id = window.setTimeout(() => setPhase('gone'), OVERLAY_OUT_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
// Preview replays so we can keep watching the transition.
|
||||
if (phase === 'gone' && previewing) {
|
||||
const id = window.setTimeout(() => {
|
||||
setTail(TAIL)
|
||||
setPhase('live')
|
||||
}, PREVIEW_REPLAY_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
}, [phase, previewing])
|
||||
|
||||
// Boot failed — BootFailureOverlay owns the screen; don't linger behind it.
|
||||
if (boot.error && !previewing) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Real connect: once the fade finishes, get out of the way for good.
|
||||
if (phase === 'gone' && !previewing) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Never showed (e.g. gateway already up on a warm reload) — stay out.
|
||||
if (!previewing && !connecting && !shownRef.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const leaving = phase !== 'live'
|
||||
const overlayHidden = phase === 'overlay-out' || phase === 'gone'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-[1200] grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
|
||||
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out',
|
||||
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
|
||||
)}
|
||||
>
|
||||
{PREFIX}
|
||||
{tail}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]"
|
||||
style={{ animation: 'gco-cursor 1s step-end infinite' }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -111,9 +111,13 @@ export class HermesGateway extends JsonRpcGatewayClient {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {
|
||||
export async function listSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude'
|
||||
): Promise<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}`
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -123,6 +127,14 @@ export async function listSessions(limit = 40, minMessages = 0): Promise<Paginat
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { archived }
|
||||
})
|
||||
}
|
||||
|
||||
export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
return window.hermesDesktop.api<SessionSearchResponse>({
|
||||
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
|
||||
|
||||
@@ -59,7 +59,7 @@ const DESKTOP_ALIASES = new Map([
|
||||
|
||||
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
|
||||
|
||||
const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider'])
|
||||
const PICKER_OWNED_COMMANDS = new Set(['/model'])
|
||||
|
||||
const TERMINAL_ONLY_COMMANDS = new Set([
|
||||
'/browser',
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
IconActivity as Activity,
|
||||
IconAlertCircle as AlertCircle,
|
||||
IconAlertTriangle as AlertTriangle,
|
||||
IconArchive as Archive,
|
||||
IconArchiveOff as ArchiveOff,
|
||||
IconArrowUp as ArrowUp,
|
||||
IconArrowUpRight as ArrowUpRight,
|
||||
IconAt as AtSign,
|
||||
@@ -98,6 +100,8 @@ export {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
ArchiveOff,
|
||||
ArrowUp,
|
||||
ArrowUpRight,
|
||||
AtSign,
|
||||
|
||||
@@ -58,6 +58,13 @@ export function mediaExternalUrl(path: string): string {
|
||||
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
|
||||
}
|
||||
|
||||
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
|
||||
// file with Range support. Used for audio/video so playback bypasses the data
|
||||
// URL size cap and supports seeking. `path` may be a plain path or `file://…`.
|
||||
export function mediaStreamUrl(path: string): string {
|
||||
return `hermes-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}`
|
||||
}
|
||||
|
||||
export function mediaPathFromMarkdownHref(href?: string): string | null {
|
||||
if (!href?.startsWith('#media:')) {
|
||||
return null
|
||||
|
||||
@@ -240,6 +240,7 @@ export interface SessionCreateResponse {
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
archived?: boolean
|
||||
cwd?: null | string
|
||||
ended_at: null | number
|
||||
id: string
|
||||
|
||||
@@ -2116,6 +2116,41 @@ def _cprint(text: str):
|
||||
pass
|
||||
|
||||
|
||||
def _prepend_note_to_message(message, note: str):
|
||||
"""Prepend a one-shot system-style note to a user message.
|
||||
|
||||
``message`` is normally a plain string, but when the user attaches an image
|
||||
to a vision-capable model it becomes a list of OpenAI-style content parts
|
||||
(text + ``image_url`` blocks). Naively doing ``note + "\\n\\n" + message``
|
||||
then raises ``TypeError: can only concatenate str (not "list") to str`` —
|
||||
e.g. running ``/model ...`` (which queues a model-switch note) and then
|
||||
sending a pasted image in the same turn.
|
||||
|
||||
Returns the message with ``note`` prepended:
|
||||
* ``str`` → ``f"{note}\\n\\n{message}"`` (just ``note`` when empty)
|
||||
* ``list`` → note folded into the first text part, or inserted as a new
|
||||
leading ``{"type": "text"}`` part when there is no text part.
|
||||
Unknown shapes are returned unchanged (fail-open).
|
||||
"""
|
||||
note = str(note or "").strip()
|
||||
if not note:
|
||||
return message
|
||||
if isinstance(message, str):
|
||||
return f"{note}\n\n{message}" if message else note
|
||||
if isinstance(message, list):
|
||||
parts = list(message)
|
||||
for i, part in enumerate(parts):
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
merged = dict(part)
|
||||
text = merged.get("text", "")
|
||||
merged["text"] = f"{note}\n\n{text}" if text else note
|
||||
parts[i] = merged
|
||||
return parts
|
||||
# No text part (image-only) — insert the note as a leading text block.
|
||||
return [{"type": "text", "text": note}, *parts]
|
||||
return message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-drop / local attachment detection — extracted as pure helpers for tests.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -12135,17 +12170,21 @@ class HermesCLI:
|
||||
reset_current_session_key = None # type: ignore[assignment]
|
||||
_approval_session_token = None
|
||||
agent_message = _voice_prefix + message if _voice_prefix else message
|
||||
# Prepend pending model switch note so the model knows about the switch
|
||||
# Prepend pending notes via _prepend_note_to_message, which
|
||||
# handles both plain-string and multimodal content-parts list
|
||||
# messages. Naive ``note + "\n\n" + agent_message`` crashed with
|
||||
# TypeError when an image was attached (agent_message is a list)
|
||||
# and a /model or /reload-skills note was queued for the turn.
|
||||
_msn = getattr(self, '_pending_model_switch_note', None)
|
||||
if _msn:
|
||||
agent_message = _msn + "\n\n" + agent_message
|
||||
agent_message = _prepend_note_to_message(agent_message, _msn)
|
||||
self._pending_model_switch_note = None
|
||||
# Prepend pending /reload-skills note so the model sees which
|
||||
# skills were added/removed before handling this turn. Same
|
||||
# one-shot queue pattern as the model-switch note above.
|
||||
_srn = getattr(self, '_pending_skills_reload_note', None)
|
||||
if _srn:
|
||||
agent_message = _srn + "\n\n" + agent_message
|
||||
agent_message = _prepend_note_to_message(agent_message, _srn)
|
||||
self._pending_skills_reload_note = None
|
||||
try:
|
||||
result = self.agent.run_conversation(
|
||||
|
||||
+27
-8
@@ -428,22 +428,18 @@ def load_jobs() -> List[Dict[str, Any]]:
|
||||
ensure_dirs()
|
||||
if not JOBS_FILE.exists():
|
||||
return []
|
||||
|
||||
|
||||
_strict_retry = False # track whether we used the strict=False fallback
|
||||
|
||||
try:
|
||||
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data.get("jobs", [])
|
||||
except json.JSONDecodeError:
|
||||
# Retry with strict=False to handle bare control chars in string values
|
||||
_strict_retry = True
|
||||
try:
|
||||
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.loads(f.read(), strict=False)
|
||||
jobs = data.get("jobs", [])
|
||||
if jobs:
|
||||
# Auto-repair: rewrite with proper escaping
|
||||
save_jobs(jobs)
|
||||
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
|
||||
return jobs
|
||||
except Exception as e:
|
||||
logger.error("Failed to auto-repair jobs.json: %s", e)
|
||||
raise RuntimeError(f"Cron database corrupted and unrepairable: {e}") from e
|
||||
@@ -451,6 +447,29 @@ def load_jobs() -> List[Dict[str, Any]]:
|
||||
logger.error("IOError reading jobs.json: %s", e)
|
||||
raise RuntimeError(f"Failed to read cron database: {e}") from e
|
||||
|
||||
# Validate the top-level JSON shape: accept a dict (expected) or a bare
|
||||
# list (auto-repair). Anything else (str/number/null) is corruption that
|
||||
# would otherwise raise an uncaught AttributeError on ``.get()`` and take
|
||||
# down the whole cron subsystem.
|
||||
if isinstance(data, dict):
|
||||
jobs = data.get("jobs", [])
|
||||
if _strict_retry and jobs:
|
||||
# Hit control-character corruption — rewrite with proper escaping.
|
||||
save_jobs(jobs)
|
||||
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
|
||||
return jobs
|
||||
if isinstance(data, list):
|
||||
# Bare array — likely saved/edited outside save_jobs(). Wrap it back
|
||||
# into the expected {"jobs": [...]} structure.
|
||||
if data:
|
||||
save_jobs(data)
|
||||
logger.warning("Auto-repaired jobs.json (bare list wrapped as dict)")
|
||||
return data
|
||||
|
||||
raise RuntimeError(
|
||||
f"Cron database corrupted: expected {{'jobs': [...]}}, got {type(data).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage."""
|
||||
|
||||
+13
-5
@@ -1182,14 +1182,22 @@ def _scan_assembled_cron_prompt(assembled: str, job: dict, *, has_skills: bool =
|
||||
markdown — often security docs / runbooks that *describe* attack
|
||||
commands in prose. The LOOSER ``_scan_cron_skill_assembled``
|
||||
pattern set is used: only unambiguous prompt-injection directives
|
||||
and invisible unicode block, command-shape patterns are dropped
|
||||
to avoid false-positives. Skill bodies are vetted at install time
|
||||
by ``skills_guard.py``.
|
||||
block; command-shape patterns are dropped and invisible unicode is
|
||||
sanitized (stripped + logged) rather than blocked, to avoid
|
||||
false-positives that permanently kill a job. Skill bodies are
|
||||
vetted at install time by ``skills_guard.py``.
|
||||
"""
|
||||
from tools.cronjob_tools import _scan_cron_prompt, _scan_cron_skill_assembled
|
||||
|
||||
scanner = _scan_cron_skill_assembled if has_skills else _scan_cron_prompt
|
||||
scan_error = scanner(assembled)
|
||||
if has_skills:
|
||||
# Skill content is install-time vetted by skills_guard.py. Invisible
|
||||
# unicode is sanitized (not blocked) so a stray zero-width space in a
|
||||
# skill code example can't permanently kill the job; the cleaned
|
||||
# prompt is what actually runs.
|
||||
cleaned, scan_error = _scan_cron_skill_assembled(assembled)
|
||||
assembled = cleaned
|
||||
else:
|
||||
scan_error = _scan_cron_prompt(assembled)
|
||||
if scan_error:
|
||||
job_label = job.get("name") or job.get("id") or "<unknown>"
|
||||
logger.warning(
|
||||
|
||||
@@ -27,10 +27,20 @@ drop() { [ "$(id -u)" = 0 ] && set -- s6-setuidgid hermes "$@"; exec "$@"; }
|
||||
# don't try to write to /root.
|
||||
export HOME=/opt/data
|
||||
|
||||
# Save the Docker -w (or default) working directory before init
|
||||
# scripts cd to /opt/data, so the container starts in the
|
||||
# directory the user requested.
|
||||
_hermes_orig_cwd="${HERMES_ORIG_CWD:-$PWD}"
|
||||
|
||||
cd /opt/data
|
||||
# shellcheck disable=SC1091
|
||||
. /opt/hermes/.venv/bin/activate
|
||||
|
||||
# Restore the original working directory before handing off to
|
||||
# the user's command so `hermes chat` starts in the Docker -w
|
||||
# directory, not /opt/data.
|
||||
cd "$_hermes_orig_cwd"
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
drop hermes
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Multi-gateway deployment
|
||||
|
||||
Hermes supports multiple gateway processes running concurrently — one per profile
|
||||
(default, writer, admin, coder, researcher). Each gateway opens its own connection
|
||||
to platform APIs and delivers messages for its profile's subscribers.
|
||||
|
||||
## Single-dispatcher posture
|
||||
|
||||
Only one gateway owns the kanban dispatcher. The owning gateway keeps
|
||||
`kanban.dispatch_in_gateway: true` (the default); every other gateway sets it
|
||||
to `false`.
|
||||
|
||||
**Why this matters:** a gateway with `dispatch_in_gateway: true` opens per-board
|
||||
SQLite connections for both the dispatcher and the notifier watcher. Multiple
|
||||
gateways doing this concurrently multiplies the open file descriptors on each
|
||||
`kanban.db` and amplifies WAL `-shm` reader contention. Gating both paths on the
|
||||
same flag means exactly one process touches the kanban DBs.
|
||||
|
||||
## Configuration
|
||||
|
||||
On the dispatch-owning gateway (typically the `default` profile), no change is
|
||||
needed. On every other profile gateway, add to `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
kanban:
|
||||
dispatch_in_gateway: false
|
||||
```
|
||||
|
||||
Or set the env var: `HERMES_KANBAN_DISPATCH_IN_GATEWAY=false`
|
||||
|
||||
## What each gateway does
|
||||
|
||||
| Gateway role | dispatch_in_gateway | Opens per-board DBs? | Runs dispatcher + notifier? |
|
||||
|---|---|---|---|
|
||||
| default (dispatch owner) | true (default) | yes | yes |
|
||||
| writer, admin, coder, etc. | false | no | no |
|
||||
|
||||
Non-dispatch gateways still deliver messages for their own platform adapters
|
||||
(Telegram, Discord, etc.) — they just don't poll kanban boards.
|
||||
@@ -1722,6 +1722,22 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
"webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"),
|
||||
"send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in {"true", "1", "yes"},
|
||||
})
|
||||
bluebubbles_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
|
||||
if bluebubbles_require_mention is not None:
|
||||
config.platforms[Platform.BLUEBUBBLES].extra["require_mention"] = (
|
||||
bluebubbles_require_mention.lower() in {"true", "1", "yes", "on"}
|
||||
)
|
||||
bluebubbles_mention_patterns = os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
|
||||
if bluebubbles_mention_patterns:
|
||||
try:
|
||||
parsed_patterns = json.loads(bluebubbles_mention_patterns)
|
||||
except Exception:
|
||||
parsed_patterns = [
|
||||
part.strip()
|
||||
for part in bluebubbles_mention_patterns.replace("\n", ",").split(",")
|
||||
if part.strip()
|
||||
]
|
||||
config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = parsed_patterns
|
||||
bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL")
|
||||
if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms:
|
||||
config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel(
|
||||
|
||||
+160
-12
@@ -1265,6 +1265,107 @@ def cleanup_document_cache(max_age_hours: int = 24) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unified media caching
|
||||
#
|
||||
# One entry point for "I have raw attachment bytes from a platform — cache them
|
||||
# and tell me what I got." Classifies by extension/MIME against the shared
|
||||
# registries above, routes to the right cache_*_from_bytes helper, and returns
|
||||
# a small result the caller can store and/or describe in a transcript. Used by
|
||||
# both the addressed-message path and the observed-group-context path, on any
|
||||
# platform — not Telegram-specific.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CachedMedia:
|
||||
"""Result of caching one attachment's bytes."""
|
||||
|
||||
path: str # absolute cache path, agent-visible (sandbox-translated)
|
||||
media_type: str # MIME type recorded on the MessageEvent
|
||||
kind: str # "image" | "video" | "audio" | "document"
|
||||
display_name: str # human-readable name for transcript notes
|
||||
|
||||
def context_note(self) -> str:
|
||||
"""One-line transcript annotation pointing the agent at the file."""
|
||||
return f"[{self.kind} '{self.display_name}' saved at: {self.path}]"
|
||||
|
||||
|
||||
def _resolve_media_ext(filename: str, mime_type: str) -> str:
|
||||
"""Best-effort file extension from filename, then MIME fallback."""
|
||||
if filename:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext:
|
||||
return ext
|
||||
mime = (mime_type or "").lower()
|
||||
if not mime:
|
||||
return ""
|
||||
for table in (
|
||||
SUPPORTED_IMAGE_DOCUMENT_TYPES,
|
||||
SUPPORTED_VIDEO_TYPES,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
):
|
||||
for ext, m in table.items():
|
||||
if m == mime:
|
||||
return ext
|
||||
return ""
|
||||
|
||||
|
||||
def cache_media_bytes(
|
||||
data: bytes,
|
||||
*,
|
||||
filename: str = "",
|
||||
mime_type: str = "",
|
||||
default_kind: Optional[str] = None,
|
||||
) -> Optional[CachedMedia]:
|
||||
"""Classify and cache raw attachment bytes; return a CachedMedia or None.
|
||||
|
||||
``default_kind`` ("image"/"video"/"audio"/"document") biases classification
|
||||
when the extension/MIME are ambiguous — e.g. a Telegram native photo whose
|
||||
file has no usable name. Unsupported document types return None so the
|
||||
caller can record an "unsupported" note. Images that fail validation
|
||||
(``cache_image_from_bytes`` raises ValueError) also return None.
|
||||
"""
|
||||
from tools.credential_files import to_agent_visible_cache_path
|
||||
|
||||
ext = _resolve_media_ext(filename, mime_type)
|
||||
mime = (mime_type or "").lower()
|
||||
display = re.sub(r"[^\w.\- ]", "_", filename) if filename else (ext.lstrip(".") or "file")
|
||||
|
||||
is_image = (
|
||||
mime.startswith("image/")
|
||||
or ext in SUPPORTED_IMAGE_DOCUMENT_TYPES
|
||||
or default_kind == "image"
|
||||
)
|
||||
is_video = mime.startswith("video/") or ext in SUPPORTED_VIDEO_TYPES or default_kind == "video"
|
||||
is_audio = mime.startswith("audio/") or default_kind == "audio"
|
||||
|
||||
if is_image:
|
||||
img_ext = ext if ext in SUPPORTED_IMAGE_DOCUMENT_TYPES else ".jpg"
|
||||
try:
|
||||
path = cache_image_from_bytes(data, ext=img_ext)
|
||||
except ValueError:
|
||||
return None
|
||||
out_mime = mime if mime.startswith("image/") else SUPPORTED_IMAGE_DOCUMENT_TYPES.get(img_ext, "image/jpeg")
|
||||
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "image", display)
|
||||
|
||||
if is_video:
|
||||
vid_ext = ext if ext in SUPPORTED_VIDEO_TYPES else ".mp4"
|
||||
path = cache_video_from_bytes(data, ext=vid_ext)
|
||||
return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_VIDEO_TYPES.get(vid_ext, "video/mp4"), "video", display)
|
||||
|
||||
if is_audio:
|
||||
aud_ext = ext if ext in {".ogg", ".mp3", ".wav", ".m4a", ".opus", ".flac"} else ".ogg"
|
||||
path = cache_audio_from_bytes(data, ext=aud_ext)
|
||||
out_mime = mime if mime.startswith("audio/") else f"audio/{aud_ext.lstrip('.')}"
|
||||
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "audio", display)
|
||||
|
||||
if ext not in SUPPORTED_DOCUMENT_TYPES:
|
||||
return None
|
||||
|
||||
path = cache_document_from_bytes(data, filename or f"document{ext}")
|
||||
return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_DOCUMENT_TYPES[ext], "document", display or f"document{ext}")
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
"""Types of incoming messages."""
|
||||
TEXT = "text"
|
||||
@@ -1644,6 +1745,22 @@ def resolve_channel_skills(
|
||||
return None
|
||||
|
||||
|
||||
def _strip_media_directives(text: str) -> str:
|
||||
"""Strip internal delivery directives ([[audio_as_voice]], [[as_document]],
|
||||
MEDIA:<path>) so they never render as visible text.
|
||||
|
||||
Backstop only: run ``extract_media`` first. MEDIA cleanup uses the shared
|
||||
``MEDIA_TAG_CLEANUP_RE`` (only tags whose path has a known deliverable
|
||||
extension are removed; an unknown-extension tag is intentionally left so the
|
||||
bare-path detector downstream can still pick it up, per #34517). [[...]] is
|
||||
exact.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
text = text.replace("[[audio_as_voice]]", "").replace("[[as_document]]", "")
|
||||
return MEDIA_TAG_CLEANUP_RE.sub("", text)
|
||||
|
||||
|
||||
class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
Base class for platform adapters.
|
||||
@@ -1734,8 +1851,8 @@ class BasePlatformAdapter(ABC):
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Whether this adapter gates inbound access before dispatch.
|
||||
|
||||
Some adapters (WeCom, Weixin, Yuanbao, QQBot) implement a documented
|
||||
config-driven access surface — ``dm_policy`` / ``group_policy`` /
|
||||
Some adapters (WeCom, Weixin, Yuanbao, QQBot, WhatsApp) implement a
|
||||
documented config-driven access surface — ``dm_policy`` / ``group_policy`` /
|
||||
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra`` — and
|
||||
enforce it at intake: a message is dropped inside the adapter and never
|
||||
reaches the gateway unless it already passed that policy.
|
||||
@@ -3884,21 +4001,20 @@ class BasePlatformAdapter(ABC):
|
||||
# where Telegram's sendPhoto recompression destroys legibility.
|
||||
force_document_attachments = "[[as_document]]" in response
|
||||
|
||||
# Pre-extract snapshot for the #29346 recovery/invariant below.
|
||||
_response_pre_extract = response
|
||||
|
||||
# Extract MEDIA:<path> tags (from TTS tool) before other processing
|
||||
media_files, response = self.extract_media(response)
|
||||
media_files = self.filter_media_delivery_paths(media_files)
|
||||
|
||||
# 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()
|
||||
# Strip only MEDIA: tags whose path has a deliverable extension
|
||||
# (shared MEDIA_TAG_CLEANUP_RE). A MEDIA: tag with an unknown
|
||||
# extension is intentionally left in the body so extract_local_files
|
||||
# below can still pick up the bare path — otherwise the file would
|
||||
# be silently dropped (issue #34517).
|
||||
text_content = MEDIA_TAG_CLEANUP_RE.sub("", text_content).strip()
|
||||
# Strip any remaining internal directives from message body (fixes #1561).
|
||||
# _strip_media_directives shares MEDIA_TAG_CLEANUP_RE, so a MEDIA: tag
|
||||
# with an unknown extension is intentionally left in the body for
|
||||
# extract_local_files below to pick up rather than silently dropped (#34517).
|
||||
text_content = _strip_media_directives(text_content).strip()
|
||||
if images:
|
||||
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
|
||||
|
||||
@@ -3912,7 +4028,25 @@ class BasePlatformAdapter(ABC):
|
||||
local_files = self.filter_local_delivery_paths(local_files)
|
||||
if local_files:
|
||||
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
||||
|
||||
|
||||
# A2 (#29346): extraction can reduce a non-empty response to
|
||||
# empty text with no attachment, and the `if text_content` guard
|
||||
# below then drops it silently. Recover on every platform (#33842
|
||||
# was Discord-only); the guard avoids duplicating an attachment.
|
||||
if not (text_content or images or local_files or media_files):
|
||||
# Recover from the post-extract_media `response`, not the raw
|
||||
# snapshot: extract_media already stripped MEDIA (incl. spaced
|
||||
# paths) with its full grammar, so no fragment can leak.
|
||||
_recovered = _strip_media_directives(response).strip()
|
||||
if _recovered:
|
||||
logger.warning(
|
||||
"[%s] response_delivery_recovered: extract pipeline "
|
||||
"reduced a non-empty response (%d chars) to empty with "
|
||||
"no attachment; delivering recovered original to %s",
|
||||
self.name, len(_response_pre_extract), event.source.chat_id,
|
||||
)
|
||||
text_content = _recovered
|
||||
|
||||
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
|
||||
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
|
||||
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
|
||||
@@ -4110,6 +4244,20 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception as file_err:
|
||||
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
|
||||
|
||||
# A3 (#29346): if a non-empty response produced nothing
|
||||
# deliverable, fail loudly rather than dropping it in silence.
|
||||
_anything_delivered = (
|
||||
delivery_attempted or _tts_caption_delivered
|
||||
or images or local_files or media_files
|
||||
)
|
||||
if not _anything_delivered and _response_pre_extract.strip():
|
||||
logger.error(
|
||||
"[%s] response_delivery_dropped: non-empty response "
|
||||
"(%d chars) produced no delivered message or attachment "
|
||||
"for %s (empty after extract, recovery yielded nothing).",
|
||||
self.name, len(_response_pre_extract), event.source.chat_id,
|
||||
)
|
||||
|
||||
# Determine overall success for the processing hook
|
||||
processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
|
||||
await self._run_processing_hook(
|
||||
|
||||
@@ -44,6 +44,15 @@ DEFAULT_WEBHOOK_PORT = 8645
|
||||
DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"
|
||||
MAX_TEXT_LENGTH = 4000
|
||||
|
||||
# BlueBubbles/iMessage does not expose a stable bot mention identity like
|
||||
# Slack (<@U...>), Telegram (@botname), or Matrix (MXID). When users opt into
|
||||
# group mention gating without custom aliases, use conservative Hermes wake
|
||||
# words so `require_mention: true` is a one-line enablement path.
|
||||
DEFAULT_MENTION_PATTERNS = [
|
||||
r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
|
||||
r"(?<![\w@])@?hermes\b[,:\-]?",
|
||||
]
|
||||
|
||||
# Tapback reaction codes (BlueBubbles associatedMessageType values)
|
||||
_TAPBACK_ADDED = {
|
||||
2000: "love", 2001: "like", 2002: "dislike",
|
||||
@@ -127,6 +136,15 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if not str(self.webhook_path).startswith("/"):
|
||||
self.webhook_path = f"/{self.webhook_path}"
|
||||
self.send_read_receipts = bool(extra.get("send_read_receipts", True))
|
||||
_require_mention = extra.get("require_mention")
|
||||
if _require_mention is None:
|
||||
_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
|
||||
self.require_mention = str(_require_mention).strip().lower() in {"true", "1", "yes", "on"}
|
||||
self._mention_patterns = self._compile_mention_patterns(
|
||||
extra["mention_patterns"]
|
||||
if "mention_patterns" in extra
|
||||
else os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
|
||||
)
|
||||
self.client: Optional[httpx.AsyncClient] = None
|
||||
self._runner = None
|
||||
self._private_api_enabled: Optional[bool] = None
|
||||
@@ -141,6 +159,62 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
sep = "&" if "?" in path else "?"
|
||||
return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}"
|
||||
|
||||
@staticmethod
|
||||
def _compile_mention_patterns(raw: Any) -> List[re.Pattern]:
|
||||
"""Compile group-mention wake words from config/env.
|
||||
|
||||
``raw`` is a list (from config or env JSON), a string (raw env var:
|
||||
JSON list, or comma/newline-separated), or None (use Hermes defaults).
|
||||
"""
|
||||
if raw is None:
|
||||
patterns = list(DEFAULT_MENTION_PATTERNS)
|
||||
elif isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
try:
|
||||
loaded = json.loads(text) if text else []
|
||||
except Exception:
|
||||
loaded = None
|
||||
patterns = loaded if isinstance(loaded, list) else [
|
||||
part.strip()
|
||||
for line in text.splitlines()
|
||||
for part in line.split(",")
|
||||
]
|
||||
elif isinstance(raw, list):
|
||||
patterns = raw
|
||||
else:
|
||||
patterns = [raw]
|
||||
|
||||
compiled: List["re.Pattern"] = []
|
||||
for pattern in patterns:
|
||||
text = str(pattern).strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
compiled.append(re.compile(text, re.IGNORECASE))
|
||||
except re.error as exc:
|
||||
logger.warning("[bluebubbles] Invalid mention pattern %r: %s", text, exc)
|
||||
return compiled
|
||||
|
||||
def _message_matches_mention_patterns(self, text: str) -> bool:
|
||||
if not text or not self._mention_patterns:
|
||||
return False
|
||||
return any(pattern.search(text) for pattern in self._mention_patterns)
|
||||
|
||||
def _clean_mention_text(self, text: str) -> str:
|
||||
"""Strip a leading BlueBubbles wake word before dispatch.
|
||||
|
||||
Custom mention patterns are regular expressions, so stripping only a
|
||||
leading match avoids deleting ordinary words later in the prompt.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
for pattern in self._mention_patterns:
|
||||
match = pattern.match(text.lstrip())
|
||||
if match:
|
||||
cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
|
||||
return cleaned or text
|
||||
return text
|
||||
|
||||
async def _api_get(self, path: str) -> Dict[str, Any]:
|
||||
assert self.client is not None
|
||||
res = await self.client.get(self._api_url(path))
|
||||
@@ -921,6 +995,13 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
|
||||
session_chat_id = chat_guid or chat_identifier
|
||||
is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or ""))
|
||||
if is_group and self.require_mention:
|
||||
if not self._message_matches_mention_patterns(text):
|
||||
logger.debug(
|
||||
"[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)"
|
||||
)
|
||||
return web.Response(text="ok")
|
||||
text = self._clean_mention_text(text)
|
||||
source = self.build_source(
|
||||
chat_id=session_chat_id,
|
||||
chat_name=chat_identifier or sender,
|
||||
|
||||
+109
-32
@@ -4918,13 +4918,109 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
channel_prompt=channel_prompt,
|
||||
)
|
||||
|
||||
def _observe_unmentioned_group_message(self, message: Message, msg_type: MessageType, update_id: Optional[int] = None) -> None:
|
||||
def _media_message_type(self, msg: Message) -> MessageType:
|
||||
"""Classify a Telegram media message into a MessageType."""
|
||||
if msg.sticker:
|
||||
return MessageType.STICKER
|
||||
if msg.photo:
|
||||
return MessageType.PHOTO
|
||||
if msg.video:
|
||||
return MessageType.VIDEO
|
||||
if msg.audio:
|
||||
return MessageType.AUDIO
|
||||
if msg.voice:
|
||||
return MessageType.VOICE
|
||||
return MessageType.DOCUMENT
|
||||
|
||||
async def _cache_observed_media(self, msg: Message, event: MessageEvent) -> None:
|
||||
"""Cache an unmentioned group attachment and annotate the observed text.
|
||||
|
||||
Passive group traffic, so downloads are bounded by the same
|
||||
``_max_doc_bytes`` limit as the addressed document path. Oversized or
|
||||
unsupported attachments are noted in the transcript without downloading.
|
||||
"""
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
|
||||
source, filename, mime, kind = self._observed_media_source(msg)
|
||||
if source is None:
|
||||
return
|
||||
|
||||
max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)
|
||||
file_size = getattr(source, "file_size", None)
|
||||
try:
|
||||
size = int(file_size or 0)
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
if not (0 < size <= max_bytes):
|
||||
limit_mb = max_bytes // (1024 * 1024)
|
||||
event.text = self._append_observed_note(
|
||||
event.text,
|
||||
f"[Observed Telegram attachment too large or unverifiable. Maximum: {limit_mb} MB.]",
|
||||
)
|
||||
logger.info("[Telegram] Observed group attachment skipped (size=%s)", file_size)
|
||||
return
|
||||
|
||||
try:
|
||||
file_obj = await source.get_file()
|
||||
data = bytes(await file_obj.download_as_bytearray())
|
||||
if not filename:
|
||||
filename = os.path.basename(getattr(file_obj, "file_path", "") or "")
|
||||
cached = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind)
|
||||
except Exception as exc:
|
||||
logger.warning("[Telegram] Failed to cache observed group media: %s", exc, exc_info=True)
|
||||
return
|
||||
|
||||
if cached is None:
|
||||
event.text = self._append_observed_note(
|
||||
event.text, "[Observed Telegram attachment: unsupported type, not cached.]"
|
||||
)
|
||||
return
|
||||
|
||||
event.media_urls = [cached.path]
|
||||
event.media_types = [cached.media_type]
|
||||
if cached.kind == "image":
|
||||
event.message_type = MessageType.PHOTO
|
||||
elif cached.kind == "video":
|
||||
event.message_type = MessageType.VIDEO
|
||||
event.text = self._append_observed_note(event.text, cached.context_note())
|
||||
logger.info("[Telegram] Cached observed group %s at %s", cached.kind, cached.path)
|
||||
|
||||
def _observed_media_source(self, msg: Message):
|
||||
"""Return (telegram_file_source, filename, mime, default_kind) or Nones."""
|
||||
if msg.photo:
|
||||
return msg.photo[-1], "", "", "image"
|
||||
if msg.video:
|
||||
return msg.video, "", "video/mp4", "video"
|
||||
if msg.voice:
|
||||
return msg.voice, "voice.ogg", "audio/ogg", "audio"
|
||||
if msg.audio:
|
||||
return msg.audio, getattr(msg.audio, "file_name", "") or "", "", "audio"
|
||||
if msg.document:
|
||||
doc = msg.document
|
||||
return doc, doc.file_name or "", (doc.mime_type or "").lower(), None
|
||||
return None, "", "", None
|
||||
|
||||
@staticmethod
|
||||
def _append_observed_note(existing: Optional[str], note: str) -> str:
|
||||
if not note:
|
||||
return existing or ""
|
||||
if not existing:
|
||||
return note
|
||||
return f"{existing}\n\n{note}"
|
||||
|
||||
def _observe_unmentioned_group_message(
|
||||
self,
|
||||
message: Message,
|
||||
msg_type: MessageType,
|
||||
update_id: Optional[int] = None,
|
||||
event: Optional[MessageEvent] = None,
|
||||
) -> None:
|
||||
"""Append skipped group chatter to the target session without dispatching."""
|
||||
store = getattr(self, "_session_store", None)
|
||||
if not store:
|
||||
return
|
||||
try:
|
||||
event = self._build_message_event(message, msg_type, update_id=update_id)
|
||||
event = event or self._build_message_event(message, msg_type, update_id=update_id)
|
||||
shared_source = self._telegram_group_observe_shared_source(event.source)
|
||||
session_entry = store.get_or_create_session(shared_source)
|
||||
entry = {
|
||||
@@ -5285,39 +5381,20 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if not self._should_process_message(update.message):
|
||||
if self._should_observe_unmentioned_group_message(update.message):
|
||||
_m = update.message
|
||||
if _m.sticker:
|
||||
_observe_type = MessageType.STICKER
|
||||
elif _m.photo:
|
||||
_observe_type = MessageType.PHOTO
|
||||
elif _m.video:
|
||||
_observe_type = MessageType.VIDEO
|
||||
elif _m.audio:
|
||||
_observe_type = MessageType.AUDIO
|
||||
elif _m.voice:
|
||||
_observe_type = MessageType.VOICE
|
||||
else:
|
||||
_observe_type = MessageType.DOCUMENT
|
||||
self._observe_unmentioned_group_message(_m, _observe_type, update_id=update.update_id)
|
||||
_observe_type = self._media_message_type(_m)
|
||||
_event = self._build_message_event(_m, _observe_type, update_id=update.update_id)
|
||||
if _m.caption:
|
||||
_event.text = self._clean_bot_trigger_text(_m.caption)
|
||||
await self._cache_observed_media(_m, _event)
|
||||
self._observe_unmentioned_group_message(
|
||||
_m, _event.message_type, update_id=update.update_id, event=_event
|
||||
)
|
||||
return
|
||||
|
||||
msg = update.message
|
||||
|
||||
# Determine media type
|
||||
if msg.sticker:
|
||||
msg_type = MessageType.STICKER
|
||||
elif msg.photo:
|
||||
msg_type = MessageType.PHOTO
|
||||
elif msg.video:
|
||||
msg_type = MessageType.VIDEO
|
||||
elif msg.audio:
|
||||
msg_type = MessageType.AUDIO
|
||||
elif msg.voice:
|
||||
msg_type = MessageType.VOICE
|
||||
elif msg.document:
|
||||
msg_type = MessageType.DOCUMENT
|
||||
else:
|
||||
msg_type = MessageType.DOCUMENT
|
||||
|
||||
|
||||
msg_type = self._media_message_type(msg)
|
||||
|
||||
event = self._build_message_event(msg, msg_type, update_id=update.update_id)
|
||||
|
||||
# Add caption as text
|
||||
|
||||
@@ -364,6 +364,15 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
{"error": f"Unknown route: {route_name}"}, status=404
|
||||
)
|
||||
|
||||
# Disabled routes are kept in the subscriptions file (so the dashboard
|
||||
# can re-enable them) but reject incoming events. Default-enabled:
|
||||
# only an explicit ``enabled: false`` turns a route off, matching the
|
||||
# mcp_servers ``enabled`` semantics.
|
||||
if route_config.get("enabled", True) is False:
|
||||
return web.json_response(
|
||||
{"error": f"Route disabled: {route_name}"}, status=403
|
||||
)
|
||||
|
||||
# ── Auth-before-body ─────────────────────────────────────
|
||||
# Check Content-Length before reading the full payload.
|
||||
content_length = request.content_length or 0
|
||||
|
||||
@@ -161,7 +161,15 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
).strip() or DEFAULT_WS_URL
|
||||
|
||||
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WECOM_DM_POLICY", "open")).strip().lower()
|
||||
self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom"))
|
||||
# dm_policy already honors WECOM_DM_POLICY, so the allowlist must honor
|
||||
# WECOM_ALLOWED_USERS too. Without the env fallback an env-only setup
|
||||
# (dm_policy=allowlist via env, no config extra) runs with an empty
|
||||
# allowlist and drops every authorized DM at intake.
|
||||
self._allow_from = _coerce_list(
|
||||
extra.get("allow_from")
|
||||
or extra.get("allowFrom")
|
||||
or os.getenv("WECOM_ALLOWED_USERS", "")
|
||||
)
|
||||
|
||||
self._group_policy = str(extra.get("group_policy") or os.getenv("WECOM_GROUP_POLICY", "open")).strip().lower()
|
||||
self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom"))
|
||||
|
||||
+20
-12
@@ -378,12 +378,16 @@ async def _api_post(
|
||||
) -> Dict[str, Any]:
|
||||
body = _json_dumps({**payload, "base_info": _base_info()})
|
||||
url = f"{base_url.rstrip('/')}/{endpoint}"
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
|
||||
async with session.post(url, data=body, headers=_headers(token, body), timeout=timeout) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
# 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() -> Dict[str, Any]:
|
||||
async with session.post(url, data=body, headers=_headers(token, body)) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
|
||||
|
||||
|
||||
async def _api_get(
|
||||
@@ -398,12 +402,16 @@ async def _api_get(
|
||||
"iLink-App-Id": ILINK_APP_ID,
|
||||
"iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION),
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
|
||||
async with session.get(url, headers=headers, timeout=timeout) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
# 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() -> Dict[str, Any]:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
|
||||
|
||||
|
||||
async def _get_updates(
|
||||
|
||||
@@ -379,6 +379,11 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
"""Check whether a DM from the given sender should be processed."""
|
||||
if self._dm_policy == "disabled":
|
||||
|
||||
+26
-2
@@ -5121,6 +5121,30 @@ class GatewayRunner:
|
||||
cross boards, so delivery semantics are unchanged — this is
|
||||
purely a fan-out of the single-DB poll.
|
||||
"""
|
||||
# Gate: only the dispatch-owning gateway opens kanban DBs for notifier polling.
|
||||
# Non-dispatch gateways have no subscriptions to deliver — all kanban state lives
|
||||
# in the dispatch owner's per-board DBs. This prevents N-gateway -shm contention.
|
||||
# TODO: gate per-board when per-board dispatcher_owner tracking lands.
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
except Exception:
|
||||
logger.warning("kanban notifier: config loader unavailable; disabled")
|
||||
return
|
||||
env_override = os.environ.get("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "").strip().lower()
|
||||
if env_override in {"0", "false", "no", "off"}:
|
||||
logger.info("kanban notifier: disabled via HERMES_KANBAN_DISPATCH_IN_GATEWAY env")
|
||||
return
|
||||
try:
|
||||
cfg = _load_config()
|
||||
except Exception as exc:
|
||||
logger.warning("kanban notifier: cannot load config (%s); disabled", exc)
|
||||
return
|
||||
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
|
||||
if not kanban_cfg.get("dispatch_in_gateway", True):
|
||||
logger.info(
|
||||
"kanban notifier: disabled via config kanban.dispatch_in_gateway=false"
|
||||
)
|
||||
return
|
||||
from gateway.config import Platform as _Platform
|
||||
try:
|
||||
from hermes_cli import kanban_db as _kb
|
||||
@@ -6820,8 +6844,8 @@ class GatewayRunner:
|
||||
"""Whether the adapter for *platform* gates access at intake itself.
|
||||
|
||||
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
|
||||
such as WeCom, Weixin, Yuanbao, and QQBot evaluate their documented
|
||||
``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
|
||||
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
|
||||
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
|
||||
message is dispatched to the gateway, so a message that reaches
|
||||
``_is_user_authorized`` has already been authorized by the adapter.
|
||||
Defaults to ``False`` when the adapter is unknown or doesn't expose
|
||||
|
||||
@@ -261,6 +261,12 @@ class GatewayStreamConsumer:
|
||||
self._last_sent_text = ""
|
||||
self._fallback_final_send = False
|
||||
self._fallback_prefix = ""
|
||||
# #29346: a tool/segment boundary means what we delivered was an interim
|
||||
# preamble, not the final answer — clear the flags so a premature setter
|
||||
# can't fool the gateway. Safe: got_done returns before any reset, and
|
||||
# run.py reads these only after the consumer task exits.
|
||||
self._final_response_sent = False
|
||||
self._final_content_delivered = False
|
||||
# Native draft streaming: bump the draft_id so the next text segment
|
||||
# animates as a fresh preview below the tool-progress bubbles, not
|
||||
# over the prior segment's already-finalized draft. This is how
|
||||
@@ -549,6 +555,9 @@ class GatewayStreamConsumer:
|
||||
current_update_visible = await self._send_or_edit(
|
||||
display_text,
|
||||
finalize=(got_done or got_segment_break),
|
||||
# A segment-break finalize closes a preamble, not the
|
||||
# turn-final answer — only got_done marks delivered (#29346).
|
||||
is_turn_final=got_done,
|
||||
)
|
||||
self._last_edit_time = time.monotonic()
|
||||
|
||||
@@ -1058,12 +1067,17 @@ class GatewayStreamConsumer:
|
||||
age = time.monotonic() - self._message_created_ts
|
||||
return age >= threshold
|
||||
|
||||
async def _try_fresh_final(self, text: str) -> bool:
|
||||
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
|
||||
"""Send ``text`` as a brand-new message (best-effort delete the old
|
||||
preview) so the platform's visible timestamp reflects completion
|
||||
time. Returns True on successful delivery, False on any failure so
|
||||
the caller falls back to the normal edit path.
|
||||
|
||||
``is_turn_final`` is False when finalizing an interim segment at a tool
|
||||
boundary (a preamble) rather than the turn-final answer; the
|
||||
final-delivery flag is then left unset so the gateway still delivers the
|
||||
real answer from the next API call (#29346).
|
||||
|
||||
Ported from openclaw/openclaw#72038.
|
||||
"""
|
||||
old_message_id = self._message_id
|
||||
@@ -1108,10 +1122,13 @@ class GatewayStreamConsumer:
|
||||
self._message_created_ts = None
|
||||
self._already_sent = True
|
||||
self._last_sent_text = text
|
||||
self._final_response_sent = True
|
||||
if is_turn_final:
|
||||
self._final_response_sent = True
|
||||
return True
|
||||
|
||||
async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
|
||||
async def _send_or_edit(
|
||||
self, text: str, *, finalize: bool = False, is_turn_final: bool = True,
|
||||
) -> bool:
|
||||
"""Send or edit the streaming message.
|
||||
|
||||
Returns True if the text was successfully delivered (sent or edited),
|
||||
@@ -1205,7 +1222,9 @@ class GatewayStreamConsumer:
|
||||
if (
|
||||
finalize
|
||||
and self._should_send_fresh_final()
|
||||
and await self._try_fresh_final(text)
|
||||
and await self._try_fresh_final(
|
||||
text, is_turn_final=is_turn_final,
|
||||
)
|
||||
):
|
||||
return True
|
||||
# Edit existing message
|
||||
|
||||
@@ -6165,6 +6165,7 @@ def _prompt_model_selection(
|
||||
selected=default_idx,
|
||||
cancel_returns=-1,
|
||||
description=description,
|
||||
searchable=True,
|
||||
)
|
||||
if idx < 0:
|
||||
return None
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ def _warn_if_gateway_running(auto_yes: bool) -> None:
|
||||
"conflicts (Telegram, Discord, and Slack only allow one active "
|
||||
"session per token)."
|
||||
)
|
||||
print_info("Recommendation: stop the gateway first with 'hermes stop'.")
|
||||
print_info("Recommendation: stop the gateway first with 'hermes gateway stop'.")
|
||||
print()
|
||||
if not auto_yes and not prompt_yes_no("Continue anyway?", default=False):
|
||||
print_info("Migration cancelled. Stop the gateway and try again.")
|
||||
|
||||
@@ -124,7 +124,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("config", "Show current configuration", "Configuration",
|
||||
cli_only=True),
|
||||
CommandDef("model", "Switch model for this session", "Configuration",
|
||||
aliases=("provider",), args_hint="[model] [--provider name] [--global] [--refresh]"),
|
||||
args_hint="[model] [--provider name] [--global] [--refresh]"),
|
||||
CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models",
|
||||
"Configuration", aliases=("codex_runtime",),
|
||||
args_hint="[auto|codex_app_server]"),
|
||||
|
||||
@@ -105,7 +105,9 @@ _hermes_profiles() {{
|
||||
local profiles_dir="$HOME/.hermes/profiles"
|
||||
local profiles="default"
|
||||
if [ -d "$profiles_dir" ]; then
|
||||
profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)"
|
||||
for f in "$profiles_dir"/*/; do
|
||||
[ -d "$f" ] && profiles="$profiles $(basename "$f")"
|
||||
done
|
||||
fi
|
||||
echo "$profiles"
|
||||
}}
|
||||
@@ -206,7 +208,7 @@ _hermes_profiles() {{
|
||||
local -a profiles
|
||||
profiles=(default)
|
||||
if [[ -d "$HOME/.hermes/profiles" ]]; then
|
||||
profiles+=("${{(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}}")
|
||||
profiles+=($HOME/.hermes/profiles/*(N/:t))
|
||||
fi
|
||||
_describe 'profile' profiles
|
||||
}}
|
||||
@@ -260,7 +262,9 @@ def generate_fish(parser: argparse.ArgumentParser) -> str:
|
||||
"function __hermes_profiles",
|
||||
" echo default",
|
||||
" if test -d $HOME/.hermes/profiles",
|
||||
" ls $HOME/.hermes/profiles 2>/dev/null",
|
||||
" for d in $HOME/.hermes/profiles/*/",
|
||||
" basename $d",
|
||||
" end",
|
||||
" end",
|
||||
"end",
|
||||
"",
|
||||
|
||||
+349
-27
@@ -5,11 +5,242 @@ Provides a curses multi-select with keyboard navigation, plus a
|
||||
text-based numbered fallback for terminals without curses support.
|
||||
"""
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, List, Optional, Set
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
|
||||
def _query_matches(label: str, query: str) -> bool:
|
||||
"""Return True when every query token is a case-insensitive subsequence."""
|
||||
normalized = label.lower()
|
||||
tokens = query.lower().split()
|
||||
|
||||
if not tokens:
|
||||
return True
|
||||
|
||||
for token in tokens:
|
||||
pos = 0
|
||||
|
||||
for ch in token:
|
||||
pos = normalized.find(ch, pos)
|
||||
|
||||
if pos < 0:
|
||||
return False
|
||||
|
||||
pos += 1
|
||||
|
||||
return True
|
||||
|
||||
|
||||
_WORD_BOUNDARY = frozenset("-_/. ")
|
||||
|
||||
|
||||
def _is_boundary(target: str, index: int) -> bool:
|
||||
"""True if position ``index`` in ``target`` starts a word.
|
||||
|
||||
Mirrors ``isBoundary`` in the TS scorer: start-of-string, after a
|
||||
separator char, or a lower->upper camelCase transition.
|
||||
"""
|
||||
if index == 0:
|
||||
return True
|
||||
|
||||
prev = target[index - 1]
|
||||
|
||||
if prev in _WORD_BOUNDARY:
|
||||
return True
|
||||
|
||||
# camelCase / lower->upper transition (e.g. the `O` in `gptO`).
|
||||
cur = target[index]
|
||||
|
||||
return prev == prev.lower() and cur != cur.lower() and cur == cur.upper()
|
||||
|
||||
|
||||
def _token_score(orig: str, lower: str, token: str) -> float | None:
|
||||
"""Score one token against a target. None if the token isn't a subsequence.
|
||||
|
||||
A faithful port of ``fuzzyScore`` in ui-tui/src/lib/fuzzy.ts and
|
||||
web/src/lib/fuzzy.ts so all three surfaces rank model ids identically:
|
||||
contiguous runs, word-boundary / first-char starts, prefix matches, and
|
||||
exact matches all score higher than scattered subsequence hits.
|
||||
|
||||
``lower`` is ``orig`` lowercased; matching is done against ``lower`` while
|
||||
boundary detection uses ``orig`` (so the camelCase rule works), exactly as
|
||||
in the TS scorer.
|
||||
"""
|
||||
score = 0.0
|
||||
prev = -1
|
||||
search_from = 0
|
||||
positions: list[int] = []
|
||||
|
||||
for ch in token:
|
||||
idx = lower.find(ch, search_from)
|
||||
|
||||
if idx < 0:
|
||||
return None
|
||||
|
||||
positions.append(idx)
|
||||
score += 1
|
||||
|
||||
if prev >= 0 and idx == prev + 1:
|
||||
score += 5
|
||||
elif prev >= 0:
|
||||
score -= min(idx - prev - 1, 3)
|
||||
|
||||
if _is_boundary(orig, idx):
|
||||
score += 3
|
||||
|
||||
if idx == 0:
|
||||
score += 5
|
||||
|
||||
prev = idx
|
||||
search_from = idx + 1
|
||||
|
||||
# Prefix bonus: the token matched a contiguous prefix of the target.
|
||||
if positions and positions[0] == 0 and positions[-1] == len(positions) - 1:
|
||||
score += 8
|
||||
|
||||
# Exact full match dominates everything else.
|
||||
if lower == token:
|
||||
score += 20
|
||||
|
||||
# Slightly prefer shorter targets when scores are otherwise close.
|
||||
score -= len(lower) * 0.01
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def _fuzzy_score(label: str, query: str) -> float | None:
|
||||
"""Aggregate score for a multi-token query (AND). None if any token fails.
|
||||
|
||||
Mirrors ``fuzzyScoreMulti`` in the TS scorer: every whitespace-separated
|
||||
token must match; per-token scores are summed.
|
||||
"""
|
||||
lower = label.lower()
|
||||
tokens = query.lower().split()
|
||||
|
||||
if not tokens:
|
||||
return 0.0
|
||||
|
||||
total = 0.0
|
||||
|
||||
for token in tokens:
|
||||
token_score = _token_score(label, lower, token)
|
||||
|
||||
if token_score is None:
|
||||
return None
|
||||
|
||||
total += token_score
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def _filter_indices(items: List[str], query: str) -> List[int]:
|
||||
"""Return item indices matching *query*, ranked best-first.
|
||||
|
||||
An empty query keeps every item in original order. Otherwise items are
|
||||
filtered to fuzzy matches and sorted by score descending, ties broken by
|
||||
original index so equal-scoring rows keep their catalog order.
|
||||
"""
|
||||
q = query.strip()
|
||||
|
||||
if not q:
|
||||
return list(range(len(items)))
|
||||
|
||||
scored = []
|
||||
|
||||
for i, label in enumerate(items):
|
||||
score = _fuzzy_score(label, q)
|
||||
|
||||
if score is not None:
|
||||
scored.append((i, score))
|
||||
|
||||
scored.sort(key=lambda pair: (-pair[1], pair[0]))
|
||||
|
||||
return [i for i, _ in scored]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SearchState:
|
||||
"""Mutable search state shared by curses picker loops."""
|
||||
|
||||
active: bool = False
|
||||
query: str = ""
|
||||
|
||||
|
||||
def _reconcile_cursor(filtered: List[int], cursor: int) -> tuple[int, int]:
|
||||
"""Return ``(cursor, cursor_pos)`` inside the filtered index list."""
|
||||
if not filtered:
|
||||
return cursor, 0
|
||||
|
||||
if cursor not in filtered:
|
||||
cursor = filtered[0]
|
||||
|
||||
return cursor, filtered.index(cursor)
|
||||
|
||||
|
||||
def _move_filtered_cursor(
|
||||
filtered: List[int], cursor: int, cursor_pos: int, delta: int
|
||||
) -> int:
|
||||
"""Move through the filtered index list, wrapping like the legacy menus."""
|
||||
if not filtered:
|
||||
return cursor
|
||||
|
||||
return filtered[(cursor_pos + delta) % len(filtered)]
|
||||
|
||||
|
||||
def _scroll_for_cursor(
|
||||
scroll_offset: int, cursor_pos: int, visible_rows: int, total_rows: int
|
||||
) -> int:
|
||||
"""Clamp scroll offset so the cursor remains visible."""
|
||||
visible_rows = max(1, visible_rows)
|
||||
|
||||
if cursor_pos < scroll_offset:
|
||||
scroll_offset = cursor_pos
|
||||
elif cursor_pos >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor_pos - visible_rows + 1
|
||||
|
||||
return max(0, min(scroll_offset, max(0, total_rows - visible_rows)))
|
||||
|
||||
|
||||
def _handle_active_search_key(
|
||||
curses_mod, key: int, search: _SearchState
|
||||
) -> tuple[bool, bool, bool]:
|
||||
"""Handle a key while the search prompt is active.
|
||||
|
||||
Returns ``(handled, confirm, changed)``. Active search consumes query
|
||||
editing keys, but leaves navigation keys for the menu loop to handle.
|
||||
"""
|
||||
if not search.active:
|
||||
return False, False, False
|
||||
|
||||
if key == 27:
|
||||
# Esc stops search AND clears the query, restoring the full list (so a
|
||||
# no-match filter can't strand the user on an empty list). Signals
|
||||
# `changed` when there was a query so the driver resets scroll/cursor.
|
||||
had_query = bool(search.query)
|
||||
search.active = False
|
||||
search.query = ""
|
||||
return True, False, had_query
|
||||
|
||||
if key in (curses_mod.KEY_BACKSPACE, 127, 8):
|
||||
search.query = search.query[:-1]
|
||||
return True, False, True
|
||||
|
||||
if key == 21: # Ctrl+U
|
||||
search.query = ""
|
||||
return True, False, True
|
||||
|
||||
if key in (curses_mod.KEY_ENTER, 10, 13):
|
||||
return True, True, False
|
||||
|
||||
if 32 <= key < 127: # printable ASCII; avoids Latin-1 mojibake from 128-255
|
||||
search.query += chr(key)
|
||||
return True, False, True
|
||||
|
||||
return False, False, False
|
||||
|
||||
|
||||
def flush_stdin() -> None:
|
||||
"""Flush any stray bytes from the stdin input buffer.
|
||||
|
||||
@@ -58,9 +289,16 @@ def read_menu_key(stdscr) -> str:
|
||||
the escape path; ``q`` also cancels. Unknown sequences map to
|
||||
``NAV_NONE`` so the caller simply ignores them rather than misfiring.
|
||||
"""
|
||||
import curses
|
||||
return _decode_menu_key(stdscr, stdscr.getch())
|
||||
|
||||
key = stdscr.getch()
|
||||
|
||||
def _decode_menu_key(stdscr, key: int) -> str:
|
||||
"""Normalize an already-read keypress to a menu action.
|
||||
|
||||
Split out from ``read_menu_key`` so search-aware loops can peek the raw
|
||||
key (e.g. to catch ``/``) before falling back to nav decoding.
|
||||
"""
|
||||
import curses
|
||||
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
return NAV_UP
|
||||
@@ -121,6 +359,8 @@ def _run_curses_menu(
|
||||
extra_color_pairs=False,
|
||||
fallback,
|
||||
cancel_value,
|
||||
searchable=False,
|
||||
search_labels=None,
|
||||
):
|
||||
"""Shared curses single-/multi-select event loop.
|
||||
|
||||
@@ -135,9 +375,12 @@ def _run_curses_menu(
|
||||
Callbacks / params:
|
||||
draw_header(stdscr, max_y, max_x) -> int
|
||||
Draw the title/hint/description rows. Returns the first screen row
|
||||
index where the scrollable item list should start.
|
||||
index where the scrollable item list should start. When search is
|
||||
active it receives the live ``_SearchState`` via the optional
|
||||
``search`` keyword (drawn by the menu so the hint line can show it).
|
||||
draw_row(stdscr, y, idx, is_cursor, max_x) -> None
|
||||
Draw one item row.
|
||||
Draw one item row. ``idx`` is always the ORIGINAL item index, so
|
||||
per-menu rendering is unchanged whether or not a filter is active.
|
||||
on_action(action, cursor) -> value
|
||||
Reducer for SELECT/TOGGLE/CANCEL. Return ``_KEEP`` to continue the
|
||||
loop; return anything else to resolve the menu with that value.
|
||||
@@ -151,6 +394,10 @@ def _run_curses_menu(
|
||||
fallback() -> value
|
||||
Called when curses errors out on a real TTY (curses unavailable).
|
||||
cancel_value: returned on non-TTY stdin, ESC/cancel, or KeyboardInterrupt.
|
||||
searchable: when true, ``/`` opens a type-to-filter prompt over
|
||||
``search_labels``. Returned values are always ORIGINAL item indices.
|
||||
search_labels: per-item text used for filtering (required when
|
||||
``searchable`` is true; length must equal ``item_count``).
|
||||
"""
|
||||
# Non-TTY (piped/redirected stdin): curses and input() both hang or spin,
|
||||
# so return the cancel value directly — matching the pre-refactor guard in
|
||||
@@ -158,6 +405,8 @@ def _run_curses_menu(
|
||||
if not sys.stdin.isatty():
|
||||
return cancel_value
|
||||
|
||||
use_search = searchable and search_labels is not None and len(search_labels) == item_count
|
||||
|
||||
try:
|
||||
import curses
|
||||
result_holder = [_KEEP]
|
||||
@@ -175,22 +424,46 @@ def _run_curses_menu(
|
||||
)
|
||||
cursor = initial_cursor
|
||||
scroll_offset = 0
|
||||
search = _SearchState()
|
||||
# Non-None labels for filtering; empty when search is disabled so
|
||||
# _filter_indices stays a cheap identity range.
|
||||
labels: List[str] = (
|
||||
search_labels if (use_search and search_labels is not None) else []
|
||||
)
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
|
||||
items_start = draw_header(stdscr, max_y, max_x)
|
||||
filtered = (
|
||||
_filter_indices(labels, search.query)
|
||||
if use_search
|
||||
else list(range(item_count))
|
||||
)
|
||||
cursor, cursor_pos = _reconcile_cursor(filtered, cursor)
|
||||
|
||||
visible_rows = max_y - items_start - reserve_bottom
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
# draw_header accepts an optional `search` kwarg when the menu
|
||||
# wants to render the live filter; tolerate headers that don't.
|
||||
try:
|
||||
items_start = draw_header(stdscr, max_y, max_x, search=search)
|
||||
except TypeError:
|
||||
items_start = draw_header(stdscr, max_y, max_x)
|
||||
|
||||
for draw_i, i in enumerate(
|
||||
range(scroll_offset, min(item_count, scroll_offset + visible_rows))
|
||||
visible_rows = max(1, max_y - items_start - reserve_bottom)
|
||||
scroll_offset = _scroll_for_cursor(
|
||||
scroll_offset, cursor_pos, visible_rows, len(filtered)
|
||||
)
|
||||
|
||||
if use_search and search.query and not filtered:
|
||||
try:
|
||||
stdscr.addnstr(items_start, 0, " No matches", max_x - 1, curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
for draw_i, filtered_pos in enumerate(
|
||||
range(scroll_offset, min(len(filtered), scroll_offset + visible_rows))
|
||||
):
|
||||
i = filtered[filtered_pos]
|
||||
y = draw_i + items_start
|
||||
if y >= max_y - reserve_bottom:
|
||||
break
|
||||
@@ -200,13 +473,46 @@ def _run_curses_menu(
|
||||
draw_footer(stdscr, max_y, max_x)
|
||||
|
||||
stdscr.refresh()
|
||||
action = read_menu_key(stdscr)
|
||||
|
||||
if use_search:
|
||||
key = stdscr.getch()
|
||||
|
||||
if search.active:
|
||||
# Active search consumes query-editing keys; nav keys
|
||||
# fall through to be decoded below.
|
||||
handled, confirm, changed = _handle_active_search_key(
|
||||
curses, key, search
|
||||
)
|
||||
if changed:
|
||||
scroll_offset = 0
|
||||
cursor, cursor_pos = _reconcile_cursor(
|
||||
_filter_indices(search_labels, search.query), cursor
|
||||
)
|
||||
if confirm:
|
||||
if filtered:
|
||||
outcome = on_action(NAV_SELECT, cursor)
|
||||
if outcome is not _KEEP:
|
||||
result_holder[0] = outcome
|
||||
return
|
||||
continue
|
||||
if handled:
|
||||
continue
|
||||
action = _decode_menu_key(stdscr, key)
|
||||
elif key == ord("/"):
|
||||
search.active = True
|
||||
continue
|
||||
else:
|
||||
action = _decode_menu_key(stdscr, key)
|
||||
else:
|
||||
action = read_menu_key(stdscr)
|
||||
|
||||
if action == NAV_UP:
|
||||
cursor = (cursor - 1) % item_count
|
||||
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, -1)
|
||||
elif action == NAV_DOWN:
|
||||
cursor = (cursor + 1) % item_count
|
||||
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, 1)
|
||||
elif action in (NAV_SELECT, NAV_TOGGLE, NAV_CANCEL):
|
||||
if action == NAV_SELECT and use_search and not filtered:
|
||||
continue
|
||||
outcome = on_action(action, cursor)
|
||||
if outcome is not _KEEP:
|
||||
result_holder[0] = outcome
|
||||
@@ -320,6 +626,7 @@ def curses_radiolist(
|
||||
*,
|
||||
cancel_returns: int | None = None,
|
||||
description: str | None = None,
|
||||
searchable: bool = False,
|
||||
) -> int:
|
||||
"""Curses single-select radio list. Returns the selected index.
|
||||
|
||||
@@ -331,6 +638,9 @@ def curses_radiolist(
|
||||
description: Optional multi-line text shown between the title and
|
||||
the item list. Useful for context that should survive the
|
||||
curses screen clear.
|
||||
searchable: When true, ``/`` opens a type-to-filter prompt. The
|
||||
returned value is always the original item index, not a filtered
|
||||
row position.
|
||||
"""
|
||||
if cancel_returns is None:
|
||||
cancel_returns = selected
|
||||
@@ -339,7 +649,7 @@ def curses_radiolist(
|
||||
if description:
|
||||
desc_lines = description.splitlines()
|
||||
|
||||
def _draw_header(stdscr, max_y, max_x):
|
||||
def _draw_header(stdscr, max_y, max_x, search=None):
|
||||
import curses
|
||||
row = 0
|
||||
try:
|
||||
@@ -356,11 +666,13 @@ def curses_radiolist(
|
||||
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
|
||||
row += 1
|
||||
|
||||
stdscr.addnstr(
|
||||
row, 0,
|
||||
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
if searchable and search is not None and search.active:
|
||||
hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop"
|
||||
elif searchable:
|
||||
hint = " \u2191\u2193 navigate ENTER/SPACE select / search ESC cancel"
|
||||
else:
|
||||
hint = " \u2191\u2193 navigate ENTER/SPACE select ESC cancel"
|
||||
stdscr.addnstr(row, 0, hint, max_x - 1, curses.A_DIM)
|
||||
row += 1
|
||||
except curses.error:
|
||||
pass
|
||||
@@ -396,6 +708,8 @@ def curses_radiolist(
|
||||
reserve_bottom=1,
|
||||
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns),
|
||||
cancel_value=cancel_returns,
|
||||
searchable=searchable,
|
||||
search_labels=list(items) if searchable else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -431,27 +745,33 @@ def curses_single_select(
|
||||
default_index: int = 0,
|
||||
*,
|
||||
cancel_label: str = "Cancel",
|
||||
searchable: bool = False,
|
||||
) -> int | None:
|
||||
"""Curses single-select menu. Returns selected index or None on cancel.
|
||||
|
||||
Works inside prompt_toolkit because curses.wrapper() restores the terminal
|
||||
safely, unlike simple_term_menu which conflicts with /dev/tty.
|
||||
|
||||
When ``searchable`` is true, ``/`` opens a type-to-filter prompt; the
|
||||
returned value is always the original item index (or None for cancel).
|
||||
"""
|
||||
all_items = list(items) + [cancel_label]
|
||||
cancel_idx = len(items)
|
||||
|
||||
def _draw_header(stdscr, max_y, max_x):
|
||||
def _draw_header(stdscr, max_y, max_x, search=None):
|
||||
import curses
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" ↑↓ navigate ENTER confirm ESC/q cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
if searchable and search is not None and search.active:
|
||||
hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop"
|
||||
elif searchable:
|
||||
hint = " ↑↓ navigate ENTER confirm / search ESC/q cancel"
|
||||
else:
|
||||
hint = " ↑↓ navigate ENTER confirm ESC/q cancel"
|
||||
stdscr.addnstr(1, 0, hint, max_x - 1, curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
return 3
|
||||
@@ -488,6 +808,8 @@ def curses_single_select(
|
||||
reserve_bottom=1,
|
||||
fallback=lambda: _numbered_single_fallback(title, all_items, cancel_idx),
|
||||
cancel_value=None,
|
||||
searchable=searchable,
|
||||
search_labels=list(all_items) if searchable else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+54
-12
@@ -453,11 +453,8 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
|
||||
if pid == my_pid or pid in exclude_pids:
|
||||
continue
|
||||
try:
|
||||
cmdline = (
|
||||
open(f"/proc/{pid}/cmdline", "rb")
|
||||
.read()
|
||||
.decode("utf-8", errors="replace")
|
||||
)
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as _f:
|
||||
cmdline = _f.read().decode("utf-8", errors="replace")
|
||||
cmdline = cmdline.replace("\x00", " ")
|
||||
cmdline_lc = cmdline.lower()
|
||||
if any(p in cmdline_lc for p in patterns) and (
|
||||
@@ -5877,15 +5874,60 @@ def _maybe_redirect_run_to_s6_supervision(args) -> bool:
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
# Block until the container is signalled. The supervised gateway's
|
||||
# lifetime is independent of this process — s6-supervise restarts
|
||||
# it on crash, and we don't want the container to exit when the
|
||||
# gateway flaps. `sleep infinity` matches the static main-hermes
|
||||
# service's pattern (see docker/s6-rc.d/main-hermes/run): the CMD
|
||||
# process is a no-op heartbeat that keeps /init alive until
|
||||
# Keep the CMD process alive as a no-op heartbeat. The supervised
|
||||
# gateway's lifetime is independent of this process — s6-supervise
|
||||
# restarts it on crash, and we don't want the container to exit when
|
||||
# the gateway flaps. The CMD process keeps /init alive until
|
||||
# `docker stop` sends SIGTERM, at which point /init runs stage 3
|
||||
# shutdown (which tears down the supervised gateway cleanly).
|
||||
os.execvp("sleep", ["sleep", "infinity"])
|
||||
#
|
||||
# Prefer `sleep infinity` (matches the static main-hermes service's
|
||||
# pattern in docker/s6-rc.d/main-hermes/run, and frees the Python
|
||||
# interpreter — the heartbeat is a tiny `sleep` process, not a
|
||||
# resident interpreter). But `os.execvp` does a PATH lookup for the
|
||||
# `sleep` binary and historically crashed the whole container with
|
||||
# FileNotFoundError when PATH was empty/truncated/clobbered at this
|
||||
# point — e.g. after user customizations rewrote PATH, or on minimal
|
||||
# images without `sleep` on PATH (issue #36208). Fall back to an
|
||||
# in-process block (no external binary, can't fail on PATH) so the
|
||||
# container keeps running instead of dying during boot.
|
||||
try:
|
||||
os.execvp("sleep", ["sleep", "infinity"])
|
||||
except OSError:
|
||||
# execvp only returns by raising; on success it replaces this
|
||||
# process. ENOENT (no `sleep` on PATH) and any other exec error
|
||||
# land here.
|
||||
print(
|
||||
"→ `sleep` is unavailable; keeping the s6 CMD process alive "
|
||||
"in-process until the container is stopped.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
_block_until_terminated()
|
||||
return True # unreachable on the execvp success path
|
||||
|
||||
|
||||
def _block_until_terminated() -> None:
|
||||
"""Keep the s6 CMD process alive until the container is stopped.
|
||||
|
||||
Fallback heartbeat for when ``os.execvp("sleep", ...)`` can't run
|
||||
(``sleep`` missing from PATH — issue #36208). Installs a SIGTERM
|
||||
handler that exits with the conventional 128+signum code so
|
||||
``docker stop`` produces a clean, expected exit, then blocks on
|
||||
``signal.pause()``. Falls back to ``threading.Event().wait()`` on
|
||||
platforms without ``signal.pause()`` (e.g. Windows) — although this
|
||||
path only runs inside the s6 Linux container image, the fallback
|
||||
keeps the helper safe to import and unit-test anywhere.
|
||||
"""
|
||||
signal.signal(signal.SIGTERM, lambda signum, _frame: sys.exit(128 + signum))
|
||||
pause = getattr(signal, "pause", None)
|
||||
if pause is not None:
|
||||
while True:
|
||||
pause()
|
||||
else: # pragma: no cover - non-Unix fallback, not exercised in the s6 image
|
||||
import threading
|
||||
|
||||
threading.Event().wait()
|
||||
|
||||
|
||||
def _gateway_command_inner(args):
|
||||
|
||||
+25
-3
@@ -4353,13 +4353,21 @@ def decompose_triage_task(
|
||||
child_ids: list[str] = []
|
||||
with write_txn(conn):
|
||||
root_row = conn.execute(
|
||||
"SELECT id, status, tenant FROM tasks WHERE id = ?", (task_id,)
|
||||
"SELECT id, status, tenant, workspace_kind, workspace_path "
|
||||
"FROM tasks WHERE id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if root_row is None:
|
||||
return None
|
||||
if root_row["status"] != "triage":
|
||||
return None
|
||||
tenant = root_row["tenant"]
|
||||
# Children inherit the root's workspace by default so a fan-out
|
||||
# of a code-gen task lands in the parent's project dir/worktree
|
||||
# rather than throwaway scratch tmp dirs. A child dict can still
|
||||
# override with its own 'workspace_kind' / 'workspace_path'.
|
||||
root_ws_kind = root_row["workspace_kind"] or "scratch"
|
||||
root_ws_path = root_row["workspace_path"]
|
||||
|
||||
# Create children. Status is 'todo' regardless of parents — we
|
||||
# link them under the root AFTER creation so the dispatcher
|
||||
@@ -4370,16 +4378,30 @@ def decompose_triage_task(
|
||||
title = child["title"].strip()
|
||||
body = child.get("body")
|
||||
assignee = _canonical_assignee(child.get("assignee"))
|
||||
# Per-child override wins; otherwise inherit the root's
|
||||
# workspace. A child that sets workspace_kind without a path
|
||||
# falls back to the root path only when kinds match (so a
|
||||
# child can't accidentally point a 'dir' at the root's
|
||||
# worktree path or vice versa).
|
||||
child_ws_kind = child.get("workspace_kind") or root_ws_kind
|
||||
if child.get("workspace_path"):
|
||||
child_ws_path = child.get("workspace_path")
|
||||
elif child_ws_kind == root_ws_kind:
|
||||
child_ws_path = root_ws_path
|
||||
else:
|
||||
child_ws_path = None
|
||||
conn.execute(
|
||||
"INSERT INTO tasks "
|
||||
"(id, title, body, assignee, status, workspace_kind, "
|
||||
" tenant, created_at, created_by) "
|
||||
"VALUES (?, ?, ?, ?, 'todo', 'scratch', ?, ?, ?)",
|
||||
" workspace_path, tenant, created_at, created_by) "
|
||||
"VALUES (?, ?, ?, ?, 'todo', ?, ?, ?, ?, ?)",
|
||||
(
|
||||
new_id,
|
||||
title,
|
||||
body if isinstance(body, str) else None,
|
||||
assignee,
|
||||
child_ws_kind,
|
||||
child_ws_path,
|
||||
tenant,
|
||||
now,
|
||||
(author or "decomposer"),
|
||||
|
||||
@@ -4575,6 +4575,7 @@ def _model_flow_named_custom(config, provider_info):
|
||||
menu_items,
|
||||
selected=default_idx,
|
||||
cancel_returns=-1,
|
||||
searchable=True,
|
||||
)
|
||||
print()
|
||||
if idx < 0 or idx >= len(models):
|
||||
|
||||
+115
-19
@@ -700,6 +700,48 @@ def switch_model(
|
||||
|
||||
target_provider = pdef.id
|
||||
|
||||
# Guard against silent aggregator hops. A vendor name like bare
|
||||
# "openai" is an alias that resolves to an aggregator ("openrouter").
|
||||
# If the user explicitly asked for that vendor but the aggregator it
|
||||
# routes to has no credentials, do NOT silently switch them onto an
|
||||
# unauthed endpoint (the classic HTTP 401 "Missing Authentication
|
||||
# header"). Point them at the real direct provider instead.
|
||||
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
|
||||
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
|
||||
_explicit_norm = explicit_provider.strip().lower()
|
||||
_alias_target = _PROVIDER_ALIAS_TABLE.get(_explicit_norm)
|
||||
if (
|
||||
_alias_target
|
||||
and _alias_target == target_provider
|
||||
and target_provider != _explicit_norm
|
||||
and target_provider in _AGG_PROVIDERS
|
||||
):
|
||||
_authed = get_authenticated_provider_slugs(
|
||||
current_provider=current_provider,
|
||||
user_providers=user_providers,
|
||||
custom_providers=custom_providers,
|
||||
)
|
||||
if target_provider not in _authed:
|
||||
_suggestions = [
|
||||
s for s in _authed
|
||||
if s.startswith(_explicit_norm) and s != _explicit_norm
|
||||
]
|
||||
_hint = (
|
||||
f" Did you mean: {', '.join(_suggestions)}?"
|
||||
if _suggestions else ""
|
||||
)
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=pdef.name,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Provider '{_explicit_norm}' is an alias that routes "
|
||||
f"through {get_label(target_provider)}, which "
|
||||
f"has no credentials configured.{_hint}"
|
||||
),
|
||||
)
|
||||
|
||||
# If no model specified, try auto-detect from endpoint
|
||||
if not new_model:
|
||||
if pdef.base_url:
|
||||
@@ -854,25 +896,62 @@ def switch_model(
|
||||
api_mode = ""
|
||||
|
||||
if provider_changed or explicit_provider:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=target_provider,
|
||||
target_model=new_model,
|
||||
)
|
||||
api_key = runtime.get("api_key", "")
|
||||
base_url = runtime.get("base_url", "")
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
except Exception as e:
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=provider_label,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Could not resolve credentials for provider "
|
||||
f"'{provider_label}': {e}"
|
||||
),
|
||||
)
|
||||
import os
|
||||
# User-config providers (providers.<name> in config.yaml) carry their
|
||||
# own base_url + transport + key reference. resolve_runtime_provider()
|
||||
# resolves by provider NAME and doesn't know user-config slugs (e.g. a
|
||||
# block named "openai"), so it would re-resolve from scratch and fail
|
||||
# or hop to an aggregator. Use the pdef's endpoint directly instead.
|
||||
_user_pdef = None
|
||||
if explicit_provider and user_providers:
|
||||
from hermes_cli.providers import resolve_user_provider as _ruser
|
||||
_user_pdef = _ruser(explicit_provider.strip().lower(), user_providers)
|
||||
if _user_pdef is None:
|
||||
_user_pdef = _ruser(target_provider, user_providers)
|
||||
if _user_pdef is not None and _user_pdef.base_url:
|
||||
_ucfg = (user_providers or {}).get(explicit_provider.strip().lower()) \
|
||||
or (user_providers or {}).get(target_provider) or {}
|
||||
_ukey = str(_ucfg.get("api_key", "") or "").strip()
|
||||
if _ukey.startswith("${") and _ukey.endswith("}"):
|
||||
_ukey = os.environ.get(_ukey[2:-1], "").strip()
|
||||
if not _ukey:
|
||||
_kenv = str(_ucfg.get("key_env", "") or "").strip()
|
||||
if _kenv:
|
||||
_ukey = os.environ.get(_kenv, "").strip()
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=target_provider,
|
||||
explicit_api_key=_ukey or None,
|
||||
explicit_base_url=_user_pdef.base_url,
|
||||
target_model=new_model,
|
||||
)
|
||||
api_key = runtime.get("api_key", "") or _ukey
|
||||
base_url = runtime.get("base_url", "") or _user_pdef.base_url
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
except Exception:
|
||||
api_key = _ukey
|
||||
base_url = _user_pdef.base_url
|
||||
api_mode = ""
|
||||
else:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=target_provider,
|
||||
target_model=new_model,
|
||||
)
|
||||
api_key = runtime.get("api_key", "")
|
||||
base_url = runtime.get("base_url", "")
|
||||
api_mode = runtime.get("api_mode", "")
|
||||
except Exception as e:
|
||||
return ModelSwitchResult(
|
||||
success=False,
|
||||
target_provider=target_provider,
|
||||
provider_label=provider_label,
|
||||
is_global=is_global,
|
||||
error_message=(
|
||||
f"Could not resolve credentials for provider "
|
||||
f"'{provider_label}': {e}"
|
||||
),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
@@ -1195,7 +1274,24 @@ def list_authenticated_providers(
|
||||
curated["lmstudio"] = live
|
||||
|
||||
# --- 1. Check Hermes-mapped providers ---
|
||||
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
|
||||
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
|
||||
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
|
||||
# Skip vendor names that are merely aliases routing through an
|
||||
# aggregator (e.g. bare "openai" → "openrouter"). These are NOT
|
||||
# directly-routable providers: emitting them as their own picker
|
||||
# row produces a phantom entry that, when selected, resolves via
|
||||
# resolve_provider_full() to the aggregator (OpenRouter) — silently
|
||||
# switching a user off their real provider onto an endpoint they
|
||||
# may have no key for (HTTP 401). The user's real provider (e.g.
|
||||
# openai-api, or a providers.openai config row) covers this vendor.
|
||||
_alias_target = _PROVIDER_ALIAS_TABLE.get(hermes_id)
|
||||
if (
|
||||
_alias_target
|
||||
and _alias_target != hermes_id
|
||||
and _alias_target in _AGG_PROVIDERS
|
||||
):
|
||||
continue
|
||||
# Skip aliases that map to the same models.dev provider (e.g.
|
||||
# kimi-coding and kimi-coding-cn both → kimi-for-coding).
|
||||
# The first one with valid credentials wins (#10526).
|
||||
|
||||
@@ -235,13 +235,13 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"gemini": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.1-flash-lite-preview",
|
||||
],
|
||||
"google-gemini-cli": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash",
|
||||
],
|
||||
"zai": [
|
||||
"glm-5.1",
|
||||
|
||||
+15
-1
@@ -677,6 +677,20 @@ def resolve_provider_full(
|
||||
ProviderDef if found, else None.
|
||||
"""
|
||||
canonical = normalize_provider(name)
|
||||
raw = name.strip().lower()
|
||||
|
||||
# 0. User-defined config providers win over the built-in alias table.
|
||||
# A user who declares ``providers.<name>`` in config.yaml has stated
|
||||
# explicit intent for that name — it must not be hijacked by a legacy
|
||||
# vendor alias (e.g. bare "openai" → "openrouter"). Resolve the raw
|
||||
# name against user config FIRST so a configured ``providers.openai``
|
||||
# (pointing at api.openai.com) beats the alias that would otherwise
|
||||
# silently route to OpenRouter. Only the raw (pre-alias) name is tried
|
||||
# here; canonical/alias resolution still happens below.
|
||||
if user_providers:
|
||||
user_pdef = resolve_user_provider(raw, user_providers)
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
|
||||
# 1. Built-in (models.dev + overlays)
|
||||
pdef = get_provider(canonical)
|
||||
@@ -690,7 +704,7 @@ def resolve_provider_full(
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
# Try original name (in case alias didn't match)
|
||||
user_pdef = resolve_user_provider(name.strip().lower(), user_providers)
|
||||
user_pdef = resolve_user_provider(raw, user_providers)
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
|
||||
|
||||
+35
-18
@@ -335,7 +335,14 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
# Collect results from all (or filtered) sources in parallel.
|
||||
# Per-source limits are generous — parallelism + 30s timeout cap prevents hangs.
|
||||
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
|
||||
# NOTE: when the centralized index is available, parallel_search_sources
|
||||
# skips the external API sources and serves everything from "hermes-index".
|
||||
# That source MUST therefore carry a high limit, or browse silently caps
|
||||
# the entire hub at the default (50) — it shipped that way and surfaced
|
||||
# ~136 of 88k skills. The external-source limits below only apply when the
|
||||
# index is unavailable (offline / first run before the cache populates).
|
||||
_PER_SOURCE_LIMIT = {
|
||||
"hermes-index": 5000,
|
||||
"official": 200, "skills-sh": 200, "well-known": 50,
|
||||
"github": 200, "clawhub": 500, "claude-marketplace": 100,
|
||||
"lobehub": 500, "browse-sh": 500,
|
||||
@@ -396,18 +403,22 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
# Build table
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("#", style="dim", width=4, justify="right")
|
||||
table.add_column("Name", style="bold cyan", max_width=25)
|
||||
table.add_column("Description", max_width=50)
|
||||
table.add_column("Name", style="bold cyan", max_width=22)
|
||||
table.add_column("Description", max_width=44)
|
||||
table.add_column("Source", style="dim", width=12)
|
||||
table.add_column("Trust", width=10)
|
||||
# The identifier is what you pass to `hermes skills install`. Browse used
|
||||
# to omit it entirely, so users couldn't act on what they saw without a
|
||||
# second `search`. overflow="fold" keeps long slugs copy-pasteable.
|
||||
table.add_column("Identifier", style="dim", overflow="fold", no_wrap=False)
|
||||
|
||||
for i, r in enumerate(page_items, start=start + 1):
|
||||
trust_style = {"builtin": "bright_cyan", "trusted": "green",
|
||||
"community": "yellow"}.get(r.trust_level, "dim")
|
||||
trust_label = "★ official" if r.source == "official" else r.trust_level
|
||||
|
||||
desc = r.description[:50]
|
||||
if len(r.description) > 50:
|
||||
desc = r.description[:44]
|
||||
if len(r.description) > 44:
|
||||
desc += "..."
|
||||
|
||||
table.add_row(
|
||||
@@ -416,6 +427,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
desc,
|
||||
r.source,
|
||||
f"[{trust_style}]{trust_label}[/]",
|
||||
r.identifier,
|
||||
)
|
||||
|
||||
c.print(table)
|
||||
@@ -439,7 +451,9 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
c.print(f" [yellow]⚡ Slow sources skipped: {', '.join(timed_out)} "
|
||||
f"— run again for cached results[/]")
|
||||
|
||||
c.print("[dim]Tip: 'hermes skills search <query>' searches deeper across all registries[/]\n")
|
||||
c.print("[dim]Tip: 'hermes skills inspect <identifier>' to preview, "
|
||||
"'hermes skills install <identifier>' to install, "
|
||||
"'hermes skills search <query>' to search deeper[/]\n")
|
||||
|
||||
|
||||
def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
@@ -725,24 +739,27 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
|
||||
|
||||
Returns ``{"items": [...], "page": int, "total_pages": int, "total": int}``.
|
||||
"""
|
||||
from tools.skills_hub import GitHubAuth, create_source_router
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth, create_source_router, parallel_search_sources,
|
||||
)
|
||||
|
||||
page_size = max(1, min(page_size, 100))
|
||||
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
|
||||
_PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50,
|
||||
# "hermes-index" must carry a high limit: when the index is available the
|
||||
# router skips external API sources and serves everything from it, so a
|
||||
# low cap here silently truncates the whole hub (see do_browse note).
|
||||
_PER_SOURCE_LIMIT = {"hermes-index": 5000, "official": 100, "skills-sh": 100,
|
||||
"well-known": 25, "github": 100, "clawhub": 50,
|
||||
"claude-marketplace": 50, "lobehub": 50, "browse-sh": 500}
|
||||
auth = GitHubAuth()
|
||||
sources = create_source_router(auth)
|
||||
all_results: list = []
|
||||
for src in sources:
|
||||
sid = src.source_id()
|
||||
if source != "all" and sid != source and sid != "official":
|
||||
continue
|
||||
try:
|
||||
limit = _PER_SOURCE_LIMIT.get(sid, 50)
|
||||
all_results.extend(src.search("", limit=limit))
|
||||
except Exception:
|
||||
continue
|
||||
# Delegate to the shared parallel walker so this inherits the index-aware
|
||||
# source-skip logic — querying hermes-index AND the external APIs at once
|
||||
# would double-count every skill.
|
||||
all_results, _counts, _timed_out = parallel_search_sources(
|
||||
sources, query="", per_source_limits=_PER_SOURCE_LIMIT,
|
||||
source_filter=source, overall_timeout=30,
|
||||
)
|
||||
if not all_results:
|
||||
return {"items": [], "page": 1, "total_pages": 1, "total": 0}
|
||||
seen: dict = {}
|
||||
@@ -759,7 +776,7 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
|
||||
page_items = deduped[start : min(start + page_size, total)]
|
||||
return {
|
||||
"items": [{"name": r.name, "description": r.description, "source": r.source,
|
||||
"trust": r.trust_level} for r in page_items],
|
||||
"trust": r.trust_level, "identifier": r.identifier} for r in page_items],
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"total": total,
|
||||
|
||||
+700
-39
@@ -753,6 +753,225 @@ async def get_status():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/system/stats")
|
||||
async def get_system_stats():
|
||||
"""Host + process system stats for the System page.
|
||||
|
||||
OS / Python / host identity from stdlib; CPU / memory / disk / uptime from
|
||||
psutil when available, with graceful degradation when it isn't. Read-only
|
||||
and non-sensitive (no env values, no paths beyond the hermes home root).
|
||||
"""
|
||||
import platform as _platform
|
||||
|
||||
info: Dict[str, Any] = {
|
||||
"os": _platform.system(),
|
||||
"os_release": _platform.release(),
|
||||
"os_version": _platform.version(),
|
||||
"platform": _platform.platform(),
|
||||
"arch": _platform.machine(),
|
||||
"hostname": _platform.node(),
|
||||
"python_version": _platform.python_version(),
|
||||
"python_impl": _platform.python_implementation(),
|
||||
"hermes_version": __version__,
|
||||
"cpu_count": os.cpu_count(),
|
||||
}
|
||||
|
||||
# psutil enriches the picture when present; everything below is optional.
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
info["memory"] = {
|
||||
"total": vm.total,
|
||||
"available": vm.available,
|
||||
"used": vm.used,
|
||||
"percent": vm.percent,
|
||||
}
|
||||
try:
|
||||
du = psutil.disk_usage(str(get_hermes_home()))
|
||||
info["disk"] = {
|
||||
"total": du.total,
|
||||
"used": du.used,
|
||||
"free": du.free,
|
||||
"percent": du.percent,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
|
||||
la = getattr(psutil, "getloadavg", None)
|
||||
if la:
|
||||
info["load_avg"] = list(la())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
boot = psutil.boot_time()
|
||||
info["uptime_seconds"] = int(time.time() - boot)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc = psutil.Process()
|
||||
info["process"] = {
|
||||
"pid": proc.pid,
|
||||
"rss": proc.memory_info().rss,
|
||||
"create_time": int(proc.create_time()),
|
||||
"num_threads": proc.num_threads(),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
info["psutil"] = True
|
||||
except Exception:
|
||||
info["psutil"] = False
|
||||
# stdlib-only fallbacks for load average + uptime where the kernel
|
||||
# exposes them.
|
||||
try:
|
||||
info["load_avg"] = list(os.getloadavg())
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curator endpoints — background skill-maintenance status + controls.
|
||||
#
|
||||
# The curator periodically reviews skills (archive stale, prune, pin). The
|
||||
# dashboard surfaces its state and the pause/resume/run-now controls that
|
||||
# `hermes curator` exposes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/curator")
|
||||
async def get_curator_status():
|
||||
try:
|
||||
from agent import curator
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Curator unavailable: {exc}")
|
||||
try:
|
||||
state = curator.load_state()
|
||||
except Exception:
|
||||
state = {}
|
||||
return {
|
||||
"enabled": _safe_call(curator, "is_enabled", True),
|
||||
"paused": _safe_call(curator, "is_paused", False),
|
||||
"interval_hours": _safe_call(curator, "get_interval_hours", None),
|
||||
"last_run_at": state.get("last_run_at"),
|
||||
"min_idle_hours": _safe_call(curator, "get_min_idle_hours", None),
|
||||
"stale_after_days": _safe_call(curator, "get_stale_after_days", None),
|
||||
"archive_after_days": _safe_call(curator, "get_archive_after_days", None),
|
||||
}
|
||||
|
||||
|
||||
class CuratorPause(BaseModel):
|
||||
paused: bool
|
||||
|
||||
|
||||
@app.put("/api/curator/paused")
|
||||
async def set_curator_paused(body: CuratorPause):
|
||||
from agent import curator
|
||||
|
||||
curator.set_paused(bool(body.paused))
|
||||
return {"ok": True, "paused": bool(body.paused)}
|
||||
|
||||
|
||||
@app.post("/api/curator/run")
|
||||
async def run_curator():
|
||||
"""Trigger a curator review now (backgrounded; tail via action status)."""
|
||||
try:
|
||||
proc = _spawn_hermes_action(["curator", "run"], "curator-run")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to run curator: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "curator-run"}
|
||||
|
||||
|
||||
def _safe_call(mod, fn_name: str, default):
|
||||
try:
|
||||
fn = getattr(mod, fn_name, None)
|
||||
return fn() if callable(fn) else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portal endpoint — Nous Portal auth + Tool Gateway routing status (read-only).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/portal")
|
||||
async def get_portal_status():
|
||||
cfg = load_config() or {}
|
||||
auth: Dict[str, Any] = {}
|
||||
try:
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
|
||||
auth = get_nous_auth_status() or {}
|
||||
except Exception:
|
||||
auth = {}
|
||||
|
||||
features = []
|
||||
try:
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
|
||||
feats = get_nous_subscription_features(cfg)
|
||||
if feats is not None:
|
||||
for feat in feats.items():
|
||||
if getattr(feat, "managed_by_nous", False):
|
||||
state = "via Nous Portal"
|
||||
elif getattr(feat, "active", False) and getattr(feat, "current_provider", None):
|
||||
state = feat.current_provider
|
||||
elif getattr(feat, "active", False):
|
||||
state = "active"
|
||||
else:
|
||||
state = "not configured"
|
||||
features.append({"label": getattr(feat, "label", ""), "state": state})
|
||||
except Exception:
|
||||
_log.exception("portal features failed")
|
||||
|
||||
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
|
||||
return {
|
||||
"logged_in": bool(auth.get("logged_in")),
|
||||
"portal_url": auth.get("portal_base_url"),
|
||||
"inference_url": auth.get("inference_base_url"),
|
||||
"provider": str((model_cfg or {}).get("provider") or ""),
|
||||
"subscription_url": "https://portal.nousresearch.com/manage-subscription",
|
||||
"features": features,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diagnostics: prompt-size, support dump, debug upload, config migrate.
|
||||
# All produce text output, so they spawn background actions tailed via
|
||||
# /api/actions/<name>/status.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/api/ops/prompt-size")
|
||||
async def run_prompt_size():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["prompt-size"], "prompt-size")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "prompt-size"}
|
||||
|
||||
|
||||
@app.post("/api/ops/dump")
|
||||
async def run_dump():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["dump"], "dump")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "dump"}
|
||||
|
||||
|
||||
@app.post("/api/ops/config-migrate")
|
||||
async def run_config_migrate():
|
||||
try:
|
||||
proc = _spawn_hermes_action(["config", "migrate"], "config-migrate")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "config-migrate"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway + update actions (invoked from the Status page).
|
||||
#
|
||||
@@ -779,6 +998,10 @@ _ACTION_LOG_FILES: Dict[str, str] = {
|
||||
"skills-install": "action-skills-install.log",
|
||||
"skills-uninstall": "action-skills-uninstall.log",
|
||||
"skills-update": "action-skills-update.log",
|
||||
"curator-run": "action-curator-run.log",
|
||||
"prompt-size": "action-prompt-size.log",
|
||||
"dump": "action-dump.log",
|
||||
"config-migrate": "action-config-migrate.log",
|
||||
}
|
||||
|
||||
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
|
||||
@@ -838,6 +1061,10 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
proc = subprocess.Popen(cmd, **popen_kwargs)
|
||||
# The child inherits its own duplicated fd for stdout/stderr, so the
|
||||
# parent's handle can be released immediately — otherwise we leak one
|
||||
# fd per spawned action.
|
||||
log_file.close()
|
||||
_ACTION_RESULTS.pop(name, None)
|
||||
_ACTION_PROCS[name] = proc
|
||||
return proc
|
||||
@@ -1131,22 +1358,51 @@ async def get_action_status(name: str, lines: int = 200):
|
||||
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0):
|
||||
async def get_sessions(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
min_messages: int = 0,
|
||||
archived: str = "exclude",
|
||||
):
|
||||
"""List sessions.
|
||||
|
||||
``archived`` controls how soft-archived sessions are treated:
|
||||
``exclude`` (default) hides them, ``only`` returns just the archived ones
|
||||
(used by the desktop "Archived sessions" settings panel), and ``include``
|
||||
returns both.
|
||||
"""
|
||||
if archived not in ("exclude", "only", "include"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="archived must be one of: exclude, only, include",
|
||||
)
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
min_message_count = max(0, min_messages)
|
||||
archived_only = archived == "only"
|
||||
include_archived = archived == "include"
|
||||
sessions = db.list_sessions_rich(
|
||||
limit=limit, offset=offset, min_message_count=min_message_count
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
min_message_count=min_message_count,
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
)
|
||||
total = db.session_count(
|
||||
min_message_count=min_message_count,
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
)
|
||||
total = db.session_count(min_message_count=min_message_count)
|
||||
now = time.time()
|
||||
for s in sessions:
|
||||
s["is_active"] = (
|
||||
s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
# SQLite stores the flag as 0/1; expose a real JSON boolean.
|
||||
s["archived"] = bool(s.get("archived"))
|
||||
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1779,6 +2035,11 @@ async def remove_env_var(body: EnvVarDelete):
|
||||
return {"ok": True, "key": body.key}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
# remove_env_value raises ValueError for invalid key names. Surface
|
||||
# the message to the SPA so the user understands why the delete was
|
||||
# refused instead of seeing an opaque 500. Mirrors PUT /api/env.
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
_log.exception("DELETE /api/env failed")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
@@ -3652,6 +3913,38 @@ def _session_latest_descendant(session_id: str):
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@app.get("/api/sessions/stats")
|
||||
async def get_session_stats():
|
||||
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
|
||||
|
||||
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
|
||||
path isn't captured as a session id by the parameterized route.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
total = db.session_count(include_archived=True)
|
||||
active_store = db.session_count(include_archived=False)
|
||||
archived = db.session_count(archived_only=True)
|
||||
messages = db.message_count()
|
||||
by_source: Dict[str, int] = {}
|
||||
try:
|
||||
for s in db.list_sessions_rich(limit=10000, include_archived=True):
|
||||
src = str(s.get("source") or "cli")
|
||||
by_source[src] = by_source.get(src, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"total": total,
|
||||
"active_store": active_store,
|
||||
"archived": archived,
|
||||
"messages": messages,
|
||||
"by_source": by_source,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session_detail(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
@@ -3707,25 +4000,82 @@ async def delete_session_endpoint(session_id: str):
|
||||
|
||||
class SessionRename(BaseModel):
|
||||
title: Optional[str] = None
|
||||
archived: Optional[bool] = None
|
||||
|
||||
|
||||
@app.patch("/api/sessions/{session_id}")
|
||||
async def rename_session_endpoint(session_id: str, body: SessionRename):
|
||||
"""Rename a session (or clear its title when ``title`` is empty/null)."""
|
||||
"""Update a session: rename (or clear its title) and/or archive it.
|
||||
|
||||
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
|
||||
restores the session. Either field may be omitted.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
try:
|
||||
updated = db.set_session_title(sid, body.title or "")
|
||||
except ValueError as e:
|
||||
# Title too long, invalid characters, or already in use.
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if not updated:
|
||||
if body.title is None and body.archived is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nothing to update; provide 'title' and/or 'archived'.",
|
||||
)
|
||||
if body.title is not None:
|
||||
try:
|
||||
db.set_session_title(sid, body.title or "")
|
||||
except ValueError as e:
|
||||
# Title too long, invalid characters, or already in use.
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if body.archived is not None:
|
||||
db.set_session_archived(sid, body.archived)
|
||||
result = {"ok": True, "title": db.get_session_title(sid) or ""}
|
||||
if body.archived is not None:
|
||||
result["archived"] = bool(body.archived)
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/export")
|
||||
async def export_session_endpoint(session_id: str):
|
||||
"""Export a single session (metadata + messages) as JSON."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"ok": True, "title": db.get_session_title(sid) or ""}
|
||||
data = db.export_session(sid)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return data
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class SessionPrune(BaseModel):
|
||||
older_than_days: int = 90
|
||||
source: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/sessions/prune")
|
||||
async def prune_sessions_endpoint(body: SessionPrune):
|
||||
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
|
||||
if body.older_than_days < 1:
|
||||
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
removed = db.prune_sessions(
|
||||
older_than_days=body.older_than_days,
|
||||
source=(body.source or None),
|
||||
sessions_dir=sessions_dir if sessions_dir.exists() else None,
|
||||
)
|
||||
return {"ok": True, "removed": removed}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -4120,6 +4470,129 @@ async def test_mcp_server(name: str):
|
||||
}
|
||||
|
||||
|
||||
class MCPEnabledToggle(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@app.put("/api/mcp/servers/{name}/enabled")
|
||||
async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle):
|
||||
"""Enable or disable an MCP server (takes effect on next session/gateway).
|
||||
|
||||
Toggles the ``enabled`` key on the server's config.yaml entry — the same
|
||||
flag the agent reads at startup. Disabled servers stay in config so they
|
||||
can be re-enabled without re-entering their settings.
|
||||
"""
|
||||
cfg = load_config()
|
||||
servers = cfg.get("mcp_servers")
|
||||
if not isinstance(servers, dict) or name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
if not isinstance(servers[name], dict):
|
||||
raise HTTPException(status_code=400, detail="Malformed server config")
|
||||
servers[name]["enabled"] = bool(body.enabled)
|
||||
save_config(cfg)
|
||||
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
|
||||
|
||||
|
||||
@app.get("/api/mcp/catalog")
|
||||
async def list_mcp_catalog():
|
||||
"""Browse the Nous-approved MCP catalog (the optional-mcps/ manifests).
|
||||
|
||||
Each entry reports whether it's already installed and enabled so the UI
|
||||
can show install / enabled state inline. This is the same catalog
|
||||
`hermes mcp catalog` / `hermes mcp install` read.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import mcp_catalog
|
||||
except Exception as exc:
|
||||
_log.exception("mcp_catalog import failed")
|
||||
raise HTTPException(status_code=500, detail=f"Catalog unavailable: {exc}")
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for entry in mcp_catalog.list_catalog():
|
||||
auth = entry.auth
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"description": entry.description,
|
||||
"source": entry.source,
|
||||
"transport": entry.transport.type,
|
||||
"auth_type": getattr(auth, "type", "none"),
|
||||
# Env vars the user must supply (names + prompts only, never values).
|
||||
"required_env": [
|
||||
{"name": e.name, "prompt": e.prompt, "required": e.required}
|
||||
for e in getattr(auth, "env", []) or []
|
||||
],
|
||||
"needs_install": entry.install is not None,
|
||||
"installed": mcp_catalog.is_installed(entry.name),
|
||||
"enabled": mcp_catalog.is_enabled(entry.name),
|
||||
})
|
||||
except Exception:
|
||||
_log.exception("list_mcp_catalog failed")
|
||||
|
||||
diagnostics = []
|
||||
try:
|
||||
diagnostics = [
|
||||
{"name": n, "kind": k, "message": m}
|
||||
for (n, k, m) in mcp_catalog.catalog_diagnostics()
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"entries": entries, "diagnostics": diagnostics}
|
||||
|
||||
|
||||
class MCPCatalogInstall(BaseModel):
|
||||
name: str
|
||||
# env: KEY=VALUE map for catalog entries that declare required env vars.
|
||||
env: Dict[str, str] = {}
|
||||
enable: bool = True
|
||||
|
||||
|
||||
@app.post("/api/mcp/catalog/install")
|
||||
async def install_mcp_catalog_entry(body: MCPCatalogInstall):
|
||||
"""Install a catalog MCP into config.yaml.
|
||||
|
||||
For HTTP/stdio entries with required env vars, those are written to .env
|
||||
via the standard env path so the agent can read them at session start.
|
||||
Entries that need a git bootstrap (``needs_install``) are installed via
|
||||
the CLI action path because the clone can take time.
|
||||
"""
|
||||
from hermes_cli import mcp_catalog
|
||||
|
||||
name = (body.name or "").strip()
|
||||
entry = mcp_catalog.get_entry(name)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"No catalog entry '{name}'")
|
||||
|
||||
# Persist any supplied env vars first (catalog entries declare which names
|
||||
# they need; we only write the ones the user provided).
|
||||
if body.env:
|
||||
for k, v in body.env.items():
|
||||
if v:
|
||||
save_env_value(k, v)
|
||||
|
||||
# Git-bootstrap entries can take a while to clone — run via the background
|
||||
# action path so the request returns immediately and the UI can tail logs.
|
||||
if entry.install is not None:
|
||||
try:
|
||||
proc = _spawn_hermes_action(["mcp", "install", name], "mcp-install")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Install failed: {exc}")
|
||||
return {"ok": True, "name": name, "background": True, "action": "mcp-install"}
|
||||
|
||||
# No git step — install synchronously via the catalog API.
|
||||
try:
|
||||
await asyncio.to_thread(mcp_catalog.install_entry, entry, enable=body.enable)
|
||||
except Exception as exc:
|
||||
_log.exception("install_mcp_catalog_entry failed")
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"ok": True, "name": name, "background": False}
|
||||
|
||||
|
||||
# Register the mcp-install action log so /api/actions/mcp-install/status works.
|
||||
_ACTION_LOG_FILES.setdefault("mcp-install", "action-mcp-install.log")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pairing endpoints — approve / revoke / list messaging pairing codes.
|
||||
#
|
||||
@@ -4231,6 +4704,8 @@ def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> D
|
||||
"url": f"{base_url}/webhooks/{name}",
|
||||
# Secret is masked on read; full value only returned on create.
|
||||
"secret_set": bool(route.get("secret")),
|
||||
# Default-enabled; only an explicit enabled:false turns a route off.
|
||||
"enabled": route.get("enabled", True) is not False,
|
||||
}
|
||||
|
||||
|
||||
@@ -4315,6 +4790,30 @@ async def delete_webhook(name: str):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class WebhookEnabledToggle(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@app.put("/api/webhooks/{name}/enabled")
|
||||
async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
|
||||
"""Enable or disable a webhook route.
|
||||
|
||||
Disabled routes stay in the subscriptions file (so they can be
|
||||
re-enabled) but the gateway rejects incoming events with 403. The
|
||||
gateway hot-reloads the subscriptions file, so this takes effect on the
|
||||
next event without a restart.
|
||||
"""
|
||||
import hermes_cli.webhook as wh
|
||||
|
||||
key = (name or "").strip().lower()
|
||||
subs = wh._load_subscriptions()
|
||||
if key not in subs:
|
||||
raise HTTPException(status_code=404, detail=f"No subscription named '{key}'")
|
||||
subs[key]["enabled"] = bool(body.enabled)
|
||||
wh._save_subscriptions(subs)
|
||||
return {"ok": True, "name": key, "enabled": bool(body.enabled)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway lifecycle endpoints — start / stop.
|
||||
#
|
||||
@@ -4636,38 +5135,160 @@ async def run_import(body: ImportRequest):
|
||||
|
||||
@app.get("/api/ops/hooks")
|
||||
async def list_hooks():
|
||||
"""Read-only list of configured shell hooks from config.yaml + allowlist."""
|
||||
"""List configured shell hooks from config.yaml with consent + health.
|
||||
|
||||
Reports each hook's allowlist (consent) status and whether the script is
|
||||
currently executable, plus the set of valid hook events so the create
|
||||
form can offer them.
|
||||
"""
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
from agent import shell_hooks
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import VALID_HOOKS
|
||||
valid_events = sorted(VALID_HOOKS)
|
||||
except Exception:
|
||||
valid_events = []
|
||||
|
||||
specs = []
|
||||
try:
|
||||
specs = shell_hooks.iter_configured_hooks(_load_config())
|
||||
except Exception:
|
||||
_log.exception("iter_configured_hooks failed")
|
||||
|
||||
out = []
|
||||
for spec in specs:
|
||||
entry = None
|
||||
try:
|
||||
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
|
||||
except Exception:
|
||||
pass
|
||||
executable = False
|
||||
try:
|
||||
executable = shell_hooks.script_is_executable(spec.command)
|
||||
except Exception:
|
||||
pass
|
||||
out.append({
|
||||
"event": spec.event,
|
||||
"matcher": spec.matcher,
|
||||
"command": spec.command,
|
||||
"timeout": spec.timeout,
|
||||
"allowed": entry is not None,
|
||||
"approved_at": (entry or {}).get("approved_at"),
|
||||
"executable": executable,
|
||||
})
|
||||
|
||||
return {"hooks": out, "valid_events": valid_events}
|
||||
|
||||
|
||||
class HookCreate(BaseModel):
|
||||
event: str
|
||||
command: str
|
||||
matcher: Optional[str] = None
|
||||
timeout: Optional[int] = None
|
||||
# approve: write the consent allowlist entry too (the operator using the
|
||||
# authenticated dashboard is giving consent). Without it the hook is
|
||||
# configured but won't fire until approved.
|
||||
approve: bool = True
|
||||
|
||||
|
||||
@app.post("/api/ops/hooks")
|
||||
async def create_hook(body: HookCreate):
|
||||
"""Add a shell hook to config.yaml (and optionally approve it).
|
||||
|
||||
Shell hooks run arbitrary commands, so this is a privileged action: it
|
||||
writes to the ``hooks:`` config block and, when ``approve`` is set, records
|
||||
consent in the allowlist so the hook actually fires. Takes effect on the
|
||||
next session / gateway restart.
|
||||
"""
|
||||
from agent import shell_hooks
|
||||
|
||||
event = (body.event or "").strip()
|
||||
command = (body.command or "").strip()
|
||||
if not event or not command:
|
||||
raise HTTPException(status_code=400, detail="event and command are required")
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import VALID_HOOKS
|
||||
if event not in VALID_HOOKS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown event '{event}'. Valid: {', '.join(sorted(VALID_HOOKS))}",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cfg = load_config()
|
||||
hooks_cfg = cfg.get("hooks")
|
||||
out = []
|
||||
if isinstance(hooks_cfg, dict):
|
||||
for event, entries in hooks_cfg.items():
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
out.append({
|
||||
"event": event,
|
||||
"matcher": entry.get("matcher"),
|
||||
"command": entry.get("command"),
|
||||
"timeout": entry.get("timeout"),
|
||||
})
|
||||
# Consent allowlist status (which commands have been approved for run).
|
||||
allowlist: List[str] = []
|
||||
if not isinstance(hooks_cfg, dict):
|
||||
hooks_cfg = {}
|
||||
cfg["hooks"] = hooks_cfg
|
||||
entries = hooks_cfg.get(event)
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
hooks_cfg[event] = entries
|
||||
|
||||
new_entry: Dict[str, Any] = {"command": command}
|
||||
if body.matcher:
|
||||
new_entry["matcher"] = body.matcher
|
||||
if body.timeout is not None:
|
||||
new_entry["timeout"] = int(body.timeout)
|
||||
entries.append(new_entry)
|
||||
save_config(cfg)
|
||||
|
||||
approved = False
|
||||
if body.approve:
|
||||
try:
|
||||
shell_hooks._record_approval(event, command)
|
||||
approved = True
|
||||
except Exception:
|
||||
_log.exception("hook consent record failed")
|
||||
|
||||
return {"ok": True, "event": event, "command": command, "approved": approved}
|
||||
|
||||
|
||||
class HookDelete(BaseModel):
|
||||
event: str
|
||||
command: str
|
||||
|
||||
|
||||
@app.delete("/api/ops/hooks")
|
||||
async def delete_hook(body: HookDelete):
|
||||
"""Remove a hook from config.yaml and revoke its consent allowlist entry."""
|
||||
from agent import shell_hooks
|
||||
|
||||
event = (body.event or "").strip()
|
||||
command = (body.command or "").strip()
|
||||
if not event or not command:
|
||||
raise HTTPException(status_code=400, detail="event and command are required")
|
||||
|
||||
cfg = load_config()
|
||||
hooks_cfg = cfg.get("hooks")
|
||||
removed = False
|
||||
if isinstance(hooks_cfg, dict) and isinstance(hooks_cfg.get(event), list):
|
||||
before = len(hooks_cfg[event])
|
||||
hooks_cfg[event] = [
|
||||
e for e in hooks_cfg[event]
|
||||
if not (isinstance(e, dict) and e.get("command") == command)
|
||||
]
|
||||
removed = len(hooks_cfg[event]) < before
|
||||
if not hooks_cfg[event]:
|
||||
del hooks_cfg[event]
|
||||
if not hooks_cfg:
|
||||
cfg.pop("hooks", None)
|
||||
save_config(cfg)
|
||||
|
||||
# Revoke consent regardless so a re-add re-prompts.
|
||||
try:
|
||||
allow_path = get_hermes_home() / "shell-hooks-allowlist.json"
|
||||
if allow_path.exists():
|
||||
data = json.loads(allow_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
allowlist = list(data.keys())
|
||||
elif isinstance(data, list):
|
||||
allowlist = [str(x) for x in data]
|
||||
shell_hooks.revoke(command)
|
||||
except Exception:
|
||||
_log.exception("Failed to read shell-hooks allowlist")
|
||||
for h in out:
|
||||
h["allowed"] = h.get("command") in allowlist
|
||||
return {"hooks": out, "allowlist": allowlist}
|
||||
pass
|
||||
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="No matching hook found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/ops/checkpoints")
|
||||
@@ -4766,6 +5387,46 @@ async def update_skills_hub():
|
||||
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
|
||||
|
||||
|
||||
@app.get("/api/skills/hub/search")
|
||||
async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20):
|
||||
"""Search the skill hub across all configured sources.
|
||||
|
||||
Network-bound (parallel source search); runs in a thread so the FastAPI
|
||||
loop isn't blocked. Returns structured results the UI installs by
|
||||
identifier via POST /api/skills/hub/install.
|
||||
"""
|
||||
query = (q or "").strip()
|
||||
if not query:
|
||||
return {"results": []}
|
||||
|
||||
def _run():
|
||||
from tools.skills_hub import create_source_router, unified_search
|
||||
|
||||
sources = create_source_router()
|
||||
metas = unified_search(
|
||||
query, sources, source_filter=source or "all", limit=min(max(limit, 1), 50)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"name": m.name,
|
||||
"description": m.description,
|
||||
"source": m.source,
|
||||
"identifier": m.identifier,
|
||||
"trust_level": m.trust_level,
|
||||
"repo": m.repo,
|
||||
"tags": list(m.tags or []),
|
||||
}
|
||||
for m in metas
|
||||
]
|
||||
|
||||
try:
|
||||
results = await asyncio.to_thread(_run)
|
||||
except Exception as exc:
|
||||
_log.exception("skills hub search failed")
|
||||
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
|
||||
return {"results": results}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile management endpoints (minimal — list/create/rename/delete + SOUL.md)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+34
-1
@@ -264,6 +264,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
handoff_platform TEXT,
|
||||
handoff_error TEXT,
|
||||
rewind_count INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
|
||||
@@ -1430,6 +1431,22 @@ class SessionDB:
|
||||
row = cursor.fetchone()
|
||||
return row["title"] if row else None
|
||||
|
||||
def set_session_archived(self, session_id: str, archived: bool) -> bool:
|
||||
"""Archive or unarchive a session.
|
||||
|
||||
Archived sessions are hidden from the default session list but keep all
|
||||
their messages — this is a soft hide, not a delete. Returns True when a
|
||||
row was updated.
|
||||
"""
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
"UPDATE sessions SET archived = ? WHERE id = ?",
|
||||
(1 if archived else 0, session_id),
|
||||
)
|
||||
return cursor.rowcount
|
||||
rowcount = self._execute_write(_do)
|
||||
return rowcount > 0
|
||||
|
||||
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a session by exact title. Returns session dict or None."""
|
||||
with self._lock:
|
||||
@@ -1549,6 +1566,8 @@ class SessionDB:
|
||||
min_message_count: int = 0,
|
||||
project_compression_tips: bool = True,
|
||||
order_by_last_active: bool = False,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions with preview (first user message) and last active timestamp.
|
||||
|
||||
@@ -1604,6 +1623,10 @@ class SessionDB:
|
||||
if min_message_count > 0:
|
||||
where_clauses.append("s.message_count >= ?")
|
||||
params.append(min_message_count)
|
||||
if archived_only:
|
||||
where_clauses.append("s.archived = 1")
|
||||
elif not include_archived:
|
||||
where_clauses.append("s.archived = 0")
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
if order_by_last_active:
|
||||
@@ -3027,7 +3050,13 @@ class SessionDB:
|
||||
# Utility
|
||||
# =========================================================================
|
||||
|
||||
def session_count(self, source: str = None, min_message_count: int = 0) -> int:
|
||||
def session_count(
|
||||
self,
|
||||
source: str = None,
|
||||
min_message_count: int = 0,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
) -> int:
|
||||
"""Count sessions, optionally filtered by source."""
|
||||
where_clauses = []
|
||||
params = []
|
||||
@@ -3038,6 +3067,10 @@ class SessionDB:
|
||||
if min_message_count > 0:
|
||||
where_clauses.append("message_count >= ?")
|
||||
params.append(min_message_count)
|
||||
if archived_only:
|
||||
where_clauses.append("archived = 1")
|
||||
elif not include_archived:
|
||||
where_clauses.append("archived = 0")
|
||||
|
||||
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
|
||||
|
||||
@@ -228,6 +228,9 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
self._session_initialized = False
|
||||
self._lazy_init_kwargs: Optional[dict] = None
|
||||
self._lazy_init_session_id: Optional[str] = None
|
||||
self._init_thread: Optional[threading.Thread] = None
|
||||
self._init_lock = threading.Lock()
|
||||
self._init_error = ""
|
||||
|
||||
# Port #4053: cron guard — when True, plugin is fully inactive
|
||||
self._cron_skipped = False
|
||||
@@ -326,22 +329,24 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
# aiPeer comes from honcho.json (host block or root) only.
|
||||
# SOUL.md is persona content, not identity config.
|
||||
|
||||
# ----- Port #1957: lazy session init for tools-only mode -----
|
||||
self._lazy_init_kwargs = dict(kwargs)
|
||||
self._lazy_init_session_id = session_id
|
||||
self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
|
||||
|
||||
# Network-backed session creation can block on Honcho service or DB
|
||||
# outages. Startup must fail open for context/hybrid modes, where
|
||||
# Honcho is initialized only to enrich prompts. Tools-only mode has
|
||||
# an explicit contract: init_on_session_start=False stays lazy until
|
||||
# the first tool call, while init_on_session_start=True remains an
|
||||
# eager, ready-on-return initialization path.
|
||||
if self._recall_mode == "tools":
|
||||
if cfg.init_on_session_start:
|
||||
# Eager init even in tools mode (opt-in)
|
||||
self._do_session_init(cfg, session_id, **kwargs)
|
||||
self._ensure_session()
|
||||
return
|
||||
# Defer actual session creation until first tool call
|
||||
self._lazy_init_kwargs = kwargs
|
||||
self._lazy_init_session_id = session_id
|
||||
# Still need a client reference for _ensure_session
|
||||
self._config = cfg
|
||||
logger.debug("Honcho tools-only mode — deferring session init until first tool call")
|
||||
return
|
||||
|
||||
# ----- Eager init (context or hybrid mode) -----
|
||||
self._do_session_init(cfg, session_id, **kwargs)
|
||||
self._start_session_init_background(wait_timeout=0.1)
|
||||
|
||||
except ImportError:
|
||||
logger.debug("honcho-ai package not installed — plugin inactive")
|
||||
@@ -349,6 +354,66 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
logger.warning("Honcho init failed: %s", e)
|
||||
self._manager = None
|
||||
|
||||
def _resolve_session_key(self, cfg, session_id: str, **kwargs) -> str:
|
||||
"""Resolve the Honcho session key without touching the network."""
|
||||
session_title = kwargs.get("session_title")
|
||||
gateway_session_key = kwargs.get("gateway_session_key")
|
||||
return (
|
||||
cfg.resolve_session_name(
|
||||
session_title=session_title,
|
||||
session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
or session_id
|
||||
or "hermes-default"
|
||||
)
|
||||
|
||||
def _start_session_init_background(self, *, wait_timeout: float = 0.0) -> None:
|
||||
"""Start Honcho session initialization in a daemon thread.
|
||||
|
||||
This keeps Hermes CLI/gateway startup responsive when Honcho is down,
|
||||
slow, or its database is unhealthy. The thread may still take the SDK
|
||||
timeout path, but it cannot block agent construction or first prompt
|
||||
assembly. ``wait_timeout`` lets fast/mock initializations finish before
|
||||
returning while still failing open for slow backends.
|
||||
"""
|
||||
if self._cron_skipped or self._session_initialized:
|
||||
return
|
||||
if not self._config or self._lazy_init_kwargs is None:
|
||||
return
|
||||
|
||||
with self._init_lock:
|
||||
if self._cron_skipped or self._session_initialized:
|
||||
return
|
||||
if self._init_thread and self._init_thread.is_alive():
|
||||
return
|
||||
if not self._config or self._lazy_init_kwargs is None:
|
||||
return
|
||||
|
||||
cfg = self._config
|
||||
init_kwargs = dict(self._lazy_init_kwargs)
|
||||
init_session_id = self._lazy_init_session_id or "hermes-default"
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
self._do_session_init(cfg, init_session_id, **init_kwargs)
|
||||
self._lazy_init_kwargs = None
|
||||
self._lazy_init_session_id = None
|
||||
self._init_error = ""
|
||||
except Exception as e:
|
||||
self._init_error = str(e)
|
||||
self._manager = None
|
||||
logger.warning("Honcho background session init failed: %s", e)
|
||||
|
||||
self._init_thread = threading.Thread(
|
||||
target=_run,
|
||||
daemon=True,
|
||||
name="honcho-session-init",
|
||||
)
|
||||
self._init_thread.start()
|
||||
if wait_timeout > 0:
|
||||
self._init_thread.join(timeout=wait_timeout)
|
||||
|
||||
def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:
|
||||
"""Shared session initialization logic for both eager and lazy paths."""
|
||||
from plugins.memory.honcho.client import get_honcho_client
|
||||
@@ -364,22 +429,15 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
)
|
||||
|
||||
# ----- B3: resolve_session_name -----
|
||||
session_title = kwargs.get("session_title")
|
||||
gateway_session_key = kwargs.get("gateway_session_key")
|
||||
self._session_key = (
|
||||
cfg.resolve_session_name(
|
||||
session_title=session_title,
|
||||
session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
or session_id
|
||||
or "hermes-default"
|
||||
)
|
||||
self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
|
||||
logger.debug("Honcho session key resolved: %s", self._session_key)
|
||||
|
||||
# Create session eagerly
|
||||
# Create the remote session before running startup-only migration and
|
||||
# prewarm work. Do not mark the provider ready until this method's
|
||||
# synchronous setup has finished; background startup sets _manager before
|
||||
# get_or_create()/migration/prewarm are complete, and lifecycle hooks must
|
||||
# not treat that partially initialized state as usable.
|
||||
session = self._manager.get_or_create(self._session_key)
|
||||
self._session_initialized = True
|
||||
|
||||
# ----- B6: Memory file migration (one-time, for new sessions) -----
|
||||
# Skip under per-session strategy: every Hermes run creates a fresh
|
||||
@@ -434,12 +492,15 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
prewarm_thread = threading.Thread(
|
||||
target=_prewarm_dialectic, daemon=True, name="honcho-prewarm-dialectic"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
prewarm_thread.start()
|
||||
self._prefetch_thread = prewarm_thread
|
||||
logger.debug("Honcho pre-warm started for session: %s", self._session_key)
|
||||
|
||||
self._session_initialized = True
|
||||
|
||||
def _ensure_session(self) -> bool:
|
||||
"""Lazily initialize the Honcho session (for tools-only mode).
|
||||
|
||||
@@ -449,7 +510,9 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
return True
|
||||
if self._cron_skipped:
|
||||
return False
|
||||
if not self._config or not self._lazy_init_kwargs:
|
||||
if self._init_thread and self._init_thread.is_alive():
|
||||
return False
|
||||
if not self._config or self._lazy_init_kwargs is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -463,9 +526,26 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
self._lazy_init_session_id = None
|
||||
return self._manager is not None
|
||||
except Exception as e:
|
||||
self._manager = None
|
||||
self._session_initialized = False
|
||||
logger.warning("Honcho lazy session init failed: %s", e)
|
||||
return False
|
||||
|
||||
def _session_ready(self) -> bool:
|
||||
"""Return whether a manager/session key can be used safely.
|
||||
|
||||
Background initialization sets ``_manager`` before the blocking
|
||||
get-or-create call completes, so ``_session_initialized`` guards real
|
||||
async startup. Tests and legacy direct construction may inject a ready
|
||||
manager/session key without setting that flag; allow that only when no
|
||||
init thread is currently in flight.
|
||||
"""
|
||||
if not self._manager or not self._session_key:
|
||||
return False
|
||||
if self._session_initialized:
|
||||
return True
|
||||
return not (self._init_thread and self._init_thread.is_alive())
|
||||
|
||||
def _format_first_turn_context(self, ctx: dict) -> str:
|
||||
"""Format the prefetch context dict into a readable system prompt block."""
|
||||
parts = []
|
||||
@@ -505,14 +585,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
if self._cron_skipped:
|
||||
return ""
|
||||
if not self._manager or not self._session_key:
|
||||
# tools-only mode without session yet still returns a minimal block
|
||||
if self._recall_mode == "tools" and self._config:
|
||||
return (
|
||||
"# Honcho Memory\n"
|
||||
"Active (tools-only mode). Use honcho_profile, honcho_search, "
|
||||
"honcho_reasoning, honcho_context, and honcho_conclude tools to access user memory."
|
||||
)
|
||||
return ""
|
||||
if not self._config:
|
||||
return ""
|
||||
|
||||
# ----- B1: adapt text based on recall_mode -----
|
||||
if self._recall_mode == "context":
|
||||
@@ -563,6 +637,10 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
if self._recall_mode == "tools":
|
||||
return ""
|
||||
|
||||
if not self._session_ready():
|
||||
self._start_session_init_background()
|
||||
return ""
|
||||
|
||||
# B5: injection_frequency — if "first-turn" and past first turn, return empty.
|
||||
# _turn_count is 1-indexed (first user message = 1), so > 1 means "past first".
|
||||
if self._injection_frequency == "first-turn" and self._turn_count > 1:
|
||||
@@ -575,18 +653,17 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
parts = []
|
||||
|
||||
# ----- Layer 1: Base context (representation + card) -----
|
||||
# On first call, fetch synchronously so turn 1 isn't empty.
|
||||
# After that, serve from cache and refresh in background on cadence.
|
||||
# First fetch is asynchronous: a slow Honcho backend must not block the
|
||||
# first response. Serve empty context now and consume the background
|
||||
# result on a later turn.
|
||||
with self._base_context_lock:
|
||||
if self._base_context_cache is None:
|
||||
# First call — synchronous fetch
|
||||
self._base_context_cache = ""
|
||||
self._last_context_turn = self._turn_count
|
||||
try:
|
||||
ctx = self._manager.get_prefetch_context(self._session_key)
|
||||
self._base_context_cache = self._format_first_turn_context(ctx) if ctx else ""
|
||||
self._last_context_turn = self._turn_count
|
||||
self._manager.prefetch_context(self._session_key, query or None)
|
||||
except Exception as e:
|
||||
logger.debug("Honcho base context fetch failed: %s", e)
|
||||
self._base_context_cache = ""
|
||||
logger.debug("Honcho base context prefetch failed: %s", e)
|
||||
base_context = self._base_context_cache
|
||||
|
||||
# Check if background context prefetch has a fresher result
|
||||
@@ -641,10 +718,11 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
first_turn_thread = threading.Thread(
|
||||
target=_run_first_turn, daemon=True, name="honcho-prefetch-first"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
first_turn_thread.start()
|
||||
self._prefetch_thread = first_turn_thread
|
||||
self._prefetch_thread.join(timeout=_first_turn_timeout)
|
||||
if self._prefetch_thread.is_alive():
|
||||
logger.debug(
|
||||
@@ -709,13 +787,14 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
"""
|
||||
if self._cron_skipped:
|
||||
return
|
||||
if not self._manager or not self._session_key or not query:
|
||||
return
|
||||
|
||||
# B1: tools-only mode — no prefetch
|
||||
if self._recall_mode == "tools":
|
||||
return
|
||||
|
||||
if not self._session_ready() or not query:
|
||||
self._start_session_init_background()
|
||||
return
|
||||
|
||||
# Trivial prompts don't warrant either a context refresh or a dialectic call.
|
||||
if self._is_trivial_prompt(query):
|
||||
return
|
||||
@@ -769,10 +848,11 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
prefetch_thread = threading.Thread(
|
||||
target=_run, daemon=True, name="honcho-prefetch"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
prefetch_thread.start()
|
||||
self._prefetch_thread = prefetch_thread
|
||||
|
||||
# ----- Dialectic depth: multi-pass .chat() with cold/warm prompts -----
|
||||
|
||||
@@ -1126,7 +1206,10 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
"""
|
||||
if self._cron_skipped:
|
||||
return
|
||||
if not self._manager or not self._session_key:
|
||||
if self._recall_mode == "tools" and not self._session_ready():
|
||||
return
|
||||
if not self._session_ready():
|
||||
self._start_session_init_background()
|
||||
return
|
||||
|
||||
msg_limit = self._config.message_max_chars if self._config else 25000
|
||||
@@ -1169,7 +1252,10 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
return
|
||||
if self._cron_skipped:
|
||||
return
|
||||
if not self._manager or not self._session_key:
|
||||
if self._recall_mode == "tools" and not self._session_ready():
|
||||
return
|
||||
if not self._session_ready():
|
||||
self._start_session_init_background()
|
||||
return
|
||||
|
||||
def _write():
|
||||
@@ -1187,6 +1273,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
return
|
||||
if not self._manager:
|
||||
return
|
||||
if not self._session_initialized and self._init_thread and self._init_thread.is_alive():
|
||||
return
|
||||
# Wait for pending sync
|
||||
if self._sync_thread and self._sync_thread.is_alive():
|
||||
self._sync_thread.join(timeout=10.0)
|
||||
@@ -1213,6 +1301,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
|
||||
# Port #1957: ensure session is initialized for tools-only mode
|
||||
if not self._session_initialized:
|
||||
if self._init_thread and self._init_thread.is_alive():
|
||||
return tool_error("Honcho session is still initializing; try again shortly.")
|
||||
if not self._ensure_session():
|
||||
return tool_error("Honcho session could not be initialized.")
|
||||
|
||||
@@ -1313,7 +1403,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
# Flush any remaining messages
|
||||
if self._manager:
|
||||
if self._manager and not (self._init_thread and self._init_thread.is_alive() and not self._session_initialized):
|
||||
try:
|
||||
self._manager.flush_all()
|
||||
except Exception:
|
||||
|
||||
@@ -56,7 +56,7 @@ gemini = GeminiProfile(
|
||||
env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta",
|
||||
auth_type="api_key",
|
||||
default_aux_model="gemini-3-flash-preview",
|
||||
default_aux_model="gemini-3.5-flash",
|
||||
)
|
||||
|
||||
google_gemini_cli = GeminiProfile(
|
||||
|
||||
@@ -269,7 +269,13 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _health_monitor(self) -> None:
|
||||
"""Force reconnect if the WebSocket has been idle too long."""
|
||||
"""Observe WebSocket idleness without reconnecting healthy quiet links.
|
||||
|
||||
simplex-chat can legitimately stay application-silent for long periods
|
||||
when no messages arrive. The websockets client already sends protocol
|
||||
pings (see _ws_listener ping_interval/ping_timeout), so treating lack of
|
||||
chat events as a stale connection causes needless reconnect churn.
|
||||
"""
|
||||
while self._running:
|
||||
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
|
||||
if not self._running:
|
||||
@@ -277,15 +283,7 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
|
||||
elapsed = time.time() - self._last_ws_activity
|
||||
if elapsed > HEALTH_CHECK_STALE_THRESHOLD:
|
||||
logger.warning(
|
||||
"SimpleX: WS idle for %.0fs, forcing reconnect", elapsed
|
||||
)
|
||||
self._last_ws_activity = time.time()
|
||||
if self._ws:
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("SimpleX: WS application-idle for %.0fs", elapsed)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inbound event handling
|
||||
@@ -293,7 +291,12 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _handle_event(self, event: dict) -> None:
|
||||
"""Dispatch a daemon event to the appropriate handler."""
|
||||
resp_type = event.get("type") or event.get("resp", {}).get("type", "")
|
||||
# simplex-chat WebSocket messages are usually shaped as:
|
||||
# {"corrId": "...", "resp": {"type": "newChatItems", ...}}
|
||||
# Older/examples may put the response fields at top-level. Normalize
|
||||
# both forms before dispatching, otherwise inbound chatItems are lost.
|
||||
resp = event.get("resp") if isinstance(event.get("resp"), dict) else event
|
||||
resp_type = event.get("type") or resp.get("type", "")
|
||||
|
||||
# Filter responses to our own commands (echoes)
|
||||
corr_id = event.get("corrId", "")
|
||||
@@ -302,10 +305,10 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
return
|
||||
|
||||
if resp_type == "newChatItem":
|
||||
await self._handle_new_chat_item(event)
|
||||
await self._handle_new_chat_item(resp)
|
||||
elif resp_type == "newChatItems":
|
||||
# Batch variant — process each item
|
||||
items = event.get("chatItems") or []
|
||||
items = resp.get("chatItems") or []
|
||||
for item_wrapper in items:
|
||||
await self._handle_new_chat_item(item_wrapper)
|
||||
# Ignore all other event types (delivery receipts, contact updates, etc.)
|
||||
@@ -347,7 +350,9 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
or contact_info.get("localDisplayName")
|
||||
or contact_id
|
||||
)
|
||||
chat_id = contact_id
|
||||
# Replies must be routed by SimpleX CLI display name, while
|
||||
# authorization should still use the stable numeric contactId.
|
||||
chat_id = contact_name or contact_id
|
||||
chat_name = contact_name
|
||||
|
||||
if not chat_id:
|
||||
@@ -364,7 +369,7 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
or sender_id
|
||||
)
|
||||
else:
|
||||
sender_id = chat_id
|
||||
sender_id = contact_id if not is_group else chat_id
|
||||
sender_name = chat_name
|
||||
|
||||
# Extract text
|
||||
@@ -508,7 +513,11 @@ class SimplexAdapter(BasePlatformAdapter):
|
||||
group_id = chat_id[6:]
|
||||
cmd_str = f"#[{group_id}] {content}"
|
||||
else:
|
||||
cmd_str = f"@[{chat_id}] {content}"
|
||||
# SimpleX CLI addresses direct contacts by display name, e.g.
|
||||
# `@Alice hello`. `@[Alice]` is interpreted literally as a contact
|
||||
# named "[Alice]" and `@[4]` as "[4]", so do not wrap direct
|
||||
# chat IDs / display names in brackets.
|
||||
cmd_str = f"@{chat_id} {content}"
|
||||
|
||||
payload = {
|
||||
"corrId": corr_id,
|
||||
@@ -643,7 +652,8 @@ async def _standalone_send(
|
||||
group_id = chat_id[6:]
|
||||
cmd_str = f"#[{group_id}] {message}"
|
||||
else:
|
||||
cmd_str = f"@[{chat_id}] {message}"
|
||||
# Direct contacts are addressed by display name without brackets.
|
||||
cmd_str = f"@{chat_id} {message}"
|
||||
|
||||
payload = {
|
||||
"corrId": f"hermes-snd-{int(time.time() * 1000)}",
|
||||
|
||||
@@ -21,9 +21,12 @@ delivers it.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
@@ -42,7 +45,9 @@ logger = logging.getLogger(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
|
||||
DEFAULT_MODEL = "grok-imagine-video"
|
||||
DEFAULT_TEXT_TO_VIDEO_MODEL = "grok-imagine-video"
|
||||
DEFAULT_IMAGE_TO_VIDEO_MODEL = "grok-imagine-video-1.5-preview"
|
||||
DEFAULT_MODEL = DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||
DEFAULT_DURATION = 8
|
||||
DEFAULT_ASPECT_RATIO = "16:9"
|
||||
DEFAULT_RESOLUTION = "720p"
|
||||
@@ -58,10 +63,18 @@ _MODELS: Dict[str, Dict[str, Any]] = {
|
||||
"grok-imagine-video": {
|
||||
"display": "Grok Imagine Video",
|
||||
"speed": "~60-240s",
|
||||
"strengths": "Text-to-video + image-to-video; up to 7 reference images for style/character.",
|
||||
"price": "see https://docs.x.ai/docs/models",
|
||||
"strengths": "Text-to-video; legacy image-to-video fallback.",
|
||||
"price": "see https://docs.x.ai/developers/models/grok-imagine-video",
|
||||
"modalities": ["text", "image"],
|
||||
},
|
||||
"grok-imagine-video-1.5-preview": {
|
||||
"display": "Grok Imagine Video 1.5 Preview",
|
||||
"speed": "~60-240s",
|
||||
"strengths": "Latest xAI image-to-video model.",
|
||||
"price": "see https://docs.x.ai/developers/models/grok-imagine-video-1.5-preview",
|
||||
"modalities": ["image"],
|
||||
"aliases": ["grok-imagine-video-1.5-2026-05-30"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -111,10 +124,31 @@ def _xai_headers(api_key: str) -> Dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _image_ref_to_xai_url(value: str) -> str:
|
||||
"""Return a URL/data URI accepted by xAI for image inputs."""
|
||||
ref = (value or "").strip()
|
||||
if not ref:
|
||||
return ""
|
||||
lower = ref.lower()
|
||||
if lower.startswith(("http://", "https://", "data:image/")):
|
||||
return ref
|
||||
|
||||
path = Path(ref).expanduser()
|
||||
if not path.is_file():
|
||||
return ref
|
||||
|
||||
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
if not mime.startswith("image/"):
|
||||
return ref
|
||||
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def _normalize_reference_images(reference_image_urls: Optional[List[str]]):
|
||||
refs = []
|
||||
for url in reference_image_urls or []:
|
||||
normalized = (url or "").strip()
|
||||
normalized = _image_ref_to_xai_url(url)
|
||||
if normalized:
|
||||
refs.append({"url": normalized})
|
||||
return refs or None
|
||||
@@ -131,6 +165,28 @@ def _clamp_duration(duration: Optional[int], has_reference_images: bool) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_model_for_modality(
|
||||
model: Optional[str],
|
||||
*,
|
||||
modality: str,
|
||||
explicit_model: bool,
|
||||
) -> str:
|
||||
"""Select xAI's text/video model without treating config as a prompt override.
|
||||
|
||||
``grok-imagine-video-1.5-preview`` currently rejects text-only video
|
||||
generation, but it is the desired image-to-video backend. Explicit tool
|
||||
``model=`` still wins for users who intentionally request another model.
|
||||
"""
|
||||
requested = (model or "").strip()
|
||||
if explicit_model and requested:
|
||||
return requested
|
||||
if modality == "image":
|
||||
return DEFAULT_IMAGE_TO_VIDEO_MODEL
|
||||
if requested == DEFAULT_IMAGE_TO_VIDEO_MODEL:
|
||||
return DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||
return requested or DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||
|
||||
|
||||
async def _submit(
|
||||
client: httpx.AsyncClient,
|
||||
payload: Dict[str, Any],
|
||||
@@ -192,7 +248,7 @@ async def _poll(
|
||||
|
||||
|
||||
class XAIVideoGenProvider(VideoGenProvider):
|
||||
"""xAI grok-imagine-video backend (text-to-video + image-to-video)."""
|
||||
"""xAI Grok Imagine video backend (text-to-video + image-to-video)."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -222,7 +278,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
return {
|
||||
"name": "xAI Grok Imagine",
|
||||
"badge": "paid",
|
||||
"tag": "grok-imagine-video — text-to-video & image-to-video; uses xAI Grok OAuth or XAI_API_KEY",
|
||||
"tag": "grok-imagine-video for text-to-video; grok-imagine-video-1.5-preview for image-to-video; uses xAI Grok OAuth or XAI_API_KEY",
|
||||
"env_vars": [],
|
||||
"post_setup": "xai_grok",
|
||||
}
|
||||
@@ -260,6 +316,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
return loop.run_until_complete(self._generate_async(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
explicit_model=bool(kwargs.get("_model_override_explicit")),
|
||||
image_url=image_url,
|
||||
reference_image_urls=reference_image_urls,
|
||||
duration=duration,
|
||||
@@ -284,6 +341,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
*,
|
||||
prompt: str,
|
||||
model: Optional[str],
|
||||
explicit_model: bool,
|
||||
image_url: Optional[str],
|
||||
reference_image_urls: Optional[List[str]],
|
||||
duration: Optional[int],
|
||||
@@ -303,10 +361,15 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
)
|
||||
|
||||
prompt = (prompt or "").strip()
|
||||
image_url_norm = (image_url or "").strip() or None
|
||||
image_url_norm = _image_ref_to_xai_url(image_url or "") or None
|
||||
normalized_aspect_ratio = (aspect_ratio or DEFAULT_ASPECT_RATIO).strip()
|
||||
normalized_resolution = (resolution or DEFAULT_RESOLUTION).strip().lower()
|
||||
modality_used = "image" if image_url_norm else "text"
|
||||
resolved_model = _resolve_model_for_modality(
|
||||
model,
|
||||
modality=modality_used,
|
||||
explicit_model=explicit_model,
|
||||
)
|
||||
|
||||
if not prompt:
|
||||
return error_response(
|
||||
@@ -340,7 +403,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
normalized_resolution = DEFAULT_RESOLUTION
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model or DEFAULT_MODEL,
|
||||
"model": resolved_model,
|
||||
"prompt": prompt,
|
||||
"duration": clamped_duration,
|
||||
"aspect_ratio": normalized_aspect_ratio,
|
||||
@@ -366,7 +429,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
error=f"xAI submit failed ({exc.response.status_code}): {detail or exc}",
|
||||
error_type="api_error",
|
||||
provider="xai",
|
||||
model=model or DEFAULT_MODEL,
|
||||
model=resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
@@ -388,7 +451,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
error="xAI video generation completed without a video URL",
|
||||
error_type="empty_response",
|
||||
provider="xai",
|
||||
model=body.get("model") or model or DEFAULT_MODEL,
|
||||
model=body.get("model") or resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
extra: Dict[str, Any] = {
|
||||
@@ -399,7 +462,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
extra["usage"] = body["usage"]
|
||||
return success_response(
|
||||
video=url,
|
||||
model=body.get("model") or model or DEFAULT_MODEL,
|
||||
model=body.get("model") or resolved_model,
|
||||
prompt=prompt,
|
||||
modality=modality_used,
|
||||
aspect_ratio=normalized_aspect_ratio,
|
||||
@@ -413,7 +476,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
error=f"Timed out waiting for video generation after {DEFAULT_TIMEOUT_SECONDS}s",
|
||||
error_type="timeout",
|
||||
provider="xai",
|
||||
model=model or DEFAULT_MODEL,
|
||||
model=resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
@@ -426,7 +489,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
error=message,
|
||||
error_type=f"xai_{status}",
|
||||
provider="xai",
|
||||
model=model or DEFAULT_MODEL,
|
||||
model=resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: xai
|
||||
version: 1.0.0
|
||||
description: "xAI Grok-Imagine video generation backend. Supports text-to-video, image-to-video, reference-image-guided generation, video edit, and video extend via the xAI async videos API."
|
||||
description: "xAI Grok Imagine video generation backend. Supports text-to-video, image-to-video, and reference-image-guided generation via the xAI async videos API."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
|
||||
@@ -45,9 +45,13 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"ben.bartholomew@vectorize.io": "benfrank241",
|
||||
"74339271+SaguaroDev@users.noreply.github.com": "SaguaroDev",
|
||||
"subw3@mail2.sysu.edu.cn": "Subway2023",
|
||||
"trevin@trevinchow.com": "tmchow",
|
||||
"zhipengli@thebrainly.ai": "a1245582339",
|
||||
"mathijs.vd.hurk@gmail.com": "mathijsvandenhurk",
|
||||
"david.gutowsky@gmail.com": "davidgut1982",
|
||||
"drpelagik@gmail.com": "SeaXen",
|
||||
"lengr@users.noreply.github.com": "LengR",
|
||||
"17255546+CharZhou@users.noreply.github.com": "CharZhou",
|
||||
@@ -61,6 +65,7 @@ AUTHOR_MAP = {
|
||||
"524706+Twanislas@users.noreply.github.com": "Twanislas",
|
||||
"9592417+adam91holt@users.noreply.github.com": "adam91holt",
|
||||
"kchuang1015@users.noreply.github.com": "kchuang1015",
|
||||
"kyssta-exe@users.noreply.github.com": "kyssta-exe",
|
||||
"45688690+fujinice@users.noreply.github.com": "fujinice",
|
||||
"276689385+carltonawong@users.noreply.github.com": "carltonawong",
|
||||
"195255660+EvilHumphrey@users.noreply.github.com": "EvilHumphrey",
|
||||
@@ -83,6 +88,7 @@ AUTHOR_MAP = {
|
||||
"33978413+Interstellar-code@users.noreply.github.com": "Interstellar-code",
|
||||
"tillfalko@gmail.com": "tillfalko",
|
||||
"hi@fesalfayed.com": "fesalfayed",
|
||||
"marek.les@seznam.cz": "maxcz79",
|
||||
# teknium (multiple emails)
|
||||
"teknium1@gmail.com": "teknium1",
|
||||
"kenyon1977@gmail.com": "kenyonxu",
|
||||
@@ -1198,6 +1204,7 @@ AUTHOR_MAP = {
|
||||
"zhicheng.han@mathematik.uni-goettingen.de": "hanzckernel", # PR #20311 (api-server approval events)
|
||||
"agentsmithlaor@gmail.com": "oferlaor", # PR #22356 salvage (cron origin sender identity)
|
||||
"jhin.lee@unity3d.com": "leehack", # PR #22053 salvage (telegram DM topic reply fallback)
|
||||
"caojiguang@gmail.com": "caojiguang", # PR #35117 carries #31853 (weixin _api_post/_api_get wait_for)
|
||||
# pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan
|
||||
"ayman.a.kamal@hotmail.com": "A-kamal", # PR #18678 (xAI image resolution fix)
|
||||
# Kanban bug-fix batch salvage (May 2026)
|
||||
@@ -1416,6 +1423,7 @@ AUTHOR_MAP = {
|
||||
"me@simontaggart.com": "SiTaggart", # PR #35583 (docker_forward_env empty-secret .env fallback)
|
||||
"2663402852@qq.com": "x1am1", # PR #35098 (chown root-owned top-level HERMES_HOME state files)
|
||||
"nicsequenzy@gmail.com": "polnikale", # PR #35717 (discover Playwright headless_shell browser)
|
||||
"wasdhkzk@gmail.com": "whyhkzk", # PR #32407 (sandbox-mirror inner-container guard; commits authored as whyhkzk + zhukun)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
name: dynamic-workflow
|
||||
description: Orchestrate large fan-out work as a plan-in-code "workflow" so the agent's context holds only the final verified answer, not the exhaust of hundreds of intermediate steps. Use for codebase-wide sweeps, large migrations, multi-angle research, and any task too big for one context window where the split strategy is known enough to script. Includes the adversarial-convergence verification recipe (independent attempts + refuters, keep only surviving claims).
|
||||
version: 1.0.0
|
||||
author: Hermes Agent + Teknium
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [orchestration, fan-out, subagents, delegation, verification, migration, audit, research]
|
||||
category: autonomous-ai-agents
|
||||
related_skills: []
|
||||
when_to_use:
|
||||
- A task is too big for one context window AND you can describe the split (per-file, per-endpoint, per-source, per-record)
|
||||
- You want orchestration codified as a re-runnable script, not improvised turn-by-turn
|
||||
- Quality matters more than token economy: you want independent attempts cross-checked / refuted before you trust the answer
|
||||
- Codebase-wide bug/security sweep, 100+ file migration, multi-angle research with sources cross-checked
|
||||
when_not_to_use:
|
||||
- Small bounded task (<~10 units) — just call the tool directly or do it inline
|
||||
- Tight serial dependency (B needs A's output) — orchestration overhead is wasted
|
||||
- You need it to survive the user sending a new message — see "The synchronous trap" below; use cron/kanban instead
|
||||
---
|
||||
|
||||
# Dynamic Workflow — plan-in-code fan-out with verification
|
||||
|
||||
This is Hermes's answer to Claude Code's "dynamic workflows" (run hundreds of
|
||||
parallel subagents in one session). The mechanic worth copying is NOT "more
|
||||
subagents" — it is **moving the plan, the loop, and the intermediate results
|
||||
OUT of the context window and INTO a script.** Normally the agent IS the
|
||||
orchestrator: every intermediate result piles into context, which is exactly
|
||||
what caps you at a handful of agents. A workflow keeps only the *final verified
|
||||
answer* in context; the script holds everything else.
|
||||
|
||||
> This skill is self-contained, but it builds on standard fan-out hygiene —
|
||||
> chunk inputs to ~50-70KB per child, route structured output to files (not the
|
||||
> `summary` field, which truncates under load), use delimiter-separated lines
|
||||
> over JSON wrappers, and remember that a "stalled" child often completed its
|
||||
> write anyway (check the filesystem before retrying). If your install has a
|
||||
> `delegate-task-output-patterns` skill, load it for the detailed thresholds;
|
||||
> the rules above are the load-bearing subset.
|
||||
|
||||
## The two orchestration-script layers (pick the right one — they are NOT interchangeable)
|
||||
|
||||
Hermes has no JS runtime. The "orchestration script" is one of two layers, and
|
||||
the split is enforced by a real capability boundary, not a style preference:
|
||||
|
||||
| | Layer A: `execute_code` (Python script) | Layer B: `delegate_task` batch |
|
||||
|---|---|---|
|
||||
| Use for | DETERMINISTIC fan-out — fetch N URLs, parse N files, run N shell commands, template N outputs | LLM-JUDGMENT fan-out — classify, review, decide, write, refute, audit per item |
|
||||
| The script holds | the loop + branching + intermediate vars (real Python) | n/a — you call it once with a `tasks=[...]` array; each task is its own isolated agent |
|
||||
| Tools available inside | `web_search, web_extract, read_file, write_file, search_files, terminal, patch` ONLY (the `SANDBOX_ALLOWED_TOOLS` set) | configured child toolsets, subject to delegate restrictions (leaf children are stripped of `delegate_task`, `clarify`, `memory`, `send_message`, `execute_code` — see `DELEGATE_BLOCKED_TOOLS`) |
|
||||
| Can it call `delegate_task`? | **NO.** `delegate_task` is NOT in `SANDBOX_ALLOWED_TOOLS`. Do not write a script that imports it — it will fail. | itself, if `role='orchestrator'` and `max_spawn_depth>=2` |
|
||||
| Concurrency | you control it in Python (`ThreadPoolExecutor`, batches) | `delegation.max_concurrent_children` (default 3; raise in config.yaml) |
|
||||
| Cost shape | cheap — most steps are tool calls, no per-item LLM unless you call `web_search`/aux | one model call tree PER child task — multiplies linearly, can be very expensive |
|
||||
|
||||
**Rule of thumb:** do the deterministic part in Layer A first (inline, in a
|
||||
script), then fan out ONLY the irreducibly-LLM step via Layer B. This is
|
||||
Pattern 1 from `delegate-task-output-patterns`, applied at workflow scale.
|
||||
Mixing them: a Layer-A script can write a manifest file, and you (the parent)
|
||||
then read that manifest and issue a single Layer-B `delegate_task` batch.
|
||||
|
||||
## The synchronous trap (READ THIS — it is the #1 way a "workflow" disappoints)
|
||||
|
||||
`delegate_task` runs **synchronously inside the parent turn**. If the user sends
|
||||
a new message, hits /stop, or /new, every in-flight child is **cancelled and its
|
||||
work discarded** (status `interrupted`). It does NOT run in the background, and
|
||||
it does NOT survive the turn. There is no cache-resume of a half-finished fan-out.
|
||||
|
||||
So a "workflow" in Hermes is one of:
|
||||
|
||||
1. **Foreground workflow (default):** Layer A and/or one Layer-B batch, completed
|
||||
within a single turn. Good for minutes-long fan-out (dozens of units). The
|
||||
user waits. This is what you build 90% of the time.
|
||||
2. **Durable workflow (hours/days, survives interruption):** use the **kanban
|
||||
swarm** (the SQLite-backed multi-agent kernel that ships with Hermes —
|
||||
`hermes_cli/kanban_swarm.py` + the kanban plugin; if your install has a
|
||||
`kanban-multiagent` skill, load it for the workflow). It
|
||||
writes a task graph (root → parallel workers → verifier → synthesizer) into
|
||||
the SQLite kanban kernel with a JSON blackboard. State persists across turns
|
||||
and restarts. This is the ONLY path that matches Claude Code's "runs into
|
||||
hours and days, resumes where it left off." Reach for it when the foreground
|
||||
path would time out or when the user must be able to walk away.
|
||||
|
||||
Never promise "background, resumable, hundreds of agents over days" from a plain
|
||||
`delegate_task` call. For a durable multi-agent workflow *graph*, the kanban
|
||||
swarm is the right fit. For simpler durable/out-of-turn cases there are lighter
|
||||
options too: a `cronjob` one-shot or scheduled job, or a managed
|
||||
`terminal(background=True, notify_on_complete=True)` process — both survive the
|
||||
turn without standing up a full task graph.
|
||||
|
||||
## Workflow recipe (foreground)
|
||||
|
||||
1. **Decompose into independent units.** What is the unit — a file? an endpoint?
|
||||
a source? a record? Each unit must be answerable WITHOUT the others' output
|
||||
(else it's serial, not fan-out — see when_not_to_use).
|
||||
2. **Deterministic pre-pass (Layer A).** In one `execute_code` script, gather the
|
||||
manifest: list the files, extract the candidate sites, fetch the raw sources,
|
||||
compute anything regex/parse can compute. Write a manifest to a **unique
|
||||
per-run** directory — `/tmp/wf_<name>_<uuid>/manifest.jsonl` (one unit per
|
||||
line), never a bare `/tmp/wf_<name>/` that a prior interrupted run could have
|
||||
left stale outputs in. This is the "plan in code." Print the unit count and
|
||||
the run dir, and stop.
|
||||
3. **Size the fan-out** against `delegate-task-output-patterns`: chunk so each
|
||||
child handles ~8-12 mechanical file edits OR ~2000-3000 lines of reading OR
|
||||
~50-70KB of corpus. Look at the LARGEST unit, not the average. One
|
||||
`delegate_task(tasks=[...])` call is bounded by
|
||||
`delegation.max_concurrent_children` (default 3) — it does NOT queue hundreds
|
||||
of tasks internally. For larger fan-out, issue bounded waves yourself (loop:
|
||||
one batch, collect, next batch) or have the user raise the config
|
||||
intentionally.
|
||||
4. **LLM-judgment fan-out (Layer B).** Issue ONE `delegate_task` with a `tasks=[]`
|
||||
array, one task per chunk. Each task: reads its slice from the manifest,
|
||||
emits delimiter-separated lines to `/tmp/wf_<name>_<uuid>/out_<i>.csv`, prints a
|
||||
status word, stops. Do NOT depend on the `summary` field for content.
|
||||
5. **Synthesize on the parent.** Read the out_*.csv files yourself — verify the
|
||||
file count and freshness (each was written this run) so a stale or missing
|
||||
output from an interrupted child isn't silently read as success — then merge
|
||||
and present. The cross-cutting "whole picture" step stays on the parent — only
|
||||
the per-unit work fanned out.
|
||||
|
||||
## The novel mechanic worth building: adversarial convergence
|
||||
|
||||
This is the part Hermes did NOT already have and the real reason to bother.
|
||||
Claude Code's quality claim ("independent agents try to refute each other's
|
||||
findings; only surviving claims surface; iterate until they converge") maps
|
||||
cleanly onto `delegate_task` batch mode:
|
||||
|
||||
### Recipe: N independent attempts + M refuters
|
||||
|
||||
For a finding-quality task (security audit, "is this code path actually
|
||||
vulnerable?", "does this migration preserve behavior?", a high-stakes plan):
|
||||
|
||||
1. **Independent attempts (round 1).** Fan out the SAME question to N children
|
||||
(N=2-4) with DIFFERENT framings/angles in each `context`, so they don't
|
||||
collapse to the same reasoning. Each writes its claims to
|
||||
`/tmp/wf_<name>/attempt_<i>.md` as a list of discrete, individually-checkable
|
||||
claims (one claim per line — atomicity is what makes refutation possible).
|
||||
2. **Collect + dedupe (parent or Layer A).** Merge all claims into a single
|
||||
numbered list. Identical claims from independent attempts = higher prior;
|
||||
note the agreement count per claim.
|
||||
3. **Refutation round (round 2).** Fan out a refuter batch: each refuter gets the
|
||||
claim list and is told "your job is to BREAK these claims — for each, find the
|
||||
counter-evidence (the auth check that DOES exist, the test that DOES cover it,
|
||||
the edge case the claim ignores). Output `claim_idx|survives|counter_evidence`."
|
||||
Give refuters the codebase/sources, not the original attempts' reasoning.
|
||||
4. **Keep only survivors.** A claim surfaces to the user only if it survived
|
||||
refutation (no refuter produced valid counter-evidence). Filtered claims are
|
||||
dropped, with a one-line note of why if the user asked for completeness.
|
||||
5. **Converge (optional).** If round 2 surfaced NEW claims (refuters often find
|
||||
adjacent issues), feed them back through one more refutation round. Stop when
|
||||
a round produces no new surviving claims — that's convergence. Cap at 3 rounds
|
||||
to bound cost.
|
||||
|
||||
This gives you the "more trustworthy than a single pass" property without a
|
||||
runtime — it's just two `delegate_task` batches and a merge, structured so
|
||||
disagreement is visible and unsupported claims die before they reach the user.
|
||||
|
||||
### Why atomic claims matter
|
||||
A refuter cannot break "the auth layer has problems." It CAN break "endpoint
|
||||
`POST /api/users/:id/role` in src/routes/users.ts:142 has no role check." Force
|
||||
attempts to emit specific, located, individually-falsifiable claims or the
|
||||
refutation round is theater.
|
||||
|
||||
## Cost discipline (this is the thing that bites)
|
||||
|
||||
A workflow can consume dramatically more tokens than a normal turn — that is
|
||||
inherent, not a bug. Two real multipliers:
|
||||
|
||||
- **Each Layer-B child is a full agent tree.** 20 children ≈ 20× the model calls.
|
||||
`delegation.max_concurrent_children` only bounds *concurrency*, not *total*.
|
||||
- **Hermes aux/subagent model defaults to main-model-first.** Children inherit
|
||||
the parent's (often expensive reasoning) model. `delegate_task` does NOT expose
|
||||
a per-task `model` or `profile` field — its per-task keys are
|
||||
`{goal, context, toolsets, role}`. To run the fan-out cheaper you either route
|
||||
delegation globally via `delegation` config (model/provider applied to all
|
||||
children), or — for genuinely model/profile-scoped work — use cron, the kanban
|
||||
swarm, or a separate Hermes process. The cleanest lever for mechanical fan-out
|
||||
is still Layer A: do the deterministic part in a script with no per-item LLM at
|
||||
all.
|
||||
|
||||
Always: start on a SCOPED slice (one directory, 20 records, 10 endpoints), prove
|
||||
the recipe end-to-end, report the token cost, THEN offer to run it at full scale.
|
||||
Never silently fan out hundreds of children — surface the cost first and let the
|
||||
user say go.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Writing `delegate_task` inside an `execute_code` script.** It's not in
|
||||
`SANDBOX_ALLOWED_TOOLS`; the import/stub won't exist. Layer A is deterministic
|
||||
tools only. Fan out LLM judgment from the parent turn, not from inside a script.
|
||||
- **Promising background/resumable from `delegate_task`.** It's synchronous and
|
||||
turn-scoped. Durable = kanban swarm.
|
||||
- **Trusting `summary` fields for content.** Route structured output to files
|
||||
(Pattern 2 in delegate-task-output-patterns).
|
||||
- **Non-atomic claims in the verify recipe.** Unfalsifiable claims survive
|
||||
refutation by default and pollute the output. Force located, specific claims.
|
||||
- **Same framing in all "independent" attempts.** They collapse to one answer and
|
||||
the cross-check is worthless. Vary the angle in each child's context.
|
||||
- **Fanning out a serial task.** If unit B needs unit A's output, parallelism
|
||||
produces wrong/empty results. Re-check independence before fanning out.
|
||||
|
||||
## Verification before you call it done
|
||||
|
||||
- Did the deterministic pre-pass actually run, and does the manifest line-count
|
||||
match the expected unit count? (`wc -l /tmp/wf_<name>/manifest.jsonl`)
|
||||
- Did every fan-out child write its output file? (`ls /tmp/wf_<name>/out_*.csv`) —
|
||||
remember stalled children often completed anyway (Pattern 6).
|
||||
- For the verify recipe: can you point to the refuter counter-evidence for every
|
||||
DROPPED claim, and confirm every SURFACED claim went through refutation?
|
||||
- Did you report token cost on the scoped run before offering full scale?
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for the container-context sandbox-mirror guard (#32049 follow-up).
|
||||
|
||||
Brian's shape-based guard (#32213) catches paths that carry the full
|
||||
``…/sandboxes/<backend>/<task>/home/.hermes/…`` prefix. This covers the
|
||||
complementary inner-container case: when file tools execute inside Docker,
|
||||
the bind-mount strips that prefix and the guard sees plain ``/root/.hermes/…``.
|
||||
The root:root ownership on the divergent SOUL.md in #32049 confirms this
|
||||
is the primary failure mode.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestClassifyContainerMirrorTarget:
|
||||
def test_returns_none_without_context(self):
|
||||
"""No Docker context — /root/.hermes/… must not be flagged."""
|
||||
from agent.file_safety import classify_container_mirror_target
|
||||
|
||||
assert classify_container_mirror_target("/root/.hermes/profiles/group1/SOUL.md") is None
|
||||
|
||||
def test_catches_soul_md_with_context(self):
|
||||
"""Primary failure mode from #32049: agent writes SOUL.md via container path."""
|
||||
from agent.file_safety import classify_container_mirror_target
|
||||
|
||||
result = classify_container_mirror_target(
|
||||
"/root/.hermes/profiles/group1/SOUL.md",
|
||||
mirror_prefix="/root/.hermes",
|
||||
)
|
||||
assert result is not None
|
||||
assert result["mirror_root"].replace("\\", "/").endswith("root/.hermes")
|
||||
assert result["inner_path"] == "profiles/group1/SOUL.md"
|
||||
|
||||
@pytest.mark.parametrize("inner", [
|
||||
"SOUL.md",
|
||||
"memories/MEMORY.md",
|
||||
])
|
||||
def test_catches_authoritative_profile_files(self, inner):
|
||||
from agent.file_safety import classify_container_mirror_target
|
||||
|
||||
result = classify_container_mirror_target(
|
||||
f"/root/.hermes/{inner}",
|
||||
mirror_prefix="/root/.hermes",
|
||||
)
|
||||
assert result is not None
|
||||
assert result["inner_path"] == inner
|
||||
|
||||
def test_non_hermes_path_not_flagged(self):
|
||||
"""/root/workspace/… is not .hermes state and must not be blocked."""
|
||||
from agent.file_safety import classify_container_mirror_target
|
||||
|
||||
assert (
|
||||
classify_container_mirror_target(
|
||||
"/root/workspace/main.py",
|
||||
mirror_prefix="/root/.hermes",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
class TestGetContainerMirrorWarning:
|
||||
def test_warning_names_inner_path_and_bypass(self):
|
||||
from agent.file_safety import get_container_mirror_warning
|
||||
|
||||
warn = get_container_mirror_warning(
|
||||
"/root/.hermes/profiles/group1/SOUL.md",
|
||||
mirror_prefix="/root/.hermes",
|
||||
)
|
||||
assert warn is not None
|
||||
assert "profiles/group1/SOUL.md" in warn
|
||||
assert "cross_profile=True" in warn
|
||||
|
||||
|
||||
class TestOrthogonality:
|
||||
"""Container-context guard catches what the shape-based guard (#32213) misses."""
|
||||
|
||||
def test_inner_container_path_caught_by_context_guard(self):
|
||||
"""No sandboxes/ segment — shape guard passes, context guard blocks."""
|
||||
from agent.file_safety import classify_container_mirror_target
|
||||
|
||||
path = "/root/.hermes/profiles/group1/SOUL.md"
|
||||
|
||||
assert classify_container_mirror_target(path) is None # no context
|
||||
assert classify_container_mirror_target(path, mirror_prefix="/root/.hermes") is not None
|
||||
|
||||
|
||||
class TestFileToolIntegration:
|
||||
"""file_tools must catch the mirror path before creating DockerEnvironment."""
|
||||
|
||||
def test_guard_uses_current_docker_config_before_env_exists(self, monkeypatch):
|
||||
import tools.file_tools as file_tools
|
||||
|
||||
monkeypatch.setattr(
|
||||
file_tools,
|
||||
"_get_container_mirror_prefix_for_task",
|
||||
lambda task_id: "/root/.hermes",
|
||||
)
|
||||
|
||||
warning = file_tools._check_cross_profile_path(
|
||||
"/root/.hermes/profiles/group1/SOUL.md",
|
||||
task_id="new-task",
|
||||
)
|
||||
|
||||
assert warning is not None
|
||||
assert "Sandbox-mirror write blocked" in warning
|
||||
assert "profiles/group1/SOUL.md" in warning
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tests for the sandbox-mirror write guard in agent/file_safety.
|
||||
|
||||
The guard fires when a tool tries to write into the per-task mirror
|
||||
directory created by a non-local terminal backend (Docker, Daytona, etc.).
|
||||
Those paths look like ``…/sandboxes/<backend>/<task>/home/.hermes/…`` and
|
||||
they accumulate divergent copies of authoritative profile state (SOUL.md,
|
||||
config.yaml, memories/*.md) because the host Hermes process never reads
|
||||
them. Soft guard — defense in depth, NOT a security boundary.
|
||||
|
||||
Reference: #32049 — under ``terminal.backend: docker``, the agent's
|
||||
``write_file`` / ``patch`` calls landed on the sandbox mirror of SOUL.md
|
||||
while the host process kept loading the untouched authoritative file.
|
||||
The agent reported success; the rule never took effect.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# classify_sandbox_mirror_target — pure path-shape detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClassifySandboxMirrorTarget:
|
||||
def test_docker_mirror_soul_md_classified(self, tmp_path):
|
||||
"""The exact path shape reported in #32049."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "profiles" / "group1"
|
||||
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
|
||||
/ "profiles" / "group1" / "SOUL.md"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("# mirror copy\n")
|
||||
|
||||
result = classify_sandbox_mirror_target(str(target))
|
||||
assert result is not None
|
||||
assert result["target_path"] == str(target.resolve())
|
||||
assert result["mirror_root"].endswith(
|
||||
"sandboxes/docker/default/home/.hermes"
|
||||
)
|
||||
assert result["inner_path"] == "profiles/group1/SOUL.md"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"backend,inner",
|
||||
[
|
||||
("docker", "profiles/coder/memories/MEMORY.md"),
|
||||
("daytona", "profiles/default/cron/jobs.json"),
|
||||
("podman", ".env"),
|
||||
],
|
||||
)
|
||||
def test_other_backends_and_inner_files_match(self, tmp_path, backend, inner):
|
||||
"""The detector is backend-agnostic — sandbox-mirror shape is what matters."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "sandboxes" / backend / "task-42" / "home" / ".hermes"
|
||||
/ Path(inner)
|
||||
)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("x")
|
||||
|
||||
result = classify_sandbox_mirror_target(str(target))
|
||||
assert result is not None
|
||||
assert result["inner_path"] == inner
|
||||
assert backend in result["mirror_root"]
|
||||
|
||||
def test_path_outside_sandbox_returns_none(self, tmp_path):
|
||||
"""A plain Hermes path is not a mirror."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = tmp_path / ".hermes" / "profiles" / "group1" / "SOUL.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("# real SOUL\n")
|
||||
|
||||
assert classify_sandbox_mirror_target(str(target)) is None
|
||||
|
||||
def test_sandboxes_segment_without_home_hermes_returns_none(self, tmp_path):
|
||||
"""A ``sandboxes/`` directory unrelated to Hermes-state mirroring (e.g.
|
||||
the sandbox workspace itself) is not flagged."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "sandboxes" / "docker" / "task-42" / "workspace" / "main.py"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("print('hi')\n")
|
||||
|
||||
assert classify_sandbox_mirror_target(str(target)) is None
|
||||
|
||||
def test_sandboxes_segment_with_home_but_no_hermes_returns_none(self, tmp_path):
|
||||
"""``sandboxes/<backend>/<task>/home/anything-not-hermes`` is not a mirror."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "sandboxes" / "docker" / "task-42" / "home" / ".bashrc"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("alias ll='ls -la'\n")
|
||||
|
||||
assert classify_sandbox_mirror_target(str(target)) is None
|
||||
|
||||
def test_truncated_sandbox_path_returns_none(self, tmp_path):
|
||||
"""``…/sandboxes/<backend>/<task>`` without ``home/.hermes/<thing>`` is not a mirror."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = tmp_path / "sandboxes" / "docker" / "task-42"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
assert classify_sandbox_mirror_target(str(target)) is None
|
||||
|
||||
def test_non_existent_path_still_classifies_by_shape(self, tmp_path):
|
||||
"""Detection is path-shape only — it must not require the file to exist
|
||||
(the agent is about to CREATE the mirror file, that's the bug)."""
|
||||
from agent.file_safety import classify_sandbox_mirror_target
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "profiles" / "group1"
|
||||
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
|
||||
/ "profiles" / "group1" / "SOUL.md"
|
||||
)
|
||||
# Parent directory exists so .resolve() doesn't strip the tail
|
||||
# under strict mode, but the file itself does NOT exist.
|
||||
target.parent.mkdir(parents=True)
|
||||
assert not target.exists()
|
||||
|
||||
result = classify_sandbox_mirror_target(str(target))
|
||||
assert result is not None
|
||||
assert result["inner_path"] == "profiles/group1/SOUL.md"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_sandbox_mirror_warning — the model-facing string
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetSandboxMirrorWarning:
|
||||
def test_non_mirror_returns_none(self, tmp_path):
|
||||
from agent.file_safety import get_sandbox_mirror_warning
|
||||
|
||||
target = tmp_path / ".hermes" / "profiles" / "group1" / "SOUL.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("# real SOUL\n")
|
||||
|
||||
assert get_sandbox_mirror_warning(str(target)) is None
|
||||
|
||||
def test_mirror_warning_names_mirror_root_and_inner_path(self, tmp_path):
|
||||
from agent.file_safety import get_sandbox_mirror_warning
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "profiles" / "group1"
|
||||
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
|
||||
/ "profiles" / "group1" / "SOUL.md"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("# mirror copy\n")
|
||||
|
||||
warn = get_sandbox_mirror_warning(str(target))
|
||||
assert warn is not None
|
||||
# Must name the mirror root so the user can locate the sandbox.
|
||||
assert "sandboxes/docker/default/home/.hermes" in warn
|
||||
# Must hint at what the agent likely meant.
|
||||
assert "profiles/group1/SOUL.md" in warn
|
||||
# Must name the bypass kwarg shared with the cross-profile guard.
|
||||
assert "cross_profile=True" in warn
|
||||
|
||||
def test_warning_is_defense_in_depth_not_boundary(self, tmp_path):
|
||||
from agent.file_safety import get_sandbox_mirror_warning
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "sandboxes" / "docker" / "t" / "home" / ".hermes"
|
||||
/ "profiles" / "g" / "SOUL.md"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("x")
|
||||
|
||||
warn = get_sandbox_mirror_warning(str(target))
|
||||
# Must self-document as defense-in-depth so future reviewers
|
||||
# don't promote it to a hard block (matches the existing
|
||||
# cross-profile guard's contract).
|
||||
assert "not a security boundary" in warn.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Independence from cross-profile classifier
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSandboxMirrorIsOrthogonalToCrossProfile:
|
||||
"""The sandbox-mirror guard must fire even when the inner path is
|
||||
in-profile from the host's view — the bug is the mirror, not the
|
||||
profile mismatch."""
|
||||
|
||||
def test_same_profile_mirror_still_flagged(self, tmp_path, monkeypatch):
|
||||
import agent.file_safety as fs
|
||||
monkeypatch.setattr(fs, "_hermes_root_path", lambda: tmp_path)
|
||||
monkeypatch.setattr(fs, "_hermes_home_path", lambda: tmp_path / "profiles" / "group1")
|
||||
|
||||
target = (
|
||||
tmp_path
|
||||
/ "profiles" / "group1"
|
||||
/ "sandboxes" / "docker" / "default" / "home" / ".hermes"
|
||||
/ "profiles" / "group1" / "SOUL.md"
|
||||
)
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("x")
|
||||
|
||||
# cross-profile classifier: active profile == target's inner-mirror
|
||||
# profile name; on the existing detector the path's parts[2] is
|
||||
# ``sandboxes``, not a scoped area, so it returns None.
|
||||
assert fs.classify_cross_profile_target(str(target)) is None
|
||||
# sandbox-mirror classifier: fires unconditionally on the shape.
|
||||
assert fs.classify_sandbox_mirror_target(str(target)) is not None
|
||||
@@ -4,8 +4,9 @@ from unittest.mock import patch
|
||||
|
||||
|
||||
class TestMinimaxContextLengths:
|
||||
"""Verify context length entries match official docs (204,800 for all models).
|
||||
"""Verify context length entries match official docs.
|
||||
|
||||
M2.x series is 204,800; M3 is 1M (max output 512K).
|
||||
Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
|
||||
"""
|
||||
|
||||
@@ -15,11 +16,80 @@ class TestMinimaxContextLengths:
|
||||
|
||||
def test_minimax_models_resolve_via_prefix(self):
|
||||
from agent.model_metadata import get_model_context_length
|
||||
# All MiniMax models should resolve to 204,800 via the "minimax" prefix
|
||||
# M2.x models resolve to 204,800 via the "minimax" catch-all
|
||||
for model in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"):
|
||||
ctx = get_model_context_length(model, "")
|
||||
assert ctx == 204_800, f"{model} expected 204800, got {ctx}"
|
||||
|
||||
def test_minimax_m3_resolves_to_1m(self):
|
||||
from agent.model_metadata import get_model_context_length
|
||||
# M3 must beat the generic "minimax" catch-all (204,800) and resolve to
|
||||
# a 1M-class context. The exact value depends on the source: our
|
||||
# hardcoded catalog says 1,000,000; the OpenRouter catalog reports
|
||||
# 1,048,576 (1024²). Either is correct — assert "≥ 1M, not 204,800".
|
||||
for model in ("MiniMax-M3", "minimax/minimax-m3", "minimax-m3"):
|
||||
ctx = get_model_context_length(model, "")
|
||||
assert ctx >= 1_000_000, f"{model} expected 1M-class, got {ctx}"
|
||||
|
||||
|
||||
class TestMinimaxM3StaleCacheGuard:
|
||||
"""Pre-catalog builds resolved M3 via the generic 'minimax' catch-all
|
||||
(204,800) and persisted it before the 'minimax-m3' (1M) catalog entry
|
||||
existed. The step-1 cache guard must drop that stale value and re-resolve
|
||||
to 1M, while leaving correct M2.x entries (204,800) untouched.
|
||||
"""
|
||||
|
||||
def test_suggests_minimax_m3(self):
|
||||
from agent.model_metadata import _model_name_suggests_minimax_m3
|
||||
assert _model_name_suggests_minimax_m3("MiniMax-M3")
|
||||
assert _model_name_suggests_minimax_m3("minimax/minimax-m3")
|
||||
assert not _model_name_suggests_minimax_m3("MiniMax-M2.7")
|
||||
assert not _model_name_suggests_minimax_m3("MiniMax-M2.5")
|
||||
|
||||
def test_stale_m3_cache_dropped_and_reresolves(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import importlib
|
||||
import agent.model_metadata as mm
|
||||
importlib.reload(mm)
|
||||
base = "https://api.minimaxi.com/anthropic"
|
||||
mm.save_context_length("MiniMax-M3", base, 204_800)
|
||||
ctx = mm.get_model_context_length(
|
||||
"MiniMax-M3", base_url=base, api_key="", provider="minimax-cn"
|
||||
)
|
||||
# Invariant: the stale 204,800 catch-all value must be DROPPED and
|
||||
# re-resolved to M3's real, larger context. The exact value depends on
|
||||
# the resolution source (hardcoded catalog = 1,000,000; the models.dev
|
||||
# registry currently reports 512,000) — both are large-context values
|
||||
# well above the generic "minimax" catch-all. Assert the contract
|
||||
# ("> 204,800, stale value gone"), not a brittle literal.
|
||||
assert ctx > 204_800, f"stale M3 cache not dropped/re-resolved, got {ctx}"
|
||||
|
||||
def test_correct_m3_cache_preserved(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import importlib
|
||||
import agent.model_metadata as mm
|
||||
importlib.reload(mm)
|
||||
base = "https://api.minimaxi.com/anthropic"
|
||||
mm.save_context_length("MiniMax-M3", base, 1_000_000)
|
||||
ctx = mm.get_model_context_length(
|
||||
"MiniMax-M3", base_url=base, api_key="", provider="minimax-cn"
|
||||
)
|
||||
assert ctx == 1_000_000
|
||||
|
||||
def test_m2_cache_not_clobbered(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import importlib
|
||||
import agent.model_metadata as mm
|
||||
importlib.reload(mm)
|
||||
base = "https://api.minimaxi.com/anthropic"
|
||||
# 204,800 is the CORRECT value for M2.x — guard must not touch it.
|
||||
for slug in ("MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1"):
|
||||
mm.save_context_length(slug, base, 204_800)
|
||||
ctx = mm.get_model_context_length(
|
||||
slug, base_url=base, api_key="", provider="minimax-cn"
|
||||
)
|
||||
assert ctx == 204_800, f"{slug} should stay 204800, got {ctx}"
|
||||
|
||||
|
||||
|
||||
class TestMinimaxThinkingSupport:
|
||||
|
||||
@@ -927,6 +927,29 @@ class TestEnvironmentHints:
|
||||
assert "Terminal backend: docker" in result
|
||||
assert "inside" in result.lower()
|
||||
|
||||
def test_build_environment_hints_uses_terminal_cwd_over_launch_dir(self, monkeypatch, tmp_path):
|
||||
"""THE BUG: gateway/cron set TERMINAL_CWD but the prompt emitted os.getcwd()
|
||||
(the daemon launch dir). Regression for #24882/#24969/#27383/#29265."""
|
||||
import agent.prompt_builder as _pb
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
configured = tmp_path / "workspace"
|
||||
configured.mkdir()
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(configured))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_pb._clear_backend_probe_cache()
|
||||
assert f"Current working directory: {configured}" in _pb.build_environment_hints()
|
||||
|
||||
def test_build_environment_hints_falls_back_to_launch_dir(self, monkeypatch, tmp_path):
|
||||
"""The #19242 local-CLI contract: no TERMINAL_CWD → the launch dir."""
|
||||
import agent.prompt_builder as _pb
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
_pb._clear_backend_probe_cache()
|
||||
assert f"Current working directory: {tmp_path}" in _pb.build_environment_hints()
|
||||
|
||||
def test_build_environment_hints_uses_live_probe_when_available(self, monkeypatch):
|
||||
"""When the probe succeeds, its output must appear in the hint block."""
|
||||
import agent.prompt_builder as _pb
|
||||
@@ -1247,4 +1270,3 @@ class TestOpenAIModelExecutionGuidance:
|
||||
# =========================================================================
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for agent/runtime_cwd.py — the single source of truth for the agent working directory."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import agent.runtime_cwd as rt
|
||||
from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd
|
||||
|
||||
|
||||
def _raise_oserror(*args, **kwargs):
|
||||
raise OSError("cwd gone")
|
||||
|
||||
|
||||
class TestResolveAgentCwd:
|
||||
def test_prefers_terminal_cwd_over_getcwd(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
monkeypatch.chdir(os.path.expanduser("~"))
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
|
||||
def test_falls_back_to_getcwd_when_unset(self, monkeypatch, tmp_path):
|
||||
# The #19242 local-CLI contract: TERMINAL_CWD is unset, so the launch dir wins.
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
|
||||
def test_skips_nonexistent_terminal_cwd(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "gone"))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
|
||||
def test_expands_leading_tilde(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_CWD", "~")
|
||||
assert resolve_agent_cwd() == Path(os.path.expanduser("~"))
|
||||
|
||||
def test_whitespace_only_terminal_cwd_falls_back_to_getcwd(self, monkeypatch, tmp_path):
|
||||
# " ".strip() → "" → falsy, so the launch dir wins (not a " " path).
|
||||
monkeypatch.setenv("TERMINAL_CWD", " ")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert resolve_agent_cwd() == tmp_path
|
||||
|
||||
def test_propagates_oserror_from_getcwd(self, monkeypatch):
|
||||
# The fallback arm calls os.getcwd(), which can raise OSError (deleted cwd).
|
||||
# The resolver must NOT swallow it — build_environment_hints owns the
|
||||
# try/except OSError guard at the call site (prompt_builder.py:805).
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
monkeypatch.setattr(rt.os, "getcwd", _raise_oserror)
|
||||
with pytest.raises(OSError):
|
||||
resolve_agent_cwd()
|
||||
|
||||
|
||||
class TestResolveContextCwd:
|
||||
def test_returns_dir_when_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
assert resolve_context_cwd() == tmp_path
|
||||
|
||||
def test_returns_none_when_unset(self, monkeypatch):
|
||||
# Unset → None; the caller (build_context_files_prompt) then getcwds —
|
||||
# the local-CLI #19242 contract. Discovery still runs; it is NOT skipped.
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
assert resolve_context_cwd() is None
|
||||
|
||||
def test_returns_nonexistent_dir_unguarded(self, monkeypatch, tmp_path):
|
||||
# Deliberate asymmetry vs resolve_agent_cwd: context discovery has no isdir
|
||||
# guard, so a missing dir is returned (not None) — discovery just finds nothing.
|
||||
missing = tmp_path / "gone"
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(missing))
|
||||
assert resolve_context_cwd() == missing
|
||||
|
||||
def test_expands_leading_tilde(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMINAL_CWD", "~")
|
||||
assert resolve_context_cwd() == Path(os.path.expanduser("~"))
|
||||
|
||||
def test_whitespace_only_terminal_cwd_returns_none(self, monkeypatch):
|
||||
# " ".strip() → "" → None, so the caller getcwds for discovery rather
|
||||
# than building Path(" ") and resolving garbage under the launch dir.
|
||||
monkeypatch.setenv("TERMINAL_CWD", " ")
|
||||
assert resolve_context_cwd() is None
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for agent/system_prompt.py — context-file cwd wiring."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.system_prompt import build_system_prompt_parts
|
||||
|
||||
|
||||
def _make_agent(**overrides):
|
||||
base = dict(
|
||||
load_soul_identity=False,
|
||||
skip_context_files=False,
|
||||
valid_tool_names=[],
|
||||
_task_completion_guidance=False,
|
||||
_tool_use_enforcement=False,
|
||||
_environment_probe=False,
|
||||
_kanban_worker_guidance="",
|
||||
_memory_store=None,
|
||||
_memory_manager=None,
|
||||
model="",
|
||||
provider="",
|
||||
platform="",
|
||||
pass_session_id=False,
|
||||
session_id="",
|
||||
)
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _captured_context_cwd(agent):
|
||||
"""The cwd build_system_prompt_parts hands to build_context_files_prompt."""
|
||||
captured = {}
|
||||
|
||||
def fake_context_files(cwd=None, skip_soul=False):
|
||||
captured["cwd"] = cwd
|
||||
return ""
|
||||
|
||||
with (
|
||||
patch("run_agent.load_soul_md", return_value=""),
|
||||
patch("run_agent.build_nous_subscription_prompt", return_value=""),
|
||||
patch("run_agent.build_environment_hints", return_value=""),
|
||||
patch("run_agent.build_context_files_prompt", side_effect=fake_context_files),
|
||||
):
|
||||
build_system_prompt_parts(agent)
|
||||
return captured["cwd"]
|
||||
|
||||
|
||||
class TestContextFileCwd:
|
||||
def test_none_when_terminal_cwd_unset(self, monkeypatch):
|
||||
# Unset → None, so discovery falls back to the launch dir inside
|
||||
# build_context_files_prompt (the local-CLI #19242 contract).
|
||||
monkeypatch.delenv("TERMINAL_CWD", raising=False)
|
||||
assert _captured_context_cwd(_make_agent()) is None
|
||||
|
||||
def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
assert _captured_context_cwd(_make_agent()) == tmp_path
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tests for cli._prepend_note_to_message.
|
||||
|
||||
Regression coverage for the TypeError raised when a queued /model or
|
||||
/reload-skills note was prepended to a multimodal (image-attached) message:
|
||||
``can only concatenate str (not "list") to str``.
|
||||
"""
|
||||
|
||||
from cli import _prepend_note_to_message
|
||||
|
||||
|
||||
def test_string_message_gets_note_prepended():
|
||||
assert _prepend_note_to_message("hello", "NOTE") == "NOTE\n\nhello"
|
||||
|
||||
|
||||
def test_empty_note_returns_message_unchanged():
|
||||
assert _prepend_note_to_message("hello", "") == "hello"
|
||||
assert _prepend_note_to_message("hello", " ") == "hello"
|
||||
parts = [{"type": "text", "text": "hi"}]
|
||||
assert _prepend_note_to_message(parts, "") == parts
|
||||
|
||||
|
||||
def test_note_is_stripped():
|
||||
assert _prepend_note_to_message("hello", " NOTE ") == "NOTE\n\nhello"
|
||||
|
||||
|
||||
def test_empty_string_message_yields_just_note():
|
||||
# No trailing blank lines when the user message is empty.
|
||||
assert _prepend_note_to_message("", "NOTE") == "NOTE"
|
||||
|
||||
|
||||
def test_empty_text_part_yields_just_note():
|
||||
message = [
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "image_url", "image_url": {"url": "x"}},
|
||||
]
|
||||
result = _prepend_note_to_message(message, "NOTE")
|
||||
assert result[0]["text"] == "NOTE"
|
||||
assert result[1]["type"] == "image_url"
|
||||
|
||||
|
||||
def test_list_message_folds_note_into_first_text_part():
|
||||
message = [
|
||||
{"type": "text", "text": "describe this"},
|
||||
{"type": "image_url", "image_url": {"url": "data:..."}},
|
||||
]
|
||||
result = _prepend_note_to_message(message, "NOTE")
|
||||
|
||||
assert result[0]["type"] == "text"
|
||||
assert result[0]["text"] == "NOTE\n\ndescribe this"
|
||||
# Image part is preserved untouched.
|
||||
assert result[1] == {"type": "image_url", "image_url": {"url": "data:..."}}
|
||||
# Original message is not mutated.
|
||||
assert message[0]["text"] == "describe this"
|
||||
|
||||
|
||||
def test_image_only_list_gets_leading_text_part():
|
||||
message = [{"type": "image_url", "image_url": {"url": "data:..."}}]
|
||||
result = _prepend_note_to_message(message, "NOTE")
|
||||
|
||||
assert result[0] == {"type": "text", "text": "NOTE"}
|
||||
assert result[1]["type"] == "image_url"
|
||||
|
||||
|
||||
def test_list_message_does_not_raise_typeerror():
|
||||
# The exact #repro shape: multimodal list + queued note must not raise
|
||||
# "can only concatenate str (not 'list') to str".
|
||||
message = [
|
||||
{"type": "text", "text": "look"},
|
||||
{"type": "image_url", "image_url": {"url": "x"}},
|
||||
]
|
||||
result = _prepend_note_to_message(
|
||||
message, "Model switched to gpt-5.5 (provider: openai-codex)."
|
||||
)
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["text"].startswith("Model switched to gpt-5.5")
|
||||
|
||||
|
||||
def test_unknown_shape_returned_unchanged():
|
||||
assert _prepend_note_to_message(123, "NOTE") == 123
|
||||
assert _prepend_note_to_message(None, "NOTE") is None
|
||||
@@ -182,6 +182,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
|
||||
"HERMES_SESSION_SOURCE",
|
||||
"HERMES_SESSION_KEY",
|
||||
"HERMES_GATEWAY_SESSION",
|
||||
"HERMES_CRON_SESSION",
|
||||
"_HERMES_GATEWAY",
|
||||
"HERMES_PLATFORM",
|
||||
"HERMES_MODEL",
|
||||
|
||||
@@ -206,7 +206,11 @@ class TestBuildJobPromptScansSkillContent:
|
||||
assert prompt is not None
|
||||
assert "cat ~/.hermes/.env" in prompt
|
||||
|
||||
def test_skill_with_invisible_unicode_raises(self, cron_env):
|
||||
def test_skill_with_invisible_unicode_sanitized_not_blocked(self, cron_env):
|
||||
"""A stray zero-width space in a vetted skill body is stripped, not
|
||||
blocked. The job builds normally with the invisible char removed.
|
||||
Regression: the free-surgeon-gpt55 cron was permanently dead because
|
||||
a single U+200B in loaded skill content tripped a hard block."""
|
||||
hermes_home, scheduler = cron_env
|
||||
# Zero-width space smuggled into the skill body.
|
||||
_plant_skill(hermes_home, "zwsp-skill", "clean looking\u200bskill content")
|
||||
@@ -218,8 +222,11 @@ class TestBuildJobPromptScansSkillContent:
|
||||
"skills": ["zwsp-skill"],
|
||||
}
|
||||
|
||||
with pytest.raises(scheduler.CronPromptInjectionBlocked):
|
||||
scheduler._build_job_prompt(job)
|
||||
# Must NOT raise — the invisible char is sanitized out and the job runs.
|
||||
prompt = scheduler._build_job_prompt(job)
|
||||
assert prompt is not None
|
||||
assert "\u200b" not in prompt
|
||||
assert "clean lookingskill content" in prompt
|
||||
|
||||
def test_no_skills_still_scans_user_prompt(self, cron_env):
|
||||
"""Defense-in-depth: even without skills, assembled-prompt scanning
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Tests for the BlueBubbles iMessage gateway adapter."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
@@ -25,6 +28,8 @@ class TestBlueBubblesConfigLoading:
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret")
|
||||
monkeypatch.setenv("BLUEBUBBLES_WEBHOOK_PORT", "9999")
|
||||
monkeypatch.setenv("BLUEBUBBLES_REQUIRE_MENTION", "true")
|
||||
monkeypatch.setenv("BLUEBUBBLES_MENTION_PATTERNS", r'["(?i)^amos\\b"]')
|
||||
from gateway.config import GatewayConfig, _apply_env_overrides
|
||||
|
||||
config = GatewayConfig()
|
||||
@@ -35,6 +40,8 @@ class TestBlueBubblesConfigLoading:
|
||||
assert bc.extra["server_url"] == "http://localhost:1234"
|
||||
assert bc.extra["password"] == "secret"
|
||||
assert bc.extra["webhook_port"] == 9999
|
||||
assert bc.extra["require_mention"] is True
|
||||
assert bc.extra["mention_patterns"] == ["(?i)^amos\\b"]
|
||||
|
||||
def test_home_channel_set_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234")
|
||||
@@ -130,6 +137,131 @@ class TestBlueBubblesHelpers:
|
||||
adapter = _make_adapter(monkeypatch, server_url="localhost:1234")
|
||||
assert adapter.server_url == "http://localhost:1234"
|
||||
|
||||
def test_default_mention_patterns_match_hermes_variants(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch, require_mention=True)
|
||||
|
||||
assert adapter.require_mention is True
|
||||
assert adapter._message_matches_mention_patterns("Hermes, summarize this")
|
||||
assert adapter._message_matches_mention_patterns("@Hermes agent help")
|
||||
assert not adapter._message_matches_mention_patterns("casual family chatter")
|
||||
assert not adapter._message_matches_mention_patterns("antihermes should not match")
|
||||
|
||||
def test_custom_mention_patterns_override_defaults(self, monkeypatch):
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
require_mention=True,
|
||||
mention_patterns=[r"(?<![\w@])@?amos\b[,:\-]?"],
|
||||
)
|
||||
|
||||
assert adapter._message_matches_mention_patterns("Amos what is next?")
|
||||
assert not adapter._message_matches_mention_patterns("Hermes what is next?")
|
||||
|
||||
def test_clean_mention_text_strips_leading_wake_word(self, monkeypatch):
|
||||
adapter = _make_adapter(monkeypatch, require_mention=True)
|
||||
|
||||
assert adapter._clean_mention_text("Hermes, summarize this") == "summarize this"
|
||||
assert adapter._clean_mention_text("Hermes agent: summarize this") == "summarize this"
|
||||
assert adapter._clean_mention_text("please ask Hermes about this") == "please ask Hermes about this"
|
||||
|
||||
|
||||
class _FakeBlueBubblesRequest:
|
||||
def __init__(self, payload, password="secret"):
|
||||
self.query = {"password": password}
|
||||
self.headers = {}
|
||||
self._body = json.dumps(payload).encode("utf-8")
|
||||
|
||||
async def read(self):
|
||||
return self._body
|
||||
|
||||
|
||||
class TestBlueBubblesMentionGating:
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_without_mention_is_acknowledged_and_skipped(self, monkeypatch):
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
require_mention=True,
|
||||
send_read_receipts=False,
|
||||
)
|
||||
handled = []
|
||||
|
||||
async def fake_handle_message(event):
|
||||
handled.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
|
||||
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
|
||||
"type": "new-message",
|
||||
"data": {
|
||||
"guid": "msg-1",
|
||||
"text": "casual family chatter",
|
||||
"handle": {"address": "+15555550100"},
|
||||
"isFromMe": False,
|
||||
"isGroup": True,
|
||||
"chats": [{"guid": "iMessage;+;group-chat"}],
|
||||
},
|
||||
}))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert response.status == 200
|
||||
assert handled == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_message_with_default_mention_is_dispatched_cleaned(self, monkeypatch):
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
require_mention=True,
|
||||
send_read_receipts=False,
|
||||
)
|
||||
handled = []
|
||||
|
||||
async def fake_handle_message(event):
|
||||
handled.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
|
||||
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
|
||||
"type": "new-message",
|
||||
"data": {
|
||||
"guid": "msg-2",
|
||||
"text": "Hermes, summarize this",
|
||||
"handle": {"address": "+15555550100"},
|
||||
"isFromMe": False,
|
||||
"isGroup": True,
|
||||
"chats": [{"guid": "iMessage;+;group-chat"}],
|
||||
},
|
||||
}))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert response.status == 200
|
||||
assert [event.text for event in handled] == ["summarize this"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_message_does_not_require_mention(self, monkeypatch):
|
||||
adapter = _make_adapter(
|
||||
monkeypatch,
|
||||
require_mention=True,
|
||||
send_read_receipts=False,
|
||||
)
|
||||
handled = []
|
||||
|
||||
async def fake_handle_message(event):
|
||||
handled.append(event)
|
||||
|
||||
monkeypatch.setattr(adapter, "handle_message", fake_handle_message)
|
||||
response = await adapter._handle_webhook(_FakeBlueBubblesRequest({
|
||||
"type": "new-message",
|
||||
"data": {
|
||||
"guid": "msg-3",
|
||||
"text": "hello from a dm",
|
||||
"handle": {"address": "user@example.com"},
|
||||
"isFromMe": False,
|
||||
"chatGuid": "iMessage;-;user@example.com",
|
||||
"chatIdentifier": "user@example.com",
|
||||
},
|
||||
}))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert response.status == 200
|
||||
assert [event.text for event in handled] == ["hello from a dm"]
|
||||
|
||||
|
||||
class TestBlueBubblesWebhookParsing:
|
||||
def test_webhook_prefers_chat_guid_over_message_guid(self, monkeypatch):
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Tests for config-driven platform access policies at the gateway layer.
|
||||
|
||||
Background (#34515): WeCom, Weixin, Yuanbao, and QQBot expose a documented
|
||||
config-driven access surface (``dm_policy`` / ``group_policy`` / ``allow_from``
|
||||
/ ``group_allow_from`` in ``PlatformConfig.extra``) and enforce it at intake —
|
||||
Background (#34515): WeCom, Weixin, Yuanbao, QQBot, and WhatsApp expose a
|
||||
documented config-driven access surface (``dm_policy`` / ``group_policy`` /
|
||||
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra``) and enforce
|
||||
it at intake —
|
||||
a message is dropped inside the adapter and never reaches the gateway unless it
|
||||
already passed that policy.
|
||||
|
||||
@@ -34,6 +35,7 @@ _OWN_POLICY_PLATFORMS = [
|
||||
Platform.WEIXIN,
|
||||
Platform.YUANBAO,
|
||||
Platform.QQBOT,
|
||||
Platform.WHATSAPP,
|
||||
]
|
||||
|
||||
|
||||
@@ -44,6 +46,7 @@ def _clear_auth_env(monkeypatch) -> None:
|
||||
"YUANBAO_ALLOWED_USERS",
|
||||
"QQ_ALLOWED_USERS",
|
||||
"QQ_GROUP_ALLOWED_USERS",
|
||||
"WHATSAPP_ALLOWED_USERS",
|
||||
"TELEGRAM_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOW_ALL_USERS",
|
||||
@@ -51,6 +54,7 @@ def _clear_auth_env(monkeypatch) -> None:
|
||||
"WEIXIN_ALLOW_ALL_USERS",
|
||||
"YUANBAO_ALLOW_ALL_USERS",
|
||||
"QQ_ALLOW_ALL_USERS",
|
||||
"WHATSAPP_ALLOW_ALL_USERS",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
@@ -103,10 +107,11 @@ def test_base_adapter_defaults_to_not_owning_access_policy():
|
||||
("gateway.platforms.weixin", "WeixinAdapter"),
|
||||
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
|
||||
("gateway.platforms.qqbot.adapter", "QQAdapter"),
|
||||
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
|
||||
],
|
||||
)
|
||||
def test_own_policy_adapters_declare_the_flag(module_path, class_name):
|
||||
"""The four config-policy adapters override the flag to True."""
|
||||
"""The config-policy adapters override the flag to True."""
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(module_path)
|
||||
|
||||
@@ -155,3 +155,64 @@ class TestSupportedDocumentTypes:
|
||||
)
|
||||
def test_expected_extensions_present(self, ext):
|
||||
assert ext in SUPPORTED_DOCUMENT_TYPES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCacheMediaBytes — the unified, platform-agnostic caching primitive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 1x1 transparent PNG (passes cache_image_from_bytes validation)
|
||||
_PNG_1PX = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
|
||||
"890000000d49444154789c6360000002000154a24f5f0000000049454e44ae426082"
|
||||
)
|
||||
|
||||
|
||||
class TestCacheMediaBytes:
|
||||
def test_pdf_routes_to_document(self):
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
result = cache_media_bytes(b"%PDF-1.4 body", filename="report.pdf", mime_type="application/pdf")
|
||||
assert result is not None
|
||||
assert result.kind == "document"
|
||||
assert result.media_type == "application/pdf"
|
||||
assert "report.pdf" in result.display_name
|
||||
assert os.path.exists(result.path)
|
||||
assert "report.pdf" in result.context_note()
|
||||
|
||||
def test_png_routes_to_image(self):
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
result = cache_media_bytes(_PNG_1PX, filename="photo.png", mime_type="image/png")
|
||||
assert result is not None
|
||||
assert result.kind == "image"
|
||||
assert result.media_type == "image/png"
|
||||
assert os.path.exists(result.path)
|
||||
|
||||
def test_native_photo_without_filename_uses_default_kind(self):
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
result = cache_media_bytes(_PNG_1PX, filename="", mime_type="", default_kind="image")
|
||||
assert result is not None
|
||||
assert result.kind == "image"
|
||||
|
||||
def test_mp4_routes_to_video(self):
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
result = cache_media_bytes(b"\x00\x00\x00\x18ftypmp42", filename="clip.mp4", mime_type="video/mp4")
|
||||
assert result is not None
|
||||
assert result.kind == "video"
|
||||
assert result.media_type == "video/mp4"
|
||||
|
||||
def test_mime_only_resolves_extension(self):
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
result = cache_media_bytes(b"col1,col2\n1,2", filename="", mime_type="text/csv")
|
||||
assert result is not None
|
||||
assert result.kind == "document"
|
||||
assert result.media_type == "text/csv"
|
||||
|
||||
def test_unsupported_document_returns_none(self):
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
result = cache_media_bytes(b"MZ", filename="malware.exe", mime_type="application/x-msdownload")
|
||||
assert result is None
|
||||
|
||||
def test_invalid_image_returns_none(self):
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
result = cache_media_bytes(b"<html>not an image</html>", filename="x.png", mime_type="image/png")
|
||||
assert result is None
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for the dispatch_in_gateway gate on _kanban_notifier_watcher.
|
||||
|
||||
- Non-dispatch gateways (dispatch_in_gateway=false) exit before opening any DB.
|
||||
- HERMES_KANBAN_DISPATCH_IN_GATEWAY env var disables without loading config.
|
||||
- Dispatch-owning gateways (dispatch_in_gateway=true) proceed past the gate.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
|
||||
def _make_runner(with_adapter=False):
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._running = True
|
||||
runner.adapters = {Platform.TELEGRAM: MagicMock()} if with_adapter else {}
|
||||
runner._kanban_sub_fail_counts = {}
|
||||
return runner
|
||||
|
||||
|
||||
def _fake_config(dispatch_in_gateway):
|
||||
return {"kanban": {"dispatch_in_gateway": dispatch_in_gateway}}
|
||||
|
||||
|
||||
def test_notifier_watcher_skips_when_dispatch_disabled():
|
||||
"""dispatch_in_gateway=false returns before opening any board DB."""
|
||||
runner = _make_runner()
|
||||
with patch("hermes_cli.config.load_config", return_value=_fake_config(False)):
|
||||
with patch("hermes_cli.kanban_db.connect") as mock_connect:
|
||||
asyncio.run(runner._kanban_notifier_watcher())
|
||||
mock_connect.assert_not_called()
|
||||
|
||||
|
||||
def test_notifier_watcher_env_override_disables(monkeypatch):
|
||||
"""HERMES_KANBAN_DISPATCH_IN_GATEWAY=false skips config load entirely."""
|
||||
runner = _make_runner()
|
||||
monkeypatch.setenv("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "false")
|
||||
with patch("hermes_cli.config.load_config") as mock_load_config:
|
||||
with patch("hermes_cli.kanban_db.connect") as mock_connect:
|
||||
asyncio.run(runner._kanban_notifier_watcher())
|
||||
mock_load_config.assert_not_called()
|
||||
mock_connect.assert_not_called()
|
||||
|
||||
|
||||
def test_notifier_watcher_runs_when_dispatch_enabled():
|
||||
"""dispatch_in_gateway=true proceeds past the gate to the board fan-out."""
|
||||
runner = _make_runner(with_adapter=True)
|
||||
past_gate = []
|
||||
sleep_calls = []
|
||||
|
||||
async def fake_sleep(delay):
|
||||
sleep_calls.append(delay)
|
||||
# Stop after the initial delay + first per-interval sleep so the loop
|
||||
# body runs exactly once.
|
||||
if len(sleep_calls) >= 2:
|
||||
runner._running = False
|
||||
|
||||
async def fake_to_thread(fn, *args, **kwargs):
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
with patch("hermes_cli.config.load_config", return_value=_fake_config(True)):
|
||||
with patch.object(
|
||||
_kb, "list_boards",
|
||||
side_effect=lambda *a, **kw: past_gate.append(True) or [],
|
||||
):
|
||||
with patch("asyncio.sleep", side_effect=fake_sleep):
|
||||
with patch("asyncio.to_thread", side_effect=fake_to_thread):
|
||||
asyncio.run(runner._kanban_notifier_watcher())
|
||||
|
||||
assert past_gate, "list_boards should be called when dispatch_in_gateway=true"
|
||||
@@ -7,6 +7,7 @@ sibling platform-plugin tests on the same xdist worker.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -214,7 +215,7 @@ async def test_send_dm():
|
||||
result = await adapter.send("contact-42", "Hello, SimpleX!")
|
||||
mock_ws.send.assert_called_once()
|
||||
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||
assert payload["cmd"] == "@[contact-42] Hello, SimpleX!"
|
||||
assert payload["cmd"] == "@contact-42 Hello, SimpleX!"
|
||||
assert payload["corrId"].startswith(_CORR_PREFIX)
|
||||
assert result.success is True
|
||||
|
||||
@@ -301,23 +302,55 @@ async def test_standalone_send_missing_websockets(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_send_missing_url(monkeypatch):
|
||||
async def test_standalone_send_defaults_to_local_daemon(monkeypatch):
|
||||
monkeypatch.delenv("SIMPLEX_WS_URL", raising=False)
|
||||
pconfig = MagicMock()
|
||||
pconfig.extra = {}
|
||||
# We expect the URL fallback (extra+env both empty) to be empty string,
|
||||
# producing an error. We also need websockets to be importable for the
|
||||
# url-check branch to be reached, so skip when it's not.
|
||||
try:
|
||||
import websockets.client # noqa: F401
|
||||
except ImportError:
|
||||
pytest.skip("websockets not installed")
|
||||
|
||||
sent_payloads = []
|
||||
|
||||
class DummyWs:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def send(self, payload):
|
||||
sent_payloads.append(json.loads(payload))
|
||||
|
||||
def fake_connect(url, **kwargs):
|
||||
assert url == "ws://127.0.0.1:5225"
|
||||
assert kwargs["open_timeout"] == 10
|
||||
assert kwargs["close_timeout"] == 5
|
||||
return DummyWs()
|
||||
|
||||
import websockets
|
||||
monkeypatch.setattr(websockets, "connect", fake_connect)
|
||||
|
||||
result = await _standalone_send(pconfig, "contact-42", "hi")
|
||||
assert isinstance(result, dict)
|
||||
# Either error about URL or a connection attempt failure — both are valid
|
||||
# signals that the standalone path requires configuration.
|
||||
assert "error" in result
|
||||
assert result == {"success": True, "platform": "simplex", "chat_id": "contact-42"}
|
||||
assert sent_payloads[0]["cmd"] == "@contact-42 hi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_monitor_does_not_reconnect_quiet_healthy_ws(monkeypatch):
|
||||
from gateway.config import PlatformConfig
|
||||
cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
|
||||
adapter = SimplexAdapter(cfg)
|
||||
adapter._running = True
|
||||
adapter._last_ws_activity = 0
|
||||
adapter._ws = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(_simplex, "HEALTH_CHECK_INTERVAL", 0.01)
|
||||
monkeypatch.setattr(_simplex, "HEALTH_CHECK_STALE_THRESHOLD", 0.01)
|
||||
|
||||
task = asyncio.create_task(adapter._health_monitor())
|
||||
await asyncio.sleep(0.03)
|
||||
adapter._running = False
|
||||
await asyncio.wait_for(task, timeout=1)
|
||||
|
||||
adapter._ws.close.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -10,6 +10,7 @@ time instead of first-token time.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -173,6 +174,179 @@ class TestFreshFinalForLongLivedPreviews:
|
||||
assert consumer._should_send_fresh_final() is False
|
||||
|
||||
|
||||
class TestSegmentBreakDoesNotMarkFinalSent:
|
||||
"""Regression for #29346 — silent response loss after tool calls.
|
||||
|
||||
When ``fresh_final_after_seconds > 0`` and a streamed *preamble* ("Let me
|
||||
search…") has aged past the threshold, finalizing it at a tool boundary
|
||||
used to route through ``_try_fresh_final``, which unconditionally set
|
||||
``_final_response_sent = True`` even though this is a NON-final segment.
|
||||
The gateway (run.py:18128) then reads that flag as "final delivered" and
|
||||
suppresses the genuine final answer (which arrives on a later API call and
|
||||
does not re-stream), so the user gets nothing.
|
||||
|
||||
The fix scopes the final-delivery flags to the turn-final segment and
|
||||
clears them at every tool/segment boundary, so a preamble can never mark
|
||||
the turn as delivered.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _delivered_texts(adapter) -> list[str]:
|
||||
"""Every text the adapter actually put on screen (sends + edits)."""
|
||||
texts = [c.kwargs.get("content", "") for c in adapter.send.call_args_list]
|
||||
texts += [c.kwargs.get("content", "") for c in adapter.edit_message.call_args_list]
|
||||
return texts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preamble_fresh_final_at_tool_boundary_does_not_mark_final(self):
|
||||
"""Real-aging reproduction (exercises the actual _should_send_fresh_final
|
||||
age gate, not a monkeypatch): a preamble ages past the threshold, then a
|
||||
tool boundary finalizes it via fresh-final. The genuine final answer is
|
||||
produced on a later API call and is NOT streamed through this consumer
|
||||
(the #29346 repro), so the consumer must NOT believe the final was sent."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
fresh_final_after_seconds=0.001, # tiny → real aging fires
|
||||
),
|
||||
)
|
||||
consumer.on_delta("Let me search the web for that.")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05) # preamble sent + aged well past 0.001s
|
||||
consumer.on_delta(None) # tool boundary → segment-break fresh-final
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# Fresh-final actually engaged (preamble preview + a fresh resend), yet
|
||||
# the turn is NOT marked delivered — no genuine final ever streamed.
|
||||
assert adapter.send.call_count >= 2
|
||||
assert consumer.final_response_sent is False
|
||||
assert consumer.final_content_delivered is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_final_answer_after_preamble_is_delivered_exactly_once(self):
|
||||
"""P0 user-visible contract: when the real final answer DOES stream in
|
||||
after the preamble + tool boundary, the user gets it exactly once AND
|
||||
the consumer marks it delivered (so the gateway correctly suppresses a
|
||||
redundant send)."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
fresh_final_after_seconds=0.001,
|
||||
),
|
||||
)
|
||||
consumer.on_delta("Let me search the web for that.")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.on_delta(None) # tool boundary
|
||||
consumer.on_delta("The answer is 42.") # genuine final answer streams
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# The real final answer was delivered → suppression must engage.
|
||||
assert consumer.final_response_sent is True
|
||||
# And it reached the user exactly once (no duplicate fresh send).
|
||||
final_sends = [
|
||||
c for c in adapter.send.call_args_list
|
||||
if "answer is 42" in c.kwargs.get("content", "")
|
||||
]
|
||||
assert len(final_sends) <= 1
|
||||
assert any("answer is 42" in t for t in self._delivered_texts(adapter))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_genuine_final_answer_without_tools_marks_delivered(self):
|
||||
"""P1 happy path: a single answer streamed straight to completion (no
|
||||
tool boundary) still sets final_response_sent so the gateway suppresses
|
||||
the redundant final send."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
fresh_final_after_seconds=60.0,
|
||||
),
|
||||
)
|
||||
consumer.on_delta("Here is the full answer.")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
assert consumer.final_response_sent is True
|
||||
assert any("Here is the full answer." in t for t in self._delivered_texts(adapter))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_edit_adapter_delivers_final_after_preamble(self):
|
||||
"""No-edit adapters (Signal/SMS/webhook → __no_edit__) accumulate and
|
||||
deliver rather than fresh-final. A preamble before a tool call must not
|
||||
swallow the genuine final answer — it must reach the user."""
|
||||
adapter = _make_adapter()
|
||||
adapter.send.return_value = SimpleNamespace(success=True, message_id=None)
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
fresh_final_after_seconds=0.001,
|
||||
),
|
||||
)
|
||||
consumer.on_delta("Let me search the web for that.")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.on_delta(None) # tool boundary
|
||||
consumer.on_delta("The answer is 42.") # genuine final answer
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
# The final answer reached the user, not swallowed by the preamble.
|
||||
assert any(
|
||||
"answer is 42" in c.kwargs.get("content", "")
|
||||
for c in adapter.send.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_tool_call_turn_delivers_final_once(self):
|
||||
"""Two tool boundaries before the final answer: flags stay clear across
|
||||
both boundaries and the genuine final is delivered exactly once and
|
||||
marked sent."""
|
||||
adapter = _make_adapter()
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
fresh_final_after_seconds=0.001,
|
||||
),
|
||||
)
|
||||
consumer.on_delta("Let me check a couple of things.")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.on_delta(None) # tool boundary 1
|
||||
consumer.on_delta("Now cross-referencing.")
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.on_delta(None) # tool boundary 2
|
||||
consumer.on_delta("The answer is 42.") # genuine final answer
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
assert consumer.final_response_sent is True
|
||||
final_sends = [
|
||||
c for c in adapter.send.call_args_list
|
||||
if "answer is 42" in c.kwargs.get("content", "")
|
||||
]
|
||||
assert len(final_sends) <= 1
|
||||
assert any("answer is 42" in t for t in self._delivered_texts(adapter))
|
||||
|
||||
|
||||
class TestStreamConsumerConfigFreshFinalField:
|
||||
"""The dataclass field must exist and default to 0 (disabled)."""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from gateway.config import Platform, PlatformConfig, load_gateway_config
|
||||
from gateway.platforms.base import MessageType
|
||||
@@ -1005,3 +1005,160 @@ def test_triggered_voice_message_uses_shared_session_in_observe_mode():
|
||||
assert "[Alice Example|111]" in event.text
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Observed-media caching (unmentioned group attachments)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _group_photo_message(*, chat_id=-100, caption="Veja esta foto", file_size=1024):
|
||||
file_obj = SimpleNamespace(
|
||||
file_path="photos/observed.png",
|
||||
download_as_bytearray=AsyncMock(return_value=bytearray(b"\x89PNG\r\n\x1a\n observed")),
|
||||
)
|
||||
photo = SimpleNamespace(file_size=file_size, get_file=AsyncMock(return_value=file_obj))
|
||||
return SimpleNamespace(
|
||||
message_id=52, text=None, caption=caption, entities=[], caption_entities=[],
|
||||
message_thread_id=None, is_topic_message=False,
|
||||
chat=SimpleNamespace(id=chat_id, type="group", title="Test Group", is_forum=False),
|
||||
from_user=SimpleNamespace(id=111, full_name="Alice Example", first_name="Alice"),
|
||||
reply_to_message=None, date=None, location=None, venue=None,
|
||||
sticker=None, photo=[photo], video=None, audio=None, voice=None, document=None,
|
||||
)
|
||||
|
||||
|
||||
def _group_document_message(*, chat_id=-100, caption="Este arquivo", document=None):
|
||||
file_obj = SimpleNamespace(
|
||||
file_path="documents/report.pdf",
|
||||
download_as_bytearray=AsyncMock(return_value=bytearray(b"%PDF observed bytes")),
|
||||
)
|
||||
document = document or SimpleNamespace(
|
||||
file_name="RESULTADO BIOLOGICO - PROTOCOLO 103- URBAN.pdf",
|
||||
mime_type="application/pdf", file_size=1024,
|
||||
get_file=AsyncMock(return_value=file_obj),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
message_id=53, text=None, caption=caption, entities=[], caption_entities=[],
|
||||
message_thread_id=None, is_topic_message=False,
|
||||
chat=SimpleNamespace(id=chat_id, type="group", title="Test Group", is_forum=False),
|
||||
from_user=SimpleNamespace(id=111, full_name="Alice Example", first_name="Alice"),
|
||||
reply_to_message=None, date=None, location=None, venue=None,
|
||||
sticker=None, photo=None, video=None, audio=None, voice=None, document=document,
|
||||
)
|
||||
|
||||
|
||||
def test_unmentioned_photo_observed_with_cached_path(monkeypatch, tmp_path):
|
||||
async def _run():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True, allowed_chats=["-100"],
|
||||
group_allowed_chats=["-100"], observe_unmentioned_group_messages=True,
|
||||
)
|
||||
store = _FakeSessionStore()
|
||||
adapter._session_store = store
|
||||
cached_path = tmp_path / "img_abc_observed.png"
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.base.cache_image_from_bytes",
|
||||
lambda _data, ext=".jpg": str(cached_path),
|
||||
)
|
||||
update = SimpleNamespace(update_id=3003, message=_group_photo_message(), effective_message=None)
|
||||
|
||||
await adapter._handle_media_message(update, SimpleNamespace())
|
||||
|
||||
adapter._message_handler.assert_not_awaited()
|
||||
assert len(store.messages) == 1
|
||||
_, message, _ = store.messages[0]
|
||||
assert message["observed"] is True
|
||||
assert "Veja esta foto" in message["content"]
|
||||
assert "image" in message["content"]
|
||||
assert str(cached_path) in message["content"]
|
||||
assert store.sources[0].user_id is None
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_unmentioned_document_observed_with_cached_path(monkeypatch, tmp_path):
|
||||
async def _run():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True, allowed_chats=["-100"],
|
||||
group_allowed_chats=["-100"], observe_unmentioned_group_messages=True,
|
||||
)
|
||||
store = _FakeSessionStore()
|
||||
adapter._session_store = store
|
||||
cached_path = tmp_path / "doc_abc_report.pdf"
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.base.cache_document_from_bytes",
|
||||
lambda _data, _filename: str(cached_path),
|
||||
)
|
||||
update = SimpleNamespace(update_id=3004, message=_group_document_message(), effective_message=None)
|
||||
|
||||
await adapter._handle_media_message(update, SimpleNamespace())
|
||||
|
||||
adapter._message_handler.assert_not_awaited()
|
||||
assert len(store.messages) == 1
|
||||
_, message, _ = store.messages[0]
|
||||
assert message["observed"] is True
|
||||
assert "Este arquivo" in message["content"]
|
||||
assert str(cached_path) in message["content"]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_unmentioned_large_document_observed_without_download(monkeypatch):
|
||||
async def _run():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True, allowed_chats=["-100"],
|
||||
group_allowed_chats=["-100"], observe_unmentioned_group_messages=True,
|
||||
)
|
||||
adapter._max_doc_bytes = 100
|
||||
store = _FakeSessionStore()
|
||||
adapter._session_store = store
|
||||
cache_doc = Mock(return_value="/tmp/huge.pdf")
|
||||
monkeypatch.setattr("gateway.platforms.base.cache_document_from_bytes", cache_doc)
|
||||
document = SimpleNamespace(
|
||||
file_name="huge.pdf", mime_type="application/pdf",
|
||||
file_size=101, get_file=AsyncMock(),
|
||||
)
|
||||
update = SimpleNamespace(
|
||||
update_id=3005, message=_group_document_message(document=document), effective_message=None,
|
||||
)
|
||||
|
||||
await adapter._handle_media_message(update, SimpleNamespace())
|
||||
|
||||
cache_doc.assert_not_called()
|
||||
document.get_file.assert_not_called()
|
||||
_, message, _ = store.messages[0]
|
||||
assert "too large" in message["content"]
|
||||
assert "/tmp/huge.pdf" not in message["content"]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_unmentioned_unsupported_document_observed_without_caching(monkeypatch):
|
||||
async def _run():
|
||||
adapter = _make_adapter(
|
||||
require_mention=True, allowed_chats=["-100"],
|
||||
group_allowed_chats=["-100"], observe_unmentioned_group_messages=True,
|
||||
)
|
||||
store = _FakeSessionStore()
|
||||
adapter._session_store = store
|
||||
cache_doc = Mock(return_value="/tmp/malware.exe")
|
||||
monkeypatch.setattr("gateway.platforms.base.cache_document_from_bytes", cache_doc)
|
||||
file_obj = SimpleNamespace(
|
||||
file_path="documents/malware.exe",
|
||||
download_as_bytearray=AsyncMock(return_value=bytearray(b"MZ")),
|
||||
)
|
||||
document = SimpleNamespace(
|
||||
file_name="malware.exe", mime_type="application/x-msdownload",
|
||||
file_size=2, get_file=AsyncMock(return_value=file_obj),
|
||||
)
|
||||
update = SimpleNamespace(
|
||||
update_id=3006, message=_group_document_message(document=document), effective_message=None,
|
||||
)
|
||||
|
||||
await adapter._handle_media_message(update, SimpleNamespace())
|
||||
|
||||
cache_doc.assert_not_called()
|
||||
_, message, _ = store.messages[0]
|
||||
assert "unsupported" in message["content"].lower()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Regression tests for tool-using response silent drop (issue #29346).
|
||||
|
||||
When the agent returns a non-empty response that the extract pipeline
|
||||
(extract_media / extract_images / extract_local_files / inline directive
|
||||
strips) happens to reduce to an empty string, the ``if text_content:`` guard
|
||||
in ``BasePlatformAdapter._process_message_background`` previously bypassed
|
||||
the send entirely. The symptom was a ``response ready`` log followed by
|
||||
silence — no ``Sending response`` line, no error — and the final answer
|
||||
never reaching the channel.
|
||||
|
||||
The fix (A2/A3 of the silent-response-loss plan) preserves the pre-extract
|
||||
response and, when no native attachment was produced to deliver in its
|
||||
place, sanitizes the original text and sends it as a fallback on ALL
|
||||
platforms (a ``response_delivery_recovered`` WARNING marks the recovery so
|
||||
the silent-drop pattern is observable). When even the sanitized recovery
|
||||
yields nothing deliverable, a ``response_delivery_dropped`` ERROR fires so a
|
||||
genuinely-lost response is never silent.
|
||||
|
||||
Salvaged and de-scoped from the superseded Discord-only PR #33842.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
SendResult,
|
||||
)
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
class _DummyAdapter(BasePlatformAdapter):
|
||||
"""Minimal BasePlatformAdapter for dispatch tests on any platform."""
|
||||
|
||||
def __init__(self, platform: Platform):
|
||||
super().__init__(PlatformConfig(enabled=True, token="fake-token"), platform)
|
||||
self.sent: list[dict] = []
|
||||
|
||||
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:
|
||||
self.sent.append({"chat_id": chat_id, "content": content})
|
||||
return SendResult(success=True, message_id="msg-1")
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
return None
|
||||
|
||||
async def get_chat_info(self, chat_id: str):
|
||||
return {"id": chat_id}
|
||||
|
||||
|
||||
def _make_event(platform: Platform, chat_id: str = "111", message_id: str = "m1") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text="hello",
|
||||
source=SessionSource(platform=platform, chat_id=chat_id, chat_type="dm"),
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
|
||||
async def _hold_typing(_chat_id, interval=2.0, metadata=None, stop_event=None):
|
||||
if stop_event is not None:
|
||||
await stop_event.wait()
|
||||
else:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
def _strip_everything(adapter, monkeypatch):
|
||||
"""Force the extract pipeline to reduce text_content to "" with no
|
||||
attachments — the exact failure mode that made the drop invisible."""
|
||||
monkeypatch.setattr(
|
||||
type(adapter), "extract_media", staticmethod(lambda content: ([], content))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
type(adapter), "extract_images", staticmethod(lambda content: ([], ""))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
type(adapter), "extract_local_files", staticmethod(lambda content: ([], ""))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", [Platform.DISCORD, Platform.TELEGRAM])
|
||||
class TestExtractStripRecoveryAllPlatforms:
|
||||
"""A non-empty response stripped to empty must be recovered on EVERY
|
||||
platform (the fix de-scopes the recovery from Discord-only)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_reduced_to_empty_is_recovered_and_sent(
|
||||
self, platform, monkeypatch, caplog
|
||||
):
|
||||
adapter = _DummyAdapter(platform)
|
||||
adapter._keep_typing = _hold_typing
|
||||
|
||||
tool_response = (
|
||||
"Based on my search, the cheapest TPE-PAR flight on Dec 14 is $632 "
|
||||
"via Saudia. Here are the top options sorted by price... "
|
||||
) * 5
|
||||
assert len(tool_response) > 500
|
||||
|
||||
async def handler(_event):
|
||||
return tool_response
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
_strip_everything(adapter, monkeypatch)
|
||||
|
||||
event = _make_event(platform)
|
||||
with caplog.at_level(logging.WARNING, logger="gateway.platforms.base"):
|
||||
await adapter._process_message_background(
|
||||
event, build_session_key(event.source)
|
||||
)
|
||||
|
||||
# The response WAS delivered, not silently dropped.
|
||||
assert len(adapter.sent) == 1, f"expected 1 send, got {adapter.sent}"
|
||||
assert adapter.sent[0]["content"] == tool_response.strip()
|
||||
# And the recovery is observable via the stable event key.
|
||||
assert any(
|
||||
"response_delivery_recovered" in r.getMessage()
|
||||
for r in caplog.records
|
||||
), [r.getMessage() for r in caplog.records]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_directives_stripped_from_fallback_text(self, platform, monkeypatch):
|
||||
adapter = _DummyAdapter(platform)
|
||||
adapter._keep_typing = _hold_typing
|
||||
|
||||
raw = (
|
||||
"[[audio_as_voice]]\n[[as_document]]\nMEDIA: /tmp/nope.ogg\n"
|
||||
"The real answer the user should see."
|
||||
)
|
||||
|
||||
async def handler(_event):
|
||||
return raw
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
_strip_everything(adapter, monkeypatch)
|
||||
|
||||
event = _make_event(platform)
|
||||
await adapter._process_message_background(event, build_session_key(event.source))
|
||||
|
||||
assert len(adapter.sent) == 1
|
||||
delivered = adapter.sent[0]["content"]
|
||||
assert "[[audio_as_voice]]" not in delivered
|
||||
assert "[[as_document]]" not in delivered
|
||||
assert "MEDIA:" not in delivered
|
||||
assert "The real answer the user should see." in delivered
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_fallback_when_attachment_produced(self, platform, monkeypatch):
|
||||
"""When an image attachment IS extracted, the empty text_content is
|
||||
intentional — recovery must NOT re-send the original markdown and
|
||||
duplicate the attachment's content."""
|
||||
adapter = _DummyAdapter(platform)
|
||||
adapter._keep_typing = _hold_typing
|
||||
|
||||
async def handler(_event):
|
||||
return ""
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
monkeypatch.setattr(
|
||||
type(adapter), "extract_media", staticmethod(lambda content: ([], content))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
type(adapter), "extract_images",
|
||||
staticmethod(lambda content: ([("https://example.com/chart.png", "chart")], "")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
type(adapter), "extract_local_files", staticmethod(lambda content: ([], ""))
|
||||
)
|
||||
adapter.send_multiple_images = lambda *a, **kw: asyncio.sleep(0, result=None)
|
||||
|
||||
event = _make_event(platform)
|
||||
await adapter._process_message_background(event, build_session_key(event.source))
|
||||
|
||||
assert adapter.sent == [], f"expected no text echo, got {adapter.sent}"
|
||||
|
||||
|
||||
class TestRecoveryDoesNotLeakMediaFragments:
|
||||
"""The A2 recovery must not leak fragments of a MEDIA: path to the user.
|
||||
|
||||
extract_media's real regex matches paths WITH SPACES; if the recovery
|
||||
sanitizes the raw pre-extract snapshot with a weaker MEDIA regex (one that
|
||||
stops at the first space), a spaced path whose file gets filtered out leaks
|
||||
a fragment like 'vacation photo.png'. The recovery must instead use the
|
||||
post-extract_media `response`, which the strong regex already cleaned.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spaced_media_path_does_not_leak_fragment(self, monkeypatch, caplog):
|
||||
adapter = _DummyAdapter(Platform.DISCORD)
|
||||
adapter._keep_typing = _hold_typing
|
||||
|
||||
async def handler(_event):
|
||||
# Spaced path with a valid extension — matched in full by the real
|
||||
# extract_media regex, then removed from the body.
|
||||
return "MEDIA: /tmp/nope_dir_zzz/my vacation photo.png"
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
# Use the REAL extract_media (so the strong regex cleans `response`),
|
||||
# but force the path to be filtered out (unsafe/nonexistent) so we hit
|
||||
# the empty-text + no-attachment recovery branch deterministically.
|
||||
monkeypatch.setattr(
|
||||
type(adapter), "filter_media_delivery_paths", staticmethod(lambda m: [])
|
||||
)
|
||||
|
||||
event = _make_event(Platform.DISCORD)
|
||||
with caplog.at_level(logging.ERROR, logger="gateway.platforms.base"):
|
||||
await adapter._process_message_background(
|
||||
event, build_session_key(event.source)
|
||||
)
|
||||
|
||||
# No fragment of the media path may reach the user.
|
||||
leaked = [
|
||||
s for s in adapter.sent
|
||||
if "vacation" in s["content"] or "photo" in s["content"] or "MEDIA" in s["content"]
|
||||
]
|
||||
assert leaked == [], f"media-path fragment leaked to user: {leaked}"
|
||||
# The genuinely-undeliverable response is logged loudly, not silent.
|
||||
assert any(
|
||||
"response_delivery_dropped" in r.getMessage()
|
||||
for r in caplog.records if r.levelno == logging.ERROR
|
||||
), [r.getMessage() for r in caplog.records]
|
||||
|
||||
|
||||
class TestUnrecoverableDropIsLoud:
|
||||
"""A non-empty response that produces NOTHING deliverable (sanitizes to
|
||||
empty, no attachment) must log a response_delivery_dropped ERROR rather
|
||||
than vanishing silently."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_directive_only_response_logs_dropped(self, monkeypatch, caplog):
|
||||
adapter = _DummyAdapter(Platform.DISCORD)
|
||||
adapter._keep_typing = _hold_typing
|
||||
|
||||
async def handler(_event):
|
||||
return "[[audio_as_voice]]\nMEDIA: /tmp/missing.ogg" # only directives
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
# Extraction strips to empty AND the media path filtered out (no file).
|
||||
_strip_everything(adapter, monkeypatch)
|
||||
|
||||
event = _make_event(Platform.DISCORD)
|
||||
with caplog.at_level(logging.ERROR, logger="gateway.platforms.base"):
|
||||
await adapter._process_message_background(
|
||||
event, build_session_key(event.source)
|
||||
)
|
||||
|
||||
assert adapter.sent == []
|
||||
assert any(
|
||||
"response_delivery_dropped" in r.getMessage()
|
||||
for r in caplog.records if r.levelno == logging.ERROR
|
||||
), [r.getMessage() for r in caplog.records]
|
||||
@@ -285,6 +285,39 @@ class TestPolicyHelpers:
|
||||
assert adapter._is_dm_allowed("user-1") is True
|
||||
assert adapter._is_dm_allowed("user-2") is False
|
||||
|
||||
def test_dm_allowlist_honors_env_only_allowed_users(self, monkeypatch):
|
||||
"""Env-only setup (WECOM_DM_POLICY + WECOM_ALLOWED_USERS, no config
|
||||
``extra``) must populate the DM allowlist. Otherwise ``dm_policy:
|
||||
allowlist`` runs with an empty allowlist and drops every listed user
|
||||
at intake — the documented env vars become no-ops."""
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
monkeypatch.setenv("WECOM_DM_POLICY", "allowlist")
|
||||
monkeypatch.setenv("WECOM_ALLOWED_USERS", "user-1, user-2")
|
||||
|
||||
adapter = WeComAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
assert adapter._dm_policy == "allowlist"
|
||||
assert adapter._allow_from == ["user-1", "user-2"]
|
||||
assert adapter._is_dm_allowed("user-1") is True
|
||||
assert adapter._is_dm_allowed("user-2") is True
|
||||
assert adapter._is_dm_allowed("stranger") is False
|
||||
|
||||
def test_dm_allowlist_extra_takes_precedence_over_env(self, monkeypatch):
|
||||
"""Config ``extra`` wins over the env fallback, so an explicit
|
||||
allowlist is never silently widened by a stray WECOM_ALLOWED_USERS."""
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
monkeypatch.setenv("WECOM_ALLOWED_USERS", "env-user")
|
||||
|
||||
adapter = WeComAdapter(
|
||||
PlatformConfig(enabled=True, extra={"dm_policy": "allowlist", "allow_from": ["cfg-user"]})
|
||||
)
|
||||
|
||||
assert adapter._allow_from == ["cfg-user"]
|
||||
assert adapter._is_dm_allowed("cfg-user") is True
|
||||
assert adapter._is_dm_allowed("env-user") is False
|
||||
|
||||
def test_group_allowlist_and_per_group_sender_allowlist(self):
|
||||
from gateway.platforms.wecom import WeComAdapter
|
||||
|
||||
|
||||
@@ -968,3 +968,148 @@ class TestWeixinTextDebounce:
|
||||
|
||||
asyncio.run(_drive())
|
||||
assert dispatched == ["one\ntwo\nthree"]
|
||||
|
||||
|
||||
class _StubResponse:
|
||||
def __init__(self, *, status=200, body="{}", delay=0.0):
|
||||
self.status = status
|
||||
self.ok = 200 <= status < 300
|
||||
self._body = body
|
||||
self._delay = delay
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_exc):
|
||||
return False
|
||||
|
||||
async def text(self):
|
||||
if self._delay:
|
||||
await asyncio.sleep(self._delay)
|
||||
return self._body
|
||||
|
||||
|
||||
class _StubSession:
|
||||
"""Records request kwargs and returns a configurable async-CM response.
|
||||
|
||||
Unlike aiohttp.ClientSession it installs no TimerContext, so it cannot
|
||||
reproduce aiohttp's cross-loop crash directly; these tests instead pin the
|
||||
observable contract of the asyncio.wait_for migration.
|
||||
"""
|
||||
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
self.post_calls = []
|
||||
self.get_calls = []
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
self.post_calls.append((url, kwargs))
|
||||
return self._response
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
self.get_calls.append((url, kwargs))
|
||||
return self._response
|
||||
|
||||
|
||||
class TestWeixinApiTimeout:
|
||||
def test_api_post_does_not_pass_aiohttp_timeout_kwarg(self):
|
||||
session = _StubSession(_StubResponse(body='{"ret": 0}'))
|
||||
result = asyncio.run(
|
||||
weixin._api_post(
|
||||
session,
|
||||
base_url="https://weixin.example.com",
|
||||
endpoint="ep",
|
||||
payload={"k": "v"},
|
||||
token="tok",
|
||||
timeout_ms=5000,
|
||||
)
|
||||
)
|
||||
assert result == {"ret": 0}
|
||||
# The fix enforces the timeout via asyncio.wait_for, so ClientTimeout is
|
||||
# gone and `timeout` is no longer forwarded to session.post().
|
||||
[(_url, kwargs)] = session.post_calls
|
||||
assert "timeout" not in kwargs
|
||||
|
||||
def test_api_get_does_not_pass_aiohttp_timeout_kwarg(self):
|
||||
session = _StubSession(_StubResponse(body='{"ret": 0}'))
|
||||
result = asyncio.run(
|
||||
weixin._api_get(
|
||||
session,
|
||||
base_url="https://weixin.example.com",
|
||||
endpoint="ep",
|
||||
timeout_ms=5000,
|
||||
)
|
||||
)
|
||||
assert result == {"ret": 0}
|
||||
[(_url, kwargs)] = session.get_calls
|
||||
assert "timeout" not in kwargs
|
||||
|
||||
def test_api_post_raises_timeout_when_response_is_slow(self):
|
||||
# 1 ms budget against a 1 s response: wait_for must cancel and raise.
|
||||
session = _StubSession(_StubResponse(delay=1.0))
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
asyncio.run(
|
||||
weixin._api_post(
|
||||
session,
|
||||
base_url="https://weixin.example.com",
|
||||
endpoint="ep",
|
||||
payload={"k": "v"},
|
||||
token="tok",
|
||||
timeout_ms=1,
|
||||
)
|
||||
)
|
||||
|
||||
def test_api_get_raises_timeout_when_response_is_slow(self):
|
||||
session = _StubSession(_StubResponse(delay=1.0))
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
asyncio.run(
|
||||
weixin._api_get(
|
||||
session,
|
||||
base_url="https://weixin.example.com",
|
||||
endpoint="ep",
|
||||
timeout_ms=1,
|
||||
)
|
||||
)
|
||||
|
||||
def test_api_post_raises_runtime_error_on_non_ok_status(self):
|
||||
# The non-2xx branch now lives inside the wait_for-wrapped inner coro;
|
||||
# confirm it still raises with the HTTP status and truncated body.
|
||||
session = _StubSession(_StubResponse(status=500, body="boom"))
|
||||
with pytest.raises(RuntimeError, match="iLink POST ep HTTP 500: boom"):
|
||||
asyncio.run(
|
||||
weixin._api_post(
|
||||
session,
|
||||
base_url="https://weixin.example.com",
|
||||
endpoint="ep",
|
||||
payload={"k": "v"},
|
||||
token="tok",
|
||||
timeout_ms=5000,
|
||||
)
|
||||
)
|
||||
|
||||
def test_api_get_raises_runtime_error_on_non_ok_status(self):
|
||||
session = _StubSession(_StubResponse(status=500, body="boom"))
|
||||
with pytest.raises(RuntimeError, match="iLink GET ep HTTP 500: boom"):
|
||||
asyncio.run(
|
||||
weixin._api_get(
|
||||
session,
|
||||
base_url="https://weixin.example.com",
|
||||
endpoint="ep",
|
||||
timeout_ms=5000,
|
||||
)
|
||||
)
|
||||
|
||||
def test_get_updates_returns_empty_sentinel_on_timeout(self):
|
||||
# wait_for raises asyncio.TimeoutError, which _get_updates swallows into
|
||||
# an empty long-poll batch rather than propagating.
|
||||
session = _StubSession(_StubResponse(delay=1.0))
|
||||
result = asyncio.run(
|
||||
weixin._get_updates(
|
||||
session,
|
||||
base_url="https://weixin.example.com",
|
||||
token="tok",
|
||||
sync_buf="buf-123",
|
||||
timeout_ms=1,
|
||||
)
|
||||
)
|
||||
assert result == {"ret": 0, "msgs": [], "get_updates_buf": "buf-123"}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for utils.atomic_json_write — crash-safe JSON file writes."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -132,6 +133,38 @@ class TestAtomicJsonWrite:
|
||||
assert result["emoji"] == "🎉"
|
||||
assert result["japanese"] == "日本語"
|
||||
|
||||
def test_mode_does_not_crash_without_fchmod(self, tmp_path):
|
||||
"""Regression: os.fchmod is Unix-only and absent on Windows. Passing a
|
||||
mode must not raise AttributeError when fchmod is unavailable.
|
||||
|
||||
Simulates the Windows os module by removing fchmod from the namespace.
|
||||
Previously this crashed in `hermes memory setup` while saving the
|
||||
Hindsight config with mode=0o600 (GitHub: Windows setup traceback).
|
||||
"""
|
||||
import utils
|
||||
|
||||
target = tmp_path / "secret.json"
|
||||
no_fchmod = {k: getattr(os, k) for k in dir(os) if k != "fchmod"}
|
||||
fake_os = type("FakeOs", (), no_fchmod)
|
||||
assert not hasattr(fake_os, "fchmod")
|
||||
|
||||
with patch.object(utils, "os", fake_os):
|
||||
atomic_json_write(target, {"api_key": "secret"}, mode=0o600)
|
||||
|
||||
assert json.loads(target.read_text(encoding="utf-8")) == {"api_key": "secret"}
|
||||
|
||||
def test_mode_applied_when_supported(self, tmp_path):
|
||||
import stat as stat_mod
|
||||
|
||||
target = tmp_path / "secret.json"
|
||||
atomic_json_write(target, {"api_key": "secret"}, mode=0o600)
|
||||
|
||||
# os.chmod's effect is platform-dependent (Windows only honors the
|
||||
# write bit), so only assert the durable mode on POSIX.
|
||||
if hasattr(os, "fchmod"):
|
||||
actual = stat_mod.S_IMODE(target.stat().st_mode)
|
||||
assert actual == 0o600
|
||||
|
||||
def test_concurrent_writes_dont_corrupt(self, tmp_path):
|
||||
"""Multiple rapid writes should each produce valid JSON."""
|
||||
import threading
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for the ranked fuzzy scorer used by the searchable curses pickers."""
|
||||
from hermes_cli.curses_ui import (
|
||||
_SearchState,
|
||||
_filter_indices,
|
||||
_fuzzy_score,
|
||||
_handle_active_search_key,
|
||||
_is_boundary,
|
||||
_token_score,
|
||||
)
|
||||
|
||||
|
||||
class _FakeCurses:
|
||||
KEY_BACKSPACE = 263
|
||||
KEY_DOWN = 258
|
||||
KEY_ENTER = 343
|
||||
|
||||
|
||||
def test_fuzzy_score_matches_subsequence():
|
||||
assert _fuzzy_score("gpt-4o", "g4o") is not None
|
||||
assert _fuzzy_score("gpt-4o", "4o") is not None
|
||||
assert _fuzzy_score("gpt-4o", "o4g") is None
|
||||
assert _fuzzy_score("gpt-4o", "xyz") is None
|
||||
|
||||
|
||||
def test_scorer_matches_typescript_reference():
|
||||
"""Score parity with ui-tui/web fuzzy.ts. These exact values are produced
|
||||
by the TS fuzzyScoreMulti for the same inputs (verified via a cross-language
|
||||
harness); keep the Python port byte-identical so all three surfaces rank
|
||||
consistently. If you change the scoring constants, update the TS copies too.
|
||||
"""
|
||||
cases = {
|
||||
("gpt-4o", "g4o"): 15.94,
|
||||
("gpt-4o", "gpt"): 28.94,
|
||||
("claude-sonnet-4", "sonnet"): 33.85,
|
||||
("claude-sonnet-4", "clad snnt"): 30.70,
|
||||
("GptO", "gpto"): 57.96, # camelCase boundary on the original-case 'O'
|
||||
}
|
||||
for (label, query), expected in cases.items():
|
||||
score = _fuzzy_score(label, query)
|
||||
assert score is not None
|
||||
assert round(score, 2) == expected, f"{label!r}/{query!r}: {score} != {expected}"
|
||||
|
||||
|
||||
def test_is_boundary_camelcase_and_separators():
|
||||
assert _is_boundary("gpt-4o", 0) is True # start
|
||||
assert _is_boundary("gpt-4o", 4) is True # after '-'
|
||||
assert _is_boundary("gpt-4o", 2) is False # mid-word
|
||||
assert _is_boundary("GptO", 3) is True # lower->upper transition
|
||||
|
||||
|
||||
def test_token_score_takes_orig_and_lower():
|
||||
# Exact match (lower == token) earns the +20 bonus over a prefix.
|
||||
exact = _token_score("sonnet", "sonnet", "sonnet")
|
||||
prefix = _token_score("sonnet-x", "sonnet-x", "sonnet")
|
||||
assert exact is not None and prefix is not None
|
||||
assert exact > prefix
|
||||
|
||||
|
||||
def test_esc_clears_query_and_signals_changed():
|
||||
# Esc during active search clears the filter (restores full list) and
|
||||
# signals `changed` so the driver resets scroll/cursor.
|
||||
search = _SearchState(active=True, query="gpt")
|
||||
handled, confirm, changed = _handle_active_search_key(_FakeCurses, 27, search)
|
||||
assert (handled, confirm, changed) == (True, False, True)
|
||||
assert search.active is False
|
||||
assert search.query == ""
|
||||
|
||||
# Esc with no query: still stops search, but nothing changed.
|
||||
search2 = _SearchState(active=True, query="")
|
||||
assert _handle_active_search_key(_FakeCurses, 27, search2) == (True, False, False)
|
||||
|
||||
|
||||
def test_high_byte_keys_ignored():
|
||||
# Bytes 128-255 must NOT append Latin-1 mojibake to the query.
|
||||
search = _SearchState(active=True, query="ab")
|
||||
handled, _, changed = _handle_active_search_key(_FakeCurses, 200, search)
|
||||
assert (handled, changed) == (False, False)
|
||||
assert search.query == "ab"
|
||||
|
||||
|
||||
def test_fuzzy_score_empty_query_is_zero():
|
||||
assert _fuzzy_score("anything", "") == 0
|
||||
assert _fuzzy_score("anything", " ") == 0
|
||||
|
||||
|
||||
def test_fuzzy_score_prefix_beats_scattered():
|
||||
prefix = _fuzzy_score("gpt-4o-mini", "gpt")
|
||||
scattered = _fuzzy_score("a-g-p-t", "gpt")
|
||||
assert prefix is not None and scattered is not None
|
||||
assert prefix > scattered
|
||||
|
||||
|
||||
def test_fuzzy_score_exact_and_shorter_rank_higher():
|
||||
exact = _fuzzy_score("sonnet", "sonnet")
|
||||
longer = _fuzzy_score("sonnet-extended", "sonnet")
|
||||
assert exact is not None and longer is not None
|
||||
# Same prefix match, but the shorter id wins on the length tiebreak.
|
||||
assert exact > longer
|
||||
|
||||
|
||||
def test_filter_indices_ranks_best_first():
|
||||
models = ["gpt-4o", "gpt-4o-mini", "claude-sonnet-4", "claude-haiku", "o1-preview"]
|
||||
|
||||
# g4o matches both gpt-4o variants; the shorter exact-ish one ranks first.
|
||||
ranked = _filter_indices(models, "g4o")
|
||||
assert [models[i] for i in ranked] == ["gpt-4o", "gpt-4o-mini"]
|
||||
|
||||
# son4 surfaces the sonnet model.
|
||||
assert [models[i] for i in _filter_indices(models, "son4")] == ["claude-sonnet-4"]
|
||||
|
||||
# Multi-token AND.
|
||||
assert [models[i] for i in _filter_indices(models, "clad snnt")] == ["claude-sonnet-4"]
|
||||
|
||||
# No match drops everything.
|
||||
assert _filter_indices(models, "zzz") == []
|
||||
|
||||
|
||||
def test_filter_indices_blank_query_preserves_order():
|
||||
models = ["b", "a", "c"]
|
||||
assert _filter_indices(models, "") == [0, 1, 2]
|
||||
assert _filter_indices(models, " ") == [0, 1, 2]
|
||||
|
||||
|
||||
def test_filter_indices_stable_for_equal_scores():
|
||||
# Identical labels score identically; original order is the tiebreak.
|
||||
items = ["ab", "ab", "ab"]
|
||||
assert _filter_indices(items, "ab") == [0, 1, 2]
|
||||
@@ -0,0 +1,68 @@
|
||||
from hermes_cli.curses_ui import (
|
||||
_SearchState,
|
||||
_filter_indices,
|
||||
_handle_active_search_key,
|
||||
_move_filtered_cursor,
|
||||
_reconcile_cursor,
|
||||
)
|
||||
|
||||
|
||||
class _FakeCurses:
|
||||
KEY_BACKSPACE = 263
|
||||
KEY_DOWN = 258
|
||||
KEY_ENTER = 343
|
||||
|
||||
|
||||
def test_filter_indices_keeps_all_items_for_blank_query():
|
||||
assert _filter_indices(["Anthropic", "OpenAI"], "") == [0, 1]
|
||||
assert _filter_indices(["Anthropic", "OpenAI"], " ") == [0, 1]
|
||||
|
||||
|
||||
def test_filter_indices_matches_subsequences():
|
||||
items = ["claude-opus-4-7", "gpt-5.4-codex", "deepseek-v4"]
|
||||
|
||||
assert _filter_indices(items, "co47") == [0]
|
||||
assert _filter_indices(items, "gpt5") == [1]
|
||||
|
||||
|
||||
def test_filter_indices_requires_all_tokens():
|
||||
items = ["OpenAI Codex", "OpenAI Chat Completions", "Anthropic Claude"]
|
||||
|
||||
assert _filter_indices(items, "open cod") == [0]
|
||||
|
||||
|
||||
def test_reconcile_cursor_moves_to_first_visible_match():
|
||||
assert _reconcile_cursor([2, 4], 0) == (2, 0)
|
||||
assert _reconcile_cursor([2, 4], 4) == (4, 1)
|
||||
|
||||
|
||||
def test_move_filtered_cursor_wraps_within_matches():
|
||||
filtered = [2, 4, 7]
|
||||
|
||||
assert _move_filtered_cursor(filtered, 2, 0, -1) == 7
|
||||
assert _move_filtered_cursor(filtered, 7, 2, 1) == 2
|
||||
|
||||
|
||||
def test_active_search_allows_navigation_keys_to_reach_menu_loop():
|
||||
search = _SearchState(active=True, query="opus")
|
||||
|
||||
assert _handle_active_search_key(_FakeCurses, _FakeCurses.KEY_DOWN, search) == (
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
)
|
||||
assert search.active is True
|
||||
assert search.query == "opus"
|
||||
|
||||
|
||||
def test_active_search_consumes_query_editing_and_confirm_keys():
|
||||
search = _SearchState(active=True, query="op")
|
||||
|
||||
assert _handle_active_search_key(_FakeCurses, ord("u"), search) == (True, False, True)
|
||||
assert search.query == "opu"
|
||||
|
||||
assert _handle_active_search_key(_FakeCurses, _FakeCurses.KEY_ENTER, search) == (
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
)
|
||||
@@ -74,6 +74,35 @@ class TestMcpEndpoints:
|
||||
r = self.client.post("/api/mcp/servers", json={"name": "bad"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_enable_disable_toggle(self):
|
||||
self.client.post("/api/mcp/servers", json={"name": "tog", "url": "u"})
|
||||
r = self.client.put("/api/mcp/servers/tog/enabled", json={"enabled": False})
|
||||
assert r.status_code == 200 and r.json()["enabled"] is False
|
||||
srv = [
|
||||
s for s in self.client.get("/api/mcp/servers").json()["servers"]
|
||||
if s["name"] == "tog"
|
||||
][0]
|
||||
assert srv["enabled"] is False
|
||||
# Toggling a missing server is a 404.
|
||||
assert self.client.put(
|
||||
"/api/mcp/servers/nope/enabled", json={"enabled": True}
|
||||
).status_code == 404
|
||||
|
||||
def test_catalog_lists_entries(self):
|
||||
r = self.client.get("/api/mcp/catalog")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "entries" in body and "diagnostics" in body
|
||||
# The shipped optional-mcps/ catalog has at least one entry; each must
|
||||
# carry the install/enabled status fields the UI relies on.
|
||||
for e in body["entries"]:
|
||||
assert {"name", "transport", "installed", "enabled", "needs_install"} <= set(e)
|
||||
|
||||
def test_catalog_install_unknown_404(self):
|
||||
r = self.client.post("/api/mcp/catalog/install", json={"name": "no-such-mcp-xyz"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
|
||||
class TestCredentialPoolEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -190,6 +219,40 @@ class TestOpsEndpoints:
|
||||
save_config(cfg)
|
||||
data = self.client.get("/api/ops/hooks").json()
|
||||
assert data["hooks"][0]["command"] == "/bin/echo hi"
|
||||
assert "valid_events" in data and len(data["valid_events"]) >= 1
|
||||
|
||||
def test_hook_create_and_delete(self):
|
||||
# Create with consent approval.
|
||||
r = self.client.post(
|
||||
"/api/ops/hooks",
|
||||
json={
|
||||
"event": "pre_tool_call",
|
||||
"command": "/bin/echo created",
|
||||
"matcher": "terminal",
|
||||
"timeout": 7,
|
||||
"approve": True,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["approved"] is True
|
||||
|
||||
hooks = self.client.get("/api/ops/hooks").json()["hooks"]
|
||||
created = [h for h in hooks if h["command"] == "/bin/echo created"]
|
||||
assert created and created[0]["allowed"] is True
|
||||
|
||||
# Unknown event rejected.
|
||||
assert self.client.post(
|
||||
"/api/ops/hooks", json={"event": "no_such_event", "command": "/x"}
|
||||
).status_code == 400
|
||||
|
||||
# Delete it.
|
||||
r = self.client.request(
|
||||
"DELETE",
|
||||
"/api/ops/hooks",
|
||||
json={"event": "pre_tool_call", "command": "/bin/echo created"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
hooks2 = self.client.get("/api/ops/hooks").json()["hooks"]
|
||||
assert not [h for h in hooks2 if h["command"] == "/bin/echo created"]
|
||||
|
||||
def test_checkpoints_list_empty(self):
|
||||
data = self.client.get("/api/ops/checkpoints").json()
|
||||
@@ -200,6 +263,131 @@ class TestOpsEndpoints:
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestSystemStatsEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_stats_shape(self):
|
||||
r = self.client.get("/api/system/stats")
|
||||
assert r.status_code == 200
|
||||
s = r.json()
|
||||
# Identity fields always present (stdlib-sourced).
|
||||
for key in ("os", "arch", "hostname", "python_version", "hermes_version"):
|
||||
assert key in s and s[key]
|
||||
# psutil flag tells the UI whether the richer metrics are populated.
|
||||
assert "psutil" in s
|
||||
|
||||
|
||||
class TestCuratorEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_status_and_pause_toggle(self):
|
||||
r = self.client.get("/api/curator")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"enabled", "paused", "interval_hours"} <= set(body)
|
||||
# Pause then resume; the read reflects the write.
|
||||
r = self.client.put("/api/curator/paused", json={"paused": True})
|
||||
assert r.status_code == 200 and r.json()["paused"] is True
|
||||
assert self.client.get("/api/curator").json()["paused"] is True
|
||||
r = self.client.put("/api/curator/paused", json={"paused": False})
|
||||
assert r.status_code == 200 and r.json()["paused"] is False
|
||||
|
||||
|
||||
class TestPortalEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_status_shape(self):
|
||||
r = self.client.get("/api/portal")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"logged_in", "features", "subscription_url", "provider"} <= set(body)
|
||||
assert isinstance(body["features"], list)
|
||||
|
||||
|
||||
class TestSessionManagementEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
db.create_session(session_id="sess-x", source="cli")
|
||||
db.close()
|
||||
|
||||
def test_stats_not_shadowed_by_session_id_route(self):
|
||||
# /api/sessions/stats must resolve to the stats handler, not be captured
|
||||
# as {session_id}="stats" by the parameterized route registered after it.
|
||||
r = self.client.get("/api/sessions/stats")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert {"total", "active_store", "archived", "messages", "by_source"} <= set(body)
|
||||
assert body["total"] >= 1
|
||||
|
||||
def test_rename(self):
|
||||
r = self.client.patch("/api/sessions/sess-x", json={"title": "Renamed"})
|
||||
assert r.status_code == 200 and r.json()["title"] == "Renamed"
|
||||
|
||||
def test_export(self):
|
||||
r = self.client.get("/api/sessions/sess-x/export")
|
||||
assert r.status_code == 200 and "messages" in r.json()
|
||||
assert self.client.get("/api/sessions/nope/export").status_code == 404
|
||||
|
||||
def test_prune_validation(self):
|
||||
r = self.client.post("/api/sessions/prune", json={"older_than_days": 9999})
|
||||
assert r.status_code == 200 and "removed" in r.json()
|
||||
assert self.client.post(
|
||||
"/api/sessions/prune", json={"older_than_days": 0}
|
||||
).status_code == 400
|
||||
|
||||
|
||||
class TestSkillsHubSearchEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
|
||||
def test_empty_query_returns_empty(self):
|
||||
# Empty query short-circuits (no network) and returns no results.
|
||||
r = self.client.get("/api/skills/hub/search?q=")
|
||||
assert r.status_code == 200 and r.json() == {"results": []}
|
||||
|
||||
|
||||
|
||||
|
||||
class TestWebhookToggleEndpoint:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, _ = _client()
|
||||
# Enable the webhook platform so a subscription can be created.
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("platforms", {})["webhook"] = {
|
||||
"enabled": True,
|
||||
"extra": {"host": "0.0.0.0", "port": 8644},
|
||||
}
|
||||
save_config(cfg)
|
||||
|
||||
def test_create_toggle_disable(self):
|
||||
r = self.client.post(
|
||||
"/api/webhooks", json={"name": "hook1", "deliver": "log", "events": ["push"]}
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["enabled"] is True
|
||||
r = self.client.put("/api/webhooks/hook1/enabled", json={"enabled": False})
|
||||
assert r.status_code == 200 and r.json()["enabled"] is False
|
||||
subs = self.client.get("/api/webhooks").json()["subscriptions"]
|
||||
assert subs[0]["enabled"] is False
|
||||
assert self.client.put(
|
||||
"/api/webhooks/nope/enabled", json={"enabled": True}
|
||||
).status_code == 404
|
||||
|
||||
|
||||
|
||||
class TestAdminEndpointsAuthGate:
|
||||
"""Every admin endpoint must sit behind the dashboard session-token gate."""
|
||||
|
||||
@@ -221,6 +409,9 @@ class TestAdminEndpointsAuthGate:
|
||||
"/api/memory",
|
||||
"/api/ops/hooks",
|
||||
"/api/ops/checkpoints",
|
||||
"/api/curator",
|
||||
"/api/portal",
|
||||
"/api/system/stats",
|
||||
],
|
||||
)
|
||||
def test_gated(self, path):
|
||||
|
||||
@@ -361,28 +361,6 @@ def _stub_s6(monkeypatch: pytest.MonkeyPatch, *, on_s6: bool) -> _CallRecorder:
|
||||
return rec
|
||||
|
||||
|
||||
class _ExecvpCalled(BaseException):
|
||||
"""Sentinel raised by the os.execvp stub so tests can assert on it
|
||||
without actually replacing the test runner process. Inherits from
|
||||
BaseException so it bypasses generic ``except Exception`` blocks in
|
||||
the code under test (just like a real exec would)."""
|
||||
|
||||
def __init__(self, argv: list[str]) -> None:
|
||||
self.argv = argv
|
||||
|
||||
|
||||
def _stub_execvp(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
|
||||
"""Replace os.execvp with a recorder that raises _ExecvpCalled."""
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_execvp(file: str, args: list[str]) -> None: # noqa: ANN401
|
||||
calls.append([file, *args])
|
||||
raise _ExecvpCalled([file, *args])
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway.os.execvp", fake_execvp)
|
||||
return calls
|
||||
|
||||
|
||||
def test_redirect_noop_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Host runs (non-s6) must not redirect. Returns False; caller
|
||||
continues to the foreground gateway code path unchanged."""
|
||||
@@ -407,14 +385,31 @@ def test_redirect_fires_inside_s6_container(
|
||||
|
||||
1. Dispatch `start` to the service manager.
|
||||
2. Print the loud breadcrumb to stderr.
|
||||
3. exec `sleep infinity` to keep the CMD alive without binding
|
||||
container lifetime to gateway PID lifetime.
|
||||
3. exec `sleep infinity` to keep the CMD alive (the cheap heartbeat;
|
||||
no resident Python interpreter) without binding container
|
||||
lifetime to gateway PID lifetime.
|
||||
"""
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
rec = _stub_s6(monkeypatch, on_s6=True)
|
||||
monkeypatch.setattr("hermes_cli.gateway._profile_suffix", lambda: "")
|
||||
execvp_calls = _stub_execvp(monkeypatch)
|
||||
|
||||
class _ExecvpCalled(BaseException):
|
||||
def __init__(self, argv: list[str]) -> None:
|
||||
self.argv = argv
|
||||
|
||||
execvp_calls: list[list[str]] = []
|
||||
|
||||
def fake_execvp(file: str, args: list[str]) -> None:
|
||||
execvp_calls.append([file, *args])
|
||||
raise _ExecvpCalled([file, *args])
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway.os.execvp", fake_execvp)
|
||||
# If the fallback ran, the normal sleep path was wrongly skipped.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway._block_until_terminated",
|
||||
lambda: pytest.fail("fallback should not run when sleep is available"),
|
||||
)
|
||||
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_NO_SUPERVISE", raising=False)
|
||||
|
||||
@@ -428,11 +423,90 @@ def test_redirect_fires_inside_s6_container(
|
||||
assert "s6 supervision" in err
|
||||
assert "--no-supervise" in err
|
||||
assert "HERMES_GATEWAY_NO_SUPERVISE" in err
|
||||
# 3. exec'd `sleep infinity`.
|
||||
# 3. exec'd `sleep infinity` (the preferred cheap heartbeat).
|
||||
assert execvp_calls == [["sleep", "sleep", "infinity"]]
|
||||
assert excinfo.value.argv == ["sleep", "sleep", "infinity"]
|
||||
|
||||
|
||||
def test_redirect_falls_back_when_sleep_missing(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Regression guard for issue #36208: when ``os.execvp("sleep", ...)``
|
||||
raises (no `sleep` on a clobbered/empty PATH, or a minimal image
|
||||
without it), the redirect must NOT crash the container — it falls
|
||||
back to the in-process ``_block_until_terminated`` heartbeat so the
|
||||
container keeps running.
|
||||
"""
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
rec = _stub_s6(monkeypatch, on_s6=True)
|
||||
monkeypatch.setattr("hermes_cli.gateway._profile_suffix", lambda: "")
|
||||
|
||||
def missing_sleep(file: str, args: list[str]) -> None:
|
||||
raise FileNotFoundError(2, "No such file or directory", file)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway.os.execvp", missing_sleep)
|
||||
block_calls: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway._block_until_terminated",
|
||||
lambda: block_calls.append(True),
|
||||
)
|
||||
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_NO_SUPERVISE", raising=False)
|
||||
|
||||
# Must not raise FileNotFoundError — that was the #36208 crash.
|
||||
result = gw._maybe_redirect_run_to_s6_supervision(_Args())
|
||||
|
||||
assert result is True
|
||||
assert rec.calls == [("start", "gateway-default")]
|
||||
# Fell back to the in-process heartbeat instead of crashing.
|
||||
assert block_calls == [True]
|
||||
err = capsys.readouterr().err
|
||||
assert "`sleep` is unavailable" in err
|
||||
|
||||
|
||||
def test_block_until_terminated_installs_sigterm_handler_and_blocks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``_block_until_terminated`` must register a SIGTERM handler (so
|
||||
`docker stop` exits cleanly) and then block on signal.pause() — never
|
||||
touching an external binary. Regression guard for issue #36208, where
|
||||
os.execvp("sleep", ...) crashed the container with FileNotFoundError
|
||||
when PATH lacked a directory containing `sleep`.
|
||||
"""
|
||||
import signal as _signal
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
registered: dict[int, object] = {}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.signal.signal",
|
||||
lambda signum, handler: registered.__setitem__(signum, handler),
|
||||
)
|
||||
|
||||
# Make signal.pause() raise after the first call so the infinite loop
|
||||
# terminates deterministically instead of hanging the test.
|
||||
pause_calls = {"n": 0}
|
||||
|
||||
def fake_pause() -> None:
|
||||
pause_calls["n"] += 1
|
||||
raise KeyboardInterrupt # break out of the `while True: pause()` loop
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway.signal.pause", fake_pause)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
gw._block_until_terminated()
|
||||
|
||||
# A SIGTERM handler was installed...
|
||||
assert _signal.SIGTERM in registered
|
||||
# ...and it exits with the conventional 128+signum code.
|
||||
handler = registered[_signal.SIGTERM]
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
handler(_signal.SIGTERM, None) # type: ignore[operator]
|
||||
assert exc.value.code == 128 + _signal.SIGTERM
|
||||
# ...and we actually blocked on pause().
|
||||
assert pause_calls["n"] == 1
|
||||
|
||||
|
||||
def test_redirect_short_circuits_supervised_child(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -516,10 +590,25 @@ def test_redirect_no_supervise_env_falsy_values_dont_opt_out(
|
||||
|
||||
_stub_s6(monkeypatch, on_s6=True)
|
||||
monkeypatch.setattr("hermes_cli.gateway._profile_suffix", lambda: "")
|
||||
_stub_execvp(monkeypatch)
|
||||
|
||||
# The redirect reaching its `sleep` heartbeat means it did NOT opt
|
||||
# out. Stub execvp to record + raise (so it doesn't replace the test
|
||||
# process) rather than actually exec.
|
||||
class _ExecvpCalled(BaseException):
|
||||
pass
|
||||
|
||||
execvp_calls: list[str] = []
|
||||
|
||||
def fake_execvp(file: str, args: list[str]) -> None:
|
||||
execvp_calls.append(file)
|
||||
raise _ExecvpCalled
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway.os.execvp", fake_execvp)
|
||||
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
|
||||
|
||||
for falsy in ("", "0", "false", "no", "off", "garbage"):
|
||||
execvp_calls.clear()
|
||||
monkeypatch.setenv("HERMES_GATEWAY_NO_SUPERVISE", falsy)
|
||||
with pytest.raises(_ExecvpCalled):
|
||||
gw._maybe_redirect_run_to_s6_supervision(_Args())
|
||||
assert execvp_calls == ["sleep"], f"redirect should fire for {falsy!r}"
|
||||
|
||||
@@ -166,3 +166,65 @@ def test_decompose_records_audit_comment_and_event(kanban_home):
|
||||
|
||||
assert any("Decomposed into" in (c.body or "") for c in comments)
|
||||
assert any(ev.kind == "decomposed" for ev in events)
|
||||
|
||||
|
||||
def test_decompose_children_inherit_dir_workspace(kanban_home):
|
||||
"""Fan-out children inherit the root's dir workspace, not scratch."""
|
||||
proj = "/home/teknium/myproject"
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(
|
||||
conn, title="codegen root", assignee="worker",
|
||||
workspace_kind="dir", workspace_path=proj, triage=True,
|
||||
)
|
||||
child_ids = kb.decompose_triage_task(
|
||||
conn, tid, root_assignee="orchestrator",
|
||||
children=[{"title": "part A"}, {"title": "part B", "parents": [0]}],
|
||||
author="decomposer",
|
||||
)
|
||||
assert child_ids and len(child_ids) == 2
|
||||
with kb.connect() as conn:
|
||||
for cid in child_ids:
|
||||
t = kb.get_task(conn, cid)
|
||||
assert t.workspace_kind == "dir"
|
||||
assert t.workspace_path == proj
|
||||
|
||||
|
||||
def test_decompose_children_stay_scratch_when_root_scratch(kanban_home):
|
||||
"""No regression: a scratch root still fans out into scratch children."""
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(
|
||||
conn, title="scratch root", assignee="worker",
|
||||
workspace_kind="scratch", triage=True,
|
||||
)
|
||||
child_ids = kb.decompose_triage_task(
|
||||
conn, tid, root_assignee="orchestrator",
|
||||
children=[{"title": "s1"}], author="decomposer",
|
||||
)
|
||||
with kb.connect() as conn:
|
||||
t = kb.get_task(conn, child_ids[0])
|
||||
assert t.workspace_kind == "scratch"
|
||||
assert t.workspace_path is None
|
||||
|
||||
|
||||
def test_decompose_per_child_workspace_override(kanban_home):
|
||||
"""An explicit per-child workspace beats inheritance."""
|
||||
proj = "/home/teknium/myproject"
|
||||
with kb.connect() as conn:
|
||||
tid = kb.create_task(
|
||||
conn, title="root", assignee="worker",
|
||||
workspace_kind="dir", workspace_path=proj, triage=True,
|
||||
)
|
||||
child_ids = kb.decompose_triage_task(
|
||||
conn, tid, root_assignee="orchestrator",
|
||||
children=[
|
||||
{"title": "override", "workspace_kind": "dir",
|
||||
"workspace_path": "/other/repo"},
|
||||
{"title": "inherit"},
|
||||
],
|
||||
author="decomposer",
|
||||
)
|
||||
with kb.connect() as conn:
|
||||
over = kb.get_task(conn, child_ids[0])
|
||||
inh = kb.get_task(conn, child_ids[1])
|
||||
assert over.workspace_path == "/other/repo"
|
||||
assert inh.workspace_path == proj
|
||||
|
||||
@@ -13,7 +13,7 @@ def test_prompt_model_selection_uses_curses_radiolist():
|
||||
|
||||
seen = {}
|
||||
|
||||
def _fake(title, items, *, selected=0, cancel_returns=None, description=None):
|
||||
def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False):
|
||||
seen["title"] = title
|
||||
seen["items"] = items
|
||||
return 1 # pick second model
|
||||
@@ -67,7 +67,7 @@ def test_model_selection_with_pricing_passes_description():
|
||||
|
||||
seen = {}
|
||||
|
||||
def _fake(title, items, *, selected=0, cancel_returns=None, description=None):
|
||||
def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False):
|
||||
seen["description"] = description
|
||||
return len(items) - 1 # Skip
|
||||
|
||||
|
||||
@@ -254,8 +254,13 @@ def test_openai_native_curated_catalog_is_non_empty():
|
||||
assert len(_PROVIDER_MODELS["openai"]) >= 4
|
||||
|
||||
|
||||
def test_list_authenticated_providers_openai_built_in_nonzero_total(monkeypatch):
|
||||
"""Built-in openai row must not report total_models=0 when creds exist."""
|
||||
def test_list_authenticated_providers_openai_alias_not_emitted_as_phantom(monkeypatch):
|
||||
"""Bare 'openai' is an alias to the OpenRouter aggregator, NOT a directly-
|
||||
routable provider. It must NOT be emitted as its own picker row: selecting
|
||||
such a row resolves via resolve_provider_full() to OpenRouter, silently
|
||||
switching the user onto an endpoint they may have no key for (HTTP 401).
|
||||
Real OpenAI access comes via 'openai-api' (direct) or a providers.openai
|
||||
config entry — both of which carry api.openai.com. See model-picker bug."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
monkeypatch.setattr(
|
||||
"agent.models_dev.fetch_models_dev",
|
||||
@@ -271,8 +276,63 @@ def test_list_authenticated_providers_openai_built_in_nonzero_total(monkeypatch)
|
||||
max_models=50,
|
||||
)
|
||||
row = next((p for p in providers if p.get("slug") == "openai"), None)
|
||||
assert row is not None
|
||||
assert row["total_models"] > 0
|
||||
assert row is None, (
|
||||
"bare 'openai' alias must not appear as a standalone picker row — "
|
||||
"it routes through OpenRouter and traps users without an OR key"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_provider_full_user_config_openai_beats_alias():
|
||||
"""A providers.openai config entry must win over the built-in
|
||||
'openai' → 'openrouter' alias. Regression for the model-picker bug
|
||||
where users with provider=openai-api + a providers.openai config block
|
||||
had their OpenAI selection silently routed to OpenRouter (HTTP 401)."""
|
||||
from hermes_cli.providers import resolve_provider_full
|
||||
|
||||
user_providers = {
|
||||
"openai": {
|
||||
"name": "OpenAI-API",
|
||||
"api": "https://api.openai.com/v1",
|
||||
"transport": "codex_responses",
|
||||
"models": {"gpt-5.4-nano": {}},
|
||||
}
|
||||
}
|
||||
pdef = resolve_provider_full("openai", user_providers, [])
|
||||
assert pdef is not None
|
||||
# Must resolve to the user's direct endpoint, NOT the OpenRouter aggregator.
|
||||
assert pdef.id == "openai"
|
||||
assert pdef.source == "user-config"
|
||||
assert pdef.base_url == "https://api.openai.com/v1"
|
||||
assert "openrouter" not in pdef.base_url
|
||||
|
||||
|
||||
def test_switch_model_user_config_openai_does_not_hop_to_openrouter(monkeypatch):
|
||||
"""End-to-end: selecting a providers.openai config row in the picker must
|
||||
resolve to api.openai.com, never silently switch to OpenRouter."""
|
||||
monkeypatch.setenv("CUSTOM_OPENAI_API_KEY", "sk-resolved")
|
||||
user_providers = {
|
||||
"openai": {
|
||||
"name": "OpenAI-API",
|
||||
"api": "https://api.openai.com/v1",
|
||||
"api_key": "${CUSTOM_OPENAI_API_KEY}",
|
||||
"transport": "codex_responses",
|
||||
"models": {"gpt-5.4-nano": {}, "gpt-4o-mini": {}},
|
||||
}
|
||||
}
|
||||
result = switch_model(
|
||||
raw_input="gpt-4o-mini",
|
||||
current_provider="openai-api",
|
||||
current_model="gpt-5.4-nano",
|
||||
current_base_url="https://api.openai.com/v1",
|
||||
current_api_key="sk-test",
|
||||
explicit_provider="openai",
|
||||
user_providers=user_providers,
|
||||
custom_providers=[],
|
||||
)
|
||||
assert result.success, result.error_message
|
||||
assert result.target_provider != "openrouter"
|
||||
assert "openrouter" not in (result.base_url or "")
|
||||
assert result.base_url == "https://api.openai.com/v1"
|
||||
|
||||
|
||||
def test_list_authenticated_providers_user_openai_official_url_fallback(monkeypatch):
|
||||
|
||||
@@ -187,11 +187,11 @@ class TestWebServerEndpoints:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def list_sessions_rich(self, limit, offset, min_message_count=0):
|
||||
def list_sessions_rich(self, limit, offset, min_message_count=0, **kwargs):
|
||||
captured["list"] = min_message_count
|
||||
return []
|
||||
|
||||
def session_count(self, min_message_count=0):
|
||||
def session_count(self, min_message_count=0, **kwargs):
|
||||
captured["count"] = min_message_count
|
||||
return 0
|
||||
|
||||
@@ -250,6 +250,76 @@ class TestWebServerEndpoints:
|
||||
resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_archive_session_via_patch(self):
|
||||
"""PATCH archived=true soft-hides a session; archived=false restores it."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="arch-me", source="cli")
|
||||
db.append_message(session_id="arch-me", role="user", content="hi")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/arch-me", json={"archived": True})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["archived"] is True
|
||||
|
||||
# Hidden from the default list, surfaced by archived=only.
|
||||
listed = self.client.get("/api/sessions").json()
|
||||
assert all(s["id"] != "arch-me" for s in listed["sessions"])
|
||||
only = self.client.get("/api/sessions?archived=only").json()
|
||||
assert any(s["id"] == "arch-me" for s in only["sessions"])
|
||||
|
||||
resp = self.client.patch("/api/sessions/arch-me", json={"archived": False})
|
||||
assert resp.status_code == 200
|
||||
restored = self.client.get("/api/sessions").json()
|
||||
assert any(s["id"] == "arch-me" for s in restored["sessions"])
|
||||
|
||||
def test_patch_session_without_fields_is_400(self):
|
||||
"""An existing session + empty body is a bad request, not a 404."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="no-fields", source="cli")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/no-fields", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_get_sessions_rejects_unknown_archived_value(self):
|
||||
resp = self.client.get("/api/sessions?archived=bogus")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_get_sessions_archived_is_boolean(self):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="bool-arch", source="cli")
|
||||
db.append_message(session_id="bool-arch", role="user", content="hi")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
row = next(s for s in self.client.get("/api/sessions").json()["sessions"] if s["id"] == "bool-arch")
|
||||
assert row["archived"] is False
|
||||
|
||||
def test_rename_response_omits_archived_when_not_set(self):
|
||||
"""Title-only PATCH keeps its legacy {ok, title} response shape."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(session_id="title-only", source="cli")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.patch("/api/sessions/title-only", json={"title": "Hi"})
|
||||
assert resp.status_code == 200
|
||||
assert "archived" not in resp.json()
|
||||
|
||||
def test_audio_transcription_endpoint(self, monkeypatch):
|
||||
import tools.transcription_tools as transcription_tools
|
||||
|
||||
|
||||
@@ -25,6 +25,43 @@ def test_xai_provider_registers():
|
||||
assert provider.default_model() == "grok-imagine-video"
|
||||
|
||||
|
||||
def test_xai_provider_lists_text_and_current_image_video_models():
|
||||
from plugins.video_gen.xai import XAIVideoGenProvider
|
||||
|
||||
models = XAIVideoGenProvider().list_models()
|
||||
ids = [model["id"] for model in models]
|
||||
|
||||
assert ids[0] == "grok-imagine-video"
|
||||
assert ids[1] == "grok-imagine-video-1.5-preview"
|
||||
assert models[1]["modalities"] == ["image"]
|
||||
assert models[1]["aliases"] == ["grok-imagine-video-1.5-2026-05-30"]
|
||||
|
||||
|
||||
def test_xai_routes_default_models_by_modality():
|
||||
from plugins.video_gen.xai import _resolve_model_for_modality
|
||||
|
||||
assert _resolve_model_for_modality(
|
||||
"grok-imagine-video",
|
||||
modality="text",
|
||||
explicit_model=False,
|
||||
) == "grok-imagine-video"
|
||||
assert _resolve_model_for_modality(
|
||||
"grok-imagine-video",
|
||||
modality="image",
|
||||
explicit_model=False,
|
||||
) == "grok-imagine-video-1.5-preview"
|
||||
assert _resolve_model_for_modality(
|
||||
"grok-imagine-video-1.5-preview",
|
||||
modality="text",
|
||||
explicit_model=False,
|
||||
) == "grok-imagine-video"
|
||||
assert _resolve_model_for_modality(
|
||||
"grok-imagine-video-1.5-preview",
|
||||
modality="text",
|
||||
explicit_model=True,
|
||||
) == "grok-imagine-video-1.5-preview"
|
||||
|
||||
|
||||
def test_xai_capabilities_text_and_image_only():
|
||||
"""xAI was previously advertised with edit/extend operations. The
|
||||
simplified surface only exposes text-to-video and image-to-video —
|
||||
|
||||
@@ -56,7 +56,7 @@ class _FakeAsyncClient:
|
||||
return _FakeResponse(200, {
|
||||
"status": "done",
|
||||
"video": {"url": "https://xai-cdn/out.mp4", "duration": 8},
|
||||
"model": "grok-imagine-video",
|
||||
"model": self.posts[-1]["json"]["model"],
|
||||
})
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ class TestXAIPayload:
|
||||
provider, captured = xai_provider
|
||||
provider.generate("a dog at sunset")
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video"
|
||||
assert payload["prompt"] == "a dog at sunset"
|
||||
assert "image" not in payload
|
||||
assert "reference_images" not in payload
|
||||
@@ -121,8 +122,31 @@ class TestXAIPayload:
|
||||
provider, captured = xai_provider
|
||||
provider.generate("animate this", image_url="https://example.com/cat.png")
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video-1.5-preview"
|
||||
assert payload["image"] == {"url": "https://example.com/cat.png"}
|
||||
|
||||
def test_local_image_path_is_sent_as_data_uri(self, xai_provider, tmp_path):
|
||||
provider, captured = xai_provider
|
||||
image_path = tmp_path / "frame.png"
|
||||
image_path.write_bytes(b"\x89PNG\r\n\x1a\nfake")
|
||||
|
||||
provider.generate("animate this", image_url=str(image_path))
|
||||
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video-1.5-preview"
|
||||
assert payload["image"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
def test_explicit_model_override_is_honored_for_image(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
provider.generate(
|
||||
"animate this",
|
||||
image_url="https://example.com/cat.png",
|
||||
model="grok-imagine-video",
|
||||
_model_override_explicit=True,
|
||||
)
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video"
|
||||
|
||||
def test_reference_images_payload(self, xai_provider):
|
||||
provider, captured = xai_provider
|
||||
provider.generate(
|
||||
|
||||
@@ -5,6 +5,37 @@ from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DASHBOARD_RUN = REPO_ROOT / "docker" / "s6-rc.d" / "dashboard" / "run"
|
||||
MAIN_WRAPPER = REPO_ROOT / "docker" / "main-wrapper.sh"
|
||||
|
||||
|
||||
def test_main_wrapper_preserves_docker_workdir() -> None:
|
||||
"""The main-wrapper MUST save and restore the original working
|
||||
directory so the container starts in the Docker ``-w`` directory,
|
||||
not /opt/data. Regression test for #35472.
|
||||
"""
|
||||
text = MAIN_WRAPPER.read_text(encoding="utf-8")
|
||||
|
||||
# Must save original cwd before cd /opt/data.
|
||||
assert "_hermes_orig_cwd" in text, (
|
||||
"main-wrapper.sh must save the original cwd before cd /opt/data"
|
||||
)
|
||||
assert 'HERMES_ORIG_CWD:-$PWD' in text, (
|
||||
"main-wrapper.sh must capture PWD as the fallback original cwd"
|
||||
)
|
||||
|
||||
# Must cd to /opt/data for init (existing behaviour preserved).
|
||||
assert "cd /opt/data" in text
|
||||
|
||||
# Must restore original cwd before exec'ing the user command.
|
||||
# The restore cd must appear AFTER venv activation but BEFORE the
|
||||
# first exec / if-block.
|
||||
activate_idx = text.index("/opt/hermes/.venv/bin/activate")
|
||||
restore_idx = text.index('cd "$_hermes_orig_cwd"')
|
||||
exec_idx = text.index("if [ $# -eq 0 ]")
|
||||
assert activate_idx < restore_idx < exec_idx, (
|
||||
"cd $_hermes_orig_cwd must appear after venv activation and "
|
||||
"before the exec routing block"
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_run_resets_home_before_dropping_privileges() -> None:
|
||||
|
||||
@@ -3509,3 +3509,43 @@ class TestApplyWalProbe:
|
||||
assert any("journal_mode=WAL" in sql for sql in conn.executed), (
|
||||
"set-pragma must fire when probe returns 'delete'"
|
||||
)
|
||||
|
||||
|
||||
class TestSessionArchive:
|
||||
"""Soft-archiving hides a session from default listings without deleting it."""
|
||||
|
||||
def _seed(self, db, sid, *, archived=False):
|
||||
db.create_session(session_id=sid, source="cli")
|
||||
db.append_message(session_id=sid, role="user", content=f"hello from {sid}")
|
||||
if archived:
|
||||
db.set_session_archived(sid, True)
|
||||
|
||||
def test_set_session_archived_roundtrip(self, db):
|
||||
self._seed(db, "s1")
|
||||
assert db.set_session_archived("s1", True) is True
|
||||
assert db.get_session("s1")["archived"] == 1
|
||||
assert db.set_session_archived("s1", False) is True
|
||||
assert db.get_session("s1")["archived"] == 0
|
||||
|
||||
def test_set_session_archived_missing_row(self, db):
|
||||
assert db.set_session_archived("nope", True) is False
|
||||
|
||||
def test_archived_excluded_by_default(self, db):
|
||||
self._seed(db, "live")
|
||||
self._seed(db, "hidden", archived=True)
|
||||
|
||||
ids = [s["id"] for s in db.list_sessions_rich()]
|
||||
assert ids == ["live"]
|
||||
assert db.session_count() == 1
|
||||
|
||||
def test_archived_only_and_include(self, db):
|
||||
self._seed(db, "live")
|
||||
self._seed(db, "hidden", archived=True)
|
||||
|
||||
only = [s["id"] for s in db.list_sessions_rich(archived_only=True)]
|
||||
assert only == ["hidden"]
|
||||
assert db.session_count(archived_only=True) == 1
|
||||
|
||||
both = {s["id"] for s in db.list_sessions_rich(include_archived=True)}
|
||||
assert both == {"live", "hidden"}
|
||||
assert db.session_count(include_archived=True) == 2
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Regression tests for Honcho startup fail-open behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from plugins.memory.honcho import HonchoMemoryProvider
|
||||
|
||||
|
||||
class _FakeHonchoConfig(SimpleNamespace):
|
||||
def resolve_session_name(self, **kwargs):
|
||||
return "test-session"
|
||||
|
||||
|
||||
def _configured_hybrid_config() -> _FakeHonchoConfig:
|
||||
return _FakeHonchoConfig(
|
||||
enabled=True,
|
||||
api_key=None,
|
||||
base_url="http://127.0.0.1:8000",
|
||||
recall_mode="hybrid",
|
||||
init_on_session_start=False,
|
||||
dialectic_depth=1,
|
||||
dialectic_depth_levels=None,
|
||||
reasoning_heuristic=True,
|
||||
reasoning_level_cap="high",
|
||||
context_tokens=None,
|
||||
message_max_chars=25000,
|
||||
session_strategy="per-directory",
|
||||
)
|
||||
|
||||
|
||||
def _configured_tools_config(*, init_on_session_start: bool = False) -> _FakeHonchoConfig:
|
||||
cfg = _configured_hybrid_config()
|
||||
cfg.recall_mode = "tools"
|
||||
cfg.init_on_session_start = init_on_session_start
|
||||
return cfg
|
||||
|
||||
|
||||
def test_honcho_hybrid_initialize_returns_without_waiting_for_session_init(monkeypatch):
|
||||
"""Slow Honcho session creation must not block agent startup."""
|
||||
provider = HonchoMemoryProvider()
|
||||
cfg = _configured_hybrid_config()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
lambda: cfg,
|
||||
)
|
||||
|
||||
def slow_session_init(self, cfg, session_id, **kwargs):
|
||||
started.set()
|
||||
release.wait(timeout=5)
|
||||
self._session_initialized = True
|
||||
|
||||
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", slow_session_init)
|
||||
|
||||
start = time.perf_counter()
|
||||
provider.initialize("session-1", platform="cli")
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
try:
|
||||
assert elapsed < 0.5
|
||||
assert started.wait(timeout=1)
|
||||
assert provider._session_key == "test-session"
|
||||
finally:
|
||||
release.set()
|
||||
init_thread = getattr(provider, "_init_thread", None)
|
||||
if init_thread:
|
||||
init_thread.join(timeout=1)
|
||||
|
||||
|
||||
def test_honcho_background_init_rechecks_state_after_lock_race():
|
||||
"""Startup should not spawn/crash if init completes while waiting for lock."""
|
||||
provider = HonchoMemoryProvider()
|
||||
provider._config = _configured_hybrid_config()
|
||||
provider._lazy_init_kwargs = {"platform": "cli"}
|
||||
provider._lazy_init_session_id = "session-1"
|
||||
|
||||
class RacingLock:
|
||||
def __enter__(self):
|
||||
provider._session_initialized = True
|
||||
provider._lazy_init_kwargs = None
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
provider._init_lock = RacingLock()
|
||||
|
||||
provider._start_session_init_background()
|
||||
|
||||
assert provider._init_thread is None
|
||||
assert provider._session_initialized is True
|
||||
|
||||
|
||||
def test_honcho_prefetch_returns_without_waiting_for_first_context_fetch():
|
||||
"""First-turn context injection must fail open when Honcho is slow."""
|
||||
provider = HonchoMemoryProvider()
|
||||
cfg = _configured_hybrid_config()
|
||||
cfg.timeout = 0.1
|
||||
fetch_started = threading.Event()
|
||||
|
||||
class SlowManager:
|
||||
def get_prefetch_context(self, session_key, user_message=None):
|
||||
fetch_started.set()
|
||||
time.sleep(5)
|
||||
return {"representation": "late"}
|
||||
|
||||
def prefetch_context(self, session_key, user_message=None):
|
||||
fetch_started.set()
|
||||
|
||||
def pop_context_result(self, session_key):
|
||||
return {}
|
||||
|
||||
provider._config = cfg
|
||||
provider._manager = SlowManager()
|
||||
provider._session_key = "test-session"
|
||||
provider._session_initialized = True
|
||||
provider._turn_count = 1
|
||||
|
||||
start = time.perf_counter()
|
||||
result = provider.prefetch("what do you know about me?")
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
assert result == ""
|
||||
assert elapsed < 0.5
|
||||
assert fetch_started.is_set()
|
||||
|
||||
|
||||
|
||||
def test_honcho_sync_turn_does_not_start_network_write_before_session_init():
|
||||
"""Session-end sync must not create a blocking writer before init finishes."""
|
||||
provider = HonchoMemoryProvider()
|
||||
cfg = _configured_hybrid_config()
|
||||
get_started = threading.Event()
|
||||
background_started = threading.Event()
|
||||
release_init = threading.Event()
|
||||
|
||||
class SlowManager:
|
||||
def get_or_create(self, session_key):
|
||||
get_started.set()
|
||||
time.sleep(5)
|
||||
return SimpleNamespace()
|
||||
|
||||
def _flush_session(self, session):
|
||||
pass
|
||||
|
||||
provider._config = cfg
|
||||
provider._manager = SlowManager()
|
||||
provider._session_key = "test-session"
|
||||
provider._session_initialized = False
|
||||
provider._start_session_init_background = background_started.set
|
||||
provider._init_thread = threading.Thread(
|
||||
target=lambda: release_init.wait(timeout=5), daemon=True
|
||||
)
|
||||
provider._init_thread.start()
|
||||
|
||||
try:
|
||||
provider.sync_turn("hello", "world")
|
||||
|
||||
assert provider._sync_thread is None
|
||||
assert background_started.is_set()
|
||||
assert not get_started.wait(timeout=0.1)
|
||||
finally:
|
||||
release_init.set()
|
||||
provider._init_thread.join(timeout=1)
|
||||
|
||||
|
||||
def test_honcho_sync_turn_waits_for_full_background_startup(monkeypatch):
|
||||
"""Manager assignment alone is not readiness while background init continues."""
|
||||
provider = HonchoMemoryProvider()
|
||||
cfg = _configured_hybrid_config()
|
||||
session_created = threading.Event()
|
||||
migration_started = threading.Event()
|
||||
release_migration = threading.Event()
|
||||
get_calls = []
|
||||
|
||||
class StartupManager:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def get_or_create(self, session_key):
|
||||
get_calls.append(session_key)
|
||||
session_created.set()
|
||||
return SimpleNamespace(messages=[])
|
||||
|
||||
def migrate_memory_files(self, session_key, mem_dir):
|
||||
migration_started.set()
|
||||
release_migration.wait(timeout=5)
|
||||
|
||||
def prefetch_context(self, session_key, user_message=None):
|
||||
pass
|
||||
|
||||
def _flush_session(self, session):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
lambda: cfg,
|
||||
)
|
||||
monkeypatch.setattr("plugins.memory.honcho.client.get_honcho_client", lambda cfg: object())
|
||||
monkeypatch.setattr("plugins.memory.honcho.session.HonchoSessionManager", StartupManager)
|
||||
|
||||
provider.initialize("session-1", platform="cli")
|
||||
try:
|
||||
assert session_created.wait(timeout=1)
|
||||
assert migration_started.wait(timeout=1)
|
||||
assert provider._manager is not None
|
||||
assert provider._session_initialized is False
|
||||
|
||||
provider.sync_turn("hello", "world")
|
||||
|
||||
assert provider._sync_thread is None
|
||||
assert get_calls == ["test-session"]
|
||||
finally:
|
||||
release_migration.set()
|
||||
init_thread = getattr(provider, "_init_thread", None)
|
||||
if init_thread:
|
||||
init_thread.join(timeout=1)
|
||||
if provider._prefetch_thread:
|
||||
provider._prefetch_thread.join(timeout=1)
|
||||
|
||||
assert provider._session_initialized is True
|
||||
|
||||
|
||||
def test_honcho_system_prompt_advertises_active_while_background_init_runs(monkeypatch):
|
||||
"""Prompt metadata should not require a completed network session."""
|
||||
provider = HonchoMemoryProvider()
|
||||
cfg = _configured_hybrid_config()
|
||||
release = threading.Event()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
lambda: cfg,
|
||||
)
|
||||
|
||||
def slow_session_init(self, cfg, session_id, **kwargs):
|
||||
release.wait(timeout=5)
|
||||
self._session_initialized = True
|
||||
|
||||
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", slow_session_init)
|
||||
|
||||
provider.initialize("session-1", platform="cli")
|
||||
try:
|
||||
prompt = provider.system_prompt_block()
|
||||
assert "Honcho Memory" in prompt
|
||||
assert "hybrid mode" in prompt
|
||||
finally:
|
||||
release.set()
|
||||
init_thread = getattr(provider, "_init_thread", None)
|
||||
if init_thread:
|
||||
init_thread.join(timeout=1)
|
||||
|
||||
|
||||
def test_honcho_tools_eager_init_still_ready_on_return(monkeypatch):
|
||||
"""tools + initOnSessionStart=true keeps its ready-on-return contract."""
|
||||
provider = HonchoMemoryProvider()
|
||||
cfg = _configured_tools_config(init_on_session_start=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
lambda: cfg,
|
||||
)
|
||||
|
||||
def fake_session_init(self, cfg, session_id, **kwargs):
|
||||
self._manager = SimpleNamespace()
|
||||
self._session_key = "test-session"
|
||||
self._session_initialized = True
|
||||
|
||||
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", fake_session_init)
|
||||
|
||||
provider.initialize("session-1", platform="cli")
|
||||
|
||||
assert provider._session_initialized is True
|
||||
assert provider._manager is not None
|
||||
assert provider._init_thread is None
|
||||
|
||||
|
||||
def test_honcho_tools_eager_init_failure_does_not_leave_ready_manager(monkeypatch):
|
||||
"""Failed eager tools startup must not leave hooks seeing a ready session."""
|
||||
provider = HonchoMemoryProvider()
|
||||
cfg = _configured_tools_config(init_on_session_start=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
lambda: cfg,
|
||||
)
|
||||
|
||||
def failing_session_init(self, cfg, session_id, **kwargs):
|
||||
self._manager = SimpleNamespace()
|
||||
self._session_key = "test-session"
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", failing_session_init)
|
||||
|
||||
provider.initialize("session-1", platform="cli")
|
||||
assert provider._session_initialized is False
|
||||
assert provider._manager is None
|
||||
|
||||
background_started = threading.Event()
|
||||
provider._start_session_init_background = background_started.set
|
||||
provider.sync_turn("hello", "world")
|
||||
provider.on_memory_write("add", "user", "prefers safe Honcho startup")
|
||||
|
||||
assert provider._sync_thread is None
|
||||
assert not background_started.is_set()
|
||||
|
||||
result = json.loads(provider.handle_tool_call("honcho_profile", {"peer": "user"}))
|
||||
assert "could not be initialized" in result["error"]
|
||||
assert provider._manager is None
|
||||
|
||||
|
||||
def test_honcho_tools_lazy_hooks_do_not_prestart_background_init(monkeypatch):
|
||||
"""tools lazy mode lets the first tool call own session initialization."""
|
||||
provider = HonchoMemoryProvider()
|
||||
cfg = _configured_tools_config(init_on_session_start=False)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
lambda: cfg,
|
||||
)
|
||||
|
||||
provider.initialize("session-1", platform="cli")
|
||||
background_started = threading.Event()
|
||||
provider._start_session_init_background = background_started.set
|
||||
|
||||
provider.prefetch("what do you know?")
|
||||
provider.queue_prefetch("what do you know?")
|
||||
provider.sync_turn("hello", "world")
|
||||
provider.on_memory_write("add", "user", "prefers fail-open memory")
|
||||
|
||||
assert not background_started.is_set()
|
||||
assert provider._session_initialized is False
|
||||
|
||||
class ToolManager:
|
||||
def get_peer_card(self, session_key, peer="user"):
|
||||
return ["ready"]
|
||||
|
||||
init_calls = []
|
||||
|
||||
def fake_session_init(self, cfg, session_id, **kwargs):
|
||||
init_calls.append(session_id)
|
||||
self._manager = ToolManager()
|
||||
self._session_key = "test-session"
|
||||
self._session_initialized = True
|
||||
|
||||
monkeypatch.setattr(HonchoMemoryProvider, "_do_session_init", fake_session_init)
|
||||
|
||||
result = json.loads(provider.handle_tool_call("honcho_profile", {"peer": "user"}))
|
||||
|
||||
assert result == {"result": ["ready"]}
|
||||
assert init_calls == ["session-1"]
|
||||
assert not background_started.is_set()
|
||||
@@ -884,6 +884,73 @@ def test_session_title_queues_when_db_row_not_ready(monkeypatch):
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
|
||||
def test_notification_event_routing_by_session_key(monkeypatch):
|
||||
"""Background-process events surface only in the session that owns them."""
|
||||
mine = _session(session_key="mine")
|
||||
other = _session(session_key="other")
|
||||
monkeypatch.setattr(server, "_sessions", {"a": mine, "b": other})
|
||||
|
||||
# My own event → handle it.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "mine"}) is False
|
||||
# Global/system event with no owner → handle it.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": ""}) is False
|
||||
assert server._notification_event_belongs_elsewhere(mine, {}) is False
|
||||
# Owned by another *live* session → defer to that session's poller.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "other"}) is True
|
||||
# Owner is gone (not in _sessions) → handle as fallback so it isn't lost.
|
||||
assert server._notification_event_belongs_elsewhere(mine, {"session_key": "ghost"}) is False
|
||||
|
||||
|
||||
def test_session_create_does_not_persist_empty_row(monkeypatch):
|
||||
"""session.create must NOT eagerly write a DB row.
|
||||
|
||||
Every TUI/desktop launch opens a session here just to paint the composer;
|
||||
eagerly creating a row left an empty "Untitled" session behind for every
|
||||
launch the user never typed into. The row is created lazily on first prompt.
|
||||
"""
|
||||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, *args, **kwargs):
|
||||
created.append((args, kwargs))
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
server.threading,
|
||||
"Timer",
|
||||
lambda *a, **k: types.SimpleNamespace(daemon=False, start=lambda: None),
|
||||
)
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.create", "params": {"cols": 80}}
|
||||
)
|
||||
sid = resp["result"]["session_id"]
|
||||
try:
|
||||
assert resp["result"]["stored_session_id"]
|
||||
assert created == [], "session.create should not persist an empty DB row"
|
||||
finally:
|
||||
server._sessions.pop(sid, None)
|
||||
|
||||
|
||||
def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path):
|
||||
"""First prompt persists the row (INSERT OR IGNORE) capturing cwd up front."""
|
||||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, cwd=None):
|
||||
created.append({"key": key, "source": source, "model": model, "cwd": cwd})
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
||||
|
||||
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})
|
||||
|
||||
assert created == [
|
||||
{"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)}
|
||||
]
|
||||
|
||||
|
||||
def test_session_title_clears_pending_after_persist(monkeypatch):
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
@@ -1666,12 +1733,21 @@ def test_setup_runtime_check_rejects_implicit_bedrock_when_unconfigured(monkeypa
|
||||
assert resp["result"]["provider"] == "bedrock"
|
||||
|
||||
|
||||
def test_complete_slash_includes_provider_alias():
|
||||
def test_complete_slash_drops_removed_provider_alias():
|
||||
# `/provider` was folded into a single `/model` command, so autocomplete
|
||||
# must no longer offer the dead alias...
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "complete.slash", "params": {"text": "/pro"}}
|
||||
)
|
||||
|
||||
assert any(item["text"] == "provider" for item in resp["result"]["items"])
|
||||
assert not any(item["text"] == "provider" for item in resp["result"]["items"])
|
||||
|
||||
# ...while `/model` stays the canonical command.
|
||||
resp_model = server.handle_request(
|
||||
{"id": "2", "method": "complete.slash", "params": {"text": "/mod"}}
|
||||
)
|
||||
|
||||
assert any(item["text"] == "model" for item in resp_model["result"]["items"])
|
||||
|
||||
|
||||
def test_complete_slash_returns_plain_string_fields():
|
||||
|
||||
@@ -389,6 +389,57 @@ class TestTeePattern:
|
||||
assert key is None
|
||||
|
||||
|
||||
class TestHermesConfigWriteProtection:
|
||||
"""Terminal-side pairing for the file_tools write_file/patch deny on
|
||||
~/.hermes/config.yaml (#14639). config.yaml IS the security policy
|
||||
(approvals.mode/yolo live there, mtime-keyed cache reloads mid-session),
|
||||
so a write_file deny without terminal-side coverage is unpaired theater.
|
||||
These pin every terminal write idiom against the config file."""
|
||||
|
||||
def test_redirect_overwrite(self):
|
||||
dangerous, key, desc = detect_dangerous_command("echo 'approvals:' > ~/.hermes/config.yaml")
|
||||
assert dangerous is True
|
||||
assert key is not None
|
||||
|
||||
def test_append(self):
|
||||
dangerous, key, desc = detect_dangerous_command("echo ' mode: off' >> ~/.hermes/config.yaml")
|
||||
assert dangerous is True
|
||||
|
||||
def test_tee(self):
|
||||
dangerous, key, desc = detect_dangerous_command("echo x | tee ~/.hermes/config.yaml")
|
||||
assert dangerous is True
|
||||
|
||||
def test_cp_over_config(self):
|
||||
dangerous, key, desc = detect_dangerous_command("cp /tmp/evil.yaml ~/.hermes/config.yaml")
|
||||
assert dangerous is True
|
||||
|
||||
def test_sed_in_place(self):
|
||||
# The gap the pairing closes: sed -i mutates the file directly,
|
||||
# bypassing the redirection/tee patterns.
|
||||
dangerous, key, desc = detect_dangerous_command("sed -i 's/manual/off/' ~/.hermes/config.yaml")
|
||||
assert dangerous is True
|
||||
assert "hermes config" in desc.lower() or "in-place" in desc.lower()
|
||||
|
||||
def test_sed_in_place_long_flag(self):
|
||||
dangerous, key, desc = detect_dangerous_command("sed --in-place 's/manual/off/' ~/.hermes/config.yaml")
|
||||
assert dangerous is True
|
||||
|
||||
def test_custom_hermes_home(self):
|
||||
dangerous, key, desc = detect_dangerous_command("echo x | tee $HERMES_HOME/config.yaml")
|
||||
assert dangerous is True
|
||||
|
||||
def test_read_is_safe(self):
|
||||
# Reading config is not a write — must not trip.
|
||||
dangerous, key, desc = detect_dangerous_command("cat ~/.hermes/config.yaml")
|
||||
assert dangerous is False
|
||||
|
||||
def test_normal_yaml_write_safe(self):
|
||||
# A non-Hermes config.yaml in a project dir is handled by the project
|
||||
# patterns, but a plain temp write must not false-positive.
|
||||
dangerous, key, desc = detect_dangerous_command("echo data > /tmp/scratch.txt")
|
||||
assert dangerous is False
|
||||
|
||||
|
||||
class TestFindExecFullPathRm:
|
||||
"""Detect find -exec with full-path rm bypasses."""
|
||||
|
||||
@@ -1344,11 +1395,16 @@ class TestApprovalTimeoutIsNotConsent:
|
||||
|
||||
self._saved_env = {
|
||||
k: os.environ.get(k)
|
||||
for k in ("HERMES_GATEWAY_SESSION", "HERMES_YOLO_MODE",
|
||||
for k in ("HERMES_GATEWAY_SESSION", "HERMES_CRON_SESSION",
|
||||
"HERMES_YOLO_MODE",
|
||||
"HERMES_SESSION_KEY", "HERMES_INTERACTIVE")
|
||||
}
|
||||
os.environ.pop("HERMES_YOLO_MODE", None)
|
||||
os.environ.pop("HERMES_INTERACTIVE", None)
|
||||
# HERMES_CRON_SESSION takes priority over HERMES_GATEWAY_SESSION in
|
||||
# _is_gateway_approval_context(); a leaked value from a parent cron
|
||||
# process would force the cron path and break these gateway tests.
|
||||
os.environ.pop("HERMES_CRON_SESSION", None)
|
||||
os.environ["HERMES_GATEWAY_SESSION"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = self.SESSION_KEY
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user