mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
Merge remote-tracking branch 'origin/main' into hermes/hermes-6fe26723
This commit is contained in:
commit
637aff46e7
146 changed files with 11333 additions and 1022 deletions
|
|
@ -102,6 +102,3 @@ acp_registry/
|
|||
.gitattributes
|
||||
.hadolint.yaml
|
||||
.mailmap
|
||||
|
||||
# Top-level LICENSE (not matched by *.md); not needed inside the container
|
||||
LICENSE
|
||||
|
|
|
|||
BIN
.github/pr-screenshots/45449/billing-confirm.png
vendored
Normal file
BIN
.github/pr-screenshots/45449/billing-confirm.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
BIN
.github/pr-screenshots/45449/billing-overview.png
vendored
Normal file
BIN
.github/pr-screenshots/45449/billing-overview.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
295
agent/billing_view.py
Normal 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)
|
||||
|
|
@ -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]}"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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. "
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
142
apps/desktop/src/app/settings/provider-config-panel.test.tsx
Normal file
142
apps/desktop/src/app/settings/provider-config-panel.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
182
apps/desktop/src/app/settings/provider-config-panel.tsx
Normal file
182
apps/desktop/src/app/settings/provider-config-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1662,6 +1662,7 @@ export const ja = defineLocale({
|
|||
search: 'モデルを検索',
|
||||
noModels: 'モデルが見つかりません',
|
||||
editModels: 'モデルを編集…',
|
||||
refreshModels: 'モデルを更新',
|
||||
fast: '高速',
|
||||
medium: '中'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1174,6 +1174,7 @@ export interface Translations {
|
|||
search: string
|
||||
noModels: string
|
||||
editModels: string
|
||||
refreshModels: string
|
||||
fast: string
|
||||
medium: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1606,6 +1606,7 @@ export const zhHant = defineLocale({
|
|||
search: '搜尋模型',
|
||||
noModels: '找不到模型',
|
||||
editModels: '編輯模型…',
|
||||
refreshModels: '重新整理模型',
|
||||
fast: '快速',
|
||||
medium: '中'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1712,6 +1712,7 @@ export const zh: Translations = {
|
|||
search: '搜索模型',
|
||||
noModels: '未找到模型',
|
||||
editModels: '编辑模型…',
|
||||
refreshModels: '刷新模型',
|
||||
fast: '快速',
|
||||
medium: '中'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
717
cli.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
149
hermes_cli/memory_providers.py
Normal file
149
hermes_cli/memory_providers.py
Normal 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)
|
||||
|
|
@ -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
406
hermes_cli/nous_billing.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
125
hermes_state.py
125
hermes_state.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
54
optional-mcps/unreal-engine/manifest.yaml
Normal file
54
optional-mcps/unreal-engine/manifest.yaml
Normal 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
|
||||
|
|
@ -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
24
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
30
run_agent.py
30
run_agent.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
59
setup.py
59
setup.py
|
|
@ -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"),
|
||||
|
|
|
|||
377
tests/agent/test_billing_view.py
Normal file
377
tests/agent/test_billing_view.py
Normal 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()
|
||||
101
tests/agent/test_platform_hint_overrides.py
Normal file
101
tests/agent/test_platform_hint_overrides.py
Normal 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."
|
||||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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 connector→gateway 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
136
tests/hermes_cli/test_billing_cli.py
Normal file
136
tests/hermes_cli/test_billing_cli.py
Normal 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
|
||||
53
tests/hermes_cli/test_billing_portal_url.py
Normal file
53
tests/hermes_cli/test_billing_portal_url.py
Normal 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"
|
||||
)
|
||||
193
tests/hermes_cli/test_billing_scope_stepup.py
Normal file
193
tests/hermes_cli/test_billing_scope_stepup.py
Normal 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"
|
||||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
46
tests/hermes_cli/test_memory_providers.py
Normal file
46
tests/hermes_cli/test_memory_providers.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
53
tests/hermes_cli/test_update_modified_notice.py
Normal file
53
tests/hermes_cli/test_update_modified_notice.py
Normal 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
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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_"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
87
tests/test_docker_webui_install_surface.py
Normal file
87
tests/test_docker_webui_install_surface.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
94
tests/test_install_ps1_native_stderr_eap.py
Normal file
94
tests/test_install_ps1_native_stderr_eap.py
Normal 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
|
||||
77
tests/test_install_ps1_uv_powershell_host.py
Normal file
77
tests/test_install_ps1_uv_powershell_host.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
167
tests/test_tui_mcp_late_refresh.py
Normal file
167
tests/test_tui_mcp_late_refresh.py
Normal 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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
349
tests/tools/test_image_generation_image_to_image.py
Normal file
349
tests/tools/test_image_generation_image_to_image.py
Normal 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
Loading…
Add table
Add a link
Reference in a new issue