Merge remote-tracking branch 'origin/main' into hermes/hermes-6fe26723

This commit is contained in:
Ben 2026-06-19 15:17:13 +10:00
commit 637aff46e7
146 changed files with 11333 additions and 1022 deletions

View file

@ -102,6 +102,3 @@ acp_registry/
.gitattributes
.hadolint.yaml
.mailmap
# Top-level LICENSE (not matched by *.md); not needed inside the container
LICENSE

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

View file

@ -1227,12 +1227,35 @@ def init_agent(
# targets.
agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True))
# Universal parallel-tool-call guidance toggle. Default True. Separate
# flag from task_completion_guidance because a user may want one but not
# the other. Steers the model to batch independent tool calls into a
# single turn; the runtime already executes such batches concurrently.
agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True))
# Local Python toolchain probe toggle. Default True. When False,
# the probe is skipped entirely (no subprocess calls, no system-prompt
# line). Useful for users on exotic setups where the probe heuristics
# are noisy.
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
# Per-platform prompt-hint overrides (config.yaml → platform_hints).
# Lets an enterprise admin append to or replace Hermes' built-in
# platform hint for a single messaging platform (e.g. WhatsApp) without
# affecting other platforms. Shape:
# platform_hints:
# whatsapp:
# append: "When tabular output would help, invoke the ... skill."
# slack:
# replace: "Custom Slack hint that fully replaces the default."
# Stored verbatim; resolution happens in agent/system_prompt.py against
# the active platform. Invalid shapes are ignored defensively so a bad
# config entry can never break prompt assembly.
_platform_hints_cfg = _agent_cfg.get("platform_hints", {})
if not isinstance(_platform_hints_cfg, dict):
_platform_hints_cfg = {}
agent._platform_hint_overrides = _platform_hints_cfg
# App-level API retry count (wraps each model API call). Default 3,
# overridable via agent.api_max_retries in config.yaml. See #11616.
try:

View file

@ -1839,28 +1839,42 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
elif function_name == "memory":
def _execute(next_args: dict) -> Any:
target = next_args.get("target", "memory")
operations = next_args.get("operations")
from tools.memory_tool import memory_tool as _memory_tool
result = _memory_tool(
action=next_args.get("action"),
target=target,
content=next_args.get("content"),
old_text=next_args.get("old_text"),
operations=operations,
store=agent._memory_store,
)
# Bridge: notify external memory provider of built-in memory writes
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
try:
agent._memory_manager.on_memory_write(
next_args.get("action", ""),
target,
next_args.get("content", ""),
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=tool_call_id,
),
# Bridge: notify external memory provider of built-in memory writes.
# Covers both the single-op shape and each add/replace inside a batch.
if agent._memory_manager:
if operations:
_mem_ops = [
op for op in operations
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
]
else:
_mem_ops = (
[{"action": next_args.get("action"), "content": next_args.get("content")}]
if next_args.get("action") in {"add", "replace"} else []
)
except Exception:
pass
for _op in _mem_ops:
try:
agent._memory_manager.on_memory_write(
_op.get("action", ""),
target,
_op.get("content", "") or "",
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=tool_call_id,
),
)
except Exception:
pass
return _finish_agent_tool(result, next_args)
elif agent._memory_manager and agent._memory_manager.has_tool(function_name):
def _execute(next_args: dict) -> Any:

View file

@ -300,6 +300,7 @@ def summarize_background_review_actions(
"target": args.get("target", "memory"),
"content": args.get("content", ""),
"old_text": args.get("old_text", ""),
"operations": args.get("operations") or [],
"name": args.get("name", ""),
"old_string": args.get("old_string", ""),
"new_string": args.get("new_string", ""),
@ -353,6 +354,7 @@ def summarize_background_review_actions(
content = detail.get("content", "")
old_text = detail.get("old_text", "")
skill_name = detail.get("name", "")
operations = detail.get("operations") or []
max_preview = 120
if is_skill:
change = data.get("_change", {})
@ -376,6 +378,21 @@ def summarize_background_review_actions(
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
else:
actions.append(f"📝 {message}" if message else f"Skill {action}")
elif operations:
for op in operations:
op = op or {}
op_act = op.get("action", "")
op_content = (op.get("content") or "")
op_old = (op.get("old_text") or "")
if op_act == "add" and op_content:
preview = op_content[:max_preview] + ("" if len(op_content) > max_preview else "")
actions.append(f"{label} {preview}")
elif op_act == "replace" and op_content:
preview = op_content[:max_preview] + ("" if len(op_content) > max_preview else "")
actions.append(f"{label} ✏️ {preview}")
elif op_act == "remove" and op_old:
preview = op_old[:60] + ("" if len(op_old) > 60 else "")
actions.append(f"{label} {preview}")
elif action == "add" and content:
preview = content[:max_preview] + ("" if len(content) > max_preview else "")
actions.append(f"{label} {preview}")
@ -391,6 +408,7 @@ def summarize_background_review_actions(
"added" in message_lower
or "replaced" in message_lower
or "removed" in message_lower
or "applied" in message_lower
or (target and "add" in message.lower())
or "Entry added" in message
):

295
agent/billing_view.py Normal file
View file

@ -0,0 +1,295 @@
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
One fetch/parse per concern, consumed identically by the CLI handler
(``cli.py::_show_billing``), the TUI JSON-RPC methods
(``tui_gateway/server.py``), and any other surface. Mirrors the proven
``agent/account_usage.py::build_credits_view`` pattern: parse the server payload
into a frozen dataclass; **fail open** when not logged in or the portal is
unreachable, return a struct with ``logged_in=False`` and let the surface degrade
gracefully (never crash).
Money discipline: the server emits decimal STRINGS (``"142.5"``, not fixed 2dp).
We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
from typing import Any, Optional
logger = logging.getLogger(__name__)
# =============================================================================
# Decimal money helpers
# =============================================================================
def parse_money(value: Any) -> Optional[Decimal]:
"""Parse a server money value (decimal string) into :class:`Decimal`.
Returns None for missing/invalid input. Never raises. Accepts str/int (and,
defensively, float though the server always sends strings).
"""
if value is None:
return None
try:
# Decimal(str(...)) avoids binary-float artifacts if a float ever sneaks in.
return Decimal(str(value).strip())
except (InvalidOperation, ValueError, TypeError):
return None
def format_money(value: Optional[Decimal]) -> str:
"""Format a Decimal as ``$X`` / ``$X.YY`` for display.
Whole dollars show no decimals; any fractional amount shows exactly 2dp:
``Decimal("142.5")`` ``"$142.50"``, ``Decimal("100")`` ``"$100"``,
``Decimal("0.01")`` ``"$0.01"``.
"""
if value is None:
return ""
if value == value.to_integral_value():
# Whole dollars — no decimal point. format(..., "f") avoids 1E+3 for 1000.
return f"${format(value.to_integral_value(), 'f')}"
# Fractional — always show 2dp.
return f"${format(value.quantize(Decimal('0.01')), 'f')}"
# =============================================================================
# Parsed sub-structures
# =============================================================================
@dataclass(frozen=True)
class CardInfo:
brand: str
last4: str
@property
def masked(self) -> str:
return f"{self.brand} ····{self.last4}"
@dataclass(frozen=True)
class MonthlyCap:
limit_usd: Optional[Decimal] = None
spent_this_month_usd: Optional[Decimal] = None
is_default_ceiling: bool = False
@dataclass(frozen=True)
class AutoReload:
enabled: bool = False
threshold_usd: Optional[Decimal] = None
reload_to_usd: Optional[Decimal] = None
@dataclass(frozen=True)
class BillingState:
"""Parsed ``GET /api/billing/state`` — the overview screen's data.
Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
portal is unreachable.
"""
logged_in: bool
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
balance_usd: Optional[Decimal] = None
cli_billing_enabled: bool = False
charge_presets: tuple[Decimal, ...] = ()
min_usd: Optional[Decimal] = None
max_usd: Optional[Decimal] = None
card: Optional[CardInfo] = None
monthly_cap: Optional[MonthlyCap] = None
auto_reload: Optional[AutoReload] = None
portal_url: Optional[str] = None
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
error: Optional[str] = None
@property
def is_admin(self) -> bool:
"""True for OWNER/ADMIN — the roles that can manage billing."""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_charge(self) -> bool:
"""True when the UI should offer charge/auto-reload actions.
Admin role AND the per-org kill-switch on. (The server still enforces;
this is just for graying out actions the user can't take.)
"""
return self.is_admin and self.cli_billing_enabled
def _parse_card(raw: Any) -> Optional[CardInfo]:
if not isinstance(raw, dict):
return None
brand = raw.get("brand")
last4 = raw.get("last4")
if isinstance(brand, str) and isinstance(last4, str):
return CardInfo(brand=brand, last4=last4)
return None
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
if not isinstance(raw, dict):
return None
return MonthlyCap(
limit_usd=parse_money(raw.get("limitUsd")),
spent_this_month_usd=parse_money(raw.get("spentThisMonthUsd")),
is_default_ceiling=bool(raw.get("isDefaultCeiling")),
)
def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
if not isinstance(raw, dict):
return None
return AutoReload(
enabled=bool(raw.get("enabled")),
threshold_usd=parse_money(raw.get("thresholdUsd")),
reload_to_usd=parse_money(raw.get("reloadToUsd")),
)
def billing_state_from_payload(
payload: dict[str, Any], *, portal_url: Optional[str] = None
) -> BillingState:
"""Map a raw ``/api/billing/state`` JSON dict into :class:`BillingState`."""
raw_org = payload.get("org")
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
raw_bounds = payload.get("bounds")
bounds: dict[str, Any] = raw_bounds if isinstance(raw_bounds, dict) else {}
presets: list[Decimal] = []
for item in payload.get("chargePresets") or ():
parsed = parse_money(item)
if parsed is not None:
presets.append(parsed)
return BillingState(
logged_in=True,
org_id=org.get("id"),
org_slug=org.get("slug"),
org_name=org.get("name"),
role=org.get("role"),
balance_usd=parse_money(payload.get("balanceUsd")),
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
charge_presets=tuple(presets),
min_usd=parse_money(bounds.get("minUsd")),
max_usd=parse_money(bounds.get("maxUsd")),
card=_parse_card(payload.get("card")),
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
auto_reload=_parse_auto_reload(payload.get("autoReload")),
portal_url=portal_url,
)
# =============================================================================
# Fail-open builders (the surface front doors)
# =============================================================================
def build_billing_state(*, timeout: float = 15.0) -> BillingState:
"""Fetch + parse ``/api/billing/state``. Fail-open.
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
a clear message rather than crashing.
"""
try:
from hermes_cli.nous_billing import (
BillingAuthError,
BillingError,
_absolutize_portal_url,
get_billing_state,
resolve_portal_base_url,
)
except Exception:
return BillingState(logged_in=False, error="billing client unavailable")
try:
payload = get_billing_state(timeout=timeout)
except BillingAuthError:
return BillingState(logged_in=False)
except BillingError as exc:
logger.debug("billing ▸ /state fetch failed (fail-open)", exc_info=True)
return BillingState(logged_in=False, error=str(exc))
except Exception:
logger.debug("billing ▸ /state unexpected error (fail-open)", exc_info=True)
return BillingState(logged_in=False, error="could not load billing state")
# Prefer a server-supplied portalUrl if present (resolved to absolute in case
# it's relative); else build the standard one.
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
if not portal_url:
try:
portal_url = _fallback_portal_url(resolve_portal_base_url())
except Exception:
portal_url = None
return billing_state_from_payload(payload, portal_url=portal_url)
def _fallback_portal_url(base: str) -> str:
"""Standard billing deep-link when the server omits ``portalUrl``."""
return f"{base.rstrip('/')}/billing?topup=open"
# =============================================================================
# Idempotency
# =============================================================================
def new_idempotency_key() -> str:
"""Fresh UUID for a user-confirmed purchase (reuse on retry of the SAME buy).
The ``Idempotency-Key`` header is mandatory on ``POST /charge``; generate one
per confirmed purchase and reuse it across retries so a double-submit collapses
to a single charge. Never reuse a key across different amounts (the server
returns 409 idempotency_conflict).
"""
return str(uuid.uuid4())
# =============================================================================
# Amount validation (Screen 3 custom input)
# =============================================================================
@dataclass(frozen=True)
class AmountValidation:
ok: bool
amount: Optional[Decimal] = None
error: Optional[str] = None
def validate_charge_amount(
raw: str, *, min_usd: Optional[Decimal], max_usd: Optional[Decimal]
) -> AmountValidation:
"""Validate a custom charge amount against bounds + 2dp (multipleOf 0.01).
Mirrors the server's accept/reject so the UI can give instant feedback rather
than round-tripping a sure-to-fail charge. The server is still authoritative.
"""
cleaned = (raw or "").strip().lstrip("$").strip()
amount = parse_money(cleaned)
if amount is None:
return AmountValidation(ok=False, error="Enter a dollar amount, e.g. 100")
if amount <= 0:
return AmountValidation(ok=False, error="Amount must be greater than $0")
# multipleOf 0.01 — reject sub-cent precision.
if amount != amount.quantize(Decimal("0.01")):
return AmountValidation(ok=False, error="Amount can't be smaller than a cent")
if min_usd is not None and amount < min_usd:
return AmountValidation(ok=False, error=f"Minimum is {format_money(min_usd)}")
if max_usd is not None and amount > max_usd:
return AmountValidation(ok=False, error=f"Maximum is {format_money(max_usd)}")
return AmountValidation(ok=True, amount=amount)

View file

@ -512,6 +512,16 @@ def compress_context(
old_title = agent._session_db.get_session_title(agent.session_id)
# Trigger memory extraction on the old session before it rotates.
agent.commit_memory_session(messages)
# Flush any un-persisted messages from the current turn to the
# old session *before* rotating. compress_context() can be
# called mid-turn (auto-compress when context exceeds threshold)
# at a point when _flush_messages_to_session_db() has not yet
# run. Without this, messages generated during the current turn
# are silently lost on session rotation (#47202).
try:
agent._flush_messages_to_session_db(messages)
except Exception:
pass # best-effort — don't block compression on a flush error
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"

View file

@ -11,6 +11,18 @@ Providers live in ``<repo>/plugins/image_gen/<name>/`` (built-in, auto-loaded
as ``kind: backend``) or ``~/.hermes/plugins/image_gen/<name>/`` (user, opt-in
via ``plugins.enabled``).
Unified surface
---------------
One tool ``image_generate`` covers **text-to-image** and
**image-to-image / image editing**. The router is the presence of
``image_url`` (and/or ``reference_image_urls``): if any source image is
provided, the provider routes to its image-to-image / edit endpoint; if
omitted, the provider routes to text-to-image. Users pick one **model**
(e.g. nano-banana-pro, gpt-image-2, grok-imagine-image); the provider
handles which underlying endpoint to hit. This mirrors the ``video_gen``
provider design (``agent/video_gen_provider.py``) so the two surfaces
stay learnable together.
Response shape
--------------
All providers return a dict that :func:`success_response` / :func:`error_response`
@ -21,6 +33,7 @@ produce. The tool wrapper JSON-serializes it. Keys:
model str provider-specific model identifier
prompt str echoed prompt
aspect_ratio str "landscape" | "square" | "portrait"
modality str "text" | "image" (which mode was used)
provider str provider name (for diagnostics)
error str only when success=False
error_type str only when success=False
@ -127,19 +140,51 @@ class ImageGenProvider(abc.ABC):
return models[0].get("id")
return None
def capabilities(self) -> Dict[str, Any]:
"""Return what this provider supports.
Returned dict (all keys optional)::
{
"modalities": ["text", "image"], # which inputs the backend accepts
"max_reference_images": 9, # cap for reference_image_urls
}
``modalities`` declares whether the active backend/model supports
text-to-image (``"text"``), image-to-image / editing (``"image"``),
or both. The tool layer surfaces this in the dynamic schema so the
model knows when ``image_url`` is honored. Used by ``hermes tools``
for the picker too. Default: text-only (backward compatible a
provider that doesn't override this advertises text-to-image only).
"""
return {
"modalities": ["text"],
"max_reference_images": 0,
}
@abc.abstractmethod
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
*,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""Generate an image.
"""Generate an image from a text prompt, or edit/transform a source image.
Routing: if ``image_url`` (or any ``reference_image_urls``) is
provided, the provider should route to its image-to-image / edit
endpoint; otherwise text-to-image. ``image_url`` is the primary
source image to edit; ``reference_image_urls`` are additional
style/composition references (provider clamps to its declared
``max_reference_images``).
Implementations should return the dict from :func:`success_response`
or :func:`error_response`. ``kwargs`` may contain forward-compat
parameters future versions of the schema will expose implementations
should ignore unknown keys.
parameters future versions of the schema will expose
implementations MUST ignore unknown keys (no TypeError).
"""
@ -162,6 +207,26 @@ def resolve_aspect_ratio(value: Optional[str]) -> str:
return DEFAULT_ASPECT_RATIO
def normalize_reference_images(value: Any) -> Optional[List[str]]:
"""Coerce a reference-image argument into a clean list of URL/path strings.
Accepts a single string or a list; strips blanks and whitespace. Returns
``None`` when nothing usable remains so providers can treat "no refs" as a
single sentinel.
"""
if value is None:
return None
if isinstance(value, str):
value = [value]
if not isinstance(value, (list, tuple)):
return None
out: List[str] = []
for item in value:
if isinstance(item, str) and item.strip():
out.append(item.strip())
return out or None
def _images_cache_dir() -> Path:
"""Return ``$HERMES_HOME/cache/images/``, creating parents as needed."""
from hermes_constants import get_hermes_home
@ -280,13 +345,16 @@ def success_response(
prompt: str,
aspect_ratio: str,
provider: str,
modality: str = "text",
extra: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build a uniform success response dict.
``image`` may be an HTTP URL or an absolute filesystem path (for b64
providers like OpenAI). Callers that need to pass through additional
backend-specific fields can supply ``extra``.
providers like OpenAI). ``modality`` is ``"text"`` (text-to-image) or
``"image"`` (image-to-image / editing) indicates which endpoint was
actually hit, useful for diagnostics. Callers that need to pass through
additional backend-specific fields can supply ``extra``.
"""
payload: Dict[str, Any] = {
"success": True,
@ -294,6 +362,7 @@ def success_response(
"model": model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
"modality": modality,
"provider": provider,
}
if extra:

View file

@ -305,6 +305,47 @@ TASK_COMPLETION_GUIDANCE = (
"is always better than inventing a result."
)
# Universal parallel-tool-call guidance — applied to ALL models.
#
# Why this matters for cost: every assistant turn resends the entire
# accumulated conversation (and, on cache-friendly providers, re-reads the
# cached prefix and pays for the newly-appended turn). A model that issues
# one tool call per turn multiplies the number of round-trips — and therefore
# the resent context — for any task that needs several independent reads,
# searches, or safe lookups. Batching independent calls into a single
# assistant response collapses N turns into one, cutting both latency and the
# resent-context cost that compounds over a long conversation.
#
# The hermes-agent runtime already executes a batch of tool calls
# concurrently when they are independent (read-only tools always; path-scoped
# file ops when their targets don't overlap — see
# run_agent._execute_tool_calls / tool_dispatch_helpers). The missing piece
# was telling the *model* to emit those calls together in the first place.
# Until now the only batching steer in the prompt lived in
# GOOGLE_MODEL_OPERATIONAL_GUIDANCE — Gemini/Gemma got it, every other model
# got nothing. This block makes the steer universal; the now-redundant
# Google-only bullet has been dropped so no model receives it twice.
#
# Short on purpose — shipped in the cached system prompt to every user, every
# session. Token cost is paid once at install and amortised across all
# sessions via prefix caching. Keep it tight.
#
# Ported from cline/cline#11514 ("encourage parallel tool calls"), adapted
# from Cline's TypeScript tool-surface guidance to hermes-agent's Python
# prompt-assembly architecture.
PARALLEL_TOOL_CALL_GUIDANCE = (
"# Parallel tool calls\n"
"When you need several pieces of information that don't depend on each "
"other, request them together in a single response instead of one tool "
"call per turn. Independent reads, searches, web fetches, and read-only "
"commands should be batched into the same assistant turn — the runtime "
"executes independent calls concurrently, and batching avoids resending "
"the whole conversation on every extra round-trip.\n"
"Only serialize calls when a later call genuinely depends on an earlier "
"call's result (e.g. you must read a file before you can patch it). When "
"in doubt and the calls are independent, batch them."
)
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
# where GPT models abandon work on partial results, skip prerequisite lookups,
# hallucinate instead of using tools, and declare "done" without verification.
@ -386,9 +427,10 @@ GOOGLE_MODEL_OPERATIONAL_GUIDANCE = (
"package.json, requirements.txt, Cargo.toml, etc. before importing.\n"
"- **Conciseness:** Keep explanatory text brief — a few sentences, not "
"paragraphs. Focus on actions and results over narration.\n"
"- **Parallel tool calls:** When you need to perform multiple independent "
"operations (e.g. reading several files), make all the tool calls in a "
"single response rather than sequentially.\n"
# Parallel-tool-call steering now lives in the universal
# PARALLEL_TOOL_CALL_GUIDANCE block (injected for all models), so it is no
# longer duplicated here — keeping it would send Gemini/Gemma the same
# instruction twice.
"- **Non-interactive commands:** Use flags like -y, --yes, --non-interactive "
"to prevent CLI tools from hanging on prompts.\n"
"- **Keep going:** Work autonomously until the task is fully resolved. "

View file

@ -33,6 +33,7 @@ from agent.prompt_builder import (
KANBAN_GUIDANCE,
MEMORY_GUIDANCE,
OPENAI_MODEL_EXECUTION_GUIDANCE,
PARALLEL_TOOL_CALL_GUIDANCE,
PLATFORM_HINTS,
SESSION_SEARCH_GUIDANCE,
SKILLS_GUIDANCE,
@ -60,6 +61,55 @@ def _ra():
return run_agent
def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) -> str:
"""Apply a per-platform prompt-hint override to the default hint.
Reads ``agent._platform_hint_overrides`` (populated from
``config.yaml`` ``platform_hints`` by ``agent_init``) and resolves the
effective hint for *platform_key*:
* ``replace`` substitute the default hint entirely.
* ``append`` keep the default and append the extra text.
* a bare string value treated as ``append`` (convenience shorthand).
Precedence: ``replace`` wins over ``append`` if both are present.
Override text is added on top of (not instead of) the SOUL/context/
memory tiers it only affects the platform-hint segment, so other
platforms are unaffected and general system instructions still apply.
Defensive: any malformed entry falls back to the unmodified default so
a bad config value can never break prompt assembly or leak across
platforms.
"""
if not platform_key:
return default_hint
overrides = getattr(agent, "_platform_hint_overrides", None)
if not isinstance(overrides, dict) or not overrides:
return default_hint
spec = overrides.get(platform_key)
if spec is None:
return default_hint
# Shorthand: a bare string is treated as append text.
if isinstance(spec, str):
extra = spec.strip()
return f"{default_hint}\n\n{extra}".strip() if extra else default_hint
if not isinstance(spec, dict):
return default_hint
replace_text = spec.get("replace")
if isinstance(replace_text, str) and replace_text.strip():
base = replace_text.strip()
else:
base = default_hint
append_text = spec.get("append")
if isinstance(append_text, str) and append_text.strip():
return f"{base}\n\n{append_text.strip()}".strip()
return base
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
"""Assemble the system prompt as three ordered parts.
@ -123,6 +173,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names:
stable_parts.append(TASK_COMPLETION_GUIDANCE)
# Universal parallel-tool-call guidance. Tells the model to batch
# independent tool calls into one assistant turn rather than emitting one
# call per turn — the runtime already runs independent calls concurrently
# (read-only tools always; non-overlapping path-scoped file ops), so the
# only thing missing was steering the model to produce the batch. Cuts
# round-trips and the resent-context cost that compounds over a long
# conversation. Gated by config.yaml ``agent.parallel_tool_call_guidance``
# (default True) and only injected when tools are actually loaded.
if getattr(agent, "_parallel_tool_call_guidance", True) and agent.valid_tool_names:
stable_parts.append(PARALLEL_TOOL_CALL_GUIDANCE)
# Tool-aware behavioral guidance: only inject when the tools are loaded
tool_guidance = []
if "memory" in agent.valid_tool_names:
@ -319,18 +380,25 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
platform_key = (agent.platform or "").lower().strip()
# Resolve the built-in/plugin default hint for this platform, then apply
# any per-platform override from config (platform_hints.<platform>).
_default_hint = ""
if platform_key in PLATFORM_HINTS:
stable_parts.append(PLATFORM_HINTS[platform_key])
_default_hint = PLATFORM_HINTS[platform_key]
elif platform_key:
# Check plugin registry for platform-specific LLM guidance
try:
from gateway.platform_registry import platform_registry
_entry = platform_registry.get(platform_key)
if _entry and _entry.platform_hint:
stable_parts.append(_entry.platform_hint)
_default_hint = _entry.platform_hint
except Exception:
pass
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
if _effective_hint:
stable_parts.append(_effective_hint)
# ── Context tier (cwd-dependent, may change between sessions) ─
context_parts: List[str] = []

View file

@ -1012,28 +1012,42 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
elif function_name == "memory":
def _execute(next_args: dict) -> Any:
target = next_args.get("target", "memory")
operations = next_args.get("operations")
from tools.memory_tool import memory_tool as _memory_tool
result = _memory_tool(
action=next_args.get("action"),
target=target,
content=next_args.get("content"),
old_text=next_args.get("old_text"),
operations=operations,
store=agent._memory_store,
)
# Bridge: notify external memory provider of built-in memory writes
if agent._memory_manager and next_args.get("action") in {"add", "replace"}:
try:
agent._memory_manager.on_memory_write(
next_args.get("action", ""),
target,
next_args.get("content", ""),
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", None),
),
# Bridge: notify external memory provider of built-in memory writes.
# Covers both the single-op shape and each add/replace inside a batch.
if agent._memory_manager:
if operations:
_mem_ops = [
op for op in operations
if isinstance(op, dict) and op.get("action") in {"add", "replace"}
]
else:
_mem_ops = (
[{"action": next_args.get("action"), "content": next_args.get("content")}]
if next_args.get("action") in {"add", "replace"} else []
)
except Exception:
pass
for _op in _mem_ops:
try:
agent._memory_manager.on_memory_write(
_op.get("action", ""),
target,
_op.get("content", "") or "",
metadata=agent._build_memory_write_metadata(
task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", None),
),
)
except Exception:
pass
return result
function_result, function_args = _run_agent_tool_execution_middleware(
agent,

View file

@ -6551,6 +6551,12 @@ app.on('before-quit', () => {
flushDesktopLogBufferSync()
closePreviewWatchers()
// Kill open PTYs before environment teardown to avoid the node-pty#904
// ThreadSafeFunction SIGABRT race.
for (const id of [...terminalSessions.keys()]) {
disposeTerminalSession(id)
}
if (hermesProcess && !hermesProcess.killed) {
hermesProcess.kill('SIGTERM')
}

View file

@ -55,7 +55,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hermes/shared": "file:../shared",
"@icons-pack/react-simple-icons": "^13.13.0",
"@icons-pack/react-simple-icons": "=13.11.1",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",

View file

@ -13,6 +13,7 @@ import {
type GatewayEventPayload,
reasoningPart,
renderMediaTags,
textPart,
upsertToolPart
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
@ -1080,6 +1081,32 @@ export function useMessageStream({
// completions / watch matches here — re-sync the status stack.
void refreshBackgroundProcesses(sessionId)
}
} else if (event.type === 'review.summary') {
// Self-improvement background review saved something to memory/skills
// and emitted a persistent summary (Python formats it as
// "💾 Self-improvement review: …"). The CLI prints this via
// prompt_toolkit and the Ink TUI renders it as a system line; the
// desktop has neither, so without this handler the skill/memory
// change happens silently. Surface it as a persistent system message
// in the transcript so the user is always informed — it must not be a
// transient toast that can be missed.
const text = coerceGatewayText(payload?.text).trim()
if (text && sessionId) {
flushQueuedDeltas(sessionId)
updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{
id: `review-summary-${Date.now()}`,
role: 'system',
parts: [textPart(text)],
timestamp: Math.floor(Date.now() / 1000)
}
]
}))
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)

View file

@ -23,6 +23,7 @@ import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import { ProviderConfigPanel } from './provider-config-panel'
function ConfigField({
schemaKey,
@ -368,6 +369,9 @@ export function ConfigSettings({
schemaKey={key}
value={getNested(config, key)}
/>
{key === 'memory.provider' && typeof getNested(config, key) === 'string' && getNested(config, key) ? (
<ProviderConfigPanel provider={String(getNested(config, key))} />
) : null}
</div>
))}
</div>

View file

@ -239,7 +239,7 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'code_execution.mode': ['project', 'strict'],
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
'memory.provider': ['', 'builtin', 'honcho'],
'memory.provider': ['', 'builtin', 'hindsight', 'honcho'],
// Terminal execution backends — kept in sync with the dispatch ladder in
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).

View file

@ -6,6 +6,12 @@ import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
describe('settings helpers', () => {
it('lists Hindsight as a built-in desktop memory provider option', () => {
const options = enumOptionsFor('memory.provider', '', {})
expect(options).toContain('hindsight')
})
describe('defineFieldCopy', () => {
it('flattens nested field copy paths', () => {
const copy = defineFieldCopy({

View file

@ -0,0 +1,142 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig } from '@/types/hermes'
const getMemoryProviderConfig = vi.fn()
const saveMemoryProviderConfig = vi.fn()
vi.mock('@/hermes', () => ({
getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider),
saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn(),
notifyError: vi.fn()
}))
function hindsightSchema(overrides: Partial<MemoryProviderConfig['fields'][number]>[] = []): MemoryProviderConfig {
const fields: MemoryProviderConfig['fields'] = [
{
key: 'mode',
label: 'Mode',
kind: 'select',
value: 'cloud',
description: 'How Hermes connects to Hindsight.',
placeholder: '',
is_set: true,
options: [
{ value: 'cloud', label: 'Cloud', description: 'Hindsight Cloud API (lightweight, just needs an API key)' },
{ value: 'local_external', label: 'Local External', description: 'Connect to an existing Hindsight instance' }
]
},
{
key: 'api_key',
label: 'API key',
kind: 'secret',
value: '',
description: 'Used to authenticate with the Hindsight API.',
placeholder: 'Enter Hindsight API key',
is_set: false,
options: []
},
{
key: 'api_url',
label: 'API URL',
kind: 'text',
value: 'https://api.hindsight.vectorize.io',
description: '',
placeholder: '',
is_set: true,
options: []
},
{ key: 'bank_id', label: 'Bank ID', kind: 'text', value: 'hermes', description: '', placeholder: '', is_set: true, options: [] },
{
key: 'recall_budget',
label: 'Recall budget',
kind: 'select',
value: 'mid',
description: '',
placeholder: '',
is_set: true,
options: [
{ value: 'low', label: 'low', description: '' },
{ value: 'mid', label: 'mid', description: '' },
{ value: 'high', label: 'high', description: '' }
]
}
]
return {
name: 'hindsight',
label: 'Hindsight',
fields: fields.map((field, index) => ({ ...field, ...overrides[index] }))
}
}
beforeEach(() => {
getMemoryProviderConfig.mockResolvedValue(hindsightSchema())
saveMemoryProviderConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderPanel(provider = 'hindsight') {
const { ProviderConfigPanel } = await import('./provider-config-panel')
return render(<ProviderConfigPanel provider={provider} />)
}
describe('ProviderConfigPanel', () => {
it('renders the declared provider fields generically', async () => {
await renderPanel()
expect(await screen.findByDisplayValue('https://api.hindsight.vectorize.io')).toBeTruthy()
expect(screen.getByDisplayValue('hermes')).toBeTruthy()
expect(screen.getByText('Cloud')).toBeTruthy()
expect(screen.getAllByText('Hindsight Cloud API (lightweight, just needs an API key)').length).toBeGreaterThan(0)
expect(screen.getByText('mid')).toBeTruthy()
})
it('collapses and expands the fields', async () => {
await renderPanel()
expect(await screen.findByLabelText('API URL')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
expect(screen.queryByLabelText('API URL')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
expect(await screen.findByLabelText('API URL')).toBeTruthy()
})
it('saves edited values without requiring a secret replacement', async () => {
await renderPanel()
const apiUrl = await screen.findByLabelText('API URL')
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() =>
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', {
mode: 'cloud',
api_key: '',
api_url: 'http://localhost:8888',
bank_id: 'ben-bank',
recall_budget: 'mid'
})
)
})
it('renders nothing for a provider with no declared config surface', async () => {
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', fields: [] })
const { container } = await renderPanel('builtin')
await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
expect(container.querySelector('section')).toBeNull()
})
})

View file

@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
import { Check, Loader2, Save } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { LoadingState, Pill } from './primitives'
/** Seed editable values from the schema: non-secret fields keep their current
* value, secret fields start blank (their value is never returned). */
function seedValues(config: MemoryProviderConfig): Record<string, string> {
return Object.fromEntries(
config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value])
)
}
function FieldControl({
field,
value,
onChange
}: {
field: MemoryProviderField
value: string
onChange: (value: string) => void
}) {
if (field.kind === 'select') {
const selected = field.options.find(option => option.value === value)
return (
<>
<Select onValueChange={onChange} value={value}>
<SelectTrigger className={CONTROL_TEXT}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{(selected?.description || field.description) && (
<span className="text-xs text-muted-foreground">{selected?.description || field.description}</span>
)}
</>
)
}
if (field.kind === 'secret') {
return (
<div className="flex flex-wrap items-center gap-2">
<Input
className="min-w-64 flex-1 font-mono"
onChange={event => onChange(event.target.value)}
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
type="password"
value={value}
/>
{field.is_set && (
<Pill tone="primary">
<Check className="size-3" />
Set
</Pill>
)}
</div>
)
}
return (
<Input
className="font-mono"
onChange={event => onChange(event.target.value)}
placeholder={field.placeholder}
value={value}
/>
)
}
export function ProviderConfigPanel({ provider }: { provider: string }) {
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
const [values, setValues] = useState<Record<string, string>>({})
const [expanded, setExpanded] = useState(true)
const [saving, setSaving] = useState(false)
const refresh = useCallback(async () => {
try {
const next = await getMemoryProviderConfig(provider)
setConfig(next)
setValues(seedValues(next))
} catch (err) {
notifyError(err, 'Memory provider settings failed to load')
setConfig(null)
}
}, [provider])
useEffect(() => {
setConfig(null)
void refresh()
}, [refresh])
const save = useCallback(async () => {
if (!config) {
return
}
setSaving(true)
try {
await saveMemoryProviderConfig(provider, values)
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
await refresh()
} catch (err) {
notifyError(err, `Failed to save ${config.label} settings`)
} finally {
setSaving(false)
}
}, [config, provider, refresh, values])
// Providers without a declared config surface (e.g. builtin) render nothing.
if (config && config.fields.length === 0) {
return null
}
if (!config) {
return <LoadingState label="Loading memory provider settings..." />
}
const secretFields = config.fields.filter(field => field.kind === 'secret')
return (
<section className="py-3">
<button
aria-expanded={expanded}
className="flex w-full items-center justify-between gap-3 rounded-lg bg-background/60 px-3 py-2 text-left hover:bg-accent/50"
onClick={() => setExpanded(open => !open)}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<DisclosureCaret open={expanded} />
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{config.label} settings
</span>
{secretFields.map(field => (
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
))}
</span>
</button>
{expanded && (
<div className="mt-3 grid gap-4 rounded-xl bg-background/60 p-4">
{config.fields.map(field => (
<label className="grid gap-1.5" key={field.key}>
<span className="text-xs font-medium text-muted-foreground">{field.label}</span>
<FieldControl
field={field}
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
value={values[field.key] ?? ''}
/>
{field.kind !== 'select' && field.description && (
<span className="text-xs text-muted-foreground">{field.description}</span>
)}
</label>
))}
<div className="flex justify-end">
<Button disabled={saving} onClick={() => void save()} size="sm">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
Save
</Button>
</div>
</div>
)}
</section>
)
}

View file

@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createContext, useContext, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
@ -62,6 +62,8 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const copy = t.shell.modelMenu
const closeMenu = useContext(ModelMenuCloseContext)
const [search, setSearch] = useState('')
const [refreshing, setRefreshing] = useState(false)
const queryClient = useQueryClient()
// Reactive session state is read from the stores here (not drilled in), so
// toggling effort/fast/model re-renders this panel in place without forcing
// the parent to rebuild the menu content (which would close the dropdown).
@ -110,6 +112,38 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// next session.create (see selectModel). The default lives in Settings → Model.
const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
// Explicit "Refresh Models": re-fetch the catalog with refresh:true so the
// backend busts its 1h provider-model disk cache and re-pulls each provider's
// live list. Fixes live-only models (e.g. OpenCode Zen free tier) vanishing
// when the cache expires and falls back to the curated static list.
const refreshModels = async () => {
if (refreshing) {
return
}
setRefreshing(true)
try {
const queryKey = ['model-options', activeSessionId || 'global']
const next =
gateway && activeSessionId
? await gateway.request<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
refresh: true
})
: await getGlobalModelOptions({ refresh: true })
queryClient.setQueryData<ModelOptionsResponse>(queryKey, next)
} catch {
// Network/backend hiccup — fall back to a plain invalidate so the next
// open re-fetches (still cached, but no worse than before).
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
} finally {
setRefreshing(false)
}
}
// Selecting a model row restores that model's remembered preset onto the
// session (effort/fast), gated by capability. Unset → Hermes defaults.
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
@ -268,6 +302,18 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
disabled={refreshing}
onSelect={event => {
event.preventDefault()
void refreshModels()
}}
>
<Codicon className={cn('mr-1.5', refreshing && 'animate-spin')} name="sync" size="0.75rem" />
{copy.refreshModels}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}

View file

@ -827,7 +827,7 @@ function StickyHumanMessageContainer({ attachments, children }: { attachments?:
// so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer.
const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-y-auto rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'

View file

@ -17,6 +17,7 @@ import type {
HermesConfig,
HermesConfigRecord,
LogsResponse,
MemoryProviderConfig,
MessagingPlatformsResponse,
MessagingPlatformTestResponse,
MessagingPlatformUpdate,
@ -71,6 +72,7 @@ export type {
HermesConfig,
HermesConfigRecord,
LogsResponse,
MemoryProviderConfig,
MessagingEnvVarInfo,
MessagingHomeChannel,
MessagingPlatformInfo,
@ -339,6 +341,23 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
})
}
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
return window.hermesDesktop.api<MemoryProviderConfig>({
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`
})
}
export function saveMemoryProviderConfig(
provider: string,
values: Record<string, string>
): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`,
method: 'PUT',
body: { values }
})
}
export function getEnvVars(): Promise<Record<string, EnvVarInfo>> {
return window.hermesDesktop.api<Record<string, EnvVarInfo>>({
...profileScoped(),
@ -641,10 +660,10 @@ export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> {
})
}
export function getGlobalModelOptions(): Promise<ModelOptionsResponse> {
export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise<ModelOptionsResponse> {
return window.hermesDesktop.api<ModelOptionsResponse>({
...profileScoped(),
path: '/api/model/options'
path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options'
})
}

View file

@ -1532,6 +1532,7 @@ export const en: Translations = {
search: 'Search models',
noModels: 'No models found',
editModels: 'Edit Models…',
refreshModels: 'Refresh Models',
fast: 'Fast',
medium: 'Med'
},

View file

@ -1662,6 +1662,7 @@ export const ja = defineLocale({
search: 'モデルを検索',
noModels: 'モデルが見つかりません',
editModels: 'モデルを編集…',
refreshModels: 'モデルを更新',
fast: '高速',
medium: '中'
},

View file

@ -1174,6 +1174,7 @@ export interface Translations {
search: string
noModels: string
editModels: string
refreshModels: string
fast: string
medium: string
}

View file

@ -1606,6 +1606,7 @@ export const zhHant = defineLocale({
search: '搜尋模型',
noModels: '找不到模型',
editModels: '編輯模型…',
refreshModels: '重新整理模型',
fast: '快速',
medium: '中'
},

View file

@ -1712,6 +1712,7 @@ export const zh: Translations = {
search: '搜索模型',
noModels: '未找到模型',
editModels: '编辑模型…',
refreshModels: '刷新模型',
fast: '快速',
medium: '中'
},

View file

@ -113,6 +113,31 @@ export interface EnvVarInfo {
url: null | string
}
export type MemoryProviderFieldKind = 'secret' | 'select' | 'text'
export interface MemoryProviderFieldOption {
description: string
label: string
value: string
}
export interface MemoryProviderField {
description: string
is_set: boolean
key: string
kind: MemoryProviderFieldKind
label: string
options: MemoryProviderFieldOption[]
placeholder: string
value: string
}
export interface MemoryProviderConfig {
fields: MemoryProviderField[]
label: string
name: string
}
export interface MessagingEnvVarInfo {
advanced: boolean
description: string

717
cli.py
View file

@ -1340,6 +1340,17 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
except Exception as e:
logger.debug("Error copying .worktreeinclude entries: %s", e)
# Lock the worktree so other processes (and `git worktree remove`) can see
# it is actively in use. Fail-soft: a lock failure never blocks the session.
try:
subprocess.run(
["git", "worktree", "lock", "--reason", f"hermes pid={os.getpid()}", str(wt_path)],
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
logger.debug("Worktree locked: %s (pid=%s)", wt_path, os.getpid())
except Exception as e:
logger.debug("git worktree lock failed (non-fatal): %s", e)
info = {
"path": str(wt_path),
"branch": branch_name,
@ -1415,6 +1426,16 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
# Remove worktree (even if working tree is dirty — uncommitted
# changes without unpushed commits are just artifacts)
# Unlock first so `git worktree remove` isn't blocked by the lock we
# placed at creation time. Fail-soft — never block cleanup.
try:
subprocess.run(
["git", "worktree", "unlock", wt_path],
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
except Exception as e:
logger.debug("git worktree unlock failed (non-fatal): %s", e)
try:
subprocess.run(
["git", "worktree", "remove", wt_path, "--force"],
@ -1984,6 +2005,24 @@ _ACCENT = _SkinAwareAnsi("response_border", "#FFD700", bold=True)
_DIM = "\x1b[2;3m"
def _b(s: str) -> str:
"""Bold if stdout is a real TTY; plain text otherwise (slash-worker safe)."""
import sys as _sys
try:
return f"\x1b[1m{s}\x1b[0m" if _sys.stdout.isatty() else str(s)
except Exception:
return str(s)
def _d(s: str) -> str:
"""Dim-italic if stdout is a real TTY; plain text otherwise."""
import sys as _sys
try:
return f"\x1b[2;3m{s}\x1b[0m" if _sys.stdout.isatty() else str(s)
except Exception:
return str(s)
def _accent_hex() -> str:
"""Return the active skin accent color for legacy CLI output lines."""
try:
@ -3485,11 +3524,36 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._last_turn_finished_at: Optional[float] = None # time.time() when the last agent loop finished
# Initialize SQLite session store early so /title works before first message
self._session_db = None
self._session_db_unavailable = False
try:
from hermes_state import SessionDB
self._session_db = SessionDB()
except Exception as e:
# #41386: a failed session store means the transcript is NOT
# persisted to state.db — the live chat looks healthy but resume
# later shows a truncated/empty session. A buried log line is not
# enough; surface it prominently so the user knows persistence is
# off for this run and can fix the store before relying on resume.
self._session_db_unavailable = True
logger.warning("Failed to initialize SessionDB — session will NOT be indexed for search: %s", e)
try:
# Console is imported at module scope; do NOT re-import it here.
# A function-local `import` would make `Console` a local name for
# the whole __init__ body and break the earlier `self.console =
# Console()` with UnboundLocalError.
Console(stderr=True).print(
"[bold yellow]⚠ Session store unavailable[/bold yellow] — "
"this conversation will [bold]NOT be saved[/bold] to disk and "
"cannot be resumed later. Searching past sessions is also disabled.\n"
f" Reason: {e}\n"
" Fix the state.db store (e.g. `hermes update` to rebuild the venv) to restore persistence."
)
except Exception:
# Never let the warning path itself break startup.
print(
"WARNING: Session store unavailable — this conversation will NOT be "
f"saved to disk and cannot be resumed later. Reason: {e}"
)
# Opportunistic state.db maintenance — runs at most once per
# min_interval_hours, tracked via state_meta in state.db itself so
@ -3664,7 +3728,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if getattr(self, "_resize_recovery_pending", False):
return
now = time.monotonic()
if hasattr(self, "_app") and self._app and (now - self._last_invalidate) >= min_interval:
if hasattr(self, "_app") and self._app and (now - getattr(self, "_last_invalidate", 0.0)) >= min_interval:
self._last_invalidate = now
self._app.invalidate()
@ -5957,6 +6021,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
old_session_id = self.session_id
if self._session_db and old_session_id:
# Flush any un-persisted messages from the current turn to the
# old session *before* rotating. /new can be called mid-turn
# when _flush_messages_to_session_db() has not yet run — without
# this, messages generated during the current turn are silently
# lost on session rotation (#47202).
if self.agent:
try:
self.agent._flush_messages_to_session_db(
self.conversation_history
)
except Exception:
pass # best-effort
try:
self._session_db.end_session(old_session_id, "new_session")
except Exception:
@ -6359,6 +6435,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
in_main_thread = threading.current_thread() is threading.main_thread()
# Slash-worker guard (#23185 / billing auto-reload hang): when a
# prompt_toolkit app is running but we're on a non-main thread (the
# process_loop / TUI slash-worker daemon thread), stdin is owned by the
# event loop / JSON-RPC pipe. A bare input() there blocks forever until
# the worker's 45s timeout fires. We cannot safely prompt off the main
# thread, so cancel cleanly (None) instead of hanging — mirrors the
# _stdin_fallback discipline in _prompt_text_input_modal.
if self._app and not in_main_thread:
self._invalidate()
return None
if self._app and in_main_thread:
from prompt_toolkit.application import run_in_terminal
was_visible = self._status_bar_visible
@ -6930,7 +7017,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
try:
if ctx is None:
raise RuntimeError("inventory context unavailable")
providers = build_models_payload(ctx, max_models=50)["providers"]
providers = build_models_payload(ctx)["providers"]
except Exception:
providers = []
@ -7506,6 +7593,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._show_usage()
elif canonical == "credits":
self._show_credits()
elif canonical == "billing":
self._show_billing(cmd_original)
elif canonical == "insights":
self._show_insights(cmd_original)
elif canonical == "copy":
@ -8425,7 +8514,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if not view.logged_in:
print()
print(f" 💳 {_DIM}Not logged into Nous Portal.{_RST}")
_cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
print(" Run `hermes portal` to log in, then /credits.")
return
@ -8487,6 +8576,628 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
else:
print(" 🟡 Cancelled. No credits added.")
# ------------------------------------------------------------------
# /billing — Phase 2b terminal billing (CLI surface, all 5 screens)
# ------------------------------------------------------------------
def _show_billing(self, command: str = "/billing"):
"""`/billing` — terminal billing for Nous (one interactive modal).
ZERO sub-commands: any argument is ignored. Bare ``/billing`` always
opens the Overview (Screen 1), whose numbered menu is the *only* way to
reach the Buy / Auto-reload / Monthly-limit sub-screens. (Per the unified
UX spec §0.4 ``/billing buy`` etc. are gone; we don't error on a stray
arg, we just open the menu.)
Interactive CLI uses the prompt_toolkit modal; non-interactive contexts
(TUI slash-worker / no live app) render text + the portal deep-link, never
prompting (the URL is the affordance), same discipline as ``_show_credits``.
All money is Decimal end-to-end; the terminal never collects card details.
"""
from agent.billing_view import build_billing_state
state = build_billing_state()
if not state.logged_in:
print()
if state.error:
_msg = f"Couldn't load billing: {state.error}"
_cprint(f" 💳 {_d(_msg)}")
else:
_cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
print(" Run `hermes portal` to log in, then /billing.")
return
# Any sub-arg is intentionally ignored — always open the menu.
self._billing_overview(state)
def _billing_portal_hint(self, state, *, reason: str = "") -> None:
"""Print a portal deep-link line (the funnel for portal-only actions)."""
url = getattr(state, "portal_url", None)
if not url:
return
if reason:
print(f" {reason}")
print(f" Manage on portal: {url}")
def _billing_overview(self, state):
"""Screen 1 — overview: balance, spend bar, role-gated action menu."""
from agent.billing_view import format_money
print()
_cprint(f" 💳 {_b('Usage credits')}")
print(f" {'' * 41}")
cap = state.monthly_cap
if cap is not None and cap.limit_usd is not None:
spent = format_money(cap.spent_this_month_usd)
limit = format_money(cap.limit_usd)
ceiling = " (default ceiling)" if cap.is_default_ceiling else ""
bar, pct = self._billing_spend_bar(
cap.spent_this_month_usd, cap.limit_usd
)
print(f" {spent} of {limit} used{ceiling} {bar} {pct}%")
print(f" Balance: {format_money(state.balance_usd)}")
ar = state.auto_reload
if ar is not None:
if ar.enabled:
print(
f" Auto-reload: on — below {format_money(ar.threshold_usd)} "
f"→ reload to {format_money(ar.reload_to_usd)}"
)
else:
print(" Auto-reload: off")
if state.org_name:
role = (state.role or "").title()
_org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}"
_cprint(f" {_d(_org_line)}")
print(f" {'' * 41}")
# Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal.
if not state.is_admin:
_cprint(f" {_d('Billing actions require an org admin/owner.')}")
self._billing_portal_hint(state)
return
if not state.cli_billing_enabled:
_cprint(f" {_d('Terminal billing is turned off for this org.')}")
self._billing_portal_hint(state, reason="Enable it on the portal to buy credits here.")
return
# Optimistic funnel: no card on file → a charge will 403 no_payment_method.
# Surface that up front (with the portal link) but DON'T hide Buy — /state.card
# can't fully prove CLI-chargeability, so we advise rather than gate.
if state.card is None:
_cprint(
f" {_d('No saved card for terminal charges yet — set one up on the portal first.')}"
)
self._billing_portal_hint(state)
# Non-interactive (slash-worker / no live app): no modal, no sub-command
# advertising — just the portal funnel (the URL is the affordance).
if not getattr(self, "_app", None):
self._billing_portal_hint(state)
return
choices = [
("buy", "Buy credits", "purchase a one-time credit top-up"),
("auto", "Adjust auto-reload", "configure automatic top-ups"),
("limit", "Adjust monthly limit", "show the monthly spend cap (read-only)"),
("portal", "Manage on portal", "open the billing page in your browser"),
("cancel", "Cancel", "do nothing"),
]
# The overview summary is already printed above; the modal only needs to
# present the action menu — repeating the title/balance reads as a dupe.
raw = self._prompt_text_input_modal(
title="💳 Choose an action", detail="",
choices=choices,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice == "buy":
self._billing_buy_flow(state)
elif choice == "auto":
self._billing_auto_reload_flow(state)
elif choice == "limit":
self._billing_limit_screen(state)
elif choice == "portal":
self._billing_open_portal(state)
else:
print(" 🟡 Cancelled.")
def _billing_spend_bar(self, spent, limit, *, cells: int = 10):
"""Render a 10-cell `█`/`░` spend bar + integer percent from spent/limit.
Returns ``(bar, pct)`` where ``bar`` is like ``[]`` and ``pct``
is the spent/limit percentage clamped to 0..100. Box-drawing glyphs are
not SGR codes, so this is leak-safe even without ``_b()``/``_d()``.
"""
from decimal import Decimal
try:
s = Decimal(str(spent)) if spent is not None else Decimal("0")
l = Decimal(str(limit)) if limit is not None else Decimal("0")
except Exception:
s, l = Decimal("0"), Decimal("0")
if l <= 0:
pct = 0
else:
pct = int((s / l) * 100)
pct = max(0, min(100, pct))
filled = int(round(pct / 100 * cells))
filled = max(0, min(cells, filled))
bar = ("" * filled) + ("" * (cells - filled))
return bar, pct
def _billing_open_portal(self, state):
url = getattr(state, "portal_url", None)
if not url:
print(" No portal URL available.")
return
opened = False
try:
import webbrowser
opened = webbrowser.open(url)
except Exception:
opened = False
if not opened:
print(f" Open this URL: {url}")
print(" Complete billing changes in the browser.")
def _billing_require_admin(self, state) -> bool:
"""Guard charge/auto-reload entry points; print + return False if blocked."""
if not state.is_admin:
print()
_cprint(f" 💳 {_d('Billing actions require an org admin/owner.')}")
self._billing_portal_hint(state)
return False
if not state.cli_billing_enabled:
print()
_cprint(f" 💳 {_d('Terminal billing is turned off for this org.')}")
self._billing_portal_hint(state, reason="Enable it on the portal first.")
return False
return True
def _billing_buy_flow(self, state):
"""Screen 2 (preset select) → Screen 3 (confirm + charge + poll)."""
from agent.billing_view import format_money, validate_charge_amount
if not self._billing_require_admin(state):
return
# Screen 3 — preset selection.
if not getattr(self, "_app", None):
presets = ", ".join(format_money(p) for p in state.charge_presets)
print()
_cprint(f" 💳 {_b('Buy usage credits')}")
print(f" Presets: {presets}")
print(" Run this in the interactive CLI to complete a purchase.")
self._billing_portal_hint(state)
return
preset_choices = []
for p in state.charge_presets:
preset_choices.append((str(p), format_money(p), "one-time credit purchase"))
preset_choices.append(("custom", "Custom amount…", "enter your own amount"))
preset_choices.append(("cancel", "Cancel", "do nothing"))
card = state.card
detail = f"Payment: {card.masked}" if card else "No saved card on file"
raw = self._prompt_text_input_modal(
title="💳 Buy usage credits", detail=detail, choices=preset_choices,
)
choice = self._normalize_slash_confirm_choice(raw, preset_choices)
if not choice or choice == "cancel":
print(" 🟡 Cancelled. No credits added.")
return
from decimal import Decimal
if choice == "custom":
entered = self._prompt_text_input(" Amount (USD): ")
if entered is None:
# None = cancelled (e.g. slash-worker can't prompt off-thread).
print(" 🟡 Cancelled. No credits added.")
return
v = validate_charge_amount(
entered or "", min_usd=state.min_usd, max_usd=state.max_usd
)
if not v.ok:
print(f" 🔴 {v.error}")
return
amount = v.amount
else:
try:
amount = Decimal(choice)
except Exception:
print(" 🔴 Invalid selection.")
return
self._billing_confirm_and_charge(state, amount)
def _billing_confirm_and_charge(self, state, amount):
"""Screen 3 — confirm total + consent, charge, then poll to settlement."""
from agent.billing_view import format_money, new_idempotency_key
card = state.card
print()
_cprint(f" 💳 {_b('Confirm purchase')}")
print(f" {'' * 41}")
print(f" Total: {format_money(amount)}")
if card:
print(f" Payment: {card.masked}")
print(f" {'' * 41}")
_consent = (
"By confirming, you allow Nous Research to charge your card."
)
_cprint(f" {_d(_consent)}")
confirm_choices = [
("pay", f"Pay {format_money(amount)} now", "submit the charge"),
("cancel", "Go back", "do not charge"),
]
if not getattr(self, "_app", None):
print(" Run in the interactive CLI to confirm a purchase.")
return
raw = self._prompt_text_input_modal(
title=f"💳 Pay {format_money(amount)}?",
detail=(card.masked if card else "no saved card"),
choices=confirm_choices,
)
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
if choice != "pay":
print(" 🟡 Cancelled. No credits added.")
return
# Submit the charge with a fresh idempotency key (reused on retry).
from hermes_cli.nous_billing import (
BillingError,
BillingScopeRequired,
post_charge,
)
key = new_idempotency_key()
try:
result = post_charge(amount_usd=amount, idempotency_key=key)
except BillingScopeRequired:
self._billing_handle_scope_required(state)
return
except BillingError as exc:
self._billing_render_charge_error(state, exc)
return
charge_id = result.get("chargeId")
if not charge_id:
print(" 🔴 No charge id returned; please check the portal.")
return
_cprint(f" {_d('Charge submitted — confirming settlement…')}")
self._billing_poll_charge(state, charge_id, amount)
def _billing_poll_charge(self, state, charge_id, amount):
"""Poll loop: 2s interval, 5-min cap, cancellable. settled = ledger truth."""
import time as _time
from agent.billing_view import format_money
from hermes_cli.nous_billing import (
BillingError,
BillingRateLimited,
get_charge_status,
)
deadline = _time.time() + 300 # 5-minute cap
interval = 2.0
while _time.time() < deadline:
try:
status = get_charge_status(charge_id)
except BillingRateLimited as exc:
# Retry-after, NOT a failure — back off and keep polling.
wait = exc.retry_after or 5
_time.sleep(min(wait, 30))
continue
except BillingError as exc:
print(f" 🔴 Could not check the charge: {exc}")
return
state_str = status.get("status")
if state_str == "settled":
amt = status.get("amountUsd")
from agent.billing_view import parse_money
shown = format_money(parse_money(amt)) if amt else format_money(amount)
print(f"{shown} in credits added.")
return
if state_str == "failed":
self._billing_render_charge_failed(state, status.get("reason"))
return
# pending → wait and poll again
_time.sleep(interval)
# Past the cap with no terminal state = timeout (not an error).
print(f" 🟡 Still processing after 5 minutes — this is a timeout, not a "
f"failure. Check /billing or the portal shortly.")
self._billing_portal_hint(state)
def _billing_render_charge_failed(self, state, reason):
"""Branch the poll `failed` reasons to the right copy + portal funnel."""
reason = (reason or "").strip()
if reason == "authentication_required":
print(" 🔴 Your bank requires verification (3DS). Complete it on the "
"portal to finish this purchase.")
elif reason == "payment_method_expired":
print(" 🔴 Your card has expired. Update it on the portal.")
elif reason == "card_declined":
print(" 🔴 Your card was declined. Try another card on the portal.")
else:
print(f" 🔴 The charge didn't go through ({reason or 'processing_error'}).")
self._billing_portal_hint(state)
def _billing_render_charge_error(self, state, exc):
"""Render a typed BillingError at submit time (pre-poll)."""
from hermes_cli.nous_billing import BillingRateLimited
code = getattr(exc, "error", None)
portal_url = getattr(exc, "portal_url", None) or getattr(state, "portal_url", None)
if code == "no_payment_method":
print(" 💳 No saved card for terminal charges yet. Set one up on the "
"portal (one-time credit buys don't save a reusable card).")
elif code == "cli_billing_disabled":
print(" 🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.")
elif code == "monthly_cap_exceeded":
remaining = (getattr(exc, "payload", {}) or {}).get("remainingUsd")
if remaining is not None:
print(f" 🔴 Monthly spend cap reached — ${remaining} headroom left.")
else:
print(" 🔴 Monthly spend cap reached.")
elif isinstance(exc, BillingRateLimited):
wait = getattr(exc, "retry_after", None)
mins = f" (try again in ~{max(1, round(wait / 60))} min)" if wait else ""
print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.")
else:
print(f" 🔴 {exc}")
if portal_url:
print(f" Portal: {portal_url}")
def _billing_handle_scope_required(self, state):
"""403 insufficient_scope → lazy step-up re-auth (plan D-A)."""
print()
print(" 💳 Terminal billing needs an extra permission (billing:manage).")
_scope_msg = (
"An org admin/owner must tick \"Allow terminal billing\" during "
"login."
)
_cprint(f" {_d(_scope_msg)}")
if not getattr(self, "_app", None):
print(" Run `hermes portal` and approve terminal billing, then retry.")
return
confirm_choices = [
("yes", "Re-authorize now", "open the portal to grant billing access"),
("no", "Not now", "cancel"),
]
raw = self._prompt_text_input_modal(
title="💳 Grant terminal billing access?",
detail="Opens the portal device-authorization page.",
choices=confirm_choices,
)
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
if choice != "yes":
print(" 🟡 Cancelled.")
return
try:
from hermes_cli.auth import step_up_nous_billing_scope
granted = step_up_nous_billing_scope(open_browser=True)
except Exception as exc:
print(f" 🔴 Re-authorization failed: {exc}")
return
if granted:
print(" ✅ Billing permission granted.")
# Step-up only grants the billing:manage TOKEN scope; the ORG
# kill-switch (cli_billing_enabled) is a separate gate. Re-fetch
# /state so we don't over-promise when a charge would still hit
# cli_billing_disabled.
from agent.billing_view import build_billing_state
fresh = build_billing_state()
if fresh.logged_in and fresh.cli_billing_enabled:
print(" Run /billing buy again to continue.")
else:
print(" 🟡 Permission granted, but terminal billing is still turned "
"off for this org. Enable it in the portal, then run /billing again.")
self._billing_portal_hint(fresh)
else:
print(" 🟡 Terminal billing was not granted (an admin must tick the box).")
def _billing_auto_reload_flow(self, state):
"""Screen 4 — auto-reload config: threshold + reload-to → PATCH.
Prefills the current values from ``state.auto_reload``. Validates both
amounts (2dp, within bounds, ``reload_to > threshold``). When auto-reload
is already on, offers a "Turn off" path (PATCH ``enabled:false``).
"""
from agent.billing_view import format_money, validate_charge_amount
if not self._billing_require_admin(state):
return
card = state.card
ar = state.auto_reload
currently_on = bool(ar and ar.enabled)
print()
_cprint(f" 💳 {_b('Auto-reload')}")
print(f" {'' * 41}")
_cprint(f" {_d('Automatically buy more credits when your balance is low.')}")
if card:
print(f" Card on file: {card.masked}")
else:
print(" No saved card — set one up on the portal first.")
self._billing_portal_hint(state)
return
if currently_on:
print(
f" Currently: below {format_money(ar.threshold_usd)}"
f"reload to {format_money(ar.reload_to_usd)}"
)
if not getattr(self, "_app", None):
print(" Run in the interactive CLI to configure auto-reload.")
self._billing_portal_hint(state)
return
# When already enabled, let the user turn it off without re-entering values.
if currently_on:
top_choices = [
("edit", "Edit thresholds", "change when / how much to reload"),
("off", "Turn off", "disable auto-reload"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Auto-reload",
detail=(
f"On — below {format_money(ar.threshold_usd)}"
f"reload to {format_money(ar.reload_to_usd)}"
),
choices=top_choices,
)
top = self._normalize_slash_confirm_choice(raw, top_choices)
if top == "off":
self._billing_auto_reload_disable(state)
return
if top != "edit":
print(" 🟡 Cancelled.")
return
# Field 1 — threshold (prefilled when editing an existing config).
cur_thr = format_money(ar.threshold_usd) if currently_on else None
thr_prompt = " When balance falls below (USD)"
thr_prompt += f" [{cur_thr}]: " if cur_thr else ": "
threshold_raw = self._prompt_text_input(thr_prompt)
if threshold_raw is None:
# None = cancelled (e.g. slash-worker can't prompt off-thread).
print(" 🟡 Cancelled.")
return
if not (threshold_raw or "").strip() and currently_on:
threshold_amt = ar.threshold_usd # keep current value on empty input
else:
tv = validate_charge_amount(
threshold_raw or "", min_usd=state.min_usd, max_usd=state.max_usd
)
if not tv.ok or tv.amount is None:
print(f" 🔴 {tv.error}")
return
threshold_amt = tv.amount
# Field 2 — reload-to (prefilled when editing an existing config).
cur_rel = format_money(ar.reload_to_usd) if currently_on else None
rel_prompt = " Reload balance to (USD)"
rel_prompt += f" [{cur_rel}]: " if cur_rel else ": "
reload_raw = self._prompt_text_input(rel_prompt)
if reload_raw is None:
print(" 🟡 Cancelled.")
return
if not (reload_raw or "").strip() and currently_on:
reload_amt = ar.reload_to_usd # keep current value on empty input
else:
rv = validate_charge_amount(
reload_raw or "", min_usd=state.min_usd, max_usd=state.max_usd
)
if not rv.ok or rv.amount is None:
print(f" 🔴 {rv.error}")
return
reload_amt = rv.amount
if reload_amt is None or threshold_amt is None or reload_amt <= threshold_amt:
print(" 🔴 Reload-to amount must be greater than the threshold.")
return
print()
_ar_consent = (
f"By confirming, you authorize Nous Research to charge {card.masked} "
f"whenever your balance reaches {format_money(threshold_amt)}. "
f"Turn off any time here or on the portal."
)
_cprint(f" {_d(_ar_consent)}")
confirm_choices = [
("agree", "Agree and turn on", "enable auto-reload"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Turn on auto-reload?",
detail=f"Below {format_money(threshold_amt)} → reload to {format_money(reload_amt)}",
choices=confirm_choices,
)
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
if choice != "agree":
print(" 🟡 Cancelled.")
return
from hermes_cli.nous_billing import (
BillingError,
BillingScopeRequired,
patch_auto_top_up,
)
try:
patch_auto_top_up(
enabled=True, threshold=float(threshold_amt), top_up_amount=float(reload_amt)
)
except BillingScopeRequired:
self._billing_handle_scope_required(state)
return
except BillingError as exc:
self._billing_render_charge_error(state, exc)
return
print(f" ✅ Auto-reload on: below {format_money(threshold_amt)}"
f"reload to {format_money(reload_amt)}.")
def _billing_auto_reload_disable(self, state):
"""Turn off auto-reload (PATCH ``enabled:false``).
The endpoint requires ``threshold``/``topUpAmount`` in the body even when
disabling, so we echo back the current values (falling back to 0).
"""
from hermes_cli.nous_billing import (
BillingError,
BillingScopeRequired,
patch_auto_top_up,
)
ar = state.auto_reload
thr = float(ar.threshold_usd) if ar and ar.threshold_usd is not None else 0.0
rel = float(ar.reload_to_usd) if ar and ar.reload_to_usd is not None else 0.0
try:
patch_auto_top_up(enabled=False, threshold=thr, top_up_amount=rel)
except BillingScopeRequired:
self._billing_handle_scope_required(state)
return
except BillingError as exc:
self._billing_render_charge_error(state, exc)
return
print(" ✅ Auto-reload turned off.")
def _billing_limit_screen(self, state):
"""Screen 5 — monthly spend limit (read-only; cap is portal-only)."""
from agent.billing_view import format_money
print()
_cprint(f" 💳 {_b('Monthly spend limit')}")
print(f" {'' * 41}")
cap = state.monthly_cap
if cap is None or cap.limit_usd is None:
_cprint(f" {_d('No monthly cap visible (managed on the portal).')}")
else:
spent = format_money(cap.spent_this_month_usd)
limit = format_money(cap.limit_usd)
ceiling = " (default ceiling)" if cap.is_default_ceiling else ""
print(f" {spent} of {limit} used this month{ceiling}")
_limit_note = (
"The monthly limit is set on the portal — the terminal shows "
"it read-only."
)
_cprint(f" {_d(_limit_note)}")
self._billing_portal_hint(state)
def _show_insights(self, command: str = "/insights"):
"""Show usage insights and analytics from session history."""
# Parse optional --days flag

View file

@ -62,33 +62,55 @@ live platform adapter's capability methods.
The connector normalizes each platform wire event into a `MessageEvent`
(`gateway/platforms/base.py`) and delivers it to the gateway. **Inbound is
delivered over a signed HTTP POST, not the outbound `/relay` WebSocket** (see
the transport note below). The gateway keys the session via `build_session_key()`
delivered over the gateway's OUTBOUND `/relay` WebSocket** (see the transport
note below) — the connector pushes an `inbound` frame down the socket the
gateway already dialed. The gateway keys the session via `build_session_key()`
from the embedded `SessionSource` — so populating the right discriminators is
the single highest-correctness responsibility of the connector.
### Inbound transport (signed HTTP POST, not the outbound WS)
### Inbound transport (WS back-channel, not HTTP)
The gateway dials **out** to the connector's `/relay` WebSocket for the
handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound,
however, is delivered the other way: the connector **POSTs** the normalized
event to the gateway's inbound endpoint (`HttpGatewayDelivery` on the connector;
`gateway/relay/inbound_receiver.py` on the gateway). The reason is
multi-instance: the connector instance that owns a platform's socket (and thus
produces inbound events) is generally **not** the instance a given gateway
dialed its outbound WS into, so inbound must target a tenant **endpoint** (which
may load-balance across gateway instances) rather than ride one gateway's
outbound socket. Each delivery is HMAC-signed with the per-tenant **delivery
key** (§6.1); the gateway verifies the signature over the exact raw bytes before
accepting the event. Two POST targets:
handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound rides
the **same socket** in the other direction: the connector pushes an `inbound`
frame (and `interrupt_inbound` for §5) down the gateway's outbound WS. There is
**no gateway-side inbound HTTP endpoint** — a gateway need not (and, when hosted,
cannot) expose any inbound port; everything flows over the connection it
initiated.
**Multi-instance routing.** The connector instance that owns a platform's socket
(and thus produces inbound events) is generally **not** the instance the gateway
dialed its outbound WS into. The producing instance therefore publishes the
event on the connector's internal **relay bus** (Redis pub/sub; `RelayBus` in
`src/core/relayBus.ts`) keyed by tenant. Every connector instance subscribes and
routes each message to its **local** sessions for that tenant
(`RelayServer.routeBusMessage`); the single instance that actually holds the
gateway's socket delivers it, and instances with no local session for the tenant
no-op. Cross-instance delivery is thus an in-cluster Redis hop, not a public
HTTP call.
Frames (connector → gateway, over the WS):
- `{"type":"inbound", "event": <MessageEvent>, "bufferId"?}`
- `{"type":"interrupt_inbound", "session_key", "chat_id"}` (§5)
**Trust.** The WS upgrade is authenticated with the gateway's per-gateway secret
(§6.1), so the channel is trusted end to end — inbound frames are not separately
HMAC-signed (the authenticated socket subsumes the per-delivery origin proof the
old HTTP path needed). The relay-bus hop is inside the connector trust domain
(same as the lease/buffer/capability stores).
> Earlier drafts of this contract delivered inbound over a signed **HTTP POST**
> to a `gatewayEndpoint` (`HttpGatewayDelivery` + a gateway-side
> `inbound_receiver`), HMAC-signed with a per-tenant delivery key. That required
> every gateway to expose a reachable inbound URL — impossible for hosted
> gateways, which have no public IP. The WS back-channel above replaces it; the
> per-tenant delivery key is retained at provision for forward-compat but is no
> longer used for inbound. `gatewayEndpoint` remains only for the **passthrough
> plane** (Class-2/3 webhooks like Discord interactions / Twilio), which is a
> separate synchronous-forward path and out of scope for this section.
- `POST {gatewayEndpoint}``{"type":"message", "event": <MessageEvent>}`
- `POST {gatewayEndpoint}/interrupt``{"type":"interrupt", "session_key", "reason"?}` (§5)
> An earlier draft of this contract delivered inbound over the WS `inbound`
> frame. That only works single-instance and predates the multi-instance
> socket-ownership + channel-auth model; the signed-HTTP path above is the
> shipped design.
### SessionSource fields (the wire surface)
@ -178,13 +200,15 @@ gateway holds zero capability material). Source of truth:
mid-turn `/stop` over the outbound WS. The connector MUST forward it to the
gateway instance running that `session_key` (the routing invariant).
- **Connector → gateway:** an inbound interrupt for a `session_key` is delivered
as a **signed HTTP POST** to `{gatewayEndpoint}/interrupt` (§3 transport note),
and bridged by the adapter's `on_interrupt(session_key, chat_id)` into the
existing per-session interrupt mechanism, cancelling exactly that turn
as an `interrupt_inbound` frame down the gateway's outbound WS (§3 transport
note) — routed cross-instance via the relay bus to whichever instance holds
the socket — and bridged by the adapter's `on_interrupt(session_key, chat_id)`
into the existing per-session interrupt mechanism, cancelling exactly that turn
(siblings untouched).
The gateway→connector `/stop` rides the outbound WS; the connector→gateway
interrupt rides the same signed-HTTP inbound path as a normalized event.
Both directions ride the gateway's outbound WS: the gateway→connector `/stop`
egresses over it, and the connector→gateway interrupt rides the same `inbound`
back-channel as a normalized event.
---
@ -231,20 +255,21 @@ only in transport. See `docs/capability-trust-boundary.md` (connector repo:
A2 makes the connector the sole holder of platform secrets while the gateway may
be **customer-managed and internet-exposed**, so the connector⇄gateway channel
is itself authenticated. The gateway holds two enrollment-issued credentials
(`hermes gateway enroll` → connector `/relay/enroll`): a **per-gateway secret**
and a **per-tenant delivery key**. Both are HMAC-SHA256 schemes with a
multi-secret rotation verify list (gateway side: `gateway/relay/auth.py`;
connector side: `src/core/relayAuthToken.ts` + `src/core/deliverySigning.ts`).
is itself authenticated. The gateway holds an enrollment- or provision-issued
**per-gateway secret** (`hermes gateway enroll` → connector `/relay/enroll`, or
managed self-provision → `/relay/provision`) that authenticates its outbound WS
upgrade. It is an HMAC-SHA256 scheme with a multi-secret rotation verify list
(gateway side: `gateway/relay/auth.py`; connector side:
`src/core/relayAuthToken.ts`).
| Leg | Credential | Mechanism |
|-----|-----------|-----------|
| Gateway → connector WS upgrade | per-gateway secret | An `Authorization` bearer header on the `/relay` upgrade. The token is `base64url(payload:exp:sig)` where `payload = gatewayId` and `sig = HMAC(payload:exp, secret)`. Connector verifies and rejects the upgrade (**close 4401**) on mismatch/absence/revocation. The authenticated tenant comes from the connector's store, never the `hello` frame. |
| Connector → gateway inbound POST | per-tenant delivery key | Two headers: `x-relay-timestamp` (unix seconds) and `x-relay-signature` (hex `HMAC(ts.rawBody, deliveryKey)`). Gateway verifies over the **exact raw bytes** within a ±300s replay window before accepting the event; rejects **401** otherwise. |
| Connector → gateway inbound (`inbound` / `interrupt_inbound` frames) | — (rides the authenticated WS) | Inbound is pushed down the gateway's already-authenticated outbound socket (§3), so no per-message signature is needed. A **per-tenant delivery key** is still issued at enroll/provision and retained for forward-compat, but is no longer used to sign inbound. |
This is the **channel** authenticator — distinct from platform crypto, which the
relay path still sheds entirely (§6). The gateway holds zero platform secrets;
these two keys authenticate only the connector link. Full threat model +
the per-gateway secret authenticates only the connector link. Full threat model +
enrollment/rotation/kill-switch design: `docs/connector-gateway-auth-design.md`
(connector repo).

View file

@ -79,40 +79,6 @@ def relay_connection_auth() -> tuple[Optional[str], Optional[str]]:
return (gateway_id or None, secret or None)
def relay_inbound_config() -> tuple[Optional[str], Optional[str], int]:
"""Resolve (delivery_key, bind_host, bind_port) for the inbound receiver.
The connector delivers normalized inbound events to this gateway over a
SIGNED HTTP POST (not the outbound WS), verified with the per-tenant delivery
key issued at enrollment (``GATEWAY_RELAY_DELIVERY_KEY``). The receiver only
starts when a delivery key AND a bind port are configured a gateway with no
public inbound URL (e.g. a purely outbound dev run) simply doesn't run it.
Env first (Docker), then ``gateway.relay_delivery_key`` /
``gateway.relay_inbound_host`` / ``gateway.relay_inbound_port`` in config.yaml.
Port 0 (default/unset) -> receiver disabled.
"""
key = os.environ.get("GATEWAY_RELAY_DELIVERY_KEY", "").strip()
host = os.environ.get("GATEWAY_RELAY_INBOUND_HOST", "").strip()
port_raw = os.environ.get("GATEWAY_RELAY_INBOUND_PORT", "").strip()
if not (key and port_raw):
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
key = key or str(cfg.get("relay_delivery_key", "") or "").strip()
host = host or str(cfg.get("relay_inbound_host", "") or "").strip()
if not port_raw:
port_raw = str(cfg.get("relay_inbound_port", "") or "").strip()
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
pass
try:
port = int(port_raw) if port_raw else 0
except ValueError:
port = 0
return (key or None, host or "0.0.0.0", port)
def relay_endpoint() -> Optional[str]:
"""The gateway's own PUBLIC inbound URL, asserted to the connector at provision.
@ -238,21 +204,33 @@ def _post_provision(
return payload
def self_provision_if_managed() -> bool:
"""Managed-boot self-provision: mint relay creds in-process, no human, no disk.
def self_provision_relay() -> bool:
"""Boot-time relay self-provision: mint relay creds in-process, no human, no disk.
Fires only on a MANAGED boot (``is_managed()``) with relay configured
(``relay_url()`` set) and NO per-gateway secret already present. In that case
the runtime resolves the agent's own Nous access token (the same
Fires when relay is configured (``relay_url()`` set) and NO per-gateway secret
is already present, AND the agent can resolve its own Nous access token. In
that case the runtime resolves the agent's own Nous access token (the same
``resolve_nous_access_token()`` the enroll CLI / dashboard register use),
POSTs ``/relay/provision`` asserting its own endpoint + route keys, and sets
``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` / ``GATEWAY_RELAY_DELIVERY_KEY``
into ``os.environ`` so the subsequent ``register_relay_adapter()`` picks them
up. The creds live ONLY in process memory never written to ``~/.hermes/.env``
(``save_env_value`` refuses under managed anyway, and keeping the secret off
any volume is the stronger posture).
up. The creds live ONLY in process memory never written to ``~/.hermes/.env``.
Stateless: process-env creds don't survive a restart, so a managed container
The trigger is deliberately NOT ``is_managed()``: that means
"package-manager/NixOS-managed" and is False on a NAS-hosted Fly agent (which
sets neither ``HERMES_MANAGED`` nor a ``.managed`` marker), so gating on it
blocked the exact hosted case this is for. The real signal is "you pointed me
at a connector and didn't pin a secret" — which is both NAS-independent and
self-guarding:
- A NAS-hosted agent: has ``GATEWAY_RELAY_URL``, no pinned secret, and a
bootstrapped NAS token -> self-provisions.
- A self-hosted operator who ran ``hermes gateway enroll``: has a PINNED
``GATEWAY_RELAY_SECRET`` -> skipped (the secret-present guard below).
- A self-hosted box with a relay URL but no NAS identity:
``resolve_nous_access_token()`` fails -> graceful no-op.
Stateless: process-env creds don't survive a restart, so a hosted container
re-provisions every boot; the connector's rotation window covers a still-
connected prior instance. An explicitly-pinned ``GATEWAY_RELAY_SECRET`` (env
or config) is RESPECTED self-provision skips so an operator pin isn't
@ -267,18 +245,12 @@ def self_provision_if_managed() -> bool:
logger = logging.getLogger("gateway.relay")
try:
from hermes_cli.config import is_managed
except Exception: # noqa: BLE001
return False
if not is_managed():
return False
dial_url = relay_url()
if not dial_url:
return False
# Respect an already-present (pinned/stamped) secret — don't stomp it.
# Respect an already-present (pinned/stamped) secret — don't stomp it. This
# is also what makes a self-hosted, enrolled gateway skip self-provision.
existing_id, existing_secret = relay_connection_auth()
if existing_id and existing_secret:
logger.info("relay self-provision skipped: GATEWAY_RELAY_SECRET already set")
@ -289,6 +261,8 @@ def self_provision_if_managed() -> bool:
access_token = resolve_nous_access_token()
except Exception as exc: # noqa: BLE001 - boot must survive a token failure
# No resolvable NAS identity (e.g. a self-hosted box that hasn't enrolled)
# -> nothing to provision with; skip quietly and let the gateway boot.
logger.warning("relay self-provision skipped: could not resolve Nous token (%s)", exc)
return False
@ -318,8 +292,11 @@ def self_provision_if_managed() -> bool:
logger.warning("relay self-provision failed (%s); gateway will boot without relay auth", exc)
return False
# Set creds in-process so register_relay_adapter() + relay_inbound_config()
# read them from os.environ. Never logged.
# Set creds in-process so register_relay_adapter() reads them from os.environ
# (the per-gateway secret authenticates the outbound WS upgrade). The delivery
# key is still issued by the connector and persisted for forward-compat, but
# inbound now rides the WS (no HTTP receiver), so it is not consumed here.
# Never logged.
os.environ["GATEWAY_RELAY_ID"] = str(result.get("gatewayId") or gateway_id)
os.environ["GATEWAY_RELAY_SECRET"] = str(result.get("secret") or "")
os.environ["GATEWAY_RELAY_DELIVERY_KEY"] = str(result.get("deliveryKey") or "")

View file

@ -58,10 +58,6 @@ class RelayAdapter(BasePlatformAdapter):
# Capability surface read by stream_consumer (getattr(..., 4096)).
self.MAX_MESSAGE_LENGTH = descriptor.max_message_length
self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain")
# Inbound delivery receiver (signed connector→gateway HTTP POSTs). Built
# lazily in connect() when a delivery key + bind port are configured; a
# purely-outbound dev gateway runs without it. See inbound_receiver.py.
self._inbound_runner: Any = None
# ── capability surface (from descriptor) ─────────────────────────────
@property
@ -80,6 +76,12 @@ class RelayAdapter(BasePlatformAdapter):
if self._transport is None:
raise RuntimeError("RelayAdapter has no transport configured")
self._transport.set_inbound_handler(self._on_inbound)
# Inbound interrupts (connector -> owning gateway) arrive as
# interrupt_inbound frames over the SAME outbound WS; bridge them to the
# adapter's interrupt path. WS-only: there is no inbound HTTP receiver.
set_interrupt = getattr(self._transport, "set_interrupt_inbound_handler", None)
if callable(set_interrupt):
set_interrupt(self.on_interrupt)
ok = await self._transport.connect()
if not ok:
return False
@ -92,40 +94,12 @@ class RelayAdapter(BasePlatformAdapter):
logger.warning("relay handshake failed: %s", exc)
return False
self._apply_descriptor(descriptor)
# Start the signed inbound-delivery receiver if configured (the connector
# POSTs normalized events to it over HTTP, verified with the tenant
# delivery key). Non-fatal: a receiver bind failure must not fail the
# outbound connection — the gateway can still send.
await self._maybe_start_inbound_receiver()
# Inbound (messages + interrupts) is delivered over the outbound WS via
# the connector's relay bus — there is NO inbound HTTP endpoint (hosted
# gateways have no public IP). The transport's reader already dispatches
# `inbound` / `interrupt_inbound` frames to the handlers wired above.
return True
async def _maybe_start_inbound_receiver(self) -> None:
"""Start the inbound HTTP receiver when a delivery key + port are set."""
from gateway.relay import relay_inbound_config
delivery_key, host, port = relay_inbound_config()
if not (delivery_key and port):
return # no inbound URL configured -> outbound-only gateway
try:
from aiohttp import web
from gateway.relay.inbound_receiver import InboundDeliveryReceiver
receiver = InboundDeliveryReceiver(
delivery_key_verify_list=lambda: [delivery_key],
on_message=self._on_inbound,
on_interrupt=self.on_interrupt,
)
runner = web.AppRunner(receiver.build_app(), access_log=None)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
self._inbound_runner = runner
logger.info("relay inbound receiver listening on http://%s:%s", host, port)
except Exception as exc: # noqa: BLE001 - inbound bind failure must not kill outbound
logger.warning("relay inbound receiver failed to start: %s", exc)
self._inbound_runner = None
def _apply_descriptor(self, descriptor: CapabilityDescriptor) -> None:
"""Adopt a (re)negotiated descriptor into the live capability surface."""
self.descriptor = descriptor
@ -148,12 +122,6 @@ class RelayAdapter(BasePlatformAdapter):
await self.interrupt_session_activity(session_key, chat_id)
async def disconnect(self) -> None:
if self._inbound_runner is not None:
try:
await self._inbound_runner.cleanup()
except Exception: # noqa: BLE001 - best-effort teardown
pass
self._inbound_runner = None
if self._transport is not None:
await self._transport.disconnect()

View file

@ -1,204 +0,0 @@
"""Gateway-side inbound delivery receiver. EXPERIMENTAL.
The connector delivers normalized inbound events to a tenant's gateway over a
**signed HTTP POST** (connector ``src/relay/httpGatewayDelivery.ts``), NOT over
the gateway's outbound ``/relay`` WebSocket: the connector instance that owns a
platform socket is generally not the instance a given gateway dialed out to, so
inbound is delivered to a tenant ENDPOINT (which may load-balance across gateway
instances). Each delivery is HMAC-signed with the per-tenant **delivery key**
(``gateway/relay/auth.py``); this receiver verifies the signature over the EXACT
raw request bytes before accepting the event.
Two routes (mirroring the connector's two POST targets):
POST {base} {"type":"message", "event": <MessageEvent>, ...}
POST {base}/interrupt {"type":"interrupt","session_key": ..., "reason"?}
The receiver:
1. reads the RAW body bytes (never a reparsed/re-serialized form the HMAC is
over the literal bytes the connector signed),
2. verifies ``x-relay-signature`` / ``x-relay-timestamp`` against the delivery
key verify list (primary + secondary during rotation), within the replay
window rejects 401 on any failure,
3. parses the JSON and dispatches: a ``message`` to the inbound handler (the
RelayAdapter's ``handle_message`` via the transport's normal path), an
``interrupt`` to the interrupt handler.
EXPERIMENTAL: the transport protocol may change without a deprecation cycle
until 2 Class-1 platforms validate it. See docs/relay-connector-contract.md.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Awaitable, Callable, Optional, Sequence
from gateway.platforms.base import MessageEvent
from gateway.relay.auth import (
DELIVERY_SIG_HEADER,
DELIVERY_TS_HEADER,
verify_delivery_signature,
)
logger = logging.getLogger(__name__)
# Callbacks the receiver dispatches verified deliveries to.
InboundMessageHandler = Callable[[MessageEvent], Awaitable[None]]
InboundInterruptHandler = Callable[[str, str], Awaitable[None]]
try: # lazy/optional dep — mirrors the other HTTP-receiving adapters
from aiohttp import web
except ImportError: # pragma: no cover - exercised only when the extra is absent
web = None # type: ignore[assignment]
AIOHTTP_AVAILABLE = web is not None
def _event_from_wire(raw: dict) -> MessageEvent:
"""Rebuild a MessageEvent from the connector's normalized inbound payload.
Identical mapping to the WS transport's ``_event_from_wire`` (the wire shape
is the same; only the transport differs). Kept here so the HTTP receiver has
no import dependency on the WS transport module.
"""
from gateway.config import Platform
from gateway.platforms.base import MessageType
from gateway.session import SessionSource
src = raw.get("source", {}) or {}
platform = src.get("platform", "relay")
try:
platform_enum = Platform(platform)
except ValueError:
platform_enum = Platform.RELAY
source = SessionSource(
platform=platform_enum,
chat_id=src.get("chat_id", ""),
chat_type=src.get("chat_type", "dm"),
chat_name=src.get("chat_name"),
user_id=src.get("user_id"),
user_name=src.get("user_name"),
thread_id=src.get("thread_id"),
chat_topic=src.get("chat_topic"),
user_id_alt=src.get("user_id_alt"),
chat_id_alt=src.get("chat_id_alt"),
guild_id=src.get("guild_id"),
parent_chat_id=src.get("parent_chat_id"),
message_id=src.get("message_id"),
)
try:
msg_type = MessageType(raw.get("message_type", "text"))
except ValueError:
msg_type = MessageType.TEXT
return MessageEvent(
text=raw.get("text", ""),
message_type=msg_type,
source=source,
message_id=raw.get("message_id"),
reply_to_message_id=raw.get("reply_to_message_id"),
media_urls=raw.get("media_urls") or [],
)
class InboundDeliveryReceiver:
"""Verifies + dispatches signed connector→gateway inbound deliveries.
Transport-agnostic core: ``handle_raw`` takes the raw body bytes + headers +
which route was hit and returns ``(status, body)``. The aiohttp wiring
(``build_app`` / ``serve``) is a thin shell so the verify+dispatch logic is
unit-testable without a live socket.
"""
def __init__(
self,
*,
delivery_key_verify_list: Callable[[], Sequence[str]],
on_message: InboundMessageHandler,
on_interrupt: Optional[InboundInterruptHandler] = None,
max_skew_seconds: int = 300,
) -> None:
# A callable (not a static list) so a rotated delivery key is picked up
# without rebuilding the receiver — mirrors the connector's verify list.
self._verify_list = delivery_key_verify_list
self._on_message = on_message
self._on_interrupt = on_interrupt
self._max_skew_seconds = max_skew_seconds
async def handle_raw(
self, *, raw_body: bytes, timestamp: Optional[str], signature: Optional[str], is_interrupt: bool
) -> tuple[int, dict]:
"""Verify the signature over ``raw_body`` and dispatch. Returns (status, json).
401 on a missing/invalid/expired signature (never dispatches unverified).
400 on malformed JSON. 200 on a verified, dispatched delivery.
"""
verify_keys = list(self._verify_list() or [])
if not verify_keys:
# No delivery key provisioned -> we cannot verify -> reject. A gateway
# that hasn't enrolled must not accept inbound (fail closed).
logger.warning("relay inbound: no delivery key configured; rejecting")
return 401, {"error": "no delivery key configured"}
# Verify over the EXACT raw bytes the connector signed. Decode to text
# with the same UTF-8 the connector's JSON.stringify produced; a single
# differing byte breaks the HMAC (raw-body-preservation discipline).
body_text = raw_body.decode("utf-8", errors="strict")
if not verify_delivery_signature(
body_text, timestamp, signature, verify_keys, self._max_skew_seconds
):
return 401, {"error": "invalid delivery signature"}
try:
payload = json.loads(body_text)
except json.JSONDecodeError:
return 400, {"error": "invalid JSON body"}
if is_interrupt or payload.get("type") == "interrupt":
session_key = str(payload.get("session_key", ""))
chat_id = str(payload.get("chat_id", "") or payload.get("reason", "") or "")
if self._on_interrupt is not None and session_key:
await self._on_interrupt(session_key, chat_id)
return 200, {"ok": True}
# Default: a normalized inbound message event.
event_raw = payload.get("event")
if not isinstance(event_raw, dict):
return 400, {"error": "missing event"}
event = _event_from_wire(event_raw)
await self._on_message(event)
return 200, {"ok": True}
# ── aiohttp wiring (thin shell over handle_raw) ──────────────────────
def build_app(self) -> Any:
"""Build an aiohttp Application exposing the delivery + interrupt routes."""
if not AIOHTTP_AVAILABLE:
raise RuntimeError(
"InboundDeliveryReceiver requires the 'aiohttp' package "
"(install the messaging extra)."
)
async def _deliver(request: Any) -> Any:
return await self._respond(request, is_interrupt=False)
async def _interrupt(request: Any) -> Any:
return await self._respond(request, is_interrupt=True)
app = web.Application()
app.router.add_get("/healthz", lambda _: web.Response(text="ok"))
app.router.add_post("/", _deliver)
app.router.add_post("/interrupt", _interrupt)
return app
async def _respond(self, request: Any, *, is_interrupt: bool) -> Any:
# Read the RAW bytes — do NOT use request.json() (it reparses and we'd
# verify over a re-serialized form, breaking the HMAC).
raw_body = await request.read()
status, body = await self.handle_raw(
raw_body=raw_body,
timestamp=request.headers.get(DELIVERY_TS_HEADER),
signature=request.headers.get(DELIVERY_SIG_HEADER),
is_interrupt=is_interrupt,
)
return web.json_response(body, status=status)

View file

@ -5119,14 +5119,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
from gateway.relay import (
register_relay_adapter,
relay_url,
self_provision_if_managed,
self_provision_relay,
)
# Managed boot: self-provision relay creds in-process (resolve the
# agent's NAS token -> POST /relay/provision -> set GATEWAY_RELAY_* in
# os.environ) BEFORE registration reads them. No-op when not managed,
# relay unconfigured, or a secret is already pinned. Never raises.
self_provision_if_managed()
# Boot-time relay self-provision: resolve the agent's NAS token ->
# POST /relay/provision -> set GATEWAY_RELAY_* in os.environ BEFORE
# registration reads them. No-op when relay is unconfigured, a secret
# is already pinned, or no NAS token resolves (self-hosted, unenrolled).
# Never raises.
self_provision_relay()
if register_relay_adapter():
logger.info("relay adapter registered (connector at %s)", relay_url())

View file

@ -2214,7 +2214,9 @@ class GatewaySlashCommandsMixin:
stranded).
``diff`` output is truncated for chat bubbles the full diff lives in
the CLI (``/skills diff <id>``) and the pending JSON file.
the pending JSON file under ``~/.hermes/pending/skills/``. (Note this is
the write-approval ``diff <id>``; the CLI also has an unrelated
``hermes skills diff <name>`` that diffs a bundled skill vs stock.)
"""
from gateway.run import _hermes_home
from hermes_cli.write_approval_commands import handle_pending_subcommand
@ -2252,12 +2254,14 @@ class GatewaySlashCommandsMixin:
"(Search/install are CLI-only.)")
# Chat bubbles can't hold a full skill diff — truncate and point at
# the real review surfaces.
# the real review surface. (Note: `hermes skills diff <name>` is a
# *different* command — it diffs a bundled skill against its stock
# version — so we point at the pending JSON file, not that command.)
if args and args[0].lower() == "diff" and len(out) > 3000:
pending_id = args[1] if len(args) > 1 else "<id>"
out = (out[:3000]
+ f"\n… (truncated — full diff: `/skills diff {pending_id}` "
f"on the CLI, or ~/.hermes/pending/skills/{pending_id}.json)")
+ "\n… (truncated — full diff in "
f"~/.hermes/pending/skills/{pending_id}.json)")
return out
async def _handle_fast_command(self, event: MessageEvent) -> str:
@ -2584,14 +2588,29 @@ class GatewaySlashCommandsMixin:
# session_id for the continuation. Write the compressed messages
# into the NEW session so the original history stays searchable.
new_session_id = tmp_agent.session_id
if new_session_id != session_entry.session_id:
rotated = new_session_id != session_entry.session_id
if rotated:
session_entry.session_id = new_session_id
self.session_store._save()
self._sync_telegram_topic_binding(
source, session_entry, reason="compress-command",
)
self.session_store.rewrite_transcript(new_session_id, compressed)
# Only rewrite the transcript when rotation actually produced a
# NEW session id. If _compress_context could not rotate (e.g.
# _session_db unavailable, or the DB split raised), session_id
# is unchanged and rewrite_transcript() would DELETE the
# original messages and replace them with only the compressed
# summary — permanent data loss (#44794, #39704). In that case
# leave the original transcript intact.
if rotated:
self.session_store.rewrite_transcript(new_session_id, compressed)
else:
logger.warning(
"Manual /compress: session rotation did not occur "
"(session_id unchanged) — preserving original transcript "
"instead of overwriting it (#44794)."
)
# Reset stored token count — transcript changed, old value is stale
self.session_store.update_session(
session_entry.session_key, last_prompt_tokens=0

View file

@ -71,6 +71,7 @@ DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com"
DEFAULT_NOUS_INFERENCE_URL = "https://inference-api.nousresearch.com/v1"
DEFAULT_NOUS_CLIENT_ID = "hermes-cli"
NOUS_INFERENCE_INVOKE_SCOPE = "inference:invoke"
NOUS_BILLING_MANAGE_SCOPE = "billing:manage"
DEFAULT_NOUS_SCOPE = NOUS_INFERENCE_INVOKE_SCOPE
NOUS_DEVICE_CODE_SOURCE = "device_code"
NOUS_AUTH_PATH_INVOKE_JWT = "invoke_jwt"
@ -7865,6 +7866,7 @@ def _nous_device_code_login(
timeout_seconds: float = 15.0,
insecure: bool = False,
ca_bundle: Optional[str] = None,
on_verification: Optional[Callable[[str, str], None]] = None,
) -> Dict[str, Any]:
"""Run the Nous device-code flow and return full OAuth state without persisting."""
pconfig = PROVIDER_REGISTRY["nous"]
@ -7919,6 +7921,16 @@ def _nous_device_code_login(
else:
print(" Could not open browser automatically — use the URL above.")
# Surface the verification URL/code to an out-of-band consumer (e.g. the
# TUI gateway, whose stdout is a JSON-RPC pipe — a plain print() there is
# dropped). Fired AFTER the print/browser block and BEFORE polling blocks,
# so the consumer can render the link while we wait. Best-effort.
if on_verification is not None:
try:
on_verification(verification_url, user_code)
except Exception:
pass
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
print(f"Waiting for approval (polling every {effective_interval}s)...")
@ -7984,6 +7996,91 @@ def _nous_device_code_login(
raise
def nous_token_has_billing_scope() -> bool:
"""Return True if the currently-held Nous token carries ``billing:manage``.
Reads the persisted ``scope`` string saved at login (``_save_provider_state``
stores ``token_data.get("scope") or scope``). A space-delimited match. Used by
the lazy step-up: if False, the first billing call will 403 ``insufficient_scope``
anyway, but checking up front lets a surface skip a doomed round-trip.
"""
try:
state = get_provider_auth_state("nous") or {}
except Exception:
return False
scope = state.get("scope")
if not isinstance(scope, str):
return False
return NOUS_BILLING_MANAGE_SCOPE in scope.split()
def step_up_nous_billing_scope(
*,
open_browser: bool = True,
timeout_seconds: float = 15.0,
on_verification: Optional[Callable[[str, str], None]] = None,
) -> bool:
"""Re-run the device flow requesting ``billing:manage`` and persist the result.
The lazy step-up (plan D-A): triggered when a billing endpoint returns
``403 insufficient_scope``. Runs a fresh device-connect with
``inference:invoke tool:invoke billing:manage`` on the scope. The user must be
an ADMIN/OWNER and tick "Allow terminal billing" in the portal for the minted
token to actually carry the scope; otherwise the server silently downscopes and this
returns False.
Reuses the held credential's portal/inference URLs + client_id so the step-up
targets the same deployment (incl. a preview via ``HERMES_PORTAL_BASE_URL`` set
at the original login). Persists to the auth store + shared store + pool, exactly
like ``_login_nous`` but WITHOUT the model picker (this is a scope upgrade, not
a fresh login).
Returns True iff the new token carries ``billing:manage``.
"""
prior = get_provider_auth_state("nous") or {}
pconfig = PROVIDER_REGISTRY["nous"]
# Build the step-up scope: existing scopes (if any) + billing:manage, deduped,
# order-stable. Fall back to the standard inference+tool+billing set.
_raw_scope = prior.get("scope")
prior_scope = _raw_scope if isinstance(_raw_scope, str) else ""
requested: list[str] = []
for tok in (prior_scope.split() or [NOUS_INFERENCE_INVOKE_SCOPE, "tool:invoke"]):
if tok and tok not in requested:
requested.append(tok)
if NOUS_BILLING_MANAGE_SCOPE not in requested:
requested.append(NOUS_BILLING_MANAGE_SCOPE)
scope = " ".join(requested)
auth_state = _nous_device_code_login(
portal_base_url=prior.get("portal_base_url") or None,
inference_base_url=prior.get("inference_base_url") or None,
client_id=prior.get("client_id") or pconfig.client_id,
scope=scope,
open_browser=open_browser,
timeout_seconds=timeout_seconds,
on_verification=on_verification,
)
with _auth_store_lock():
auth_store = _load_auth_store()
_save_provider_state(auth_store, "nous", auth_state)
_save_auth_store(auth_store)
# Mirror to shared store + reseed the pool (best-effort), same as _login_nous.
try:
_write_shared_nous_state(auth_state)
except Exception:
pass
try:
_sync_nous_pool_from_auth_store()
except Exception:
pass
granted = auth_state.get("scope")
return isinstance(granted, str) and NOUS_BILLING_MANAGE_SCOPE in granted.split()
def _login_nous(args, pconfig: ProviderConfig) -> None:
"""Nous Portal device authorization flow."""
timeout_seconds = getattr(args, "timeout", None) or 15.0

View file

@ -215,6 +215,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
gateway_only=True),
CommandDef("usage", "Show token usage and rate limits for the current session", "Info"),
CommandDef("credits", "Show Nous credit balance and top up", "Info"),
CommandDef("billing", "Manage Nous terminal billing — buy credits, auto-reload, limits", "Info"),
CommandDef("insights", "Show usage insights and analytics", "Info",
args_hint="[days]"),
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
@ -1053,8 +1054,9 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
# the telegram-parity test reads it so an entry here is a deliberate
# "Slack-via-/hermes" decision, not a silent clamp.
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
# - billing: the terminal-billing surface (buy/auto-reload/limit); /hermes billing.
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "debug"})
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "debug"})
def _sanitize_slack_name(raw: str) -> str:

View file

@ -925,6 +925,15 @@ DEFAULT_CONFIG = {
# plausible-looking output when a real path is blocked. Costs ~80
# tokens in the cached system prompt. Set False to disable globally.
"task_completion_guidance": True,
# Universal parallel-tool-call guidance — short prompt block applied to
# all models that tells the model to batch independent tool calls
# (reads, searches, web fetches, read-only commands) into one turn
# instead of one call per turn. The runtime already runs independent
# calls concurrently, so this just steers the model to produce the
# batch — cutting round-trips and the resent-context cost that
# compounds over a long conversation. Costs ~70 tokens in the cached
# system prompt. Set False to disable globally.
"parallel_tool_call_guidance": True,
# Local-environment toolchain probe — surfaces Python/pip/uv/PEP-668
# state in the system prompt when something non-default is detected
# (e.g. python3 has no pip module, pip→python version mismatch, PEP
@ -1262,6 +1271,21 @@ DEFAULT_CONFIG = {
# global threshold regardless.
},
# Kanban subsystem (orchestrator workers + dispatcher-driven child tasks).
# See tools/kanban_tools.py and hermes_cli/kanban_db.py for the actual
# implementations. Per-platform notification opt-out is handled by the
# kanban dashboard (see ``hermes dashboard`` -> Notifications).
"kanban": {
# Auto-subscribe the originating gateway/TUI session to task
# completion + block events when ``kanban_create`` is called from
# inside a session that has a persistent delivery channel. The
# agent that dispatched the task will get notified automatically
# instead of having to poll. Disable to mirror pre-feature
# behaviour — e.g. for a profile that prefers explicit
# ``kanban_notify-subscribe`` calls per task.
"auto_subscribe_on_create": True,
},
# Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
# cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored.
"prompt_caching": {
@ -2146,6 +2170,22 @@ DEFAULT_CONFIG = {
# User-defined quick commands that bypass the agent loop (type: exec only)
"quick_commands": {},
# Per-platform system-prompt hint overrides. Lets an admin append to or
# replace Hermes' built-in platform hint for a single messaging platform
# (WhatsApp, Slack, Telegram, ...) without affecting other platforms.
# Useful for enterprise/managed profiles that ship platform-aware skills.
# Each key is a platform name; the value is either:
# { "append": "extra text" } — keep the default hint, append text
# { "replace": "full text" } — substitute the default hint entirely
# "extra text" — shorthand for { "append": ... }
# `replace` wins over `append` if both are given. Example:
# platform_hints:
# whatsapp:
# append: >
# When tabular output would be useful, invoke the
# table_formatting skill instead of emitting a Markdown table.
"platform_hints": {},
# Shell-script hooks — declarative bridge that invokes shell scripts
# on plugin-hook events (pre_tool_call, post_tool_call, pre_llm_call,
# subagent_stop, etc.). Each entry maps an event name to a list of
@ -2523,11 +2563,14 @@ DEFAULT_CONFIG = {
"updates": {
# Run a full ``hermes backup``-style zip of HERMES_HOME before every
# ``hermes update``. Backups land in ``<HERMES_HOME>/backups/`` and
# can be restored with ``hermes import <path>``. Off by default —
# on large HERMES_HOME directories the zip can add minutes to every
# update. Set to true to re-enable, or pass ``--backup`` to opt in
# for a single update run.
"pre_update_backup": False,
# can be restored with ``hermes import <path>``. Defaults to true
# after the #48200 incident: a ``hermes update --yes`` run that
# computed a wrong path silently wiped the user's ``.env``,
# ``MEMORY.md``, ``kanban.db``, custom skills, and scripts in one
# go. The cost of a few minutes of zip time per update is
# negligible compared to the alternative. Set to false to opt
# out, or pass ``--no-backup`` for a single update run.
"pre_update_backup": True,
# How many pre-update backup zips to retain. Older ones are pruned
# automatically after each successful backup. Values below 1 are
# floored to 1 — the backup just created is always preserved. To

View file

@ -117,7 +117,8 @@ def build_models_payload(
pricing: bool = False,
capabilities: bool = False,
force_fresh_nous_tier: bool = False,
max_models: int = 50,
refresh: bool = False,
max_models: int | None = None,
) -> dict:
"""Build the ``{providers, model, provider}`` shape every consumer
needs from a single substrate call.
@ -144,6 +145,10 @@ def build_models_payload(
selecting Portal-recommended Nous models and applying tier gating. Keep
this false for UI picker opens; explicit auth/model flows can opt in
when they need freshly-purchased credits to show up immediately.
- ``refresh``: bust the per-provider model-id disk cache so every row
re-fetches its live catalog. Set only for an explicit user-triggered
"refresh models" action; normal picker opens leave it false to stay
snappy on the 1h cache.
"""
from hermes_cli.model_switch import list_authenticated_providers
@ -155,6 +160,7 @@ def build_models_payload(
custom_providers=ctx.custom_providers,
force_fresh_nous_tier=force_fresh_nous_tier,
max_models=max_models,
refresh=refresh,
)
# --- Deduplicate: remove models from aggregators that overlap with

View file

@ -6073,6 +6073,10 @@ def _update_via_zip(args):
)
if result.get("user_modified"):
print(f" ~ {len(result['user_modified'])} user-modified (kept)")
print(
" → see them: hermes skills list-modified "
"(diff/reset to resume updates)"
)
if result.get("cleaned"):
print(f" {len(result['cleaned'])} removed from manifest")
if not result["copied"] and not result.get("updated"):
@ -8114,7 +8118,13 @@ def _run_pre_update_backup(args) -> None:
cfg = {}
updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {}
enabled = updates_cfg.get("pre_update_backup", False)
# The default config ships with ``pre_update_backup: true`` (see
# ``hermes_cli/config.py``). Fall back to true if the key is missing
# (e.g. a user has an older custom config without the field). The
# ``False`` default from before #48200 caused silent data loss when
# an update step computed a wrong path — the cost of a few minutes
# of zip time per update is negligible compared to the alternative.
enabled = updates_cfg.get("pre_update_backup", True)
keep = updates_cfg.get("backup_keep", 5)
if not enabled and not force_backup:
@ -9061,6 +9071,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
if result.get("user_modified"):
print(f" ~ {len(result['user_modified'])} user-modified (kept)")
print(
" → see them: hermes skills list-modified "
"(diff/reset to resume updates)"
)
if result.get("cleaned"):
print(f" {len(result['cleaned'])} removed from manifest")
if not result["copied"] and not result.get("updated"):

View file

@ -0,0 +1,149 @@
"""Declarative configuration schema for desktop memory providers.
Each memory provider *declares* its configurable surface here the fields, their
types, which values are secrets, and (for selects) the allowed options. A single
generic renderer in the desktop UI and a single generic ``GET/PUT
/api/memory/providers/{name}/config`` endpoint pair drive the whole experience,
so adding a new provider (mem0, honcho, ...) is pure declaration with zero
bespoke UI components or endpoints.
This module is intentionally pure data: it imports nothing from the config/env
layer. ``web_server`` owns the generic read/write logic that interprets these
declarations against config.yaml, the provider config file, and the env store.
"""
from __future__ import annotations
from dataclasses import dataclass, field as dataclass_field
# Field kinds understood by the generic renderer.
KIND_TEXT = "text"
KIND_SELECT = "select"
KIND_SECRET = "secret"
@dataclass(frozen=True)
class ProviderFieldOption:
"""A single choice for a ``select`` field."""
value: str
label: str
description: str = ""
@dataclass(frozen=True)
class ProviderField:
"""One configurable field on a memory provider.
A field is stored in exactly one place, decided by ``kind``:
* ``text`` / ``select`` persisted to the provider's JSON config file
(``<hermes_home>/<provider>/config.json``) under ``key``.
* ``secret`` persisted to the env store under ``env_key`` and never read
back out over the API (only an ``is_set`` flag is surfaced).
``aliases`` and ``env_fallbacks`` let a field read legacy values written by
earlier CLI/env setup without re-introducing per-provider code.
"""
key: str
label: str
kind: str = KIND_TEXT
default: str = ""
description: str = ""
placeholder: str = ""
options: tuple[ProviderFieldOption, ...] = ()
env_key: str | None = None
aliases: tuple[str, ...] = ()
env_fallbacks: tuple[str, ...] = ()
@property
def is_secret(self) -> bool:
return self.kind == KIND_SECRET
def allowed_values(self) -> set[str]:
return {opt.value for opt in self.options}
@dataclass(frozen=True)
class MemoryProvider:
"""A declared memory provider and its configurable fields."""
name: str
label: str
fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple)
HINDSIGHT = MemoryProvider(
name="hindsight",
label="Hindsight",
fields=(
ProviderField(
key="mode",
label="Mode",
kind=KIND_SELECT,
default="cloud",
description="How Hermes connects to Hindsight.",
options=(
ProviderFieldOption(
"cloud",
"Cloud",
"Hindsight Cloud API (lightweight, just needs an API key)",
),
ProviderFieldOption(
"local_external",
"Local External",
"Connect to an existing Hindsight instance",
),
),
),
ProviderField(
key="api_key",
label="API key",
kind=KIND_SECRET,
env_key="HINDSIGHT_API_KEY",
description="Used to authenticate with the Hindsight API.",
placeholder="Enter Hindsight API key",
),
ProviderField(
key="api_url",
label="API URL",
kind=KIND_TEXT,
default="https://api.hindsight.vectorize.io",
aliases=("apiUrl",),
env_fallbacks=("HINDSIGHT_API_URL",),
),
ProviderField(
key="bank_id",
label="Bank ID",
kind=KIND_TEXT,
default="hermes",
aliases=("bankId",),
),
ProviderField(
key="recall_budget",
label="Recall budget",
kind=KIND_SELECT,
default="mid",
aliases=("budget",),
options=(
ProviderFieldOption("low", "low"),
ProviderFieldOption("mid", "mid"),
ProviderFieldOption("high", "high"),
),
),
),
)
# Registry of providers that expose a desktop config surface. Providers without
# an entry here (e.g. ``builtin``) simply render no config panel.
MEMORY_PROVIDERS: dict[str, MemoryProvider] = {
HINDSIGHT.name: HINDSIGHT,
}
def get_memory_provider(name: str) -> MemoryProvider | None:
"""Return the declared provider for ``name``, or ``None`` if undeclared."""
return MEMORY_PROVIDERS.get(name)

View file

@ -1188,7 +1188,6 @@ def prewarm_picker_cache_async() -> Optional["_threading.Thread"]:
current_model=ctx.current_model,
user_providers=ctx.user_providers,
custom_providers=ctx.custom_providers,
max_models=50,
)
except Exception:
# Best-effort warmup — never surface errors into the session.
@ -1206,8 +1205,9 @@ def list_authenticated_providers(
custom_providers: list | None = None,
*,
force_fresh_nous_tier: bool = False,
max_models: int = 8,
max_models: int | None = None,
current_model: str = "",
refresh: bool = False,
) -> List[dict]:
"""Detect which providers have credentials and list their curated models.
@ -1228,6 +1228,12 @@ def list_authenticated_providers(
``force_fresh_nous_tier`` bypasses the short Nous tier cache for explicit
account-sensitive flows. UI picker opens should leave it false so they do
not block on fresh Portal/account checks every time.
``refresh`` busts the per-provider model-id disk cache
(``provider_models_cache.json``) up front so every row re-fetches its
live catalog. Use for an explicit user-triggered "refresh models" action
(e.g. the desktop picker's refresh control); leave false for normal picker
opens so they stay snappy on the 1h cache.
"""
import os
from agent.models_dev import (
@ -1239,9 +1245,21 @@ def list_authenticated_providers(
from hermes_cli.models import (
OPENROUTER_MODELS, _PROVIDER_MODELS,
_MODELS_DEV_PREFERRED, _merge_with_models_dev, cached_provider_model_ids,
get_curated_nous_model_ids,
clear_provider_models_cache, get_curated_nous_model_ids,
)
# Explicit refresh: drop every provider's cached model-id list so the
# cached_provider_model_ids() calls below all re-fetch live. Without this
# a stale 1h cache can fall back to the curated static list when its live
# fetch later fails, silently dropping live-only models (e.g. OpenCode
# Zen's free tier) the user had seen before.
if refresh:
try:
clear_provider_models_cache()
except Exception:
pass
results: List[dict] = []
seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545)
seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn)
@ -1426,7 +1444,7 @@ def list_authenticated_providers(
if hermes_id in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_id, model_ids)
total = len(model_ids)
top = model_ids[:max_models]
top = model_ids[:max_models] if max_models is not None else model_ids
slug = hermes_id
pinfo = _mdev_pinfo(mdev_id)
@ -1589,7 +1607,7 @@ def list_authenticated_providers(
if hermes_slug in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
total = len(model_ids)
top = model_ids[:max_models]
top = model_ids[:max_models] if max_models is not None else model_ids
results.append({
"slug": hermes_slug,
@ -1664,7 +1682,7 @@ def list_authenticated_providers(
if not _cp_model_ids:
_cp_model_ids = curated.get(_cp.slug, [])
_cp_total = len(_cp_model_ids)
_cp_top = _cp_model_ids[:max_models]
_cp_top = _cp_model_ids[:max_models] if max_models is not None else _cp_model_ids
results.append({
"slug": _cp.slug,
@ -1813,7 +1831,7 @@ def list_authenticated_providers(
"name": "Custom endpoint",
"is_current": True,
"is_user_defined": True,
"models": _models[:max_models] if max_models else _models,
"models": _models[:max_models] if max_models is not None else _models,
"total_models": len(_models),
"source": "model-config",
"api_url": str(current_base_url).strip().rstrip("/"),
@ -2040,7 +2058,7 @@ def list_picker_providers(
current_base_url: str = "",
user_providers: dict = None,
custom_providers: list | None = None,
max_models: int = 8,
max_models: int | None = None,
current_model: str = "",
) -> List[dict]:
"""Interactive-picker variant of :func:`list_authenticated_providers`.
@ -2083,7 +2101,7 @@ def list_picker_providers(
except Exception:
live_ids = list(p.get("models", []))
p = dict(p)
p["models"] = live_ids[:max_models]
p["models"] = live_ids[:max_models] if max_models is not None else live_ids
p["total_models"] = len(live_ids)
has_models = bool(p.get("models"))

406
hermes_cli/nous_billing.py Normal file
View file

@ -0,0 +1,406 @@
"""Nous Portal terminal-billing HTTP client (Phase 2b).
Thin, fail-loud client for the four ``/api/billing/*`` endpoints the terminal
billing screens drive. Companion to ``hermes_cli/nous_account.py`` (which owns
read-only entitlement/balance) this module owns the *write* side: buy credits,
poll a charge, configure auto-reload.
Design rules:
- **Money is decimal, never float.** The server emits decimal STRINGS
(``"142.5"`` not fixed 2dp). We parse with :class:`decimal.Decimal` and never
round-trip through float.
- **This client raises typed exceptions; it does NOT fail open.** Fail-open is the
*caller's* job (the ``agent/billing_view.py`` builders) so each surface can
decide how to degrade. A raw network/HTTP error here surfaces as
:class:`BillingError` (or a subclass) carrying the parsed server ``error`` code,
HTTP status, ``portalUrl`` deep-link, and ``retry_after``.
- **Auth** = the OAuth bearer JWT Hermes already holds for inference
(``get_provider_auth_state("nous")["access_token"]``). No API-key auth on these.
- **Portal base URL** resolves with the same precedence as the device-flow login
(``auth.py``): ``HERMES_PORTAL_BASE_URL`` ``NOUS_PORTAL_BASE_URL`` the
stored auth-state ``portal_base_url`` the registry default. This is how the
E2E run points the client at a preview deployment with zero code change.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Optional
DEFAULT_PORTAL_BASE_URL = "https://portal.nousresearch.com"
# Default HTTP timeout (seconds). Charge/poll calls are quick; keep this tight so
# a hung portal doesn't freeze the TUI.
DEFAULT_TIMEOUT = 15.0
# Scope the privileged billing endpoints require. Mirrored from
# hermes_cli.auth.NOUS_BILLING_MANAGE_SCOPE (kept here too so this module has no
# import-time dependency on the much heavier auth module).
BILLING_MANAGE_SCOPE = "billing:manage"
# =============================================================================
# Typed errors
# =============================================================================
class BillingError(Exception):
"""A billing HTTP call failed.
Carries everything a surface needs to render the right message + affordance:
the server ``error`` code, HTTP ``status``, an optional human ``message``, the
``portalUrl`` deep-link (present on every gate denial), and ``retry_after``
seconds (429/503). ``payload`` is the full parsed JSON body when available.
"""
def __init__(
self,
message: str,
*,
status: Optional[int] = None,
error: Optional[str] = None,
portal_url: Optional[str] = None,
retry_after: Optional[int] = None,
payload: Optional[dict[str, Any]] = None,
) -> None:
super().__init__(message)
self.status = status
self.error = error
self.portal_url = portal_url
self.retry_after = retry_after
self.payload = payload or {}
class BillingScopeRequired(BillingError):
"""``403 insufficient_scope`` — the held token lacks ``billing:manage``.
The lazy step-up trigger: catching this kicks off a fresh device-connect that
requests ``billing:manage`` (and tells the user an ADMIN must tick "Allow
terminal billing"). Also fires mid-session if the scope is stripped on refresh
after the user loses ADMIN.
"""
class BillingRateLimited(BillingError):
"""``429 rate_limited`` or ``503 temporarily_unavailable``.
NOT a payment failure. Carries ``retry_after`` (seconds) back off and tell
the user "try again in N min"; never auto-retry-spam (the limiter is
5/org/hr + 5/token/hr and easy to dig deeper into).
"""
class BillingAuthError(BillingError):
"""``401`` — missing/invalid bearer token (not logged in / expired)."""
# =============================================================================
# Base-URL + auth resolution
# =============================================================================
def resolve_portal_base_url(state: Optional[dict[str, Any]] = None) -> str:
"""Resolve the portal base URL with login-time precedence.
``HERMES_PORTAL_BASE_URL`` ``NOUS_PORTAL_BASE_URL`` stored auth-state
``portal_base_url`` registry default. Trailing slash stripped.
"""
env = os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL")
if env and env.strip():
return env.strip().rstrip("/")
if state:
stored = state.get("portal_base_url")
if isinstance(stored, str) and stored.strip():
return stored.strip().rstrip("/")
return DEFAULT_PORTAL_BASE_URL
def _absolutize_portal_url(portal_url: Optional[str]) -> Optional[str]:
"""Resolve a (possibly relative) server portalUrl to an absolute URL.
The server emits ``portalUrl`` relative by design (e.g. ``/billing?topup=open``)
it doesn't know which deployment the client points at. Resolve it against the
client's portal base (preview / staging / prod) so deep-links are clickable.
Idempotent: an already-absolute URL is returned unchanged (urljoin keeps it).
"""
if not (isinstance(portal_url, str) and portal_url.strip()):
return portal_url
base = resolve_portal_base_url()
# urljoin needs a trailing slash on the base to treat it as a directory and
# join an absolute path like "/billing?..." against the host. An already-
# absolute portal_url (with its own scheme/host) is returned as-is.
return urllib.parse.urljoin(base.rstrip("/") + "/", portal_url)
# Short-lived cache for the resolved (token, base). `resolve_nous_access_token`
# acquires two cross-process file locks + reads two files on every call (even on
# its fast path), which is wasteful when the 2s/5-min charge poll loop calls a
# billing endpoint ~150x per purchase. Cache the result briefly: the resolver
# only ever returns a token with >=120s of life (its refresh skew), so a 30s
# cache can never hand back an about-to-expire token. A 401 still surfaces
# normally (the cache holds a valid token, not the HTTP outcome).
_TOKEN_CACHE_TTL_SECONDS = 30.0
_token_cache: tuple[float, str, str] | None = None # (cached_at, token, base)
def _billing_not_logged_in(exc: Optional[BaseException] = None) -> "BillingAuthError":
"""Build the canonical 'not logged in' BillingAuthError (single source)."""
err = BillingAuthError(
"Not logged into Nous Portal — run `hermes portal` to log in.",
status=401,
error="invalid_token",
)
if exc is not None:
err.__cause__ = exc
return err
def _resolve_token_and_base(*, use_cache: bool = True) -> tuple[str, str]:
"""Return ``(access_token, portal_base_url)`` for billing calls.
Uses the same refresh-aware resolver the inference path uses
(``resolve_nous_access_token``), so a short-lived (~15 min) access token that
has expired is transparently refreshed via the stored ``refresh_token``
instead of failing as "not logged in". Raises :class:`BillingAuthError` only
when there is no usable Nous session at all.
The result is cached for ``_TOKEN_CACHE_TTL_SECONDS`` to keep the charge poll
loop from re-locking + re-reading the auth store on every 2s tick. Pass
``use_cache=False`` to force a fresh resolution (e.g. after a 401).
"""
global _token_cache
import time as _time
if use_cache and _token_cache is not None:
cached_at, token, base = _token_cache
if (_time.time() - cached_at) < _TOKEN_CACHE_TTL_SECONDS:
return token, base
try:
from hermes_cli.auth import get_provider_auth_state
state = get_provider_auth_state("nous") or {}
except Exception:
state = {}
base = resolve_portal_base_url(state)
try:
from hermes_cli.auth import AuthError, resolve_nous_access_token
except ImportError:
# auth module unavailable — fall back to the raw stored token.
token = state.get("access_token")
if isinstance(token, str) and token.strip():
resolved = (token.strip(), base)
_token_cache = (_time.time(), *resolved)
return resolved
raise _billing_not_logged_in()
try:
token = resolve_nous_access_token()
except AuthError as exc:
raise _billing_not_logged_in(exc) from exc
resolved = (token.strip(), base)
_token_cache = (_time.time(), *resolved)
return resolved
# =============================================================================
# HTTP plumbing
# =============================================================================
def _retry_after_seconds(headers: Any) -> Optional[int]:
"""Parse a ``Retry-After`` header (integer seconds) — None if absent/bad."""
if headers is None:
return None
try:
raw = headers.get("Retry-After")
except Exception:
raw = None
if raw is None:
return None
try:
return int(str(raw).strip())
except (TypeError, ValueError):
return None
def _raise_for_error(
status: int, payload: dict[str, Any], headers: Any = None
) -> None:
"""Map an HTTP error response to the right typed :class:`BillingError`."""
error = payload.get("error") if isinstance(payload, dict) else None
message = payload.get("message") if isinstance(payload, dict) else None
portal_url = _absolutize_portal_url(
payload.get("portalUrl") if isinstance(payload, dict) else None
)
retry_after = _retry_after_seconds(headers)
common = {
"status": status,
"error": error,
"portal_url": portal_url,
"retry_after": retry_after,
"payload": payload if isinstance(payload, dict) else None,
}
if status == 401:
raise BillingAuthError(message or "Authentication required.", **common)
if status == 403 and error == "insufficient_scope":
raise BillingScopeRequired(
message or "This action needs the billing:manage scope.", **common
)
if status in (429, 503):
raise BillingRateLimited(
message or "Rate limited — try again shortly.", **common
)
raise BillingError(message or error or f"Billing request failed ({status}).", **common)
def _request(
method: str,
path: str,
*,
body: Optional[dict[str, Any]] = None,
extra_headers: Optional[dict[str, str]] = None,
timeout: float = DEFAULT_TIMEOUT,
_retried_auth: bool = False,
) -> dict[str, Any]:
"""Make an authenticated billing request; return the parsed JSON dict.
Raises a typed :class:`BillingError` on any non-2xx response (or transport
failure). 2xx with an empty body returns ``{}``. A 401 triggers exactly one
retry with a freshly-resolved token (bypassing the short token cache) so a
cached-but-just-expired token self-heals instead of failing the call.
"""
token, base = _resolve_token_and_base(use_cache=not _retried_auth)
url = f"{base}{path}"
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
}
if body is not None:
headers["Content-Type"] = "application/json"
if extra_headers:
headers.update(extra_headers)
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as exc:
# A 401 on a cached token → drop the cache and retry once with a fresh
# (refresh-aware) resolve before surfacing the auth error.
if exc.code == 401 and not _retried_auth:
global _token_cache
_token_cache = None
return _request(
method,
path,
body=body,
extra_headers=extra_headers,
timeout=timeout,
_retried_auth=True,
)
raw = ""
try:
raw = exc.read().decode("utf-8")
except Exception:
raw = ""
try:
payload = json.loads(raw) if raw.strip() else {}
except json.JSONDecodeError:
payload = {}
_raise_for_error(exc.code, payload, getattr(exc, "headers", None))
raise # unreachable; _raise_for_error always raises
except urllib.error.URLError as exc:
raise BillingError(
f"Could not reach Nous Portal: {exc.reason}", error="network_error"
) from exc
# =============================================================================
# The four endpoints
# =============================================================================
def get_billing_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
"""``GET /api/billing/state`` — role-tiered overview (no scope required)."""
return _request("GET", "/api/billing/state", timeout=timeout)
def patch_auto_top_up(
*,
enabled: bool,
threshold: float | str,
top_up_amount: float | str,
timeout: float = DEFAULT_TIMEOUT,
) -> dict[str, Any]:
"""``PATCH /api/billing/auto-top-up`` — configure auto-reload (scope required).
Body is strict server-side: extra keys (``maxMonthlySpend``, a payment method)
are rejected with 400. Numbers are sent as JSON numbers per the contract.
"""
return _request(
"PATCH",
"/api/billing/auto-top-up",
body={
"enabled": bool(enabled),
"threshold": float(threshold),
"topUpAmount": float(top_up_amount),
},
timeout=timeout,
)
def post_charge(
*,
amount_usd: float | str,
idempotency_key: str,
timeout: float = DEFAULT_TIMEOUT,
) -> dict[str, Any]:
"""``POST /api/billing/charge`` — buy credits (scope required).
``Idempotency-Key`` header is MANDATORY (a missing header is a server 400, not
a default): generate a UUID per user-confirmed purchase and reuse it on retry.
Returns ``202 {chargeId}`` money is NOT confirmed yet; poll with
:func:`get_charge_status`.
"""
if not (isinstance(idempotency_key, str) and idempotency_key.strip()):
raise BillingError(
"Idempotency-Key is required for a charge.",
error="idempotency_key_required",
)
return _request(
"POST",
"/api/billing/charge",
body={"amountUsd": float(amount_usd)},
extra_headers={"Idempotency-Key": idempotency_key.strip()},
timeout=timeout,
)
def get_charge_status(
charge_id: str, *, timeout: float = DEFAULT_TIMEOUT
) -> dict[str, Any]:
"""``GET /api/billing/charge/{id}`` — poll a charge (scope required).
Returns ``{status: "pending"|"settled"|"failed", ...}``. An unknown or foreign
id returns ``{status:"pending"}`` (never 404, never another org's data) — so a
``pending`` that never resolves past the 5-min cap is a *timeout*, not an error.
"""
if not (isinstance(charge_id, str) and charge_id.strip()):
raise BillingError("A charge id is required.", error="invalid_charge_id")
# urllib does not need manual quoting for the opaque ids the server mints, but
# guard against a stray slash that would change the path shape.
safe_id = urllib.parse.quote(charge_id.strip(), safe="")
return _request("GET", f"/api/billing/charge/{safe_id}", timeout=timeout)

View file

@ -713,6 +713,69 @@ def find_custom_provider_identity(base_url: str) -> Optional[str]:
return None
def canonical_custom_identity(
*,
base_url: Optional[str] = None,
config_provider: Optional[str] = None,
) -> Optional[str]:
"""Recover a routable ``custom:<name>`` identity for a bare custom provider.
The bare string ``"custom"`` is the *resolved billing class* shared by
every named ``providers:`` / ``custom_providers:`` entry it is NOT a
routable provider identity (``resolve_runtime_provider("custom")`` falls
through to the OpenRouter default URL with no api_key, which surfaces to
the user as "No LLM provider configured").
Any code path that persists or restores a session's provider override
must run the resolved provider through this helper so a bare ``"custom"``
is upgraded back to its durable ``custom:<name>`` menu key. Two recovery
sources, in priority order:
1. ``base_url`` reverse-lookup the entry that owns the endpoint URL
(the one fact that always survives the persistence round-trip when a
URL was recorded).
2. ``config_provider`` the active ``config.model.provider`` (or its
``provider``/``HERMES_INFERENCE_PROVIDER`` equivalent). When the agent
was built without a base_url on the override (the recurring
Desktop/TUI regression vector), the configured provider is the only
durable identity left, so fall back to it when it names a real entry.
Returns ``custom:<name>`` when a routable identity is recovered, else
``None`` (caller keeps whatever it had bare ``"custom"`` only as a last
resort, e.g. a genuine ad-hoc endpoint with no config entry).
"""
# 1. Reverse-lookup by endpoint URL.
if base_url:
identity = find_custom_provider_identity(base_url)
if identity:
return identity
# 2. Fall back to the configured provider when it names a real entry.
candidate = str(config_provider or "").strip()
if not candidate:
try:
candidate = str(_get_model_config().get("provider") or "").strip()
except Exception:
candidate = ""
if not candidate:
candidate = os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip()
candidate_norm = _normalize_custom_provider_name(candidate)
# A bare/non-routable candidate cannot heal a bare custom override.
if not candidate_norm or candidate_norm in {"custom", "auto", "openrouter"}:
return None
# Only return it when it actually resolves to a configured custom entry,
# so we never invent a `custom:<x>` that resolution can't honor.
try:
if _get_named_custom_provider(candidate) is not None:
if candidate_norm.startswith("custom:"):
return candidate_norm
return f"custom:{candidate_norm}"
except Exception:
pass
return None
def _normalize_base_url_for_match(value) -> str:
return str(value or "").strip().rstrip("/").lower()

View file

@ -1149,6 +1149,73 @@ def do_reset(name: str, restore: bool = False,
c.print("[dim]Use /reset to start a new session now, or --now to apply immediately (invalidates prompt cache).[/]\n")
def do_list_modified(console: Optional[Console] = None,
as_json: bool = False) -> None:
"""List bundled skills the user has edited (which `hermes update` keeps)."""
from tools.skills_sync import list_user_modified_bundled_skills
c = console or _console
modified = list_user_modified_bundled_skills()
if as_json:
import json
c.print(json.dumps([m["name"] for m in modified]))
return
if not modified:
c.print("[dim]No user-modified bundled skills — everything tracks upstream.[/]\n")
return
c.print(f"\n[bold]{len(modified)} user-modified bundled skill(s)[/] "
"[dim](kept as-is by `hermes update`):[/]")
for entry in modified:
c.print(f" [yellow]~[/] {entry['name']}")
c.print()
c.print("[dim]See changes: hermes skills diff <name>[/]")
c.print("[dim]Resume updates: hermes skills reset <name> (keep your copy, re-baseline)[/]")
c.print("[dim]Revert to stock: hermes skills reset <name> --restore[/]\n")
def do_diff(name: str, console: Optional[Console] = None) -> None:
"""Show how the user's copy of a bundled skill differs from the stock version."""
from tools.skills_sync import diff_bundled_skill
c = console or _console
result = diff_bundled_skill(name)
if not result["ok"]:
c.print(f"[bold red]Error:[/] {result['message']}\n")
return
if not result["modified"]:
c.print(f"[green]{result['message']}[/]\n")
return
c.print(f"\n[bold]{result['message']}[/]\n")
for entry in result["diffs"]:
status = entry["status"]
if status == "modified":
# Render the unified diff with light coloring.
for line in entry["diff"].splitlines():
if line.startswith("+") and not line.startswith("+++"):
c.print(f"[green]{line}[/]")
elif line.startswith("-") and not line.startswith("---"):
c.print(f"[red]{line}[/]")
elif line.startswith("@@"):
c.print(f"[cyan]{line}[/]")
else:
c.print(line, highlight=False)
elif status == "added":
c.print(f"[green]+ only in your copy:[/] {entry['path']}")
elif status == "removed":
c.print(f"[red]- only in stock:[/] {entry['path']}")
else: # binary
c.print(f"[yellow]~ {entry['path']}:[/] binary file differs")
c.print()
c.print(f"[dim]Revert with: hermes skills reset {name} --restore[/]\n")
def do_opt_out(remove: bool = False,
console: Optional[Console] = None,
skip_confirm: bool = False,
@ -1624,6 +1691,10 @@ def skills_command(args) -> None:
elif action == "reset":
do_reset(args.name, restore=getattr(args, "restore", False),
skip_confirm=getattr(args, "yes", False))
elif action == "list-modified":
do_list_modified(as_json=getattr(args, "json", False))
elif action == "diff":
do_diff(args.name)
elif action == "opt-out":
do_opt_out(remove=getattr(args, "remove", False),
skip_confirm=getattr(args, "yes", False))
@ -1654,7 +1725,7 @@ def skills_command(args) -> None:
return
do_tap(tap_action, repo=repo)
else:
_console.print("Usage: hermes skills [browse|search|install|inspect|list|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
_console.print("Usage: hermes skills [browse|search|install|inspect|list|list-modified|diff|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
_console.print("Run 'hermes skills <command> --help' for details.\n")
@ -1826,6 +1897,15 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
do_reset(name, restore=restore, console=c, skip_confirm=True,
invalidate_cache=invalidate_cache)
elif action in {"list-modified", "modified"}:
do_list_modified(console=c, as_json="--json" in args)
elif action == "diff":
if not args:
c.print("[bold red]Usage:[/] /skills diff <name>\n")
return
do_diff(args[0], console=c)
elif action == "publish":
if not args:
c.print("[bold red]Usage:[/] /skills publish <skill-path> [--to github] [--repo owner/repo]\n")
@ -1883,6 +1963,8 @@ def _print_skills_help(console: Console) -> None:
" [cyan]update[/] [name] Update hub skills with upstream changes\n"
" [cyan]audit[/] [name] Re-scan hub skills for security\n"
" [cyan]uninstall[/] <name> Remove a hub-installed skill\n"
" [cyan]list-modified[/] List bundled skills you've edited (kept by update)\n"
" [cyan]diff[/] <name> Diff your copy of a bundled skill vs the stock version\n"
" [cyan]reset[/] <name> [--restore] Reset bundled-skill tracking (fix 'user-modified' flag)\n"
" [cyan]publish[/] <path> --repo <r> Publish a skill to GitHub via PR\n"
" [cyan]snapshot[/] export|import Export/import skill configurations\n"

View file

@ -164,6 +164,35 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None:
help="Skip confirmation prompt when using --restore",
)
skills_list_modified = skills_subparsers.add_parser(
"list-modified",
help="List bundled skills you've edited (which `hermes update` keeps)",
description=(
"Show the bundled skills whose local copy differs from the version last "
"synced, i.e. the ones `hermes update` reports as user-modified and skips. "
"Use `hermes skills diff <name>` to see changes and `hermes skills reset "
"<name>` to resume updates."
),
)
skills_list_modified.add_argument(
"--json",
action="store_true",
help="Output the list as JSON",
)
skills_diff = skills_subparsers.add_parser(
"diff",
help="Show how your copy of a bundled skill differs from the stock version",
description=(
"Print a unified diff between your local copy of a bundled skill and the "
"current bundled (stock) version, so you can confirm what changed before "
"running `hermes skills reset`."
),
)
skills_diff.add_argument(
"name", help="Skill name to diff (e.g. google-workspace)"
)
skills_opt_out = skills_subparsers.add_parser(
"opt-out",
help="Stop bundled skills from being seeded into this profile",

View file

@ -62,6 +62,11 @@ from hermes_cli.config import (
recommended_update_command_for_method,
redact_key,
)
from hermes_cli.memory_providers import (
MemoryProvider,
ProviderField,
get_memory_provider,
)
from gateway.status import (
get_running_pid,
get_runtime_status_running_pid,
@ -139,6 +144,11 @@ def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60
async def _lifespan(app: "FastAPI"):
app.state.event_channels = {} # dict[str, set]
app.state.event_lock = asyncio.Lock()
# Serializes chat-argv resolution so concurrent /api/pty connections
# don't trigger overlapping ``npm install`` / ``npm run build`` work.
# On app.state (not a module global) so the Lock binds to the running
# event loop during lifespan startup — see _get_event_state's docstring.
app.state.chat_argv_lock = asyncio.Lock()
# Desktop-spawned backends (HERMES_DESKTOP=1) fire cron jobs themselves,
# since the app has no gateway running the scheduler. Server `hermes
@ -179,6 +189,20 @@ def _get_event_state(app: "FastAPI"):
return app.state.event_channels, app.state.event_lock
def _get_chat_argv_lock(app: "FastAPI") -> asyncio.Lock:
"""Return the chat-argv resolution lock from app.state.
Mirrors :func:`_get_event_state`: prefers the lifespan-initialised Lock
(created on the correct event loop) but lazily initialises it for
non-``with`` TestClient usages.
"""
try:
return app.state.chat_argv_lock
except AttributeError:
app.state.chat_argv_lock = asyncio.Lock()
return app.state.chat_argv_lock
app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)
# ---------------------------------------------------------------------------
@ -670,6 +694,10 @@ class EnvVarReveal(BaseModel):
profile: Optional[str] = None
class MemoryProviderConfigUpdate(BaseModel):
values: Dict[str, str] = {}
class MessagingPlatformUpdate(BaseModel):
enabled: Optional[bool] = None
env: Dict[str, str] = {}
@ -3160,6 +3188,160 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
return config
def _memory_provider_config_path(provider: MemoryProvider) -> Path:
return get_hermes_home() / provider.name / "config.json"
def _read_memory_provider_file(provider: MemoryProvider) -> Dict[str, Any]:
path = _memory_provider_config_path(provider)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
_log.warning("Failed to read memory provider config from %s", path, exc_info=True)
return {}
return data if isinstance(data, dict) else {}
def _read_field_value(field: ProviderField, data: Dict[str, Any]) -> str:
"""Resolve the stored value for a non-secret field, honoring legacy reads."""
for source_key in (field.key, *field.aliases):
value = data.get(source_key)
if value:
return str(value)
env_on_disk = load_env()
for env_key in field.env_fallbacks:
value = env_on_disk.get(env_key)
if value:
return str(value)
return field.default
def _field_is_set(field: ProviderField, data: Dict[str, Any]) -> bool:
"""Whether a secret field has a value anywhere it may have been written."""
env_on_disk = load_env()
for env_key in (field.env_key, *field.env_fallbacks):
if env_key and env_on_disk.get(env_key):
return True
return any(data.get(source_key) for source_key in (field.key, *field.aliases))
def _memory_provider_payload(provider: MemoryProvider) -> Dict[str, Any]:
data = _read_memory_provider_file(provider)
fields: List[Dict[str, Any]] = []
for field in provider.fields:
entry: Dict[str, Any] = {
"key": field.key,
"label": field.label,
"kind": field.kind,
"description": field.description,
"placeholder": field.placeholder,
"options": [
{"value": opt.value, "label": opt.label, "description": opt.description}
for opt in field.options
],
}
if field.is_secret:
# Secrets are write-only over the API; only expose whether one is set.
entry["value"] = ""
entry["is_set"] = _field_is_set(field, data)
else:
value = _read_field_value(field, data)
if field.kind == "select" and value not in field.allowed_values():
value = field.default
entry["value"] = value
entry["is_set"] = bool(value)
fields.append(entry)
return {"name": provider.name, "label": provider.label, "fields": fields}
def _coerce_field_value(field: ProviderField, raw: str) -> str:
"""Validate and normalize a submitted non-secret value, or raise ValueError."""
value = (raw or "").strip()
if field.kind == "select":
if not value:
value = field.default
if value not in field.allowed_values():
raise ValueError(f"Invalid value for '{field.key}'")
return value
return value or field.default
@app.get("/api/memory/providers/{name}/config")
async def get_memory_provider_config(name: str):
provider = get_memory_provider(name)
if provider is None:
# Undeclared providers (e.g. builtin) have no config surface. Return an
# empty schema so the generic panel simply renders nothing.
return {"name": name, "label": name, "fields": []}
return _memory_provider_payload(provider)
@app.put("/api/memory/providers/{name}/config")
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate):
provider = get_memory_provider(name)
if provider is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
values = body.values or {}
try:
existing = _read_memory_provider_file(provider)
json_values: Dict[str, Any] = {}
secrets: Dict[str, str] = {}
for field in provider.fields:
if field.is_secret:
submitted = (values.get(field.key) or "").strip()
if submitted and field.env_key:
secrets[field.env_key] = submitted
continue
raw = (
values[field.key]
if field.key in values
else str(existing.get(field.key, field.default))
)
json_values[field.key] = _coerce_field_value(field, raw)
config = load_config()
memory_config = config.get("memory")
if not isinstance(memory_config, dict):
memory_config = {}
config["memory"] = memory_config
memory_config["provider"] = provider.name
save_config(config)
path = _memory_provider_config_path(provider)
path.parent.mkdir(parents=True, exist_ok=True)
existing.update(json_values)
from utils import atomic_json_write
atomic_json_write(path, existing, mode=0o600)
for env_key, secret in secrets.items():
save_env_value(env_key, secret)
return {"ok": True}
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception:
_log.exception("PUT /api/memory/providers/%s/config failed", name)
raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/api/config")
async def get_config(profile: Optional[str] = None):
with _profile_scope(profile):
@ -3294,7 +3476,7 @@ _AUX_TASK_SLOTS: Tuple[str, ...] = (
@app.get("/api/model/options")
def get_model_options(profile: Optional[str] = None):
def get_model_options(profile: Optional[str] = None, refresh: bool = False):
"""Return authenticated providers + their curated model lists.
REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the
@ -3305,6 +3487,10 @@ def get_model_options(profile: Optional[str] = None):
``profile`` scopes the picker context (current model/provider, custom
providers from config, per-profile .env auth state) so the Models page
reads the SAME profile /api/model/set writes.
``refresh`` busts the per-provider model-id disk cache so every row
re-fetches its live catalog used by the picker's explicit "Refresh
Models" control. Normal opens leave it false to stay on the 1h cache.
"""
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
@ -3320,12 +3506,12 @@ def get_model_options(profile: Optional[str] = None):
with _profile_scope(profile):
return build_models_payload(
load_picker_context(),
max_models=50,
include_unconfigured=True,
picker_hints=True,
canonical_order=True,
pricing=True,
capabilities=True,
refresh=bool(refresh),
)
except HTTPException:
raise
@ -3395,7 +3581,7 @@ def get_recommended_default_model(provider: str = ""):
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
payload = build_models_payload(load_picker_context(), max_models=50)
payload = build_models_payload(load_picker_context())
for row in payload.get("providers", []):
if str(row.get("slug", "")).lower() == slug:
models = row.get("models") or []
@ -6884,8 +7070,19 @@ async def delete_session_endpoint(session_id: str, profile: Optional[str] = None
# desktop routes their DELETE to the remote backend. Omit for current/default.
db = _open_session_db_for_profile(profile)
try:
if not db.delete_session(session_id):
raise HTTPException(status_code=404, detail="Session not found")
# Resolve exact ids / unique prefixes like every other session endpoint
# (detail, messages, rename, export all do). A session that no longer
# exists is an idempotent success: DELETE's contract is "ensure it's
# gone", and the desktop optimistically removes the row then RESTORES it
# on any error — so a 404 on an already-absent row resurrected a ghost
# row and surfaced "session not found". /goal + auto-compression churn
# leaves transient empty rows (reaped by empty-session hygiene) that
# race the sidebar snapshot, which is exactly when this fired. Mirrors
# the bulk-delete endpoint, which already treats ghost ids as success.
sid = db.resolve_session_id(session_id)
if not sid:
return {"ok": True, "already_absent": True}
db.delete_session(sid)
return {"ok": True}
finally:
db.close()
@ -7677,17 +7874,35 @@ async def list_mcp_catalog(profile: Optional[str] = None):
}
for entry in catalog_entries:
auth = entry.auth
transport = entry.transport
install = entry.install
entries.append({
"name": entry.name,
"description": entry.description,
"source": entry.source,
"transport": entry.transport.type,
"transport": 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 []
],
# Transport details so the UI can show exactly what connects/runs.
# The trust model (docs: user-guide/features/mcp) tells users to
# inspect command/args/url and the install bootstrap before
# installing — surface them rather than hiding them in the repo.
"command": transport.command,
"args": list(transport.args or []),
"url": transport.url,
# Git bootstrap (present only for entries that clone + build).
"install_url": install.url if install else None,
"install_ref": install.ref if install else None,
"bootstrap": list(install.bootstrap) if install else [],
# Default tool pre-selection hint and post-install guidance.
"default_enabled": list(entry.tools.default_enabled)
if entry.tools.default_enabled is not None
else None,
"post_install": entry.post_install or "",
"needs_install": entry.install is not None,
"installed": installed_state.get(entry.name, (False, False))[0],
"enabled": installed_state.get(entry.name, (False, False))[1],
@ -10638,7 +10853,8 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
# the chat tab generates on mount; entries auto-evict when the last subscriber
# drops AND the publisher has disconnected.
# (State is initialised in _lifespan on app startup — see above.)
# (Channel state and the chat-argv lock are initialised in _lifespan on app
# startup — see _get_event_state / _get_chat_argv_lock above.)
def _resolve_chat_argv(
@ -10755,6 +10971,30 @@ def _build_gateway_ws_url() -> Optional[str]:
return f"ws://{netloc}/api/ws?{qs}"
async def _resolve_chat_argv_async(
resume: Optional[str] = None,
sidecar_url: Optional[str] = None,
profile: Optional[str] = None,
) -> tuple[list[str], Optional[str], Optional[dict]]:
"""Resolve chat argv without blocking the dashboard event loop.
``_resolve_chat_argv`` may run ``npm install`` / ``npm run build`` through
``_make_tui_argv``. Keep that synchronous work off the WebSocket event
loop so reverse proxies and existing dashboard connections can continue
to exchange keepalives while the TUI launch command is prepared. The
async lock preserves the previous one-build-at-a-time behavior when
multiple browser tabs connect at once without occupying worker threads
while queued connections wait.
"""
async with _get_chat_argv_lock(app):
return await asyncio.to_thread(
_resolve_chat_argv,
resume=resume,
sidecar_url=sidecar_url,
profile=profile,
)
def _build_sidecar_url(channel: str) -> Optional[str]:
"""ws:// URL the PTY child should publish events to, or None when unbound.
@ -10885,7 +11125,7 @@ async def pty_ws(ws: WebSocket) -> None:
sidecar_url = _build_sidecar_url(channel) if channel else None
try:
argv, cwd, env = _resolve_chat_argv(
argv, cwd, env = await _resolve_chat_argv_async(
resume=resume, sidecar_url=sidecar_url, profile=profile
)
except HTTPException as exc:

View file

@ -684,6 +684,7 @@ class SessionDB:
self._lock = threading.Lock()
self._write_count = 0
self._fts_enabled = False
self._trigram_available = False
self._fts_unavailable_warned = False
self._conn = None
try:
@ -772,7 +773,33 @@ class SessionDB:
@staticmethod
def _is_fts5_unavailable_error(exc: sqlite3.OperationalError) -> bool:
err = str(exc).lower()
return "no such module" in err and "fts5" in err
if "no such module" in err and "fts5" in err:
return True
# SQLite builds that have FTS5 but lack the optional trigram tokenizer
# raise "no such tokenizer: trigram" instead of "no such module".
# Scope to trigram specifically to avoid masking unrelated tokenizer errors.
if "no such tokenizer: trigram" in err:
return True
return False
@staticmethod
def _is_trigram_unavailable_error(exc: sqlite3.OperationalError) -> bool:
"""True when only the trigram tokenizer is missing (FTS5 itself works)."""
return "no such tokenizer: trigram" in str(exc).lower()
def _warn_trigram_unavailable(self, exc: sqlite3.OperationalError) -> None:
"""Log once that the trigram tokenizer is missing; base FTS5 stays enabled."""
if getattr(self, "_trigram_unavailable_warned", False):
return
self._trigram_unavailable_warned = True
logger.info(
"SQLite trigram tokenizer unavailable for %s "
"(requires SQLite >= 3.34, this build is %s); "
"CJK/substring search will fall back to LIKE: %s",
self.db_path,
sqlite3.sqlite_version,
exc,
)
def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None:
self._fts_enabled = False
@ -818,9 +845,12 @@ class SessionDB:
return int(row[0] if not isinstance(row, sqlite3.Row) else row[0])
@staticmethod
def _rebuild_fts_indexes(cursor: sqlite3.Cursor) -> None:
for table_name in ("messages_fts", "messages_fts_trigram"):
cursor.execute(f"DELETE FROM {table_name}")
def _rebuild_fts_indexes(
cursor: sqlite3.Cursor,
*,
include_trigram: bool = True,
) -> None:
cursor.execute("DELETE FROM messages_fts")
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
@ -829,6 +859,9 @@ class SessionDB:
"COALESCE(tool_calls, '') "
"FROM messages"
)
if not include_trigram:
return
cursor.execute("DELETE FROM messages_fts_trigram")
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
@ -844,7 +877,12 @@ class SessionDB:
return True
except sqlite3.OperationalError as exc:
if self._is_fts5_unavailable_error(exc):
self._warn_fts5_unavailable(exc)
# Only disable FTS entirely when the whole module is missing.
# A missing trigram tokenizer only affects trigram searches.
if self._is_trigram_unavailable_error(exc):
self._warn_trigram_unavailable(exc)
else:
self._warn_fts5_unavailable(exc)
return None
if "no such table" in str(exc).lower():
return False
@ -868,7 +906,13 @@ class SessionDB:
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
# Only disable FTS entirely when the whole FTS5 module is missing.
# A missing specific tokenizer (e.g. trigram) means only that
# particular table cannot be created — the base FTS5 table is fine.
if self._is_trigram_unavailable_error(exc):
self._warn_trigram_unavailable(exc)
else:
self._warn_fts5_unavailable(exc)
return False
def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T:
@ -1166,21 +1210,23 @@ class SessionDB:
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
fts5_available = False
fts_migrations_complete = False
if self._is_trigram_unavailable_error(exc):
self._warn_trigram_unavailable(exc)
else:
self._warn_fts5_unavailable(exc)
fts5_available = False
fts_migrations_complete = False
break
if fts5_available:
# Recreate virtual tables + triggers with the new inline-mode
# schema that indexes content || tool_name || tool_calls.
if (
self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL)
and self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
):
# Backfill both indexes from every existing messages row.
# Handle base and trigram independently — a missing
# trigram tokenizer should not prevent base FTS backfill.
base_fts_ok = self._ensure_fts_schema(
cursor, "messages_fts", FTS_SQL
)
if base_fts_ok:
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
@ -1189,6 +1235,10 @@ class SessionDB:
"COALESCE(tool_calls, '') "
"FROM messages"
)
trigram_ok = self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
if trigram_ok:
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
@ -1197,8 +1247,12 @@ class SessionDB:
"COALESCE(tool_calls, '') "
"FROM messages"
)
else:
if not base_fts_ok:
fts_migrations_complete = False
# Track trigram availability for CJK LIKE fallback.
self._trigram_available = trigram_ok
else:
fts_migrations_complete = False
else:
fts_migrations_complete = False
if current_version < 12:
@ -1268,8 +1322,12 @@ class SessionDB:
trigram_enabled = self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
if trigram_enabled and triggers_need_repair:
self._rebuild_fts_indexes(cursor)
self._trigram_available = trigram_enabled
if triggers_need_repair:
self._rebuild_fts_indexes(
cursor,
include_trigram=trigram_enabled,
)
self._conn.commit()
@ -2820,6 +2878,24 @@ class SessionDB:
if not session_id:
return session_id
# Follow the compression-continuation chain forward to the live tip
# FIRST. Auto-compression ends the current session and forks a
# continuation child, but a long-lived parent keeps its own flushed
# message rows — so the empty-head walk below never redirects it, and
# resuming the parent id reloads the pre-compression transcript while
# the turns generated *after* compression (and their responses) sit in
# the continuation. ``get_compression_tip`` is lineage-aware: it only
# follows children whose parent ended with ``end_reason='compression'``
# (created after the parent was ended), so delegation / branch children
# never hijack the resume. This is the fix for the desktop "I came back
# and the reply isn't there" report on large sessions.
try:
tip = self.get_compression_tip(session_id)
except Exception:
tip = session_id
if tip and tip != session_id:
session_id = tip
with self._lock:
# If this session already has messages, nothing to redirect.
try:
@ -3386,7 +3462,8 @@ class SessionDB:
self._count_cjk(t) < 3 for t in _tokens_for_check
)
if cjk_count >= 3 and not _any_short_cjk:
_trigram_succeeded = False
if cjk_count >= 3 and not _any_short_cjk and self._trigram_available:
# Trigram FTS5 path — quote each non-operator token to handle
# FTS5 special chars (%, *, etc.) while preserving boolean
# operators (AND, OR, NOT) for multi-term queries.
@ -3435,11 +3512,13 @@ class SessionDB:
try:
tri_cursor = self._conn.execute(tri_sql, tri_params)
except sqlite3.OperationalError:
matches = []
# Trigram query failed at runtime — fall through to LIKE.
pass
else:
matches = [dict(row) for row in tri_cursor.fetchall()]
else:
# Short / mixed CJK query: trigram cannot match tokens with
_trigram_succeeded = True
if not _trigram_succeeded:
# Short / mixed CJK query, trigram unavailable, or trigram
# <3 CJK chars. Fall back to LIKE substring search.
# For multi-token OR queries (e.g. "广西 OR 桂林 OR 漓江"),
# build one LIKE condition per non-operator token so each term

View file

@ -21,7 +21,7 @@ let
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-m9cjbjzi4SaFCjODfdrawS5e+1ag+MpRn528/upSNqo=";
npmDepsHash = "sha256-kbjJksq7limRIYqP3DwI+GNgCXkG96tXcsQqmuEedxo=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;

View file

@ -0,0 +1,54 @@
# Nous-approved MCP catalog entry.
# Presence in this directory = approval. Merged via PR review.
manifest_version: 1
name: unreal-engine
description: Drive the Unreal Engine 5.8 editor over its local MCP server.
source: https://dev.epicgames.com/documentation/unreal-engine/unreal-mcp-in-unreal-editor
# Epic's official "Unreal MCP" plugin (internal id ModelContextProtocol)
# embeds an MCP server inside the running Unreal Editor process and serves it
# over local HTTP. There is nothing to install on the Hermes side — the user
# enables the plugin in-editor and the server binds to 127.0.0.1. Hermes's
# MCP client just connects to the URL.
#
# Default bind is http://127.0.0.1:8000/mcp (port + path are configurable in
# Editor Preferences > General > Model Context Protocol). If you change the
# port/path in-editor, edit the url in mcp_servers.unreal-engine afterward.
transport:
type: http
url: http://127.0.0.1:8000/mcp
# The editor-embedded server accepts connections only from the same machine
# and has no authentication of its own (Epic's experimental design — not for
# remote use). Nothing to prompt for.
auth:
type: none
# Tool selection at install time:
# The plugin advertises engine tools (spawn actors, configure lighting, create
# material instances, inspect Slate widgets, run automation tests) and is
# user-extensible, so the exact surface depends on the project's enabled
# toolsets. Leave default_enabled unset — the install-time probe lists whatever
# the live editor exposes and pre-checks all of it; users prune from there.
post_install: |
This entry connects to Epic's official Unreal MCP plugin, which runs INSIDE
the Unreal Editor. Before Hermes can connect:
1. Open your project in Unreal Editor 5.8+.
2. Edit > Plugins, search "Unreal MCP", enable it, restart the editor
(the Toolset Registry dependency enables automatically).
3. Edit > Editor Preferences > General > Model Context Protocol, turn on
"Auto Start Server" (or run `ModelContextProtocol.StartServer` in the
editor console). It binds to http://127.0.0.1:8000/mcp by default.
Start Hermes AFTER the editor's server is running so the tools are probed.
If you changed the port or URL path in Editor Preferences, update the url in
mcp_servers.unreal-engine to match.
Status: Epic ships this as EXPERIMENTAL. The server runs Tool calls serially
on the engine game thread — avoid issuing overlapping calls.
Re-run the tool checklist any time with:
hermes mcp configure unreal-engine

View file

@ -26,13 +26,13 @@ Trigger phrases:
- "manage my stack credentials", "rotate this key", "upgrade my plan"
- "what providers can I add?"
If the user already has the service set up manually and just wants to use it, this skill is not the right entry point.
If the user already has a provider account, this skill can still connect it with `stripe projects link <provider>`. If the user wants to use an existing provider resource, such as an existing database or Vercel project, check provider support first; many providers currently support provisioning new resources but not importing existing ones.
## Prerequisites
- Stripe CLI installed (Homebrew on macOS, package manager on Linux, or download from https://docs.stripe.com/stripe-cli/install)
- Stripe Projects plugin installed
- A Stripe account, logged in via `stripe login`
- A Stripe account. If the user doesn't have one yet, the CLI can guide them through sign-in or account creation in the browser during setup.
## Install

24
package-lock.json generated
View file

@ -69,7 +69,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hermes/shared": "file:../shared",
"@icons-pack/react-simple-icons": "^13.13.0",
"@icons-pack/react-simple-icons": "=13.11.1",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
@ -173,6 +173,15 @@
"global-agent": "^3.0.0"
}
},
"apps/desktop/node_modules/@icons-pack/react-simple-icons": {
"version": "13.11.1",
"resolved": "https://registry.npmjs.org/@icons-pack/react-simple-icons/-/react-simple-icons-13.11.1.tgz",
"integrity": "sha512-WbwN/o7dUHEjDCJh2p3RvDZ4kZ8nhfUSkUSm0bWuPTXIsoKgDJpwD5UkMCG22R/5kZH6lHAZXwuHWsKNtX7fYA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.13 || ^17 || ^18 || ^19"
}
},
"apps/desktop/node_modules/@nous-research/ui": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.2.tgz",
@ -2285,19 +2294,6 @@
"import-meta-resolve": "^4.2.0"
}
},
"node_modules/@icons-pack/react-simple-icons": {
"version": "13.13.0",
"resolved": "https://registry.npmjs.org/@icons-pack/react-simple-icons/-/react-simple-icons-13.13.0.tgz",
"integrity": "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g==",
"license": "MIT",
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"peerDependencies": {
"react": "^16.13 || ^17 || ^18 || ^19"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",

View file

@ -87,7 +87,7 @@ class FalImageGenProvider(ImageGenProvider):
return {
"name": "FAL.ai",
"badge": "paid",
"tag": "Pick from flux-2-klein, flux-2-pro, gpt-image, nano-banana, etc.",
"tag": "Pick from flux-2-klein, flux-2-pro, gpt-image, nano-banana, etc. — text-to-image & image editing",
"env_vars": [
{
"key": "FAL_KEY",
@ -97,18 +97,40 @@ class FalImageGenProvider(ImageGenProvider):
],
}
def capabilities(self) -> Dict[str, Any]:
# Whether image-to-image is available depends on the currently-
# selected FAL model (each model entry declares an edit_endpoint or
# not). Report the active model's actual surface so the dynamic tool
# schema is accurate.
import tools.image_generation_tool as _it
try:
_model_id, meta = _it._resolve_fal_model()
except Exception: # noqa: BLE001
return {"modalities": ["text"], "max_reference_images": 0}
if meta.get("edit_endpoint"):
return {
"modalities": ["text", "image"],
"max_reference_images": int(meta.get("max_reference_images") or 1),
}
return {"modalities": ["text"], "max_reference_images": 0}
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
*,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""Generate an image via the legacy FAL pipeline.
"""Generate or edit an image via the legacy FAL pipeline.
Forwards prompt + aspect_ratio (and any forward-compat extras
the schema supports) into :func:`tools.image_generation_tool.image_generate_tool`,
then reshapes its JSON-string response into the provider-ABC
dict format consumed by ``_dispatch_to_plugin_provider``.
Forwards prompt + aspect_ratio + image_url/reference_image_urls (and
any forward-compat extras the schema supports) into
:func:`tools.image_generation_tool.image_generate_tool`, then reshapes
its JSON-string response into the provider-ABC dict format consumed by
``_dispatch_to_plugin_provider``.
"""
import tools.image_generation_tool as _it
@ -124,6 +146,13 @@ class FalImageGenProvider(ImageGenProvider):
)
if key in kwargs and kwargs[key] is not None
}
# Only forward the image-to-image inputs when actually supplied, so a
# plain text-to-image call delegates exactly as it did before (no
# noisy None kwargs).
if image_url is not None:
passthrough["image_url"] = image_url
if reference_image_urls is not None:
passthrough["reference_image_urls"] = reference_image_urls
try:
raw = _it.image_generate_tool(

View file

@ -33,6 +33,7 @@ from agent.image_gen_provider import (
DEFAULT_ASPECT_RATIO,
ImageGenProvider,
error_response,
normalize_reference_images,
resolve_aspect_ratio,
save_url_image,
success_response,
@ -191,7 +192,7 @@ class KreaImageGenProvider(ImageGenProvider):
return {
"name": "Krea",
"badge": "paid",
"tag": "Krea 2 foundation model — Medium ($0.03) + Large ($0.06). Strong style transfer + moodboards.",
"tag": "Krea 2 foundation model — Medium ($0.03) + Large ($0.06). Style transfer, moodboards, reference-guided generation.",
"env_vars": [
{
"key": "KREA_API_KEY",
@ -201,6 +202,11 @@ class KreaImageGenProvider(ImageGenProvider):
],
}
def capabilities(self) -> Dict[str, Any]:
# Krea supports reference-guided generation (image-to-image style
# transfer) via image_style_references — up to 10 refs.
return {"modalities": ["text", "image"], "max_reference_images": 10}
# ------------------------------------------------------------------
# generate()
# ------------------------------------------------------------------
@ -209,12 +215,48 @@ class KreaImageGenProvider(ImageGenProvider):
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
*,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
prompt = (prompt or "").strip()
aspect = resolve_aspect_ratio(aspect_ratio)
krea_ar = _ASPECT_MAP.get(aspect, "1:1")
# Collect reference images for reference-guided generation (image-to-
# image style transfer). Sources, in order:
# 1. unified image_url (primary source) + reference_image_urls (strings)
# 2. legacy image_style_references kwarg — may be plain URL strings OR
# Krea's richer ref objects (e.g. {"url": ..., "strength": ...}),
# which are passed through verbatim for backward compatibility.
style_refs: List[Any] = []
if isinstance(image_url, str) and image_url.strip():
style_refs.append(image_url.strip())
for ref in (normalize_reference_images(reference_image_urls) or []):
style_refs.append(ref)
legacy_refs = kwargs.get("image_style_references")
if isinstance(legacy_refs, list):
for ref in legacy_refs:
if isinstance(ref, str):
if ref.strip():
style_refs.append(ref.strip())
elif ref:
# Non-string ref object (dict, etc.) — pass through as-is.
style_refs.append(ref)
# Dedupe string entries while preserving order (dict refs aren't
# hashable, so they're kept verbatim); Krea caps at 10.
seen: set = set()
deduped: List[Any] = []
for r in style_refs:
if isinstance(r, str):
if r in seen:
continue
seen.add(r)
deduped.append(r)
style_refs = deduped[:10]
modality = "image" if style_refs else "text"
if not prompt:
return error_response(
error="Prompt is required and must be a non-empty string",
@ -256,10 +298,10 @@ class KreaImageGenProvider(ImageGenProvider):
if isinstance(styles, list) and styles:
payload["styles"] = styles
image_style_references = kwargs.get("image_style_references")
if isinstance(image_style_references, list) and image_style_references:
# Krea caps at 10 refs per request.
payload["image_style_references"] = image_style_references[:10]
if style_refs:
# Reference-guided generation (image-to-image style transfer).
# Krea caps at 10 refs per request (already clamped above).
payload["image_style_references"] = style_refs
moodboards = kwargs.get("moodboards")
if isinstance(moodboards, list) and moodboards:
@ -483,19 +525,19 @@ class KreaImageGenProvider(ImageGenProvider):
# Per Krea's job-lifecycle docs the completed payload exposes
# ``result.urls`` (an array). Fall back to a single ``url`` field
# for forward/backward compatibility.
image_url: Optional[str] = None
result_image_url: Optional[str] = None
urls = result.get("urls")
if isinstance(urls, list) and urls:
for candidate in urls:
if isinstance(candidate, str) and candidate.strip():
image_url = candidate.strip()
result_image_url = candidate.strip()
break
if image_url is None:
if result_image_url is None:
single = result.get("url")
if isinstance(single, str) and single.strip():
image_url = single.strip()
result_image_url = single.strip()
if image_url is None:
if result_image_url is None:
return error_response(
error="Krea result contained no image URL",
error_type="empty_response",
@ -508,14 +550,14 @@ class KreaImageGenProvider(ImageGenProvider):
# Materialise locally — Krea result URLs may expire, mirroring
# what we do for xAI / OpenAI URL responses (#26942).
try:
saved_path = save_url_image(image_url, prefix=f"krea_{model_id}")
saved_path = save_url_image(result_image_url, prefix=f"krea_{model_id}")
except Exception as exc: # noqa: BLE001
logger.warning(
"Krea image URL %s could not be cached (%s); falling back to bare URL.",
image_url,
result_image_url,
exc,
)
image_ref = image_url
image_ref = result_image_url
else:
image_ref = str(saved_path)
@ -534,6 +576,7 @@ class KreaImageGenProvider(ImageGenProvider):
prompt=prompt,
aspect_ratio=aspect,
provider="krea",
modality=modality,
extra=extra,
)

View file

@ -319,7 +319,7 @@ class OpenAICodexImageGenProvider(ImageGenProvider):
return {
"name": "OpenAI (Codex auth)",
"badge": "free",
"tag": "gpt-image-2 via ChatGPT/Codex OAuth — no API key required",
"tag": "gpt-image-2 via ChatGPT/Codex OAuth — no API key required (text-to-image only)",
"env_vars": [],
"post_setup_hint": (
"Sign in with `hermes auth codex` (or `hermes setup` → Codex) "
@ -327,15 +327,41 @@ class OpenAICodexImageGenProvider(ImageGenProvider):
),
}
def capabilities(self) -> Dict[str, Any]:
# The Codex Responses image_generation tool path is text-to-image
# only here. Image-to-image / editing via Codex OAuth is not wired —
# users who need editing should use the `openai` (API key), `fal`, or
# `xai` backends. Declaring text-only keeps the dynamic tool schema
# honest so the model doesn't attempt an unsupported edit.
return {"modalities": ["text"], "max_reference_images": 0}
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
*,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
prompt = (prompt or "").strip()
aspect = resolve_aspect_ratio(aspect_ratio)
# Image-to-image / editing is not supported on the Codex OAuth path.
# Surface a clear, actionable error instead of silently ignoring the
# source image and producing an unrelated picture.
if (isinstance(image_url, str) and image_url.strip()) or reference_image_urls:
return error_response(
error=(
"This model is not capable of image-to-image / editing. "
"Please provide a text-only prompt (drop image_url and "
"reference_image_urls)."
),
error_type="modality_unsupported",
provider="openai-codex",
aspect_ratio=aspect,
)
if not prompt:
return error_response(
error="Prompt is required and must be a non-empty string",

View file

@ -31,6 +31,7 @@ from agent.image_gen_provider import (
DEFAULT_ASPECT_RATIO,
ImageGenProvider,
error_response,
normalize_reference_images,
resolve_aspect_ratio,
save_b64_image,
save_url_image,
@ -117,13 +118,48 @@ def _resolve_model() -> Tuple[str, Dict[str, Any]]:
return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL]
# ---------------------------------------------------------------------------
# Source-image loading (for image-to-image / edit)
# ---------------------------------------------------------------------------
def _load_image_bytes(ref: str) -> Tuple[bytes, str]:
"""Load image bytes from a URL or local file path.
Returns ``(data, filename)``. Raises on any network / IO error so the
caller can surface a clean error_response.
"""
ref = ref.strip()
lower = ref.lower()
if lower.startswith(("http://", "https://")):
import requests
resp = requests.get(ref, timeout=60)
resp.raise_for_status()
name = ref.split("?", 1)[0].rsplit("/", 1)[-1] or "image.png"
return resp.content, name
if lower.startswith("data:"):
import base64
header, _, b64 = ref.partition(",")
ext = "png"
if "image/" in header:
ext = header.split("image/", 1)[1].split(";", 1)[0] or "png"
return base64.b64decode(b64), f"image.{ext}"
# Local file path.
with open(ref, "rb") as fh:
data = fh.read()
name = os.path.basename(ref) or "image.png"
return data, name
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
class OpenAIImageGenProvider(ImageGenProvider):
"""OpenAI ``images.generate`` backend — gpt-image-2 at low/medium/high."""
"""OpenAI ``images.generate`` / ``images.edit`` backend — gpt-image-2."""
@property
def name(self) -> str:
@ -161,7 +197,7 @@ class OpenAIImageGenProvider(ImageGenProvider):
return {
"name": "OpenAI",
"badge": "paid",
"tag": "gpt-image-2 at low/medium/high quality tiers",
"tag": "gpt-image-2 at low/medium/high quality tiers — text-to-image & image editing",
"env_vars": [
{
"key": "OPENAI_API_KEY",
@ -171,10 +207,18 @@ class OpenAIImageGenProvider(ImageGenProvider):
],
}
def capabilities(self) -> Dict[str, Any]:
# gpt-image-2 supports editing via images.edit() with up to 16 source
# images.
return {"modalities": ["text", "image"], "max_reference_images": 16}
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
*,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
prompt = (prompt or "").strip()
@ -213,29 +257,82 @@ class OpenAIImageGenProvider(ImageGenProvider):
tier_id, meta = _resolve_model()
size = _SIZES.get(aspect, _SIZES["square"])
# gpt-image-2 returns b64_json unconditionally and REJECTS
# ``response_format`` as an unknown parameter. Don't send it.
payload: Dict[str, Any] = {
"model": API_MODEL,
"prompt": prompt,
"size": size,
"n": 1,
"quality": meta["quality"],
}
# Collect source images (primary + references) for image-to-image.
sources: List[str] = []
if isinstance(image_url, str) and image_url.strip():
sources.append(image_url.strip())
for ref in (normalize_reference_images(reference_image_urls) or []):
sources.append(ref)
sources = sources[:16] # gpt-image-2 edit caps at 16 images
is_edit = bool(sources)
modality = "image" if is_edit else "text"
try:
client = openai.OpenAI()
response = client.images.generate(**payload)
except Exception as exc:
logger.debug("OpenAI image generation failed", exc_info=True)
return error_response(
error=f"OpenAI image generation failed: {exc}",
error_type="api_error",
provider="openai",
model=tier_id,
prompt=prompt,
aspect_ratio=aspect,
)
client = openai.OpenAI()
if is_edit:
# images.edit() expects file-like objects. Download/read each
# source into a named BytesIO so the SDK sends correct multipart.
import io
try:
files = []
for ref in sources:
data, fname = _load_image_bytes(ref)
bio = io.BytesIO(data)
bio.name = fname
files.append(bio)
except Exception as exc:
return error_response(
error=f"Could not load source image for editing: {exc}",
error_type="io_error",
provider="openai",
model=tier_id,
prompt=prompt,
aspect_ratio=aspect,
)
try:
response = client.images.edit(
model=API_MODEL,
image=files if len(files) > 1 else files[0],
prompt=prompt,
size=size, # type: ignore[arg-type] # _SIZES values are valid gpt-image sizes
quality=meta["quality"],
n=1,
)
except Exception as exc:
logger.debug("OpenAI image edit failed", exc_info=True)
return error_response(
error=f"OpenAI image editing failed: {exc}",
error_type="api_error",
provider="openai",
model=tier_id,
prompt=prompt,
aspect_ratio=aspect,
)
else:
# gpt-image-2 returns b64_json unconditionally and REJECTS
# ``response_format`` as an unknown parameter. Don't send it.
payload: Dict[str, Any] = {
"model": API_MODEL,
"prompt": prompt,
"size": size,
"n": 1,
"quality": meta["quality"],
}
try:
response = client.images.generate(**payload)
except Exception as exc:
logger.debug("OpenAI image generation failed", exc_info=True)
return error_response(
error=f"OpenAI image generation failed: {exc}",
error_type="api_error",
provider="openai",
model=tier_id,
prompt=prompt,
aspect_ratio=aspect,
)
data = getattr(response, "data", None) or []
if not data:
@ -302,6 +399,7 @@ class OpenAIImageGenProvider(ImageGenProvider):
prompt=prompt,
aspect_ratio=aspect,
provider="openai",
modality=modality,
extra=extra,
)

View file

@ -27,6 +27,7 @@ from agent.image_gen_provider import (
DEFAULT_ASPECT_RATIO,
ImageGenProvider,
error_response,
normalize_reference_images,
resolve_aspect_ratio,
save_b64_image,
save_url_image,
@ -114,6 +115,31 @@ def _resolve_resolution() -> str:
return DEFAULT_RESOLUTION
def _xai_image_field(source: str) -> Dict[str, str]:
"""Build the xAI ``image`` field for an edit request.
xAI's ``/v1/images/edits`` accepts ``{"url": <ref>, "type": "image_url"}``
where ``<ref>`` is a public URL or a base64 data URI. Public URLs and
existing data URIs pass through unchanged; local file paths are read and
encoded into a ``data:`` URI.
"""
source = source.strip()
lower = source.lower()
if lower.startswith(("http://", "https://", "data:")):
return {"url": source, "type": "image_url"}
# Local file path → base64 data URI.
import base64
import os as _os
with open(source, "rb") as fh:
raw = fh.read()
ext = (_os.path.splitext(source)[1].lstrip(".") or "png").lower()
if ext == "jpg":
ext = "jpeg"
b64 = base64.b64encode(raw).decode("utf-8")
return {"url": f"data:image/{ext};base64,{b64}", "type": "image_url"}
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
@ -153,18 +179,34 @@ class XAIImageGenProvider(ImageGenProvider):
return {
"name": "xAI Grok Imagine (image)",
"badge": "paid",
"tag": "grok-imagine-image — text-to-image; uses xAI Grok OAuth or XAI_API_KEY",
"tag": "grok-imagine-image — text-to-image & image editing; uses xAI Grok OAuth or XAI_API_KEY",
"env_vars": [],
"post_setup": "xai_grok",
}
def capabilities(self) -> Dict[str, Any]:
# xAI's /v1/images/edits supports image editing via grok-imagine-image
# -quality. Single primary source image (multi-image editing exists as
# a separate capability but we keep the primary edit surface here).
return {"modalities": ["text", "image"], "max_reference_images": 1}
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
*,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
"""Generate an image using xAI's grok-imagine-image."""
"""Generate an image (text-to-image) or edit a source image (image-to-image).
Routing: when ``image_url`` is provided, POST to ``/v1/images/edits``
with the source image; otherwise POST to ``/v1/images/generations``.
Per xAI docs, editing uses the ``grok-imagine-image-quality`` model and
a JSON body (the OpenAI SDK's multipart ``images.edit()`` is NOT
supported by xAI).
"""
creds = resolve_xai_http_credentials()
api_key = str(creds.get("api_key") or "").strip()
provider_name = str(creds.get("provider") or "xai").strip() or "xai"
@ -182,12 +224,17 @@ class XAIImageGenProvider(ImageGenProvider):
resolution = _resolve_resolution()
xai_res = resolution if resolution in _XAI_RESOLUTIONS else DEFAULT_RESOLUTION
payload: Dict[str, Any] = {
"model": model_id,
"prompt": prompt,
"aspect_ratio": xai_ar,
"resolution": xai_res,
}
# Pick the primary source image: explicit image_url wins, else the
# first reference image.
source_image = None
if isinstance(image_url, str) and image_url.strip():
source_image = image_url.strip()
else:
refs = normalize_reference_images(reference_image_urls)
if refs:
source_image = refs[0]
is_edit = bool(source_image)
modality = "image" if is_edit else "text"
headers = {
"Authorization": f"Bearer {api_key}",
@ -197,9 +244,41 @@ class XAIImageGenProvider(ImageGenProvider):
base_url = str(creds.get("base_url") or "https://api.x.ai/v1").strip().rstrip("/")
if is_edit:
# Editing requires the quality model per xAI docs. The source
# image may be a public URL or a base64 data URI; local file paths
# are converted to a data URI here.
edit_model = "grok-imagine-image-quality"
try:
image_field = _xai_image_field(source_image)
except Exception as exc:
return error_response(
error=f"Could not load source image for editing: {exc}",
error_type="io_error",
provider=provider_name,
model=edit_model,
prompt=prompt,
aspect_ratio=aspect,
)
payload: Dict[str, Any] = {
"model": edit_model,
"prompt": prompt,
"image": image_field,
}
endpoint_url = f"{base_url}/images/edits"
model_id = edit_model
else:
payload = {
"model": model_id,
"prompt": prompt,
"aspect_ratio": xai_ar,
"resolution": xai_res,
}
endpoint_url = f"{base_url}/images/generations"
try:
response = requests.post(
f"{base_url}/images/generations",
endpoint_url,
headers=headers,
json=payload,
timeout=120,
@ -310,9 +389,9 @@ class XAIImageGenProvider(ImageGenProvider):
aspect_ratio=aspect,
)
extra: Dict[str, Any] = {
"resolution": xai_res,
}
extra: Dict[str, Any] = {}
if not is_edit:
extra["resolution"] = xai_res
return success_response(
image=image_ref,
@ -320,6 +399,7 @@ class XAIImageGenProvider(ImageGenProvider):
prompt=prompt,
aspect_ratio=aspect,
provider="xai",
modality=modality,
extra=extra,
)

View file

@ -31,10 +31,14 @@ All config via environment variables in `.env`:
| Env Var | Default | Description |
|---------|---------|-------------|
| `OPENVIKING_ENDPOINT` | `http://127.0.0.1:1933` | Server URL |
| `OPENVIKING_API_KEY` | (none) | API key (optional) |
| `OPENVIKING_ACCOUNT` | (none) | Tenant account override |
| `OPENVIKING_USER` | (none) | Tenant user override |
| `OPENVIKING_AGENT` | `hermes` | Tenant agent namespace |
| `OPENVIKING_API_KEY` | (none) | User/admin API key for authenticated servers |
| `OPENVIKING_ACCOUNT` | `default` | Tenant account for local/trusted mode |
| `OPENVIKING_USER` | `default` | Tenant user for local/trusted mode |
| `OPENVIKING_AGENT` | `hermes` | Hermes peer ID in OpenViking, used for peer-scoped memories |
When `OPENVIKING_API_KEY` is set, Hermes lets OpenViking derive account/user
identity from the key. In local or trusted deployments without an API key,
Hermes sends `OPENVIKING_ACCOUNT` and `OPENVIKING_USER` as identity headers.
## Tools

View file

@ -11,9 +11,9 @@ Config via environment variables (profile-scoped via each profile's .env)
or a linked OpenViking CLI config:
OPENVIKING_ENDPOINT Server URL (default: http://127.0.0.1:1933)
OPENVIKING_API_KEY API key (required for authenticated servers)
OPENVIKING_ACCOUNT Optional tenant account override
OPENVIKING_USER Optional tenant user override
OPENVIKING_AGENT Tenant agent (default: hermes)
OPENVIKING_ACCOUNT Tenant account for local/trusted mode (default: default)
OPENVIKING_USER Tenant user for local/trusted mode (default: default)
OPENVIKING_AGENT Hermes peer ID in OpenViking (default: hermes)
Capabilities:
- Automatic memory extraction on session commit (6 categories)
@ -55,6 +55,7 @@ logger = logging.getLogger(__name__)
_DEFAULT_ENDPOINT = "http://127.0.0.1:1933"
_OPENVIKING_SERVICE_ENDPOINT = "https://api.vikingdb.cn-beijing.volces.com/openviking"
_DEFAULT_AGENT = "hermes"
_AGENT_PROMPT_LABEL = "Hermes peer ID in OpenViking"
_OVCLI_CONFIG_ENV = "OPENVIKING_CLI_CONFIG_FILE"
_OVCLI_DEFAULT_RELATIVE_PATH = ".openviking/ovcli.conf"
_OVCLI_SAVED_PREFIX = "ovcli.conf."
@ -200,10 +201,9 @@ class _VikingClient:
agent: Optional[str] = None):
self._endpoint = endpoint.rstrip("/")
self._api_key = api_key
# Empty account/user fall back to "default" and the tenant headers are
# always sent — ROOT API keys require them (preserves the merged
# contract from #22414/#21232; an empty string must NOT omit the
# header). Use `or` (not `is not None`) so "" also falls back.
# Account/user are local/trusted-mode tenant identity. API-key requests
# omit these headers by default; trusted-mode retry may send them only
# after OpenViking explicitly asks for asserted tenant identity.
self._account = account or os.environ.get("OPENVIKING_ACCOUNT", "default")
self._user = user or os.environ.get("OPENVIKING_USER", "default")
self._agent = agent if agent is not None else os.environ.get("OPENVIKING_AGENT", _DEFAULT_AGENT)
@ -211,15 +211,18 @@ class _VikingClient:
if self._httpx is None:
raise ImportError("httpx is required for OpenViking: pip install httpx")
def _headers(self) -> dict:
def _headers(self, *, include_tenant: bool | None = None) -> dict:
if include_tenant is None:
include_tenant = not bool(self._api_key)
h = {"Content-Type": "application/json"}
if self._agent:
h["X-OpenViking-Actor-Peer"] = self._agent
h["X-OpenViking-Agent"] = self._agent
if self._account:
h["X-OpenViking-Account"] = self._account
if self._user:
h["X-OpenViking-User"] = self._user
if include_tenant:
if self._account:
h["X-OpenViking-Account"] = self._account
if self._user:
h["X-OpenViking-User"] = self._user
if self._api_key:
h["X-API-Key"] = self._api_key
h["Authorization"] = "Bearer " + self._api_key
@ -228,11 +231,33 @@ class _VikingClient:
def _url(self, path: str) -> str:
return f"{self._endpoint}{path}"
def _multipart_headers(self) -> dict:
headers = self._headers()
def _multipart_headers(self, *, include_tenant: bool | None = None) -> dict:
headers = self._headers(include_tenant=include_tenant)
headers.pop("Content-Type", None)
return headers
@staticmethod
def _needs_trusted_identity_retry(exc: Exception) -> bool:
message = str(exc)
return (
"Trusted mode requests must include X-OpenViking-Account" in message
or "Trusted mode requests must include X-OpenViking-User" in message
or "Trusted mode requests must include X-OpenViking-Account or explicit account_id" in message
)
def _send_with_trusted_identity_retry(self, send, *, multipart: bool = False) -> dict:
try:
headers = self._multipart_headers() if multipart else self._headers()
return self._parse_response(send(headers))
except Exception as exc:
if not self._api_key or not self._needs_trusted_identity_retry(exc):
raise
headers = (
self._multipart_headers(include_tenant=True)
if multipart else self._headers(include_tenant=True)
)
return self._parse_response(send(headers))
def _parse_response(self, resp) -> dict:
try:
data = resp.json()
@ -267,28 +292,33 @@ class _VikingClient:
return data
def get(self, path: str, **kwargs) -> dict:
resp = self._httpx.get(
self._url(path), headers=self._headers(), timeout=_TIMEOUT, **kwargs
return self._send_with_trusted_identity_retry(
lambda headers: self._httpx.get(
self._url(path), headers=headers, timeout=_TIMEOUT, **kwargs
)
)
return self._parse_response(resp)
def post(self, path: str, payload: dict = None, **kwargs) -> dict:
resp = self._httpx.post(
self._url(path), json=payload or {}, headers=self._headers(),
timeout=_TIMEOUT, **kwargs
return self._send_with_trusted_identity_retry(
lambda headers: self._httpx.post(
self._url(path), json=payload or {}, headers=headers,
timeout=_TIMEOUT, **kwargs
)
)
return self._parse_response(resp)
def upload_temp_file(self, file_path: Path) -> str:
mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
with file_path.open("rb") as f:
resp = self._httpx.post(
self._url("/api/v1/resources/temp_upload"),
files={"file": (file_path.name, f, mime_type)},
headers=self._multipart_headers(),
timeout=_TIMEOUT,
)
data = self._parse_response(resp)
def _send(headers):
with file_path.open("rb") as f:
return self._httpx.post(
self._url("/api/v1/resources/temp_upload"),
files={"file": (file_path.name, f, mime_type)},
headers=headers,
timeout=_TIMEOUT,
)
data = self._send_with_trusted_identity_retry(_send, multipart=True)
result = data.get("result", {})
temp_file_id = result.get("temp_file_id", "")
if not temp_file_id:
@ -1219,7 +1249,7 @@ def _prompt_manual_connection_values(prompt, select, cancelled, *, service: bool
return _SETUP_CANCELLED
if credential_choice == 0:
values["agent"] = _clean_config_value(
prompt("OpenViking agent", default=_DEFAULT_AGENT)
prompt(_AGENT_PROMPT_LABEL, default=_DEFAULT_AGENT)
) or _DEFAULT_AGENT
_print_validation_progress("Validating OpenViking local dev access...")
valid, message, _role = _validate_openviking_setup_values(values)
@ -1339,7 +1369,7 @@ def _prompt_manual_connection_values(prompt, select, cancelled, *, service: bool
prefilled_agent = ""
else:
values["agent"] = _clean_config_value(
prompt("OpenViking agent", default=_DEFAULT_AGENT)
prompt(_AGENT_PROMPT_LABEL, default=_DEFAULT_AGENT)
) or _DEFAULT_AGENT
_print_validation_progress("Validating OpenViking API access...")
valid, message, role = _validate_openviking_setup_values(
@ -1697,7 +1727,10 @@ class OpenVikingMemoryProvider(MemoryProvider):
},
{
"key": "agent",
"description": "OpenViking agent ID within the account ([hermes], useful in multi-agent mode)",
"description": (
"Hermes peer ID in OpenViking, sent as the actor peer and "
"used for peer-scoped memories"
),
"default": "hermes",
"env_var": "OPENVIKING_AGENT",
},
@ -2129,18 +2162,22 @@ class OpenVikingMemoryProvider(MemoryProvider):
def _text_part(content: str) -> Dict[str, str]:
return {"type": "text", "text": content}
@classmethod
def _turn_batch_payload(cls, user_content: str, assistant_content: str) -> Dict[str, Any]:
def _turn_batch_payload(self, user_content: str, assistant_content: str) -> Dict[str, Any]:
assistant_message: Dict[str, Any] = {
"role": "assistant",
"parts": [self._text_part(assistant_content)],
}
if self._agent:
assistant_message["peer_id"] = self._agent
return {
"messages": [
{"role": "user", "parts": [cls._text_part(user_content)]},
{"role": "assistant", "parts": [cls._text_part(assistant_content)]},
{"role": "user", "parts": [self._text_part(user_content)]},
assistant_message,
]
}
@classmethod
def _post_session_turn(
cls,
self,
client: _VikingClient,
sid: str,
user_content: str,
@ -2148,7 +2185,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
) -> None:
client.post(
f"/api/v1/sessions/{sid}/messages/batch",
cls._turn_batch_payload(user_content, assistant_content),
self._turn_batch_payload(user_content, assistant_content),
)
def _session_has_pending_tokens(self, sid: str) -> bool:
@ -2402,9 +2439,9 @@ class OpenVikingMemoryProvider(MemoryProvider):
)
def _build_memory_uri(self, subdir: str) -> str:
"""Build a viking:// memory URI under the configured user/agent/subdir."""
"""Build a viking:// memory URI under the configured peer namespace."""
slug = uuid.uuid4().hex[:12]
return f"viking://user/{self._user}/agent/{self._agent}/memories/{subdir}/mem_{slug}.md"
return f"viking://user/peers/{self._agent}/memories/{subdir}/mem_{slug}.md"
def on_memory_write(
self,
@ -2535,14 +2572,16 @@ class OpenVikingMemoryProvider(MemoryProvider):
payload: Dict[str, Any] = {"query": query}
mode = args.get("mode", "auto")
if mode != "auto":
payload["mode"] = mode
if args.get("scope"):
payload["target_uri"] = args["scope"]
if args.get("limit"):
payload["limit"] = args["limit"]
resp = self._client.post("/api/v1/search/find", payload)
endpoint = "/api/v1/search/search" if mode == "deep" else "/api/v1/search/find"
if endpoint == "/api/v1/search/search" and self._session_id:
payload["session_id"] = self._session_id
resp = self._client.post(endpoint, payload)
result = resp.get("result", {})
# Format results for the model — keep it concise

View file

@ -54,6 +54,15 @@ class TraceState:
_STATE_LOCK = threading.Lock()
_TRACE_STATE: Dict[str, TraceState] = {}
# Hard cap on live trace state. Each turn keys _TRACE_STATE by a unique
# turn_id, and an entry is normally reclaimed by _finish_trace when a turn
# ends cleanly (final response has content and no tool calls). A turn that
# never reaches that state — interrupted, a tool-only final step, or empty
# final content — would otherwise linger forever, so over the cap we evict
# the least-recently-updated entries (ending their root span first). The cap
# is far above any realistic concurrent-live-turn working set; it exists only
# to bound the leak from non-finalizing turns, not to limit concurrency.
_MAX_TRACE_STATE = 256
_LANGFUSE_CLIENT = None
_READ_FILE_LINE_RE = re.compile(r"^\s*(\d+)\|(.*)$")
_READ_FILE_HEAD_LINES = 25
@ -219,14 +228,43 @@ def _get_langfuse() -> Optional[Langfuse]:
return _LANGFUSE_CLIENT
def _trace_key(task_id: str, session_id: str) -> str:
def _scope_prefix(task_id: str, session_id: str) -> str:
"""The task/session/thread prefix shared by every trace-key shape."""
if task_id:
return task_id
return f"task:{task_id}"
if session_id:
return f"session:{session_id}"
return f"thread:{threading.get_ident()}"
def _trace_key(
task_id: str,
session_id: str,
*,
turn_id: str = "",
api_request_id: str = "",
) -> str:
"""Build a stable in-process trace scope key for one agent turn.
Older Hermes paths only expose ``task_id``/``session_id``. Newer paths
pass ``turn_id`` and ``api_request_id`` in LLM/tool hooks; when present,
they must scope trace state so concurrent requests sharing one task/session
never collide. ``turn_id`` is preferred over ``api_request_id`` so the
turn-level ``post_llm_call`` hook (which carries ``turn_id`` but no
``api_request_id``) resolves to the same key as the request-level hooks.
"""
if turn_id:
return f"{_scope_prefix(task_id, session_id)}:turn:{turn_id}"
if api_request_id:
return f"{_scope_prefix(task_id, session_id)}:api:{api_request_id}"
# Legacy shape: a bare ``task_id`` (NOT the ``task:`` prefix) when present,
# otherwise the session/thread prefix. Kept distinct for backward
# compatibility with keys minted before turn/request scoping existed.
if task_id:
return task_id
return _scope_prefix(task_id, session_id)
def _is_base64_data_uri(value: str) -> bool:
prefix = value[:200].lower()
return prefix.startswith("data:") and ";base64," in prefix
@ -563,12 +601,15 @@ def _usage_and_cost(response: Any, *, provider: str, api_mode: str, model: str,
def _start_root_trace(task_key: str, *, task_id: str, session_id: str, platform: str, provider: str, model: str,
api_mode: str, messages: Any, client: Langfuse) -> TraceState:
api_mode: str, messages: Any, client: Langfuse,
turn_id: str = "", api_request_id: str = "") -> TraceState:
trace_id = client.create_trace_id(seed=f"{session_id or 'sessionless'}::{task_id or task_key}")
trace_input = _extract_last_user_message(messages)
metadata = {
"source": "hermes",
"task_id": task_id,
"turn_id": turn_id,
"api_request_id": api_request_id,
"platform": platform,
"provider": provider,
"model": model,
@ -669,6 +710,30 @@ def _merge_trace_output(output: Any, state: TraceState) -> Any:
return merged
def _evict_stale_locked() -> None:
"""Drop least-recently-updated trace state to make room for a new entry.
Caller MUST hold ``_STATE_LOCK`` and call this immediately before inserting
one new entry. Bounds the leak from turns that never reach ``_finish_trace``
(interrupted / tool-only final step / empty final content), whose unique
per-turn key would otherwise linger forever. We evict down to
``_MAX_TRACE_STATE - 1`` so that the about-to-be-added entry leaves the dict
at ``_MAX_TRACE_STATE`` a true ceiling. The evicted entry's root span is
ended so it is not left dangling on the Langfuse side.
"""
over = len(_TRACE_STATE) - (_MAX_TRACE_STATE - 1)
if over <= 0:
return
# Oldest-first by last_updated_at; evict just enough to make room.
stale = sorted(_TRACE_STATE.items(), key=lambda kv: kv[1].last_updated_at)[:over]
for key, state in stale:
_TRACE_STATE.pop(key, None)
try:
state.root_span.end()
except Exception as exc: # pragma: no cover - fail-open
_debug(f"evict stale trace failed: {exc}")
def _finish_trace(task_key: str, *, output: Any = None) -> None:
client = _get_langfuse()
if client is None:
@ -712,7 +777,8 @@ def _request_key(api_call_count: Any) -> str:
def on_pre_llm_call(*, task_id: str = "", session_id: str = "", platform: str = "", model: str = "",
provider: str = "", base_url: str = "", api_mode: str = "",
api_call_count: int = 0, messages: Any = None, turn_type: str = "user",
conversation_history: Any = None, user_message: Any = None, **_: Any) -> None:
conversation_history: Any = None, user_message: Any = None,
turn_id: str = "", api_request_id: str = "", **_: Any) -> None:
# Older Hermes branches used pre_llm_call for request-scoped tracing and
# passed the actual API messages. Current Hermes also has a turn-scoped
# pre_llm_call used for context injection; tracing that hook creates an
@ -729,7 +795,12 @@ def on_pre_llm_call(*, task_id: str = "", session_id: str = "", platform: str =
# pre_llm_call with API messages directly. Current Hermes fires
# pre_llm_call for context injection (conversation_history/user_message,
# no messages list) — tracing that would create orphan traces.
task_key = _trace_key(task_id, session_id)
task_key = _trace_key(
task_id,
session_id,
turn_id=turn_id,
api_request_id=api_request_id,
)
with _STATE_LOCK:
state = _TRACE_STATE.get(task_key)
@ -744,7 +815,10 @@ def on_pre_llm_call(*, task_id: str = "", session_id: str = "", platform: str =
api_mode=api_mode,
messages=messages,
client=client,
turn_id=turn_id,
api_request_id=api_request_id,
)
_evict_stale_locked()
_TRACE_STATE[task_key] = state
state.last_updated_at = time.time()
@ -769,6 +843,8 @@ def on_pre_llm_request(
max_tokens: Any = None,
conversation_history: Any = None,
user_message: Any = None,
turn_id: str = "",
api_request_id: str = "",
**_: Any,
) -> None:
client = _get_langfuse()
@ -782,7 +858,12 @@ def on_pre_llm_request(
user_message=user_message,
)
task_key = _trace_key(task_id, session_id)
task_key = _trace_key(
task_id,
session_id,
turn_id=turn_id,
api_request_id=api_request_id,
)
req_key = _request_key(api_call_count)
with _STATE_LOCK:
@ -798,7 +879,10 @@ def on_pre_llm_request(
api_mode=api_mode,
messages=input_messages,
client=client,
turn_id=turn_id,
api_request_id=api_request_id,
)
_evict_stale_locked()
_TRACE_STATE[task_key] = state
state.last_updated_at = time.time()
previous = state.generations.pop(req_key, None)
@ -827,12 +911,18 @@ def on_post_llm_call(*, task_id: str = "", session_id: str = "", provider: str =
api_duration: float = 0.0, finish_reason: str = "",
usage: Any = None, assistant_content_chars: int = 0,
assistant_tool_call_count: int = 0, assistant_response: Any = None,
turn_id: str = "", api_request_id: str = "",
**_: Any) -> None:
client = _get_langfuse()
if client is None:
return
task_key = _trace_key(task_id, session_id)
task_key = _trace_key(
task_id,
session_id,
turn_id=turn_id,
api_request_id=api_request_id,
)
req_key = _request_key(api_call_count)
with _STATE_LOCK:
@ -950,12 +1040,18 @@ def on_post_llm_call(*, task_id: str = "", session_id: str = "", provider: str =
def on_pre_tool_call(*, tool_name: str = "", args: Any = None, task_id: str = "",
session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
session_id: str = "", tool_call_id: str = "",
turn_id: str = "", api_request_id: str = "", **_: Any) -> None:
client = _get_langfuse()
if client is None:
return
task_key = _trace_key(task_id, session_id)
task_key = _trace_key(
task_id,
session_id,
turn_id=turn_id,
api_request_id=api_request_id,
)
with _STATE_LOCK:
state = _TRACE_STATE.get(task_key)
@ -976,8 +1072,14 @@ def on_pre_tool_call(*, tool_name: str = "", args: Any = None, task_id: str = ""
def on_post_tool_call(*, tool_name: str = "", args: Any = None, result: Any = None,
task_id: str = "", session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
task_key = _trace_key(task_id, session_id)
task_id: str = "", session_id: str = "", tool_call_id: str = "",
turn_id: str = "", api_request_id: str = "", **_: Any) -> None:
task_key = _trace_key(
task_id,
session_id,
turn_id=turn_id,
api_request_id=api_request_id,
)
observation = None
with _STATE_LOCK:

View file

@ -1840,6 +1840,35 @@ class AIAgent:
return detail
return f"{detail}{hint}"
@staticmethod
def _coerce_api_error_detail(value: Any) -> str:
"""Return a display-safe string for structured provider error fields."""
if isinstance(value, str):
return value
if isinstance(value, dict):
for key in ("message", "detail", "error", "code", "type"):
nested = value.get(key)
if isinstance(nested, str) and nested.strip():
return nested
for key in ("message", "detail", "error", "code", "type"):
if key in value:
nested_detail = AIAgent._coerce_api_error_detail(value[key])
if nested_detail:
return nested_detail
try:
return json.dumps(value, ensure_ascii=False, sort_keys=True)
except TypeError:
return str(value)
if isinstance(value, (list, tuple)):
parts = [
AIAgent._coerce_api_error_detail(item)
for item in value
]
return "; ".join(part for part in parts if part)
if value is None:
return ""
return str(value)
@staticmethod
def _summarize_api_error(error: Exception) -> str:
"""Extract a human-readable one-liner from an API error.
@ -1879,6 +1908,7 @@ class AIAgent:
if msg:
status_code = getattr(error, "status_code", None)
prefix = f"HTTP {status_code}: " if status_code else ""
msg = AIAgent._coerce_api_error_detail(msg)
return AIAgent._decorate_xai_entitlement_error(f"{prefix}{msg[:300]}")
# Fallback: truncate the raw string but give more room than 200 chars

View file

@ -185,6 +185,18 @@ function Write-Err {
Write-Host "[X] $Message" -ForegroundColor Red
}
function Invoke-NativeWithRelaxedErrorAction {
param([scriptblock]$Script)
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
& $Script
} finally {
$ErrorActionPreference = $prevEAP
}
}
# Inspect npm output for a TLS-trust failure and, if found, print actionable
# remediation. npm/Node surface corporate MITM proxies and missing root CAs as
# "unable to get local issuer certificate" / "self-signed certificate in
@ -318,6 +330,36 @@ function Install-AgentBrowser {
# Dependency checks
# ============================================================================
# Resolve the PowerShell host executable used to spawn child PowerShell
# processes (the astral uv installer below). We must NOT hardcode the bare
# name `powershell`: it names *Windows PowerShell* and only resolves when its
# System32 directory is on PATH. When install.ps1 is run under PowerShell 7+
# (`pwsh`) -- or any session where `powershell` isn't on PATH -- a bare
# `powershell` spawn dies with "The term 'powershell' is not recognized",
# aborting uv installation (field report: Windows install stuck, uv install
# failed with exactly that message). Prefer the absolute path of the host we
# are already running in (PATH-independent), then fall back to whichever of
# powershell/pwsh is resolvable, and only then to the bare name.
function Get-PowerShellHostExe {
try {
$hostExe = (Get-Process -Id $PID).Path
if ($hostExe -and (Test-Path $hostExe)) {
$leaf = Split-Path $hostExe -Leaf
# Only trust the current host when it is a real PowerShell CLI
# (not e.g. powershell_ise.exe or an embedded host that can't take
# `-ExecutionPolicy`/`-Command`).
if ($leaf -match '^(?i:powershell|pwsh)\.exe$') { return $hostExe }
}
} catch { }
foreach ($candidate in @("powershell", "pwsh")) {
$cmd = Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($cmd -and $cmd.Source) { return $cmd.Source }
}
# Last-ditch: hand back the bare name so the spawn surfaces its own error.
return "powershell"
}
function Install-Uv {
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
# no PATH probing, no conda guards, no multi-location resolution chains.
@ -341,7 +383,11 @@ function Install-Uv {
try {
$ErrorActionPreference = "Continue"
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
# Spawn via the resolved host exe (see Get-PowerShellHostExe) rather
# than a bare `powershell`, which isn't guaranteed to be on PATH under
# PowerShell 7 / pwsh-only setups.
$psHostExe = Get-PowerShellHostExe
& $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
$ErrorActionPreference = $prevEAP
if (Test-Path $managedUv) {
@ -1306,7 +1352,7 @@ function Install-Repository {
Write-Info "Trying SSH clone..."
$env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5"
try {
git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlSsh $InstallDir
Invoke-NativeWithRelaxedErrorAction { git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlSsh $InstallDir }
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
} catch { }
$env:GIT_SSH_COMMAND = $null
@ -1315,7 +1361,7 @@ function Install-Repository {
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
Write-Info "SSH failed, trying HTTPS..."
try {
git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlHttps $InstallDir
Invoke-NativeWithRelaxedErrorAction { git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlHttps $InstallDir }
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
} catch { }
}
@ -1443,8 +1489,20 @@ function Install-Venv {
Remove-Item -Recurse -Force "venv"
}
# uv creates the venv and pins the Python version in one step
& $UvCmd venv venv --python $PythonVersion
# uv creates the venv and pins the Python version in one step. uv emits
# normal progress such as "Using CPython ..." on stderr; under Windows
# PowerShell 5.1 with EAP=Stop that stderr is a NativeCommandError unless
# we temporarily relax EAP and trust $LASTEXITCODE for real failures.
Invoke-NativeWithRelaxedErrorAction { & $UvCmd venv venv --python $PythonVersion }
# Relaxing EAP above means a *genuine* uv-venv failure (exit != 0) no longer
# aborts on its own. Capture $LASTEXITCODE immediately and fail fast, so the
# `venv` stage can't falsely report success (and Invoke-Stage can't emit
# ok=true) when the venv was never created.
$venvExitCode = $LASTEXITCODE
if ($venvExitCode -ne 0) {
Pop-Location
throw "Failed to create virtual environment (uv venv exited with $venvExitCode)"
}
# Neutralize any inherited UV_PYTHON (e.g. $env:UV_PYTHON = "3.14" left in
# the user's shell). uv honours UV_PYTHON over an existing venv for the
@ -1514,7 +1572,7 @@ function Install-Dependencies {
# in the wrong directory and imports fail with ModuleNotFoundError.
# (Mirrors the same flag in scripts/install.sh::install_deps.)
$env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv"
& $UvCmd sync --extra all --locked
Invoke-NativeWithRelaxedErrorAction { & $UvCmd sync --extra all --locked }
if ($LASTEXITCODE -eq 0) {
Write-Success "Main package installed (hash-verified via uv.lock)"
$script:InstalledTier = "hash-verified (uv.lock)"
@ -1589,7 +1647,7 @@ except Exception:
if (-not $skipPipFallback) {
foreach ($tier in $installTiers) {
Write-Info "Trying tier: $($tier.Name) ..."
& $UvCmd pip install -e $tier.Spec
Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install -e $tier.Spec }
if ($LASTEXITCODE -eq 0) {
Write-Success "Main package installed ($($tier.Name))"
$script:InstalledTier = $tier.Name

View file

@ -45,6 +45,9 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"victor@rocketfueldev.com": "victor-kyriazakos",
"87440198+JoaoMarcos44@users.noreply.github.com": "JoaoMarcos44",
"286497132+srojk34@users.noreply.github.com": "srojk34",
"59806492+sitkarev@users.noreply.github.com": "sitkarev",
"zheng@omegasys.eu": "omegazheng",
"220877172+james47kjv@users.noreply.github.com": "james47kjv",
@ -66,6 +69,7 @@ AUTHOR_MAP = {
"joe.rinaldijohnson@shopify.com": "joerj123",
"adalsteinnhelgason@Aalsteinns-MacBook-Pro-3.local": "AIalliAI",
"adalsteinnhelgason@users.noreply.github.com": "AIalliAI",
"iamlukethedev@users.noreply.github.com": "iamlukethedev",
"zhang.hz6666@gmail.com": "HaozheZhang6",
"barronlroth@gmail.com": "barronlroth",
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
@ -91,6 +95,7 @@ AUTHOR_MAP = {
"al@randomsnowflake.me": "randomsnowflake",
"zakame@zakame.net": "zakame",
"152110621+jiangkoumo@users.noreply.github.com": "jiangkoumo",
"qinhaojie.exe@bytedance.com": "qin-ctx",
"834740219@qq.com": "ViewWay",
"matt@vestigial.dev": "m4dni5",
"harjoth.khara@gmail.com": "harjothkhara",
@ -98,6 +103,7 @@ AUTHOR_MAP = {
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
"dirtyren@users.noreply.github.com": "dirtyren",
"chanyoung.kim@nota.ai": "channkim",
"stevenn.damatoo@gmail.com": "x1erra",
"evansrory@gmail.com": "zimigit2020",
"237263164+ft-ioxcs@users.noreply.github.com": "ft-ioxcs",
@ -202,6 +208,7 @@ AUTHOR_MAP = {
"me@promplate.dev": "CNSeniorious000",
"yichengqiao21@gmail.com": "YarrowQiao",
"erhanyasarx@gmail.com": "erhnysr",
"draihan@student.ubc.ca": "0xdany", # PR #26124 salvage (chat argv off event loop)
"30366221+WorldWriter@users.noreply.github.com": "WorldWriter",
"dafeng@DafengdeMacBook-Pro.local": "WorldWriter",
"schepers.zander1@gmail.com": "Strontvod",
@ -1569,6 +1576,7 @@ AUTHOR_MAP = {
"bsmith@bramarstrategicservices.com": "bcsmith528", # PR #20589 salvage (register_slack_action_handler plugin API)
"sunsky.lau@gmail.com": "liuhao1024", # PR #45494 salvage (claim session slot before auto-resume task; #45456)
"andrewdmwalker@gmail.com": "capt-marbles", # PR #38440 salvage (resolve xAI OAuth credentials across profiles; #43589)
"infinitycrew39@gmail.com": "infinitycrew39", # PR #47945 salvage (scope langfuse trace state by turn/request ids; #48292)
}

View file

@ -2,13 +2,68 @@ from __future__ import annotations
from collections import defaultdict
from pathlib import Path
import tempfile
from setuptools import setup
from setuptools.command.build import build as _build
from setuptools.command.egg_info import egg_info as _egg_info
REPO_ROOT = Path(__file__).parent.resolve()
def _source_tree_is_writable() -> bool:
probe = REPO_ROOT / ".setuptools-write-probe"
try:
with probe.open("w", encoding="utf-8") as handle:
handle.write("")
probe.unlink()
except OSError:
try:
probe.unlink(missing_ok=True)
except OSError:
pass
return False
return True
def _temporary_build_dir(kind: str) -> str:
return tempfile.mkdtemp(prefix=f"hermes-agent-{kind}-")
def _would_write_under_source(path_value: str | None) -> bool:
if path_value is None:
return True
path = Path(path_value)
if not path.is_absolute():
path = REPO_ROOT / path
try:
path.resolve().relative_to(REPO_ROOT)
except ValueError:
return False
return True
class ReadOnlySourceBuild(_build):
def finalize_options(self) -> None:
if (
not _source_tree_is_writable()
and _would_write_under_source(self.build_base)
):
self.build_base = _temporary_build_dir("build")
super().finalize_options()
class ReadOnlySourceEggInfo(_egg_info):
def finalize_options(self) -> None:
if (
not _source_tree_is_writable()
and _would_write_under_source(self.egg_base)
):
self.egg_base = _temporary_build_dir("egg-info")
super().finalize_options()
def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]:
root = REPO_ROOT / root_name
grouped: defaultdict[str, list[str]] = defaultdict(list)
@ -21,6 +76,10 @@ def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]:
setup(
cmdclass={
"build": ReadOnlySourceBuild,
"egg_info": ReadOnlySourceEggInfo,
},
data_files=[
*_data_file_tree("skills"),
*_data_file_tree("optional-skills"),

View file

@ -0,0 +1,377 @@
"""Unit tests for the Phase 2b terminal-billing core + HTTP client.
Covers:
- Decimal money parsing/formatting (server emits decimal strings, not 2dp).
- BillingState payload parsing (role tiering, presets, bounds, sub-structs).
- Error-code typed-exception mapping (the live-verified contract matrix).
- Fail-open builder behavior.
- Idempotency key generation.
- Custom-amount validation against bounds + multipleOf 0.01.
No network: HTTP-layer tests drive _raise_for_error directly and monkeypatch the
request function for the builder.
"""
from __future__ import annotations
from decimal import Decimal
import pytest
import agent.billing_view as bv
from agent.billing_view import (
AutoReload,
BillingState,
CardInfo,
MonthlyCap,
billing_state_from_payload,
build_billing_state,
format_money,
new_idempotency_key,
parse_money,
validate_charge_amount,
)
import hermes_cli.nous_billing as nb
from hermes_cli.nous_billing import (
BillingAuthError,
BillingError,
BillingRateLimited,
BillingScopeRequired,
_raise_for_error,
resolve_portal_base_url,
)
# ---------------------------------------------------------------------------
# Decimal money
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"raw,expected",
[
("142.5", Decimal("142.5")), # decimal string, NOT 2dp — the headline case
("100", Decimal("100")),
("10000", Decimal("10000")),
("0.01", Decimal("0.01")),
(250, Decimal("250")),
(" 50 ", Decimal("50")),
],
)
def test_parse_money_valid(raw, expected):
assert parse_money(raw) == expected
@pytest.mark.parametrize("raw", [None, "", "abc", "1.2.3", "$5", {}])
def test_parse_money_invalid_returns_none(raw):
assert parse_money(raw) is None
def test_parse_money_never_uses_binary_float():
# If a float ever sneaks through, we still get an exact decimal, not 0.1+0.2 junk.
assert parse_money(0.1) == Decimal("0.1")
@pytest.mark.parametrize(
"value,expected",
[
(Decimal("142.5"), "$142.50"),
(Decimal("100"), "$100"),
(Decimal("0.01"), "$0.01"),
(Decimal("1000"), "$1000"),
(None, ""),
],
)
def test_format_money(value, expected):
assert format_money(value) == expected
# ---------------------------------------------------------------------------
# BillingState payload parsing
# ---------------------------------------------------------------------------
def _member_payload() -> dict:
return {
"org": {"id": "o1", "slug": "acme", "name": "Acme", "role": "MEMBER"},
"balanceUsd": "142.5",
"cliBillingEnabled": True,
"chargePresets": ["100", "250", "500"],
"bounds": {"minUsd": "10", "maxUsd": "10000"},
"card": None,
"monthlyCap": None,
"autoReload": None,
}
def _owner_payload() -> dict:
p = _member_payload()
p["org"]["role"] = "OWNER"
p["card"] = {"brand": "visa", "last4": "4242"}
p["monthlyCap"] = {
"limitUsd": "1000",
"spentThisMonthUsd": "180",
"isDefaultCeiling": True,
}
p["autoReload"] = {"enabled": True, "thresholdUsd": "20", "reloadToUsd": "100"}
return p
def test_state_member_tier_parse():
s = billing_state_from_payload(_member_payload())
assert s.logged_in
assert s.role == "MEMBER"
assert s.balance_usd == Decimal("142.5")
assert s.cli_billing_enabled is True
assert s.charge_presets == (Decimal("100"), Decimal("250"), Decimal("500"))
assert s.min_usd == Decimal("10") and s.max_usd == Decimal("10000")
assert s.card is None and s.monthly_cap is None and s.auto_reload is None
assert s.is_admin is False
assert s.can_charge is False # not admin
def test_state_owner_tier_parse():
s = billing_state_from_payload(_owner_payload())
assert s.is_admin is True
assert s.can_charge is True # admin + kill-switch on
assert s.card == CardInfo(brand="visa", last4="4242")
assert s.card is not None and s.card.masked == "visa ····4242"
assert s.monthly_cap == MonthlyCap(
limit_usd=Decimal("1000"),
spent_this_month_usd=Decimal("180"),
is_default_ceiling=True,
)
assert s.auto_reload == AutoReload(
enabled=True, threshold_usd=Decimal("20"), reload_to_usd=Decimal("100")
)
def test_state_can_charge_false_when_killswitch_off():
p = _owner_payload()
p["cliBillingEnabled"] = False
s = billing_state_from_payload(p)
assert s.is_admin is True
assert s.can_charge is False # kill-switch off gates the action
def test_state_handles_garbage_substructs():
p = _member_payload()
p["card"] = "not-a-dict"
p["monthlyCap"] = 42
p["chargePresets"] = ["100", "bad", "250"] # bad preset dropped, not crash
s = billing_state_from_payload(p)
assert s.card is None and s.monthly_cap is None
assert s.charge_presets == (Decimal("100"), Decimal("250"))
# ---------------------------------------------------------------------------
# Error-code → typed-exception mapping (live-verified contract)
# ---------------------------------------------------------------------------
class _Headers:
def __init__(self, d):
self._d = d
def get(self, k):
return self._d.get(k)
def test_401_maps_to_auth_error():
with pytest.raises(BillingAuthError) as ei:
_raise_for_error(401, {"error": "invalid_token"})
assert ei.value.status == 401
def test_403_insufficient_scope_maps_to_scope_required():
with pytest.raises(BillingScopeRequired) as ei:
_raise_for_error(403, {"error": "insufficient_scope", "portalUrl": "/billing"})
assert ei.value.error == "insufficient_scope"
# portalUrl is resolved to an absolute URL (relative-by-design from the server).
assert (ei.value.portal_url or "").startswith("http")
assert (ei.value.portal_url or "").endswith("/billing")
@pytest.mark.parametrize("status", [429, 503])
def test_rate_limited_maps_with_retry_after(status):
with pytest.raises(BillingRateLimited) as ei:
_raise_for_error(
status,
{"error": "rate_limited"},
_Headers({"Retry-After": "60"}),
)
assert ei.value.retry_after == 60
# Critically: a rate limit is NOT a generic BillingError-only — surfaces branch on type.
assert isinstance(ei.value, BillingRateLimited)
@pytest.mark.parametrize(
"error",
[
"no_payment_method",
"cli_billing_disabled",
"role_required",
"monthly_cap_exceeded",
"org_access_denied",
],
)
def test_other_403s_map_to_base_error_with_portal_url(error):
with pytest.raises(BillingError) as ei:
_raise_for_error(403, {"error": error, "portalUrl": "/billing?topup=open"})
# Not a scope/auth/rate subclass — the generic gate-denial path.
assert not isinstance(ei.value, (BillingScopeRequired, BillingAuthError, BillingRateLimited))
assert ei.value.error == error
# portalUrl resolved to an absolute deep-link (server sends it relative).
assert (ei.value.portal_url or "").startswith("http")
assert (ei.value.portal_url or "").endswith("/billing?topup=open")
def test_monthly_cap_exceeded_carries_remaining_in_payload():
with pytest.raises(BillingError) as ei:
_raise_for_error(
403,
{
"error": "monthly_cap_exceeded",
"remainingUsd": "12.50",
"isDefaultCeiling": True,
"portalUrl": "/billing",
},
)
assert ei.value.payload["remainingUsd"] == "12.50"
assert ei.value.payload["isDefaultCeiling"] is True
def test_400_amount_out_of_bounds_is_base_error():
with pytest.raises(BillingError) as ei:
_raise_for_error(400, {"error": "amount_out_of_bounds", "message": "too big"})
assert ei.value.status == 400
assert "too big" in str(ei.value)
# ---------------------------------------------------------------------------
# post_charge requires idempotency key (client-side guard)
# ---------------------------------------------------------------------------
def test_post_charge_requires_idempotency_key():
with pytest.raises(BillingError) as ei:
nb.post_charge(amount_usd=50, idempotency_key="")
assert ei.value.error == "idempotency_key_required"
def test_get_charge_status_requires_id():
with pytest.raises(BillingError) as ei:
nb.get_charge_status("")
assert ei.value.error == "invalid_charge_id"
# ---------------------------------------------------------------------------
# Base-URL resolution precedence
# ---------------------------------------------------------------------------
def test_portal_base_url_env_override(monkeypatch):
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", "https://preview.example.com/")
assert resolve_portal_base_url() == "https://preview.example.com"
def test_portal_base_url_falls_back_to_state(monkeypatch):
monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False)
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
assert (
resolve_portal_base_url({"portal_base_url": "https://stored.example.com/"})
== "https://stored.example.com"
)
def test_portal_base_url_default(monkeypatch):
monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False)
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
assert resolve_portal_base_url() == nb.DEFAULT_PORTAL_BASE_URL
# ---------------------------------------------------------------------------
# Fail-open builder
# ---------------------------------------------------------------------------
def test_build_billing_state_logged_out_on_auth_error(monkeypatch):
def _auth(*a, **kw):
raise BillingAuthError("nope", status=401)
monkeypatch.setattr(nb, "get_billing_state", _auth)
s = build_billing_state()
assert s.logged_in is False
assert s.error is None # cleanly logged out, not an error
def test_build_billing_state_fail_open_on_http_error(monkeypatch):
def _boom(*a, **kw):
raise BillingError("portal exploded", status=500)
monkeypatch.setattr(nb, "get_billing_state", _boom)
s = build_billing_state()
assert s.logged_in is False
assert "portal exploded" in (s.error or "")
def test_build_billing_state_parses_and_prefers_server_portal_url(monkeypatch):
payload = _owner_payload()
payload["portalUrl"] = "https://portal.example.com/billing?topup=open"
monkeypatch.setattr(nb, "get_billing_state", lambda *a, **kw: payload)
s = build_billing_state()
assert s.logged_in is True
assert s.portal_url == "https://portal.example.com/billing?topup=open"
assert s.balance_usd == Decimal("142.5")
def test_build_billing_state_builds_fallback_portal_url(monkeypatch):
payload = _member_payload() # no portalUrl key
monkeypatch.setattr(nb, "get_billing_state", lambda *a, **kw: payload)
monkeypatch.setattr(bv, "_fallback_portal_url", lambda base: "FALLBACK")
# resolve_portal_base_url is imported into bv via local import; patch nb's.
s = build_billing_state()
assert s.portal_url == "FALLBACK"
# ---------------------------------------------------------------------------
# Idempotency
# ---------------------------------------------------------------------------
def test_new_idempotency_key_unique_and_uuid_shaped():
a, b = new_idempotency_key(), new_idempotency_key()
assert a != b
assert len(a) == 36 and a.count("-") == 4
# ---------------------------------------------------------------------------
# Amount validation (Screen 3 custom input)
# ---------------------------------------------------------------------------
def test_validate_amount_ok():
v = validate_charge_amount("100", min_usd=Decimal("10"), max_usd=Decimal("10000"))
assert v.ok and v.amount == Decimal("100")
def test_validate_amount_strips_dollar_sign():
v = validate_charge_amount("$250", min_usd=Decimal("10"), max_usd=Decimal("10000"))
assert v.ok and v.amount == Decimal("250")
@pytest.mark.parametrize(
"raw,err_substr",
[
("", "dollar amount"),
("0", "greater than"),
("-5", "greater than"),
("10.005", "cent"), # multipleOf 0.01 — sub-cent rejected
("5", "Minimum"), # below bounds.minUsd
("99999", "Maximum"), # above bounds.maxUsd
],
)
def test_validate_amount_rejections(raw, err_substr):
v = validate_charge_amount(raw, min_usd=Decimal("10"), max_usd=Decimal("10000"))
assert not v.ok
assert err_substr.lower() in (v.error or "").lower()

View file

@ -0,0 +1,101 @@
"""Tests for per-platform prompt-hint overrides (config.yaml → platform_hints).
Covers agent/system_prompt.py::_resolve_platform_hint the resolver that
applies append/replace overrides to a platform's default hint. Feature added
for enterprise managed profiles (per-platform behavior without affecting other
platforms). See HA Core ticket: configurable per-platform prompt hints.
"""
import types
from agent.system_prompt import _resolve_platform_hint
def _agent(overrides):
"""Minimal stand-in carrying just the override attribute the resolver reads."""
a = types.SimpleNamespace()
a._platform_hint_overrides = overrides
return a
DEFAULT = "You are on WhatsApp. Do not use markdown."
EXTRA = "When tabular output would help, invoke the table_formatting skill."
class TestResolvePlatformHint:
def test_no_overrides_returns_default(self):
assert _resolve_platform_hint(_agent({}), "whatsapp", DEFAULT) == DEFAULT
def test_missing_attr_returns_default(self):
a = types.SimpleNamespace() # no _platform_hint_overrides at all
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
def test_platform_not_in_overrides_returns_default(self):
a = _agent({"slack": {"append": "x"}})
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
def test_append_dict(self):
a = _agent({"whatsapp": {"append": EXTRA}})
out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
assert out == f"{DEFAULT}\n\n{EXTRA}"
assert DEFAULT in out and EXTRA in out
def test_replace_dict(self):
a = _agent({"whatsapp": {"replace": EXTRA}})
out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
assert out == EXTRA
assert DEFAULT not in out
def test_replace_wins_over_append_but_both_applied(self):
a = _agent({"whatsapp": {"replace": "BASE", "append": "TAIL"}})
out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
# replace substitutes the base, append still tacks on
assert out == "BASE\n\nTAIL"
assert DEFAULT not in out
def test_bare_string_is_append_shorthand(self):
a = _agent({"whatsapp": EXTRA})
out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
assert out == f"{DEFAULT}\n\n{EXTRA}"
def test_other_platform_unaffected(self):
"""An override for whatsapp must not change telegram's hint."""
a = _agent({"whatsapp": {"append": EXTRA}})
tg_default = "You are on Telegram. Markdown works."
assert _resolve_platform_hint(a, "telegram", tg_default) == tg_default
def test_empty_platform_key_returns_default(self):
a = _agent({"whatsapp": {"append": EXTRA}})
assert _resolve_platform_hint(a, "", DEFAULT) == DEFAULT
# --- defensive / malformed input: never break prompt assembly ---
def test_malformed_spec_list_returns_default(self):
a = _agent({"whatsapp": ["not", "valid"]})
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
def test_overrides_not_a_dict_returns_default(self):
a = _agent(["nope"])
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
def test_empty_append_string_returns_default(self):
a = _agent({"whatsapp": {"append": " "}})
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
def test_empty_replace_falls_back_to_default_base(self):
a = _agent({"whatsapp": {"replace": " "}})
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
def test_non_string_append_ignored(self):
a = _agent({"whatsapp": {"append": 123}})
assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
def test_replace_with_empty_default_hint(self):
"""replace works even when the platform had no built-in default."""
a = _agent({"customplat": {"replace": "Custom hint."}})
assert _resolve_platform_hint(a, "customplat", "") == "Custom hint."
def test_append_with_empty_default_hint(self):
"""append on a platform with no default just yields the extra text."""
a = _agent({"customplat": {"append": "Only this."}})
assert _resolve_platform_hint(a, "customplat", "") == "Only this."

View file

@ -27,6 +27,8 @@ from agent.prompt_builder import (
TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS,
OPENAI_MODEL_EXECUTION_GUIDANCE,
PARALLEL_TOOL_CALL_GUIDANCE,
GOOGLE_MODEL_OPERATIONAL_GUIDANCE,
MEMORY_GUIDANCE,
SESSION_SEARCH_GUIDANCE,
PLATFORM_HINTS,
@ -1497,6 +1499,49 @@ class TestOpenAIModelExecutionGuidance:
assert len(OPENAI_MODEL_EXECUTION_GUIDANCE) > 100
class TestParallelToolCallGuidance:
"""Behavior contracts for the universal parallel-tool-call guidance block.
Asserts the invariants the block must satisfy (steer batching, scope to
independent calls, stay short for the cached prompt) rather than freezing
its exact wording.
"""
def test_is_nonempty_string(self):
assert isinstance(PARALLEL_TOOL_CALL_GUIDANCE, str)
assert PARALLEL_TOOL_CALL_GUIDANCE.strip()
def test_steers_batching_into_one_response(self):
text = PARALLEL_TOOL_CALL_GUIDANCE.lower()
# Must tell the model to group independent calls together — accept any
# phrasing that means "one turn" without freezing exact wording.
assert "single response" in text or ("same" in text and "turn" in text)
assert "independent" in text
def test_carves_out_dependent_calls(self):
# Must NOT tell the model to batch dependent calls — that would break
# ordering (read-before-patch). The block has to acknowledge the
# serialize-when-dependent case.
text = PARALLEL_TOOL_CALL_GUIDANCE.lower()
assert "depend" in text
def test_stays_short_for_cached_prompt(self):
# Shipped in every cached system prompt — keep it tight. The existing
# task-completion block is ~600 chars; allow generous headroom but
# guard against accidental essay growth.
assert len(PARALLEL_TOOL_CALL_GUIDANCE) < 900
def test_has_a_heading(self):
# Heading delimits it as its own section in the assembled prompt.
assert PARALLEL_TOOL_CALL_GUIDANCE.lstrip().startswith("#")
def test_not_duplicated_in_google_guidance(self):
# The universal block is now the single source of parallel-batching
# steer. The Google-only block must NOT carry its own copy, otherwise
# Gemini/Gemma would receive the instruction twice in one prompt.
assert "parallel tool call" not in GOOGLE_MODEL_OPERATIONAL_GUIDANCE.lower()
# =========================================================================
# Budget warning history stripping
# =========================================================================

View file

@ -34,37 +34,35 @@ class TestPromptTextInputThreadSafety:
# not the orphaned-coroutine result.
assert mock_rit.called
def test_background_thread_falls_back_to_direct_input(self):
"""On a daemon thread, skip run_in_terminal and call input() directly.
def test_background_thread_cancels_instead_of_hanging(self):
"""On a daemon thread with an active app, cancel cleanly (return None).
This preserves the fallback for any prompt that still runs off the main
UI thread: run_in_terminal's coroutine would otherwise be orphaned.
stdin is owned by the prompt_toolkit event loop / JSON-RPC pipe on the
non-main (process_loop / slash-worker) thread, so a bare input() there
would block until the worker's timeout (#23185 / billing auto-reload
hang). The guard cancels to None instead of hanging it must NOT call
run_in_terminal (orphaned coroutine) and must NOT call input().
"""
cli = _make_cli()
captured = {}
def fake_input(prompt):
captured["prompt"] = prompt
return "1"
result_holder = {}
def run_on_daemon():
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
patch("builtins.input", side_effect=fake_input):
patch("builtins.input", side_effect=AssertionError("input() must not be called off-main-thread")) as mock_input:
result_holder["value"] = cli._prompt_text_input("Choice [1/2/3]: ")
result_holder["rit_called"] = mock_rit.called
result_holder["input_called"] = mock_input.called
t = threading.Thread(target=run_on_daemon, daemon=True)
t.start()
t.join(timeout=2.0)
assert not t.is_alive(), "daemon thread hung — input() was not driven"
assert not t.is_alive(), "daemon thread hung — guard did not cancel cleanly"
# run_in_terminal was bypassed entirely on the background thread.
# Cancelled cleanly: None returned, neither run_in_terminal nor input() called.
assert result_holder["value"] is None
assert result_holder["rit_called"] is False
# input() was invoked with the prompt and its return value was captured.
assert captured.get("prompt") == "Choice [1/2/3]: "
assert result_holder["value"] == "1"
assert result_holder["input_called"] is False
def test_no_app_uses_direct_input(self):
"""Without an active prompt_toolkit app, always call input() directly."""

View file

@ -26,6 +26,7 @@ class StubConnector:
def __init__(self, descriptor: CapabilityDescriptor) -> None:
self._descriptor = descriptor
self._inbound: Optional[InboundHandler] = None
self._interrupt_inbound: Optional[Any] = None
self.connected = False
self.sent: List[Dict[str, Any]] = []
self.interrupts: List[Dict[str, Any]] = []
@ -51,6 +52,11 @@ class StubConnector:
def set_inbound_handler(self, handler: InboundHandler) -> None:
self._inbound = handler
def set_interrupt_inbound_handler(self, handler: Any) -> None:
"""Mirror the real WS transport: the adapter registers its interrupt
bridge here so connectorgateway interrupt_inbound frames route to it."""
self._interrupt_inbound = handler
async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]:
self.sent.append(action)
if action.get("op") == "send":
@ -73,3 +79,9 @@ class StubConnector:
if self._inbound is None:
raise RuntimeError("no inbound handler registered (call adapter.connect first)")
await self._inbound(event)
async def push_interrupt(self, session_key: str, chat_id: str) -> None:
"""Simulate the connector delivering an interrupt_inbound over the WS."""
if self._interrupt_inbound is None:
raise RuntimeError("no interrupt_inbound handler registered (call adapter.connect first)")
await self._interrupt_inbound(session_key, chat_id)

View file

@ -1,150 +0,0 @@
"""Unit tests for gateway/relay/inbound_receiver.py.
Covers the verify-then-dispatch core (handle_raw): a correctly-signed message
delivery is verified + dispatched; an interrupt delivery routes to the interrupt
handler; unsigned/tampered/expired/no-key deliveries are rejected 401; malformed
JSON is 400. Signatures are produced with the SAME auth primitives the connector
uses (gateway/relay/auth.py sign), so this exercises the real verify path.
"""
from __future__ import annotations
import json
import time
import pytest
from gateway.relay.auth import sign
from gateway.relay.inbound_receiver import InboundDeliveryReceiver
_KEY = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
def _signed(body_obj: dict, key: str = _KEY, ts: int | None = None) -> tuple[bytes, str, str]:
"""Serialize compactly (as the connector's JSON.stringify does), sign it."""
body = json.dumps(body_obj, separators=(",", ":"))
raw = body.encode("utf-8")
t = ts if ts is not None else int(time.time())
return raw, str(t), sign(f"{t}.{body}", key)
def _receiver(**kw):
received: list = []
interrupts: list = []
async def on_message(ev):
received.append(ev)
async def on_interrupt(sk, chat):
interrupts.append((sk, chat))
r = InboundDeliveryReceiver(
delivery_key_verify_list=lambda: [_KEY],
on_message=on_message,
on_interrupt=on_interrupt,
**kw,
)
return r, received, interrupts
@pytest.mark.asyncio
async def test_valid_message_delivery_dispatched():
r, received, _ = _receiver()
raw, ts, sig = _signed(
{
"type": "message",
"event": {
"text": "hello",
"message_type": "text",
"source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "guild_id": "guildA"},
},
}
)
status, body = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False)
assert status == 200 and body == {"ok": True}
assert len(received) == 1
assert received[0].text == "hello"
assert received[0].source.guild_id == "guildA"
@pytest.mark.asyncio
async def test_valid_interrupt_delivery_routes_to_interrupt_handler():
r, _, interrupts = _receiver()
raw, ts, sig = _signed({"type": "interrupt", "session_key": "agent:main:discord:group:c:u", "reason": "stop"})
status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=True)
assert status == 200
assert interrupts and interrupts[0][0] == "agent:main:discord:group:c:u"
@pytest.mark.asyncio
async def test_tampered_body_rejected_401():
r, received, _ = _receiver()
raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}})
status, _ = await r.handle_raw(raw_body=raw + b" ", timestamp=ts, signature=sig, is_interrupt=False)
assert status == 401
assert received == []
@pytest.mark.asyncio
async def test_unsigned_rejected_401():
r, _, _ = _receiver()
raw, _, _ = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}})
status, _ = await r.handle_raw(raw_body=raw, timestamp=None, signature=None, is_interrupt=False)
assert status == 401
@pytest.mark.asyncio
async def test_expired_timestamp_rejected_401():
r, _, _ = _receiver(max_skew_seconds=300)
raw, _, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, ts=1)
# ts=1 (1970) is far outside the 300s window vs now.
status, _ = await r.handle_raw(raw_body=raw, timestamp="1", signature=sig, is_interrupt=False)
assert status == 401
@pytest.mark.asyncio
async def test_wrong_key_rejected_401():
r, _, _ = _receiver()
other = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, key=other)
status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False)
assert status == 401
@pytest.mark.asyncio
async def test_no_delivery_key_fails_closed_401():
async def on_message(ev):
pass
r = InboundDeliveryReceiver(delivery_key_verify_list=lambda: [], on_message=on_message)
raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}})
status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False)
assert status == 401
@pytest.mark.asyncio
async def test_rotation_secondary_key_accepted():
new = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
received: list = []
async def on_message(ev):
received.append(ev)
# Connector still signs with the OLD key (secondary); verify list has both.
r = InboundDeliveryReceiver(
delivery_key_verify_list=lambda: [new, _KEY], on_message=on_message
)
raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, key=_KEY)
status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False)
assert status == 200 and len(received) == 1
@pytest.mark.asyncio
async def test_malformed_json_after_valid_signature_is_400():
r, _, _ = _receiver()
# Sign a non-JSON body so the signature passes but json.loads fails.
raw = b"not json at all"
ts = str(int(time.time()))
sig = sign(f"{ts}.{raw.decode()}", _KEY)
status, body = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False)
assert status == 400

View file

@ -67,3 +67,23 @@ async def test_outbound_interrupt_reaches_connector(adapter):
assert stub.interrupts == [
{"session_key": "agent:main:discord:group:chanA:userX", "reason": "stop"}
]
@pytest.mark.asyncio
async def test_connect_wires_inbound_interrupt_over_ws(adapter):
"""WS-only inbound: connect() registers BOTH the inbound message handler AND
the interrupt_inbound handler on the transport, so a connector-delivered
interrupt_inbound frame (no HTTP receiver) reaches the right session."""
await adapter.connect()
stub = adapter._transport
# Both connector->gateway handlers are wired post-connect.
assert stub._inbound is not None
assert stub._interrupt_inbound is not None
key = "agent:main:discord:group:chanA:userX"
ev = asyncio.Event()
adapter._active_sessions[key] = ev
# Simulate the connector pushing an interrupt_inbound frame down the WS.
await stub.push_interrupt(key, chat_id="chanA")
assert ev.is_set() is True, "interrupt delivered over the WS must cancel the target turn"

View file

@ -48,16 +48,14 @@ def _relay_py_files() -> list[Path]:
# ``auth.py`` is the connector⇄gateway CHANNEL authenticator (the gateway's WS
# upgrade bearer + inbound-delivery signature verification). ``inbound_receiver.py``
# is the signed-inbound-delivery receiver that USES that channel auth to verify
# connector→gateway POSTs. Both are net-new, intended, and the whole point of
# authenticating an untrusted/disposable gateway — they are NOT platform crypto.
# They use HMAC over the connector's per-gateway / per-tenant secrets (NOT any
# platform's signing secret), so they are exempt from the platform-crypto symbol
# scan below. The module-import ban (platform-crypto modules) still applies to
# every file including these — they import only stdlib hmac/hashlib and each
# other, never a platform-crypto module, so they stay clean there.
_CHANNEL_AUTH_FILES = {"auth.py", "inbound_receiver.py"}
# upgrade bearer). It is net-new, intended, and the whole point of
# authenticating an untrusted/disposable gateway — it is NOT platform crypto.
# It uses HMAC over the connector's per-gateway secret (NOT any platform's
# signing secret), so it is exempt from the platform-crypto symbol scan below.
# The module-import ban (platform-crypto modules) still applies to every file
# including this one — it imports only stdlib hmac/hashlib, never a
# platform-crypto module, so it stays clean there.
_CHANNEL_AUTH_FILES = {"auth.py"}
def test_relay_package_imports_no_platform_crypto():

View file

@ -1,13 +1,19 @@
"""Unit tests for managed-boot relay self-provisioning.
"""Unit tests for boot-time relay self-provisioning.
Covers gateway.relay.self_provision_if_managed() + the relay_endpoint() /
Covers gateway.relay.self_provision_relay() + the relay_endpoint() /
relay_route_keys() config readers. The connector HTTP POST is monkeypatched
(the cross-repo E2E exercises the real /relay/provision); these prove the
TRIGGER logic, in-process env wiring, and fail-soft boot behaviour.
The trigger is deliberately NOT is_managed() (that means NixOS/package-manager-
managed, which is False on a NAS-hosted Fly agent). The real gate is
"relay_url set + no pinned secret + a resolvable NAS token".
"""
from __future__ import annotations
import os
import pytest
import gateway.relay as relay
@ -46,8 +52,13 @@ def _stub_post(captured: dict):
return _fake
def _arm(monkeypatch, *, managed=True, url="wss://connector.example/relay", token="nas-token"):
monkeypatch.setattr("hermes_cli.config.is_managed", lambda: managed)
def _arm(monkeypatch, *, url="wss://connector.example/relay", token="nas-token"):
"""Arm the real trigger: a relay URL + a resolvable NAS token.
Note there is intentionally no `managed` knob self-provision no longer
consults is_managed(). A test that wants the "no NAS identity" branch
monkeypatches resolve_nous_access_token to raise instead.
"""
monkeypatch.setattr(relay, "relay_url", lambda: url)
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: token)
@ -80,29 +91,37 @@ def test_provision_url_maps_ws_to_http():
# ─────────────────────────── trigger logic ───────────────────────────
def test_skips_when_not_managed(monkeypatch):
_arm(monkeypatch, managed=False)
called = {"n": 0}
monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {})
assert relay.self_provision_if_managed() is False
assert called["n"] == 0
def test_provisions_on_nas_host_that_is_NOT_is_managed(monkeypatch):
"""Regression: a NAS-hosted Fly agent sets neither HERMES_MANAGED nor a
.managed marker, so is_managed() is False. Self-provision must STILL fire
the old is_managed() gate silently no-oped exactly this case in staging.
"""
# Force is_managed() False to model a real hosted agent; it must be irrelevant.
monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False)
_arm(monkeypatch)
captured: dict = {}
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
assert relay.self_provision_relay() is True
assert relay.relay_connection_auth()[1] == "a" * 64
def test_skips_when_relay_not_configured(monkeypatch):
_arm(monkeypatch, url=None)
called = {"n": 0}
monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {})
assert relay.self_provision_if_managed() is False
assert relay.self_provision_relay() is False
assert called["n"] == 0
def test_skips_when_secret_already_pinned(monkeypatch):
"""A self-hosted, enrolled gateway has a pinned secret -> never self-provisions."""
_arm(monkeypatch)
monkeypatch.setenv("GATEWAY_RELAY_ID", "gw-pinned")
monkeypatch.setenv("GATEWAY_RELAY_SECRET", "deadbeef")
called = {"n": 0}
monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {})
assert relay.self_provision_if_managed() is False
assert relay.self_provision_relay() is False
assert called["n"] == 0
# The pinned secret is untouched.
assert relay.relay_connection_auth() == ("gw-pinned", "deadbeef")
@ -117,7 +136,7 @@ def test_provisions_and_sets_env_in_process(monkeypatch):
captured: dict = {}
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
assert relay.self_provision_if_managed() is True
assert relay.self_provision_relay() is True
# The connector POST carried the gateway-asserted endpoint + route keys.
assert captured["provision_url"] == "https://connector.example/relay/provision"
assert captured["access_token"] == "nas-token"
@ -126,8 +145,9 @@ def test_provisions_and_sets_env_in_process(monkeypatch):
# Creds landed in os.environ (in-process), so register_relay_adapter() reads them.
gid, secret = relay.relay_connection_auth()
assert gid and secret == "a" * 64
key, _host, _port = relay.relay_inbound_config()
assert key == "b" * 64
# The delivery key is persisted in-process too (issued by the connector,
# kept for forward-compat; inbound rides the WS so it isn't consumed).
assert os.environ["GATEWAY_RELAY_DELIVERY_KEY"] == "b" * 64
def test_outbound_only_when_no_endpoint(monkeypatch):
@ -135,7 +155,7 @@ def test_outbound_only_when_no_endpoint(monkeypatch):
captured: dict = {}
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
assert relay.self_provision_if_managed() is True
assert relay.self_provision_relay() is True
assert captured["gateway_endpoint"] is None
assert captured["route_keys"] == []
assert relay.relay_connection_auth()[1] == "a" * 64
@ -143,15 +163,18 @@ def test_outbound_only_when_no_endpoint(monkeypatch):
# ─────────────────────────── fail-soft ───────────────────────────
def test_token_failure_is_non_fatal(monkeypatch):
_arm(monkeypatch)
def test_no_nas_token_is_non_fatal(monkeypatch):
"""A self-hosted box with a relay URL but no resolvable NAS identity skips
quietly (this is the branch that replaces the old is_managed() gate for the
non-NAS case)."""
monkeypatch.setattr(relay, "relay_url", lambda: "wss://connector.example/relay")
def _boom():
raise RuntimeError("no token")
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", _boom)
# Must not raise; returns False; no creds set.
assert relay.self_provision_if_managed() is False
assert relay.self_provision_relay() is False
assert relay.relay_connection_auth() == (None, None)
@ -162,5 +185,5 @@ def test_connector_failure_is_non_fatal(monkeypatch):
raise RuntimeError("connector returned HTTP 503")
monkeypatch.setattr(relay, "_post_provision", _boom)
assert relay.self_provision_if_managed() is False
assert relay.self_provision_relay() is False
assert relay.relay_connection_auth() == (None, None)

View file

@ -1726,16 +1726,21 @@ class TestRunPreUpdateBackup:
backups = list((hermes_home / "backups").glob("pre-update-*.zip"))
assert len(backups) == 1
def test_default_disabled_is_silent(self, hermes_home, capsys):
"""With the default-off config and no --backup flag, the hook is silent
and creates no backup. This is the common case for every update."""
def test_default_enabled_creates_backup(self, hermes_home, capsys):
"""With the new safe default (``pre_update_backup: true``), every
``hermes update`` creates a backup before any destructive step
runs the cost is a few minutes of zip time vs. the alternative
of silent total data loss of ``~/.hermes/`` observed in #48200
when an update step computes a wrong path and the user had no
safety net.
"""
from hermes_cli.main import _run_pre_update_backup
_run_pre_update_backup(Namespace(no_backup=False, backup=False))
out = capsys.readouterr().out
assert out == ""
assert not (hermes_home / "backups").exists() or not list(
(hermes_home / "backups").glob("pre-update-*.zip")
)
assert "Creating pre-update backup" in out
assert "Saved:" in out
backups = list((hermes_home / "backups").glob("pre-update-*.zip"))
assert len(backups) == 1
def test_no_backup_flag_skips(self, hermes_home, capsys):
from hermes_cli.main import _run_pre_update_backup

View file

@ -0,0 +1,136 @@
"""Tests for the /billing CLI handler (cli.py::_show_billing).
Focus on the non-interactive (no live prompt_toolkit app) path the same
discipline as the /credits non-interactive test: it must render text, never
invoke the modal (which would read the slash-worker's JSON-RPC stdin and hang).
Plus role/kill-switch gating and logged-out handling.
"""
from __future__ import annotations
from decimal import Decimal
import pytest
import agent.billing_view as bv
from agent.billing_view import BillingState, CardInfo, MonthlyCap
from cli import HermesCLI
@pytest.fixture
def cli():
obj = HermesCLI.__new__(HermesCLI) # bypass __init__ (no full app needed)
obj._app = None # non-interactive: forces the text path
return obj
def _boom_modal(*a, **kw):
raise AssertionError("modal must NOT be called in non-interactive mode")
def test_billing_logged_out(cli, monkeypatch, capsys):
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: BillingState(logged_in=False))
cli._show_billing("/billing")
out = capsys.readouterr().out
assert "Not logged into Nous Portal" in out
assert "hermes portal" in out
def test_billing_overview_non_interactive_renders_text_not_modal(cli, monkeypatch, capsys):
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
state = BillingState(
logged_in=True,
org_name="Acme",
role="OWNER",
balance_usd=Decimal("142.5"),
cli_billing_enabled=True,
charge_presets=(Decimal("100"),),
monthly_cap=MonthlyCap(limit_usd=Decimal("1000"), spent_this_month_usd=Decimal("180"),
is_default_ceiling=True),
portal_url="https://portal/billing?topup=open",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/billing")
out = capsys.readouterr().out
assert "Usage credits" in out
assert "$142.50" in out
assert "$180 of $1000 used (default ceiling)" in out
# New design: a spend bar with a percentage on the overview.
assert "%" in out and ("" in out or "" in out)
# ZERO sub-commands: no /billing buy|auto-reload|limit advertising.
assert "/billing buy" not in out
assert "Actions:" not in out
# Non-interactive funnels to the portal (the URL is the affordance).
assert "Manage on portal:" in out
def test_billing_member_cannot_charge(cli, monkeypatch, capsys):
state = BillingState(
logged_in=True, role="MEMBER", balance_usd=Decimal("10"),
cli_billing_enabled=True, portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/billing")
out = capsys.readouterr().out
assert "require an org admin/owner" in out
def test_billing_killswitch_off_blocks(cli, monkeypatch, capsys):
state = BillingState(
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
cli_billing_enabled=False, portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/billing")
out = capsys.readouterr().out
assert "turned off for this org" in out
def test_billing_limit_screen_readonly(cli, monkeypatch, capsys):
state = BillingState(
logged_in=True, role="OWNER", cli_billing_enabled=True,
monthly_cap=MonthlyCap(limit_usd=Decimal("1000"), spent_this_month_usd=Decimal("250"),
is_default_ceiling=True),
portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
# ZERO sub-commands: the limit screen is reached via the menu, never a
# sub-command — call it directly the way the overview menu would.
cli._billing_limit_screen(state)
out = capsys.readouterr().out
assert "Monthly spend limit" in out
assert "$250 of $1000 used" in out
assert "read-only" in out
def test_billing_sub_arg_ignored_opens_overview(cli, monkeypatch, capsys):
# A stray sub-arg must NOT error and must NOT dispatch to a sub-screen —
# it just opens the overview (spec §0.4: zero sub-commands).
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
state = BillingState(
logged_in=True, role="OWNER", balance_usd=Decimal("142.5"),
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/billing buy") # arg is ignored
out = capsys.readouterr().out
assert "Usage credits" in out # overview, NOT the buy screen
assert "Buy usage credits" not in out
def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys):
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
state = BillingState(
logged_in=True, role="OWNER", cli_billing_enabled=True,
charge_presets=(Decimal("25"), Decimal("50"), Decimal("100")),
card=CardInfo(brand="visa", last4="4242"),
portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
# Reached via the menu in real use; non-interactively it defers to the portal.
cli._billing_buy_flow(state)
out = capsys.readouterr().out
assert "Buy usage credits" in out
assert "$25" in out and "$50" in out and "$100" in out
assert "interactive CLI" in out # defers; no charge attempted non-interactively

View file

@ -0,0 +1,53 @@
"""Portal-URL resolution for Phase 2b billing errors (nous_billing).
The server emits ``portalUrl`` relative by design (``/billing?topup=open``); the
client must resolve it against the active portal base so deep-links are clickable
on whatever deployment (preview / staging / prod) the user is pointed at.
"""
from __future__ import annotations
import pytest
from hermes_cli.nous_billing import (
BillingError,
_absolutize_portal_url,
_raise_for_error,
)
@pytest.fixture
def _preview(monkeypatch):
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", "https://nas-pr-412.nousresearch.wtf")
def test_absolutize_resolves_relative(_preview):
assert (
_absolutize_portal_url("/billing?topup=open")
== "https://nas-pr-412.nousresearch.wtf/billing?topup=open"
)
def test_absolutize_leaves_absolute_unchanged(_preview):
# Idempotent: an already-absolute URL must NOT be double-prefixed.
url = "https://other.example/billing?topup=open"
assert _absolutize_portal_url(url) == url
def test_absolutize_passthrough_empty(_preview):
assert _absolutize_portal_url(None) is None
assert _absolutize_portal_url("") == ""
def test_raise_for_error_attaches_absolute_portal_url(_preview):
# The 403 no_payment_method envelope carries a RELATIVE portalUrl; the raised
# BillingError must expose it as ABSOLUTE so CLI + TUI render a clickable link.
with pytest.raises(BillingError) as exc_info:
_raise_for_error(
403,
{"error": "no_payment_method", "portalUrl": "/billing?topup=open"},
)
assert (
exc_info.value.portal_url
== "https://nas-pr-412.nousresearch.wtf/billing?topup=open"
)

View file

@ -0,0 +1,193 @@
"""Tests for the Phase 2b billing:manage scope step-up (auth.py)."""
from __future__ import annotations
import pytest
import hermes_cli.auth as auth
from hermes_cli.auth import (
NOUS_BILLING_MANAGE_SCOPE,
nous_token_has_billing_scope,
step_up_nous_billing_scope,
)
# ---------------------------------------------------------------------------
# nous_token_has_billing_scope
# ---------------------------------------------------------------------------
def test_has_scope_true_when_present(monkeypatch):
monkeypatch.setattr(
auth,
"get_provider_auth_state",
lambda p: {"scope": "inference:invoke tool:invoke billing:manage"},
)
assert nous_token_has_billing_scope() is True
def test_has_scope_false_when_absent(monkeypatch):
monkeypatch.setattr(
auth, "get_provider_auth_state", lambda p: {"scope": "inference:invoke tool:invoke"}
)
assert nous_token_has_billing_scope() is False
def test_has_scope_false_when_no_state(monkeypatch):
monkeypatch.setattr(auth, "get_provider_auth_state", lambda p: None)
assert nous_token_has_billing_scope() is False
def test_has_scope_no_substring_false_positive(monkeypatch):
# "billing:manage-lite" must NOT match billing:manage (split-based, not substring).
monkeypatch.setattr(
auth, "get_provider_auth_state", lambda p: {"scope": "billing:manage-lite"}
)
assert nous_token_has_billing_scope() is False
# ---------------------------------------------------------------------------
# step_up_nous_billing_scope
# ---------------------------------------------------------------------------
@pytest.fixture
def _stub_persist(monkeypatch):
"""Neutralize the persistence side-effects so step-up tests are pure."""
monkeypatch.setattr(auth, "_auth_store_lock", lambda: _NullCtx())
monkeypatch.setattr(auth, "_load_auth_store", lambda: {})
monkeypatch.setattr(auth, "_save_provider_state", lambda *a, **kw: None)
monkeypatch.setattr(auth, "_save_auth_store", lambda *a, **kw: "auth.json")
monkeypatch.setattr(auth, "_write_shared_nous_state", lambda *a, **kw: None)
monkeypatch.setattr(auth, "_sync_nous_pool_from_auth_store", lambda: None)
class _NullCtx:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def test_step_up_requests_billing_scope_and_reuses_prior_urls(monkeypatch, _stub_persist):
monkeypatch.setattr(
auth,
"get_provider_auth_state",
lambda p: {
"scope": "inference:invoke tool:invoke",
"portal_base_url": "https://preview.example.com",
"inference_base_url": "https://inf.example.com",
"client_id": "hermes-cli",
},
)
captured = {}
def _fake_login(**kw):
captured.update(kw)
# Simulate the admin ticking the box → token comes back WITH the scope.
return {"scope": "inference:invoke tool:invoke billing:manage", "access_token": "t"}
monkeypatch.setattr(auth, "_nous_device_code_login", _fake_login)
granted = step_up_nous_billing_scope()
assert granted is True
# Requested scope must include billing:manage, preserving prior scopes.
assert NOUS_BILLING_MANAGE_SCOPE in captured["scope"].split()
assert "inference:invoke" in captured["scope"].split()
# Reuses the prior credential's deployment URLs (so a preview stays a preview).
assert captured["portal_base_url"] == "https://preview.example.com"
assert captured["client_id"] == "hermes-cli"
def test_step_up_returns_false_when_downscoped(monkeypatch, _stub_persist):
# Non-admin / unticked → the server silently downscopes; token comes back WITHOUT scope.
monkeypatch.setattr(auth, "get_provider_auth_state", lambda p: {"scope": "inference:invoke"})
monkeypatch.setattr(
auth,
"_nous_device_code_login",
lambda **kw: {"scope": "inference:invoke", "access_token": "t"},
)
assert step_up_nous_billing_scope() is False
def test_step_up_falls_back_to_standard_scope_when_no_prior(monkeypatch, _stub_persist):
monkeypatch.setattr(auth, "get_provider_auth_state", lambda p: {})
captured = {}
def _fake_login(**kw):
captured.update(kw)
return {"scope": "inference:invoke tool:invoke billing:manage"}
monkeypatch.setattr(auth, "_nous_device_code_login", _fake_login)
step_up_nous_billing_scope()
requested = captured["scope"].split()
assert "inference:invoke" in requested
assert "tool:invoke" in requested
assert NOUS_BILLING_MANAGE_SCOPE in requested
# ---------------------------------------------------------------------------
# on_verification callback plumbing (TUI surfaces the device-flow URL via this)
# ---------------------------------------------------------------------------
def test_step_up_forwards_on_verification_callback(monkeypatch, _stub_persist):
monkeypatch.setattr(auth, "get_provider_auth_state", lambda p: {})
captured = {}
def _fake_login(**kw):
captured.update(kw)
return {"scope": "inference:invoke tool:invoke billing:manage"}
monkeypatch.setattr(auth, "_nous_device_code_login", _fake_login)
def _cb(url, code):
pass
step_up_nous_billing_scope(on_verification=_cb)
# The callback must be threaded straight through to the device-code login.
assert captured["on_verification"] is _cb
def test_device_login_fires_on_verification_before_polling(monkeypatch):
"""on_verification(url, code) must fire BEFORE _poll_for_token (so the TUI
can render the link while the flow blocks waiting for approval)."""
order: list[str] = []
monkeypatch.setattr(
auth,
"_request_device_code",
lambda **kw: {
"verification_uri_complete": "https://portal.example/device?code=ABCD",
"user_code": "ABCD-1234",
"device_code": "dev",
"expires_in": 600,
"interval": 5,
},
)
def _fake_poll(**kw):
order.append("poll")
return {"access_token": "t", "scope": "inference:invoke", "expires_in": 3600}
monkeypatch.setattr(auth, "_poll_for_token", _fake_poll)
seen = {}
def _cb(url, code):
order.append("verify")
seen["url"] = url
seen["code"] = code
# We only assert the callback fires before polling. Post-poll token
# validation (JWT usability checks) is out of scope and may raise on the
# synthetic token — swallow it; the ordering assertion is what matters.
try:
auth._nous_device_code_login(open_browser=False, on_verification=_cb)
except Exception:
pass
assert order[:2] == ["verify", "poll"], "callback must fire before polling"
assert seen["url"] == "https://portal.example/device?code=ABCD"
assert seen["code"] == "ABCD-1234"

View file

@ -94,9 +94,31 @@ class TestMcpEndpoints:
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.
# carry the install/enabled status fields plus the inspection detail
# the dashboard renders (transport target, install source, guidance) so
# users can vet an entry before installing.
for e in body["entries"]:
assert {"name", "transport", "installed", "enabled", "needs_install"} <= set(e)
assert {
"name",
"transport",
"auth_type",
"installed",
"enabled",
"needs_install",
"command",
"args",
"url",
"install_url",
"install_ref",
"bootstrap",
"default_enabled",
"post_install",
} <= set(e)
# http entries expose a url; stdio entries expose a command.
if e["transport"] == "http":
assert e["url"]
elif e["transport"] == "stdio":
assert e["command"]
def test_catalog_install_unknown_404(self):
r = self.client.post("/api/mcp/catalog/install", json={"name": "no-such-mcp-xyz"})

View file

@ -660,3 +660,68 @@ def test_two_custom_providers_with_overlap_both_survive():
assert a_row["total_models"] == 2
assert b_row["total_models"] == 2
def test_build_models_payload_no_max_models_returns_full_list():
"""When max_models is not passed (None), build_models_payload must
return the full model list not truncate to the old default of 50.
Regression for #48279: Kilo Gateway picker was capped at 50 of 336
models, making most models undiscoverable via search."""
full_models = [f"model-{i}" for i in range(100)]
rows = [
{
"slug": "kilocode",
"name": "Kilo Code",
"models": full_models,
"total_models": len(full_models),
"is_current": False,
"is_user_defined": False,
"source": "built-in",
},
]
ctx = _empty_ctx()
with _list_auth_returning(rows):
# No max_models argument — should return all 100 models
payload = build_models_payload(ctx)
kilo_row = next(r for r in payload["providers"] if r["slug"] == "kilocode")
assert kilo_row["models"] == full_models
assert kilo_row["total_models"] == 100
assert len(kilo_row["models"]) == 100
# ─── refresh flag (cache-bust) ─────────────────────────────────────────
def test_build_models_payload_forwards_refresh_flag():
"""build_models_payload must forward refresh= to list_authenticated_providers.
The desktop picker's "Refresh Models" control passes refresh=True; the
flag has to reach list_authenticated_providers so the per-provider
model-id cache gets busted. Default opens pass refresh=False.
"""
captured: dict = {}
def _capture(*args, **kwargs):
captured["refresh"] = kwargs.get("refresh")
return []
with patch("hermes_cli.model_switch.list_authenticated_providers", side_effect=_capture):
build_models_payload(_empty_ctx())
assert captured["refresh"] is False
with patch("hermes_cli.model_switch.list_authenticated_providers", side_effect=_capture):
build_models_payload(_empty_ctx(), refresh=True)
assert captured["refresh"] is True
def test_list_authenticated_providers_refresh_busts_cache():
"""refresh=True clears the provider-model disk cache exactly once;
refresh=False leaves it untouched (so normal picker opens stay snappy)."""
from hermes_cli import model_switch
with patch("hermes_cli.models.clear_provider_models_cache") as clear:
model_switch.list_authenticated_providers(refresh=False)
assert clear.call_count == 0
model_switch.list_authenticated_providers(refresh=True)
assert clear.call_count == 1

View file

@ -0,0 +1,46 @@
"""Tests for the declarative memory-provider registry."""
from hermes_cli.memory_providers import (
KIND_SECRET,
KIND_SELECT,
get_memory_provider,
)
def test_hindsight_is_declared():
provider = get_memory_provider("hindsight")
assert provider is not None
assert provider.label == "Hindsight"
assert {field.key for field in provider.fields} == {
"mode",
"api_key",
"api_url",
"bank_id",
"recall_budget",
}
def test_hindsight_mode_gating_is_expressed_as_select_options():
provider = get_memory_provider("hindsight")
assert provider is not None
mode = next(field for field in provider.fields if field.key == "mode")
assert mode.kind == KIND_SELECT
assert mode.allowed_values() == {"cloud", "local_external"}
# local_embedded is intentionally unsupported on desktop.
assert "local_embedded" not in mode.allowed_values()
def test_api_key_is_a_secret_bound_to_env():
provider = get_memory_provider("hindsight")
assert provider is not None
api_key = next(field for field in provider.fields if field.key == "api_key")
assert api_key.kind == KIND_SECRET
assert api_key.is_secret is True
assert api_key.env_key == "HINDSIGHT_API_KEY"
def test_unknown_provider_is_none():
assert get_memory_provider("builtin") is None

View file

@ -423,6 +423,71 @@ class TestIntegrationWithModelsModule:
assert nous_row is not None, "nous row must appear when authed"
assert nous_row["models"] == expected
def test_picker_max_models_cap_semantics(self, tmp_path, monkeypatch):
"""The cap argument has three distinct meanings on the real slicing
path: ``None`` = unlimited (the cap-removal fix, #48297), ``0`` = no
models (preserved for slug-only callers), an int N = first N. Guards
the ``is not None`` distinction the cap-removal follow-up introduced
a ``if max_models`` (falsy) check would conflate ``0`` with unlimited.
"""
import importlib
from hermes_cli import model_catalog
from hermes_cli.models import get_curated_nous_model_ids
importlib.reload(model_catalog)
try:
from hermes_cli.model_switch import (
list_authenticated_providers,
list_picker_providers,
)
active_home = Path(os.environ["HERMES_HOME"])
(active_home / "auth.json").write_text(
json.dumps(
{
"providers": {"nous": {"access_token": "fake"}},
"credential_pool": {},
}
)
)
with patch.object(
model_catalog, "_fetch_manifest", return_value=_valid_manifest()
), patch("hermes_cli.models.check_nous_free_tier", return_value=False), patch(
"hermes_cli.models.union_with_portal_free_recommendations",
side_effect=lambda ids, *a, **k: (ids, {}),
), patch(
"hermes_cli.models.union_with_portal_paid_recommendations",
side_effect=lambda ids, *a, **k: (ids, {}),
):
expected = get_curated_nous_model_ids()
full = list_picker_providers(current_provider="nous", max_models=None)
one = list_picker_providers(current_provider="nous", max_models=1)
# 0 is exercised on list_authenticated_providers (the slug-only
# path); the picker variant drops empty-model rows entirely, so
# the empty-list contract lives on the auth-providers call.
zero = list_authenticated_providers(
current_provider="nous", max_models=0
)
finally:
model_catalog.reset_cache()
def _nous(rows):
return next((r for r in rows if r["slug"] == "nous"), None)
# Only meaningful when the curated list actually exceeds 1 entry.
assert len(expected) > 1, "test needs a multi-model curated nous list"
full_row = _nous(full)
assert full_row is not None and full_row["models"] == expected
one_row = _nous(one)
assert one_row is not None and one_row["models"] == expected[:1]
zero_row = _nous(zero)
# 0 means an empty model list — NOT unlimited. total_models still real.
assert zero_row is not None
assert zero_row["models"] == []
assert zero_row["total_models"] == len(expected)
# -----------------------------------------------------------------------------
# Drift guard — prevent the in-repo curated lists from going out of sync with

View file

@ -0,0 +1,53 @@
"""Guard: every `hermes update` path that reports user-modified skills must
also tell the user how to find them.
`hermes update` keeps (does not overwrite) bundled skills the user edited and
prints a ``~ N user-modified (kept)`` count. There are two independent update
code paths in ``hermes_cli/main.py`` that print this notice (the git-pull path
in ``_cmd_update_impl`` and the unpack/install path). Both must point the user
at ``hermes skills list-modified`` so the count is actionable otherwise,
depending on which path a user hits, they may never learn the discovery command
exists.
This is an *invariant* test (the two sibling notices must agree), not a literal
snapshot: it asserts the relationship "count line ⇒ discovery hint", so it
keeps holding if the wording is reworded, as long as both sites stay in sync.
"""
import re
from pathlib import Path
import hermes_cli.main as main_mod
_COUNT_RE = re.compile(r"user-modified \(kept\)")
_HINT_RE = re.compile(r"hermes skills list-modified")
def _source_lines() -> list[str]:
return Path(main_mod.__file__).read_text(encoding="utf-8").splitlines()
def test_every_user_modified_notice_points_at_list_modified():
lines = _source_lines()
count_sites = [i for i, ln in enumerate(lines) if _COUNT_RE.search(ln)]
# The notice must exist somewhere (guard against it being deleted outright),
# but we deliberately do NOT assert a fixed *count* of sites: consolidating
# the duplicated print paths into a shared helper is a welcome refactor and
# must not fail this test. The invariant is per-site, not how many sites.
assert count_sites, (
"no 'user-modified (kept)' notice found in main.py — the update "
"summary that surfaces kept user edits appears to have been removed"
)
for idx in count_sites:
# The count print and its discovery hint sit on adjacent lines; allow a
# small window so wording/formatting tweaks don't break the check.
window = "\n".join(lines[idx : idx + 5])
assert _HINT_RE.search(window), (
"a 'user-modified (kept)' notice near line "
f"{idx + 1} of main.py does not point users at "
"`hermes skills list-modified` within the following lines — the "
"update paths have drifted apart again:\n" + window
)

View file

@ -1,5 +1,6 @@
"""Tests for hermes_cli.web_server and related config utilities."""
import asyncio
import os
import json
import shutil
@ -264,6 +265,110 @@ class TestWebServerEndpoints:
assert web_server._dashboard_local_update_managed_externally() is True
@staticmethod
def _provider_field_map(payload):
return {field["key"]: field for field in payload["fields"]}
def test_get_memory_provider_config_returns_safe_defaults(self):
resp = self.client.get("/api/memory/providers/hindsight/config")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "hindsight"
assert data["label"] == "Hindsight"
fields = self._provider_field_map(data)
assert fields["mode"]["kind"] == "select"
assert fields["mode"]["value"] == "cloud"
assert {opt["value"] for opt in fields["mode"]["options"]} == {"cloud", "local_external"}
assert fields["api_url"]["value"] == "https://api.hindsight.vectorize.io"
assert fields["bank_id"]["value"] == "hermes"
assert fields["recall_budget"]["value"] == "mid"
assert fields["api_key"]["kind"] == "secret"
assert fields["api_key"]["is_set"] is False
def test_put_memory_provider_config_writes_config_and_secret(self):
from hermes_constants import get_hermes_home
from hermes_cli.config import load_config, load_env
resp = self.client.put(
"/api/memory/providers/hindsight/config",
json={
"values": {
"mode": "local_external",
"api_url": "http://localhost:8888",
"api_key": "hs-test-key",
"bank_id": "ben-bank",
"recall_budget": "high",
}
},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert load_config()["memory"]["provider"] == "hindsight"
assert load_env()["HINDSIGHT_API_KEY"] == "hs-test-key"
config_path = get_hermes_home() / "hindsight" / "config.json"
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
assert provider_config == {
"mode": "local_external",
"api_url": "http://localhost:8888",
"bank_id": "ben-bank",
"recall_budget": "high",
}
def test_put_memory_provider_config_rejects_unsupported_select_value(self):
resp = self.client.put(
"/api/memory/providers/hindsight/config",
json={
"values": {
"mode": "local_embedded",
"api_url": "http://localhost:8888",
"bank_id": "hermes",
"recall_budget": "mid",
}
},
)
assert resp.status_code == 400
def test_put_unknown_memory_provider_returns_404(self):
resp = self.client.put(
"/api/memory/providers/nope/config", json={"values": {}}
)
assert resp.status_code == 404
def test_get_unknown_memory_provider_returns_empty_schema(self):
resp = self.client.get("/api/memory/providers/builtin/config")
assert resp.status_code == 200
assert resp.json()["fields"] == []
def test_get_memory_provider_config_does_not_return_secret(self):
self.client.put(
"/api/memory/providers/hindsight/config",
json={
"values": {
"mode": "cloud",
"api_url": "https://api.hindsight.vectorize.io",
"api_key": "secret-value",
"bank_id": "hermes",
"recall_budget": "mid",
}
},
)
resp = self.client.get("/api/memory/providers/hindsight/config")
assert resp.status_code == 200
data = resp.json()
fields = self._provider_field_map(data)
assert fields["api_key"]["is_set"] is True
assert fields["api_key"]["value"] == ""
assert "secret-value" not in json.dumps(data)
# ── GET /api/media (remote image display) ───────────────────────────
def test_get_media_serves_image_in_root(self):
@ -377,7 +482,6 @@ class TestWebServerEndpoints:
assert config["dashboard"]["theme"] == "ember"
assert config["dashboard"]["font"] == "jetbrains-mono"
def test_get_sessions_uses_only_persisted_cwd(self, monkeypatch):
"""Session rows without persisted cwd must not inherit TERMINAL_CWD.
@ -4334,6 +4438,79 @@ class TestNormaliseThemeExtensions:
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
class TestDeleteSessionEndpoint:
"""Tests for ``DELETE /api/sessions/{session_id}`` — the single-row delete
behind the desktop sidebar's per-session delete.
The desktop optimistically removes the row, then RESTORES it on any error
and surfaces the message. So a 404 on a row that is already gone (reaped by
empty-session hygiene, or removed by a concurrent client both common amid
/goal + auto-compression churn that leaves transient empty rows) resurrected
a ghost row and showed "session not found". DELETE must be idempotent and
resolve ids like every other session endpoint.
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self, ids):
from hermes_state import SessionDB
db = SessionDB()
try:
for sid in ids:
db.create_session(session_id=sid, source="cli")
finally:
db.close()
def _exists(self, sid) -> bool:
from hermes_state import SessionDB
db = SessionDB()
try:
return db.get_session(sid) is not None
finally:
db.close()
def test_delete_existing_session(self):
self._seed(["real_one"])
resp = self.auth_client.delete("/api/sessions/real_one")
assert resp.status_code == 200
assert resp.json().get("ok") is True
assert not self._exists("real_one")
def test_delete_absent_session_is_idempotent(self):
# PREMISE / regression: deleting a row that no longer exists must NOT
# 404 — the desktop would resurrect the ghost row and show
# "session not found". DELETE's contract is "ensure it's gone".
resp = self.auth_client.delete("/api/sessions/never_existed")
assert resp.status_code == 200
assert resp.json().get("ok") is True
def test_delete_resolves_unique_prefix(self):
# Symmetry with the other session endpoints, which all resolve ids.
self._seed(["20260618_abcdef_unique"])
resp = self.auth_client.delete("/api/sessions/20260618_abcdef")
assert resp.status_code == 200
assert resp.json().get("ok") is True
assert not self._exists("20260618_abcdef_unique")
class TestBulkDeleteSessionsEndpoint:
"""Tests for ``POST /api/sessions/bulk-delete`` — backs the
dashboard's "Delete N selected" flow on the sessions page.
@ -4956,6 +5133,107 @@ class TestPtyWebSocket:
pass
assert exc.value.code == 4401
def test_resolve_chat_argv_async_uses_worker_thread(self, monkeypatch):
captured: dict = {}
def fake_resolve(resume=None, sidecar_url=None, profile=None):
captured["resume"] = resume
captured["sidecar_url"] = sidecar_url
captured["profile"] = profile
return (["node", "dist/entry.js"], "/tmp/ui-tui", {"NODE_ENV": "production"})
async def fake_to_thread(fn, *args, **kwargs):
captured["thread_fn"] = fn
captured["thread_args"] = args
captured["thread_kwargs"] = kwargs
return fn(*args, **kwargs)
monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", fake_resolve)
monkeypatch.setattr(self.ws_module.asyncio, "to_thread", fake_to_thread)
argv, cwd, env = asyncio.run(
self.ws_module._resolve_chat_argv_async(
resume="sess-42",
sidecar_url="ws://127.0.0.1:9119/api/pub?channel=abc",
profile="worker",
)
)
assert callable(captured["thread_fn"])
assert captured["thread_args"] == ()
assert captured["thread_kwargs"] == {
"resume": "sess-42",
"sidecar_url": "ws://127.0.0.1:9119/api/pub?channel=abc",
"profile": "worker",
}
assert argv == ["node", "dist/entry.js"]
assert cwd == "/tmp/ui-tui"
assert env == {"NODE_ENV": "production"}
assert captured["resume"] == "sess-42"
assert captured["sidecar_url"] == "ws://127.0.0.1:9119/api/pub?channel=abc"
assert captured["profile"] == "worker"
def test_pty_ws_resolves_argv_through_async_wrapper(self, monkeypatch):
captured: dict = {}
async def fake_resolve_async(resume=None, sidecar_url=None, profile=None):
captured["resume"] = resume
captured["sidecar_url"] = sidecar_url
captured["profile"] = profile
return (["/bin/sh", "-c", "printf async-resolve-ok"], None, None)
monkeypatch.setattr(self.ws_module, "_resolve_chat_argv_async", fake_resolve_async)
with self.client.websocket_connect(self._url(resume="sess-99")) as conn:
try:
conn.receive_bytes()
except Exception:
pass
assert captured["resume"] == "sess-99"
def _assert_pty_propagates(self, monkeypatch, raising_resolver, *, profile=None, expect_detail=None):
"""Drive /api/pty with a resolver that raises, and assert the error
propagates through the real _resolve_chat_argv_async -> asyncio.to_thread
-> lock -> re-raise chain into pty_ws's handler: the "Chat unavailable"
notice is sent and the socket closes with code 1011 (the stable
contract we assert the close code, not the exact notice wording)."""
from starlette.websockets import WebSocketDisconnect
# Patch the REAL resolver so the whole wrapper/to_thread/lock chain runs.
monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", raising_resolver)
url = self._url(profile=profile) if profile else self._url()
with self.client.websocket_connect(url) as conn:
notice = conn.receive_text()
with pytest.raises(WebSocketDisconnect) as exc:
conn.receive_text()
assert "Chat unavailable" in notice
assert exc.value.code == 1011
if expect_detail is not None:
assert expect_detail in notice
def test_pty_ws_propagates_systemexit_through_async_wrapper(self, monkeypatch):
"""SystemExit from _make_tui_argv (node/npm missing) propagates through
the async wrapper and is caught by pty_ws's ``except SystemExit``."""
def boom(resume=None, sidecar_url=None, profile=None):
raise SystemExit("node not found")
self._assert_pty_propagates(monkeypatch, boom)
def test_pty_ws_propagates_httpexception_through_async_wrapper(self, monkeypatch):
"""An invalid-profile HTTPException raised inside the threaded resolver
propagates through the wrapper and hits pty_ws's ``except HTTPException``."""
from fastapi import HTTPException
def bad_profile(resume=None, sidecar_url=None, profile=None):
raise HTTPException(status_code=404, detail="unknown profile")
self._assert_pty_propagates(
monkeypatch, bad_profile, profile="ghost", expect_detail="unknown profile"
)
def test_streams_child_stdout_to_client(self, monkeypatch):
monkeypatch.setattr(
self.ws_module,

View file

@ -83,6 +83,50 @@ def test_walks_from_middle_of_chain(db):
assert db.resolve_resume_session_id("c") == "d"
def test_follows_compression_tip_when_parent_retains_messages(db):
# The bug behind the desktop "I came back and the reply isn't there" report
# on large sessions: auto-compression ends the live session and forks a
# continuation child, but a long parent keeps its own flushed message rows.
# The empty-head walk below never redirects a non-empty head, so resuming
# the parent id reloaded the pre-compression transcript and the response
# generated *after* compression (which lives in the continuation) was
# missing. resolve_resume_session_id must follow the compression-tip chain
# forward even when the parent still has messages.
base = int(time.time()) - 10_000
db.create_session("root", source="cli")
db.append_message("root", role="user", content="pre-compression turn")
db.end_session("root", "compression")
db.create_session("cont", source="cli", parent_session_id="root")
db.append_message("cont", role="assistant", content="post-compression reply")
# Force deterministic ordering so the continuation's started_at is clearly
# at/after the parent's ended_at (the get_compression_tip discriminator).
conn = db._conn
assert conn is not None
conn.execute("UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'root'", (base, base + 50))
conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont'", (base + 100,))
conn.commit()
assert db.resolve_resume_session_id("root") == "cont"
def test_compression_tip_not_confused_with_delegation_child(db):
# A delegation/branch child is created while the parent is still live (the
# parent is NOT ended with end_reason='compression'), so resuming the
# parent must stay on the parent, not get hijacked into the subagent branch.
base = int(time.time()) - 10_000
db.create_session("conv", source="cli")
db.append_message("conv", role="user", content="parent turn")
db.create_session("subagent", source="cli", parent_session_id="conv")
db.append_message("subagent", role="assistant", content="delegated work")
conn = db._conn
assert conn is not None
conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'conv'", (base,))
conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'subagent'", (base + 100,))
conn.commit()
assert db.resolve_resume_session_id("conv") == "conv"
def test_prefers_most_recent_child_when_fork_exists(db):
# If a session was somehow forked (two children), pick the latest one.
# In practice, compression only produces single-chain shape, but the helper

View file

@ -239,6 +239,7 @@ class TestOpenVikingSkillQuerySafety:
{
"role": "assistant",
"parts": [{"type": "text", "text": "Done."}],
"peer_id": "hermes",
},
]
},
@ -474,8 +475,8 @@ class TestOpenVikingBrowse:
class TestOpenVikingMemoryUriBuilder:
"""Regression tests for _build_memory_uri — fixes #36969.
Before the fix the URI omitted /agent/{agent}/, causing all agents
under the same user to share the same memory namespace.
OpenViking's current memory layout stores peer-scoped memories under
viking://user/peers/{peer_id}/...
"""
def _make_provider(self, user="alice", agent="coder"):
@ -484,19 +485,19 @@ class TestOpenVikingMemoryUriBuilder:
p._agent = agent
return p
def test_uri_layout_includes_agent_segment(self):
"""URI must contain /agent/{agent}/ between user and memories."""
def test_uri_layout_includes_peer_segment(self):
"""URI must contain /peers/{peer_id}/ between user and memories."""
p = self._make_provider(user="alice", agent="coder")
uri = p._build_memory_uri("preferences")
assert uri.startswith("viking://user/alice/agent/coder/memories/preferences/mem_")
assert uri.startswith("viking://user/peers/coder/memories/preferences/mem_")
assert uri.endswith(".md")
def test_uri_uses_configured_agent_not_default(self):
"""_agent value must be interpolated — not hardcoded to 'hermes'."""
def test_uri_uses_configured_peer_not_default(self):
"""_agent value is the OpenViking actor peer ID, not hardcoded to 'hermes'."""
p = self._make_provider(user="alice", agent="research-bot")
uri = p._build_memory_uri("entities")
assert "/agent/research-bot/" in uri
assert "/agent/hermes/" not in uri
assert "/peers/research-bot/" in uri
assert "/peers/hermes/" not in uri
def test_uri_slug_is_twelve_hex_chars_and_unique(self):
"""Slug must be 12 hex chars and differ between calls."""

View file

@ -369,7 +369,7 @@ def test_post_setup_create_remote_user_profile_can_mirror_to_openviking_store(tm
_prompt_from_values({
"OpenViking server URL": "https://openviking.example",
"OpenViking user API key": "user-secret",
"OpenViking agent": "hermes",
"Hermes peer ID in OpenViking": "hermes",
"OpenViking profile name": "VPS",
}),
)
@ -411,7 +411,7 @@ def test_post_setup_create_remote_user_can_keep_hermes_only(tmp_path, monkeypatc
_prompt_from_values({
"OpenViking server URL": "https://openviking.example",
"OpenViking user API key": "user-secret",
"OpenViking agent": "agent",
"Hermes peer ID in OpenViking": "agent",
}),
)
config = {"memory": {}}
@ -455,7 +455,7 @@ def test_post_setup_create_openviking_service_validates_after_api_key(tmp_path,
_prompt_from_values(
{
"OpenViking API key": "service-secret",
"OpenViking agent": "agent",
"Hermes peer ID in OpenViking": "agent",
},
forbidden={"OpenViking server URL", "OpenViking user API key", "OpenViking root API key"},
),
@ -540,7 +540,7 @@ def test_post_setup_user_key_path_can_route_detected_root_key_to_root_setup(tmp_
"OpenViking user API key": "root-secret",
"OpenViking account": "acct",
"OpenViking user": "alice",
"OpenViking agent": "agent",
"Hermes peer ID in OpenViking": "agent",
}
return values.get(label, default or "")
@ -549,7 +549,7 @@ def test_post_setup_user_key_path_can_route_detected_root_key_to_root_setup(tmp_
OpenVikingMemoryProvider().post_setup(str(hermes_home), config)
assert prompt_events.count("OpenViking agent") == 1
assert prompt_events.count("Hermes peer ID in OpenViking") == 1
env_text = (hermes_home / ".env").read_text(encoding="utf-8")
assert "OPENVIKING_API_KEY=root-secret" in env_text
assert "OPENVIKING_ACCOUNT=acct" in env_text
@ -580,7 +580,7 @@ def test_post_setup_root_key_path_can_route_detected_user_key_to_user_setup(tmp_
{
"OpenViking server URL": "https://openviking.example",
"OpenViking root API key": "user-secret",
"OpenViking agent": "agent",
"Hermes peer ID in OpenViking": "agent",
},
forbidden={"OpenViking user API key", "OpenViking account", "OpenViking user"},
),
@ -616,7 +616,7 @@ def test_manual_root_key_flow_prints_validation_progress(monkeypatch, capsys):
"OpenViking root API key": "root-secret",
"OpenViking account": "acct",
"OpenViking user": "alice",
"OpenViking agent": "agent",
"Hermes peer ID in OpenViking": "agent",
}),
lambda *args, **kwargs: next(choices),
-1,
@ -1091,7 +1091,7 @@ def test_post_setup_local_server_down_can_offer_autostart(tmp_path, monkeypatch)
"_prompt",
_prompt_from_values({
"OpenViking server URL": "localhost",
"OpenViking agent": "agent",
"Hermes peer ID in OpenViking": "agent",
}),
)
config = {"memory": {}}
@ -1126,7 +1126,7 @@ def test_post_setup_invalid_env_profile_can_create_new_config(tmp_path, monkeypa
_prompt_from_values({
"OpenViking server URL": "https://openviking.example",
"OpenViking user API key": "user-secret",
"OpenViking agent": "agent",
"Hermes peer ID in OpenViking": "agent",
}),
)
config = {"memory": {}}
@ -1210,6 +1210,36 @@ def test_tool_search_sends_limit_not_legacy_top_k():
assert "top_k" not in payload
def test_tool_search_uses_find_for_normal_search():
provider = OpenVikingMemoryProvider()
provider._client = MagicMock()
provider._client.post.return_value = {
"result": {"memories": [], "resources": [], "skills": [], "total": 0}
}
provider._tool_search({"query": "simple lookup", "mode": "fast"})
provider._client.post.assert_called_once_with("/api/v1/search/find", {
"query": "simple lookup",
})
def test_tool_search_uses_session_search_for_deep_search():
provider = OpenVikingMemoryProvider()
provider._client = MagicMock()
provider._session_id = "session-123"
provider._client.post.return_value = {
"result": {"memories": [], "resources": [], "skills": [], "total": 0}
}
provider._tool_search({"query": "connect facts", "mode": "deep"})
provider._client.post.assert_called_once_with("/api/v1/search/search", {
"query": "connect facts",
"session_id": "session-123",
})
def test_tool_add_resource_uploads_existing_local_file(tmp_path):
sample = tmp_path / "sample.md"
sample.write_text("# Local resource\n", encoding="utf-8")
@ -1457,10 +1487,10 @@ def test_viking_client_upload_temp_file_uses_multipart_identity_headers(tmp_path
assert "files" in captured_kwargs
assert "json" not in captured_kwargs
headers = captured_kwargs["headers"]
assert headers["X-OpenViking-Account"] == "test-account"
assert headers["X-OpenViking-User"] == "test-user"
assert "X-OpenViking-Account" not in headers
assert "X-OpenViking-User" not in headers
assert headers["X-OpenViking-Actor-Peer"] == "test-agent"
assert headers["X-OpenViking-Agent"] == "test-agent"
assert "X-OpenViking-Agent" not in headers
assert headers["X-API-Key"] == "test-key"
assert "Content-Type" not in headers
@ -1517,16 +1547,17 @@ def test_viking_client_headers_include_bearer_when_api_key_set():
headers = client._headers()
assert headers["X-API-Key"] == "test-key"
assert headers["Authorization"] == "Bearer test-key"
assert headers["X-OpenViking-Actor-Peer"] == "hermes"
assert "X-OpenViking-Agent" not in headers
assert "X-OpenViking-Account" not in headers
assert "X-OpenViking-User" not in headers
def test_viking_client_headers_send_tenant_when_default():
# account/user set to the literal string "default". OpenViking 0.3.x
# requires X-OpenViking-Account and X-OpenViking-User for ROOT API key
# requests to tenant-scoped APIs — omitting them causes
# INVALID_ARGUMENT errors even when account="default".
def test_viking_client_headers_send_tenant_in_local_mode():
# Local/trusted mode needs explicit tenant identity headers.
client = _VikingClient(
"https://example.com",
api_key="test-key",
api_key="",
account="default",
user="default",
agent="hermes",
@ -1535,14 +1566,13 @@ def test_viking_client_headers_send_tenant_when_default():
assert headers["X-OpenViking-Account"] == "default"
assert headers["X-OpenViking-User"] == "default"
assert headers["X-OpenViking-Actor-Peer"] == "hermes"
assert headers["X-OpenViking-Agent"] == "hermes"
assert headers["Authorization"] == "Bearer test-key"
assert "X-OpenViking-Agent" not in headers
assert "Authorization" not in headers
def test_viking_client_headers_send_tenant_when_empty_falls_back_to_default(monkeypatch):
_clear_openviking_tenant_env(monkeypatch)
# Empty account/user strings fall back to "default" via the constructor.
# Headers are sent even for the default value — ROOT API keys need them.
# Empty account/user strings fall back to "default" in local mode.
client = _VikingClient(
"https://example.com",
api_key="",
@ -1553,11 +1583,13 @@ def test_viking_client_headers_send_tenant_when_empty_falls_back_to_default(monk
headers = client._headers()
assert headers["X-OpenViking-Account"] == "default"
assert headers["X-OpenViking-User"] == "default"
assert headers["X-OpenViking-Actor-Peer"] == "hermes"
assert "X-OpenViking-Agent" not in headers
assert "Authorization" not in headers
assert "X-API-Key" not in headers
def test_viking_client_headers_sent_with_real_tenant_values():
def test_viking_client_headers_can_include_tenant_for_trusted_retry():
client = _VikingClient(
"https://example.com",
api_key="test-key",
@ -1565,9 +1597,54 @@ def test_viking_client_headers_sent_with_real_tenant_values():
user="real-user",
agent="hermes",
)
headers = client._headers()
headers = client._headers(include_tenant=True)
assert headers["X-OpenViking-Account"] == "real-account"
assert headers["X-OpenViking-User"] == "real-user"
assert headers["Authorization"] == "Bearer test-key"
def test_viking_client_retries_with_tenant_headers_for_trusted_mode(monkeypatch):
client = _VikingClient(
"https://example.com",
api_key="test-key",
account="acct",
user="usr",
agent="hermes",
)
captured_headers = []
def capture_get(url, **kwargs):
captured_headers.append(kwargs.get("headers") or {})
if len(captured_headers) == 1:
return SimpleNamespace(
status_code=400,
text="",
json=lambda: {
"status": "error",
"error": {
"code": "INVALID_ARGUMENT",
"message": "Trusted mode requests must include X-OpenViking-Account.",
},
},
raise_for_status=lambda: None,
)
return SimpleNamespace(
status_code=200,
text="",
json=lambda: {"status": "ok", "result": {"ok": True}},
raise_for_status=lambda: None,
)
monkeypatch.setattr(client._httpx, "get", capture_get)
assert client.get("/api/v1/system/status") == {
"status": "ok",
"result": {"ok": True},
}
assert "X-OpenViking-Account" not in captured_headers[0]
assert "X-OpenViking-User" not in captured_headers[0]
assert captured_headers[1]["X-OpenViking-Account"] == "acct"
assert captured_headers[1]["X-OpenViking-User"] == "usr"
def test_viking_client_health_sends_auth_headers(monkeypatch):
@ -1590,6 +1667,10 @@ def test_viking_client_health_sends_auth_headers(monkeypatch):
assert client.health() is True
assert captured["url"] == "https://example.com/health"
assert captured["headers"]["Authorization"] == "Bearer test-key"
assert captured["headers"]["X-OpenViking-Actor-Peer"] == "hermes"
assert "X-OpenViking-Agent" not in captured["headers"]
assert "X-OpenViking-Account" not in captured["headers"]
assert "X-OpenViking-User" not in captured["headers"]
def test_viking_client_validate_auth_uses_authenticated_system_status(monkeypatch):
@ -1620,8 +1701,9 @@ def test_viking_client_validate_auth_uses_authenticated_system_status(monkeypatc
}
assert captured["url"] == "https://example.com/api/v1/system/status"
assert captured["headers"]["Authorization"] == "Bearer test-key"
assert captured["headers"]["X-OpenViking-Account"] == "acct"
assert captured["headers"]["X-OpenViking-User"] == "alice"
assert captured["headers"]["X-OpenViking-Actor-Peer"] == "hermes"
assert "X-OpenViking-Account" not in captured["headers"]
assert "X-OpenViking-User" not in captured["headers"]
def test_viking_client_validate_root_access_uses_admin_accounts(monkeypatch):
@ -1650,10 +1732,9 @@ def test_viking_client_validate_root_access_uses_admin_accounts(monkeypatch):
assert client.validate_root_access() == {"status": "ok", "result": []}
assert captured["url"] == "https://example.com/api/v1/admin/accounts"
assert captured["headers"]["Authorization"] == "Bearer root-key"
# Empty account/user fall back to "default" and the tenant headers are
# always sent — ROOT API keys require them (#22414/#21232 contract).
assert captured["headers"]["X-OpenViking-Account"] == "default"
assert captured["headers"]["X-OpenViking-User"] == "default"
assert captured["headers"]["X-OpenViking-Actor-Peer"] == "hermes"
assert "X-OpenViking-Account" not in captured["headers"]
assert "X-OpenViking-User" not in captured["headers"]
def test_validate_openviking_reachability_uses_health_only(monkeypatch):
@ -2055,7 +2136,7 @@ def test_sync_turn_captures_session_id_before_worker_runs():
assert captured_payloads == [{
"messages": [
{"role": "user", "parts": [{"type": "text", "text": "u"}]},
{"role": "assistant", "parts": [{"type": "text", "text": "a"}]},
{"role": "assistant", "parts": [{"type": "text", "text": "a"}], "peer_id": "hermes"},
]
}]
@ -2099,7 +2180,7 @@ def test_sync_turn_retries_batch_write_with_fresh_client():
{
"messages": [
{"role": "user", "parts": [{"type": "text", "text": "u"}]},
{"role": "assistant", "parts": [{"type": "text", "text": "a"}]},
{"role": "assistant", "parts": [{"type": "text", "text": "a"}], "peer_id": "hermes"},
]
},
)]
@ -2453,7 +2534,7 @@ def test_on_memory_write_uses_content_write_independent_of_session_rotation():
assert captured_payloads[0]["content"] == "remember this"
assert captured_payloads[0]["mode"] == "create"
assert captured_payloads[0]["uri"].startswith(
"viking://user/usr/agent/hermes/memories/preferences/mem_"
"viking://user/peers/hermes/memories/preferences/mem_"
)

View file

@ -205,6 +205,216 @@ class TestPayloadSanitization:
}
class TestTraceScopeKey:
def _fresh_plugin(self):
mod_name = "plugins.observability.langfuse"
sys.modules.pop(mod_name, None)
return importlib.import_module(mod_name)
def test_trace_key_scopes_by_turn_id_when_available(self):
plugin = self._fresh_plugin()
key_a = plugin._trace_key("task-1", "session-1", turn_id="turn-a")
key_b = plugin._trace_key("task-1", "session-1", turn_id="turn-b")
assert key_a != key_b
assert "turn:turn-a" in key_a
assert "turn:turn-b" in key_b
def test_trace_key_scopes_by_api_request_id_when_turn_missing(self):
plugin = self._fresh_plugin()
key_a = plugin._trace_key("task-1", "session-1", api_request_id="req-a")
key_b = plugin._trace_key("task-1", "session-1", api_request_id="req-b")
assert key_a != key_b
assert "api:req-a" in key_a
assert "api:req-b" in key_b
def test_trace_key_keeps_legacy_shape_without_turn_or_api_id(self):
plugin = self._fresh_plugin()
assert plugin._trace_key("task-1", "session-1") == "task-1"
# ---------------------------------------------------------------------------
# End-to-end collision regression: two turns of ONE gateway session must not
# share trace state. The helper-level tests above prove _trace_key returns
# distinct keys; this drives the real pre/post hooks to prove the keys are
# actually threaded through so the second turn gets its own root trace.
#
# Gateway reality this reproduces:
# * task_id == session_id for every turn (gateway/run.py)
# * turn_id is unique per turn (turn_context.py)
# * api_call_count resets to 1 each turn (conversation_loop.py)
#
# Before the turn/request scoping, _trace_key collapsed to the constant
# session_id. That worked only because _finish_trace pops the key on a clean
# turn end. When turn 1 does NOT finalize (interrupted, tool-only final step,
# or empty final content), its state lingered under session_id and turn 2
# silently merged into turn 1's trace instead of opening its own.
# ---------------------------------------------------------------------------
class TestTurnTraceIsolation:
def _fresh_plugin(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
@staticmethod
def _fake_client(started):
"""A minimal Langfuse stand-in that records each root trace opened.
``_start_root_trace`` calls ``create_trace_id`` then opens a root via
``start_as_current_observation(...)`` (a context manager whose
``__enter__`` returns the root span). We record one entry per root
actually opened so the test can count distinct traces.
"""
class _Span:
def update(self, **kw):
pass
def end(self, **kw):
pass
def set_trace_io(self, **kw):
pass
def start_observation(self, **kw):
return _Span()
class _RootCM:
def __enter__(self):
return _Span()
def __exit__(self, *exc):
return False
class _Client:
def create_trace_id(self, seed=None):
return f"trace::{seed}"
def start_as_current_observation(self, **kw):
started.append(kw.get("trace_context", {}).get("trace_id"))
return _RootCM()
def flush(self):
pass
return _Client()
def _run_turn(self, mod, *, session, turn_n, finalize):
"""Drive one turn through the request-scoped hooks the gateway fires."""
task_id = session # gateway sets task_id == session_id
turn_id = f"{session}:{task_id}:turn{turn_n}"
api_call_count = 1 # resets every turn
api_request_id = f"{turn_id}:api:{api_call_count}"
mod.on_pre_llm_request(
task_id=task_id,
session_id=session,
model="m",
provider="p",
api_mode="chat",
api_call_count=api_call_count,
request_messages=[{"role": "user", "content": "hi"}],
turn_id=turn_id,
api_request_id=api_request_id,
)
# finalize=False => leave a tool call on the final response so
# _finish_trace is skipped and the turn's state lingers.
mod.on_post_llm_call(
task_id=task_id,
session_id=session,
model="m",
provider="p",
api_mode="chat",
api_call_count=api_call_count,
assistant_content_chars=5 if finalize else 0,
assistant_tool_call_count=0 if finalize else 1,
usage={"input_tokens": 10, "output_tokens": 5},
turn_id=turn_id,
api_request_id=api_request_id,
)
def test_unfinalized_turn_does_not_capture_next_turn(self, monkeypatch):
"""A turn that never finalizes must not absorb the following turn."""
mod = self._fresh_plugin()
started: list = []
monkeypatch.setattr(mod, "_get_langfuse", lambda: self._fake_client(started))
monkeypatch.setattr(mod, "_end_observation", lambda *a, **k: None)
mod._TRACE_STATE.clear()
# Turn 1 ends without finalizing (its final step still has a tool call).
self._run_turn(mod, session="sess-iso", turn_n=1, finalize=False)
# Turn 2 is a normal, fully finalizing turn in the SAME session.
self._run_turn(mod, session="sess-iso", turn_n=2, finalize=True)
# Each turn opened its OWN root trace. On the pre-fix code the second
# turn reused turn 1's lingering state and only one trace was opened.
assert len(started) == 2
# Turn 2 finalized and was popped by _finish_trace; only turn 1's
# (non-finalizing) state lingers. Assert the surviving key is turn 1's
# and that turn 2 never merged into it — `all(...)` over an empty set
# would pass vacuously, so pin the exact surviving key instead.
keys = list(mod._TRACE_STATE.keys())
assert len(keys) == 1
assert "turn1" in keys[0]
assert "turn2" not in keys[0]
def test_pre_and_post_hooks_share_one_key_within_a_turn(self, monkeypatch):
"""turn_id is preferred over api_request_id so the turn-scoped
post_llm_call (which carries no api_request_id) still resolves to the
same key as the request-scoped pre/post_api_request hooks. If the
ordering were reversed, finalization would silently break."""
mod = self._fresh_plugin()
turn_id = "S:T:turnX"
api_request_id = f"{turn_id}:api:1"
k_pre_api = mod._trace_key("T", "S", turn_id=turn_id, api_request_id=api_request_id)
k_post_api = mod._trace_key("T", "S", turn_id=turn_id, api_request_id=api_request_id)
k_post_turn = mod._trace_key("T", "S", turn_id=turn_id, api_request_id="")
assert k_pre_api == k_post_api == k_post_turn
def test_non_finalizing_turns_do_not_grow_state_unboundedly(self, monkeypatch):
"""Per-turn keys mean a turn that never finalizes leaves a lingering
entry. Without a cap that grows once per non-finalizing turn forever;
the LRU eviction must bound _TRACE_STATE at _MAX_TRACE_STATE.
"""
mod = self._fresh_plugin()
started: list = []
monkeypatch.setattr(mod, "_get_langfuse", lambda: self._fake_client(started))
monkeypatch.setattr(mod, "_end_observation", lambda *a, **k: None)
monkeypatch.setattr(mod, "_MAX_TRACE_STATE", 8)
mod._TRACE_STATE.clear()
# Far more non-finalizing turns than the cap.
for n in range(50):
self._run_turn(mod, session="sess-leak", turn_n=n, finalize=False)
assert len(mod._TRACE_STATE) <= 8
# The survivors are the most-recently-updated turns (LRU eviction).
surviving = sorted(int(k.rsplit("turn", 1)[1]) for k in mod._TRACE_STATE)
assert surviving == list(range(42, 50))
def test_trace_key_strings_unchanged_by_refactor(self):
"""Pin the exact key strings across all task/session/turn/api
combinations so the _scope_prefix extraction can never silently change
a key (keys are matched across hooks; a drift breaks finalization)."""
mod = self._fresh_plugin()
tk = mod._trace_key
assert tk("t", "s", turn_id="u") == "task:t:turn:u"
assert tk("", "s", turn_id="u") == "session:s:turn:u"
assert tk("t", "s", api_request_id="r") == "task:t:api:r"
assert tk("", "s", api_request_id="r") == "session:s:api:r"
assert tk("t", "s") == "t" # legacy: bare task_id
assert tk("", "s") == "session:s"
# turn_id wins over api_request_id when both are present.
assert tk("t", "s", turn_id="u", api_request_id="r") == "task:t:turn:u"
# ---------------------------------------------------------------------------
# Placeholder-credential guard (#23823).
#

View file

@ -252,6 +252,35 @@ def test_summarize_api_error_decorates_xai_body_message():
assert "X Premium+ does NOT include" in summary
def test_summarize_api_error_handles_nested_provider_message():
"""HF router may put a structured object in error.message."""
from run_agent import AIAgent
class _NestedProviderErr(Exception):
status_code = 400
body = {
"error": {
"message": {
"type": "Bad Request",
"code": "context_length_exceeded",
"message": (
"This model's maximum context length is 262144 tokens. "
"Please reduce the length of the messages."
),
"param": None,
},
"type": "invalid_request_error",
"param": None,
"code": None,
}
}
summary = AIAgent._summarize_api_error(_NestedProviderErr("400"))
assert "HTTP 400" in summary
assert "maximum context length is 262144 tokens" in summary
assert "context_length_exceeded" not in summary
def test_summarize_api_error_idempotent_for_entitlement_hint():
"""Decorating twice must not double up the hint."""
from run_agent import AIAgent

View file

@ -0,0 +1,87 @@
"""Guards for the multi-container Hermes WebUI install surface."""
from __future__ import annotations
from pathlib import Path
import runpy
from setuptools import Distribution
import setuptools
REPO_ROOT = Path(__file__).resolve().parent.parent
def _is_under(path: str, root: Path) -> bool:
try:
Path(path).resolve().relative_to(root.resolve())
except ValueError:
return False
return True
def test_docker_context_includes_license_file() -> None:
"""PEP 639 license-files metadata must resolve inside the Docker image."""
dockerignore = (REPO_ROOT / ".dockerignore").read_text(encoding="utf-8")
active_lines = [
line.strip()
for line in dockerignore.splitlines()
if line.strip() and not line.lstrip().startswith("#")
]
assert "LICENSE" not in active_lines
def test_setup_uses_temporary_outputs_when_source_tree_is_read_only(
monkeypatch,
) -> None:
"""WebUI installs from read-only /opt/hermes must not write build metadata."""
captured: dict[str, object] = {}
def capture_setup(**kwargs: object) -> None:
captured.update(kwargs)
monkeypatch.setattr(setuptools, "setup", capture_setup)
namespace = runpy.run_path(str(REPO_ROOT / "setup.py"))
cmdclass = captured["cmdclass"]
monkeypatch.setitem(
cmdclass["build"].finalize_options.__globals__,
"_source_tree_is_writable",
lambda: False,
)
monkeypatch.setitem(
cmdclass["egg_info"].finalize_options.__globals__,
"_source_tree_is_writable",
lambda: False,
)
build_cmd = cmdclass["build"](Distribution())
build_cmd.initialize_options()
build_cmd.finalize_options()
assert not _is_under(build_cmd.build_base, REPO_ROOT)
assert Path(build_cmd.build_base).name.startswith("hermes-agent-build")
source_relative_build = cmdclass["build"](Distribution())
source_relative_build.initialize_options()
source_relative_build.build_base = "nested/build"
source_relative_build.finalize_options()
assert not _is_under(source_relative_build.build_base, REPO_ROOT)
assert Path(source_relative_build.build_base).name.startswith("hermes-agent-build")
egg_info_cmd = cmdclass["egg_info"](Distribution())
egg_info_cmd.initialize_options()
egg_info_cmd.finalize_options()
assert egg_info_cmd.egg_base is not None
assert not _is_under(egg_info_cmd.egg_base, REPO_ROOT)
assert Path(egg_info_cmd.egg_base).name.startswith("hermes-agent-egg-info")
source_relative_egg_info = cmdclass["egg_info"](Distribution())
source_relative_egg_info.initialize_options()
source_relative_egg_info.egg_base = "."
source_relative_egg_info.finalize_options()
assert source_relative_egg_info.egg_base is not None
assert not _is_under(source_relative_egg_info.egg_base, REPO_ROOT)
assert Path(source_relative_egg_info.egg_base).name.startswith(
"hermes-agent-egg-info"
)

View file

@ -50,6 +50,20 @@ class _NoFtsExistingTableConnection(sqlite3.Connection):
return super().cursor(factory or _NoFtsExistingTableCursor)
class _NoTrigramCursor(sqlite3.Cursor):
"""Simulate a SQLite build with FTS5 but without the trigram tokenizer."""
def executescript(self, sql_script):
if "tokenize='trigram'" in sql_script:
raise sqlite3.OperationalError("no such tokenizer: trigram")
return super().executescript(sql_script)
class _NoTrigramConnection(sqlite3.Connection):
def cursor(self, factory=None):
return super().cursor(factory or _NoTrigramCursor)
@pytest.fixture()
def db(tmp_path):
"""Create a SessionDB with a temp database file."""
@ -330,6 +344,167 @@ class TestSessionLifecycle:
finally:
restored.close()
def test_base_fts_rebuilds_after_trigger_repair_without_trigram(
self, tmp_path, monkeypatch
):
"""Trigger repair must rebuild base FTS even when trigram is unavailable."""
db_path = tmp_path / "state.db"
seeded = SessionDB(db_path=db_path)
try:
seeded.create_session(session_id="s1", source="cli")
seeded.append_message("s1", role="user", content="already indexed")
for trigger in (
"messages_fts_insert",
"messages_fts_delete",
"messages_fts_update",
"messages_fts_trigram_insert",
"messages_fts_trigram_delete",
"messages_fts_trigram_update",
):
seeded._conn.execute(f"DROP TRIGGER IF EXISTS {trigger}")
seeded._conn.commit()
seeded.append_message("s1", role="assistant", content="repair only base needle")
finally:
seeded.close()
real_connect = sqlite3.connect
def connect_without_trigram(*args, **kwargs):
kwargs["factory"] = _NoTrigramConnection
return real_connect(*args, **kwargs)
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram)
restored = SessionDB(db_path=db_path)
try:
assert restored._fts_enabled is True
assert restored._trigram_available is False
assert restored._fts_table_exists("messages_fts") is True
assert len(restored.search_messages("needle")) == 1
finally:
restored.close()
def test_is_fts5_unavailable_error_catches_trigram_tokenizer(self):
"""Unit test: _is_fts5_unavailable_error matches 'no such tokenizer: trigram'."""
fts5_err = sqlite3.OperationalError("no such module: fts5")
trigram_err = sqlite3.OperationalError("no such tokenizer: trigram")
generic_tokenizer_err = sqlite3.OperationalError("no such tokenizer: foo")
unrelated_err = sqlite3.OperationalError("no such table: foo")
assert SessionDB._is_fts5_unavailable_error(fts5_err) is True
assert SessionDB._is_fts5_unavailable_error(trigram_err) is True
# Generic tokenizer errors should NOT match — only trigram.
assert SessionDB._is_fts5_unavailable_error(generic_tokenizer_err) is False
assert SessionDB._is_fts5_unavailable_error(unrelated_err) is False
def test_is_trigram_unavailable_error(self):
"""Unit test: _is_trigram_unavailable_error is scoped to trigram."""
trigram_err = sqlite3.OperationalError("no such tokenizer: trigram")
generic_err = sqlite3.OperationalError("no such tokenizer: foo")
fts5_err = sqlite3.OperationalError("no such module: fts5")
assert SessionDB._is_trigram_unavailable_error(trigram_err) is True
assert SessionDB._is_trigram_unavailable_error(generic_err) is False
assert SessionDB._is_trigram_unavailable_error(fts5_err) is False
def test_db_initializes_without_trigram_tokenizer(self, tmp_path, monkeypatch):
"""SessionDB must not crash when FTS5 exists but trigram tokenizer is missing."""
real_connect = sqlite3.connect
def connect_without_trigram(*args, **kwargs):
kwargs["factory"] = _NoTrigramConnection
return real_connect(*args, **kwargs)
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram)
db = SessionDB(db_path=tmp_path / "state.db")
try:
# Base FTS5 should still work (trigram is optional).
assert db._fts_enabled is True
assert db._fts_table_exists("messages_fts") is True
# Trigram table should NOT have been created.
assert db._fts_table_exists("messages_fts_trigram") is False
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="hello without trigram")
messages = db.get_messages("s1")
assert len(messages) == 1
assert messages[0]["content"] == "hello without trigram"
# FTS5 keyword search should still work.
assert len(db.search_messages("hello")) == 1
finally:
db.close()
def test_v11_migration_backfills_base_fts_when_trigram_unavailable(
self, tmp_path, monkeypatch
):
"""Regression: v11 migration must backfill base FTS even when trigram is unavailable."""
real_connect = sqlite3.connect
db_path = tmp_path / "state.db"
# Phase 1: create a DB at schema v10 with messages.
db = SessionDB(db_path=db_path)
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="legacy message alpha")
db.append_message("s1", role="assistant", content="legacy reply beta")
# Force schema version to v10 so migration runs on next open.
db._conn.execute(
"UPDATE schema_version SET version = 10"
)
db._conn.commit()
db.close()
# Phase 2: reopen with trigram disabled — migration should still
# backfill base FTS and make existing messages searchable.
def connect_without_trigram(*args, **kwargs):
kwargs["factory"] = _NoTrigramConnection
return real_connect(*args, **kwargs)
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram)
migrated_db = SessionDB(db_path=db_path)
try:
assert migrated_db._fts_enabled is True
assert migrated_db._trigram_available is False
assert migrated_db._fts_table_exists("messages_fts") is True
assert migrated_db._fts_table_exists("messages_fts_trigram") is False
# Existing messages must be searchable via base FTS.
results = migrated_db.search_messages("legacy message")
assert len(results) == 1
# snippet has FTS5 highlight markers (>>>...<<<); check raw content via get_messages
msgs = migrated_db.get_messages("s1")
assert any("legacy message" in m["content"] for m in msgs)
finally:
migrated_db.close()
def test_cjk_search_falls_back_to_like_when_trigram_unavailable(
self, tmp_path, monkeypatch
):
"""Regression: long CJK queries must fall back to LIKE when trigram is missing."""
real_connect = sqlite3.connect
db_path = tmp_path / "state.db"
def connect_without_trigram(*args, **kwargs):
kwargs["factory"] = _NoTrigramConnection
return real_connect(*args, **kwargs)
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram)
db = SessionDB(db_path=db_path)
try:
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="大别山项目计划书")
db.append_message("s1", role="user", content="长江大桥设计方案")
# 3+ CJK chars would normally use trigram, but it's unavailable.
# Must fall back to LIKE and still return results.
results = db.search_messages("大别山")
assert len(results) == 1
# Note: search_messages strips 'content' from results; use 'snippet'.
assert "大别山" in results[0]["snippet"]
finally:
db.close()
# =========================================================================
# Message storage

View file

@ -0,0 +1,94 @@
"""Regression tests for #48352: Windows PowerShell 5.1 native stderr.
PowerShell 5.1 turns stderr from native commands into ``NativeCommandError``
records when ``$ErrorActionPreference = "Stop"``. ``scripts/install.ps1`` has a
few git/uv calls where stderr can be normal progress output, so those calls must
run with EAP temporarily relaxed and then inspect ``$LASTEXITCODE``.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
def _install_ps1() -> str:
return INSTALL_PS1.read_text(encoding="utf-8")
def _assert_relaxed_call(text: str, command_pattern: str) -> None:
helper_block_pattern = (
r"Invoke-NativeWithRelaxedErrorAction\s*\{[^}]*"
+ command_pattern
+ r"[^}]*\}"
)
inline_pattern = (
r"\$ErrorActionPreference\s*=\s*\"Continue\"[\s\S]{0,900}?"
+ command_pattern
)
assert re.search(helper_block_pattern, text) or re.search(inline_pattern, text), (
f"install.ps1 must relax ErrorActionPreference around {command_pattern}"
)
def test_repository_stage_relieves_eap_for_ssh_and_https_git_clone() -> None:
text = _install_ps1()
assert "function Invoke-NativeWithRelaxedErrorAction" in text
_assert_relaxed_call(
text,
r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlSsh \$InstallDir",
)
_assert_relaxed_call(
text,
r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlHttps \$InstallDir",
)
def test_uv_venv_and_dependency_installs_relax_eap() -> None:
text = _install_ps1()
_assert_relaxed_call(text, r"& \$UvCmd venv venv --python \$PythonVersion")
_assert_relaxed_call(text, r"& \$UvCmd sync --extra all --locked")
_assert_relaxed_call(text, r"& \$UvCmd pip install -e \$tier\.Spec")
def test_uv_venv_failure_is_not_swallowed_after_eap_relax() -> None:
"""Relaxing EAP must not let a genuine `uv venv` failure pass as success.
Once EAP is relaxed, a real non-zero `uv venv` exit no longer aborts on its
own, so install.ps1 must capture $LASTEXITCODE right after the call and fail
fast otherwise the `venv` stage falsely reports success (Invoke-Stage emits
ok=true) when no venv was created. Regression guard for the gap caught while
reviewing #48372 (the explicit check originally proposed in #48463).
"""
text = _install_ps1()
# The uv-venv invocation, then an exit-code capture, then a throw — all
# within a small window after the relaxed call.
guard = re.search(
r"& \$UvCmd venv venv --python \$PythonVersion[\s\S]{0,400}?"
r"\$LASTEXITCODE[\s\S]{0,200}?"
r"-ne 0[\s\S]{0,200}?throw",
text,
)
assert guard is not None, (
"install.ps1 must capture uv venv's exit code and throw on failure after "
"relaxing ErrorActionPreference, so a genuine venv-creation failure isn't "
"reported as a successful stage"
)
def test_native_eap_helper_always_restores_previous_preference() -> None:
text = _install_ps1()
m = re.search(
r"function Invoke-NativeWithRelaxedErrorAction \{(?P<body>[\s\S]*?)^\}",
text,
re.MULTILINE,
)
assert m is not None, "expected a shared helper for NativeCommandError-safe calls"
body = m.group("body")
assert "$prevEAP = $ErrorActionPreference" in body
assert '$ErrorActionPreference = "Continue"' in body
assert "finally" in body
assert "$ErrorActionPreference = $prevEAP" in body

View file

@ -0,0 +1,77 @@
"""Regression: the Windows installer must not spawn a bare ``powershell``.
A user on Windows reported the installer getting stuck; running
``irm https://hermes-agent.nousresearch.com/install.ps1 | iex`` failed at the
uv step with::
[X] Failed to install uv: The term 'powershell' is not recognized as the
name of a cmdlet, function, script file, or operable program.
Root cause: ``Install-Uv`` spawned the astral uv installer via a hardcoded
bare ``powershell`` command. That name resolves only to *Windows PowerShell*
and only when its System32 directory is on ``PATH``. Under PowerShell 7+
(``pwsh``) -- or any session where ``powershell`` isn't on ``PATH`` -- the
spawn dies and uv installation aborts.
The fix resolves the PowerShell host executable (preferring the absolute path
of the running host, then ``powershell``/``pwsh`` via ``Get-Command``) and
invokes *that* instead of a bare name. These tests lock that contract at the
source level (the script only runs on Windows, so there's no runner to
execute it on Linux CI).
"""
from pathlib import Path
import pytest
_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1"
@pytest.fixture(scope="module")
def source() -> str:
return _INSTALL_PS1.read_text(encoding="utf-8")
def test_astral_uv_installer_not_spawned_via_bare_powershell(source: str):
"""The exact failing literal must be gone."""
forbidden = 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv'
assert forbidden not in source, (
"Install-Uv still spawns the astral uv installer via a bare "
"`powershell` — it must use the resolved PowerShell host exe so it "
"works under pwsh / when powershell isn't on PATH."
)
def test_astral_uv_installer_invoked_via_resolved_host_variable(source: str):
"""The astral uv installer line must use the call operator on a variable.
i.e. ``& $psHostExe -ExecutionPolicy ... irm https://astral.sh/uv...``
rather than naming a fixed executable.
"""
lines = [ln for ln in source.splitlines() if "astral.sh/uv/install.ps1 | iex" in ln]
# Exactly one invocation line carries the astral installer.
invocation = [ln for ln in lines if "irm https://astral.sh/uv/install.ps1 | iex" in ln]
assert invocation, "astral uv install invocation line not found"
for ln in invocation:
stripped = ln.strip()
assert stripped.startswith("& $"), (
f"astral uv installer must be invoked via the call operator on a "
f"resolved host variable (`& $...`), got: {stripped!r}"
)
def test_powershell_host_resolver_is_defined_and_portable(source: str):
"""A host-resolver helper must exist and be PATH-independent + pwsh-aware."""
assert "function Get-PowerShellHostExe" in source, (
"expected a Get-PowerShellHostExe helper that resolves the host exe"
)
# PATH-independent: derive the absolute path of the running host.
assert "Get-Process -Id $PID" in source, (
"resolver must derive the current host's absolute path "
"(Get-Process -Id $PID), which is independent of PATH"
)
# pwsh-aware fallback: PowerShell 7's executable is `pwsh`, not `powershell`.
assert "pwsh" in source, (
"resolver must fall back to pwsh (PowerShell 7) when powershell is "
"unavailable"
)

View file

@ -265,39 +265,3 @@ def test_locale_catalogs_ship_in_both_wheel_and_sdist():
on_disk = list((REPO_ROOT / "locales").glob("*.yaml"))
assert on_disk, "expected locales/*.yaml catalogs on disk"
def test_optional_mcps_manifests_ship_in_both_wheel_and_sdist():
"""Regression guard: the shipped MCP catalog must reach packaged installs.
hermes_cli/mcp_catalog.py resolves the catalog via get_optional_mcps_dir()
-> _get_packaged_data_dir("optional-mcps"), and list_catalog() returns []
when that directory is absent. optional-mcps/ is a bare data directory (no
__init__.py), invisible to packages.find and package-data. It must ship as
setuptools data-files (wheel) AND be grafted in MANIFEST.in (sdist), or
`hermes mcp catalog` and the dashboard catalog screen come up empty on
pip / Homebrew / Nix installs even though the manifests exist in the repo.
data-files flattens every glob match into its single target dir, so each
catalog entry needs its OWN target to preserve the optional-mcps/<name>/
directory the catalog iterates over. This asserts one target per on-disk
entry so a newly-added MCP can't silently miss the wheel.
"""
entries = sorted(
p.parent.name for p in (REPO_ROOT / "optional-mcps").glob("*/manifest.yaml")
)
assert entries, "expected optional-mcps/<name>/manifest.yaml on disk"
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
data_files = data["tool"]["setuptools"].get("data-files", {})
for name in entries:
target = f"optional-mcps/{name}"
assert target in data_files, (
f"pyproject [tool.setuptools.data-files] must declare a '{target}' "
f"target so the wheel ships optional-mcps/{name}/manifest.yaml "
f"(data-files flattens globs, so each catalog entry needs its own target)"
)
manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8")
assert "graft optional-mcps" in manifest, (
"MANIFEST.in must `graft optional-mcps` so the sdist ships MCP manifests"
)

View file

@ -954,6 +954,67 @@ def test_session_resume_uses_parent_lineage_for_display(monkeypatch):
assert captured["history_calls"] == [("tip", False), ("tip", True)]
def test_session_resume_follows_compression_tip(monkeypatch, tmp_path):
"""Resuming a rotated-out parent id must load the continuation's messages.
Regression for the desktop "I came back and the reply isn't there" report:
auto-compression ends the live session and forks a continuation child, so a
resume on the parent id (the desktop's routed id when the chat was opened
before it rotated) used to reload the pre-compression transcript and drop
the response generated after compression. session.resume must follow the
compression tip via resolve_resume_session_id.
"""
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
base = int(time.time()) - 10_000
db.create_session("parent_root", source="tui")
db.append_message("parent_root", role="user", content="pre-compression turn")
db.end_session("parent_root", "compression")
db.create_session("cont_tip", source="tui", parent_session_id="parent_root")
db.append_message("cont_tip", role="assistant", content="post-compression reply")
conn = db._conn
assert conn is not None
conn.execute(
"UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'parent_root'",
(base, base + 50),
)
conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont_tip'", (base + 100,))
conn.commit()
captured = {}
def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
captured["agent_session_id"] = session_id
return types.SimpleNamespace(model="test", provider="test")
monkeypatch.setattr(server, "_get_db", lambda: db)
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
monkeypatch.setattr(server, "_set_session_context", lambda target: [])
monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
monkeypatch.setattr(server, "_make_agent", fake_make_agent)
monkeypatch.setattr(
server, "_session_info", lambda agent, *a: {"model": "test", "tools": {}, "skills": {}}
)
monkeypatch.setattr(
server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None
)
try:
resp = server.handle_request(
{"id": "1", "method": "session.resume", "params": {"session_id": "parent_root"}}
)
finally:
db.close()
# The agent must bind to the continuation tip, and the returned transcript
# must include the post-compression reply (which lives only in the tip).
assert resp["result"]["session_key"] == "cont_tip"
assert captured["agent_session_id"] == "cont_tip"
texts = [m.get("text") for m in resp["result"]["messages"]]
assert "post-compression reply" in texts
def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
captured = {}
@ -6890,6 +6951,8 @@ def test_config_show_displays_nested_max_turns(monkeypatch):
def test_notification_poller_delivers_completion(monkeypatch):
"""Poller picks up completion events and triggers agent turns."""
import queue as _queue_mod
from tools.process_registry import process_registry
turns = []
@ -6916,16 +6979,23 @@ def test_notification_poller_delivers_completion(monkeypatch):
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
# Clear queue
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
# Isolate the completion queue for the duration of this test. The poller
# reads process_registry.completion_queue by attribute at runtime; the
# event below carries no session_key, so any *other* poller (a leaked
# daemon thread from another test, or a concurrent one in the same xdist
# worker) is allowed to dequeue and dispatch it to its own session — whose
# agent may be a fixture double without run_conversation. A fresh Queue
# here fully isolates this test; monkeypatch restores the original on
# teardown. (Same pattern as test_notification_poller_requeues_when_busy.)
isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
process_registry._completion_consumed.discard("proc_poller_test")
stop = threading.Event()
# Put event on queue, then immediately signal stop so the poller
# runs exactly one iteration.
process_registry.completion_queue.put({
isolated_queue.put({
"type": "completion",
"session_id": "proc_poller_test",
"command": "echo hello",
@ -6953,6 +7023,8 @@ def test_notification_poller_delivers_completion(monkeypatch):
def test_notification_poller_skips_consumed(monkeypatch):
"""Already-consumed completions are not dispatched by the poller."""
import queue as _queue_mod
from tools.process_registry import process_registry
turns = []
@ -6975,11 +7047,15 @@ def test_notification_poller_skips_consumed(monkeypatch):
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
# Isolate the completion queue so a concurrent/leaked poller in the same
# xdist worker can't dequeue this session_key-less event before our poller
# does. monkeypatch restores the shared singleton on teardown. (Same
# pattern as test_notification_poller_requeues_when_busy.)
isolated_queue: _queue_mod.Queue = _queue_mod.Queue()
monkeypatch.setattr(process_registry, "completion_queue", isolated_queue)
process_registry._completion_consumed.add("proc_already_done")
process_registry.completion_queue.put({
isolated_queue.put({
"type": "completion",
"session_id": "proc_already_done",
"command": "echo x",

View file

@ -0,0 +1,167 @@
"""Tests for the TUI gateway's late MCP tool-snapshot refresh.
When an MCP server connects slower than the bounded wait in ``_make_agent``,
the agent is built without its tools and the banner/tool count is stale for the
session. ``_schedule_mcp_late_refresh`` waits for discovery to land, then
rebuilds the snapshot and re-emits ``session.info`` but only while the
session is still pre-first-turn, so it never invalidates a cached prompt.
"""
import threading
import time
import types
import model_tools
from tui_gateway import server
from tui_gateway import entry
def _make_fake_agent(initial_tools, *, user_turns=0, api_calls=0):
agent = types.SimpleNamespace()
agent.tools = list(initial_tools)
agent.valid_tool_names = {t["function"]["name"] for t in initial_tools}
agent._user_turn_count = user_turns
agent._api_call_count = api_calls
return agent
def _tool(name):
return {"type": "function", "function": {"name": name, "description": "", "parameters": {}}}
def _drain_refresh_threads(timeout=5.0):
deadline = time.time() + timeout
for th in list(threading.enumerate()):
if th.name.startswith("tui-mcp-late-refresh-"):
th.join(timeout=max(0.0, deadline - time.time()))
def _install(monkeypatch, *, in_flight, join_result, new_defs):
"""Wire entry discovery accessors + get_tool_definitions, capture emits."""
monkeypatch.setattr(entry, "mcp_discovery_in_flight", lambda: in_flight)
monkeypatch.setattr(entry, "join_mcp_discovery", lambda timeout=None: join_result)
monkeypatch.setattr(model_tools, "get_tool_definitions", lambda **kw: list(new_defs))
monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: None)
monkeypatch.setattr(server, "_session_info", lambda agent, session: {"tools_len": len(agent.tools)})
emitted = []
monkeypatch.setattr(server, "_emit", lambda event, sid, payload=None: emitted.append((event, sid, payload)))
return emitted
def test_late_refresh_adds_tools_and_reemits_when_pre_first_turn(monkeypatch):
base = [_tool("read_file"), _tool("write_file")]
full = base + [_tool("mcp__nous_support__a")] # discovery added one tool
agent = _make_fake_agent(base)
sid = "sess-late-1"
server._sessions[sid] = {"agent": agent}
try:
emitted = _install(monkeypatch, in_flight=True, join_result=True, new_defs=full)
server._schedule_mcp_late_refresh(sid, agent)
_drain_refresh_threads()
assert len(agent.tools) == 3
assert "mcp__nous_support__a" in agent.valid_tool_names
assert ("session.info", sid, {"tools_len": 3}) in emitted
finally:
server._sessions.pop(sid, None)
def test_no_refresh_when_discovery_not_in_flight(monkeypatch):
base = [_tool("read_file")]
agent = _make_fake_agent(base)
sid = "sess-late-2"
server._sessions[sid] = {"agent": agent}
try:
# in_flight=False → helper returns immediately, no thread, no rebuild.
emitted = _install(monkeypatch, in_flight=False, join_result=True, new_defs=base + [_tool("x")])
server._schedule_mcp_late_refresh(sid, agent)
_drain_refresh_threads()
assert len(agent.tools) == 1
assert emitted == []
finally:
server._sessions.pop(sid, None)
def test_no_refresh_once_conversation_started(monkeypatch):
"""Cache safety: never rebuild the tool list after the first turn."""
base = [_tool("read_file")]
full = base + [_tool("mcp__late__b")]
agent = _make_fake_agent(base, user_turns=1) # a turn already happened
sid = "sess-late-3"
server._sessions[sid] = {"agent": agent}
try:
emitted = _install(monkeypatch, in_flight=True, join_result=True, new_defs=full)
server._schedule_mcp_late_refresh(sid, agent)
_drain_refresh_threads()
# Snapshot frozen; no re-emit that would invalidate the prompt cache.
assert len(agent.tools) == 1
assert emitted == []
finally:
server._sessions.pop(sid, None)
def test_no_reemit_when_discovery_added_nothing(monkeypatch):
base = [_tool("read_file"), _tool("write_file")]
agent = _make_fake_agent(base)
sid = "sess-late-4"
server._sessions[sid] = {"agent": agent}
try:
# Discovery finished but the registry is unchanged (same count) →
# don't churn the client with a redundant session.info.
emitted = _install(monkeypatch, in_flight=True, join_result=True, new_defs=list(base))
server._schedule_mcp_late_refresh(sid, agent)
_drain_refresh_threads()
assert len(agent.tools) == 2
assert emitted == []
finally:
server._sessions.pop(sid, None)
def test_no_refresh_when_join_times_out(monkeypatch):
base = [_tool("read_file")]
full = base + [_tool("mcp__slow__c")]
agent = _make_fake_agent(base)
sid = "sess-late-5"
server._sessions[sid] = {"agent": agent}
try:
# Server never connected within the bound → join returns False, no rebuild.
emitted = _install(monkeypatch, in_flight=True, join_result=False, new_defs=full)
server._schedule_mcp_late_refresh(sid, agent)
_drain_refresh_threads()
assert len(agent.tools) == 1
assert emitted == []
finally:
server._sessions.pop(sid, None)
def test_no_refresh_when_session_replaced(monkeypatch):
"""If the session's agent was swapped (e.g. /new) while we waited, bail."""
base = [_tool("read_file")]
full = base + [_tool("mcp__late__d")]
agent = _make_fake_agent(base)
other_agent = _make_fake_agent(base)
sid = "sess-late-6"
server._sessions[sid] = {"agent": agent}
try:
emitted = _install(monkeypatch, in_flight=True, join_result=True, new_defs=full)
# Swap the stored agent out the moment join is awaited.
def _swap_join(timeout=None):
server._sessions[sid]["agent"] = other_agent
return True
monkeypatch.setattr(entry, "join_mcp_discovery", _swap_join)
server._schedule_mcp_late_refresh(sid, agent)
_drain_refresh_threads()
# Neither agent's snapshot was rebuilt; no emit.
assert len(agent.tools) == 1
assert len(other_agent.tools) == 1
assert emitted == []
finally:
server._sessions.pop(sid, None)

View file

@ -1450,3 +1450,80 @@ class TestFocusAppFilterNoMatch:
assert res.ok is True
assert backend._active_pid == 200
assert backend._active_window_id == 2
class TestCuaEnvironmentScrubbing:
"""Verify that cua-driver subprocess environment is sanitized (issue #37878)."""
def test_cua_session_sanitizes_provider_env_vars(self):
"""_CuaDriverSession._aenter() must sanitize sensitive env vars.
The cua-driver MCP subprocess should not inherit Hermes-managed credentials
or other sensitive environment variables only runtime-required vars.
This is a regression test for issue #37878.
"""
from unittest.mock import MagicMock, patch, AsyncMock
from tools.computer_use.cua_backend import _CuaDriverSession, _AsyncBridge
import asyncio
bridge = _AsyncBridge()
session = _CuaDriverSession(bridge)
captured_env = {}
async def test_aenter():
# Set up test environment with both safe and blocked vars
test_env = {
"OPENAI_API_KEY": "sk-secret", # blocked
"ANTHROPIC_API_KEY": "sk-ant-secret", # blocked
"PATH": "/usr/bin:/bin", # safe
"HOME": "/home/user", # safe
"SAFE_VAR": "allowed", # safe
}
with patch.dict(os.environ, test_env, clear=True):
with patch("tools.computer_use.cua_backend.cua_driver_binary_available",
return_value=True):
# Mock StdioServerParameters to capture the env arg
def capture_env(**kwargs):
captured_env.update(kwargs.get("env", {}))
# Return mock that works with async context manager
mock = MagicMock()
mock.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock()))
mock.__aexit__ = AsyncMock(return_value=None)
return mock
with patch("mcp.StdioServerParameters", side_effect=capture_env), \
patch("mcp.client.stdio.stdio_client") as mock_stdio, \
patch("mcp.ClientSession") as mock_session_class, \
patch("contextlib.AsyncExitStack"):
# Setup mocks for stdio_client and ClientSession
mock_read = MagicMock()
mock_write = MagicMock()
mock_stdio.return_value.__aenter__ = AsyncMock(
return_value=(mock_read, mock_write))
mock_stdio.return_value.__aexit__ = AsyncMock(return_value=None)
mock_session = MagicMock()
mock_session.initialize = AsyncMock()
mock_session_class.return_value.__aenter__ = AsyncMock(
return_value=mock_session)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
try:
await session._aenter()
except Exception:
pass # Mocks may raise, but env should be captured
asyncio.run(test_aenter())
# Verify blocked credentials are not in the passed env
assert "OPENAI_API_KEY" not in captured_env, \
"OPENAI_API_KEY should be stripped from cua-driver subprocess"
assert "ANTHROPIC_API_KEY" not in captured_env, \
"ANTHROPIC_API_KEY should be stripped from cua-driver subprocess"
# Verify PATH is preserved (safe var)
assert "PATH" in captured_env or "SAFE_VAR" in captured_env, \
"At least one safe environment variable should be preserved"

View file

@ -363,11 +363,16 @@ class TestAspectRatioNormalization:
class TestRegistryIntegration:
def test_schema_exposes_only_prompt_and_aspect_ratio_to_agent(self, image_tool):
"""The agent-facing schema must stay tight — model selection is a
user-level config choice, not an agent-level arg."""
def test_schema_exposes_expected_agent_params(self, image_tool):
"""The agent-facing schema exposes the unified text+image surface:
prompt (required), aspect_ratio, and the image-to-image inputs
image_url + reference_image_urls. Model selection stays a user-level
config choice, never an agent-level arg."""
props = image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["properties"]
assert set(props.keys()) == {"prompt", "aspect_ratio"}
assert set(props.keys()) == {
"prompt", "aspect_ratio", "image_url", "reference_image_urls",
}
assert image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["required"] == ["prompt"]
def test_aspect_ratio_enum_is_three_values(self, image_tool):
enum = image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["properties"]["aspect_ratio"]["enum"]

View file

@ -110,7 +110,7 @@ def test_handle_image_generate_postprocesses_plugin_result(monkeypatch, tmp_path
monkeypatch.setattr(
image_generation_tool,
"_dispatch_to_plugin_provider",
lambda prompt, aspect_ratio: json.dumps({"success": True, "image": str(image_path)}),
lambda prompt, aspect_ratio, **kw: json.dumps({"success": True, "image": str(image_path)}),
)
result = json.loads(

View file

@ -0,0 +1,349 @@
"""Tests for the image-to-image / editing surface of ``image_generate``.
Mirrors the video-gen image-to-video tests: the unified ``image_generate``
tool routes to a provider's edit endpoint when ``image_url`` /
``reference_image_urls`` is supplied, otherwise to text-to-image. Coverage:
- In-tree FAL edit payload construction (``_build_fal_edit_payload``)
- In-tree FAL routing (text vs edit endpoint) via ``image_generate_tool``
- Plugin dispatch forwards image_url / reference_image_urls to ``generate()``
- ``capabilities()`` honesty drives the dynamic tool-schema description
- Models without an edit endpoint reject image inputs with a clear error
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
import pytest
import yaml
from agent import image_gen_registry
from agent.image_gen_provider import ImageGenProvider
@pytest.fixture(autouse=True)
def _reset_registry():
image_gen_registry._reset_for_tests()
yield
image_gen_registry._reset_for_tests()
@pytest.fixture
def cfg_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
return tmp_path
def _write_cfg(home, cfg: dict):
(home / "config.yaml").write_text(yaml.safe_dump(cfg))
# ---------------------------------------------------------------------------
# In-tree FAL edit payload + routing
# ---------------------------------------------------------------------------
class TestFalEditPayload:
def test_edit_payload_includes_image_urls(self):
from tools.image_generation_tool import _build_fal_edit_payload
payload = _build_fal_edit_payload(
"fal-ai/nano-banana-pro", "make it night", ["https://x/y.png"],
"landscape",
)
assert payload["prompt"] == "make it night"
assert payload["image_urls"] == ["https://x/y.png"]
# nano-banana edit advertises aspect_ratio in edit_supports
assert payload.get("aspect_ratio") == "16:9"
def test_edit_payload_strips_keys_outside_edit_supports(self):
from tools.image_generation_tool import _build_fal_edit_payload
# gpt-image-2 edit does NOT advertise image_size (auto-inferred), so
# it must be stripped even though the text-to-image path sets it.
payload = _build_fal_edit_payload(
"fal-ai/gpt-image-2", "swap bg", ["https://x/y.png"], "square",
)
assert "image_size" not in payload
assert payload["image_urls"] == ["https://x/y.png"]
assert payload["quality"] == "medium"
def test_text_only_model_has_no_edit_endpoint(self):
from tools.image_generation_tool import FAL_MODELS
# z-image/turbo is a pure text-to-image model — no edit endpoint.
assert "edit_endpoint" not in FAL_MODELS["fal-ai/z-image/turbo"]
# while nano-banana-pro is edit-capable
assert FAL_MODELS["fal-ai/nano-banana-pro"].get("edit_endpoint")
class TestFalRouting:
def _patch_submit(self, monkeypatch, image_tool, capture: dict):
class _Handler:
def get(self_inner):
return {"images": [{"url": "https://out/img.png", "width": 1, "height": 1}]}
def fake_submit(endpoint, arguments):
capture["endpoint"] = endpoint
capture["arguments"] = arguments
return _Handler()
monkeypatch.setattr(image_tool, "_submit_fal_request", fake_submit)
monkeypatch.setattr(image_tool, "fal_key_is_configured", lambda: True)
monkeypatch.setattr(image_tool, "_resolve_managed_fal_gateway", lambda: None)
def test_text_to_image_uses_base_endpoint(self, cfg_home, monkeypatch):
import tools.image_generation_tool as image_tool
_write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/nano-banana-pro"}})
capture: dict = {}
self._patch_submit(monkeypatch, image_tool, capture)
raw = image_tool.image_generate_tool(prompt="a cat", aspect_ratio="square")
out = json.loads(raw)
assert out["success"] is True
assert out["modality"] == "text"
assert capture["endpoint"] == "fal-ai/nano-banana-pro"
assert "image_urls" not in capture["arguments"]
def test_image_to_image_routes_to_edit_endpoint(self, cfg_home, monkeypatch):
import tools.image_generation_tool as image_tool
_write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/nano-banana-pro"}})
capture: dict = {}
self._patch_submit(monkeypatch, image_tool, capture)
raw = image_tool.image_generate_tool(
prompt="make it night",
aspect_ratio="square",
image_url="https://in/src.png",
)
out = json.loads(raw)
assert out["success"] is True
assert out["modality"] == "image"
assert capture["endpoint"] == "fal-ai/nano-banana-pro/edit"
assert capture["arguments"]["image_urls"] == ["https://in/src.png"]
def test_reference_images_clamped_to_model_cap(self, cfg_home, monkeypatch):
import tools.image_generation_tool as image_tool
# nano-banana-pro caps at 2 reference images.
_write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/nano-banana-pro"}})
capture: dict = {}
self._patch_submit(monkeypatch, image_tool, capture)
raw = image_tool.image_generate_tool(
prompt="blend",
image_url="https://in/a.png",
reference_image_urls=["https://in/b.png", "https://in/c.png", "https://in/d.png"],
)
out = json.loads(raw)
assert out["success"] is True
assert capture["arguments"]["image_urls"] == ["https://in/a.png", "https://in/b.png"]
def test_text_only_model_rejects_image_url(self, cfg_home, monkeypatch):
import tools.image_generation_tool as image_tool
_write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/z-image/turbo"}})
capture: dict = {}
self._patch_submit(monkeypatch, image_tool, capture)
raw = image_tool.image_generate_tool(
prompt="edit this", image_url="https://in/src.png",
)
out = json.loads(raw)
assert out["success"] is False
assert "image-to-image" in out["error"]
# Must NOT have submitted anything.
assert capture == {}
def test_edit_skips_upscaler(self, cfg_home, monkeypatch):
import tools.image_generation_tool as image_tool
# flux-2-pro has upscale=True for text-to-image, but edits must skip it.
_write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/flux-2-pro"}})
capture: dict = {}
self._patch_submit(monkeypatch, image_tool, capture)
upscale_called = {"hit": False}
monkeypatch.setattr(
image_tool, "_upscale_image",
lambda *a, **k: upscale_called.__setitem__("hit", True) or None,
)
raw = image_tool.image_generate_tool(
prompt="tweak", image_url="https://in/src.png",
)
out = json.loads(raw)
assert out["success"] is True
assert out["modality"] == "image"
assert upscale_called["hit"] is False
# ---------------------------------------------------------------------------
# Plugin dispatch forwarding
# ---------------------------------------------------------------------------
class _EditCapableProvider(ImageGenProvider):
def __init__(self):
self.received: Dict[str, Any] = {}
@property
def name(self) -> str:
return "editcap"
def capabilities(self) -> Dict[str, Any]:
return {"modalities": ["text", "image"], "max_reference_images": 4}
def generate(self, prompt, aspect_ratio="landscape", *, image_url=None,
reference_image_urls=None, **kwargs):
self.received = {
"prompt": prompt,
"aspect_ratio": aspect_ratio,
"image_url": image_url,
"reference_image_urls": reference_image_urls,
}
return {
"success": True, "image": "/tmp/out.png", "model": "editcap-1",
"prompt": prompt, "aspect_ratio": aspect_ratio,
"modality": "image" if image_url else "text", "provider": "editcap",
}
class _LegacyProvider(ImageGenProvider):
"""Provider whose generate() predates image_url (no **kwargs absorb)."""
@property
def name(self) -> str:
return "legacy"
def generate(self, prompt, aspect_ratio="landscape"): # narrow signature
return {"success": True, "image": "/tmp/legacy.png", "provider": "legacy"}
class TestPluginDispatchImageToImage:
def test_dispatch_forwards_image_url(self, cfg_home, monkeypatch):
import tools.image_generation_tool as image_tool
from hermes_cli import plugins as plugins_module
from agent import image_gen_registry as reg
provider = _EditCapableProvider()
reg.register_provider(provider)
monkeypatch.setattr(image_tool, "_read_configured_image_provider", lambda: "editcap")
monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda *a, **k: None)
monkeypatch.setattr(reg, "get_provider", lambda n: provider if n == "editcap" else None)
raw = image_tool._dispatch_to_plugin_provider(
"make night", "square",
image_url="https://in/src.png",
reference_image_urls=["https://in/ref.png"],
)
out = json.loads(raw)
assert out["success"] is True
assert out["modality"] == "image"
assert provider.received["image_url"] == "https://in/src.png"
assert provider.received["reference_image_urls"] == ["https://in/ref.png"]
def test_dispatch_text_only_when_no_image(self, cfg_home, monkeypatch):
import tools.image_generation_tool as image_tool
from hermes_cli import plugins as plugins_module
from agent import image_gen_registry as reg
provider = _EditCapableProvider()
reg.register_provider(provider)
monkeypatch.setattr(image_tool, "_read_configured_image_provider", lambda: "editcap")
monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda *a, **k: None)
monkeypatch.setattr(reg, "get_provider", lambda n: provider if n == "editcap" else None)
raw = image_tool._dispatch_to_plugin_provider("a dog", "landscape")
out = json.loads(raw)
assert out["success"] is True
assert provider.received["image_url"] is None
assert "reference_image_urls" not in provider.received or provider.received["reference_image_urls"] is None
def test_legacy_provider_edit_request_surfaces_clear_error(self, cfg_home, monkeypatch):
import tools.image_generation_tool as image_tool
from hermes_cli import plugins as plugins_module
from agent import image_gen_registry as reg
provider = _LegacyProvider()
reg.register_provider(provider)
monkeypatch.setattr(image_tool, "_read_configured_image_provider", lambda: "legacy")
monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda *a, **k: None)
monkeypatch.setattr(reg, "get_provider", lambda n: provider if n == "legacy" else None)
raw = image_tool._dispatch_to_plugin_provider(
"edit it", "square", image_url="https://in/src.png",
)
out = json.loads(raw)
assert out["success"] is False
assert out["error_type"] == "modality_unsupported"
# ---------------------------------------------------------------------------
# Dynamic schema reflects active capabilities
# ---------------------------------------------------------------------------
class _PluginBothProvider(ImageGenProvider):
@property
def name(self) -> str:
return "both"
def is_available(self) -> bool:
return True
def default_model(self) -> Optional[str]:
return "both-v1"
def capabilities(self) -> Dict[str, Any]:
return {"modalities": ["text", "image"], "max_reference_images": 5}
def generate(self, prompt, aspect_ratio="landscape", *, image_url=None,
reference_image_urls=None, **kwargs):
return {"success": True}
class TestDynamicSchema:
def _no_discovery(self, monkeypatch):
import hermes_cli.plugins as plugins_module
monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda *a, **k: None)
def test_fal_edit_model_advertises_both(self, cfg_home, monkeypatch):
from tools.image_generation_tool import _build_dynamic_image_schema
_write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/nano-banana-pro"}})
desc = _build_dynamic_image_schema()["description"]
assert "text-to-image" in desc and "image-to-image" in desc
assert "routes automatically" in desc
def test_fal_text_only_model_warns(self, cfg_home, monkeypatch):
from tools.image_generation_tool import _build_dynamic_image_schema
_write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/z-image/turbo"}})
desc = _build_dynamic_image_schema()["description"]
assert "text-to-image only" in desc
assert "NOT capable of image-to-image" in desc
def test_plugin_both_provider_advertises_refs(self, cfg_home, monkeypatch):
from tools.image_generation_tool import _build_dynamic_image_schema
from agent import image_gen_registry as reg
_write_cfg(cfg_home, {"image_gen": {"provider": "both"}})
reg.register_provider(_PluginBothProvider())
self._no_discovery(monkeypatch)
desc = _build_dynamic_image_schema()["description"]
assert "image-to-image / editing" in desc
assert "up to 5 reference image(s)" in desc
def test_builder_wired_into_registry(self):
from tools.registry import discover_builtin_tools, registry
discover_builtin_tools()
entry = registry._tools["image_generate"]
assert entry.dynamic_schema_overrides is not None
out = entry.dynamic_schema_overrides()
assert "description" in out

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