Compare commits

..
Author SHA1 Message Date
teknium1 762b681423 feat(curator): add hermes curator usage — all-skills usage view
Surfaces the usage_report()/provenance() data layer added in #36701 as a
user-facing CLI command. Unlike `hermes curator status` (scoped to
curator-managed agent-created candidates), `usage` lists every skill on disk
— bundled built-ins and hub-installed included — with per-skill use/view/patch
counts and an agent/bundled/hub provenance tag.

Flags: --sort {activity,recent,name}, --provenance {agent,bundled,hub} filter,
--json for machine-readable output.
2026-06-01 03:05:41 -07:00
175 changed files with 1384 additions and 11925 deletions
+11 -40
View File
@@ -26,10 +26,6 @@ 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
@@ -200,34 +196,11 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# 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)
# 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)
if: github.event_name == 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
@@ -238,11 +211,9 @@ 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 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.
# Main/release builds still use the per-arch gha cache so the digest
# push below can reuse layers from this smoke-test build.
- name: Build image (arm64, smoke test, cached publish)
if: github.event_name != 'pull_request'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
@@ -254,8 +225,8 @@ 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
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
- name: Smoke test image
uses: ./.github/actions/hermes-smoke-test
@@ -282,8 +253,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=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
- name: Export digest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
+1 -1
View File
@@ -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 python3-venv libffi-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
+5 -8
View File
@@ -308,14 +308,11 @@ 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", 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
if not getattr(agent, "_compression_feasibility_checked", True):
try:
check_compression_model_feasibility(agent)
finally:
agent._compression_feasibility_checked = True
_pre_msg_count = len(messages)
logger.info(
-187
View File
@@ -451,190 +451,3 @@ 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.)"
)
-25
View File
@@ -1128,18 +1128,6 @@ 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
@@ -1551,19 +1539,6 @@ 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
+1 -2
View File
@@ -14,7 +14,6 @@ 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,
@@ -803,7 +802,7 @@ def build_environment_hints() -> str:
host_lines.append(f"User home directory: {os.path.expanduser('~')}")
try:
host_lines.append(f"Current working directory: {resolve_agent_cwd()}")
host_lines.append(f"Current working directory: {os.getcwd()}")
except OSError:
pass
-33
View File
@@ -1,33 +0,0 @@
"""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
+7 -6
View File
@@ -24,6 +24,7 @@ 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 (
@@ -40,7 +41,6 @@ from agent.prompt_builder import (
TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS,
)
from agent.runtime_cwd import resolve_context_cwd
def _ra():
@@ -288,12 +288,13 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
context_parts.append(system_message)
if not agent.skip_context_files:
# 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.
# 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
context_files_prompt = _r.build_context_files_prompt(
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
cwd=_context_cwd, skip_soul=_soul_loaded)
if context_files_prompt:
context_parts.append(context_files_prompt)
+31 -71
View File
@@ -8,24 +8,18 @@ fn main() {
// `option_env!()` macro to default the install-script reference.
// Precedence (matches install.ps1's own arg precedence): commit > branch.
//
// 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.
// 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).
//
// Build script reruns on git HEAD change so a new commit triggers
// a rebuild without `cargo clean`.
@@ -36,20 +30,11 @@ 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}");
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)"
),
}
println!("cargo:warning=hermes-bootstrap: pinning to branch {b}");
}
if commit.is_none() && branch.is_none() {
// Fail loudly rather than silently produce a binary that errors
@@ -61,11 +46,8 @@ fn main() {
);
}
// 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
// Rerun build.rs when HEAD moves so successive builds pick up new
// commits without needing `cargo clean`. .git/HEAD changes on every
// commit / branch switch / rebase.
let git_dir = locate_git_dir();
if let Some(gd) = &git_dir {
@@ -101,46 +83,24 @@ fn main() {
}
fn resolve_commit_pin() -> Option<String> {
// 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;
}
// 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);
}
}
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") {
if !v.trim().is_empty() {
return Some(v.trim().to_string());
}
}
// 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());
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
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> {
-63
View File
@@ -8,8 +8,6 @@ const {
ipcMain,
nativeImage,
nativeTheme,
net: electronNet,
protocol,
safeStorage,
session,
shell,
@@ -366,66 +364,6 @@ 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
@@ -3716,7 +3654,6 @@ app.whenReady().then(() => {
Menu.setApplicationMenu(null)
}
installMediaPermissions()
registerMediaProtocol()
ensureWslWindowsFonts()
createWindow()
+1 -1
View File
@@ -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 session'
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
return (
+21 -66
View File
@@ -67,12 +67,7 @@ import { VirtualSessionList } from './virtual-session-list'
const VIRTUALIZE_THRESHOLD = 25
const SIDEBAR_NAV: SidebarNavItem[] = [
{
id: 'new-session',
label: 'New session',
icon: props => <Codicon name="robot" {...props} />,
action: 'new-session'
},
{ id: 'new-session', label: 'New agent', 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 }
@@ -154,8 +149,6 @@ 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({
@@ -163,9 +156,7 @@ export function ChatSidebar({
onNavigate,
onLoadMoreSessions,
onResumeSession,
onDeleteSession,
onArchiveSession,
onNewSessionInWorkspace
onDeleteSession
}: ChatSidebarProps) {
const sidebarOpen = useStore($sidebarOpen)
const agentsGrouped = useStore($sidebarAgentsGrouped)
@@ -337,7 +328,6 @@ export function ChatSidebar({
dndSensors={dndSensors}
emptyState={<SidebarPinnedEmptyState />}
label="Pinned"
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onReorder={handlePinnedDragEnd}
onResumeSession={onResumeSession}
@@ -371,9 +361,9 @@ export function ChatSidebar({
groups={agentsGrouped ? agentGroups : undefined}
headerAction={
<Button
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
className={cn(
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
'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',
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
@@ -382,17 +372,15 @@ export function ChatSidebar({
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
}
label="Sessions"
label="Agents"
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={onNewSessionInWorkspace}
onReorder={handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
@@ -484,9 +472,7 @@ 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
@@ -510,9 +496,7 @@ function SidebarSessionsSection({
workingSessionIdSet,
onResumeSession,
onDeleteSession,
onArchiveSession,
onTogglePin,
onNewSessionInWorkspace,
pinned,
rootClassName,
contentClassName,
@@ -534,7 +518,6 @@ 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),
@@ -568,19 +551,9 @@ function SidebarSessionsSection({
} else if (groups?.length) {
const groupNodes = groups.map(group =>
dndActive ? (
<SortableSidebarWorkspaceGroup
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderSessionList}
/>
<SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
) : (
<SidebarWorkspaceGroup
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderSessionList}
/>
<SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
)
)
@@ -595,7 +568,6 @@ function SidebarSessionsSection({
inner = (
<VirtualSessionList
activeSessionId={activeSessionId}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onTogglePin={onTogglePin}
@@ -638,7 +610,6 @@ 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>
@@ -647,7 +618,6 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
function SidebarWorkspaceGroup({
group,
renderRows,
onNewSession,
reorderable = false,
dragging = false,
dragHandleProps,
@@ -664,31 +634,18 @@ function SidebarWorkspaceGroup({
return (
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
<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>
)}
<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}
/>
{reorderable && (
<span
{...dragHandleProps}
@@ -706,7 +663,7 @@ function SidebarWorkspaceGroup({
/>
</span>
)}
</div>
</button>
{open && (
<>
{renderRows(visibleSessions)}
@@ -730,7 +687,6 @@ function SidebarWorkspaceGroup({
interface SortableWorkspaceProps {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
}
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
@@ -746,7 +702,6 @@ interface SortableSessionRowProps {
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@@ -26,7 +26,6 @@ interface SessionActions {
title: string
pinned?: boolean
onPin?: () => void
onArchive?: () => void
onDelete?: () => void
}
@@ -41,7 +40,7 @@ interface ItemSpec {
variant?: 'destructive'
}
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) {
const [renameOpen, setRenameOpen] = useState(false)
const items: ItemSpec[] = [
@@ -82,15 +81,6 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive,
setRenameOpen(true)
}
},
{
disabled: !onArchive,
icon: 'archive',
label: 'Archive',
onSelect: () => {
triggerHaptic('selection')
onArchive?.()
}
},
{
className: 'text-destructive focus:text-destructive',
disabled: !onDelete,
@@ -14,7 +14,6 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@@ -46,7 +45,6 @@ export function SidebarSessionRow({
isPinned,
isSelected,
isWorking,
onArchive,
onDelete,
onPin,
onResume,
@@ -63,14 +61,7 @@ export function SidebarSessionRow({
const handleLabel = `Reorder ${title}`
return (
<SessionContextMenu
onArchive={onArchive}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
sessionId={session.id}
title={title}
>
<SessionContextMenu 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',
@@ -97,15 +88,6 @@ export function SidebarSessionRow({
return
}
if (event.metaKey || event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onArchive()
return
}
onResume()
}}
type="button"
@@ -145,14 +127,7 @@ export function SidebarSessionRow({
{age}
</span>
)}
<SessionActionsMenu
onArchive={onArchive}
onDelete={onDelete}
onPin={onPin}
pinned={isPinned}
sessionId={session.id}
title={title}
>
<SessionActionsMenu 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,7 +12,6 @@ interface SessionRowCommonProps {
isPinned: boolean
isSelected: boolean
isWorking: boolean
onArchive: () => void
onDelete: () => void
onPin: () => void
onResume: () => void
@@ -21,7 +20,6 @@ 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
@@ -37,7 +35,6 @@ const OVERSCAN_ROWS = 12
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
activeSessionId,
className,
onArchiveSession,
onDeleteSession,
onResumeSession,
onTogglePin,
@@ -75,7 +72,6 @@ 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 session', detail: 'Start a fresh session' },
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', 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' },
{
+2 -36
View File
@@ -6,7 +6,6 @@ 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'
@@ -34,8 +33,6 @@ import {
$selectedStoredSessionId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentProvider,
setMessages,
@@ -125,7 +122,6 @@ export function DesktopController() {
settingsOpen,
toggleCommandCenter
} = useOverlayRouting()
const terminalTakeoverActive = chatOpen && terminalTakeover
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
@@ -196,10 +192,7 @@ export function DesktopController() {
try {
const limit = $sessionsLimit.get()
// 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)
const result = await listSessions(limit)
if (refreshSessionsRequestRef.current === requestId) {
setSessions(result.sessions)
@@ -331,7 +324,6 @@ export function DesktopController() {
})
const {
archiveSession,
branchCurrentSession,
createBackendSessionForSend,
openSettings,
@@ -400,29 +392,6 @@ 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 } =
@@ -492,11 +461,9 @@ 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))}
/>
)
@@ -518,7 +485,6 @@ export function DesktopController() {
/>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />
{settingsOpen && (
@@ -609,10 +575,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, setSessionArchived } from '@/hermes'
import { deleteSession, getSessionMessages } 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,39 +751,7 @@ 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,
+1 -5
View File
@@ -311,15 +311,11 @@ 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' | 'sessions' | 'tools',
string
> = {
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | '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...'
}
+1 -12
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -19,7 +19,6 @@ 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'
@@ -28,7 +27,6 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'gateway',
'keys',
'mcp',
'sessions',
'tools',
'about'
]
@@ -42,7 +40,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
gateway: '',
keys: '',
mcp: '',
sessions: '',
tools: ''
})
@@ -152,12 +149,6 @@ 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'}
@@ -209,8 +200,6 @@ 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} />
)}
@@ -1,168 +0,0 @@
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>
)
}
+2 -2
View File
@@ -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' | 'sessions' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | '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,
mediaStreamUrl
mediaPathFromMarkdownHref
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
@@ -40,22 +40,24 @@ 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)
}
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
}
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
@@ -276,7 +278,10 @@ 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(
() =>
@@ -1,183 +0,0 @@
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>
)
}
+2 -14
View File
@@ -111,13 +111,9 @@ export class HermesGateway extends JsonRpcGatewayClient {
}
}
export async function listSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude'
): Promise<PaginatedSessions> {
export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}`
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`
})
return {
@@ -127,14 +123,6 @@ export async function listSessions(
}
}
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'])
const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider'])
const TERMINAL_ONLY_COMMANDS = new Set([
'/browser',
-4
View File
@@ -2,8 +2,6 @@ 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,
@@ -100,8 +98,6 @@ export {
Activity,
AlertCircle,
AlertTriangle,
Archive,
ArchiveOff,
ArrowUp,
ArrowUpRight,
AtSign,
-7
View File
@@ -58,13 +58,6 @@ 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
-1
View File
@@ -240,7 +240,6 @@ export interface SessionCreateResponse {
}
export interface SessionInfo {
archived?: boolean
cwd?: null | string
ended_at: null | number
id: string
+3 -42
View File
@@ -2116,41 +2116,6 @@ 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.
# ---------------------------------------------------------------------------
@@ -12170,21 +12135,17 @@ 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 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.
# Prepend pending model switch note so the model knows about the switch
_msn = getattr(self, '_pending_model_switch_note', None)
if _msn:
agent_message = _prepend_note_to_message(agent_message, _msn)
agent_message = _msn + "\n\n" + agent_message
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 = _prepend_note_to_message(agent_message, _srn)
agent_message = _srn + "\n\n" + agent_message
self._pending_skills_reload_note = None
try:
result = self.agent.run_conversation(
+8 -27
View File
@@ -428,18 +428,22 @@ 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
@@ -447,29 +451,6 @@ 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."""
+5 -13
View File
@@ -1182,22 +1182,14 @@ 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
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``.
and invisible unicode block, command-shape patterns are dropped
to avoid false-positives. Skill bodies are vetted at install time
by ``skills_guard.py``.
"""
from tools.cronjob_tools import _scan_cron_prompt, _scan_cron_skill_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)
scanner = _scan_cron_skill_assembled if has_skills else _scan_cron_prompt
scan_error = scanner(assembled)
if scan_error:
job_label = job.get("name") or job.get("id") or "<unknown>"
logger.warning(
-10
View File
@@ -27,20 +27,10 @@ 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
-39
View File
@@ -1,39 +0,0 @@
# 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.
-16
View File
@@ -1722,22 +1722,6 @@ 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(
+12 -160
View File
@@ -1265,107 +1265,6 @@ 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"
@@ -1745,22 +1644,6 @@ 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.
@@ -1851,8 +1734,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, WhatsApp) implement a
documented config-driven access surface ``dm_policy`` / ``group_policy`` /
Some adapters (WeCom, Weixin, Yuanbao, QQBot) 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.
@@ -4001,20 +3884,21 @@ 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).
# _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()
# 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()
if images:
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
@@ -4028,25 +3912,7 @@ 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
@@ -4244,20 +4110,6 @@ 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(
-81
View File
@@ -44,15 +44,6 @@ 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",
@@ -136,15 +127,6 @@ 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
@@ -159,62 +141,6 @@ 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))
@@ -995,13 +921,6 @@ 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,
+32 -109
View File
@@ -4918,109 +4918,13 @@ class TelegramAdapter(BasePlatformAdapter):
channel_prompt=channel_prompt,
)
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:
def _observe_unmentioned_group_message(self, message: Message, msg_type: MessageType, update_id: Optional[int] = None) -> None:
"""Append skipped group chatter to the target session without dispatching."""
store = getattr(self, "_session_store", None)
if not store:
return
try:
event = event or self._build_message_event(message, msg_type, update_id=update_id)
event = 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 = {
@@ -5381,20 +5285,39 @@ class TelegramAdapter(BasePlatformAdapter):
if not self._should_process_message(update.message):
if self._should_observe_unmentioned_group_message(update.message):
_m = update.message
_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
)
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)
return
msg = update.message
msg_type = self._media_message_type(msg)
# 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
event = self._build_message_event(msg, msg_type, update_id=update.update_id)
# Add caption as text
-9
View File
@@ -364,15 +364,6 @@ 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
+1 -9
View File
@@ -161,15 +161,7 @@ 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()
# 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._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom"))
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"))
+12 -20
View File
@@ -378,16 +378,12 @@ async def _api_post(
) -> Dict[str, Any]:
body = _json_dumps({**payload, "base_info": _base_info()})
url = f"{base_url.rstrip('/')}/{endpoint}"
# 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)
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)
async def _api_get(
@@ -402,16 +398,12 @@ async def _api_get(
"iLink-App-Id": ILINK_APP_ID,
"iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION),
}
# 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)
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)
async def _get_updates(
-5
View File
@@ -379,11 +379,6 @@ 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":
+2 -26
View File
@@ -5121,30 +5121,6 @@ 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
@@ -6844,8 +6820,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, QQBot, and WhatsApp evaluate their
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
such as WeCom, Weixin, Yuanbao, and QQBot 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
+4 -23
View File
@@ -261,12 +261,6 @@ 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
@@ -555,9 +549,6 @@ 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()
@@ -1067,17 +1058,12 @@ class GatewayStreamConsumer:
age = time.monotonic() - self._message_created_ts
return age >= threshold
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
async def _try_fresh_final(self, text: str) -> 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
@@ -1122,13 +1108,10 @@ class GatewayStreamConsumer:
self._message_created_ts = None
self._already_sent = True
self._last_sent_text = text
if is_turn_final:
self._final_response_sent = True
self._final_response_sent = True
return True
async def _send_or_edit(
self, text: str, *, finalize: bool = False, is_turn_final: bool = True,
) -> bool:
async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
"""Send or edit the streaming message.
Returns True if the text was successfully delivered (sent or edited),
@@ -1222,9 +1205,7 @@ class GatewayStreamConsumer:
if (
finalize
and self._should_send_fresh_final()
and await self._try_fresh_final(
text, is_turn_final=is_turn_final,
)
and await self._try_fresh_final(text)
):
return True
# Edit existing message
-1
View File
@@ -6165,7 +6165,6 @@ def _prompt_model_selection(
selected=default_idx,
cancel_returns=-1,
description=description,
searchable=True,
)
if idx < 0:
return None
+1 -1
View File
@@ -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 gateway stop'.")
print_info("Recommendation: stop the gateway first with 'hermes 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.")
+1 -1
View File
@@ -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",
args_hint="[model] [--provider name] [--global] [--refresh]"),
aliases=("provider",), 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]"),
+3 -7
View File
@@ -105,9 +105,7 @@ _hermes_profiles() {{
local profiles_dir="$HOME/.hermes/profiles"
local profiles="default"
if [ -d "$profiles_dir" ]; then
for f in "$profiles_dir"/*/; do
[ -d "$f" ] && profiles="$profiles $(basename "$f")"
done
profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)"
fi
echo "$profiles"
}}
@@ -208,7 +206,7 @@ _hermes_profiles() {{
local -a profiles
profiles=(default)
if [[ -d "$HOME/.hermes/profiles" ]]; then
profiles+=($HOME/.hermes/profiles/*(N/:t))
profiles+=("${{(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}}")
fi
_describe 'profile' profiles
}}
@@ -262,9 +260,7 @@ def generate_fish(parser: argparse.ArgumentParser) -> str:
"function __hermes_profiles",
" echo default",
" if test -d $HOME/.hermes/profiles",
" for d in $HOME/.hermes/profiles/*/",
" basename $d",
" end",
" ls $HOME/.hermes/profiles 2>/dev/null",
" end",
"end",
"",
+79
View File
@@ -473,6 +473,66 @@ def _cmd_list_archived(args) -> int:
return 0
def _cmd_usage(args) -> int:
"""Show usage telemetry for ALL skills, with provenance.
Unlike `status` (curator-scoped to agent-created candidates), this lists
every skill on disk bundled built-ins and hub-installed included so you
can see how often each is actually used regardless of curation.
"""
import json as _json
from tools import skill_usage
rows = skill_usage.usage_report()
prov_filter = getattr(args, "provenance", None)
if prov_filter:
rows = [r for r in rows if r.get("provenance") == prov_filter]
sort_key = getattr(args, "sort", "activity")
if sort_key == "name":
rows.sort(key=lambda r: r["name"])
elif sort_key == "recent":
# Most-recently-active first; never-active sinks to the bottom.
rows.sort(key=lambda r: r.get("last_activity_at") or "", reverse=True)
else: # "activity" (default): most-used first
rows.sort(key=lambda r: r.get("activity_count", 0), reverse=True)
if getattr(args, "json", False):
print(_json.dumps(rows, indent=2, ensure_ascii=False))
return 0
if not rows:
print("curator: no skills found")
return 0
# Provenance tallies for a quick header.
counts = {"agent": 0, "bundled": 0, "hub": 0}
for r in rows:
counts[r.get("provenance", "agent")] = counts.get(r.get("provenance", "agent"), 0) + 1
print(
f"skills: {len(rows)} total "
f"(agent={counts['agent']} bundled={counts['bundled']} hub={counts['hub']})"
)
print()
print(
f" {'skill':40s} {'origin':8s} "
f"{'use':>4s} {'view':>4s} {'patch':>5s} {'act':>4s} last_activity"
)
for r in rows:
last = _fmt_ts(r.get("last_activity_at"))
print(
f" {r['name'][:40]:40s} "
f"{r.get('provenance', 'agent'):8s} "
f"{r.get('use_count', 0):>4d} "
f"{r.get('view_count', 0):>4d} "
f"{r.get('patch_count', 0):>5d} "
f"{r.get('activity_count', 0):>4d} "
f"{last}"
)
return 0
# ---------------------------------------------------------------------------
# argparse wiring (called from hermes_cli.main)
# ---------------------------------------------------------------------------
@@ -489,6 +549,25 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
p_status = subs.add_parser("status", help="Show curator status and skill stats")
p_status.set_defaults(func=_cmd_status)
p_usage = subs.add_parser(
"usage",
help="Show usage telemetry for ALL skills (built-in, hub, agent) with provenance",
)
p_usage.add_argument(
"--sort", choices=("activity", "recent", "name"), default="activity",
help="Sort order: activity (most-used first, default), recent "
"(most-recently-active first), or name (alphabetical)",
)
p_usage.add_argument(
"--provenance", choices=("agent", "bundled", "hub"), default=None,
help="Only show skills of this origin",
)
p_usage.add_argument(
"--json", action="store_true",
help="Emit the full report as JSON instead of a table",
)
p_usage.set_defaults(func=_cmd_usage)
p_run = subs.add_parser("run", help="Trigger a curator review now")
p_run.add_argument(
"--sync", "--synchronous", dest="synchronous", action="store_true",
+27 -349
View File
@@ -5,242 +5,11 @@ 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.
@@ -289,17 +58,10 @@ 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.
"""
return _decode_menu_key(stdscr, 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
key = stdscr.getch()
if key in (curses.KEY_UP, ord("k")):
return NAV_UP
if key in (curses.KEY_DOWN, ord("j")):
@@ -359,8 +121,6 @@ def _run_curses_menu(
extra_color_pairs=False,
fallback,
cancel_value,
searchable=False,
search_labels=None,
):
"""Shared curses single-/multi-select event loop.
@@ -375,12 +135,9 @@ 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. 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).
index where the scrollable item list should start.
draw_row(stdscr, y, idx, is_cursor, max_x) -> None
Draw one item row. ``idx`` is always the ORIGINAL item index, so
per-menu rendering is unchanged whether or not a filter is active.
Draw one item row.
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.
@@ -394,10 +151,6 @@ 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
@@ -405,8 +158,6 @@ 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]
@@ -424,46 +175,22 @@ 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()
filtered = (
_filter_indices(labels, search.query)
if use_search
else list(range(item_count))
)
cursor, cursor_pos = _reconcile_cursor(filtered, cursor)
items_start = draw_header(stdscr, max_y, max_x)
# 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)
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
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))
for draw_i, i in enumerate(
range(scroll_offset, min(item_count, scroll_offset + visible_rows))
):
i = filtered[filtered_pos]
y = draw_i + items_start
if y >= max_y - reserve_bottom:
break
@@ -473,46 +200,13 @@ def _run_curses_menu(
draw_footer(stdscr, max_y, max_x)
stdscr.refresh()
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)
action = read_menu_key(stdscr)
if action == NAV_UP:
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, -1)
cursor = (cursor - 1) % item_count
elif action == NAV_DOWN:
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, 1)
cursor = (cursor + 1) % item_count
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
@@ -626,7 +320,6 @@ 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.
@@ -638,9 +331,6 @@ 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
@@ -649,7 +339,7 @@ def curses_radiolist(
if description:
desc_lines = description.splitlines()
def _draw_header(stdscr, max_y, max_x, search=None):
def _draw_header(stdscr, max_y, max_x):
import curses
row = 0
try:
@@ -666,13 +356,11 @@ def curses_radiolist(
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
row += 1
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)
stdscr.addnstr(
row, 0,
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
max_x - 1, curses.A_DIM,
)
row += 1
except curses.error:
pass
@@ -708,8 +396,6 @@ 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,
)
@@ -745,33 +431,27 @@ 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, search=None):
def _draw_header(stdscr, max_y, max_x):
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)
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)
stdscr.addnstr(
1, 0,
" ↑↓ navigate ENTER confirm ESC/q cancel",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
return 3
@@ -808,8 +488,6 @@ 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,
)
+12 -54
View File
@@ -453,8 +453,11 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
if pid == my_pid or pid in exclude_pids:
continue
try:
with open(f"/proc/{pid}/cmdline", "rb") as _f:
cmdline = _f.read().decode("utf-8", errors="replace")
cmdline = (
open(f"/proc/{pid}/cmdline", "rb")
.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 (
@@ -5874,60 +5877,15 @@ def _maybe_redirect_run_to_s6_supervision(args) -> bool:
file=sys.stderr,
flush=True,
)
# 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
# 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
# `docker stop` sends SIGTERM, at which point /init runs stage 3
# shutdown (which tears down the supervised gateway cleanly).
#
# 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()
os.execvp("sleep", ["sleep", "infinity"])
def _gateway_command_inner(args):
+3 -25
View File
@@ -4353,21 +4353,13 @@ def decompose_triage_task(
child_ids: list[str] = []
with write_txn(conn):
root_row = conn.execute(
"SELECT id, status, tenant, workspace_kind, workspace_path "
"FROM tasks WHERE id = ?",
(task_id,),
"SELECT id, status, tenant 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
@@ -4378,30 +4370,16 @@ 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, "
" workspace_path, tenant, created_at, created_by) "
"VALUES (?, ?, ?, ?, 'todo', ?, ?, ?, ?, ?)",
" tenant, created_at, created_by) "
"VALUES (?, ?, ?, ?, 'todo', 'scratch', ?, ?, ?)",
(
new_id,
title,
body if isinstance(body, str) else None,
assignee,
child_ws_kind,
child_ws_path,
tenant,
now,
(author or "decomposer"),
-1
View File
@@ -4575,7 +4575,6 @@ 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):
+19 -115
View File
@@ -700,48 +700,6 @@ 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:
@@ -896,62 +854,25 @@ def switch_model(
api_mode = ""
if provider_changed or explicit_provider:
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}"
),
)
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(
@@ -1274,24 +1195,7 @@ 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).
+2 -2
View File
@@ -235,13 +235,13 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gemini": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3.5-flash",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite-preview",
],
"google-gemini-cli": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3.5-flash",
"gemini-3-flash-preview",
],
"zai": [
"glm-5.1",
+1 -15
View File
@@ -677,20 +677,6 @@ 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)
@@ -704,7 +690,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(raw, user_providers)
user_pdef = resolve_user_provider(name.strip().lower(), user_providers)
if user_pdef is not None:
return user_pdef
+18 -35
View File
@@ -335,14 +335,7 @@ 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,
@@ -403,22 +396,18 @@ 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=22)
table.add_column("Description", max_width=44)
table.add_column("Name", style="bold cyan", max_width=25)
table.add_column("Description", max_width=50)
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[:44]
if len(r.description) > 44:
desc = r.description[:50]
if len(r.description) > 50:
desc += "..."
table.add_row(
@@ -427,7 +416,6 @@ 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)
@@ -451,9 +439,7 @@ 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 inspect <identifier>' to preview, "
"'hermes skills install <identifier>' to install, "
"'hermes skills search <query>' to search deeper[/]\n")
c.print("[dim]Tip: 'hermes skills search <query>' searches deeper across all registries[/]\n")
def do_install(identifier: str, category: str = "", force: bool = False,
@@ -739,27 +725,24 @@ 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, parallel_search_sources,
)
from tools.skills_hub import GitHubAuth, create_source_router
page_size = max(1, min(page_size, 100))
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
# "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,
_PER_SOURCE_LIMIT = {"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)
# 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,
)
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
if not all_results:
return {"items": [], "page": 1, "total_pages": 1, "total": 0}
seen: dict = {}
@@ -776,7 +759,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, "identifier": r.identifier} for r in page_items],
"trust": r.trust_level} for r in page_items],
"page": page,
"total_pages": total_pages,
"total": total,
+40 -701
View File
@@ -753,225 +753,6 @@ 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).
#
@@ -998,10 +779,6 @@ _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
@@ -1061,10 +838,6 @@ 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
@@ -1358,51 +1131,22 @@ 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,
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",
)
async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0):
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,
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,
limit=limit, offset=offset, min_message_count=min_message_count
)
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()
@@ -2035,11 +1779,6 @@ 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")
@@ -3913,38 +3652,6 @@ 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
@@ -4000,82 +3707,25 @@ 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):
"""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.
"""
"""Rename a session (or clear its title when ``title`` is empty/null)."""
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")
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:
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:
raise HTTPException(status_code=404, detail="Session not found")
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}
return {"ok": True, "title": db.get_session_title(sid) or ""}
finally:
db.close()
@@ -4470,129 +4120,6 @@ 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.
#
@@ -4704,8 +4231,6 @@ 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,
}
@@ -4790,30 +4315,6 @@ 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.
#
@@ -5135,160 +4636,38 @@ async def run_import(body: ImportRequest):
@app.get("/api/ops/hooks")
async def list_hooks():
"""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")
"""Read-only list of configured shell hooks from config.yaml + allowlist."""
cfg = load_config()
hooks_cfg = cfg.get("hooks")
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")
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] = []
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
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]
except Exception:
pass
cfg = load_config()
hooks_cfg = cfg.get("hooks")
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:
shell_hooks.revoke(command)
except Exception:
pass
if not removed:
raise HTTPException(status_code=404, detail="No matching hook found")
return {"ok": True}
_log.exception("Failed to read shell-hooks allowlist")
for h in out:
h["allowed"] = h.get("command") in allowlist
return {"hooks": out, "allowlist": allowlist}
@app.get("/api/ops/checkpoints")
@@ -5387,46 +4766,6 @@ 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)
# ---------------------------------------------------------------------------
+1 -34
View File
@@ -264,7 +264,6 @@ 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)
);
@@ -1431,22 +1430,6 @@ 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:
@@ -1566,8 +1549,6 @@ 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.
@@ -1623,10 +1604,6 @@ 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:
@@ -3050,13 +3027,7 @@ class SessionDB:
# Utility
# =========================================================================
def session_count(
self,
source: str = None,
min_message_count: int = 0,
include_archived: bool = False,
archived_only: bool = False,
) -> int:
def session_count(self, source: str = None, min_message_count: int = 0) -> int:
"""Count sessions, optionally filtered by source."""
where_clauses = []
params = []
@@ -3067,10 +3038,6 @@ 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 ""
+52 -142
View File
@@ -228,9 +228,6 @@ 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
@@ -329,24 +326,22 @@ class HonchoMemoryProvider(MemoryProvider):
# aiPeer comes from honcho.json (host block or root) only.
# SOUL.md is persona content, not identity config.
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.
# ----- Port #1957: lazy session init for tools-only mode -----
if self._recall_mode == "tools":
if cfg.init_on_session_start:
self._ensure_session()
# Eager init even in tools mode (opt-in)
self._do_session_init(cfg, session_id, **kwargs)
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
self._start_session_init_background(wait_timeout=0.1)
# ----- Eager init (context or hybrid mode) -----
self._do_session_init(cfg, session_id, **kwargs)
except ImportError:
logger.debug("honcho-ai package not installed — plugin inactive")
@@ -354,66 +349,6 @@ 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
@@ -429,15 +364,22 @@ class HonchoMemoryProvider(MemoryProvider):
)
# ----- B3: resolve_session_name -----
self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
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"
)
logger.debug("Honcho session key resolved: %s", self._session_key)
# 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.
# Create session eagerly
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
@@ -492,15 +434,12 @@ class HonchoMemoryProvider(MemoryProvider):
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
prewarm_thread = threading.Thread(
self._prefetch_thread = threading.Thread(
target=_prewarm_dialectic, daemon=True, name="honcho-prewarm-dialectic"
)
prewarm_thread.start()
self._prefetch_thread = prewarm_thread
self._prefetch_thread.start()
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).
@@ -510,9 +449,7 @@ class HonchoMemoryProvider(MemoryProvider):
return True
if self._cron_skipped:
return False
if self._init_thread and self._init_thread.is_alive():
return False
if not self._config or self._lazy_init_kwargs is None:
if not self._config or not self._lazy_init_kwargs:
return False
try:
@@ -526,26 +463,9 @@ 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 = []
@@ -585,8 +505,14 @@ class HonchoMemoryProvider(MemoryProvider):
if self._cron_skipped:
return ""
if not self._manager or not self._session_key:
if not self._config:
return ""
# 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 ""
# ----- B1: adapt text based on recall_mode -----
if self._recall_mode == "context":
@@ -637,10 +563,6 @@ 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:
@@ -653,17 +575,18 @@ class HonchoMemoryProvider(MemoryProvider):
parts = []
# ----- Layer 1: Base context (representation + card) -----
# 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.
# On first call, fetch synchronously so turn 1 isn't empty.
# After that, serve from cache and refresh in background on cadence.
with self._base_context_lock:
if self._base_context_cache is None:
self._base_context_cache = ""
self._last_context_turn = self._turn_count
# First call — synchronous fetch
try:
self._manager.prefetch_context(self._session_key, query or None)
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
except Exception as e:
logger.debug("Honcho base context prefetch failed: %s", e)
logger.debug("Honcho base context fetch failed: %s", e)
self._base_context_cache = ""
base_context = self._base_context_cache
# Check if background context prefetch has a fresher result
@@ -718,11 +641,10 @@ class HonchoMemoryProvider(MemoryProvider):
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
first_turn_thread = threading.Thread(
self._prefetch_thread = threading.Thread(
target=_run_first_turn, daemon=True, name="honcho-prefetch-first"
)
first_turn_thread.start()
self._prefetch_thread = first_turn_thread
self._prefetch_thread.start()
self._prefetch_thread.join(timeout=_first_turn_timeout)
if self._prefetch_thread.is_alive():
logger.debug(
@@ -787,12 +709,11 @@ class HonchoMemoryProvider(MemoryProvider):
"""
if self._cron_skipped:
return
# B1: tools-only mode — no prefetch
if self._recall_mode == "tools":
if not self._manager or not self._session_key or not query:
return
if not self._session_ready() or not query:
self._start_session_init_background()
# B1: tools-only mode — no prefetch
if self._recall_mode == "tools":
return
# Trivial prompts don't warrant either a context refresh or a dialectic call.
@@ -848,11 +769,10 @@ class HonchoMemoryProvider(MemoryProvider):
self._dialectic_empty_streak += 1
self._prefetch_thread_started_at = time.monotonic()
prefetch_thread = threading.Thread(
self._prefetch_thread = threading.Thread(
target=_run, daemon=True, name="honcho-prefetch"
)
prefetch_thread.start()
self._prefetch_thread = prefetch_thread
self._prefetch_thread.start()
# ----- Dialectic depth: multi-pass .chat() with cold/warm prompts -----
@@ -1206,10 +1126,7 @@ class HonchoMemoryProvider(MemoryProvider):
"""
if self._cron_skipped:
return
if self._recall_mode == "tools" and not self._session_ready():
return
if not self._session_ready():
self._start_session_init_background()
if not self._manager or not self._session_key:
return
msg_limit = self._config.message_max_chars if self._config else 25000
@@ -1252,10 +1169,7 @@ class HonchoMemoryProvider(MemoryProvider):
return
if self._cron_skipped:
return
if self._recall_mode == "tools" and not self._session_ready():
return
if not self._session_ready():
self._start_session_init_background()
if not self._manager or not self._session_key:
return
def _write():
@@ -1273,8 +1187,6 @@ 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)
@@ -1301,8 +1213,6 @@ 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.")
@@ -1403,7 +1313,7 @@ class HonchoMemoryProvider(MemoryProvider):
if t and t.is_alive():
t.join(timeout=5.0)
# Flush any remaining messages
if self._manager and not (self._init_thread and self._init_thread.is_alive() and not self._session_initialized):
if self._manager:
try:
self._manager.flush_all()
except Exception:
+1 -1
View File
@@ -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.5-flash",
default_aux_model="gemini-3-flash-preview",
)
google_gemini_cli = GeminiProfile(
+17 -27
View File
@@ -269,13 +269,7 @@ class SimplexAdapter(BasePlatformAdapter):
# ------------------------------------------------------------------
async def _health_monitor(self) -> None:
"""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.
"""
"""Force reconnect if the WebSocket has been idle too long."""
while self._running:
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
if not self._running:
@@ -283,7 +277,15 @@ class SimplexAdapter(BasePlatformAdapter):
elapsed = time.time() - self._last_ws_activity
if elapsed > HEALTH_CHECK_STALE_THRESHOLD:
logger.debug("SimpleX: WS application-idle for %.0fs", elapsed)
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
# ------------------------------------------------------------------
# Inbound event handling
@@ -291,12 +293,7 @@ class SimplexAdapter(BasePlatformAdapter):
async def _handle_event(self, event: dict) -> None:
"""Dispatch a daemon event to the appropriate handler."""
# 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", "")
resp_type = event.get("type") or event.get("resp", {}).get("type", "")
# Filter responses to our own commands (echoes)
corr_id = event.get("corrId", "")
@@ -305,10 +302,10 @@ class SimplexAdapter(BasePlatformAdapter):
return
if resp_type == "newChatItem":
await self._handle_new_chat_item(resp)
await self._handle_new_chat_item(event)
elif resp_type == "newChatItems":
# Batch variant — process each item
items = resp.get("chatItems") or []
items = event.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.)
@@ -350,9 +347,7 @@ class SimplexAdapter(BasePlatformAdapter):
or contact_info.get("localDisplayName")
or 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_id = contact_id
chat_name = contact_name
if not chat_id:
@@ -369,7 +364,7 @@ class SimplexAdapter(BasePlatformAdapter):
or sender_id
)
else:
sender_id = contact_id if not is_group else chat_id
sender_id = chat_id
sender_name = chat_name
# Extract text
@@ -513,11 +508,7 @@ class SimplexAdapter(BasePlatformAdapter):
group_id = chat_id[6:]
cmd_str = f"#[{group_id}] {content}"
else:
# 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}"
cmd_str = f"@[{chat_id}] {content}"
payload = {
"corrId": corr_id,
@@ -652,8 +643,7 @@ async def _standalone_send(
group_id = chat_id[6:]
cmd_str = f"#[{group_id}] {message}"
else:
# Direct contacts are addressed by display name without brackets.
cmd_str = f"@{chat_id} {message}"
cmd_str = f"@[{chat_id}] {message}"
payload = {
"corrId": f"hermes-snd-{int(time.time() * 1000)}",
+13 -76
View File
@@ -21,12 +21,9 @@ 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
@@ -45,9 +42,7 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
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_MODEL = "grok-imagine-video"
DEFAULT_DURATION = 8
DEFAULT_ASPECT_RATIO = "16:9"
DEFAULT_RESOLUTION = "720p"
@@ -63,18 +58,10 @@ _MODELS: Dict[str, Dict[str, Any]] = {
"grok-imagine-video": {
"display": "Grok Imagine Video",
"speed": "~60-240s",
"strengths": "Text-to-video; legacy image-to-video fallback.",
"price": "see https://docs.x.ai/developers/models/grok-imagine-video",
"strengths": "Text-to-video + image-to-video; up to 7 reference images for style/character.",
"price": "see https://docs.x.ai/docs/models",
"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"],
},
}
@@ -124,31 +111,10 @@ 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 = _image_ref_to_xai_url(url)
normalized = (url or "").strip()
if normalized:
refs.append({"url": normalized})
return refs or None
@@ -165,28 +131,6 @@ 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],
@@ -248,7 +192,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:
@@ -278,7 +222,7 @@ class XAIVideoGenProvider(VideoGenProvider):
return {
"name": "xAI Grok Imagine",
"badge": "paid",
"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",
"tag": "grok-imagine-video text-to-video & image-to-video; uses xAI Grok OAuth or XAI_API_KEY",
"env_vars": [],
"post_setup": "xai_grok",
}
@@ -316,7 +260,6 @@ 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,
@@ -341,7 +284,6 @@ class XAIVideoGenProvider(VideoGenProvider):
*,
prompt: str,
model: Optional[str],
explicit_model: bool,
image_url: Optional[str],
reference_image_urls: Optional[List[str]],
duration: Optional[int],
@@ -361,15 +303,10 @@ class XAIVideoGenProvider(VideoGenProvider):
)
prompt = (prompt or "").strip()
image_url_norm = _image_ref_to_xai_url(image_url or "") or None
image_url_norm = (image_url or "").strip() 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(
@@ -403,7 +340,7 @@ class XAIVideoGenProvider(VideoGenProvider):
normalized_resolution = DEFAULT_RESOLUTION
payload: Dict[str, Any] = {
"model": resolved_model,
"model": model or DEFAULT_MODEL,
"prompt": prompt,
"duration": clamped_duration,
"aspect_ratio": normalized_aspect_ratio,
@@ -429,7 +366,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error=f"xAI submit failed ({exc.response.status_code}): {detail or exc}",
error_type="api_error",
provider="xai",
model=resolved_model,
model=model or DEFAULT_MODEL,
prompt=prompt,
)
@@ -451,7 +388,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error="xAI video generation completed without a video URL",
error_type="empty_response",
provider="xai",
model=body.get("model") or resolved_model,
model=body.get("model") or model or DEFAULT_MODEL,
prompt=prompt,
)
extra: Dict[str, Any] = {
@@ -462,7 +399,7 @@ class XAIVideoGenProvider(VideoGenProvider):
extra["usage"] = body["usage"]
return success_response(
video=url,
model=body.get("model") or resolved_model,
model=body.get("model") or model or DEFAULT_MODEL,
prompt=prompt,
modality=modality_used,
aspect_ratio=normalized_aspect_ratio,
@@ -476,7 +413,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error=f"Timed out waiting for video generation after {DEFAULT_TIMEOUT_SECONDS}s",
error_type="timeout",
provider="xai",
model=resolved_model,
model=model or DEFAULT_MODEL,
prompt=prompt,
)
@@ -489,7 +426,7 @@ class XAIVideoGenProvider(VideoGenProvider):
error=message,
error_type=f"xai_{status}",
provider="xai",
model=resolved_model,
model=model or DEFAULT_MODEL,
prompt=prompt,
)
+1 -1
View File
@@ -1,6 +1,6 @@
name: xai
version: 1.0.0
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."
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."
author: NousResearch
kind: backend
requires_env:
-8
View File
@@ -45,13 +45,9 @@ 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",
@@ -65,7 +61,6 @@ 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",
@@ -88,7 +83,6 @@ 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",
@@ -1204,7 +1198,6 @@ 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)
@@ -1423,7 +1416,6 @@ 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)
}
@@ -1,209 +0,0 @@
---
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?
@@ -1,106 +0,0 @@
"""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
@@ -1,224 +0,0 @@
"""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
+2 -72
View File
@@ -4,9 +4,8 @@ from unittest.mock import patch
class TestMinimaxContextLengths:
"""Verify context length entries match official docs.
"""Verify context length entries match official docs (204,800 for all models).
M2.x series is 204,800; M3 is 1M (max output 512K).
Source: https://platform.minimax.io/docs/api-reference/text-anthropic-api
"""
@@ -16,80 +15,11 @@ class TestMinimaxContextLengths:
def test_minimax_models_resolve_via_prefix(self):
from agent.model_metadata import get_model_context_length
# M2.x models resolve to 204,800 via the "minimax" catch-all
# All MiniMax models should resolve to 204,800 via the "minimax" prefix
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:
+1 -23
View File
@@ -927,29 +927,6 @@ 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
@@ -1270,3 +1247,4 @@ class TestOpenAIModelExecutionGuidance:
# =========================================================================
-79
View File
@@ -1,79 +0,0 @@
"""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
-57
View File
@@ -1,57 +0,0 @@
"""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
-80
View File
@@ -1,80 +0,0 @@
"""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
-1
View File
@@ -182,7 +182,6 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
"HERMES_SESSION_SOURCE",
"HERMES_SESSION_KEY",
"HERMES_GATEWAY_SESSION",
"HERMES_CRON_SESSION",
"_HERMES_GATEWAY",
"HERMES_PLATFORM",
"HERMES_MODEL",
+3 -10
View File
@@ -206,11 +206,7 @@ class TestBuildJobPromptScansSkillContent:
assert prompt is not None
assert "cat ~/.hermes/.env" in prompt
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."""
def test_skill_with_invisible_unicode_raises(self, cron_env):
hermes_home, scheduler = cron_env
# Zero-width space smuggled into the skill body.
_plant_skill(hermes_home, "zwsp-skill", "clean looking\u200bskill content")
@@ -222,11 +218,8 @@ class TestBuildJobPromptScansSkillContent:
"skills": ["zwsp-skill"],
}
# 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
with pytest.raises(scheduler.CronPromptInjectionBlocked):
scheduler._build_job_prompt(job)
def test_no_skills_still_scans_user_prompt(self, cron_env):
"""Defense-in-depth: even without skills, assembled-prompt scanning
-132
View File
@@ -1,7 +1,4 @@
"""Tests for the BlueBubbles iMessage gateway adapter."""
import asyncio
import json
import pytest
from gateway.config import Platform, PlatformConfig
@@ -28,8 +25,6 @@ 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()
@@ -40,8 +35,6 @@ 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")
@@ -137,131 +130,6 @@ 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,9 +1,8 @@
"""Tests for config-driven platform access policies at the gateway layer.
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
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
a message is dropped inside the adapter and never reaches the gateway unless it
already passed that policy.
@@ -35,7 +34,6 @@ _OWN_POLICY_PLATFORMS = [
Platform.WEIXIN,
Platform.YUANBAO,
Platform.QQBOT,
Platform.WHATSAPP,
]
@@ -46,7 +44,6 @@ 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",
@@ -54,7 +51,6 @@ 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)
@@ -107,11 +103,10 @@ 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 config-policy adapters override the flag to True."""
"""The four config-policy adapters override the flag to True."""
import importlib
module = importlib.import_module(module_path)
-61
View File
@@ -155,64 +155,3 @@ 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
@@ -1,74 +0,0 @@
"""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"
+13 -46
View File
@@ -7,7 +7,6 @@ sibling platform-plugin tests on the same xdist worker.
from __future__ import annotations
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
@@ -215,7 +214,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
@@ -302,55 +301,23 @@ async def test_standalone_send_missing_websockets(monkeypatch):
@pytest.mark.asyncio
async def test_standalone_send_defaults_to_local_daemon(monkeypatch):
async def test_standalone_send_missing_url(monkeypatch):
monkeypatch.delenv("SIMPLEX_WS_URL", raising=False)
pconfig = MagicMock()
pconfig.extra = {}
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)
# 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")
result = await _standalone_send(pconfig, "contact-42", "hi")
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()
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
# ---------------------------------------------------------------------------
@@ -10,7 +10,6 @@ time instead of first-token time.
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -174,179 +173,6 @@ 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 -158
View File
@@ -1,7 +1,7 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from unittest.mock import AsyncMock
from gateway.config import Platform, PlatformConfig, load_gateway_config
from gateway.platforms.base import MessageType
@@ -1005,160 +1005,3 @@ 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())
@@ -1,258 +0,0 @@
"""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 "![chart](https://example.com/chart.png)"
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]
-33
View File
@@ -285,39 +285,6 @@ 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
-145
View File
@@ -968,148 +968,3 @@ 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,7 +1,6 @@
"""Tests for utils.atomic_json_write — crash-safe JSON file writes."""
import json
import os
from pathlib import Path
from unittest.mock import patch
@@ -133,38 +132,6 @@ 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
+112
View File
@@ -0,0 +1,112 @@
"""Tests for `hermes curator usage` — the all-skills usage view.
Covers:
- Lists every skill regardless of provenance (agent / bundled / hub), unlike
`status` which is scoped to curator-managed candidates.
- --provenance filter, --sort ordering, and --json output.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
def _fake_rows():
return [
{
"name": "agent-skill", "provenance": "agent", "state": "active",
"use_count": 2, "view_count": 1, "patch_count": 0,
"activity_count": 3, "last_activity_at": "2026-05-01T10:00:00+00:00",
"created_at": "2026-01-01T00:00:00+00:00", "_persisted": True,
},
{
"name": "bundled-skill", "provenance": "bundled", "state": "active",
"use_count": 9, "view_count": 4, "patch_count": 0,
"activity_count": 13, "last_activity_at": "2026-05-10T10:00:00+00:00",
"created_at": "2026-01-01T00:00:00+00:00", "_persisted": True,
},
{
"name": "hub-skill", "provenance": "hub", "state": "active",
"use_count": 0, "view_count": 0, "patch_count": 0,
"activity_count": 0, "last_activity_at": None,
"created_at": "2026-01-01T00:00:00+00:00", "_persisted": False,
},
]
def test_usage_lists_all_provenances(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "usage_report", _fake_rows)
args = SimpleNamespace(sort="activity", provenance=None, json=False)
assert curator_cli._cmd_usage(args) == 0
out = capsys.readouterr().out
# Header tally and all three skills present.
assert "agent=1" in out and "bundled=1" in out and "hub=1" in out
assert "agent-skill" in out
assert "bundled-skill" in out
assert "hub-skill" in out
def test_usage_sort_activity_orders_most_used_first(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "usage_report", _fake_rows)
args = SimpleNamespace(sort="activity", provenance=None, json=False)
assert curator_cli._cmd_usage(args) == 0
out = capsys.readouterr().out
# bundled-skill (act=13) must appear before agent-skill (act=3).
assert out.index("bundled-skill") < out.index("agent-skill")
def test_usage_provenance_filter(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "usage_report", _fake_rows)
args = SimpleNamespace(sort="activity", provenance="bundled", json=False)
assert curator_cli._cmd_usage(args) == 0
out = capsys.readouterr().out
assert "bundled-skill" in out
assert "agent-skill" not in out
assert "hub-skill" not in out
def test_usage_json_output(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "usage_report", _fake_rows)
args = SimpleNamespace(sort="name", provenance=None, json=True)
assert curator_cli._cmd_usage(args) == 0
out = capsys.readouterr().out
data = json.loads(out)
assert {r["name"] for r in data} == {"agent-skill", "bundled-skill", "hub-skill"}
assert {r["provenance"] for r in data} == {"agent", "bundled", "hub"}
def test_usage_empty(monkeypatch, capsys):
import hermes_cli.curator as curator_cli
import tools.skill_usage as skill_usage
monkeypatch.setattr(skill_usage, "usage_report", lambda: [])
args = SimpleNamespace(sort="activity", provenance=None, json=False)
assert curator_cli._cmd_usage(args) == 0
assert "no skills found" in capsys.readouterr().out
def test_usage_command_is_registered():
"""The `usage` subcommand must be wired into the curator argparse tree."""
import argparse
import hermes_cli.curator as curator_cli
parser = argparse.ArgumentParser(prog="hermes curator")
curator_cli.register_cli(parser)
args = parser.parse_args(["usage", "--sort", "recent", "--provenance", "hub", "--json"])
assert args.func is curator_cli._cmd_usage
assert args.sort == "recent"
assert args.provenance == "hub"
assert args.json is True
@@ -1,127 +0,0 @@
"""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]
-68
View File
@@ -1,68 +0,0 @@
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,35 +74,6 @@ 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)
@@ -219,40 +190,6 @@ 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()
@@ -263,131 +200,6 @@ 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."""
@@ -409,9 +221,6 @@ class TestAdminEndpointsAuthGate:
"/api/memory",
"/api/ops/hooks",
"/api/ops/checkpoints",
"/api/curator",
"/api/portal",
"/api/system/stats",
],
)
def test_gated(self, path):
+27 -116
View File
@@ -361,6 +361,28 @@ 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."""
@@ -385,31 +407,14 @@ 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 (the cheap heartbeat;
no resident Python interpreter) without binding container
lifetime to gateway PID lifetime.
3. exec `sleep infinity` to keep the CMD alive 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: "")
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"),
)
execvp_calls = _stub_execvp(monkeypatch)
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_NO_SUPERVISE", raising=False)
@@ -423,90 +428,11 @@ 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` (the preferred cheap heartbeat).
# 3. exec'd `sleep infinity`.
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:
@@ -590,25 +516,10 @@ 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: "")
# 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)
_stub_execvp(monkeypatch)
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,65 +166,3 @@ 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, searchable=False):
def _fake(title, items, *, selected=0, cancel_returns=None, description=None):
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, searchable=False):
def _fake(title, items, *, selected=0, cancel_returns=None, description=None):
seen["description"] = description
return len(items) - 1 # Skip
@@ -254,13 +254,8 @@ def test_openai_native_curated_catalog_is_non_empty():
assert len(_PROVIDER_MODELS["openai"]) >= 4
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."""
def test_list_authenticated_providers_openai_built_in_nonzero_total(monkeypatch):
"""Built-in openai row must not report total_models=0 when creds exist."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
monkeypatch.setattr(
"agent.models_dev.fetch_models_dev",
@@ -276,63 +271,8 @@ def test_list_authenticated_providers_openai_alias_not_emitted_as_phantom(monkey
max_models=50,
)
row = next((p for p in providers if p.get("slug") == "openai"), None)
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"
assert row is not None
assert row["total_models"] > 0
def test_list_authenticated_providers_user_openai_official_url_fallback(monkeypatch):
+2 -72
View File
@@ -187,11 +187,11 @@ class TestWebServerEndpoints:
def __init__(self, *args, **kwargs):
pass
def list_sessions_rich(self, limit, offset, min_message_count=0, **kwargs):
def list_sessions_rich(self, limit, offset, min_message_count=0):
captured["list"] = min_message_count
return []
def session_count(self, min_message_count=0, **kwargs):
def session_count(self, min_message_count=0):
captured["count"] = min_message_count
return 0
@@ -250,76 +250,6 @@ 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,43 +25,6 @@ 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": self.posts[-1]["json"]["model"],
"model": "grok-imagine-video",
})
@@ -113,7 +113,6 @@ 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
@@ -122,31 +121,8 @@ 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,37 +5,6 @@ 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:
-40
View File
@@ -3509,43 +3509,3 @@ 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
-357
View File
@@ -1,357 +0,0 @@
"""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()

Some files were not shown because too many files have changed in this diff Show More