mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
Merge origin/main into bb/contrib-areas
This commit is contained in:
commit
db488df1c3
219 changed files with 17229 additions and 2062 deletions
|
|
@ -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
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
108
agent/kanban_stop.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 %s — treating 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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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>[] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ function settledClarifyProps(
|
|||
args,
|
||||
argsText: JSON.stringify(args),
|
||||
isError: false,
|
||||
respondToApproval: vi.fn(),
|
||||
result,
|
||||
resume: vi.fn(),
|
||||
status: { type: 'complete' },
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -297,6 +297,8 @@ export const ja = defineLocale({
|
|||
`アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`,
|
||||
translucencyTitle: 'ウィンドウの透過',
|
||||
translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。',
|
||||
backdropTitle: 'チャット背景',
|
||||
backdropDesc: '会話の背後に表示される淡い彫像の画像。',
|
||||
embedsTitle: 'インライン埋め込み',
|
||||
embedsDesc:
|
||||
'リッチプレビューは第三者サイト(YouTube、X など)から読み込まれます。確認は許可するまでプレースホルダーを表示し、常には自動で読み込み、オフはリンクのままにします。',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -290,6 +290,8 @@ export const zhHant = defineLocale({
|
|||
`縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`,
|
||||
translucencyTitle: '視窗透明',
|
||||
translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。',
|
||||
backdropTitle: '聊天背景',
|
||||
backdropDesc: '對話後方那張淡淡的雕像圖片。',
|
||||
embedsTitle: '內嵌預覽',
|
||||
embedsDesc:
|
||||
'豐富預覽會從第三方網站(YouTube、X 等)載入。詢問會在你允許前顯示佔位符;一律會自動載入;關閉則保留純連結。',
|
||||
|
|
|
|||
|
|
@ -399,6 +399,8 @@ export const zh: Translations = {
|
|||
`缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`,
|
||||
translucencyTitle: '窗口透明',
|
||||
translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。',
|
||||
backdropTitle: '聊天背景',
|
||||
backdropDesc: '对话后方那张淡淡的雕像图片。',
|
||||
embedsTitle: '内嵌预览',
|
||||
embedsDesc:
|
||||
'富预览会从第三方网站(YouTube、X 等)加载。询问会在你允许前显示占位符;总是会自动加载;关闭则保留纯链接。',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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))
|
||||
}
|
||||
14
apps/desktop/src/store/backdrop.ts
Normal file
14
apps/desktop/src/store/backdrop.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
243
cli.py
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
(oldest→newest, 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")),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
147
gateway/run.py
147
gateway/run.py
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 にログインしていません。ログインすると残高の確認とチャージができます。"
|
||||
|
|
|
|||
|
|
@ -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에 로그인되어 있지 않습니다. 로그인하면 크레딧 잔액 확인 및 충전을 할 수 있습니다."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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. Войдите, чтобы увидеть баланс кредитов и пополнить его."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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. Увійдіть, щоб переглянути баланс кредитів і поповнити його."
|
||||
|
|
|
|||
|
|
@ -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。登入後即可查看額度餘額並儲值。"
|
||||
|
|
|
|||
|
|
@ -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
1288
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -38,7 +38,6 @@
|
|||
},
|
||||
"overrides": {
|
||||
"lodash": "4.18.1",
|
||||
"@assistant-ui/store": "0.2.13",
|
||||
"yauzl": "^3.3.1"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue