Merge origin/main into bb/contrib-areas

This commit is contained in:
Brooklyn Nicholson 2026-07-15 02:30:17 -04:00
commit db488df1c3
219 changed files with 17229 additions and 2062 deletions

View file

@ -136,6 +136,15 @@
# Optional base URL override:
# XIAOMI_BASE_URL=https://api.xiaomimimo.com/v1
# =============================================================================
# LLM PROVIDER (Upstage Solar)
# =============================================================================
# Upstage provides access to Upstage Solar models.
# Get your key at: https://console.upstage.ai/api-keys
# UPSTAGE_API_KEY=your_key_here
# Optional base URL override:
# UPSTAGE_BASE_URL=https://api.upstage.ai/v1
# =============================================================================
# TOOL API KEYS
# =============================================================================

View file

@ -425,15 +425,28 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
)
def _resolve_codex_usage_url(base_url: str) -> str:
def _codex_backend_urls(base_url: str) -> tuple[str, str, str]:
"""Resolve the Codex backend endpoints (usage, reset-credits list, consume).
Mirrors the Codex CLI's PathStyle split (codex-rs backend-client): base URLs
containing ``/backend-api`` use the ChatGPT ``/wham/...`` paths; everything
else uses ``/api/codex/...``.
"""
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
normalized = "https://chatgpt.com/backend-api/codex"
if normalized.endswith("/codex"):
normalized = normalized[: -len("/codex")]
if "/backend-api" in normalized:
return normalized + "/wham/usage"
return normalized + "/api/codex/usage"
prefix = normalized + ("/wham" if "/backend-api" in normalized else "/api/codex")
return (
prefix + "/usage",
prefix + "/rate-limit-reset-credits",
prefix + "/rate-limit-reset-credits/consume",
)
def _resolve_codex_usage_url(base_url: str) -> str:
return _codex_backend_urls(base_url)[0]
def _resolve_codex_usage_credentials(
@ -525,6 +538,14 @@ def _fetch_codex_account_usage(
)
)
details: list[str] = []
reset_credits = payload.get("rate_limit_reset_credits") or {}
banked = reset_credits.get("available_count")
if isinstance(banked, (int, float)) and int(banked) > 0:
count = int(banked)
plural = "s" if count != 1 else ""
details.append(
f"You have {count} reset{plural} banked - use /usage reset to activate"
)
credits = payload.get("credits") or {}
if credits.get("has_credits"):
balance = credits.get("balance")
@ -542,6 +563,179 @@ def _fetch_codex_account_usage(
)
@dataclass(frozen=True)
class CodexResetRedeemResult:
"""Outcome of a `/usage reset` attempt against the Codex backend."""
status: str # reset | nothing_to_reset | no_credit | already_redeemed |
# not_exhausted | no_credits_banked | unavailable
message: str
available_count: int = 0
windows_reset: int = 0
@property
def redeemed(self) -> bool:
return self.status == "reset"
# Client-side guard threshold: a rate-limit window only counts as exhausted
# when it is fully used. Below this, redeeming a banked reset wastes most of
# its value, so we block and point at --force instead.
_CODEX_WINDOW_EXHAUSTED_PERCENT = 100.0
def redeem_codex_reset_credit(
*,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
force: bool = False,
) -> CodexResetRedeemResult:
"""Redeem one banked Codex rate-limit reset credit (`/usage reset`).
Flow (mirrors the Codex CLI's reset-credits picker, codex-rs
``backend-client``):
1. ``GET .../usage`` read the current windows + banked credit count.
2. Guard: zero banked credits refuse. No window fully used and not
``force`` refuse with a warning (a banked reset restores the WHOLE
5h + weekly allowance; burning it early wastes it). The backend has
the same protection (``nothing_to_reset`` doesn't consume the
credit), but failing fast client-side gives a clearer message.
3. ``POST .../rate-limit-reset-credits/consume`` with a fresh UUID
idempotency key (``redeem_request_id``). No ``credit_id`` the
backend picks the next available credit, exactly like the CLI's
default "Full reset" option.
Never raises: every failure mode returns a ``CodexResetRedeemResult``
with a user-renderable message.
"""
import uuid
try:
token, resolved_base_url, account_id = _resolve_codex_usage_credentials(base_url, api_key)
except Exception:
return CodexResetRedeemResult(
status="unavailable",
message="No Codex credentials available. Run `hermes auth` to sign in with your ChatGPT account.",
)
usage_url, _credits_url, consume_url = _codex_backend_urls(resolved_base_url)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
"User-Agent": "codex-cli",
}
if account_id:
headers["ChatGPT-Account-Id"] = account_id
try:
with httpx.Client(timeout=15.0) as client:
usage_resp = client.get(usage_url, headers=headers)
usage_resp.raise_for_status()
payload = usage_resp.json() or {}
reset_credits = payload.get("rate_limit_reset_credits") or {}
raw_count = reset_credits.get("available_count")
available = int(raw_count) if isinstance(raw_count, (int, float)) else 0
if available <= 0:
return CodexResetRedeemResult(
status="no_credits_banked",
message="No banked reset credits on this account — nothing to redeem.",
)
rate_limit = payload.get("rate_limit") or {}
worst_used: Optional[float] = None
for key in ("primary_window", "secondary_window"):
used = (rate_limit.get(key) or {}).get("used_percent")
if isinstance(used, (int, float)):
worst_used = max(worst_used or 0.0, float(used))
exhausted = worst_used is not None and worst_used >= _CODEX_WINDOW_EXHAUSTED_PERCENT
if not exhausted and not force:
usage_note = (
f"your busiest window is only {worst_used:.0f}% used"
if worst_used is not None
else "your current usage could not be confirmed as exhausted"
)
plural = "s" if available != 1 else ""
return CodexResetRedeemResult(
status="not_exhausted",
message=(
f"⚠️ Not redeeming: {usage_note}. A banked reset restores your FULL "
f"5h + weekly limits, so spending it now would waste most of it. "
f"You have {available} reset{plural} banked. "
f"Use `/usage reset --force` to redeem anyway."
),
available_count=available,
)
consume_resp = client.post(
consume_url,
headers={**headers, "Content-Type": "application/json"},
json={"redeem_request_id": str(uuid.uuid4())},
)
consume_resp.raise_for_status()
body = consume_resp.json() or {}
except httpx.HTTPStatusError as exc:
code = exc.response.status_code
if code in (401, 403):
return CodexResetRedeemResult(
status="unavailable",
message=(
"Codex backend rejected the request (HTTP "
f"{code}). Reset credits require ChatGPT-account (OAuth) auth — "
"run `hermes auth` and sign in with your ChatGPT account."
),
)
return CodexResetRedeemResult(
status="unavailable",
message=f"Codex backend error (HTTP {code}) — try again shortly.",
)
except Exception as exc:
return CodexResetRedeemResult(
status="unavailable",
message=f"Could not reach the Codex backend: {exc}",
)
code = str(body.get("code", "") or "").strip().lower()
windows_reset = body.get("windows_reset")
windows_reset = int(windows_reset) if isinstance(windows_reset, (int, float)) else 0
remaining = max(0, available - 1)
plural = "s" if remaining != 1 else ""
if code == "reset":
return CodexResetRedeemResult(
status="reset",
message=(
f"✅ Reset redeemed — your usage limits have been reset. "
f"{remaining} banked reset{plural} remaining."
),
available_count=remaining,
windows_reset=windows_reset,
)
if code == "nothing_to_reset":
return CodexResetRedeemResult(
status="nothing_to_reset",
message=(
"Backend reports nothing to reset — your limits aren't exhausted. "
"The credit was NOT spent."
),
available_count=available,
)
if code == "no_credit":
return CodexResetRedeemResult(
status="no_credit",
message="Backend reports no available reset credit on this account.",
)
if code == "already_redeemed":
return CodexResetRedeemResult(
status="already_redeemed",
message="This redemption was already processed — no additional credit was spent.",
available_count=remaining,
)
return CodexResetRedeemResult(
status="unavailable",
message=f"Unexpected response from the Codex backend: {body!r}",
)
def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]:
token = (resolve_anthropic_token() or "").strip()
if not token:

View file

@ -434,18 +434,6 @@ def init_agent(
agent.base_url = base_url or ""
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
agent.provider = provider_name or ""
if credential_pool is not None:
try:
from agent.credential_pool import credential_pool_matches_provider
if not credential_pool_matches_provider(
credential_pool,
agent.provider,
base_url=agent.base_url,
):
credential_pool = None
except Exception:
credential_pool = None
agent._credential_pool = credential_pool
agent.acp_command = acp_command or command
agent.acp_args = list(acp_args or args or [])
@ -482,6 +470,24 @@ def init_agent(
else:
agent.api_mode = "chat_completions"
# Credential-pool validation runs AFTER provider auto-detection so
# a pool scoped to e.g. "anthropic" is not rejected when the agent
# was constructed with provider=None and an anthropic.com URL.
# Regression from #63048 which placed this check before the
# URL-based auto-detection block above (fixed #63425).
if credential_pool is not None:
try:
from agent.credential_pool import credential_pool_matches_provider
if not credential_pool_matches_provider(
credential_pool,
agent.provider,
base_url=agent.base_url,
):
agent._credential_pool = None
except Exception:
agent._credential_pool = None
# Eagerly warm the transport cache so import errors surface at init,
# not mid-conversation. Also validates the api_mode is registered.
try:
@ -1311,6 +1317,14 @@ def init_agent(
# SQLite session store (optional -- provided by CLI or gateway)
agent._session_db = session_db
agent._parent_session_id = parent_session_id
# A close flush and the worker's turn-start flush can overlap. The durable
# marker is attached to each in-memory message dict, so its test-and-append
# sequence must be serialized per agent rather than relying on SQLite alone.
agent._session_persist_lock = threading.RLock()
# CLI retains its just-accepted user dict until turn setup can reuse it.
# This preserves the message-local durable marker if close persistence wins
# the race before the agent's normal early turn flush.
agent._pending_cli_user_message = None
agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes
agent._session_db_created = False # DB row deferred to run_conversation()
# Most agents own their session row and should finalize it on close().

View file

@ -1305,6 +1305,13 @@ def restore_primary_runtime(agent) -> bool:
primary_provider or "?",
)
# ── Restore reasoning_config if it was saved ──
# switch_model saves reasoning_config in _primary_runtime. If the
# snapshot predates that (older sessions), keep the current value.
saved_reasoning = rt.get("reasoning_config")
if saved_reasoning is not None:
agent.reasoning_config = dict(saved_reasoning)
# ── Reset fallback chain for the new turn ──
agent._fallback_activated = False
agent._fallback_index = 0
@ -2065,6 +2072,24 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
api_mode=agent.api_mode,
)
# ── Re-resolve reasoning_config from per-model override ──
# The new model may have a different reasoning_effort override. Re-read
# config so the override takes effect immediately on /model switch —
# resolved through the shared chokepoint (per-model > global; YAML
# boolean False = disabled).
try:
from hermes_constants import resolve_reasoning_config
from hermes_cli.config import load_config as _sm_load_config
_reasoning_cfg = _sm_load_config() or {}
agent.reasoning_config = resolve_reasoning_config(_reasoning_cfg, agent.model)
logger.info(
"switch_model: reasoning_config resolved for %s: %s",
agent.model, agent.reasoning_config,
)
except Exception as _reasoning_err:
logger.debug("switch_model: could not re-resolve reasoning_config: %s", _reasoning_err)
# ── Invalidate cached system prompt so it rebuilds next turn ──
agent._cached_system_prompt = None
@ -2087,6 +2112,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"client_kwargs": dict(agent._client_kwargs),
"use_prompt_caching": agent._use_prompt_caching,
"use_native_cache_layout": agent._use_native_cache_layout,
"reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None,
"compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model,
"compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url,
"compressor_api_key": getattr(_cc, "api_key", "") if _cc else "",

View file

@ -1286,6 +1286,7 @@ class _AnthropicCompletionsAdapter:
model = kwargs.get("model", self._model)
tools = kwargs.get("tools")
tool_choice = kwargs.get("tool_choice")
reasoning_config = kwargs.get("_reasoning_config")
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
# models (glm-4v-flash etc.) with error code 1210. When the caller
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
@ -1306,12 +1307,25 @@ class _AnthropicCompletionsAdapter:
elif choice_type in {"auto", "required", "none"}:
normalized_tool_choice = choice_type
# Reasoning priority: explicit per-call reasoning_config (MoA per-slot,
# passed as _reasoning_config by _build_call_kwargs) wins over an
# extra_body.reasoning dict (auxiliary.<task>.extra_body config).
# build_anthropic_kwargs translates the config dict into the native
# ``thinking`` field and handles models where thinking is mandatory.
_reasoning_cfg = reasoning_config
if _reasoning_cfg is None:
_eb = kwargs.get("extra_body")
if isinstance(_eb, dict):
_rc = _eb.get("reasoning")
if isinstance(_rc, dict):
_reasoning_cfg = _rc
anthropic_kwargs = build_anthropic_kwargs(
model=model,
messages=messages,
tools=tools,
max_tokens=max_tokens,
reasoning_config=None,
reasoning_config=_reasoning_cfg,
tool_choice=normalized_tool_choice,
is_oauth=self._is_oauth,
)
@ -3429,6 +3443,7 @@ def _retry_same_provider_sync(
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
reasoning_config: Optional[dict],
) -> Any:
if task == "vision":
_, retry_client, retry_model = resolve_vision_provider_client(
@ -3462,6 +3477,7 @@ def _retry_same_provider_sync(
tools=tools,
timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=retry_base or resolved_base_url,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
@ -3486,6 +3502,7 @@ async def _retry_same_provider_async(
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
reasoning_config: Optional[dict],
) -> Any:
if task == "vision":
_, retry_client, retry_model = resolve_vision_provider_client(
@ -3519,6 +3536,7 @@ async def _retry_same_provider_async(
tools=tools,
timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=retry_base or resolved_base_url,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
@ -3638,6 +3656,7 @@ def _call_fallback_candidate_sync(
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
reasoning_config: Optional[dict],
) -> Optional[Any]:
"""Call one fallback candidate with stale-credential recovery.
@ -3659,7 +3678,8 @@ def _call_fallback_candidate_sync(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, base_url=fb_base)
extra_body=effective_extra_body, reasoning_config=reasoning_config,
base_url=fb_base)
try:
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
@ -3675,6 +3695,7 @@ def _call_fallback_candidate_sync(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
try:
return _validate_llm_response(
@ -3707,6 +3728,7 @@ async def _call_fallback_candidate_async(
tools: Optional[list],
effective_timeout: float,
effective_extra_body: dict,
reasoning_config: Optional[dict],
) -> Optional[Any]:
"""Async mirror of :func:`_call_fallback_candidate_sync`."""
fb_base = str(getattr(fb_client, "base_url", "") or "")
@ -3714,7 +3736,8 @@ async def _call_fallback_candidate_async(
fb_label, fb_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body, base_url=fb_base)
extra_body=effective_extra_body, reasoning_config=reasoning_config,
base_url=fb_base)
try:
return _validate_llm_response(
await fb_client.chat.completions.create(**fb_kwargs), task)
@ -3731,6 +3754,7 @@ async def _call_fallback_candidate_async(
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout,
extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=str(getattr(retry_client, "base_url", "") or fb_base))
try:
return _validate_llm_response(
@ -6141,12 +6165,49 @@ def _effective_aux_timeout(task: str, timeout: Optional[float]) -> float:
def _get_task_extra_body(task: str) -> Dict[str, Any]:
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid."""
"""Read auxiliary.<task>.extra_body and return a shallow copy when valid.
Also folds in ``auxiliary.<task>.reasoning_effort`` as an
``extra_body.reasoning`` config dict ({"enabled": ..., "effort": ...})
when set. An explicit ``extra_body.reasoning`` in config wins over the
``reasoning_effort`` shorthand (it is the more specific wire control).
Downstream, each wire already translates ``extra_body.reasoning``:
chat.completions passes it through, the Codex Responses adapter maps it
to top-level ``reasoning``/``include``, and the Anthropic auxiliary
client maps it to ``build_anthropic_kwargs(reasoning_config=...)``.
MoA tasks are excluded by design: reasoning depth for MoA is a per-slot
setting in the MoA preset (``moa.presets.<name>.reference_models[].
reasoning_effort`` / ``aggregator.reasoning_effort``), not an
auxiliary-task knob an ensemble-wide value would override the
per-slot ones.
"""
task_config = _get_auxiliary_task_config(task)
raw = task_config.get("extra_body")
if isinstance(raw, dict):
return dict(raw)
return {}
result = dict(raw) if isinstance(raw, dict) else {}
if "reasoning" not in result:
effort = task_config.get("reasoning_effort")
if effort is not None and effort != "":
if task in ("moa_reference", "moa_aggregator"):
logger.warning(
"auxiliary.%s.reasoning_effort is not supported — MoA "
"reasoning depth is per-slot: set reasoning_effort on the "
"preset's reference_models entries / aggregator instead "
"(moa.presets.<name>...). Ignoring.",
task,
)
return result
from hermes_constants import parse_reasoning_effort
parsed = parse_reasoning_effort(effort)
if parsed is not None:
result["reasoning"] = parsed
else:
logger.warning(
"auxiliary.%s.reasoning_effort %r is not a valid level "
"(none, minimal, low, medium, high, xhigh, max, ultra) — ignoring",
task, effort,
)
return result
# ---------------------------------------------------------------------------
@ -6254,6 +6315,32 @@ def _convert_openai_images_to_anthropic(messages: list) -> list:
return converted
_PROFILE_REASONING_KEYS = {
"reasoning",
"reasoning_effort",
"thinking",
"thinking_config",
"thinkingconfig",
"thinking_budget",
"thinkingbudget",
"enable_thinking",
"think",
"verbosity",
}
def _contains_profile_reasoning_fields(value: Any) -> bool:
"""Return whether a profile payload contains a reasoning wire control."""
if not isinstance(value, dict):
return False
for key, nested in value.items():
normalized = str(key).strip().lower()
if normalized in _PROFILE_REASONING_KEYS:
return True
if _contains_profile_reasoning_fields(nested):
return True
return False
def _build_call_kwargs(
provider: str,
@ -6264,6 +6351,7 @@ def _build_call_kwargs(
tools: Optional[list] = None,
timeout: float = 30.0,
extra_body: Optional[dict] = None,
reasoning_config: Optional[dict] = None,
base_url: Optional[str] = None,
) -> dict:
"""Build kwargs for .chat.completions.create() with model/provider adjustments."""
@ -6347,13 +6435,89 @@ def _build_call_kwargs(
_deduped.append(_t)
kwargs["tools"] = _deduped
# Provider-specific extra_body
# Build provider-aware reasoning kwargs through the same profile hooks used
# by the standard chat-completions transport. Some providers require
# top-level controls (Kimi/custom ``reasoning_effort``), others use nested
# body fields (Gemini ``thinking_config``), and OpenRouter/Nous use
# ``extra_body.reasoning``. Profiles are the source of truth for those wire
# shapes. Providers without a reasoning-aware profile retain the generic
# ``extra_body.reasoning`` fallback used by Codex-compatible adapters.
effective_base = base_url or (
_current_custom_base_url() if provider == "custom" else ""
)
profile_body: Dict[str, Any] = {}
profile_reasoning_extra: Dict[str, Any] = {}
profile_top_level: Dict[str, Any] = {}
profile_handles_reasoning = False
try:
from providers import get_provider_profile
from providers.base import ProviderProfile
profile = get_provider_profile(str(provider or "").strip().lower())
if profile is not None:
profile_body = profile.build_extra_body(
model=model,
base_url=effective_base,
reasoning_config=reasoning_config,
) or {}
profile_reasoning_extra, profile_top_level = (
profile.build_api_kwargs_extras(
reasoning_config=reasoning_config,
supports_reasoning=reasoning_config is not None,
model=model,
base_url=effective_base,
)
)
profile_reasoning_extra = profile_reasoning_extra or {}
profile_top_level = profile_top_level or {}
profile_handles_reasoning = (
type(profile).build_api_kwargs_extras
is not ProviderProfile.build_api_kwargs_extras
or _contains_profile_reasoning_fields(profile_body)
or _contains_profile_reasoning_fields(profile_reasoning_extra)
or _contains_profile_reasoning_fields(profile_top_level)
)
except Exception as exc:
logger.debug(
"_build_call_kwargs: provider profile projection failed for %s: %s",
provider,
exc,
)
kwargs.update(profile_top_level)
merged_extra = dict(extra_body or {})
if provider == "nous":
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
merged_extra.update(profile_body)
merged_extra.update(profile_reasoning_extra)
if (
reasoning_config
and isinstance(reasoning_config, dict)
and not profile_handles_reasoning
):
if reasoning_config.get("enabled") is False:
merged_extra["reasoning"] = {"enabled": False}
else:
effort = reasoning_config.get("effort") or "medium"
merged_extra["reasoning"] = {"enabled": True, "effort": effort}
if provider == "nous" and "tags" not in merged_extra:
merged_extra["tags"] = _nous_portal_tags()
if merged_extra:
kwargs["extra_body"] = merged_extra
# Native Anthropic Messages adapters do not consume ``extra_body``. Carry
# the normalized Hermes reasoning config through a private kwarg so the
# adapter can pass it into build_anthropic_kwargs(), where provider-aware
# thinking/output_config projection lives. Do not expose this private kwarg
# to ordinary OpenAI-compatible SDK clients, which would reject it.
if reasoning_config and isinstance(reasoning_config, dict):
provider_norm = str(provider or "").strip().lower()
effective_base = base_url or ""
if (
provider_norm == "anthropic"
or _endpoint_speaks_anthropic_messages(effective_base)
or _is_anthropic_compat_endpoint(provider_norm, effective_base)
):
kwargs["_reasoning_config"] = dict(reasoning_config)
return kwargs
@ -6463,6 +6627,7 @@ def call_llm(
tools: list = None,
timeout: float = None,
extra_body: dict = None,
reasoning_config: Optional[dict] = None,
api_mode: str = None,
stream: bool = False,
stream_options: dict = None,
@ -6486,6 +6651,8 @@ def call_llm(
tools: Tool definitions (for function calling).
timeout: Request timeout in seconds (None = read from auxiliary.{task}.timeout config).
extra_body: Additional request body fields.
reasoning_config: Optional Hermes reasoning config for direct model calls
such as MoA reference/aggregator slots.
stream: When True, return the raw SDK streaming iterator instead of a
validated complete response. The caller is responsible for consuming
chunks (and for any fallback). Used by the MoA aggregator so its
@ -6590,6 +6757,7 @@ def call_llm(
resolved_provider, final_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=_base_info or resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
@ -6842,6 +7010,7 @@ def call_llm(
tools=tools,
effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config,
)
# ── Same-provider credential-pool recovery ─────────────────────
@ -6884,6 +7053,7 @@ def call_llm(
tools=tools,
effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config,
)
except Exception as retry2_err:
# The rotated key also hit a quota/auth wall. Mark it
@ -7005,7 +7175,8 @@ def call_llm(
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config)
if fb_resp is not None:
return fb_resp
# The candidate had a stale/unrefreshable credential and was
@ -7019,7 +7190,8 @@ def call_llm(
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config)
if fb_resp is not None:
return fb_resp
# All fallback layers exhausted — emit a single user-visible
@ -7114,6 +7286,7 @@ async def async_call_llm(
tools: list = None,
timeout: float = None,
extra_body: dict = None,
reasoning_config: Optional[dict] = None,
) -> Any:
"""Centralized asynchronous LLM call.
@ -7193,6 +7366,7 @@ async def async_call_llm(
resolved_provider, final_model, messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, timeout=effective_timeout, extra_body=effective_extra_body,
reasoning_config=reasoning_config,
base_url=_client_base or resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
@ -7389,6 +7563,7 @@ async def async_call_llm(
tools=tools,
effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config,
)
# ── Same-provider credential-pool recovery (mirrors sync) ─────
@ -7426,6 +7601,7 @@ async def async_call_llm(
tools=tools,
effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config,
)
except Exception as retry2_err:
if (_is_payment_error(retry2_err) or _is_auth_error(retry2_err)
@ -7514,7 +7690,8 @@ async def async_call_llm(
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config)
if fb_resp is not None:
return fb_resp
# Stale/unrefreshable candidate credential — quarantined; walk
@ -7530,7 +7707,8 @@ async def async_call_llm(
task=task, messages=messages,
temperature=temperature, max_tokens=max_tokens,
tools=tools, effective_timeout=effective_timeout,
effective_extra_body=effective_extra_body)
effective_extra_body=effective_extra_body,
reasoning_config=reasoning_config)
if fb_resp is not None:
return fb_resp
# All fallback layers exhausted — warn before re-raising. (#26882)

View file

@ -696,6 +696,19 @@ def _run_review_in_thread(
if isinstance(_rt.get("command"), str) and _rt["command"]:
_fork_kwargs["acp_command"] = _rt["command"]
_fork_kwargs["acp_args"] = _rt.get("args") or []
# Match parent's reasoning config so the fork's ``thinking`` /
# ``output_config`` are byte-identical in the request body —
# Anthropic's cache key is namespaced by ``thinking`` presence.
# Same-model path only: when routed to a different aux model the
# cache is cold regardless (parity buys nothing) and the parent's
# effort vocabulary may not be valid for the routed model/provider
# (e.g. OpenRouter ``extra_body.reasoning.effort`` is forwarded
# unclamped; codex_responses passes ``max``/``ultra`` through
# unmapped except on gpt-5.6/xAI). Let the routed fork use
# provider defaults — matching the ``not _routed`` gate on
# _cached_system_prompt below.
if not _routed:
_fork_kwargs["reasoning_config"] = getattr(agent, "reasoning_config", None)
review_agent = AIAgent(
model=_rt.get("model") or agent.model,
max_iterations=16,

View file

@ -28,6 +28,7 @@ from typing import Any, Dict, Optional
from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
from agent.error_classifier import FailoverReason
from agent.errors import EmptyStreamError
from agent.gemini_native_adapter import is_native_gemini_base_url
from agent.model_metadata import is_local_endpoint
from agent.message_sanitization import (
@ -1635,6 +1636,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
api_mode=agent.api_mode,
)
# Re-resolve reasoning_config for the new fallback model (Closes #21256).
# Shared chokepoint: per-model override > global reasoning_effort
# (YAML boolean False = disabled). Wrapped in try/except because a
# config load failure must not kill the swap.
try:
from hermes_cli.config import load_config
from hermes_constants import resolve_reasoning_config
agent.reasoning_config = resolve_reasoning_config(
load_config() or {}, agent.model
)
logger.info(
"Fallback %s: reasoning_config resolved: %s",
agent.model, agent.reasoning_config,
)
except Exception as _reasoning_err:
logger.debug(
"Failed to resolve reasoning_config for fallback %s; keeping current: %s",
agent.model, _reasoning_err,
)
# Keep whatever reasoning_config was active — don't break the fallback swap.
# Keep the prompt's self-identity in sync with the model actually
# answering, so "what model are you?" doesn't report the primary.
rewrite_prompt_model_identity(agent, fb_model, fb_provider)
@ -2511,7 +2534,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
and not reasoning_parts
and not tool_calls_acc
):
raise RuntimeError(
raise EmptyStreamError(
"Provider returned an empty stream with no finish_reason "
"(possible upstream error or malformed SSE response)."
)
@ -2600,6 +2623,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
works unchanged.
"""
has_tool_use = False
# Zero-event guard parity with the chat_completions path: track
# whether the provider delivered ANY stream event. On an eventless
# stream the real Anthropic SDK's get_final_message() raises
# AssertionError (no message_start ⇒ no final-message snapshot);
# OpenAI-compat shims may instead fabricate a contentless Message
# with no stop_reason, or return None under ``python -O`` (assert
# stripped). Every one of those shapes is normalized below to
# EmptyStreamError so the shared _call() retry loop treats it as
# transient instead of surfacing a raw AssertionError or a
# fabricated "successful" empty turn.
saw_stream_event = False
# Reset stale-stream timer for this attempt
last_chunk_time["t"] = time.time()
@ -2627,6 +2661,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
except Exception:
pass
for event in stream:
saw_stream_event = True
# Update stale-stream timer on every event so the
# outer poll loop knows data is flowing. Without
# this, the detector kills healthy long-running
@ -2687,7 +2722,38 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# this return value is discarded anyway.
if agent._interrupt_requested:
return None
return stream.get_final_message()
# Zero-event guard (parity with the chat_completions zero-chunk
# guard above). Real SDK: an eventless stream has no
# message_start, so get_final_message() raises AssertionError
# (final-message snapshot is None) — normalize that to
# EmptyStreamError so it gets the transient retry budget
# instead of surfacing raw.
try:
_final_message = stream.get_final_message()
except AssertionError:
if not saw_stream_event:
raise EmptyStreamError(
"Provider returned an empty stream with no events "
"(possible upstream error or malformed event stream)."
) from None
raise
# Shim variants of the same failure: an OpenAI-compat adapter
# may fabricate a contentless Message with no stop_reason, or
# return None where the SDK assert would have fired (e.g.
# ``python -O``). A real completed response always carries a
# stop_reason, so this cannot fire on legitimate turns.
if not saw_stream_event and (
_final_message is None
or (
not getattr(_final_message, "content", None)
and getattr(_final_message, "stop_reason", None) is None
)
):
raise EmptyStreamError(
"Provider returned an empty stream with no stop_reason "
"(possible upstream error or malformed event stream)."
)
return _final_message
def _call():
import httpx as _httpx
@ -2734,6 +2800,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
e, (_httpx.ConnectError, _httpx.RemoteProtocolError, ConnectionError)
)
_is_stream_parse_err = agent._is_provider_stream_parse_error(e)
_is_empty_stream = isinstance(e, EmptyStreamError)
# If the stream died AFTER some tokens were delivered:
# normally we don't retry (the user already saw text,
@ -2876,7 +2943,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
for phrase in _SSE_CONN_PHRASES
)
if _is_timeout or _is_conn_err or _is_sse_conn_err or _is_stream_parse_err:
if (
_is_timeout
or _is_conn_err
or _is_sse_conn_err
or _is_stream_parse_err
or _is_empty_stream
):
# Transient network / timeout error. Retry the
# streaming request with a fresh connection first.
if _stream_attempt < _max_stream_retries:
@ -2919,17 +2992,32 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
mid_tool_call=False,
diag=request_client_holder.get("diag"),
)
agent._buffer_status(
"❌ Provider returned malformed streaming data after "
f"{_max_stream_retries + 1} attempts. "
"The provider may be experiencing issues — "
"try again in a moment."
if _is_stream_parse_err else
"❌ Connection to provider failed after "
f"{_max_stream_retries + 1} attempts. "
"The provider may be experiencing issues — "
"try again in a moment."
)
if _is_stream_parse_err:
_exhausted_msg = (
"❌ Provider returned malformed streaming data after "
f"{_max_stream_retries + 1} attempts. "
"The provider may be experiencing issues — "
"try again in a moment."
)
elif _is_empty_stream:
# The connection SUCCEEDED (stream opened) but the
# provider sent no chunks — saying "connection
# failed" here sends users chasing network issues
# when the problem is the provider/endpoint.
_exhausted_msg = (
"❌ Provider returned an empty response stream "
f"after {_max_stream_retries + 1} attempts. "
"The provider may be experiencing issues — "
"try again in a moment."
)
else:
_exhausted_msg = (
"❌ Connection to provider failed after "
f"{_max_stream_retries + 1} attempts. "
"The provider may be experiencing issues — "
"try again in a moment."
)
agent._buffer_status(_exhausted_msg)
else:
_err_lower = str(e).lower()
_is_stream_unsupported = (

View file

@ -194,7 +194,6 @@ def _is_nous_inference_route(provider: str, base_url: str) -> bool:
base = str(base_url or "")
return (
base_url_host_matches(base, "inference-api.nousresearch.com")
or base_url_host_matches(base, "inference.nousresearch.com")
)
@ -522,12 +521,12 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt):
def run_conversation(
agent,
user_message: str,
user_message: Any,
system_message: str = None,
conversation_history: List[Dict[str, Any]] = None,
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
@ -857,10 +856,19 @@ def run_conversation(
if moa_config:
try:
from agent.message_content import flatten_message_text as _flatten_mt
from agent.moa_loop import _preset_temperature, aggregate_moa_context
_moa_context = aggregate_moa_context(
user_prompt=original_user_message if isinstance(original_user_message, str) else str(original_user_message),
user_prompt=(
original_user_message
if isinstance(original_user_message, str)
# Multimodal / decorated content list: extract the
# visible text instead of str()-ing a Python repr of
# the parts (which would leak base64 image payloads
# into the aggregator prompt).
else _flatten_mt(original_user_message)
),
api_messages=api_messages,
reference_models=moa_config.get("reference_models") or [],
aggregator=moa_config.get("aggregator") or {},
@ -874,6 +882,14 @@ def run_conversation(
_base = _msg.get("content", "")
if isinstance(_base, str):
_msg["content"] = _base + "\n\n" + _moa_context
elif isinstance(_base, list):
# Multimodal user turn (text + image parts):
# append the MoA context as a trailing text
# part instead of silently dropping it.
_msg["content"] = [
*_base,
{"type": "text", "text": "\n\n" + _moa_context},
]
break
except Exception as _moa_exc:
logger.warning("MoA context aggregation failed: %s", _moa_exc)
@ -3061,14 +3077,24 @@ def run_conversation(
# (``/new``), switch to a larger-context model, or reduce
# attachments. Forced compaction via ``/compress``
# (``force=True``) is unaffected — it never reaches this loop.
#
# Output-cap errors (max_tokens too large) are NOT input
# overflow — the recovery is a max_tokens-only retry that
# does not require compression. Exempt them from this guard
# so the retry still fires even when compression is disabled.
_overflow_reasons = {
FailoverReason.long_context_tier,
FailoverReason.payload_too_large,
FailoverReason.context_overflow,
}
_is_output_cap_error = (
is_output_cap_error(error_msg)
or parse_available_output_tokens_from_error(error_msg) is not None
)
if (
classified.reason in _overflow_reasons
and not getattr(agent, "compression_enabled", True)
and not _is_output_cap_error
):
agent._flush_status_buffer()
agent._vprint(
@ -3472,15 +3498,33 @@ def run_conversation(
# context_length = total window (input + output combined).
available_out = parse_available_output_tokens_from_error(error_msg)
if available_out is not None:
# Error is purely about the output cap being too large.
# Cap output to the available space and retry without
# touching context_length or triggering compression.
safe_out = max(1, available_out - 64) # small safety margin
# This is an output-cap error, not input overflow.
# The provider's available_tokens is the authoritative
# cap for the failed request, so keep it as an upper
# bound. Also estimate the current API request shape
# (system prompt, injected context, tool schemas) because
# Hermes may add API-only content not present in persisted
# messages. Use the smaller budget and apply a small
# safety margin. Do not alter context_length.
request_input_estimate = estimate_request_tokens_rough(
api_messages, tools=agent.tools or None,
)
local_available_out = old_ctx - request_input_estimate
if local_available_out > 0:
safe_out = max(1, min(available_out, local_available_out) - 64)
else:
# The rough local estimate can overshoot the real
# request size. Fall back to the provider-reported
# budget, which is authoritative for the failed
# request.
safe_out = max(1, available_out - 64)
agent._ephemeral_max_output_tokens = safe_out
agent._buffer_vprint(
f"⚠️ Output cap too large for current prompt — "
f"retrying with max_tokens={safe_out:,} "
f"(available_tokens={available_out:,}; context_length unchanged at {old_ctx:,})"
f"(provider_available={available_out:,}, "
f"estimated_request_tokens={request_input_estimate:,}; "
f"context_length unchanged at {old_ctx:,})"
)
# Still count against compression_attempts so we don't
# loop forever if the error keeps recurring.
@ -4450,7 +4494,9 @@ def run_conversation(
if agent.verbose_logging:
for tc in assistant_message.tool_calls:
logging.debug(f"Tool call: {tc.function.name} with args: {tc.function.arguments[:200]}...")
raw_args = tc.function.arguments
args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200]
logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview)
# Validate tool call names - detect model hallucinations
# Repair mismatched tool names before validating
@ -4631,11 +4677,37 @@ def run_conversation(
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
turn_content = assistant_message.content or ""
# Classify tools in this turn to determine if they are all housekeeping.
# This classification is needed regardless of whether the turn has visible content,
# because a substantive tool-only turn must invalidate any older housekeeping fallback.
_HOUSEKEEPING_TOOLS = frozenset({
"memory", "todo", "skill_manage", "session_search",
})
_all_housekeeping = all(
tc.function.name in _HOUSEKEEPING_TOOLS
for tc in assistant_message.tool_calls
)
# If this turn has substantive tools (non-housekeeping), clear any older fallback.
# Prevents a two-turn-old housekeeping narration from being treated as if it belonged
# to the immediately preceding substantive tool turn.
if assistant_message.tool_calls and not _all_housekeeping:
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
# Also clear the mute flag: a prior housekeeping turn may
# have set _mute_post_response (line ~4667), and the
# substantive tools in THIS turn should produce visible
# progress output. Without this reset, _vprint suppresses
# tool progress until the no-tool-call branch clears it at
# line ~4834 — after all tools have finished.
agent._mute_post_response = False
# If this turn has both content AND tool_calls, capture the content
# as a fallback final response. Common pattern: model delivers its
# answer and calls memory/skill tools as a side-effect in the same
# turn. If the follow-up turn after tools is empty, we use this.
turn_content = assistant_message.content or ""
if turn_content and agent._has_content_after_think_block(turn_content):
agent._last_content_with_tools = turn_content
# Only mute subsequent output when EVERY tool call in
@ -4643,13 +4715,6 @@ def run_conversation(
# skill_manage, etc.). If any substantive tool is present
# (search_files, read_file, write_file, terminal, ...),
# keep output visible so the user sees progress.
_HOUSEKEEPING_TOOLS = frozenset({
"memory", "todo", "skill_manage", "session_search",
})
_all_housekeeping = all(
tc.function.name in _HOUSEKEEPING_TOOLS
for tc in assistant_message.tool_calls
)
agent._last_content_tools_all_housekeeping = _all_housekeeping
if _all_housekeeping and agent._has_stream_consumers():
agent._mute_post_response = True
@ -5265,6 +5330,53 @@ def run_conversation(
final_response = None
continue
# ── Kanban worker terminal-tool stop guard ─────────────
# Workers must end with kanban_complete / kanban_block.
# Models sometimes narrate the next step ("Let me write the
# report") and stop with finish_reason=stop — a clean exit
# that the dispatcher records as protocol_violation. Nudge
# once or twice before allowing that exit.
try:
from agent.kanban_stop import build_kanban_stop_nudge
_kanban_nudge = build_kanban_stop_nudge(
messages=messages,
attempts=getattr(agent, "_kanban_stop_nudges", 0),
)
except Exception:
logger.debug("kanban stop-loop check failed", exc_info=True)
_kanban_nudge = None
if _kanban_nudge:
agent._kanban_stop_nudges = (
getattr(agent, "_kanban_stop_nudges", 0) + 1
)
final_msg["finish_reason"] = "kanban_terminal_required"
final_msg["_kanban_stop_synthetic"] = True
messages.append(final_msg)
messages.append({
"role": "user",
"content": _kanban_nudge,
"_kanban_stop_synthetic": True,
})
agent._session_messages = messages
logger.info(
"kanban stop-loop nudge issued (attempt %d) task=%s",
agent._kanban_stop_nudges,
os.environ.get("HERMES_KANBAN_TASK", ""),
)
agent._emit_status(
"⚠️ Kanban worker tried to exit without "
"kanban_complete/kanban_block — nudging to finish"
)
# Same finalizer contract as verify-on-stop: clear
# final_response while continuing so a later budget
# exhaustion path does not treat the narrated stop as
# a completed answer.
_pending_verification_response = final_response
final_response = None
continue
messages.append(final_msg)
_turn_exit_reason = f"text_response(finish_reason={finish_reason})"

View file

@ -26,7 +26,7 @@ from openai.types.chat.chat_completion_message_tool_call import (
Function,
)
from agent.file_safety import get_read_block_error, is_write_denied
from agent.file_safety import get_read_block_error, get_write_denied_error
from agent.redact import redact_sensitive_text
from tools.environments.local import hermes_subprocess_env
@ -727,10 +727,9 @@ class CopilotACPClient:
elif method == "fs/write_text_file":
try:
path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
if is_write_denied(str(path)):
raise PermissionError(
f"Write denied: '{path}' is a protected system/credential file."
)
denied = get_write_denied_error(str(path))
if denied:
raise PermissionError(denied)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(params.get("content") or ""))
response = {

View file

@ -1,3 +1,9 @@
class SSLConfigurationError(Exception):
"""Raised when SSL/TLS certificate bundle configuration fails."""
pass
class EmptyStreamError(RuntimeError):
"""Raised when a provider closes a stream without yielding a response."""
pass

View file

@ -95,16 +95,16 @@ def get_safe_write_roots() -> set[str]:
return roots
def is_write_denied(path: str) -> bool:
"""Return True if path is blocked by the write denylist or safe root."""
def _classify_write_denial(path: str) -> Optional[str]:
"""Return ``'credential'``, ``'safe_root'``, or ``None`` if writes are allowed."""
home = os.path.realpath(os.path.expanduser("~"))
resolved = os.path.realpath(os.path.expanduser(str(path)))
if resolved in build_write_denied_paths(home):
return True
return "credential"
for prefix in build_write_denied_prefixes(home):
if resolved.startswith(prefix):
return True
return "credential"
mcp_tokens_dir_name = "mcp-tokens"
@ -121,13 +121,13 @@ def is_write_denied(path: str) -> bool:
try:
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
return True
return "credential"
except Exception:
pass
try:
pairing_real = os.path.realpath(os.path.join(base_real, "pairing"))
if resolved == pairing_real or resolved.startswith(pairing_real + os.sep):
return True
return "credential"
except Exception:
pass
@ -139,9 +139,28 @@ def is_write_denied(path: str) -> bool:
allowed = True
break
if not allowed:
return True
return "safe_root"
return False
return None
def is_write_denied(path: str) -> bool:
"""Return True if path is blocked by the write denylist or safe root."""
return _classify_write_denial(path) is not None
def get_write_denied_error(path: str, *, verb: str = "Write") -> Optional[str]:
"""Return a user/model-facing error when writes to ``path`` are blocked."""
denial = _classify_write_denial(path)
if denial is None:
return None
if denial == "safe_root":
roots_display = os.pathsep.join(sorted(get_safe_write_roots()))
return (
f"{verb} denied: '{path}' is outside HERMES_WRITE_SAFE_ROOT "
f"({roots_display}). Unset the variable or add this path's directory prefix."
)
return f"{verb} denied: '{path}' is a protected system/credential file."
# Common secret-bearing project-local environment file basenames.

View file

@ -32,6 +32,13 @@ from agent.gemini_schema import sanitize_gemini_tool_parameters
logger = logging.getLogger(__name__)
try:
import hermes_cli as _hermes_cli
_HERMES_VERSION = str(_hermes_cli.__version__)
except Exception:
_HERMES_VERSION = "0.0.0"
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
# Published max output-token ceiling shared by every current Gemini text model
@ -99,7 +106,10 @@ def probe_gemini_tier(
url,
params={"key": key},
json=payload,
headers={"Content-Type": "application/json"},
headers={
"Content-Type": "application/json",
"X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}",
},
)
except Exception as exc:
logger.debug("probe_gemini_tier: network error: %s", exc)
@ -901,7 +911,11 @@ class GeminiNativeClient:
"Content-Type": "application/json",
"Accept": "application/json",
"x-goog-api-key": self.api_key,
"User-Agent": "hermes-agent (gemini-native)",
# Include Hermes client context following Gemini's partner
# integration guidance.
# See https://ai.google.dev/gemini-api/docs/partner-integration
"User-Agent": f"hermes-agent/{_HERMES_VERSION} (gemini-native)",
"X-Goog-Api-Client": f"hermes-agent/{_HERMES_VERSION}",
}
headers.update(self._default_headers)
return headers

108
agent/kanban_stop.py Normal file
View file

@ -0,0 +1,108 @@
"""Turn-end guard for kanban workers.
Kanban workers must end with ``kanban_complete`` or ``kanban_block``. Models
(especially GLM / Qwen families) sometimes narrate the next step
("Let me write the report now") and stop with ``finish_reason=stop`` and no
tool calls. Hermes treats that as a clean exit ``rc=0`` dispatcher
``protocol_violation``.
This module is policy-only: when a kanban worker tries to finish without a
terminal board tool, return a bounded synthetic nudge so the conversation
loop continues instead of exiting.
"""
from __future__ import annotations
import os
from typing import Any, Iterable, Optional
_TERMINAL_KANBAN_TOOLS = frozenset({"kanban_complete", "kanban_block"})
_DEFAULT_MAX_ATTEMPTS = 2
def kanban_stop_nudge_enabled() -> bool:
"""Return whether the kanban stop-guard is active for this process.
On when ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), unless
``HERMES_KANBAN_STOP_NUDGE`` explicitly disables it.
"""
env = os.environ.get("HERMES_KANBAN_STOP_NUDGE")
if env is not None and env.strip().lower() in {"0", "false", "no", "off"}:
return False
task = (os.environ.get("HERMES_KANBAN_TASK") or "").strip()
return bool(task)
def _tool_call_name(tc: Any) -> str:
if isinstance(tc, dict):
fn = tc.get("function")
if isinstance(fn, dict):
return str(fn.get("name") or "")
return str(tc.get("name") or "")
fn = getattr(tc, "function", None)
if fn is not None:
return str(getattr(fn, "name", "") or "")
return str(getattr(tc, "name", "") or "")
def session_called_kanban_terminal(messages: Iterable[dict] | None) -> bool:
"""True if this conversation already invoked a terminal kanban tool."""
if not messages:
return False
for msg in messages:
if not isinstance(msg, dict):
continue
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if _tool_call_name(tc) in _TERMINAL_KANBAN_TOOLS:
return True
elif role == "tool":
name = str(msg.get("name") or "")
if name in _TERMINAL_KANBAN_TOOLS:
return True
return False
def build_kanban_stop_nudge(
*,
messages: Iterable[dict] | None = None,
attempts: int = 0,
max_attempts: int = _DEFAULT_MAX_ATTEMPTS,
task_id: Optional[str] = None,
) -> Optional[str]:
"""Return a synthetic follow-up when a kanban worker exits without a terminal tool.
Returns ``None`` when the guard should not fire (not a kanban worker,
already completed/blocked, or nudge budget exhausted).
"""
if not kanban_stop_nudge_enabled():
return None
if attempts >= max_attempts:
return None
if session_called_kanban_terminal(messages):
return None
tid = (task_id or os.environ.get("HERMES_KANBAN_TASK") or "").strip() or "this task"
return (
"[System: You are a Hermes kanban worker. A plain-text reply is NOT a "
"terminal state for the board.\n\n"
f"Task `{tid}` is still `running`. Ending now without a board tool "
"causes a protocol violation (clean exit with no "
"`kanban_complete` / `kanban_block`).\n\n"
"Do this immediately in your next response — do not narrate intent:\n"
"1. Finish any remaining deliverable (write the required file(s) now).\n"
"2. Call `kanban_complete(summary=..., artifacts=[...])` if the work "
"is done, OR `kanban_block(reason=...)` if you are blocked.\n\n"
"Never end a turn with only a promise of future action. Repeated "
"protocol violations will block this task and require manual intervention.]"
)
__all__ = [
"build_kanban_stop_nudge",
"kanban_stop_nudge_enabled",
"session_called_kanban_terminal",
]

View file

@ -14,6 +14,7 @@ from concurrent.futures import ThreadPoolExecutor
from typing import Any
from agent.auxiliary_client import call_llm
from agent.message_content import flatten_message_text
from agent.transports import get_transport
logger = logging.getLogger(__name__)
@ -119,11 +120,24 @@ _REFERENCE_SYSTEM_PROMPT = (
def _slot_label(slot: dict[str, str]) -> str:
return f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}"
def _slot_label(slot: dict[str, Any]) -> str:
label = f"{(slot.get('provider') or '').strip()}:{(slot.get('model') or '').strip()}"
effort = str(slot.get("reasoning_effort") or "").strip()
return f"{label}[reasoning={effort}]" if effort else label
def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:
def _slot_reasoning_config(slot: dict[str, Any]) -> dict[str, Any] | None:
"""Translate optional per-MoA-slot reasoning_effort into runtime config."""
effort = slot.get("reasoning_effort")
try:
from hermes_constants import parse_reasoning_effort
return parse_reasoning_effort(effort)
except Exception: # pragma: no cover - defensive; bad config must not break MoA
return None
def _slot_runtime(slot: dict[str, Any]) -> dict[str, Any]:
"""Resolve a reference/aggregator slot to real runtime call kwargs.
A MoA slot is just a model selection it must be called the same way any
@ -275,6 +289,7 @@ def _run_reference(
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
reasoning_config=_slot_reasoning_config(slot),
**runtime,
)
usage = CanonicalUsage()
@ -470,13 +485,52 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
for msg in messages:
role = msg.get("role")
content = msg.get("content")
text = content if isinstance(content, str) else ""
# Flatten structured content (lists of parts) to visible text. Content
# arrives as a list — not a string — in two common cases:
# 1. Anthropic prompt-cache decoration: conversation_loop runs
# apply_anthropic_cache_control BEFORE the MoA facade, converting
# string content to [{"type": "text", "text": ..., "cache_control":
# ...}]. A str-only read here flattened the user's ENTIRE prompt to
# "" — Claude references then 400'd ("messages: at least one
# message is required") while tolerant models answered "no user
# request is present".
# 2. Multimodal turns (pasted image → text + image_url parts) and
# multimodal tool results (screenshots).
# flatten_message_text extracts the text parts and skips image parts,
# and returns strings unchanged — so a decorated and an undecorated
# transcript produce a byte-identical advisory view (which keeps the
# advisory prefix stable across iterations for advisor prompt caching).
text = flatten_message_text(content)
if role == "system":
continue
if role == "user":
if text.strip():
last_user_content = text
if not text.strip() and isinstance(content, list) and content:
# Structured content with no extractable text (e.g. an
# image-only turn). Emitting an empty user message would be
# dropped/rejected by strict providers (Anthropic 400s on
# empty text blocks — the original "closed" preset failure
# mode), and silently skipping the turn would break
# user/assistant alternation in the advisory view. Substitute
# a placeholder so the reference knows a non-text turn
# happened. Only structured content qualifies — an empty or
# whitespace-only STRING turn carries nothing and is dropped
# below instead.
text = "[user sent non-text content (e.g. an image attachment)]"
if not text.strip():
# Genuinely empty user turn (content="" / None). It carries
# nothing advisory, and strict providers (Kimi/Moonshot, ZAI,
# and others that enforce non-empty user content) reject it
# with 400 "message ... with role 'user' must not be empty" —
# the same way the assistant branch below drops turns with no
# parts. Lenient providers (DeepSeek) accept the empty turn,
# which is why a MoA fan-out would fail on one reference and
# pass on another for the identical rendered view. The
# advisory view is already not strictly alternating (adjacent
# assistant turns occur in every tool loop), so dropping a
# contentless turn is safe.
continue
last_user_content = text
rendered.append({"role": "user", "content": text})
elif role == "assistant":
parts: list[str] = []
@ -517,8 +571,10 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
if last_user_content is not None:
return [{"role": "user", "content": last_user_content}]
for msg in reversed(messages):
if msg.get("role") == "user" and isinstance(msg.get("content"), str):
return [{"role": "user", "content": msg["content"]}]
if msg.get("role") == "user":
fallback_text = flatten_message_text(msg.get("content"))
if fallback_text.strip():
return [{"role": "user", "content": fallback_text}]
return rendered
@ -638,6 +694,7 @@ def aggregate_moa_context(
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=max_tokens,
reasoning_config=_slot_reasoning_config(aggregator),
**agg_runtime,
)
synthesis = _extract_text(response)
@ -671,13 +728,28 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str
Appending at the very end keeps the ``[system][task][tool-history]`` prefix
stable and cache-reusable (only the new block re-prefills), and gives the
aggregator the references with recency. Merge into the last message only when
it is already a trailing string ``user`` turn (plain chat still at the end).
it is already a trailing ``user`` turn (plain chat still at the end).
A trailing user turn's content may be a STRING or a LIST of content parts —
Anthropic prompt-cache decoration (which runs before the MoA facade)
converts string content to ``[{"type": "text", ..., "cache_control": ...}]``,
and multimodal turns are lists natively. Both shapes are merged in place:
appending a new text part AFTER the cache_control-marked part keeps the
cached prefix byte-stable (the marker still terminates it) while the
turn-varying guidance rides outside the cached span. Appending a SEPARATE
user message here instead would produce two consecutive user turns
strict providers reject that.
"""
last = agg_messages[-1] if agg_messages else None
if last is not None and last.get("role") == "user" and isinstance(last.get("content"), str):
last["content"] = last["content"] + "\n\n" + guidance
else:
agg_messages.append({"role": "user", "content": guidance})
if last is not None and last.get("role") == "user":
last_content = last.get("content")
if isinstance(last_content, str):
last["content"] = last_content + "\n\n" + guidance
return
if isinstance(last_content, list):
last["content"] = [*last_content, {"type": "text", "text": "\n\n" + guidance}]
return
agg_messages.append({"role": "user", "content": guidance})
class MoAChatCompletions:
@ -1017,6 +1089,7 @@ class MoAChatCompletions:
max_tokens=agg_kwargs.get("max_tokens"),
tools=agg_kwargs.get("tools"),
extra_body=agg_kwargs.get("extra_body"),
reasoning_config=_slot_reasoning_config(aggregator),
**stream_kwargs,
**_slot_runtime(aggregator),
)

View file

@ -47,7 +47,7 @@ def _resolve_requests_verify() -> bool | str:
# are preserved so the full model name reaches cache lookups and server queries.
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "deepinfra",
"opencode-zen", "opencode-go", "kilocode", "alibaba", "novita",
"qwen-oauth",
"xiaomi",
@ -58,7 +58,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
# Common aliases
"google", "google-gemini", "google-ai-studio",
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "deep-infra",
"ollama",
"stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen",
"mimo", "xiaomi-mimo",
@ -318,6 +318,16 @@ DEFAULT_CONTEXT_LENGTHS = {
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
# Kimi
"kimi": 262144,
# Upstage Solar — api.upstage.ai/v1/models does not return context_length,
# so these fallbacks keep token budgeting / compression from probing down
# to the 128k default. Ids are matched longest-first, so dated variants
# (e.g. solar-pro3-250127) resolve via their family prefix.
# Sources: Solar Pro 3 = 128K, Solar Pro 2 = 64K, Solar Mini = 32K,
# Solar Open 2 = 256K.
"solar-open2": 262144, # 256K
"solar-pro3": 131072,
"solar-pro2": 65536,
"solar-mini": 32768,
# Tencent — Hy3 Preview (Hunyuan) with 256K context window.
# OpenRouter live metadata reports 262144 (256 × 1024); align the
# static fallback so cache and offline both agree (issue #22268).

View file

@ -659,19 +659,7 @@ PLATFORM_HINTS = {
"Standard Markdown is automatically converted to Telegram formatting. "
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
"`inline code`, ```code blocks```, [links](url), and ## headers. "
"Telegram now supports rich Markdown, so lean into it: whenever it "
"makes the answer clearer or easier to scan, actively reach for real "
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
"collapsible details, footnotes/references, math/formulas (`$...$`, "
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
"text, and anchors. Default to structured formatting over dense "
"paragraphs for any comparison, set of steps, key/value summary, or "
"tabular data. Prefer real Markdown tables and task lists over "
"hand-built bullet substitutes when presenting structured data; these "
"degrade gracefully (tables become readable bullet groups) when rich "
"rendering is unavailable, but advanced constructs like math and "
"collapsible details may render as plain source text in that case. "
"Prefer bullet lists and labeled key:value pairs for structured data. "
"You can send media files natively: to deliver a file to the user, "
"include MEDIA:/absolute/path/to/file in your response. Images "
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
@ -865,6 +853,27 @@ PLATFORM_HINTS = {
),
}
# Telegram rich-messages extension — only injected when the user has opted in
# to ``platforms.telegram.extra.rich_messages: true``. The base
# PLATFORM_HINTS["telegram"] covers MarkdownV2-compatible constructs; this
# extension adds the Bot API 10.1 rich-Markdown guidance (tables, task lists,
# collapsible details, math, etc.).
TELEGRAM_RICH_MESSAGES_HINT = (
"Telegram now supports rich Markdown, so lean into it: whenever it "
"makes the answer clearer or easier to scan, actively reach for real "
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
"collapsible details, footnotes/references, math/formulas (`$...$`, "
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
"text, and anchors. Default to structured formatting over dense "
"paragraphs for any comparison, set of steps, key/value summary, or "
"tabular data. Prefer real Markdown tables and task lists over "
"hand-built bullet substitutes when presenting structured data; these "
"degrade gracefully (tables become readable bullet groups) when rich "
"rendering is unavailable, but advanced constructs like math and "
"collapsible details may render as plain source text in that case. "
)
# ---------------------------------------------------------------------------
# Environment hints — execution-environment awareness for the agent.
# Unlike PLATFORM_HINTS (which describe the messaging channel), these describe

View file

@ -329,6 +329,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
try:
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names
from agent.skill_utils import get_external_skills_dirs, iter_skill_index_files
from hermes_cli.commands import resolve_command
disabled = _get_disabled_skill_names()
seen_names: set = set()
@ -374,7 +375,32 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
cmd_name = _SKILL_MULTI_HYPHEN.sub('-', cmd_name).strip('-')
if not cmd_name:
continue
_skill_commands[f"/{cmd_name}"] = {
# Skip if this skill's auto-generated /command collides
# with a core Hermes slash command (name or alias). The
# skill remains fully loadable via /skill <name>.
# Uses resolve_command() so aliases and case variants are
# covered without maintaining a separate cache.
if resolve_command(cmd_name) is not None:
logger.warning(
"Skill %r generates slash command '/%s' which "
"collides with a core Hermes command; skipping "
"auto-registration. Use '/skill %s' instead.",
name, cmd_name, name,
)
continue
# Dedup on the resolved slug, not just the raw name: two
# distinct frontmatter names can normalize to the same
# slug (e.g. "git_helper" vs "git-helper"). First-wins
# preserves local-before-external precedence.
cmd_key = f"/{cmd_name}"
if cmd_key in _skill_commands:
logger.warning(
"Skill %r maps to slash command %s already claimed "
"by %r; keeping the first and skipping this one.",
name, cmd_key, _skill_commands[cmd_key]["name"],
)
continue
_skill_commands[cmd_key] = {
"name": name,
"description": description or f"Invoke the {name} skill",
"skill_md_path": str(skill_md),

View file

@ -40,6 +40,7 @@ from agent.prompt_builder import (
SKILLS_GUIDANCE,
STEER_CHANNEL_NOTE,
TASK_COMPLETION_GUIDANCE,
TELEGRAM_RICH_MESSAGES_HINT,
TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS,
drain_truncation_warnings,
@ -429,6 +430,20 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
except Exception:
pass
# For Telegram: append the rich-messages extension only when the user has
# opted in to ``platforms.telegram.extra.rich_messages: true``. The base
# hint covers MarkdownV2-compatible constructs; the extension adds Bot API
# 10.1 guidance (tables, task lists, math, collapsible details, etc.).
if platform_key == "telegram" and _default_hint:
try:
from hermes_cli.config import load_config_readonly
_cfg = load_config_readonly()
_tg_extra = ((_cfg.get("platforms") or {}).get("telegram") or {}).get("extra") or {}
if _tg_extra.get("rich_messages"):
_default_hint = _default_hint.rstrip() + " " + TELEGRAM_RICH_MESSAGES_HINT
except Exception:
pass # Config read failure — fall back to base hint only
_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
if platform_key == "tui" and _effective_hint:
_effective_hint = _tui_embedded_pane_clarifier(_effective_hint)

View file

@ -102,50 +102,119 @@ def _is_mcp_tool_parallel_safe(tool_name: str) -> bool:
return False
def _should_parallelize_tool_batch(tool_calls) -> bool:
"""Return True when a tool-call batch is safe to run concurrently."""
if len(tool_calls) <= 1:
return False
def _plan_tool_batch_segments(tool_calls) -> List[tuple]:
"""Split a tool-call batch into ordered ``(kind, calls)`` segments.
tool_names = [tc.function.name for tc in tool_calls]
if any(name in _NEVER_PARALLEL_TOOLS for name in tool_names):
return False
``kind`` is ``"parallel"`` (a maximal contiguous run of parallel-safe
calls) or ``"sequential"`` (one or more barrier calls that must run
in-order on the sequential path). Segments preserve the model's
original call order exactly a later call never crosses an earlier
barrier so tool-result ordering and side-effect boundaries are
identical to fully-sequential execution. The per-call safety rules
are the same ones the old all-or-nothing gate applied to the whole
batch:
* ``_NEVER_PARALLEL_TOOLS`` (interactive tools) barrier.
* Unparseable / non-dict arguments barrier.
* Path-scoped tools (``read_file``/``write_file``/``patch``) join a
parallel run only when their target path does not overlap another
path already reserved in the same run; an overlap closes the run so
the conflicting call starts a NEW run after the first completes.
* Anything not in ``_PARALLEL_SAFE_TOOLS`` and not an opted-in MCP
tool barrier.
Parallel runs shorter than two calls are demoted to sequential (no
concurrency win, and the sequential executor owns the richer inline
dispatch), and adjacent sequential segments are merged.
"""
segments: list[list] = [] # [kind, calls] pairs, normalized to tuples on return
current: list = []
reserved_paths: list[Path] = []
def _close_parallel() -> None:
nonlocal current, reserved_paths
if current:
segments.append(["parallel", current])
current = []
reserved_paths = []
def _add_sequential(tc) -> None:
_close_parallel()
if segments and segments[-1][0] == "sequential":
segments[-1][1].append(tc)
else:
segments.append(["sequential", [tc]])
for tool_call in tool_calls:
tool_name = tool_call.function.name
if tool_name in _NEVER_PARALLEL_TOOLS:
_add_sequential(tool_call)
continue
try:
function_args = json.loads(tool_call.function.arguments)
except Exception:
_raw = tool_call.function.arguments
logging.debug(
"Could not parse args for %s — defaulting to sequential; raw=%s",
"Could not parse args for %streating as sequential barrier; raw=%s",
tool_name,
tool_call.function.arguments[:200],
_raw[:200] if isinstance(_raw, str) else repr(_raw)[:200],
)
return False
_add_sequential(tool_call)
continue
if not isinstance(function_args, dict):
logging.debug(
"Non-dict args for %s (%s) — defaulting to sequential",
"Non-dict args for %s (%s) — treating as sequential barrier",
tool_name,
type(function_args).__name__,
)
return False
_add_sequential(tool_call)
continue
if tool_name in _PATH_SCOPED_TOOLS:
scoped_path = _extract_parallel_scope_path(tool_name, function_args)
if scoped_path is None:
return False
_add_sequential(tool_call)
continue
if any(_paths_overlap(scoped_path, existing) for existing in reserved_paths):
return False
# Same-subtree conflict inside this run: close it so this
# call starts a fresh run AFTER the conflicting one lands.
_close_parallel()
reserved_paths.append(scoped_path)
current.append(tool_call)
continue
if tool_name not in _PARALLEL_SAFE_TOOLS:
# Check if it's an MCP tool from a server that opted into parallel calls.
if not _is_mcp_tool_parallel_safe(tool_name):
return False
if tool_name in _PARALLEL_SAFE_TOOLS or _is_mcp_tool_parallel_safe(tool_name):
current.append(tool_call)
continue
return True
_add_sequential(tool_call)
_close_parallel()
normalized: list[list] = []
for kind, calls in segments:
if kind == "parallel" and len(calls) < 2:
kind = "sequential"
if normalized and normalized[-1][0] == "sequential" and kind == "sequential":
normalized[-1][1].extend(calls)
else:
normalized.append([kind, calls])
return [(kind, calls) for kind, calls in normalized]
def _should_parallelize_tool_batch(tool_calls) -> bool:
"""Return True when the WHOLE tool-call batch is safe to run concurrently.
Thin view over ``_plan_tool_batch_segments`` kept for callers/tests that
only care about the homogeneous case: True iff the planner produces a
single all-parallel segment.
"""
if len(tool_calls) <= 1:
return False
segments = _plan_tool_batch_segments(tool_calls)
return len(segments) == 1 and segments[0][0] == "parallel"
def _extract_parallel_scope_path(tool_name: str, function_args: dict) -> Optional[Path]:
@ -542,6 +611,7 @@ __all__ = [
"_DESTRUCTIVE_PATTERNS",
"_REDIRECT_OVERWRITE",
"_is_destructive_command",
"_plan_tool_batch_segments",
"_should_parallelize_tool_batch",
"_extract_parallel_scope_path",
"_paths_overlap",

View file

@ -36,6 +36,7 @@ from agent.tool_dispatch_helpers import (
_is_multimodal_tool_result,
_multimodal_text_summary,
_append_subdir_hint_to_multimodal,
_plan_tool_batch_segments,
make_tool_result_message,
)
from tools.terminal_tool import (
@ -322,11 +323,15 @@ def _run_agent_tool_execution_middleware(
return result, observed_args
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None:
"""Execute multiple tool calls concurrently using a thread pool.
Results are collected in the original tool-call order and appended to
messages so the API sees them in the expected sequence.
``finalize=False`` skips the end-of-batch aggregate budget enforcement
and /steer injection used when this call is one segment of a larger
mixed batch and the segmented dispatcher owns the turn-end work.
"""
tool_calls = assistant_message.tool_calls
num_tools = len(tool_calls)
@ -1006,7 +1011,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# ── Per-turn aggregate budget enforcement ─────────────────────────
num_tools = len(parsed_calls)
if num_tools > 0:
if finalize and num_tools > 0:
turn_tool_msgs = messages[-num_tools:]
enforce_turn_budget(turn_tool_msgs, env=get_active_env(effective_task_id), config=_tool_budget)
@ -1014,13 +1019,18 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
# Append any pending user steer text to the last tool result so the
# agent sees it on its next iteration. Runs AFTER budget enforcement
# so the steer marker is never truncated. See steer() for details.
if num_tools > 0:
if finalize and num_tools > 0:
agent._apply_pending_steer_to_tool_results(messages, num_tools)
def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None:
"""Execute tool calls sequentially (original behavior). Used for single calls or interactive tools."""
def execute_tool_calls_sequential(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None:
"""Execute tool calls sequentially (original behavior). Used for single calls or interactive tools.
``finalize=False`` skips the end-of-batch aggregate budget enforcement
and /steer injection used when this call is one segment of a larger
mixed batch and the segmented dispatcher owns the turn-end work.
"""
# Resolve the context-scaled tool-output budget once per turn.
_tool_budget = _budget_for_agent(agent)
for i, tool_call in enumerate(assistant_message.tool_calls, 1):
@ -1716,19 +1726,73 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# ── Per-turn aggregate budget enforcement ─────────────────────────
num_tools_seq = len(assistant_message.tool_calls)
if num_tools_seq > 0:
if finalize and num_tools_seq > 0:
enforce_turn_budget(messages[-num_tools_seq:], env=get_active_env(effective_task_id), config=_tool_budget)
# ── /steer injection ──────────────────────────────────────────────
# See _execute_tool_calls_parallel for the rationale. Same hook,
# applied to sequential execution as well.
if num_tools_seq > 0:
if finalize and num_tools_seq > 0:
agent._apply_pending_steer_to_tool_results(messages, num_tools_seq)
def execute_tool_calls_segmented(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, segments=None) -> None:
"""Execute a mixed tool-call batch as ordered parallel/sequential segments.
``segments`` is the ``(kind, calls)`` plan from
``_plan_tool_batch_segments``: maximal contiguous runs of parallel-safe
calls execute on the concurrent path, barrier calls on the sequential
path, strictly in the model's original call order. Because segments are
contiguous, every tool result is still appended one-per-call in emission
order and no call ever starts before an earlier barrier finishes
identical ordering and side-effect boundaries to fully-sequential
execution, with I/O parallelism recovered inside the safe runs.
Turn-end work (aggregate budget enforcement + /steer injection) is done
once here for the WHOLE batch; the per-segment executor calls run with
``finalize=False`` so a multi-segment turn cannot multiply the budget or
truncate a steer marker.
Interrupt semantics: each segment executor already checks
``agent._interrupt_requested`` up front and appends a cancelled/skipped
result per call, so an interrupt during segment *k* drains segments
*k+1..n* without executing them while preserving one result per
tool_call_id.
"""
from types import SimpleNamespace
if segments is None:
segments = _plan_tool_batch_segments(assistant_message.tool_calls)
for kind, calls in segments:
segment_message = SimpleNamespace(tool_calls=list(calls))
if kind == "parallel":
execute_tool_calls_concurrent(
agent, segment_message, messages, effective_task_id, api_call_count,
finalize=False,
)
else:
execute_tool_calls_sequential(
agent, segment_message, messages, effective_task_id, api_call_count,
finalize=False,
)
# ── Whole-turn finalize (budget + /steer) ─────────────────────────
total_tools = len(assistant_message.tool_calls)
if total_tools > 0:
_tool_budget = _budget_for_agent(agent)
enforce_turn_budget(
messages[-total_tools:],
env=get_active_env(effective_task_id),
config=_tool_budget,
)
agent._apply_pending_steer_to_tool_results(messages, total_tools)
__all__ = [
"execute_tool_calls_concurrent",
"execute_tool_calls_sequential",
"execute_tool_calls_segmented",
]

View file

@ -44,6 +44,7 @@ Spawned by: CodexAppServerSession.ensure_started() when the runtime is
from __future__ import annotations
import inspect
import json
import logging
import os
@ -52,6 +53,49 @@ from typing import Any, Optional
logger = logging.getLogger(__name__)
# JSON Schema type -> Python type mapping for signature generation
_JSON_TO_PY = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
"array": list,
"object": dict,
}
def _signature_from_schema(schema: dict | None) -> tuple[inspect.Signature, dict[str, type]]:
"""Build a Python function signature and annotations from a JSON schema.
Args:
schema: JSON Schema dict with "properties" and "required" keys.
Returns:
(signature, annotations_dict) where signature has KEYWORD_ONLY params
and annotations maps param names to Python types.
"""
props = (schema or {}).get("properties") or {}
required = set((schema or {}).get("required") or [])
params, annots = [], {}
for pname, pspec in props.items():
if pname.startswith("_"):
continue
py = _JSON_TO_PY.get((pspec or {}).get("type"), Any)
ann, default = (
(py, inspect.Parameter.empty)
if pname in required
else (Optional[py], None)
)
annots[pname] = ann
params.append(
inspect.Parameter(
pname, inspect.Parameter.KEYWORD_ONLY, annotation=ann, default=default
)
)
return inspect.Signature(params, return_annotation=str), annots
# Tools we expose. Each name MUST match a registered Hermes tool that
# `model_tools.handle_function_call()` can dispatch.
@ -159,29 +203,36 @@ def _build_server() -> Any:
# the result string. We use add_tool() for full control over the
# input schema (FastMCP's @tool() decorator inspects type hints,
# which we can't get from a JSON schema at runtime).
def _make_handler(tool_name: str):
def _make_handler(tool_name: str, schema: dict | None):
sig, annots = _signature_from_schema(schema)
def _dispatch(**kwargs: Any) -> str:
try:
return handle_function_call(tool_name, kwargs or {})
# Filter out None values before dispatch so unset optionals
# aren't forwarded to the handler.
args = {k: v for k, v in kwargs.items() if v is not None}
return handle_function_call(tool_name, args or {})
except Exception as exc:
logger.exception("tool %s raised", tool_name)
return json.dumps({"error": str(exc), "tool": tool_name})
_dispatch.__name__ = tool_name
_dispatch.__doc__ = description
_dispatch.__signature__ = sig
_dispatch.__annotations__ = {**annots, "return": str}
return _dispatch
try:
mcp.add_tool(
_make_handler(name),
_make_handler(name, params_schema),
name=name,
description=description,
# FastMCP accepts JSON schema directly via the
# input_schema parameter on newer versions; older
# versions use parameters_schema. Try both for compat.
)
except TypeError:
# Older mcp SDK signature — fall back to decorator-style.
handler = _make_handler(name)
# Older mcp SDK signature — fall back to decorator-style. The
# synthesized __signature__ on the handler still drives schema
# generation there.
handler = _make_handler(name, params_schema)
handler = mcp.tool(name=name, description=description)(handler)
exposed_count += 1

View file

@ -118,12 +118,12 @@ class TurnContext:
def build_turn_context(
agent,
user_message: str,
user_message: Any,
system_message: Optional[str],
conversation_history: Optional[List[Dict[str, Any]]],
task_id: Optional[str],
stream_callback,
persist_user_message: Optional[str],
persist_user_message: Optional[Any],
persist_user_timestamp: Optional[float] = None,
*,
restore_or_build_system_prompt,
@ -271,6 +271,29 @@ def build_turn_context(
# Initialize conversation (copy to avoid mutating the caller's list).
messages = list(conversation_history) if conversation_history else []
# The CLI may already have staged this input outside the history passed to
# ``run_conversation``. Reuse it only when its clean transcript text matches
# this turn; a stale handoff from a failed prior turn must not replace a
# later, different user input. Voice turns compare against their explicit
# clean persistence override rather than the API-only prefixed payload.
pending_cli_message = getattr(agent, "_pending_cli_user_message", None)
expected_persist_content = (
persist_user_message if persist_user_message is not None else user_message
)
if (
isinstance(pending_cli_message, dict)
and pending_cli_message.get("content") == expected_persist_content
):
user_msg = pending_cli_message
# The CLI-staged value is the clean transcript text. Restore the
# API-facing variant (for example, a voice-mode prefix) while retaining
# the same dict and any close-path durable marker.
user_msg["content"] = user_message
else:
user_msg = {"role": "user", "content": user_message}
if isinstance(pending_cli_message, dict):
agent._pending_cli_user_message = None
# Hydrate todo store from conversation history.
if conversation_history and not agent._todo_store.has_items():
agent._hydrate_todo_store(conversation_history)
@ -285,6 +308,13 @@ def build_turn_context(
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
# Add the current user message after the prompt/session setup has made
# close persistence safe. The handoff above preserves any marker already
# stamped by an earlier close flush.
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
# Track user turns for memory flush and periodic nudge logic.
agent._user_turn_count += 1
# Copilot x-initiator: the first API call of this user turn is
@ -313,12 +343,6 @@ def build_turn_context(
should_review_memory = True
agent._turns_since_memory = 0
# Add user message.
user_msg = {"role": "user", "content": user_message}
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
# Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot)
# and notify the host so it can play hearts. Token-free, never touches the
# conversation, and never fatal — a purely optional UI beat.
@ -348,18 +372,33 @@ def build_turn_context(
# Create the DB session row now that _cached_system_prompt is populated, so
# the persisted snapshot is written non-NULL on the first turn (Issue
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
agent._ensure_db_session()
# #45499). Keep row creation and the marker-based append in the same
# per-agent critical section as CLI close persistence.
persist_lock = getattr(agent, "_session_persist_lock", None)
def _ensure_and_persist() -> None:
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
try:
agent._persist_session(messages, conversation_history)
if persist_lock is None:
_ensure_and_persist()
else:
with persist_lock:
_ensure_and_persist()
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
finally:
# Keep an unmarked staged input available to a later close retry if the
# normal persistence attempt failed. Once the marker is present, the
# close path must no longer treat it as a pre-worker UI input.
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None
# ── Preflight context compression ──
# Gate the (expensive) full token estimate behind a cheap pre-check.

View file

@ -228,6 +228,15 @@ def finalize_turn(
if _tail_role != "assistant":
messages.append({"role": "assistant", "content": final_response})
# The model has completed its request, so replace API-local
# voice/model/skill guidance with the clean user input before writing the
# final durable snapshot and returning the continuation history. Earlier
# turn-start flushes use the DB-only override because their messages are
# still needed for the API request; this finalizer runs after that request
# is complete (#48677 / #63766).
_apply_override = getattr(agent, "_apply_persist_user_message_override", None)
if callable(_apply_override):
_apply_override(messages)
agent._persist_session(messages, conversation_history)
except Exception as _persist_err:
_cleanup_errors.append(f"persist_session: {_persist_err}")

View file

@ -50,8 +50,8 @@
"check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build"
},
"dependencies": {
"@assistant-ui/react": "^0.12.28",
"@assistant-ui/react-streamdown": "^0.1.11",
"@assistant-ui/react": "^0.14.23",
"@assistant-ui/react-streamdown": "^0.3.4",
"@audiowave/react": "^0.6.2",
"@chenglou/pretext": "^0.0.6",
"@codemirror/commands": "^6.10.4",

View file

@ -0,0 +1,82 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $compactingSessions, setSessionCompacting } from '@/store/compaction'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
const SID = 'session-1'
const OTHER_SID = 'session-2'
let handleEvent: ((event: RpcEvent) => void) | null = null
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}) {
act(() => handleEvent!({ payload, session_id: SID, type }))
}
describe('useMessageStream compaction lifecycle', () => {
beforeEach(() => {
handleEvent = null
$compactingSessions.set({})
})
afterEach(() => {
cleanup()
$compactingSessions.set({})
vi.restoreAllMocks()
})
it.each([
['message.delta', { text: 'resumed' }],
['thinking.delta', { text: 'still working' }],
['reasoning.delta', { text: 'thinking again' }],
['tool.start', { name: 'terminal', tool_id: 'tool-1' }]
] as const)('clears the stale compaction phase when %s resumes the turn', async (type, payload) => {
await mountStream()
setSessionCompacting(OTHER_SID, true)
emit('status.update', { kind: 'compacting' })
expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true, [SID]: true })
emit(type, payload)
expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true })
})
})

View file

@ -51,6 +51,19 @@ import type { ClientSessionState } from '../../../types'
import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils'
const COMPACTION_RESUME_EVENT_TYPES = new Set([
'message.delta',
'thinking.delta',
'reasoning.delta',
'reasoning.available',
'moa.reference',
'moa.aggregating',
'tool.start',
'tool.progress',
'tool.generating',
'tool.complete'
])
interface GatewayEventDeps {
activeSessionIdRef: MutableRefObject<string | null>
compactedTurnRef: MutableRefObject<Set<string>>
@ -119,6 +132,18 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const sessionId = route.sessionId
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
// Mid-turn compaction does not emit another message.start. The first
// model output or tool event proves summarization has finished and the
// turn has resumed, so retire the phase label without waiting for the
// whole turn to complete.
if (
sessionId &&
COMPACTION_RESUME_EVENT_TYPES.has(event.type) &&
compactedTurnRef.current.has(sessionId)
) {
setSessionCompacting(sessionId, false)
}
if (event.type === 'gateway.ready') {
return
} else if (event.type === 'session.info') {

View file

@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { textPart } from '@/lib/chat-messages'
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'
import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import { uploadComposerAttachment, usePromptActions } from '.'
@ -1075,6 +1075,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
afterEach(() => {
cleanup()
$turnStartedAt.set(null)
vi.restoreAllMocks()
})
@ -1168,6 +1169,32 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
})
it('clears the active and cached turn clocks when stopping a turn', async () => {
const states: Record<string, unknown>[] = []
const requestGateway = vi.fn(async () => ({}) as never)
$turnStartedAt.set(1_700_000_000_000)
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => states.push(state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.cancelRun()
expect($turnStartedAt.get()).toBeNull()
expect(states.at(-1)).toMatchObject({
awaitingResponse: false,
busy: false,
interrupted: true,
turnStartedAt: null
})
})
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
const calls: string[] = []
const states: Record<string, unknown>[] = []

View file

@ -21,7 +21,15 @@ import { resetSessionBackground } from '@/store/composer-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { clearPreviewArtifacts } from '@/store/preview-status'
import { clearAllPrompts } from '@/store/prompts'
import { $busy, $connection, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import {
$busy,
$connection,
$messages,
setAwaitingResponse,
setBusy,
setMessages,
setTurnStartedAt
} from '@/store/session'
import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
@ -507,6 +515,7 @@ export function usePromptActions({
}
setAwaitingResponse(false)
setTurnStartedAt(null)
if (!sessionId) {
releaseBusy()
@ -527,7 +536,8 @@ export function usePromptActions({
streamId: null,
pendingBranchGroup: null,
needsInput: false,
interrupted: true
interrupted: true,
turnStartedAt: null
}
})

View file

@ -12,6 +12,7 @@ import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $backdrop, setBackdrop } from '@/store/backdrop'
import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
@ -248,6 +249,7 @@ export function AppearanceSettings() {
const embedMode = useStore($embedMode)
const embedAllowed = useStore($embedAllowed)
const translucency = useStore($translucency)
const backdrop = useStore($backdrop)
const installs = useStore($marketplaceInstalls)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
@ -451,6 +453,24 @@ export function AppearanceSettings() {
title={a.translucencyTitle}
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setBackdrop(id === 'on')
}}
options={[
{ id: 'off', label: t.common.off },
{ id: 'on', label: t.common.on }
]}
value={backdrop ? 'on' : 'off'}
/>
}
description={a.backdropDesc}
title={a.backdropTitle}
/>
<ListRow
action={
<SegmentedControl

View file

@ -1,6 +1,9 @@
import { useStore } from '@nanostores/react'
import { Leva, useControls } from 'leva'
import { type CSSProperties, useEffect, useState } from 'react'
import { $backdrop } from '@/store/backdrop'
const BLEND_MODES = [
'normal',
'multiply',
@ -25,6 +28,7 @@ const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/
export function Backdrop() {
const [controlsOpen, setControlsOpen] = useState(false)
const on = useStore($backdrop)
useEffect(() => {
if (!import.meta.env.DEV) {
@ -87,7 +91,7 @@ export function Backdrop() {
<>
<Leva collapsed hidden={!import.meta.env.DEV || !controlsOpen} titleBar={{ title: 'backdrop', drag: true }} />
{statue.enabled && (
{on && statue.enabled && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-2"

View file

@ -29,6 +29,7 @@ function settledClarifyProps(
args,
argsText: JSON.stringify(args),
isError: false,
respondToApproval: vi.fn(),
result,
resume: vi.fn(),
status: { type: 'complete' },

View file

@ -210,4 +210,29 @@ describe('preprocessMarkdown', () => {
expect(() => preprocessMarkdown(input)).not.toThrow()
})
it('keeps $$<digit>$$ display math intact instead of escaping it as currency', () => {
const output = preprocessMarkdown('$$5x = 10$$')
expect(output).toContain('$$5x = 10$$')
expect(output).not.toContain('\\$')
})
it('rewrites double-backslash bracket math to dollar delimiters', () => {
const output = preprocessMarkdown('\\\\(x^2\\\\)')
expect(output).toContain('$x^2$')
})
it('rewrites [/math] and [/inline] tag pairs to dollar delimiters', () => {
expect(preprocessMarkdown('[/math]a+b[/math]')).toContain('$$a+b$$')
expect(preprocessMarkdown('[/inline]x[/inline]')).toContain('$x$')
})
it('escapes currency dollars in prose so they are not parsed as math', () => {
const output = preprocessMarkdown('$5 and $10')
expect(output).toContain('\\$5')
expect(output).toContain('\\$10')
})
})

View file

@ -1,22 +1,15 @@
'use client'
import { TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react'
import { type SmoothOptions, TextMessagePartProvider, useMessagePartText } from '@assistant-ui/react'
import {
parseMarkdownIntoBlocks,
type StreamdownTextComponents,
StreamdownTextPrimitive,
type SyntaxHighlighterProps
type SyntaxHighlighterProps,
tailBoundedRemend
} from '@assistant-ui/react-streamdown'
import { code } from '@streamdown/code'
import {
type ComponentProps,
memo,
type ReactNode,
useDeferredValue,
useEffect,
useMemo,
useState
} from 'react'
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
@ -37,7 +30,6 @@ import {
mediaStreamUrl
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { tailBoundedRemend } from '@/lib/remend-tail'
import { cn } from '@/lib/utils'
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
@ -57,8 +49,8 @@ import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } fro
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
// Replaces Streamdown's `parseIncompleteMarkdown` (full-text remend per
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
// module-scope so the prop identity is stable across renders.
// flush) with a tail-bounded repair. Must stay module-scope so the prop
// identity is stable across renders.
function preprocessWithTailRepair(text: string): string {
try {
return tailBoundedRemend(preprocessMarkdown(text))
@ -70,8 +62,7 @@ function preprocessWithTailRepair(text: string): string {
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
// `marked` lex of the entire message, ~1.6ms per 28KB) inside a useMemo keyed
// on the text — but the same text is re-lexed every time a message REMOUNTS
// (virtualizer scroll, session switch) and whenever multiple surfaces render
// the same content (deferred + smooth reveal republish). A small module-level
// (virtualizer scroll, session switch). A small module-level
// LRU keyed by the exact source string removes all of those repeat parses
// with zero correctness risk (same input → same output). Streaming tail
// growth misses the cache by design (every flush is a new string) — that
@ -347,44 +338,11 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
)
}
/**
* Re-publish the active message-part context with React's `useDeferredValue`
* applied to the streaming text and status. The outer wrapper still re-renders
* on every token, but the work it does is trivial (one hook, one provider).
*
* The expensive subtree (Streamdown micromark mdast hast React) lives
* inside `<TextMessagePartProvider>` and reads the deferred text via the
* normal `useMessagePartText` hook. React's concurrent scheduler then has
* permission to:
* - skip intermediate token states when the next token arrives mid-render
* (it abandons the in-flight deferred render and starts over)
* - deprioritize the markdown render when the main thread is busy with an
* urgent task (typing, scrolling, layout work elsewhere)
*
* Net effect: per-token CPU is unchanged but the *blocking* part of that work
* goes away typing-while-streaming stays a single-frame paint, scroll
* stutter disappears, and the longtask histogram tightens because long
* commits can be interrupted and discarded.
*
* Industry standard (Streamdown's own block-array setState already uses
* `useTransition`); this just lifts the deferral up to the consumer text
* boundary so it covers the whole pipeline, not just the inner setState.
*/
function DeferStreamingText({ children }: { children: ReactNode }) {
const { text, status } = useMessagePartText()
const deferredText = useDeferredValue(text)
const isRunning = status.type === 'running'
return (
<TextMessagePartProvider isRunning={isRunning} text={deferredText}>
{children}
</TextMessagePartProvider>
)
}
interface MarkdownTextSurfaceProps {
containerClassName?: string
containerProps?: ComponentProps<'div'>
defer?: boolean
smooth?: boolean | SmoothOptions
}
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
@ -433,7 +391,7 @@ function HugeTextFallback({ containerClassName, text }: { containerClassName?: s
)
}
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
function MarkdownTextSurface({ containerClassName, containerProps, defer, smooth }: MarkdownTextSurfaceProps) {
const { status, text } = useMessagePartText()
const isStreaming = status.type === 'running'
@ -561,26 +519,29 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
components={components}
containerClassName={cn(MARKDOWN_CONTAINER_CLASS_NAME, containerClassName)}
containerProps={containerProps}
defer={defer}
lineNumbers={false}
mode="streaming"
// Incomplete-markdown repair is handled by `preprocessWithTailRepair`
// below (tail-bounded remend) instead of Streamdown's built-in pass,
// which re-runs remend over the ENTIRE message on every flush — ~18%
// of streaming script time on 50KB+ messages. The repair itself stays
// always-on (even between flushes / for completed messages): an
// unclosed ```python ... ``` whose body contains `$` (shell snippets,
// JS template strings, dollar amounts) would otherwise leak those
// dollars to the math parser and render broken inline math. Shiki is
// independently deferred via `defer={isStreaming}` on the
// SyntaxHighlighter component.
// Incomplete-markdown repair runs in preprocessWithTailRepair on the
// full accumulated text; the built-in tail-bounded remend is disabled
// because a custom parseMarkdownIntoBlocksFn is supplied, and
// parseIncompleteMarkdown stays false to avoid a second full-text
// remend pass.
parseIncompleteMarkdown={false}
parseMarkdownIntoBlocksFn={parseMarkdownIntoBlocksCached}
plugins={plugins}
preprocess={preprocessWithTailRepair}
smooth={smooth}
/>
)
}
const SMOOTH_OPTIONS: SmoothOptions = {
drainMs: 500,
maxCharsPerFrame: 30,
minCommitMs: 33
}
interface MarkdownTextContentProps extends MarkdownTextSurfaceProps {
isRunning: boolean
text: string
@ -592,19 +553,13 @@ export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: Markdo
// the whole message), blanking the Thinking widget.
return (
<TextMessagePartProvider isRunning={isRunning} text={text}>
<DeferStreamingText>
<MarkdownTextSurface {...surfaceProps} />
</DeferStreamingText>
<MarkdownTextSurface defer smooth={SMOOTH_OPTIONS} {...surfaceProps} />
</TextMessagePartProvider>
)
}
const MarkdownTextImpl = () => {
return (
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
)
return <MarkdownTextSurface defer />
}
export const MarkdownText = memo(MarkdownTextImpl)

View file

@ -13,7 +13,7 @@ function Boom({ error }: { error: Error | null }): null {
return null
}
const lookupError = new Error('tapClientLookup: Index 2 out of bounds (length: 2)')
const lookupError = new Error('useClientLookup: Index 2 out of bounds (length: 2)')
describe('MessageRenderBoundary', () => {
it('renders children when nothing throws', () => {
@ -26,7 +26,7 @@ describe('MessageRenderBoundary', () => {
expect(screen.getByText('content')).toBeTruthy()
})
it('swallows the transient tapClientLookup out-of-bounds store race', () => {
it('swallows the transient useClientLookup out-of-bounds store race', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { container } = render(

View file

@ -1,6 +1,6 @@
import { Component, type ReactNode } from 'react'
// `@assistant-ui/store`'s index-keyed child-scope lookup (`tapClientLookup`)
// `@assistant-ui/store`'s index-keyed child-scope lookup (`useClientLookup`)
// throws — rather than returning undefined — when a subscriber reads an index
// that the message/parts list no longer has. This races during high-frequency
// store replacement (session switch mid-stream, gateway reconnect replay): a
@ -10,7 +10,7 @@ import { Component, type ReactNode } from 'react'
// without a local boundary it unwinds to the root and blanks the whole app.
// Upstream-tracked: assistant-ui/assistant-ui#4051, #3652.
const isTransientLookupError = (error: unknown): boolean =>
error instanceof Error && /tapClient(Lookup|Resource).*out of bounds/.test(error.message)
error instanceof Error && /(useClientLookup|tapClient(Lookup|Resource)).*out of bounds/.test(error.message)
interface Props {
// Changes whenever the message list mutates; remounting clears the caught

View file

@ -0,0 +1,56 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetElapsedTimerRegistryForTests } from '@/components/chat/activity-timer'
import { I18nProvider } from '@/i18n'
import { $activeSessionId, $turnStartedAt } from '@/store/session'
import { ResponseLoadingIndicator } from './status'
function renderIndicator() {
return render(
<I18nProvider configClient={null} initialLocale="en">
<ResponseLoadingIndicator />
</I18nProvider>
)
}
describe('ResponseLoadingIndicator timer', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
__resetElapsedTimerRegistryForTests()
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
$turnStartedAt.set(null)
__resetElapsedTimerRegistryForTests()
vi.useRealTimers()
})
it('preserves each running session timer while switching between sessions', () => {
$activeSessionId.set('session-a')
$turnStartedAt.set(Date.now())
const sessionA = renderIndicator()
act(() => vi.advanceTimersByTime(5_000))
expect(screen.getByText('5s')).toBeTruthy()
sessionA.unmount()
$activeSessionId.set('session-b')
$turnStartedAt.set(Date.now())
const sessionB = renderIndicator()
act(() => vi.advanceTimersByTime(3_000))
expect(screen.getByText('3s')).toBeTruthy()
sessionB.unmount()
$activeSessionId.set('session-a')
$turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime())
renderIndicator()
expect(screen.getByText('8s')).toBeTruthy()
})
})

View file

@ -11,6 +11,7 @@ import { cn } from '@/lib/utils'
import { $backgroundResume } from '@/store/background-delegation'
import { $compactionActive } from '@/store/compaction'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { $activeSessionId, $turnStartedAt } from '@/store/session'
const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({
children,
@ -36,6 +37,13 @@ const CompactionHint: FC = () => (
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
)
function useActiveTurnTimerKey(): string | undefined {
const activeSessionId = useStore($activeSessionId)
const turnStartedAt = useStore($turnStartedAt)
return activeSessionId && turnStartedAt ? `turn:${activeSessionId}:${turnStartedAt}` : undefined
}
export const CenteredThreadSpinner: FC = () => {
const { t } = useI18n()
@ -59,7 +67,8 @@ export const CenteredThreadSpinner: FC = () => {
export const ResponseLoadingIndicator: FC = () => {
const { t } = useI18n()
const elapsed = useElapsedSeconds()
const timerKey = useActiveTurnTimerKey()
const elapsed = useElapsedSeconds(true, timerKey)
const compacting = useStore($compactionActive)
return (
@ -134,6 +143,7 @@ export const StreamStallIndicator: FC = () => {
const [stalled, setStalled] = useState(false)
const compacting = useStore($compactionActive)
const turnTimerKey = useActiveTurnTimerKey()
// A pending clarify / approval / sudo / secret means the turn is paused on the
// user, not working — so don't resurrect the "thinking" timer while they
// decide (matches the pet's awaitingInput pose taking priority over busy).
@ -147,7 +157,7 @@ export const StreamStallIndicator: FC = () => {
}, [activity])
const active = (stalled || compacting) && !awaitingInput
const elapsed = useElapsedSeconds(active)
const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined)
if (!active) {
return null

View file

@ -1,4 +1,4 @@
import { ExportedMessageRepository } from '@assistant-ui/core/internal'
import { ExportedMessageRepository } from '@assistant-ui/react'
// Clicking a user bubble must open the inline edit composer — through the
// app's incremental external-store runtime (which reimplements capability
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.

View file

@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { shouldBoundToolGroup } from './fallback'
describe('shouldBoundToolGroup', () => {
it('bounds long runs of ordinary tool calls', () => {
expect(shouldBoundToolGroup(3, false)).toBe(true)
})
it('never bounds a run containing clarify', () => {
expect(shouldBoundToolGroup(3, true)).toBe(false)
})
})

View file

@ -611,6 +611,10 @@ function ToolEntry({ part }: ToolEntryProps) {
// auto-scrolling window; fewer than this stays a plain inline stack.
const TOOL_GROUP_SCROLL_THRESHOLD = 3
export function shouldBoundToolGroup(childCount: number, containsClarify: boolean) {
return childCount >= TOOL_GROUP_SCROLL_THRESHOLD && !containsClarify
}
// Pin-to-bottom + top-fade for the bounded tool window. Pins the newest row on
// growth (a call lands or a row expands) unless the user scrolled up, and fades
// the top edge once anything sits above it. Mirrors ThinkingDisclosure's live
@ -678,13 +682,21 @@ function useToolWindow(enabled: boolean) {
*/
export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
children,
endIndex,
startIndex
}) => {
const messageId = useAuiState(s => s.message.id)
const messageRunning = useAuiState(selectMessageRunning)
const containsClarify = useAuiState(s =>
s.message.parts
.slice(Math.max(0, startIndex), endIndex + 1)
.some(part => part.type === 'tool-call' && part.toolName === 'clarify')
)
const enterRef = useEnterAnimation(messageRunning, `tool-group:${messageId}:${startIndex}`)
const bounded = Children.count(children) >= TOOL_GROUP_SCROLL_THRESHOLD
const bounded = shouldBoundToolGroup(Children.count(children), containsClarify)
const { contentRef, faded, onScroll, scrollRef } = useToolWindow(bounded)
return (

View file

@ -516,15 +516,16 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool
})
}
// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema.
export function getMemoryProviderConfig(provider: string): Promise<MemoryProviderConfig> {
return window.hermesDesktop.api<MemoryProviderConfig>({
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`
})
}
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`,
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`,
method: 'PUT',
body: { values }
})

View file

@ -411,6 +411,8 @@ export const en: Translations = {
`Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`,
translucencyTitle: 'Window Translucency',
translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.',
backdropTitle: 'Chat Backdrop',
backdropDesc: 'The faint statue image behind the conversation.',
embedsTitle: 'Inline Embeds',
embedsDesc:
'Rich previews load from third-party sites (YouTube, X, …). Ask shows a placeholder until you allow each one; Always loads them automatically; Off keeps plain links.',

View file

@ -297,6 +297,8 @@ export const ja = defineLocale({
`アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`,
translucencyTitle: 'ウィンドウの透過',
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
backdropTitle: 'チャット背景',
backdropDesc: '会話の背後に表示される淡い彫像の画像。',
embedsTitle: 'インライン埋め込み',
embedsDesc:
'リッチプレビューは第三者サイトYouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',

View file

@ -327,6 +327,8 @@ export interface Translations {
uiScaleDesc: (percent: number) => string
translucencyTitle: string
translucencyDesc: string
backdropTitle: string
backdropDesc: string
embedsTitle: string
embedsDesc: string
embedsAsk: string

View file

@ -290,6 +290,8 @@ export const zhHant = defineLocale({
`縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`,
translucencyTitle: '視窗透明',
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
backdropTitle: '聊天背景',
backdropDesc: '對話後方那張淡淡的雕像圖片。',
embedsTitle: '內嵌預覽',
embedsDesc:
'豐富預覽會從第三方網站YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',

View file

@ -399,6 +399,8 @@ export const zh: Translations = {
`缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`,
translucencyTitle: '窗口透明',
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
backdropTitle: '聊天背景',
backdropDesc: '对话后方那张淡淡的雕像图片。',
embedsTitle: '内嵌预览',
embedsDesc:
'富预览会从第三方网站YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',

View file

@ -8,6 +8,8 @@ import {
import {
type AssistantRuntime,
type ExternalStoreAdapter,
fromThreadMessageLike,
generateId,
type ThreadMessage,
useRuntimeAdapters
} from '@assistant-ui/react'
@ -145,11 +147,19 @@ class IncrementalExternalStoreThreadRuntimeCore extends ExternalStoreThreadRunti
self._notifyEventSubscribers(store.isRunning ? 'runStart' : 'runEnd', {})
}
// metadata.isOptimistic keeps this placeholder ephemeral: core evicts
// off-branch optimistic messages on head moves and omits them from export().
if (hasUpcomingMessage(isRunning, messages)) {
self._assistantOptimisticId = this.repository.appendOptimisticMessage(messages.at(-1)?.id ?? null, {
role: 'assistant',
content: []
})
const optimisticId = generateId()
this.repository.addOrUpdateMessage(
messages.at(-1)?.id ?? null,
fromThreadMessageLike(
{ role: 'assistant', content: [], metadata: { isOptimistic: true } },
optimisticId,
{ type: 'running' }
)
)
self._assistantOptimisticId = optimisticId
}
this.repository.resetHead(self._assistantOptimisticId ?? messages.at(-1)?.id ?? null)

View file

@ -1,3 +1,5 @@
import { escapeCurrencyDollars, normalizeMathDelimiters } from '@assistant-ui/react-streamdown'
import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
import { stripPreviewTargets } from '@/lib/preview-targets'
@ -310,41 +312,6 @@ function normalizeFenceBlocks(text: string): string {
return out.join('\n')
}
// Convert LaTeX bracket delimiters to remark-math's dollar-sign syntax.
// Models often emit `\(...\)` for inline math and `\[...\]` for display
// math (the standard LaTeX convention) instead of `$...$` / `$$...$$`.
// remark-math only natively recognizes the dollar form, so we rewrite at
// preprocess time. Done with simple non-greedy matches keyed on the
// escaped-bracket sequences — these are rare enough in non-math content
// (you'd have to write a literal `\(` followed eventually by a literal
// `\)` with NO interleaving newline-paragraph-break) that false positives
// are extremely unlikely.
const LATEX_INLINE_RE = /\\\(([^\n]+?)\\\)/g
const LATEX_DISPLAY_RE = /\\\[([\s\S]+?)\\\]/g
function rewriteLatexBracketDelimiters(text: string): string {
return text
.replace(LATEX_INLINE_RE, (_, body: string) => `$${body}$`)
.replace(LATEX_DISPLAY_RE, (_, body: string) => `$$${body}$$`)
}
// Escape `$<digit>` patterns so they don't get eaten as math delimiters.
// Models commonly write currency amounts ($5, $19.99, $1,299) in prose.
// With `singleDollarTextMath: true`, remark-math is greedy and matches
// EVERY pair of `$`s — including the open of `$5` to the next `$10`,
// rendering "5 in my pocket and you have " as italicized math text.
// The de-facto convention across math-supporting LLM UIs is to treat
// `$` followed by a digit as currency rather than math, since math
// expressions almost always start with a letter or `\command`. Trade-
// off: a math expression like `$5x = 10$` would have its leading 5
// escaped — annoying but rare. The escape `\$` survives to render as
// a literal `$` in the final output.
const CURRENCY_DOLLAR_RE = /(^|[^\\])\$(?=\d)/g
function escapeCurrencyDollars(text: string): string {
return text.replace(CURRENCY_DOLLAR_RE, '$1\\$')
}
export function preprocessMarkdown(text: string): string {
const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '')
const scrubbed = scrubBacktickNoise(cleaned)
@ -377,12 +344,10 @@ export function preprocessMarkdown(text: string): string {
const leading = part.match(/^\s*/)?.[0] ?? ''
const trailing = part.match(/\s*$/)?.[0] ?? ''
// rewriteLatexBracketDelimiters runs only on prose segments so
// we don't accidentally touch `\(` inside a code block.
// escapeCurrencyDollars likewise only runs on prose, so legit
// `$5` literals inside fenced code stay intact.
// Run only on prose segments so `$5` literals and `\(` inside code
// blocks stay intact.
const transformed = normalizeVisibleProse(
stripPreviewTargets(rewriteLatexBracketDelimiters(escapeCurrencyDollars(part)))
stripPreviewTargets(normalizeMathDelimiters(escapeCurrencyDollars(part)))
)
return leading + transformed + trailing

View file

@ -1,105 +0,0 @@
import { parseMarkdownIntoBlocks } from '@assistant-ui/react-streamdown'
import remend from 'remend'
import { describe, expect, it } from 'vitest'
import { findRemendWindowStart, tailBoundedRemend } from './remend-tail'
const CORPUS = `# Heading one
Intro paragraph with **bold**, *italic*, \`inline code\`, and a [link](https://example.com).
## Code
\`\`\`python
def main():
cost = "$5"
print(f"total: $\{cost}")
\`\`\`
Some text after the fence with $x^2 + y^2$ inline math.
$$
\\int_0^1 f(x) dx
$$
- list item one with **bold**
- list item two
| col a | col b |
| ----- | ----- |
| 1 | 2 |
~~~js
const s = \`template \${value}\`
~~~
Final paragraph with ~~strike~~ and unfinished [link text](https://exa
`
/**
* Render-equivalence oracle: full-text remend and tail-bounded remend may
* differ in raw string output ONLY in ways that cannot affect rendering
* i.e. after block splitting, every block must be identical. (Streamdown
* renders blocks independently, so block-level equality IS render equality.)
*/
function blocksOf(text: string): string[] {
return parseMarkdownIntoBlocks(text)
}
describe('tailBoundedRemend', () => {
it('matches full remend block output at every streaming prefix', () => {
for (let end = 1; end <= CORPUS.length; end++) {
const prefix = CORPUS.slice(0, end)
const full = blocksOf(remend(prefix))
const tail = blocksOf(tailBoundedRemend(prefix))
expect(tail, `prefix length ${end}: ${JSON.stringify(prefix.slice(-60))}`).toEqual(full)
}
})
it('repairs an unclosed fence opened early in a long message', () => {
const text = `intro\n\n\`\`\`python\n${'x = 1\n'.repeat(500)}print("$dollar")`
const repaired = tailBoundedRemend(text)
expect(blocksOf(repaired)).toEqual(blocksOf(remend(text)))
// the window must reach back to the fence opener
expect(findRemendWindowStart(text)).toBe(text.indexOf('```python'))
})
it('bounds the window to the tail paragraph when no fence is open', () => {
const text = `para one\n\npara two\n\npara three with **bold`
const start = findRemendWindowStart(text)
expect(start).toBe(text.indexOf('para three'))
expect(tailBoundedRemend(text)).toBe(remend(text))
})
it('widens the window across an open $$ math block', () => {
const text = `before\n\n$$\n\\frac{a}{b}`
const start = findRemendWindowStart(text)
expect(start).toBeLessThanOrEqual(text.indexOf('$$'))
expect(blocksOf(tailBoundedRemend(text))).toEqual(blocksOf(remend(text)))
})
it('handles closed constructs without modification', () => {
const text = `done **bold** and \`code\`\n\n\`\`\`js\nconst a = 1\n\`\`\`\n\nlast line.`
expect(tailBoundedRemend(text)).toBe(text)
})
it('intentionally diverges from full remend on cross-block dangling openers', () => {
// Full remend scans the whole document and appends `**` for an opener
// left dangling in an EARLIER block, dumping stray asterisks into the
// unrelated tail block ("|**"). Because Streamdown splits into blocks
// after the repair, that opener never renders as bold either way — the
// tail-bounded result is the cleaner of the two. This test documents
// the divergence so a future remend upgrade that changes the behavior
// gets noticed.
const text = `- item with **dangling\n- item two\n\n|`
expect(remend(text).endsWith('|**')).toBe(true)
expect(tailBoundedRemend(text).endsWith('|')).toBe(true)
expect(tailBoundedRemend(text).endsWith('|**')).toBe(false)
})
})

View file

@ -1,108 +0,0 @@
import remend from 'remend'
// Tail-bounded incomplete-markdown repair.
//
// Streamdown's built-in `parseIncompleteMarkdown` runs `remend` over the whole
// accumulated message on every streaming flush (~18% of script time on 50KB+
// messages). But repairs only ever matter in the trailing block: inline
// constructs can't cross a blank line, and Streamdown splits into blocks AFTER
// the repair, so a dangling opener in an earlier block can't reach the tail.
// We run `remend` on just that block instead.
const BACKTICK = 96 // `
const TILDE = 126 // ~
const SPACE = 32
const TAB = 9
const BACKSLASH = 92
const isSpace = (c: number) => c === SPACE || c === TAB
/**
* Index of the last top-level block start the char after the most recent
* blank line that sits outside any open code fence or `$$` math block. An
* unclosed fence/math always begins after that blank, so it stays wholly
* inside the window without separate tracking. One cheap char pass, no regex.
*/
export function findRemendWindowStart(text: string): number {
const n = text.length
let inFence = false
let fenceChar = 0
let fenceRun = 0
let inMath = false
let boundary = 0
let pending = -1 // a blank line, committed to `boundary` once content follows
for (let lineStart = 0; lineStart <= n; ) {
let lineEnd = text.indexOf('\n', lineStart)
if (lineEnd === -1) {
lineEnd = n
}
let i = lineStart
while (i < lineEnd && isSpace(text.charCodeAt(i))) {
i += 1
}
const first = i < lineEnd ? text.charCodeAt(i) : -1
let marker = false
// Fence open/close (``` or ~~~, ≤3 spaces indent).
if ((first === BACKTICK || first === TILDE) && i - lineStart <= 3) {
let run = i
while (run < lineEnd && text.charCodeAt(run) === first) {
run += 1
}
if (run - i >= 3) {
marker = true
if (!inFence) {
inFence = true
fenceChar = first
fenceRun = run - i
} else if (first === fenceChar && run - i >= fenceRun && onlyWhitespace(text, run, lineEnd)) {
inFence = false
}
}
}
// Toggle `$$` math state on plain lines ($$ inside a fence is literal).
if (!inFence && !marker) {
for (let s = text.indexOf('$$', lineStart); s !== -1 && s < lineEnd - 1; s = text.indexOf('$$', s + 2)) {
if (s === 0 || text.charCodeAt(s - 1) !== BACKSLASH) {
inMath = !inMath
}
}
}
if (first === -1 && !inFence && !inMath) {
pending = lineEnd + 1
} else if (pending !== -1) {
boundary = pending
pending = -1
}
lineStart = lineEnd + 1
}
return boundary
}
function onlyWhitespace(text: string, from: number, to: number): boolean {
for (let i = from; i < to; i += 1) {
if (!isSpace(text.charCodeAt(i))) {
return false
}
}
return true
}
export function tailBoundedRemend(text: string): string {
const start = findRemendWindowStart(text)
return start <= 0 ? remend(text) : text.slice(0, start) + remend(text.slice(start))
}

View file

@ -0,0 +1,14 @@
import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
const KEY = 'hermes.desktop.backdrop.v1'
/** Whether the faint statue image renders behind the chat transcript. */
export const $backdrop = atom(storedBoolean(KEY, true))
$backdrop.subscribe(on => persistBoolean(KEY, on))
export function setBackdrop(on: boolean) {
$backdrop.set(on)
}

View file

@ -506,11 +506,19 @@ prompt_caching:
# timeout: 30 # LLM API call timeout (seconds)
# download_timeout: 30 # Image HTTP download timeout (seconds)
# # Increase for slow connections or self-hosted image servers
# reasoning_effort: "" # Per-task thinking level: none, minimal, low, medium,
# # high, xhigh, max, ultra. Empty = provider default.
# # Works on every auxiliary task block (vision,
# # web_extract, compression, title_generation, curator,
# # background_review, moa_reference, ...). Example: run
# # compression at "low" and vision at "none" to cut
# # side-task latency/cost on reasoning models.
#
# # Web page scraping / summarization + browser page text extraction
# web_extract:
# provider: "auto"
# model: ""
# reasoning_effort: "low"
#
# # Gemini 3.1 TTS hidden audio-tag insertion
# tts_audio_tags:
@ -742,6 +750,19 @@ agent:
# Options: "xhigh" (max), "high", "medium", "low", "minimal", "none" (disable)
reasoning_effort: "medium"
# Per-model reasoning effort overrides (optional dict)
# Key: any sensible model spelling works (exact, dots↔dashes interchangeable,
# provider prefix optional). First match wins.
# Value: reasoning effort level (same options as reasoning_effort)
# Override the global reasoning_effort for that specific model.
# NOTE: no `hermes config set` support for this key -- edit YAML directly.
# reasoning_overrides:
# "openrouter/anthropic/claude-opus-4.5": "xhigh"
# "openai/gpt-5": "low"
# "claude-opus-4.6": "high" # bare model name also works
# "deepseek/deepseek-v4-pro": "xhigh" # dots and dashes are interchangeable
reasoning_overrides: {}
# Predefined personalities (use with /personality command)
personalities:
helpful: "You are a helpful, friendly AI assistant."

243
cli.py
View file

@ -3917,9 +3917,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
)
# Reasoning config (OpenRouter reasoning effort level)
self.reasoning_config = _parse_reasoning_config(
CLI_CONFIG["agent"].get("reasoning_effort", "")
)
# Per-model override > global reasoning_effort — resolved through the
# shared chokepoint in hermes_constants (Closes #21256).
from hermes_constants import resolve_reasoning_config
self.reasoning_config = resolve_reasoning_config(CLI_CONFIG, self.model)
self.service_tier = _parse_service_tier_config(
CLI_CONFIG["agent"].get("service_tier", "")
)
@ -8722,7 +8723,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
elif canonical == "compress":
self._manual_compress(cmd_original)
elif canonical == "usage":
self._show_usage()
self._handle_usage_command(cmd_original)
elif canonical == "credits":
self._show_credits()
elif canonical == "billing":
@ -9147,6 +9148,55 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
def _owns_process_notification(self, event: dict) -> bool:
"""Return whether this CLI session provably owns a delegation event.
Delegations dispatched before context compression retain the original
session key, so resolve that key to its continuation before comparing.
Missing or foreign keys fail closed and remain queued for their owner.
"""
event_key = str(event.get("session_key") or "")
current_key = str(getattr(self, "session_id", "") or "")
if not event_key or not current_key:
return False
if event_key == current_key:
return True
try:
session_db = getattr(self, "_session_db", None)
resolved_key = (
session_db.resolve_resume_session_id(event_key)
if session_db is not None
else event_key
) or event_key
except Exception:
resolved_key = event_key
return str(resolved_key) == current_key
def _drain_process_notifications(self, consumer: str) -> None:
"""Queue background notifications owned by this visible CLI session.
``process_registry`` restores durable delegation completions into every
process using the same Hermes profile. Always pass this CLI's stable
session identity when draining so another window cannot claim and mark
delivered a completion that belongs to this one.
"""
from tools.process_registry import process_registry
from tools.async_delegation import (
claim_event_delivery,
complete_event_delivery,
)
session_key = getattr(self, "session_id", "") or ""
for event, synthetic_message in process_registry.drain_notifications(
session_key=session_key,
owns_event=self._owns_process_notification,
):
claim = claim_event_delivery(event, consumer)
if claim is None:
continue
self._pending_input.put(synthetic_message)
complete_event_delivery(event, claim)
def _drain_interrupt_queue_to_pending_input(self) -> None:
"""Move stray messages from ``_interrupt_queue`` into ``_pending_input``.
@ -9623,6 +9673,53 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
def _handle_usage_command(self, cmd_original: str):
"""Dispatch `/usage [reset [--force]]`.
Bare `/usage` keeps the classic display. `/usage reset` redeems one
banked Codex rate-limit reset credit (guarded: refuses when limits
aren't exhausted unless --force).
"""
parts = cmd_original.split()
args = [p.lower() for p in parts[1:]]
if args and args[0] == "reset":
self._usage_reset(force="--force" in args[1:])
return
if args:
print(f" Unknown /usage subcommand: {' '.join(parts[1:])}. Try /usage or /usage reset [--force].")
return
self._show_usage()
def _usage_reset(self, force: bool = False):
"""`/usage reset [--force]` — redeem one banked Codex reset credit."""
provider = (
(getattr(self.agent, "provider", None) if self.agent else None)
or getattr(self, "provider", None)
)
normalized = str(provider or "").strip().lower()
if normalized != "openai-codex":
print(" Banked usage resets are only available on the openai-codex provider.")
print(" Switch with `/model` or `hermes auth` first.")
return
base_url = (getattr(self.agent, "base_url", None) if self.agent else None) or getattr(self, "base_url", None)
api_key = (getattr(self.agent, "api_key", None) if self.agent else None) or getattr(self, "api_key", None)
from agent.account_usage import redeem_codex_reset_credit
print(" ⏳ Checking banked reset credits...")
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _pool:
try:
result = _pool.submit(
redeem_codex_reset_credit,
base_url=base_url,
api_key=api_key,
force=force,
).result(timeout=45.0)
except concurrent.futures.TimeoutError:
print(" ❌ Timed out talking to the Codex backend — try again shortly.")
return
print(f" {result.message}")
def _show_usage(self):
"""Rate limits + session token usage (when a live agent exists) + Nous credits.
@ -12129,7 +12226,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
request_overrides=turn_route.get("request_overrides"),
):
return None
agent = self.agent
if agent is None:
return None
# Route image attachments based on the active model's vision capability.
# "native" → pass pixels as OpenAI-style content parts (adapters
# translate for Anthropic/Gemini/Bedrock).
@ -12217,8 +12317,31 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
from run_agent import _sanitize_surrogates
message = _sanitize_surrogates(message)
# Add user message to history
self.conversation_history.append({"role": "user", "content": message})
# Keep the exact CLI input dict available until turn-start persistence.
# Copy the completed agent transcript before appending: otherwise this
# UI-only staging step mutates ``agent._session_messages`` and exposes a
# duplicate-prone intermediate snapshot to terminal-close persistence.
if self.conversation_history is getattr(agent, "_session_messages", None):
self.conversation_history = list(self.conversation_history)
# The prior turn's override applies only to its own user dict. Clear it
# before exposing the next staged input to close persistence; otherwise
# a shutdown before the worker prologue can write old API-local text as
# this new user message (#63766).
persist_lock = getattr(agent, "_session_persist_lock", None)
def _stage_user_message() -> None:
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
staged_user_message = {"role": "user", "content": message}
agent._pending_cli_user_message = staged_user_message
self.conversation_history.append(staged_user_message)
if persist_lock is None:
_stage_user_message()
else:
with persist_lock:
_stage_user_message()
ChatConsole().print(f"[{_accent_hex()}]{'' * 40}[/]")
print(flush=True)
@ -12355,13 +12478,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._pending_moa_config = None
if _moa_cfg is None:
_moa_cfg = None
# Model/skill notes and voice instructions are API-local. Keep
# the original staged input as the durable transcript value so a
# close-path marker follows the same dict into turn setup rather
# than producing a second noted user row (#63766).
_persist_clean_user_message = (
message if (_voice_prefix or agent_message != message) else None
)
try:
result = self.agent.run_conversation(
user_message=agent_message,
conversation_history=self.conversation_history[:-1], # Exclude the message we just added
stream_callback=stream_callback,
task_id=self.session_id,
persist_user_message=message if _voice_prefix else None,
persist_user_message=_persist_clean_user_message,
moa_config=_moa_cfg,
)
if getattr(self, "_pending_moa_disable_after_turn", False):
@ -12824,20 +12954,75 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if not agent or not hasattr(agent, "_persist_session"):
return
messages = getattr(agent, "_session_messages", None)
if not isinstance(messages, list):
messages = getattr(self, "conversation_history", None)
if not isinstance(messages, list) or not messages:
return
persist_lock = getattr(agent, "_session_persist_lock", None)
conversation_history = getattr(self, "conversation_history", None)
if not isinstance(conversation_history, list):
conversation_history = messages
def _snapshot_and_persist() -> None:
# This snapshot must share the staging lock with ``chat()``. Without
# it, close can retain a mutable history baseline just before chat
# appends its pending dict; the later flush then mistakes that dict
# for durable history and stamps it without writing a row (#63766).
messages = getattr(agent, "_session_messages", None)
pending_cli_message = getattr(agent, "_pending_cli_user_message", None)
if not isinstance(messages, list):
messages = getattr(self, "conversation_history", None)
if not isinstance(messages, list):
return
if isinstance(pending_cli_message, dict) and not any(
message is pending_cli_message for message in messages
):
# The UI has accepted a new input but the worker still exposes its
# prior snapshot. Include only that staged dict; the baseline below
# keeps any durable resumed prefix from being re-appended.
messages = [*messages, pending_cli_message]
if not messages:
return
try:
# A normal turn builds a new list that reuses the resumed-history dicts.
# Keep that CLI history as the baseline so a signal between assigning
# ``_session_messages`` and the turn's DB flush cannot append its durable
# prefix a second time. Once the CLI takes the turn result, however, both
# names can point at the same live list; passing that alias would mark an
# unflushed tail durable without writing it. Marker-only persistence is
# correct only in that alias case.
conversation_history = getattr(self, "conversation_history", None)
pending_cli_message = getattr(agent, "_pending_cli_user_message", None)
if (
isinstance(conversation_history, list)
and conversation_history
and conversation_history[-1] is pending_cli_message
):
# The UI accepted this user message before the agent finished its
# early persistence. Its dict can already be in ``messages`` but is
# not durable yet, so exclude it from the resumed-history baseline.
conversation_history = conversation_history[:-1]
elif not isinstance(conversation_history, list) or conversation_history is messages:
conversation_history = None
# A first-turn close can arrive before the worker builds its cached
# prompt. Build or restore it before the DB row is created so the
# durable transcript never leaves a NULL system_prompt cache entry.
if getattr(agent, "_cached_system_prompt", None) is None:
try:
from agent.conversation_loop import _restore_or_build_system_prompt
_restore_or_build_system_prompt(agent, None, conversation_history)
except Exception:
logger.debug("Could not build system prompt during CLI close", exc_info=True)
return
if getattr(agent, "_cached_system_prompt", None) is None:
return
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
if getattr(agent, "session_id", None):
self.session_id = agent.session_id
try:
if persist_lock is None:
_snapshot_and_persist()
else:
with persist_lock:
_snapshot_and_persist()
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not persist active CLI session before close: %s", e)
@ -15185,18 +15370,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Check for background process notifications (completions
# and watch pattern matches) while agent is idle.
try:
from tools.process_registry import process_registry
from tools.approval import get_current_session_key
_drain_sk = get_current_session_key(default="")
for _evt, _synth in process_registry.drain_notifications(session_key=_drain_sk):
from tools.async_delegation import (
claim_event_delivery, complete_event_delivery,
)
_claim = claim_event_delivery(_evt, "cli-idle")
if _claim is None:
continue
self._pending_input.put(_synth)
complete_event_delivery(_evt, _claim)
self._drain_process_notifications("cli-idle")
except Exception:
pass
continue
@ -15356,16 +15530,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Drain process notifications (completions + watch matches)
# that arrived while the agent was running.
try:
from tools.process_registry import process_registry
for _evt, _synth in process_registry.drain_notifications():
from tools.async_delegation import (
claim_event_delivery, complete_event_delivery,
)
_claim = claim_event_delivery(_evt, "cli-post-turn")
if _claim is None:
continue
self._pending_input.put(_synth)
complete_event_delivery(_evt, _claim)
self._drain_process_notifications("cli-post-turn")
except Exception:
pass # Non-fatal — don't break the main loop

View file

@ -209,7 +209,13 @@ def _job_running_in_this_process(job_id: str) -> bool:
from cron.scheduler import get_running_job_ids
return job_id in get_running_job_ids()
except Exception:
return False
logger.warning(
"Cron running-set liveness check failed for job %r; keeping the "
"entry to avoid deleting a possibly live one-shot run",
job_id,
exc_info=True,
)
return True
def _jobs_lock_file() -> Path:

View file

@ -1976,6 +1976,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
_DEFAULT_SCRIPT_TIMEOUT = 3600 # seconds (1 hour)
# Backward-compatible module override used by tests and emergency monkeypatches.
_SCRIPT_TIMEOUT = _DEFAULT_SCRIPT_TIMEOUT
_RUN_CLAIM_HEARTBEAT_SECONDS = 60.0
def _get_script_timeout() -> int:
@ -2135,6 +2136,71 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
return False, f"Script execution failed: {exc}"
def _run_job_script_with_claim_heartbeat(
job: dict, script_path: str
) -> tuple[bool, str]:
"""Run a cron script while keeping its owned one-shot claim fresh.
Script execution is synchronous and may legitimately outlive the stale
claim TTL. Without a concurrent heartbeat, another scheduler process can
mistake the live run for a dead owner and dispatch the same one-shot again.
Recurring jobs and unclaimed/manual runs have no durable one-shot claim and
therefore use the ordinary script path without starting a thread.
The claim owner is captured from the dispatched job and never re-read from
storage. ``heartbeat_run_claim`` compares that stable owner before every
refresh, so a stale runner cannot extend a replacement owner's claim.
"""
schedule = job.get("schedule")
claim = job.get("run_claim")
owner = str(claim.get("by") or "") if isinstance(claim, dict) else ""
if not (
isinstance(schedule, dict)
and schedule.get("kind") == "once"
and owner
):
return _run_job_script(script_path)
job_id = str(job.get("id") or "")
stop = threading.Event()
heartbeat_context = contextvars.copy_context()
def _heartbeat_loop() -> None:
while not stop.wait(_RUN_CLAIM_HEARTBEAT_SECONDS):
try:
heartbeat_run_claim(job_id, expected_owner=owner)
except Exception:
logger.debug(
"Job '%s': script run_claim heartbeat failed",
job_id,
exc_info=True,
)
heartbeat_thread = threading.Thread(
target=heartbeat_context.run,
args=(_heartbeat_loop,),
name="cron-script-claim-heartbeat",
daemon=True,
)
try:
heartbeat_thread.start()
except Exception:
logger.debug(
"Job '%s': could not start script run_claim heartbeat",
job_id,
exc_info=True,
)
return _run_job_script(script_path)
try:
return _run_job_script(script_path)
finally:
stop.set()
# Event.wait() wakes immediately. Keep completion bounded if the
# heartbeat is already waiting on another process's jobs-file lock.
heartbeat_thread.join(timeout=1.0)
def _parse_wake_gate(script_output: str) -> bool:
"""Parse the last non-empty stdout line of a cron job's pre-check script
as a wake gate.
@ -2542,7 +2608,7 @@ def run_job(
_prior_cwd = None
try:
ok, output = _run_job_script(script_path)
ok, output = _run_job_script_with_claim_heartbeat(job, script_path)
finally:
if _prior_cwd is not None:
try:
@ -2617,10 +2683,68 @@ def run_job(
# Initialize SQLite session store so cron job messages are persisted
# and discoverable via session_search (same pattern as gateway/run.py).
#
# Bounded with its own timeout (separate from HERMES_CRON_TIMEOUT, which
# only watches the agent's run_conversation below): SessionDB.__init__
# opens/migrates state.db synchronously and has no timeout of its own
# against a wedged sqlite3.connect (e.g. a stale flock left by a crashed
# sibling process). An unbounded hang here is invisible to every other
# cron safeguard, because it happens BEFORE _submit_with_guard's future
# exists — the finally block that releases the job from
# _running_job_ids never runs, so the job stays wedged "running" until
# the whole gateway process is restarted, silently skipping every
# scheduled fire in between with "already running — skipping".
_session_db = None
try:
from hermes_state import SessionDB
_session_db = SessionDB()
# Resolve timeout: env override → config.yaml → default 10s.
# Mirrors the script_timeout_seconds resolution pattern.
_session_db_timeout: float | None = None
_raw_env_timeout = os.getenv("HERMES_CRON_SESSION_DB_TIMEOUT", "").strip()
if _raw_env_timeout:
try:
_session_db_timeout = float(_raw_env_timeout)
except (ValueError, TypeError):
logger.warning(
"Invalid HERMES_CRON_SESSION_DB_TIMEOUT=%r; using config/default",
_raw_env_timeout,
)
if _session_db_timeout is None:
try:
from hermes_cli.config import load_config
_cfg = load_config() or {}
_cron_cfg = _cfg.get("cron", {}) if isinstance(_cfg, dict) else {}
_configured = _cron_cfg.get("session_db_timeout_seconds")
if _configured is not None:
_session_db_timeout = float(_configured)
except Exception as exc:
logger.debug(
"Failed to load cron.session_db_timeout_seconds from config: %s",
exc,
)
if _session_db_timeout is None:
_session_db_timeout = 10.0
if _session_db_timeout > 0:
_session_db_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
try:
_session_db = _session_db_pool.submit(SessionDB).result(timeout=_session_db_timeout)
finally:
# Don't wait for a wedged connect() to unwind — abandon the
# worker thread (same pattern as the agent inactivity timeout
# further down) rather than blocking shutdown on it too.
_session_db_pool.shutdown(wait=False)
else:
# 0 = unlimited (legacy behavior, opt-in for debugging)
_session_db = SessionDB()
except concurrent.futures.TimeoutError:
logger.error(
"Job '%s': SessionDB init did not return within %.0fs — proceeding "
"without a session store for this run instead of blocking it "
"forever",
job.get("id", "?"), _session_db_timeout,
)
except Exception as e:
logger.debug("Job '%s': SQLite session store not available: %s", job.get("id", "?"), e)
@ -2631,7 +2755,7 @@ def run_job(
prerun_script = None
script_path = job.get("script")
if script_path:
prerun_script = _run_job_script(script_path)
prerun_script = _run_job_script_with_claim_heartbeat(job, script_path)
_ran_ok, _script_output = prerun_script
if _ran_ok and not _parse_wake_gate(_script_output):
logger.info(
@ -2864,11 +2988,12 @@ def run_job(
except Exception:
pass
# Reasoning config from config.yaml (raw value — a YAML boolean False
# means thinking disabled, see parse_reasoning_effort)
from hermes_constants import parse_reasoning_effort
reasoning_config = parse_reasoning_effort(
_cfg.get("agent", {}).get("reasoning_effort", "")
# Reasoning config from config.yaml (per-model override > global) —
# resolved through the shared chokepoint against the job's effective
# model (per-job override > HERMES_MODEL env > config.yaml default).
from hermes_constants import resolve_reasoning_config
reasoning_config = resolve_reasoning_config(
_cfg if isinstance(_cfg, dict) else {}, str(model)
)
# Prefill messages from env or config.yaml. The top-level
@ -3113,7 +3238,6 @@ def run_job(
_run_claim_owner = (
str(_run_claim.get("by") or "") if isinstance(_run_claim, dict) else ""
)
_CLAIM_HEARTBEAT_SECONDS = 60.0
_last_claim_heartbeat = time.monotonic()
def _heartbeat_run_claim_if_due():
@ -3121,7 +3245,7 @@ def run_job(
if not _is_oneshot or not _run_claim_owner:
return
_mono = time.monotonic()
if _mono - _last_claim_heartbeat < _CLAIM_HEARTBEAT_SECONDS:
if _mono - _last_claim_heartbeat < _RUN_CLAIM_HEARTBEAT_SECONDS:
return
_last_claim_heartbeat = _mono
try:

View file

@ -51,6 +51,7 @@ JSON object. Source of truth: `gateway/relay/descriptor.py`.
| `emoji` | string | no | Display emoji (default 🔌). |
| `platform_hint` | string | no | System-prompt platform hint. |
| `pii_safe` | bool | no | Redact PII in session descriptions. |
| `supports_context` | bool | no | Whether the connector can supply surrounding channel/group **context** for an addressed turn on this platform (Model A on-demand history fetch — Discord/Slack/Matrix; Model B passive buffer — Telegram/Signal/WhatsApp). Default false ⇒ no `context` is attached to inbound events. See §3. |
Most fields are a projection of the gateway's existing `PlatformEntry`; the
runtime-only fields (`len_unit`, `supports_*`, `markdown_dialect`) come from the
@ -95,6 +96,24 @@ Frames (connector → gateway, over the WS):
- `{"type":"interrupt_inbound", "session_key", "chat_id"}` (§5)
- `{"type":"passthrough_forward", "forward": <PassthroughForward>, "bufferId"?}` (§5.1)
**Channel context on inbound (design relay-channel-context).** When the source
platform's descriptor advertised `supports_context` (§2) and the chat is
multi-party (`chat_type` ∈ group/channel/thread/forum, never `dm`), the
connector MAY attach two optional, additive fields to the inbound `MessageEvent`:
- `context`: an array of read-only surrounding messages (same channel, oldest→
newest) — nearby non-addressed chatter the connector fetched (Model A) or
buffered (Model B). REFERENCE ONLY: it never triggers the agent (the trigger
decision was already made connector-side on the addressed event alone). The
gateway renders it into `MessageEvent.channel_context` (the same read-only
injection path history-backfill uses).
- `context_error`: bool, true when the platform is context-capable but the
fetch/buffer failed and the connector fail-opened to an empty `context`
(observability marker; surfaced connector-side via the delivery span).
Both absent ⇒ byte-identical to today. A connector that never sends them, or a
`dm`, or a no-context platform, yields no `channel_context`.
`PassthroughForward` is the wire form of a forwarded passthrough-plane request
(Class-2/3 webhooks — Discord interactions, Twilio): `{platform, botId, method,
path, headers: [[k,v],…], bodyB64}`. The body is base64-encoded so arbitrary

View file

@ -63,9 +63,17 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
``direct_messages_topic_id`` when the Bot API supports it.
"""
thread_id = getattr(source, "thread_id", None)
if thread_id is None:
metadata = {"thread_id": thread_id} if thread_id is not None else {}
# Slack workspace identity is durable routing state, not ephemeral event
# metadata. Carry it on every outbound path (including unthreaded sends)
# so a multi-workspace Socket Mode gateway never falls back to its primary
# WebClient after an async, stream, or recovery boundary.
if _platform_name(getattr(source, "platform", None)) == "slack":
scope_id = getattr(source, "scope_id", None)
if scope_id:
metadata["slack_team_id"] = str(scope_id)
if not metadata:
return None
metadata = {"thread_id": thread_id}
if _platform_name(getattr(source, "platform", None)) == "telegram" and getattr(source, "chat_type", None) == "dm":
metadata["telegram_dm_topic_reply_fallback"] = True
tid = str(thread_id)
@ -3202,6 +3210,30 @@ class BasePlatformAdapter(ABC):
"""
pass
async def _stop_typing_with_metadata(self, chat_id: str, metadata=None) -> None:
"""Stop typing while preserving platform-specific routing metadata.
Most adapters key typing state by chat and retain the historical
``stop_typing(chat_id)`` signature. Slack AI status is per thread and
workspace, however, so losing metadata can clear a sibling thread or
leave the current one active. Introspect at this shared chokepoint so
existing adapters remain source-compatible.
"""
if metadata:
try:
params = inspect.signature(self.stop_typing).parameters
accepts_metadata = "metadata" in params or any(
param.kind is inspect.Parameter.VAR_KEYWORD
for param in params.values()
)
except (TypeError, ValueError):
accepts_metadata = False
if accepts_metadata:
stop_typing = getattr(self, "stop_typing")
await stop_typing(chat_id, metadata=metadata)
return
await self.stop_typing(chat_id)
async def send_multiple_images(
self,
chat_id: str,
@ -3879,7 +3911,7 @@ class BasePlatformAdapter(ABC):
# Cancelling _keep_typing alone won't clean that up.
if hasattr(self, "stop_typing"):
try:
await self.stop_typing(chat_id)
await self._stop_typing_with_metadata(chat_id, metadata)
except Exception:
pass
self._typing_paused.discard(chat_id)
@ -3889,6 +3921,7 @@ class BasePlatformAdapter(ABC):
chat_id: str,
typing_task: asyncio.Task | None = None,
*,
metadata=None,
timeout: float = 0.5,
stop_attempts: int = 2,
) -> None:
@ -3908,7 +3941,7 @@ class BasePlatformAdapter(ABC):
attempts = max(1, stop_attempts)
for attempt in range(attempts):
try:
await self.stop_typing(chat_id)
await self._stop_typing_with_metadata(chat_id, metadata)
except Exception:
pass
if attempt < attempts - 1:
@ -3928,14 +3961,14 @@ class BasePlatformAdapter(ABC):
"""Resume typing indicator for a chat after approval resolves."""
self._typing_paused.discard(chat_id)
async def interrupt_session_activity(self, session_key: str, chat_id: str) -> None:
async def interrupt_session_activity(self, session_key: str, chat_id: str, metadata=None) -> None:
"""Signal the active session loop to stop and clear typing immediately."""
if session_key:
interrupt_event = self._active_sessions.get(session_key)
if interrupt_event is not None:
interrupt_event.set()
try:
await self.stop_typing(chat_id)
await self._stop_typing_with_metadata(chat_id, metadata)
except Exception:
pass
@ -4873,6 +4906,7 @@ class BasePlatformAdapter(ABC):
await self._stop_typing_refresh(
event.source.chat_id,
typing_task,
metadata=_thread_metadata,
)
try:
@ -5296,6 +5330,7 @@ class BasePlatformAdapter(ABC):
await self._stop_typing_refresh(
event.source.chat_id,
None,
metadata=_thread_metadata,
stop_attempts=1,
)
# Final drain/release boundary: force-flush any timer that missed
@ -5464,6 +5499,7 @@ class BasePlatformAdapter(ABC):
user_id_alt: Optional[str] = None,
chat_id_alt: Optional[str] = None,
is_bot: bool = False,
scope_id: Optional[str] = None,
guild_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
message_id: Optional[str] = None,
@ -5487,6 +5523,7 @@ class BasePlatformAdapter(ABC):
user_id_alt=user_id_alt,
chat_id_alt=chat_id_alt,
is_bot=is_bot,
scope_id=str(scope_id) if scope_id else None,
guild_id=str(guild_id) if guild_id else None,
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
message_id=str(message_id) if message_id else None,

View file

@ -58,6 +58,12 @@ class CapabilityDescriptor:
emoji: str = "\U0001f50c" # 🔌 default (matches PlatformEntry default)
platform_hint: str = ""
pii_safe: bool = False
# Whether the connector can supply surrounding channel/group CONTEXT for an
# addressed turn (Model A pull / Model B buffer, per platform). Optional +
# defaults False so an older connector that never sends it is treated as
# "no context" — additive within contract_version 1. from_json filters
# unknown keys, so a connector sending this to an older gateway is safe too.
supports_context: bool = False
def to_json(self) -> str:
"""Serialize to a compact, stable JSON string for the handshake frame."""

View file

@ -34,7 +34,7 @@ import json
import logging
import uuid
from dataclasses import dataclass
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
@ -91,6 +91,44 @@ def _ws_dial_url(url: str) -> str:
return raw
def _render_relay_context(context: Any) -> Optional[str]:
"""Render the connector's read-only surrounding-context array into the string
``MessageEvent.channel_context`` field.
The connector attaches ``context`` as a list of normalized message objects
(oldestnewest, same channel) for an addressed turn on a context-capable
platform (design relay-channel-context). We flatten each to a
``<author>: <text>`` line so it rides the SAME read-only injection path that
history-backfill already uses (run.py prepends ``channel_context`` ahead of
the trigger message). This is REFERENCE context only it never triggers the
agent; the trigger decision was already made connector-side on the addressed
event alone.
Returns None when there is no usable context (absent/empty list, or a
connector that doesn't send the field), so ``channel_context`` stays unset
and behaviour is byte-identical to today. Never raises a malformed context
payload must not break inbound delivery of the (already-admitted) turn.
"""
if not context or not isinstance(context, list):
return None
lines: List[str] = []
for item in context:
if not isinstance(item, dict):
continue
text = item.get("text")
if not text:
continue
src = item.get("source") or {}
author = ""
if isinstance(src, dict):
author = src.get("user_name") or src.get("user_id") or ""
lines.append(f"{author}: {text}" if author else str(text))
if not lines:
return None
body = "\n".join(lines)
return f"[Recent channel messages]\n{body}"
def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
"""Rebuild a MessageEvent from the connector's normalized inbound payload.
@ -150,6 +188,15 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
message_id=raw.get("message_id"),
reply_to_message_id=raw.get("reply_to_message_id"),
media_urls=raw.get("media_urls") or [],
# Surrounding channel/group CONTEXT the connector attached for this
# addressed turn (design relay-channel-context): a read-only, oldest→
# newest list of nearby non-addressed messages (Model A pull / Model B
# buffer). Rendered into the existing ``channel_context`` field — the
# same read-only injection path history-backfill already uses
# (run.py prepends it ahead of the trigger message). Absent / empty on a
# connector that doesn't send it, a dm, or a no-context platform, so
# this is purely additive and byte-identical to today when unset.
channel_context=_render_relay_context(raw.get("context")),
)

View file

@ -4851,23 +4851,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return getattr(self, "_ephemeral_system_prompt", None) or ""
@staticmethod
def _load_reasoning_config() -> dict | None:
"""Load reasoning effort from config.yaml.
def _load_reasoning_config(model: str = "") -> dict | None:
"""Load reasoning effort from config.yaml, respecting per-model overrides.
Reads agent.reasoning_effort from config.yaml. Valid: "none",
"minimal", "low", "medium", "high", "xhigh", "max", "ultra". Returns None to use
default (medium).
Thin wrapper over the shared chokepoint
:func:`hermes_constants.resolve_reasoning_config` (per-model override >
global ``agent.reasoning_effort``; YAML boolean False = disabled).
Closes #21256.
Args:
model: The effective model for the calling session. When empty,
the config's ``model.default`` is used.
"""
from hermes_constants import parse_reasoning_effort
from hermes_constants import resolve_reasoning_config
cfg = _load_gateway_runtime_config()
# Keep the raw value — coercing with ``or ""`` turns a YAML boolean
# False (``reasoning_effort: false``/``off``/``no``) into "", silently
# re-enabling thinking for users who explicitly disabled it.
effort = cfg_get(cfg, "agent", "reasoning_effort", default="")
result = parse_reasoning_effort(effort)
if effort and str(effort).strip() and result is None:
logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
return result
return resolve_reasoning_config(cfg, model)
@staticmethod
def _parse_reasoning_command_args(raw_args: str) -> tuple[str, bool]:
@ -4900,8 +4898,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
*,
source: Optional[SessionSource] = None,
session_key: Optional[str] = None,
model: str = "",
) -> dict | None:
"""Resolve reasoning effort for a session, honoring session overrides."""
"""Resolve reasoning effort for a session, honoring session overrides.
Priority: session-scoped ``/reasoning --session`` override >
per-model override (``agent.reasoning_overrides``) > global
``agent.reasoning_effort``. ``model`` should be the session's
*effective* model (session ``/model`` override included) so
per-model overrides track what the session actually runs when
empty, the config's ``model.default`` is used.
"""
resolved_session_key = session_key
if not resolved_session_key and source is not None:
try:
@ -4912,7 +4919,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
overrides = getattr(self, "_session_reasoning_overrides", {}) or {}
if resolved_session_key and resolved_session_key in overrides:
return overrides[resolved_session_key]
return self._load_reasoning_config()
return self._load_reasoning_config(model)
def _set_session_reasoning_override(
self,
@ -5262,8 +5269,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
session_id = await asyncio.to_thread(
self._lookup_session_id_under_store_lock, session_store, session_key
)
except Exception:
except (AttributeError, TypeError):
return False
except Exception:
logger.warning(
"Compression in-flight check failed while reading session %s; "
"treating compression as active to avoid interrupting a possible "
"parent-session rotation",
session_key,
exc_info=True,
)
return True
if not session_id:
return False
session_db = getattr(self, "_session_db", None)
@ -5275,8 +5291,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
raw_db.get_compression_lock_holder, str(session_id)
)
return bool(holder)
except Exception:
except (AttributeError, TypeError):
return False
except Exception:
logger.warning(
"Compression in-flight check failed while reading lock holder "
"for session %s; treating compression as active to avoid "
"interrupting a possible parent-session rotation",
session_id,
exc_info=True,
)
return True
@staticmethod
def _lookup_session_id_under_store_lock(session_store, session_key: str):
@ -9183,6 +9208,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Gateway intercepted clarify text response (session=%s, id=%s)",
_quick_key, _pending_clarify.clarify_id,
)
# The clarify callback pauses the platform typing/status
# indicator while waiting so Slack users can type their
# answer. The active agent resumes as soon as this reply
# resolves the wait, so re-enable its indicator here too.
# Without this, Slack stays silent until the independent
# long-running heartbeat fires (three minutes by default).
_clarify_adapter = self._adapter_for_source(source)
if _clarify_adapter:
try:
_clarify_adapter.resume_typing_for_chat(source.chat_id)
except Exception:
logger.debug(
"Failed to resume typing after clarify response",
exc_info=True,
)
# Acknowledge with empty string so adapters that emit
# the agent's response don't double-post. The agent
# itself will produce the next user-facing message.
@ -11731,10 +11771,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
persist_user_timestamp=persist_user_timestamp,
)
# Stop persistent typing indicator now that the agent is done
# Stop persistent typing indicator now that the agent is done.
# Slack AI status is scoped to a thread/workspace, so preserve the
# same routing metadata used by the response delivery path.
try:
_typing_adapter = self._adapter_for_source(source)
if _typing_adapter and hasattr(_typing_adapter, "stop_typing"):
_stop_with_metadata = getattr(
type(_typing_adapter), "_stop_typing_with_metadata", None
)
_stop_typing = getattr(type(_typing_adapter), "stop_typing", None)
if _typing_adapter and callable(_stop_with_metadata):
await _typing_adapter._stop_typing_with_metadata(
source.chat_id,
self._thread_metadata_for_source(
source, self._reply_anchor_for_event(event)
),
)
elif _typing_adapter and callable(_stop_typing):
await _typing_adapter.stop_typing(source.chat_id)
except Exception:
pass
@ -12280,10 +12333,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return response
except Exception as e:
# Stop typing indicator on error too
# Stop typing indicator on error too, retaining Slack thread/workspace
# routing so a failed turn cannot leave its status visible.
try:
_err_adapter = self._adapter_for_source(source)
if _err_adapter and hasattr(_err_adapter, "stop_typing"):
_stop_with_metadata = getattr(
type(_err_adapter), "_stop_typing_with_metadata", None
)
_stop_typing = getattr(type(_err_adapter), "stop_typing", None)
if _err_adapter and callable(_stop_with_metadata):
await _err_adapter._stop_typing_with_metadata(
source.chat_id,
self._thread_metadata_for_source(
source, self._reply_anchor_for_event(event)
),
)
elif _err_adapter and callable(_stop_typing):
await _err_adapter.stop_typing(source.chat_id)
except Exception:
pass
@ -13464,7 +13529,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
pr = self._provider_routing
max_iterations = _current_max_iterations()
reasoning_config = self._resolve_session_reasoning_config(source=source)
reasoning_config = self._resolve_session_reasoning_config(
source=source, model=model
)
self._reasoning_config = reasoning_config
self._service_tier = self._load_service_tier()
turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs)
@ -14442,13 +14509,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
reply_to_message_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Build the metadata dict platforms need for thread-aware replies."""
return self._thread_metadata_for_target(
metadata = self._thread_metadata_for_target(
getattr(source, "platform", None),
getattr(source, "chat_id", None),
getattr(source, "thread_id", None),
chat_type=getattr(source, "chat_type", None),
reply_to_message_id=reply_to_message_id or getattr(source, "message_id", None),
)
if getattr(source, "platform", None) == Platform.SLACK:
team_id = getattr(source, "scope_id", None)
if team_id:
metadata = dict(metadata or {})
metadata["slack_team_id"] = str(team_id)
return metadata
def _thread_metadata_for_target(
self,
@ -16346,8 +16419,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
running_agent.interrupt(interrupt_reason)
self._invalidate_session_run_generation(session_key, reason=invalidation_reason)
adapter = self._adapter_for_source(source)
if adapter and hasattr(adapter, "interrupt_session_activity"):
await adapter.interrupt_session_activity(session_key, source.chat_id)
interrupt_session_activity = getattr(
type(adapter), "interrupt_session_activity", None
)
if adapter and callable(interrupt_session_activity):
metadata = self._thread_metadata_for_source(source)
try:
params = inspect.signature(interrupt_session_activity).parameters
accepts_metadata = "metadata" in params or any(
param.kind is inspect.Parameter.VAR_KEYWORD
for param in params.values()
)
except (TypeError, ValueError):
accepts_metadata = False
if accepts_metadata:
await adapter.interrupt_session_activity(
session_key, source.chat_id, metadata=metadata
)
else:
await adapter.interrupt_session_activity(session_key, source.chat_id)
if adapter and hasattr(adapter, "get_pending_message"):
adapter.get_pending_message(session_key) # consume and discard
self._pending_messages.pop(session_key, None)
@ -17123,7 +17213,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
event_message_id: Optional[str] = None,
channel_prompt: Optional[str] = None,
moa_config: Optional[dict] = None,
persist_user_message: Optional[str] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""Profile-scoping wrapper around the agent run.
@ -17184,7 +17274,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
event_message_id: Optional[str] = None,
channel_prompt: Optional[str] = None,
moa_config: Optional[dict] = None,
persist_user_message: Optional[str] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""
@ -18215,6 +18305,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
reasoning_config = self._resolve_session_reasoning_config(
source=source,
session_key=session_key,
model=model,
)
self._reasoning_config = reasoning_config
self._service_tier = self._load_service_tier()

View file

@ -1904,9 +1904,10 @@ class SessionStore:
# Stale routing self-heal (#54878): the in-memory entry
# points at a session that has ALREADY been ended in
# state.db. Drop it and fall through to recovery/create.
# Recovery finder reopens ``agent_close`` rows (preserving
# the transcript) but returns None for other end_reasons
# (e.g. /new), starting a fresh session.
# Recovery finder reopens ``agent_close`` and mistaken
# ``ws_orphan_reap`` rows (preserving the transcript) but
# returns None for other end_reasons (e.g. /new), starting
# a fresh session.
logger.warning(
"gateway.session: routing key %r -> %s is ended in "
"state.db but still live in sessions.json; dropping "
@ -2178,6 +2179,10 @@ class SessionStore:
"has_active_processes_fn raised during prune for %s: %s",
entry.session_key, exc,
)
# Fail safe: if we can't tell whether a background
# process is attached, keep the entry rather than
# risk orphaning live work.
continue
if entry.updated_at < cutoff:
removed_keys.append(key)
for key in removed_keys:

View file

@ -1112,6 +1112,26 @@ class GatewaySlashCommandsMixin:
)
return EphemeralReply(t("gateway.stop.stopped"))
# No running agent anywhere for this scope. A platform status
# indicator can still be stuck — e.g. Slack's persistent
# assistant.threads.setStatus survives a gateway restart or a turn
# that died without a final send (#32295). Best-effort clear so
# /stop always dismisses a phantom "is thinking...".
adapter = getattr(self, "adapters", {}).get(source.platform)
if adapter and hasattr(adapter, "_stop_typing_with_metadata"):
try:
await adapter._stop_typing_with_metadata(
source.chat_id,
self._thread_metadata_for_source(
source, self._reply_anchor_for_event(event)
),
)
except Exception:
logger.debug(
"Failed to clear typing on /stop with no active agent",
exc_info=True,
)
return t("gateway.stop.no_active")
async def _handle_platform_command(self, event: MessageEvent) -> str:
@ -2658,9 +2678,15 @@ class GatewaySlashCommandsMixin:
_reasoning_source = await asyncio.to_thread(self._normalize_source_for_session_key, event.source)
session_key = self._session_key_for_source(_reasoning_source)
self._show_reasoning = self._load_show_reasoning()
# Use the session's effective model (session /model override wins over
# config default) so per-model reasoning_overrides display correctly.
_session_model = str(
((getattr(self, "_session_model_overrides", {}) or {}).get(session_key) or {}).get("model") or ""
)
self._reasoning_config = self._resolve_session_reasoning_config(
source=event.source,
session_key=session_key,
model=_session_model,
)
def _save_config_key(key_path: str, value):
@ -3951,6 +3977,15 @@ class GatewaySlashCommandsMixin:
source = event.source
session_key = self._session_key_for_source(source)
# `/usage reset [--force]` — redeem one banked Codex rate-limit reset
# credit. Parsed before the display path so it never mixes with the
# stats rendering below.
raw_args = event.get_command_args().strip()
args = [a.lower() for a in raw_args.split()] if raw_args else []
wants_reset = bool(args) and args[0] == "reset"
if args and not wants_reset:
return t("gateway.usage.unknown_subcommand", args=raw_args)
# Try running agent first (mid-turn), then cached agent (between turns)
agent = self._running_agents.get(session_key)
if not agent or agent is _AGENT_PENDING_SENTINEL:
@ -3978,6 +4013,21 @@ class GatewaySlashCommandsMixin:
provider = provider or persisted.get("billing_provider")
base_url = base_url or persisted.get("billing_base_url")
if wants_reset:
normalized_provider = str(provider or "").strip().lower()
if normalized_provider != "openai-codex":
return t("gateway.usage.reset_wrong_provider")
force = "--force" in args[1:]
from agent.account_usage import redeem_codex_reset_credit
result = await asyncio.to_thread(
redeem_codex_reset_credit,
base_url=base_url,
api_key=api_key,
force=force,
)
return result.message
# Fetch account usage off the event loop so slow provider APIs don't
# block the gateway. Failures are non-fatal -- account_lines stays [].
account_lines: list[str] = []

View file

@ -228,7 +228,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("help", "Show available commands", "Info"),
CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session",
gateway_only=True),
CommandDef("usage", "Show token usage and rate limits for the current session", "Info"),
CommandDef("usage", "Show token usage and rate limits; `reset` redeems a banked Codex limit reset", "Info",
args_hint="[reset [--force]]"),
CommandDef("credits", "Show Nous credit balance and top up", "Info"),
CommandDef("billing", "Manage Nous terminal billing — buy credits, auto-reload, limits", "Info",
cli_only=True),

View file

@ -1158,8 +1158,15 @@ DEFAULT_CONFIG = {
# only controls how inbound user images are presented.
"image_input_mode": "auto",
"disabled_toolsets": [],
# Per-model reasoning effort overrides (spelling-tolerant).
# Dict mapping model names (any reasonable spelling) to effort levels.
# Takes precedence over agent.reasoning_effort when the current model
# matches a key in this dict.
# Edit directly in config.yaml (no CLI support due to dots in keys).
"reasoning_overrides": {},
},
"terminal": {
"backend": "local",
"modal_mode": "auto",
@ -1581,6 +1588,7 @@ DEFAULT_CONFIG = {
"api_key": "", # API key for base_url (falls back to OPENAI_API_KEY)
"timeout": 120, # seconds — LLM API call timeout; vision payloads need generous timeout
"extra_body": {}, # OpenAI-compatible provider-specific request fields
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
"download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections
},
"web_extract": {
@ -1590,6 +1598,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
"compression": {
"provider": "auto",
@ -1598,6 +1607,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 120, # seconds — compression summarises large contexts; increase for local models
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Note: session_search no longer uses an auxiliary LLM (PR #27590 —
# single-shape tool returns DB content directly). The old
@ -1610,6 +1620,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 30,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
"approval": {
"provider": "auto",
@ -1618,6 +1629,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 30,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
"mcp": {
"provider": "auto",
@ -1626,6 +1638,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 30,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
"title_generation": {
"provider": "auto",
@ -1634,6 +1647,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 30,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
"language": "",
},
"tts_audio_tags": {
@ -1643,6 +1657,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 30,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Triage specifier — flesh out a rough one-liner in the Kanban
# Triage column into a concrete spec, then promote it to ``todo``.
@ -1656,6 +1671,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 120,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Kanban decomposer — decomposes a triage task into a graph of
# child tasks routed to specialist profiles by description.
@ -1669,6 +1685,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 180,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Profile describer — auto-generates a 1-2 sentence description
# of what a profile is good at. Invoked by
@ -1681,6 +1698,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 60,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Curator — skill-usage review fork. Timeout is generous because the
# review pass can take several minutes on reasoning models (umbrella
@ -1694,6 +1712,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 600,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Monitor — urgency/importance classifier used by the important-mail
# monitor catalog automation (cron/scripts/classify_items.py). Scores
@ -1708,6 +1727,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 60,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
# Background review — the post-turn self-improvement fork that decides
# whether to save a memory / patch a skill. "auto" (default) = run on
@ -1727,6 +1747,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 120,
"extra_body": {},
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
},
"moa_reference": {
"provider": "auto",
@ -1735,6 +1756,10 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 900,
"extra_body": {},
# NOTE: no reasoning_effort here by design — MoA reasoning depth is
# configured PER SLOT in the MoA preset (moa.presets.<name>.
# reference_models[].reasoning_effort / aggregator.reasoning_effort),
# not at the auxiliary-task level.
},
"moa_aggregator": {
"provider": "auto",
@ -1743,6 +1768,7 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 900,
"extra_body": {},
# NOTE: no reasoning_effort here by design — see moa_reference above.
},
},
@ -2705,6 +2731,12 @@ DEFAULT_CONFIG = {
# recent .md files and prunes older ones. 0 or negative disables
# pruning (for operators who manage cleanup externally). Default 50.
"output_retention": 50,
# Timeout (seconds) for SessionDB() init inside cron jobs.
# SessionDB opens/migrates state.db synchronously and has no timeout
# of its own against a wedged sqlite3.connect. An unbounded hang here
# wedges the job's dispatch guard forever. Also overridable via
# HERMES_CRON_SESSION_DB_TIMEOUT env var. 0 = unlimited (skip the bound).
"session_db_timeout_seconds": 10,
},
# Kanban multi-agent coordination — controls the dispatcher loop that
@ -3701,6 +3733,21 @@ OPTIONAL_ENV_VARS = {
"category": "provider",
"advanced": True,
},
"UPSTAGE_API_KEY": {
"description": "Upstage API key for Solar LLM models",
"prompt": "Upstage API Key",
"url": "https://console.upstage.ai/api-keys",
"password": True,
"category": "provider",
},
"UPSTAGE_BASE_URL": {
"description": "Upstage base URL override (default: https://api.upstage.ai/v1)",
"prompt": "Upstage base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
"AWS_REGION": {
"description": "AWS region for Bedrock API calls (e.g. us-east-1, eu-central-1)",
"prompt": "AWS Region",

View file

@ -94,9 +94,11 @@ class InvalidCredentialsError(Exception):
class RefreshExpiredError(Exception):
"""The refresh token is dead.
"""This provider rejects the refresh token as dead or invalid.
Middleware clears cookies and forces re-login (302 ``/login``).
In a multi-provider deployment this does not prove token ownership, so
middleware may try remaining providers. It clears cookies and forces
re-login only after every reachable provider rejects the token.
"""
@ -125,9 +127,13 @@ class DashboardAuthProvider(ABC):
raises ``ProviderError`` if the IDP is unreachable. Middleware
treats expiry and unreachable differently (expiry refresh;
unreachable 503).
* ``refresh_session`` raises ``RefreshExpiredError`` when the
refresh token is also invalid; middleware then forces re-login.
Raises ``ProviderError`` on network failure.
* ``refresh_session`` raises ``RefreshExpiredError`` when the refresh
token is invalid for that provider. Middleware tries the remaining
providers because an opaque foreign token can be indistinguishable
from an expired one; it forces re-login only after every reachable
provider rejects the token. Raises ``ProviderError`` on network
failure; middleware still tries remaining providers, but returns 503
without clearing cookies if none succeeds and any was unavailable.
* ``revoke_session`` is best-effort and must not raise.
Subclasses MUST set ``name`` (lowercase identifier, stable forever)

View file

@ -66,6 +66,10 @@ from fastapi.responses import Response
# request's HTTPS + prefix combination.
SESSION_AT_COOKIE = "hermes_session_at"
SESSION_RT_COOKIE = "hermes_session_rt"
# Provider that minted the session. This non-secret routing hint prevents a
# refresh token from being handed to the wrong provider when several dashboard
# auth plugins are enabled (for example Basic + Nous OAuth).
SESSION_PROVIDER_COOKIE = "hermes_session_provider"
PKCE_COOKIE = "hermes_session_pkce"
# One-shot loop-guard marker for the auto-SSO redirect (Phase 1,
# cloud-auto-discovery). Set when the gate auto-initiates the portal OAuth
@ -141,6 +145,24 @@ def _common_attrs(*, use_https: bool, prefix: str) -> dict:
return attrs
def set_session_provider_cookie(
response: Response,
*,
provider: str,
use_https: bool,
prefix: str = "",
) -> None:
"""Persist the non-secret provider routing hint for token refresh."""
if not provider:
return
response.set_cookie(
_resolved_name(SESSION_PROVIDER_COOKIE, use_https=use_https, prefix=prefix),
provider,
max_age=_RT_MAX_AGE,
**_common_attrs(use_https=use_https, prefix=prefix),
)
def set_session_cookies(
response: Response,
*,
@ -149,6 +171,7 @@ def set_session_cookies(
access_token_expires_in: int,
use_https: bool,
prefix: str = "",
provider: str = "",
) -> None:
"""Set the session cookies on the response.
@ -181,6 +204,12 @@ def set_session_cookies(
max_age=_RT_MAX_AGE,
**_common_attrs(use_https=use_https, prefix=prefix),
)
set_session_provider_cookie(
response,
provider=provider,
use_https=use_https,
prefix=prefix,
)
def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
@ -202,6 +231,10 @@ def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
f"{variant}{SESSION_RT_COOKIE}", "", max_age=0,
path=path, httponly=True, samesite="lax",
)
response.set_cookie(
f"{variant}{SESSION_PROVIDER_COOKIE}", "", max_age=0,
path=path, httponly=True, samesite="lax",
)
def set_pkce_cookie(
@ -248,6 +281,11 @@ def read_session_cookies(request: Request) -> Tuple[Optional[str], Optional[str]
return at, rt
def read_session_provider(request: Request) -> Optional[str]:
"""Return the provider routing hint associated with the session cookies."""
return _read_with_fallback(request, SESSION_PROVIDER_COOKIE)
def read_pkce_cookie(request: Request) -> Optional[str]:
return _read_with_fallback(request, PKCE_COOKIE)

View file

@ -24,11 +24,17 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response
from hermes_cli.dashboard_auth import list_session_providers
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import ProviderError, RefreshExpiredError
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
ProviderError,
RefreshExpiredError,
)
from hermes_cli.dashboard_auth.cookies import (
clear_sso_attempt_cookie,
read_session_cookies,
read_session_provider,
read_sso_attempt_cookie,
set_session_provider_cookie,
set_sso_attempt_cookie,
)
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
@ -83,6 +89,22 @@ def _client_ip(request: Request) -> str:
return request.client.host if request.client else ""
def _ordered_session_providers(
provider_hint: str | None,
) -> list[DashboardAuthProvider]:
"""Prefer the hinted provider without making the hint authoritative.
The cookie can outlive a provider rename/removal or become stale after a
deployment change. A stable sort moves a matching provider to the front
while preserving registration order for every remaining candidate; an
unknown hint therefore leaves the normal scan unchanged.
"""
providers = list_session_providers()
if provider_hint:
providers.sort(key=lambda provider: provider.name != provider_hint)
return providers
def _unauth_response(request: Request, *, reason: str) -> Response:
"""API routes → 401 JSON with ``login_url``; HTML routes → 302 → /login.
@ -276,6 +298,7 @@ async def gated_auth_middleware(
return await call_next(request)
at, _rt = read_session_cookies(request)
provider_hint = read_session_provider(request)
if not at and not _rt:
# Neither token present — no session at all. Nothing to verify or
# refresh. Before falling back to the /login interstitial, try to
@ -321,7 +344,7 @@ async def gated_auth_middleware(
# 503 — distinguishing "transient IDP outage" (don't force re-login)
# from "token genuinely invalid" (fall through to refresh/relogin).
unreachable_provider: str | None = None
for provider in list_session_providers():
for provider in _ordered_session_providers(provider_hint):
try:
session = provider.verify_session(access_token=at)
except ProviderError as e:
@ -353,9 +376,22 @@ async def gated_auth_middleware(
# Access token is expired/invalid. Before forcing re-login, try to
# rotate it using the refresh token (if the session cookie carries
# one). On success we re-set the rotated cookies on the response and
# serve the request transparently; on RefreshExpiredError (RT dead /
# revoked / reuse-detected) we fall through to clear-and-relogin.
refreshed = _attempt_refresh(request, refresh_token=_rt)
# serve the request transparently; only after every provider rejects
# the RT do we fall through to clear-and-relogin.
try:
refreshed = _attempt_refresh(
request,
refresh_token=_rt,
provider_hint=provider_hint,
)
except ProviderError as e:
# At least one provider could not confirm or reject the RT, and no
# other provider refreshed it. Preserve the cookies and surface a
# transient outage instead of turning uncertainty into a logout.
return JSONResponse(
{"detail": f"Auth provider {str(e)!r} unreachable"},
status_code=503,
)
if refreshed is not None:
new_session, refreshing_provider = refreshed
request.state.session = new_session
@ -378,6 +414,7 @@ async def gated_auth_middleware(
access_token_expires_in=_expires_in_seconds(new_session),
use_https=detect_https(request),
prefix=prefix_from_request(request),
provider=refreshing_provider,
)
audit_log(
AuditEvent.REFRESH_SUCCESS,
@ -405,7 +442,18 @@ async def gated_auth_middleware(
return response
request.state.session = session
return await call_next(request)
response = await call_next(request)
if not provider_hint and session.provider:
from hermes_cli.dashboard_auth.cookies import detect_https
from hermes_cli.dashboard_auth.prefix import prefix_from_request
set_session_provider_cookie(
response,
provider=session.provider,
use_https=detect_https(request),
prefix=prefix_from_request(request),
)
return response
def _expires_in_seconds(session) -> int:
@ -421,33 +469,32 @@ def _expires_in_seconds(session) -> int:
return max(60, int(session.expires_at) - int(time.time()))
def _attempt_refresh(request: Request, *, refresh_token):
def _attempt_refresh(request: Request, *, refresh_token, provider_hint: str | None = None):
"""Try to rotate an expired session via the refresh token.
Returns ``(new_session, provider_name)`` on success, or ``None`` if
there's no RT or every provider's ``refresh_session`` failed with
``RefreshExpiredError`` (dead/revoked/reuse-detected RT force re-login).
A ``ProviderError`` (Portal unreachable) is NOT swallowed into a re-login
here re-raising would 500 the request; instead we log and return None so
the caller forces a clean re-login, which is the safer UX than a hard
error on a transient network blip during the narrow refresh window.
The provider hint only changes candidate order. ``RefreshExpiredError``
rejects the token for that candidate, but cannot prove ownership because
providers such as Basic raise it for foreign opaque tokens too. Likewise,
``ProviderError`` only makes that candidate unavailable. Both are audited
and the remaining providers are tried. Returns ``None`` only when there is
no RT or every reachable provider rejects it. If no provider succeeds and
at least one raised ``ProviderError``, re-raises with that provider's name
so the caller can return 503 without clearing potentially valid cookies.
"""
if not refresh_token:
return None
for provider in list_session_providers():
unavailable_provider: str | None = None
for provider in _ordered_session_providers(provider_hint):
try:
new_session = provider.refresh_session(refresh_token=refresh_token)
except RefreshExpiredError:
# This provider owns the RT but it's dead — stop trying others
# (an RT belongs to exactly one provider) and force re-login.
audit_log(
AuditEvent.REFRESH_FAILURE,
provider=provider.name,
reason="refresh_expired",
ip=_client_ip(request),
)
return None
continue
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during refresh: %s",
@ -459,7 +506,11 @@ def _attempt_refresh(request: Request, *, refresh_token):
reason="provider_unreachable",
ip=_client_ip(request),
)
return None
if unavailable_provider is None:
unavailable_provider = provider.name
continue
if new_session is not None:
return new_session, provider.name
if unavailable_provider is not None:
raise ProviderError(unavailable_provider)
return None

View file

@ -365,6 +365,7 @@ async def auth_callback(
access_token_expires_in=expires_in,
use_https=detect_https(request),
prefix=_prefix(request),
provider=session.provider,
)
clear_pkce_cookie(resp, prefix=_prefix(request))
# Clear the one-shot auto-SSO loop-guard marker now that login succeeded,
@ -549,6 +550,7 @@ async def auth_password_login(request: Request, body: _PasswordLoginBody):
access_token_expires_in=expires_in,
use_https=detect_https(request),
prefix=_prefix(request),
provider=session.provider,
)
return resp

View file

@ -6567,6 +6567,77 @@ def _error_fingerprint(error_text: str) -> str:
return fp.lower().strip()
# Empirically ~96% of "clean exit without a terminal tool call" tasks complete
# on a later run (a goal-mode finalize nudge, or the model simply emitting the
# tool call next time), so a protocol violation is NOT deterministic — give it a
# bounded retry before the breaker trips instead of blocking on the first hit.
#
# The budget is a violation-only STREAK, not a share of the unified
# ``consecutive_failures`` counter: it counts consecutive clean-exit protocol
# violations (derived from run history by ``_protocol_violation_streak``), so
# earlier timeouts / nonzero exits neither consume nor extend it, and a
# below-budget violation does not tick the unified counter either. A per-task
# ``max_retries`` overrides this bound — the same "task override wins"
# precedence ``_record_task_failure`` documents for every other failure kind.
_PROTOCOL_VIOLATION_FAILURE_LIMIT = 3
# How far back to walk a task's closed runs when counting the violation
# streak. The streak trips at a handful of violations, so anything beyond a
# few dozen rows (violations interleaved with neutral rate-limited requeues)
# can only mean "way past the bound" anyway.
_PROTOCOL_VIOLATION_SCAN_LIMIT = 50
def _protocol_violation_streak(conn: sqlite3.Connection, task_id: str) -> int:
"""Count the task's trailing run of clean-exit protocol violations.
Walks the task's closed runs newest-first — including the violation run
``detect_crashed_workers`` just closed and counts how many in a row were
clean-exit protocol violations:
* ``rate_limited`` runs are neutral and skipped: a quota wall says nothing
about the task, exactly as it is neutral for the unified
``consecutive_failures`` counter.
* Any other closed run (completed, plain crash, timeout, spawn failure,
reclaim, ) breaks the streak, so the bounded retry budget counts ONLY
protocol violations mixed failure kinds can neither consume nor
extend it.
Violation runs are recognized by the ``protocol_violation`` marker that
``detect_crashed_workers`` stamps into the run metadata; the violation
error text is matched as a fallback for runs recorded before the marker
existed.
"""
streak = 0
rows = conn.execute(
"SELECT outcome, error, metadata FROM task_runs "
"WHERE task_id = ? AND ended_at IS NOT NULL "
"ORDER BY id DESC LIMIT ?",
(task_id, _PROTOCOL_VIOLATION_SCAN_LIMIT),
).fetchall()
for row in rows:
outcome = row["outcome"] or ""
if outcome == "rate_limited":
continue
if outcome == "crashed":
is_violation = False
raw_meta = row["metadata"]
if raw_meta:
try:
is_violation = bool(
json.loads(raw_meta).get("protocol_violation")
)
except (ValueError, TypeError):
is_violation = False
if not is_violation:
is_violation = "protocol violation" in (row["error"] or "")
if is_violation:
streak += 1
continue
break
return streak
def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
"""Reclaim ``running`` tasks whose worker PID is no longer alive.
@ -6600,8 +6671,9 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
# Per-crash details collected inside the main txn, used after it
# closes to run ``_record_task_failure`` (which needs its own
# write_txn so can't nest). ``protocol_violation`` flags the
# clean-exit-but-still-running case so we can trip the breaker
# immediately instead of incrementing by 1.
# clean-exit-but-still-running case, which is accounted against its
# own bounded violation streak instead of the unified failure
# counter (see the post-txn loop below).
crash_details: list[tuple[str, int, str, bool, str]] = []
# (task_id, pid, claimer, protocol_violation, error_text)
with write_txn(conn):
@ -6632,18 +6704,29 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
if kind == "clean_exit":
# Worker subprocess returned 0 but its task is still
# ``running`` in the DB — it exited without calling
# ``kanban_complete`` / ``kanban_block``. Retrying won't
# help.
# ``kanban_complete`` / ``kanban_block``. Overwhelmingly the
# work itself succeeded and only the paperwork was skipped, so
# a retry usually completes; the corrective sentence below is
# surfaced to the retry worker via the prior-attempt error in
# ``build_worker_context`` (guidance approach from #61817).
protocol_violation = True
error_text = (
"worker exited cleanly (rc=0) without calling "
"kanban_complete or kanban_block — protocol violation"
"kanban_complete or kanban_block — protocol violation. "
"If the prior run already did the work, verify it and "
"report the result via kanban_complete; a run that ends "
"without a terminal kanban call counts as failed no "
"matter what it did."
)
event_kind = "protocol_violation"
event_payload = {
"pid": pid,
"claimer": row["claim_lock"],
"exit_code": code,
# Durable marker for _protocol_violation_streak: _end_run
# copies this payload into the run metadata, which is how
# the violation-only retry budget is derived later.
"protocol_violation": True,
}
elif kind == "rate_limited":
# Worker bailed because the provider rate-limited / exhausted
@ -6714,21 +6797,39 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
)
rate_limited.append(row["id"])
else:
if protocol_violation:
# Stamp the failure error now: a below-budget
# violation never reaches ``_record_task_failure``
# (which stamps this column for every other failure
# kind), yet the board UI and the retry worker's
# context still need the violation message + the
# corrective guidance it carries.
conn.execute(
"UPDATE tasks SET last_failure_error = ? "
"WHERE id = ?",
(error_text[:500], row["id"]),
)
crashed.append(row["id"])
crash_details.append(
(row["id"], pid, row["claim_lock"],
protocol_violation, error_text)
)
# Outside the main txn: increment the unified failure counter for
# each crashed task. If the breaker trips, the task transitions
# ready → blocked with a ``gave_up`` event on top of the ``crashed``
# event we already emitted.
# Outside the main txn: account each crashed task and maybe trip the
# breaker (the task transitions ready → blocked with a ``gave_up`` event
# on top of the event we already emitted).
#
# Protocol-violation crashes force an immediate trip (failure_limit=1)
# because clean-exit-without-transition is deterministic: the next
# respawn will do exactly the same thing. Better to surface to a
# human with a clear reason than to loop ``DEFAULT_FAILURE_LIMIT``
# times first.
# Protocol-violation crashes (clean exit, no terminal tool call) get a
# BOUNDED retry, not an immediate trip: empirically ~96% of these tasks
# complete on a later run (a goal-mode finalize nudge, or the model simply
# emitting kanban_complete/kanban_block next time), so blocking on the first
# occurrence just churned them through the respawn cycle. The retry budget
# is a violation-only streak (``_protocol_violation_streak``): earlier
# timeouts / nonzero exits neither consume nor extend it, and a
# below-budget violation does not tick the unified
# ``consecutive_failures`` counter, so the two budgets stay independent.
# A per-task ``max_retries`` overrides the violation bound with the same
# top precedence it has for every other failure kind. Systemic same-error
# crashes still trip immediately.
auto_blocked: list[str] = []
if crash_details:
# Fingerprint errors to detect systemic failures.
@ -6737,16 +6838,59 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
fp = _error_fingerprint(err_text)
_fp_counts[fp] = _fp_counts.get(fp, 0) + 1
for tid, pid, claimer, protocol_violation, error_text in crash_details:
if protocol_violation:
streak = _protocol_violation_streak(conn, tid)
trow = conn.execute(
"SELECT max_retries FROM tasks WHERE id = ?", (tid,),
).fetchone()
if trow is None:
continue # task deleted mid-loop
task_override = (
trow["max_retries"] if "max_retries" in trow.keys() else None
)
violation_limit = (
int(task_override)
if task_override is not None
else _PROTOCOL_VIOLATION_FAILURE_LIMIT
)
if streak < violation_limit:
# Below budget: the task is already back at ``ready``
# (respawn allowed) with ``last_failure_error`` stamped.
# Deliberately no ``_record_task_failure`` call — a
# below-budget violation must not consume the unified
# failure budget, just as other failure kinds don't
# consume this one.
continue
# Streak reached the bound: trip the breaker. ``force_trip``
# skips the threshold resolution inside
# ``_record_task_failure`` because the decision — including
# the per-task ``max_retries`` override — was already made
# against the violation streak above.
tripped = _record_task_failure(
conn, tid,
error=error_text,
outcome="crashed",
failure_limit=violation_limit,
force_trip=True,
release_claim=False,
end_run=False,
event_payload_extra={
"pid": pid,
"claimer": claimer,
"protocol_violations": streak,
"protocol_violation_limit": violation_limit,
},
)
if tripped:
auto_blocked.append(tid)
continue
fp = _error_fingerprint(error_text)
is_systemic = (
not protocol_violation
and _fp_counts.get(fp, 0) >= 3
)
is_systemic = _fp_counts.get(fp, 0) >= 3
tripped = _record_task_failure(
conn, tid,
error=error_text,
outcome="crashed",
failure_limit=1 if (protocol_violation or is_systemic) else None,
failure_limit=1 if is_systemic else None,
release_claim=False,
end_run=False,
event_payload_extra={"pid": pid, "claimer": claimer},
@ -6771,6 +6915,7 @@ def _record_task_failure(
*,
outcome: str,
failure_limit: int = None,
force_trip: bool = False,
release_claim: bool = False,
end_run: bool = False,
event_payload_extra: Optional[dict] = None,
@ -6808,6 +6953,15 @@ def _record_task_failure(
2. caller-supplied ``failure_limit`` (gateway passes the config
value from ``kanban.failure_limit``; tests pass fixed values)
3. ``DEFAULT_FAILURE_LIMIT``
``force_trip=True`` trips the breaker unconditionally, skipping the
counter-vs-threshold comparison (the resolution order above is then
only reported in the ``gave_up`` payload, not re-evaluated). Callers
use it when they have already applied their own bounded-retry policy
e.g. the clean-exit protocol-violation streak in
``detect_crashed_workers``, which resolves the per-task
``max_retries`` override against the violation streak itself. The
failure is still counted into ``consecutive_failures``.
"""
if failure_limit is None:
failure_limit = DEFAULT_FAILURE_LIMIT
@ -6834,7 +6988,7 @@ def _record_task_failure(
effective_limit = int(failure_limit)
limit_source = "dispatcher"
if failures >= effective_limit:
if force_trip or failures >= effective_limit:
# Trip the breaker.
if release_claim:
# Spawn path: still running, also clear claim state.

View file

@ -8126,6 +8126,90 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
return resolve_uv() or shutil.which("uv")
def _npm_manifest_paths() -> tuple[Path, ...]:
"""Manifests whose changes must defeat the update-skip.
The lockfile alone is NOT a sufficient key: on a local checkout a dev
can edit package.json (root or a workspace) without running npm the
lockfile is then unchanged but `hermes update` is exactly the step
expected to sync node_modules (via the `npm install` fallback in
_run_npm_install_deterministic).
The workspace list is pulled from the root package.json's `workspaces`
globs (npm's own source of truth) rather than hardcoded, so adding a
workspace can never silently escape the skip key. The root install
(step 1, --workspaces=false) still hoists shared deps for EVERY
workspace desktop included so all of them belong in the key, not
just the ones step 2 installs. Falls back to hashing just root
manifests if package.json is unreadable (never skips more than main
would have installed).
"""
root_pkg = PROJECT_ROOT / "package.json"
paths = [PROJECT_ROOT / "package-lock.json", root_pkg]
try:
workspaces = json.loads(root_pkg.read_text(encoding="utf-8")).get(
"workspaces", []
)
if isinstance(workspaces, dict): # legacy {"packages": [...]} form
workspaces = workspaces.get("packages", [])
for pattern in workspaces:
for match in sorted(PROJECT_ROOT.glob(str(pattern))):
manifest = match / "package.json"
if manifest.is_file():
paths.append(manifest)
except (OSError, json.JSONDecodeError, TypeError):
pass
return tuple(paths)
def _npm_manifests_digest() -> str | None:
"""Combined sha256 over the lockfile + all workspace package.json files.
Returns None when the lockfile is missing (never skip then).
"""
if not (PROJECT_ROOT / "package-lock.json").exists():
return None
h = hashlib.sha256()
for p in _npm_manifest_paths():
h.update(str(p.relative_to(PROJECT_ROOT)).encode())
try:
h.update(p.read_bytes())
except OSError:
h.update(b"<missing>")
return h.hexdigest()
def _npm_lockfile_changed(hermes_root: Path) -> bool:
current = _npm_manifests_digest()
if current is None:
return True
# Also check that node_modules exists; a matching hash with missing
# node_modules means the cache was recorded by another checkout.
if not (PROJECT_ROOT / "node_modules").is_dir():
return True
try:
# Key the cache by PROJECT_ROOT so parallel worktrees don't collide.
cache_key = hashlib.sha256(str(PROJECT_ROOT).encode()).hexdigest()[:12]
cache_file = hermes_root / f".npm_lock_hash_{cache_key}"
if not cache_file.exists():
return True
return cache_file.read_text(encoding="utf-8").strip() != current
except OSError:
return True
def _record_npm_lockfile_hash(hermes_root: Path) -> None:
digest = _npm_manifests_digest()
if digest is None:
return
try:
cache_key = hashlib.sha256(str(PROJECT_ROOT).encode()).hexdigest()[:12]
cache_file = hermes_root / f".npm_lock_hash_{cache_key}"
cache_file.write_text(digest, encoding="utf-8")
except OSError:
logger.debug("Could not write npm lockfile hash cache")
def _update_node_dependencies() -> None:
from hermes_constants import find_node_executable, with_hermes_node_path
@ -8136,6 +8220,16 @@ def _update_node_dependencies() -> None:
if not (PROJECT_ROOT / "package.json").exists():
return
from hermes_constants import get_default_hermes_root
# This cache describes PROJECT_ROOT/node_modules, which is shared by every
# Hermes profile using this checkout. Keep one per-checkout cache under the
# shared Hermes root rather than rerunning npm once per named profile.
shared_hermes_root = get_default_hermes_root()
if not _npm_lockfile_changed(shared_hermes_root):
logger.info("npm lockfile unchanged, skipping npm install")
return
# With a single workspace lockfile the root install would cover ALL
# workspaces — but apps/desktop pulls in Electron as a devDependency,
# and its postinstall downloads a ~200MB binary. Most users don't
@ -8180,6 +8274,7 @@ def _update_node_dependencies() -> None:
env=nixos_env,
)
if ws_result.returncode == 0:
_record_npm_lockfile_hash(shared_hermes_root)
print(" ✓ repo root + ui-tui, web workspaces (desktop skipped)")
else:
print(" ⚠ npm workspace install failed")

View file

@ -60,6 +60,12 @@ def _pick_slot(current: dict[str, str] | None = None) -> dict[str, str]:
return {"provider": str(provider.get("slug") or ""), "model": str(model)}
def _format_slot(slot: dict[str, Any]) -> str:
label = f"{slot['provider']}:{slot['model']}"
effort = str(slot.get("reasoning_effort") or "").strip()
return f"{label} [reasoning={effort}]" if effort else label
def _print_config(config: dict[str, Any]) -> None:
cfg = normalize_moa_config(config.get("moa") if isinstance(config, dict) else {})
print("Mixture of Agents presets")
@ -71,9 +77,9 @@ def _print_config(config: dict[str, Any]) -> None:
print(f"\n{marker} {name}")
print(" Reference models:")
for idx, slot in enumerate(preset["reference_models"], start=1):
print(f" {idx}. {slot['provider']}:{slot['model']}")
print(f" {idx}. {_format_slot(slot)}")
agg = preset["aggregator"]
print(f" Aggregator: {agg['provider']}:{agg['model']}")
print(f" Aggregator: {_format_slot(agg)}")
def cmd_moa(args) -> None:

View file

@ -73,7 +73,23 @@ def _coerce_fanout(value: Any) -> str:
return mode if mode in {"per_iteration", "user_turn"} else "per_iteration"
def _clean_slot(slot: Any) -> dict[str, str] | None:
def _clean_reasoning_effort(value: Any) -> str | None:
"""Return a canonical per-slot reasoning effort, or None when unset/invalid."""
if value is None or value is True:
return None
if value is False:
return "none"
text = str(value or "").strip().lower()
if not text:
return None
if text in {"none", "false", "disabled"}:
return "none"
if text in {"minimal", "low", "medium", "high", "xhigh", "max"}:
return text
return None
def _clean_slot(slot: Any) -> dict[str, Any] | None:
if not isinstance(slot, dict):
return None
provider = str(slot.get("provider") or "").strip()
@ -87,7 +103,11 @@ def _clean_slot(slot: Any) -> dict[str, str] | None:
# an invalid slot is dropped, falling back to the preset's defaults.
if provider.lower() == "moa":
return None
return {"provider": provider, "model": model}
clean: dict[str, Any] = {"provider": provider, "model": model}
effort = _clean_reasoning_effort(slot.get("reasoning_effort"))
if effort:
clean["reasoning_effort"] = effort
return clean
def _default_preset() -> dict[str, Any]:

View file

@ -1301,16 +1301,39 @@ _PROVIDER_ALIASES = {
}
# The model Hermes silently lands on when the user never picked one and the
# provider's catalog carries it (GUI onboarding confirm card, empty
# ``model.default``, provider-set-but-model-missing resolution). Deliberately a
# capable low-cost model rather than the curated lists' entry [0]: aggregator
# lists are ordered most-capable-first, so [0] is the priciest Anthropic
# flagship (claude-fable-5 / opus) — silently billing the most expensive model
# for traffic the user never opted into.
PREFERRED_SILENT_DEFAULT_MODEL = "z-ai/glm-5.2"
def pick_silent_default_model(model_ids: list[str]) -> str:
"""Pick the silent default from an available-models list.
Returns :data:`PREFERRED_SILENT_DEFAULT_MODEL` when the list carries it,
else the first entry, else "". Used by every surface that must choose a
model on the user's behalf without an interactive picker (GUI onboarding
recommended-default, empty-model runtime fallback).
"""
if PREFERRED_SILENT_DEFAULT_MODEL in model_ids:
return PREFERRED_SILENT_DEFAULT_MODEL
return model_ids[0] if model_ids else ""
# Cost-safe overrides for the *silent* auto-default
# (``get_default_model_for_provider``). Most providers' curated lists lead with a
# sensible default, but Nous Portal is a per-token *metered aggregator* whose
# list is ordered best-/most-capable-first — entry [0] is the priciest flagship
# (``anthropic/claude-opus-4.8``, $5/$25 per Mtok). Using that as the
# non-interactive fallback when a profile sets ``provider: nous`` with no model
# silently bills the most expensive model for traffic the user never opted into
# (a missing default escalated to Opus and billed 863 requests before the user
# noticed). Pin the silent default to a low-cost curated model instead so a
# missing model can never escalate to the flagship.
# sensible default, but metered aggregators (Nous Portal, OpenRouter) order
# their lists best-/most-capable-first — entry [0] is the priciest flagship
# (``anthropic/claude-fable-5``). Using that as the non-interactive fallback
# when a profile sets a provider with no model silently bills the most
# expensive model for traffic the user never opted into (a missing default
# escalated to Opus and billed 863 requests before the user noticed). Pin the
# silent default to ``PREFERRED_SILENT_DEFAULT_MODEL`` instead so a missing
# model can never escalate to the flagship.
#
# This is deliberately a fixed, side-effect-free default for the hot resolution
# path. The *interactive* default (GUI onboarding / ``hermes model``) uses the
@ -1318,7 +1341,12 @@ _PROVIDER_ALIASES = {
# in hermes_cli/web_server.py and ``partition_nous_models_by_tier`` — which can
# hit the Portal; this fallback must stay cheap and network-free.
_PROVIDER_SILENT_DEFAULT_OVERRIDES: dict[str, str] = {
"nous": "deepseek/deepseek-v4-flash",
"nous": PREFERRED_SILENT_DEFAULT_MODEL,
# OpenRouter has no static ``_PROVIDER_MODELS`` entry (its picker list is
# fetched live), but the curated snapshot (``OPENROUTER_MODELS``) carries
# the preferred default — trust the override so provider-set-but-model-
# missing setups land on it instead of resolving to "".
"openrouter": PREFERRED_SILENT_DEFAULT_MODEL,
}
@ -1333,13 +1361,15 @@ def get_default_model_for_provider(provider: str) -> str:
same model the ``hermes model`` picker offers first. For metered aggregators
whose curated list is ordered most-capable-first, that entry is also the
most EXPENSIVE one, so silently defaulting to it is a billing footgun. Such
providers carry an explicit low-cost override in
providers carry an explicit override in
``_PROVIDER_SILENT_DEFAULT_OVERRIDES``; a missing model must never
auto-escalate to the flagship.
"""
models = _PROVIDER_MODELS.get(provider, [])
override = _PROVIDER_SILENT_DEFAULT_OVERRIDES.get(provider)
if override and override in models:
# Trust the override when the provider has no static catalog (OpenRouter's
# picker list is fetched live; its curated snapshot carries the override).
if override and (override in models or not models):
return override
return models[0] if models else ""
@ -3594,6 +3624,8 @@ def probe_api_models(
tried: list[str] = []
headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT}
if urllib.parse.urlparse(normalized).hostname == "generativelanguage.googleapis.com":
headers["X-Goog-Api-Client"] = f"hermes-agent/{_HERMES_VERSION}"
if api_key and api_mode == "anthropic_messages":
headers["x-api-key"] = api_key
headers["anthropic-version"] = "2023-06-01"

View file

@ -201,6 +201,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
extra_env_vars=("FIREWORKS_API_KEY",),
base_url_override="https://api.fireworks.ai/inference/v1",
),
"upstage": HermesOverlay(
transport="openai_chat",
extra_env_vars=("UPSTAGE_API_KEY",),
base_url_override="https://api.upstage.ai/v1",
base_url_env_var="UPSTAGE_BASE_URL",
),
"ollama-cloud": HermesOverlay(
transport="openai_chat",
base_url_override="https://ollama.com/v1",
@ -352,6 +358,9 @@ ALIASES: Dict[str, str] = {
"fireworks-ai": "fireworks",
"fw": "fireworks",
# upstage
"solar": "upstage",
# Local server aliases → virtual "local" concept (resolved via user config)
"lmstudio": "lmstudio",
"lm-studio": "lmstudio",
@ -376,6 +385,7 @@ _LABEL_OVERRIDES: Dict[str, str] = {
"stepfun": "StepFun Step Plan",
"xiaomi": "Xiaomi MiMo",
"gmi": "GMI Cloud",
"upstage": "Upstage Solar",
"tencent-tokenhub": "Tencent TokenHub",
"lmstudio": "LM Studio",
"local": "Local endpoint",

View file

@ -27,6 +27,7 @@ def _build_full_manifest(
bot_name: str,
bot_description: str,
include_assistant: bool = True,
messaging_experience: str | None = None,
) -> dict:
"""Build a full Slack manifest merging display info + our slash list.
@ -36,17 +37,24 @@ def _build_full_manifest(
for a Hermes deployment users can tweak them in the Slack UI after
pasting.
When ``include_assistant`` is True (default) the manifest opts the app
into Slack's AI Assistant container: the ``assistant_view`` feature, the
``assistant:write`` scope, and the ``assistant_thread_*`` events. Slack
then renders DMs as the right-hand Assistant split-pane, where every
exchange is a thread and bare slash commands are not delivered as normal
``command`` events. Pass ``include_assistant=False`` (``--no-assistant``)
to omit those three pieces and get a flat DM surface where ``/help``,
``/new``, etc. work inline.
By default, this keeps Hermes on Slack's older Assistant messaging
experience (``assistant_view``) for backward compatibility. Pass
``messaging_experience="agent"`` (``--agent-view``) to emit Slack's Agent
messaging experience (``agent_view`` + ``app_home_opened``). Pass
``include_assistant=False`` or ``messaging_experience="none"``
(``--no-assistant``) to omit Slack AI messaging features and get a flat DM
surface where ``/help``, ``/new``, etc. work inline.
"""
from hermes_cli.commands import slack_app_manifest
if messaging_experience is None:
messaging_experience = "assistant" if include_assistant else "none"
messaging_experience = str(messaging_experience).strip().lower()
if messaging_experience not in {"assistant", "agent", "none"}:
raise ValueError(
"messaging_experience must be one of: assistant, agent, none"
)
partial = slack_app_manifest()
slashes = partial["features"]["slash_commands"]
@ -89,7 +97,7 @@ def _build_full_manifest(
"message.mpim",
]
if include_assistant:
if messaging_experience == "assistant":
features["assistant_view"] = {
"assistant_description": "Chat with Hermes in threads and DMs.",
}
@ -100,8 +108,18 @@ def _build_full_manifest(
"assistant_thread_started",
]
)
bot_scopes.sort()
bot_events.sort()
elif messaging_experience == "agent":
features["agent_view"] = {
"agent_description": "Chat with Hermes in Slack Messages.",
}
bot_scopes.append("assistant:write")
# Slack includes current viewing context in Agent DM events only after
# this subscription is enabled; the adapter consumes that context to
# preserve the referred channel across the agent turn.
bot_events.extend(["app_context_changed", "app_home_opened"])
bot_scopes.sort()
bot_events.sort()
return {
"_metadata": {
@ -147,17 +165,29 @@ def slack_manifest_command(args) -> int:
assistant:write scope, assistant_thread_* events) so
DMs render as a flat chat where bare slash commands
work inline instead of the Assistant thread pane.
--agent-view Use Slack's Agent messaging experience (agent_view,
app_home_opened + message.im) instead of the legacy
Assistant messaging experience.
"""
name = getattr(args, "name", None) or "Hermes"
description = getattr(args, "description", None) or "Your Hermes agent on Slack"
include_assistant = not getattr(args, "no_assistant", False)
if getattr(args, "agent_view", False):
messaging_experience = "agent"
elif getattr(args, "no_assistant", False):
messaging_experience = "none"
else:
messaging_experience = "assistant"
if getattr(args, "slashes_only", False):
from hermes_cli.commands import slack_app_manifest
manifest = slack_app_manifest()["features"]["slash_commands"]
else:
manifest = _build_full_manifest(name, description, include_assistant=include_assistant)
manifest = _build_full_manifest(
name,
description,
messaging_experience=messaging_experience,
)
payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"

View file

@ -57,7 +57,8 @@ def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:
help="Emit only the features.slash_commands array (for merging "
"into an existing manifest manually).",
)
slack_manifest.add_argument(
slack_messaging = slack_manifest.add_mutually_exclusive_group()
slack_messaging.add_argument(
"--no-assistant",
action="store_true",
help="Omit Slack AI Assistant mode (assistant_view, assistant:write "
@ -65,4 +66,12 @@ def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:
"where bare slash commands (/help, /new) work inline instead of "
"Slack's Assistant thread pane.",
)
slack_messaging.add_argument(
"--agent-view",
action="store_true",
help="Emit Slack's Agent messaging experience (agent_view, "
"app_home_opened + message.im) instead of the legacy assistant_view "
"experience. This changes Slack's app messaging surface and cannot "
"be reversed in Slack after applying the manifest.",
)
slack_parser.set_defaults(func=cmd_slack)

View file

@ -84,6 +84,11 @@ from gateway.status import (
parse_active_agents,
read_runtime_status,
)
from hermes_cli.memory_providers import (
MemoryProvider as DeclaredMemoryProvider,
ProviderField as DeclaredProviderField,
get_memory_provider as get_declared_memory_provider,
)
from utils import env_var_enabled
try:
@ -610,7 +615,30 @@ async def _token_auth_seam(request: Request, call_next):
# ---------------------------------------------------------------------------
# Manual overrides for fields that need select options or custom types
def _memory_provider_options() -> List[str]:
"""Discovered memory providers for the ``memory.provider`` select.
Directory-scan only (no provider imports), so it's safe at module import
time. ``""`` (built-in) is always first; discovery failures degrade to the
bundled defaults rather than dropping the field.
"""
options = ["", "builtin"]
try:
from plugins.memory import list_memory_provider_names
options.extend(list_memory_provider_names())
except Exception:
options.extend(["honcho"])
# Dedupe, preserve order
return list(dict.fromkeys(options))
_SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
"memory.provider": {
"type": "select",
"description": "Memory provider plugin",
"options": _memory_provider_options(),
},
"model": {
"type": "string",
"description": "Default model (e.g. anthropic/claude-sonnet-4.6)",
@ -780,7 +808,7 @@ def _build_schema_from_config(
full_key = f"{prefix}.{key}" if prefix else key
# Skip internal / version keys
if full_key in {"_config_version", "memory.provider"}:
if full_key in {"_config_version"}:
continue
# Category is the first path component for nested keys, or "general"
@ -5146,9 +5174,129 @@ def _require_valid_memory_provider_name(name: str) -> None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
# ---------------------------------------------------------------------------
# Declared surface — curated desktop schema from hermes_cli.memory_providers.
# The desktop panel requests ?surface=declared; the dashboard keeps the raw
# plugin schema. Providers without a declaration render no desktop panel.
# ---------------------------------------------------------------------------
def _declared_provider_file_path(provider: DeclaredMemoryProvider) -> Path:
return get_hermes_home() / provider.name / "config.json"
def _read_declared_provider_file(provider: DeclaredMemoryProvider) -> Dict[str, Any]:
return _read_json_file(_declared_provider_file_path(provider))
def _declared_read_field_value(field: DeclaredProviderField, data: Dict[str, Any]) -> str:
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 _declared_field_is_set(field: DeclaredProviderField, data: Dict[str, Any]) -> bool:
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 _declared_provider_payload(provider: DeclaredMemoryProvider) -> Dict[str, Any]:
data = _read_declared_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"] = _declared_field_is_set(field, data)
else:
value = _declared_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_declared_field_value(field: DeclaredProviderField, raw: str) -> str:
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
def _update_declared_provider_config(provider: DeclaredMemoryProvider, values: Dict[str, Any]) -> None:
existing = _read_declared_provider_file(provider)
json_values: Dict[str, Any] = {}
secrets: Dict[str, str] = {}
for field in provider.fields:
if field.is_secret:
submitted = str(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_declared_field_value(field, str(raw))
path = _declared_provider_file_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)
@app.get("/api/memory/providers/{name}/config")
async def get_memory_provider_config(name: str):
async def get_memory_provider_config(name: str, surface: Optional[str] = None):
_require_valid_memory_provider_name(name)
if surface == "declared":
declared = get_declared_memory_provider(name)
if declared is None:
# Undeclared providers (e.g. builtin, honcho) have no desktop
# config surface; the generic panel renders nothing.
return {"name": name, "label": name, "fields": []}
return _declared_provider_payload(declared)
provider = _load_memory_provider(name)
if provider is None:
# Undeclared providers (e.g. builtin) have no config surface. Return an
@ -5179,8 +5327,22 @@ async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest):
@app.put("/api/memory/providers/{name}/config")
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate):
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate, surface: Optional[str] = None):
_require_valid_memory_provider_name(name)
if surface == "declared":
declared = get_declared_memory_provider(name)
if declared is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
try:
_update_declared_provider_config(declared, body.values or {})
return {"ok": True}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception:
_log.exception("PUT /api/memory/providers/%s/config (declared) failed", name)
raise HTTPException(status_code=500, detail="Internal server error")
provider = _load_memory_provider(name)
if provider is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
@ -5414,6 +5576,7 @@ def get_recommended_default_model(provider: str = ""):
get_pricing_for_provider,
check_nous_free_tier,
partition_nous_models_by_tier,
pick_silent_default_model,
union_with_portal_free_recommendations,
union_with_portal_paid_recommendations,
)
@ -5442,21 +5605,25 @@ def get_recommended_default_model(provider: str = ""):
model_ids, pricing, portal_url
)
model = model_ids[0] if model_ids else ""
model = pick_silent_default_model(model_ids)
return {"provider": "nous", "model": model, "free_tier": bool(free_tier)}
except Exception:
_log.exception("GET /api/model/recommended-default (nous) failed")
return {"provider": "nous", "model": "", "free_tier": None}
# Non-Nous: first curated model for the provider, matching prior behaviour.
# Non-Nous: preferred silent default when the provider's curated list
# carries it, else the first curated model. Aggregator lists lead with the
# priciest Anthropic flagship (claude-fable-5), which must never be the
# model a user lands on without explicitly picking it.
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
from hermes_cli.models import pick_silent_default_model
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 []
return {"provider": slug, "model": models[0] if models else "", "free_tier": None}
models = [str(m) for m in (row.get("models") or [])]
return {"provider": slug, "model": pick_silent_default_model(models), "free_tier": None}
return {"provider": slug, "model": "", "free_tier": None}
except Exception:
_log.exception("GET /api/model/recommended-default failed")
@ -11753,8 +11920,9 @@ def _new_dashboard_backup_path() -> Path:
async def run_backup(body: BackupRequest):
args = ["backup"]
archive: Optional[Path] = None
if body.output:
args.append(body.output.strip())
output = (body.output or "").strip()
if output:
args.extend(["-o", output])
else:
archive = _new_dashboard_backup_path()
try:
@ -11764,7 +11932,7 @@ async def run_backup(body: BackupRequest):
status_code=500,
detail=f"Could not create backup directory: {exc}",
)
args.append(str(archive))
args.extend(["-o", str(archive)])
try:
proc = _spawn_hermes_action(args, "backup")
except Exception as exc:

View file

@ -823,6 +823,192 @@ def parse_reasoning_effort(effort) -> dict | None:
return None
def _canonical_model_variants(model: str) -> list[str]:
"""Generate bounded spelling variants for tolerant override matching.
Model names mix two types of separators:
- **Word separators**: dashes between words (``claude-opus``)
- **Version separators**: dots or dashes between version digits (``4.5``, ``4-5``)
The tricky case is that ``.`` appears in BOTH roles (word sep in some
spellings, version sep in others), so a blanket ``.replace('.', '-')``
is lossy it collapses version dots into dashes and no later step
recovers the canonical form (``claude-opus-4.5``).
Strategy: generate a small set of base forms, then apply version-dot
recovery to EACH of them. This ensures symmetry:
``claude-opus-4.5``, ``claude-opus-4-5``, and ``claude-opus.4.5`` all
produce the same variant set.
Steps:
1. Exact input
2. Dots/dashes cross-substitution on the entire string
3. Version-dot recovery applied to ALL derivatives
4. Strip provider/aggregator prefix bare model variants
5. Apply version-dot recovery to bare derivatives
6. Prepend known provider/aggregator prefixes
Duplicates removed in insertion order (exact always wins).
"""
import re
# Version-dot regexes — digit-separator-digit interconversion
_dash_to_dot = lambda s: re.sub(r'(\d)-(\d)', r'\1.\2', s)
_dot_to_dash = lambda s: re.sub(r'(\d)\.(\d)', r'\1-\2', s)
seen = set()
variants = []
def _add(v):
if v and v not in seen:
seen.add(v)
variants.append(v)
def _add_with_derivatives(s):
"""Add s plus its dots↔dashes and version-dot derivatives."""
_add(s)
all_dashed = s.replace('.', '-')
_add(all_dashed)
all_dotted = s.replace('-', '.')
_add(all_dotted)
# Version-dot recovery on each base form
_add(_dash_to_dot(s))
_add(_dot_to_dash(s))
_add(_dash_to_dot(all_dashed))
_add(_dot_to_dash(all_dotted))
# 1-3. Base variants for the full string
_add_with_derivatives(model)
# Split by / to handle provider prefix
parts = model.split('/')
# 4. Bare model variants (strip provider/aggregator prefix)
if len(parts) >= 2:
bare = parts[-1]
_add_with_derivatives(bare)
# Strip aggregator only (3+ parts)
# e.g. "openrouter/anthropic/claude-opus-4.5" → "anthropic/claude-opus-4.5"
if len(parts) >= 3:
_add_with_derivatives('/'.join(parts[1:]))
# 5. Prepend known provider prefixes to bare variants
known_providers = (
'anthropic', 'openai', 'google', 'openrouter', 'groq', 'mistral',
'xai', 'cohere', 'perplexity', 'together', 'fireworks', 'deepseek',
)
bare_variants = [v for v in variants if '/' not in v]
for v in bare_variants:
for provider in known_providers:
_add(f"{provider}/{v}")
# Prepend aggregator to single-slash variants
single_slash_variants = [v for v in variants if v.count('/') == 1]
known_aggregators = ('openrouter', 'opencode', 'fireworks', 'groq', 'together')
for v in single_slash_variants:
for agg in known_aggregators:
_add(f"{agg}/{v}")
return variants
def resolve_per_model_reasoning_effort(model: str, overrides: dict | None) -> dict | None:
"""Lookup a per-model reasoning_effort override with spelling-tolerance.
Args:
model: The model string (any spelling exact, normalized, bare,
with provider prefix, etc.)
overrides: The dict of per-model overrides from
agent.reasoning_overrides in config.yaml. Keys can be
any sensible spelling of the model name.
Returns:
The parsed reasoning_config dict if a match is found,
None otherwise (caller should fall back to global reasoning_effort).
Resolution order:
1. Exact match
2. Dots dashes variants
3. Strip provider prefix (bare model name only)
4. Strip aggregator prefix (middle segment only)
5. Prepend known aggregator prefixes to bare/single-slash variants
First non-None parse_reasoning_effort result wins.
"""
if not overrides or not isinstance(overrides, dict) or not model:
return None
for variant in _canonical_model_variants(model):
if variant in overrides:
result = parse_reasoning_effort(overrides[variant])
if result is not None:
return result
return None
def resolve_reasoning_config(cfg: dict | None, model: str = "") -> dict | None:
"""Resolve the effective reasoning config for *model* from a config dict.
Single chokepoint for reasoning-effort resolution, shared by every
surface (CLI startup, messaging gateway, Desktop/TUI, cron, ``/model``
switch, fallback activation). Priority:
1. Per-model override from ``agent.reasoning_overrides``
(spelling-tolerant see :func:`resolve_per_model_reasoning_effort`)
2. Global ``agent.reasoning_effort`` the raw value is passed through
so a YAML boolean ``False`` (``reasoning_effort: false``/``off``/
``no``) means "thinking disabled", never silently re-enabled.
Session-scoped overrides (gateway ``/reasoning --session``) are resolved
by the caller BEFORE this function they always win.
Args:
cfg: A loaded config dict (any of the three loaders' shapes — only
the ``agent`` and ``model`` sections are read).
model: The effective model for this surface/session. When empty,
it is derived from the config's ``model`` section (string
form, or a dict's ``default``/``model`` keys).
Returns:
The parsed reasoning config dict, or None when unset/unrecognized
(caller uses the provider default).
"""
cfg = cfg if isinstance(cfg, dict) else {}
agent_cfg = cfg.get("agent")
if not isinstance(agent_cfg, dict):
agent_cfg = {}
if not model:
model_cfg = cfg.get("model")
if isinstance(model_cfg, str):
model = model_cfg.strip()
elif isinstance(model_cfg, dict):
model = str(
model_cfg.get("default") or model_cfg.get("model") or ""
).strip()
else:
model = ""
overrides = agent_cfg.get("reasoning_overrides") or {}
per_model = resolve_per_model_reasoning_effort(model, overrides)
if per_model is not None:
return per_model
# Global fallback — keep the raw value; coercing with ``or ""`` turns a
# YAML boolean False into "", silently re-enabling thinking for users
# who explicitly disabled it.
effort = agent_cfg.get("reasoning_effort", "")
result = parse_reasoning_effort(effort)
if effort and str(effort).strip() and result is None:
import logging
logging.getLogger(__name__).warning(
"Unknown reasoning_effort '%s', using default (medium)", effort
)
return result
def is_termux() -> bool:
"""Return True when running inside a Termux (Android) environment.

View file

@ -356,6 +356,36 @@ def _apply_macos_checkpoint_barrier(conn: sqlite3.Connection) -> None:
pass
def _enforce_macos_synchronous_full(conn: sqlite3.Connection) -> None:
"""Enforce ``PRAGMA synchronous=FULL`` on macOS to prevent btree corruption.
On Darwin, the default ``synchronous=NORMAL`` only calls ``fsync()``,
which Apple's fsync(2) man page explicitly states does *not* guarantee
data-on-platter or write-ordering. During a WAL checkpoint race with
process termination (e.g., launchd shutdown), this can leave the main
DB with half-written btree pages ``btreeInitPage error 11``.
WAL mode's durability guarantee assumes the OS honors fsync barriers;
macOS does not unless we explicitly set ``synchronous=FULL``, which issues
a real ``fsync()`` on every transaction commit. The ``F_FULLFSYNC``
barrier at checkpoint boundaries is handled separately by
:func:`_apply_macos_checkpoint_barrier`.
This function is called after any successful WAL activation (either
from ``apply_wal_with_fallback()`` setting a fresh WAL or when probing
an existing WAL mode). It ensures macOS connections always use FULL
synchronous mode, even if a prior connection set ``synchronous=NORMAL``.
Best-effort: never raises.
"""
if sys.platform != "darwin":
return
try:
conn.execute("PRAGMA synchronous=FULL")
except sqlite3.OperationalError:
pass
def apply_wal_with_fallback(
conn: sqlite3.Connection,
*,
@ -387,6 +417,7 @@ def apply_wal_with_fallback(
current_mode = conn.execute("PRAGMA journal_mode").fetchone()
if current_mode and current_mode[0] == "wal":
_apply_macos_checkpoint_barrier(conn)
_enforce_macos_synchronous_full(conn)
return "wal"
except sqlite3.OperationalError:
pass
@ -394,6 +425,7 @@ def apply_wal_with_fallback(
try:
conn.execute("PRAGMA journal_mode=WAL")
_apply_macos_checkpoint_barrier(conn)
_enforce_macos_synchronous_full(conn)
return "wal"
except sqlite3.OperationalError as exc:
msg = str(exc).lower()
@ -2081,9 +2113,10 @@ class SessionDB:
pruned after process-level restart bugs. New gateway sessions persist
the deterministic ``session_key`` on the durable session row so the
mapping can be rebuilt exactly. Rows ended only by older gateway
cleanup's ``agent_close`` bug are treated as recoverable; explicit
conversation boundaries such as /new, /resume switches, and compression
splits are not.
cleanup's ``agent_close`` bug or a mistaken TUI ``ws_orphan_reap``
(dashboard viewer disconnect before #60609) are treated as recoverable;
explicit conversation boundaries such as /new, /resume switches, and
compression splits are not.
"""
if not session_key:
return None
@ -2093,7 +2126,7 @@ class SessionDB:
SELECT * FROM sessions
WHERE session_key = ?
AND source = ?
AND (ended_at IS NULL OR end_reason = 'agent_close')
AND (ended_at IS NULL OR end_reason IN ('agent_close', 'ws_orphan_reap'))
AND (COALESCE(message_count, 0) > 0 OR EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = sessions.id LIMIT 1
))
@ -2118,7 +2151,7 @@ class SessionDB:
AND COALESCE(chat_id, '') = COALESCE(?, '')
AND COALESCE(chat_type, '') = COALESCE(?, '')
AND COALESCE(thread_id, '') = COALESCE(?, '')
AND (ended_at IS NULL OR end_reason = 'agent_close')
AND (ended_at IS NULL OR end_reason IN ('agent_close', 'ws_orphan_reap'))
AND (COALESCE(message_count, 0) > 0 OR EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = sessions.id LIMIT 1
))

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "Geskatte konteks: ~{count} tokens"
detailed_after_first: "_(Gedetailleerde gebruik beskikbaar na die eerste agent-antwoord)_"
no_data: "Geen gebruiksdata beskikbaar vir hierdie sessie nie."
unknown_subcommand: "Onbekende /usage subopdrag: `{args}`. Probeer `/usage` of `/usage reset [--force]`."
reset_wrong_provider: "Gebankte gebruikslimiet-terugstellings is slegs beskikbaar op die openai-codex verskaffer. Skakel eers oor met `/model`."
credits:
not_logged_in: "Nie by Nous Portal aangemeld nie. Meld aan om jou kredietsaldo te sien en op te laai."

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "Geschätzter Kontext: ~{count} Tokens"
detailed_after_first: "_(Detaillierte Nutzung nach der ersten Agentenantwort verfügbar)_"
no_data: "Keine Nutzungsdaten für diese Sitzung verfügbar."
unknown_subcommand: "Unbekannter /usage-Unterbefehl: `{args}`. Versuche `/usage` oder `/usage reset [--force]`."
reset_wrong_provider: "Gespeicherte Limit-Resets sind nur mit dem openai-codex-Anbieter verfügbar. Wechsle zuerst mit `/model`."
credits:
not_logged_in: "Nicht bei Nous Portal angemeldet. Melde dich an, um dein Guthaben zu sehen und aufzuladen."

View file

@ -376,6 +376,8 @@ gateway:
label_estimated_context: "Estimated context: ~{count} tokens"
detailed_after_first: "_(Detailed usage available after the first agent response)_"
no_data: "No usage data available for this session."
unknown_subcommand: "Unknown /usage subcommand: `{args}`. Try `/usage` or `/usage reset [--force]`."
reset_wrong_provider: "Banked usage resets are only available on the openai-codex provider. Switch with `/model` first."
credits:
not_logged_in: "Not logged into Nous Portal. Log in to see your credit balance and top up."

View file

@ -361,6 +361,8 @@ gateway:
label_estimated_context: "Contexto estimado: ~{count} tokens"
detailed_after_first: "_(Uso detallado disponible tras la primera respuesta del agente)_"
no_data: "No hay datos de uso disponibles para esta sesión."
unknown_subcommand: "Subcomando /usage desconocido: `{args}`. Prueba `/usage` o `/usage reset [--force]`."
reset_wrong_provider: "Los reinicios de límite acumulados solo están disponibles con el proveedor openai-codex. Cambia primero con `/model`."
credits:
not_logged_in: "No has iniciado sesión en Nous Portal. Inicia sesión para ver tu saldo de créditos y recargar."

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "Contexte estimé : ~{count} jetons"
detailed_after_first: "_(Utilisation détaillée disponible après la première réponse de l'agent)_"
no_data: "Aucune donnée d'utilisation disponible pour cette session."
unknown_subcommand: "Sous-commande /usage inconnue : `{args}`. Essayez `/usage` ou `/usage reset [--force]`."
reset_wrong_provider: "Les réinitialisations de limite en réserve ne sont disponibles qu'avec le fournisseur openai-codex. Changez d'abord avec `/model`."
credits:
not_logged_in: "Non connecté à Nous Portal. Connecte-toi pour voir ton solde de crédits et recharger."

View file

@ -368,6 +368,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "Comhthéacs measta: ~{count} comhartha"
detailed_after_first: "_(Úsáid mhionsonraithe ar fáil tar éis chéad fhreagra an ghníomhaire)_"
no_data: "Níl aon sonraí úsáide ar fáil don seisiún seo."
unknown_subcommand: "Fo-ordú /usage anaithnid: `{args}`. Bain triail as `/usage` nó `/usage reset [--force]`."
reset_wrong_provider: "Níl athshocruithe teorann bainc ar fáil ach ar an soláthraí openai-codex. Athraigh le `/model` ar dtús."
credits:
not_logged_in: "Níl tú logáilte isteach i Nous Portal. Logáil isteach chun d'iarmhéid creidmheasa a fheiceáil agus breis a chur leis."

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "Becsült kontextus: ~{count} token"
detailed_after_first: "_(A részletes használat az első ügynökválasz után érhető el)_"
no_data: "Ehhez a munkamenethez nincsenek elérhető használati adatok."
unknown_subcommand: "Ismeretlen /usage alparancs: `{args}`. Próbáld: `/usage` vagy `/usage reset [--force]`."
reset_wrong_provider: "A félretett limit-visszaállítások csak az openai-codex szolgáltatónál érhetők el. Válts először a `/model` paranccsal."
credits:
not_logged_in: "Nincs bejelentkezve a Nous Portalra. Jelentkezz be a kreditegyenleg megtekintéséhez és feltöltéséhez."

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "Contesto stimato: ~{count} token"
detailed_after_first: "_(L'uso dettagliato sarà disponibile dopo la prima risposta dell'agente)_"
no_data: "Nessun dato di utilizzo disponibile per questa sessione."
unknown_subcommand: "Sottocomando /usage sconosciuto: `{args}`. Prova `/usage` o `/usage reset [--force]`."
reset_wrong_provider: "I reset dei limiti accumulati sono disponibili solo con il provider openai-codex. Cambia prima con `/model`."
credits:
not_logged_in: "Non hai effettuato l'accesso a Nous Portal. Accedi per vedere il saldo dei crediti e ricaricare."

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "推定コンテキスト: ~{count} トークン"
detailed_after_first: "_(詳細な使用状況は最初のエージェント応答後に利用可能)_"
no_data: "このセッションの使用データはありません。"
unknown_subcommand: "不明な /usage サブコマンド: `{args}`。`/usage` または `/usage reset [--force]` をお試しください。"
reset_wrong_provider: "バンクされた使用制限リセットは openai-codex プロバイダーでのみ利用できます。まず `/model` で切り替えてください。"
credits:
not_logged_in: "Nous Portal にログインしていません。ログインすると残高の確認とチャージができます。"

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "예상 컨텍스트: 약 {count} 토큰"
detailed_after_first: "_(자세한 사용량은 첫 에이전트 응답 이후 확인할 수 있습니다)_"
no_data: "이 세션에 사용 가능한 사용량 데이터가 없습니다."
unknown_subcommand: "알 수 없는 /usage 하위 명령: `{args}`. `/usage` 또는 `/usage reset [--force]`를 사용해 보세요."
reset_wrong_provider: "적립된 사용량 한도 초기화는 openai-codex 공급자에서만 사용할 수 있습니다. 먼저 `/model`로 전환하세요."
credits:
not_logged_in: "Nous Portal에 로그인되어 있지 않습니다. 로그인하면 크레딧 잔액 확인 및 충전을 할 수 있습니다."

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "Contexto estimado: ~{count} tokens"
detailed_after_first: "_(Utilização detalhada disponível após a primeira resposta do agente)_"
no_data: "Não há dados de utilização disponíveis para esta sessão."
unknown_subcommand: "Subcomando /usage desconhecido: `{args}`. Tente `/usage` ou `/usage reset [--force]`."
reset_wrong_provider: "Os resets de limite acumulados só estão disponíveis no provedor openai-codex. Troque primeiro com `/model`."
credits:
not_logged_in: "Você não está conectado ao Nous Portal. Faça login para ver seu saldo de créditos e recarregar."

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "Ориентировочный контекст: ~{count} токенов"
detailed_after_first: "_(Подробное использование доступно после первого ответа агента)_"
no_data: "Данные об использовании для этого сеанса отсутствуют."
unknown_subcommand: "Неизвестная подкоманда /usage: `{args}`. Попробуйте `/usage` или `/usage reset [--force]`."
reset_wrong_provider: "Накопленные сбросы лимитов доступны только с провайдером openai-codex. Сначала переключитесь через `/model`."
credits:
not_logged_in: "Вы не вошли в Nous Portal. Войдите, чтобы увидеть баланс кредитов и пополнить его."

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "Tahmini bağlam: ~{count} token"
detailed_after_first: "_(Ayrıntılı kullanım, ilk ajan yanıtından sonra kullanılabilir)_"
no_data: "Bu oturum için kullanım verisi yok."
unknown_subcommand: "Bilinmeyen /usage alt komutu: `{args}`. `/usage` veya `/usage reset [--force]` deneyin."
reset_wrong_provider: "Biriktirilen limit sıfırlamaları yalnızca openai-codex sağlayıcısında kullanılabilir. Önce `/model` ile geçiş yapın."
credits:
not_logged_in: "Nous Portal'a giriş yapılmadı. Bakiyenizi görmek ve yükleme yapmak için giriş yapın."

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "Орієнтовний контекст: ~{count} токенів"
detailed_after_first: "_(Детальне використання доступне після першої відповіді агента)_"
no_data: "Дані про використання для цього сеансу відсутні."
unknown_subcommand: "Невідома підкоманда /usage: `{args}`. Спробуйте `/usage` або `/usage reset [--force]`."
reset_wrong_provider: "Накопичені скидання лімітів доступні лише з провайдером openai-codex. Спочатку перемкніться через `/model`."
credits:
not_logged_in: "Ви не ввійшли в Nous Portal. Увійдіть, щоб переглянути баланс кредитів і поповнити його."

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "預估上下文:~{count} 個 token"
detailed_after_first: "_首次代理回應後可檢視詳細使用情況_"
no_data: "此工作階段沒有可用的使用資料。"
unknown_subcommand: "未知的 /usage 子命令:`{args}`。請嘗試 `/usage` 或 `/usage reset [--force]`。"
reset_wrong_provider: "儲存的用量重設僅在 openai-codex 提供者上可用。請先使用 `/model` 切換。"
credits:
not_logged_in: "未登入 Nous Portal。登入後即可查看額度餘額並儲值。"

View file

@ -364,6 +364,8 @@ Future messages in this room will use that transcript until `/reset` or another
label_estimated_context: "估计上下文:~{count} 个令牌"
detailed_after_first: "_首次代理响应后可查看详细使用情况_"
no_data: "此会话暂无使用数据。"
unknown_subcommand: "未知的 /usage 子命令:`{args}`。请尝试 `/usage` 或 `/usage reset [--force]`。"
reset_wrong_provider: "存储的用量重置仅在 openai-codex 提供商上可用。请先使用 `/model` 切换。"
credits:
not_logged_in: "未登录 Nous Portal。登录后即可查看额度余额并充值。"

1288
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -38,7 +38,6 @@
},
"overrides": {
"lodash": "4.18.1",
"@assistant-ui/store": "0.2.13",
"yauzl": "^3.3.1"
},
"engines": {

View file

@ -19,6 +19,7 @@ hand-roll JWT verification.
from __future__ import annotations
import logging
import threading
from typing import Any, Callable, Dict, Optional
logger = logging.getLogger("cron.chronos.verify")
@ -27,6 +28,44 @@ logger = logging.getLogger("cron.chronos.verify")
# JWT (without this claim) must NOT be replayable against /api/cron/fire.
_FIRE_PURPOSE = "cron_fire"
# Process-wide cache of PyJWKClient instances, keyed by JWKS URL.
#
# WHY THIS EXISTS: a PyJWKClient caches the fetched JWKS (signing keys) on the
# INSTANCE. Constructing a fresh client per fire therefore threw that cache
# away and forced a synchronous JWKS HTTP GET to the portal on EVERY fire. Under
# a burst of concurrent fires (an instance with several cron jobs firing in the
# same window) that fanned out into N simultaneous JWKS fetches, which the
# portal rate-limited (HTTP 403) — verification then failed and the agent
# answered 401. When a fetch was merely slow rather than rate-limited, it blocked
# the event loop long enough that the fire webhook could not return its 202
# before the relay's 30s timeout (observed in prod as relay 504s concentrated on
# high-job-count instances). Reusing one client per URL keeps the signing keys
# cached (NAS keys rotate rarely), so the steady state is zero JWKS fetches per
# fire. See docs/chronos-managed-cron-contract.md and the betterstack triage.
_JWK_CLIENTS: Dict[str, Any] = {}
_JWK_CLIENTS_LOCK = threading.Lock()
def _get_jwk_client(jwks_url: str) -> Any:
"""Return a process-cached PyJWKClient for ``jwks_url`` (one per URL).
PyJWKClient does its own key caching internally (``cache_keys``/``lifespan``);
the whole point here is to reuse the SAME instance across fires so that cache
is actually hit instead of discarded. Double-checked-locked so concurrent
fires resolve to a single shared client without racing.
"""
client = _JWK_CLIENTS.get(jwks_url)
if client is not None:
return client
with _JWK_CLIENTS_LOCK:
client = _JWK_CLIENTS.get(jwks_url)
if client is None:
from jwt import PyJWKClient
client = PyJWKClient(jwks_url)
_JWK_CLIENTS[jwks_url] = client
return client
def verify_nas_fire_token(
*,
@ -60,12 +99,15 @@ def verify_nas_fire_token(
try:
import jwt
from jwt import PyJWKClient
# Resolve the signing key from the JWKS endpoint by the token's kid.
signing_key = None
if jwks_or_key.startswith("http://") or jwks_or_key.startswith("https://"):
jwk_client = PyJWKClient(jwks_or_key)
# Reuse a process-cached client so the JWKS fetch is amortised across
# fires (a fresh client per fire re-fetched the JWKS every time and,
# under concurrent fires, tripped the portal's rate limit → 403 →
# 401, or blocked the event loop past the relay's 30s timeout → 504).
jwk_client = _get_jwk_client(jwks_or_key)
signing_key = jwk_client.get_signing_key_from_jwt(token).key
else:
# A PEM public key passed inline (test / pinned-key deployments).

View file

@ -143,6 +143,17 @@ def find_provider_dir(name: str) -> Optional[Path]:
# Public API
# ---------------------------------------------------------------------------
def list_memory_provider_names() -> List[str]:
"""Cheap name-only listing of discoverable memory providers.
Unlike :func:`discover_memory_providers`, this does NOT import provider
modules or run availability checks it's a directory scan only, safe to
call at module-import time (e.g. when building the dashboard config
schema).
"""
return sorted({name for name, _ in _iter_provider_dirs()})
def discover_memory_providers() -> List[Tuple[str, str, bool]]:
"""Scan bundled and user-installed directories for available providers.

View file

@ -51,7 +51,7 @@ nous = NousProfile(
"hermes-3-405b",
"hermes-3-70b",
),
base_url="https://inference.nousresearch.com/v1",
base_url="https://inference-api.nousresearch.com/v1",
auth_type="oauth_device_code",
)

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