Merge remote-tracking branch 'origin/main' into wake-toggle-config

# Conflicts:
#	tests/test_tui_gateway_server.py
#	tui_gateway/server.py
This commit is contained in:
Teknium 2026-07-28 12:37:35 -07:00
commit 0cf58de85e
No known key found for this signature in database
251 changed files with 27112 additions and 3467 deletions

View file

@ -2475,7 +2475,8 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
tool_call_id: Optional[str] = None, messages: list = None,
pre_tool_block_checked: bool = False,
skip_tool_request_middleware: bool = False,
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None) -> str:
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
skip_tool_execution_middleware: bool = False) -> str:
"""Invoke a single tool and return the result string. No display logic.
Handles both agent-level tools (todo, memory, etc.) and registry-dispatched
@ -2654,8 +2655,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
else:
def _execute(next_args: dict) -> Any:
return _ra().handle_function_call(
function_name, next_args, effective_task_id,
dispatch_kwargs = dict(
tool_call_id=tool_call_id,
session_id=agent.session_id or "",
turn_id=getattr(agent, "_current_turn_id", "") or "",
@ -2667,6 +2667,17 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
tool_request_middleware_trace=list(_tool_middleware_trace),
)
if skip_tool_execution_middleware:
dispatch_kwargs["skip_tool_execution_middleware"] = True
return _ra().handle_function_call(
function_name,
next_args,
effective_task_id,
**dispatch_kwargs,
)
if skip_tool_execution_middleware:
return _execute(function_args)
from hermes_cli.middleware import run_tool_execution_middleware

View file

@ -42,6 +42,7 @@ Payment / credit exhaustion fallback:
import contextlib
import contextvars
import functools
import hashlib
import inspect
import json
@ -50,9 +51,10 @@ import os
import re
import threading
import time
import uuid
from pathlib import Path # noqa: F401 — used by test mocks
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
from urllib.parse import urlparse, parse_qs, urlunparse
# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the
@ -2492,6 +2494,167 @@ _RUNTIME_MAIN_AUTH_MODE: str = ""
_RUNTIME_MAIN_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = (
contextvars.ContextVar("auxiliary_runtime_main", default=None)
)
_RELAY_AUX_CALL_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = (
contextvars.ContextVar("auxiliary_relay_call", default=None)
)
def _relay_auxiliary_call(callback):
"""Give every physical retry in one auxiliary call a shared Relay identity."""
@functools.wraps(callback)
def wrapped(*args, **kwargs):
task = args[0] if args else kwargs.get("task")
token = _RELAY_AUX_CALL_CONTEXT.set({
"task": str(task or "unknown"),
"request_id": f"aux-{uuid.uuid4().hex}",
"attempt_count": 0,
"provider": "",
"model": "",
"api_mode": "chat_completions",
})
try:
return callback(*args, **kwargs)
except BaseException:
_fail_relay_auxiliary_call()
raise
finally:
_RELAY_AUX_CALL_CONTEXT.reset(token)
return wrapped
def _relay_auxiliary_call_async(callback):
"""Async counterpart to :func:`_relay_auxiliary_call`."""
@functools.wraps(callback)
async def wrapped(*args, **kwargs):
task = args[0] if args else kwargs.get("task")
token = _RELAY_AUX_CALL_CONTEXT.set({
"task": str(task or "unknown"),
"request_id": f"aux-{uuid.uuid4().hex}",
"attempt_count": 0,
"provider": "",
"model": "",
"api_mode": "chat_completions",
})
try:
return await callback(*args, **kwargs)
except BaseException:
_fail_relay_auxiliary_call()
raise
finally:
_RELAY_AUX_CALL_CONTEXT.reset(token)
return wrapped
def _set_relay_auxiliary_route(
provider: str | None,
model: str | None,
api_mode: str | None,
) -> None:
context = _RELAY_AUX_CALL_CONTEXT.get()
if context is None:
return
context["provider"] = str(provider or "auxiliary")
context["model"] = str(model or "unknown")
context["api_mode"] = str(api_mode or "chat_completions")
def _relay_auxiliary_metadata(
*,
provider: str | None = None,
api_mode: str | None = None,
) -> tuple[str, str, dict[str, Any]] | None:
context = _RELAY_AUX_CALL_CONTEXT.get()
if context is None:
return None
attempt_count = int(context.get("attempt_count") or 0)
context["attempt_count"] = attempt_count + 1
provider_name = str(provider or context.get("provider") or "auxiliary")
model_name = str(context.get("model") or "unknown")
return provider_name, model_name, {
"api_mode": str(api_mode or context.get("api_mode") or "chat_completions"),
"api_request_id": str(context["request_id"]),
"call_role": f"auxiliary:{context['task']}",
"retry_count": attempt_count,
"auxiliary_task": str(context["task"]),
}
def _relay_sync_completion(
client: Any,
kwargs: dict[str, Any],
*,
provider: str | None = None,
api_mode: str | None = None,
create: Callable[[dict[str, Any]], Any] | None = None,
) -> Any:
callback = create or (lambda request: client.chat.completions.create(**request))
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
if route is None:
return callback(kwargs)
provider_name, fallback_model, metadata = route
from agent import relay_llm
return relay_llm.execute_current(
kwargs,
callback,
name=provider_name,
model_name=str(kwargs.get("model") or fallback_model),
metadata=metadata,
defer_logical_completion=True,
)
async def _relay_async_completion(
client: Any,
kwargs: dict[str, Any],
*,
provider: str | None = None,
api_mode: str | None = None,
create: Callable[[dict[str, Any]], Any] | None = None,
) -> Any:
callback = create or (lambda request: client.chat.completions.create(**request))
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
if route is None:
return await callback(kwargs)
provider_name, fallback_model, metadata = route
from agent import relay_llm
return await relay_llm.execute_current_async(
kwargs,
callback,
name=provider_name,
model_name=str(kwargs.get("model") or fallback_model),
metadata=metadata,
defer_logical_completion=True,
)
def _relay_sync_stream(
client: Any,
kwargs: dict[str, Any],
*,
provider: str | None = None,
api_mode: str | None = None,
) -> Any:
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
if route is None:
return client.chat.completions.create(**kwargs)
provider_name, fallback_model, metadata = route
from agent import relay_llm
return relay_llm.stream_current(
kwargs,
lambda request: client.chat.completions.create(**request),
name=provider_name,
model_name=str(kwargs.get("model") or fallback_model),
finalizer=dict,
metadata=metadata,
)
_RUNTIME_MAIN_COMPAT_SNAPSHOT: Tuple[Any, ...] = ("", "", "", "", "", "")
_RUNTIME_MAIN_COMPAT_LOCK = threading.Lock()
@ -3801,7 +3964,13 @@ def _retry_same_provider_sync(
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task,
_relay_sync_completion(
retry_client,
retry_kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
),
task,
)
@ -3866,7 +4035,13 @@ async def _retry_same_provider_async(
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task,
await _relay_async_completion(
retry_client,
retry_kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
),
task,
)
@ -4071,7 +4246,7 @@ def _call_fallback_candidate_sync(
base_url=fb_base, task=task)
try:
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
_relay_sync_completion(fb_client, fb_kwargs, provider=fb_label), task)
except Exception as fb_err:
if not _is_auth_error(fb_err):
raise
@ -4088,7 +4263,13 @@ def _call_fallback_candidate_sync(
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
try:
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task)
_relay_sync_completion(
retry_client,
retry_kwargs,
provider=fb_provider,
),
task,
)
except Exception as retry_err:
if not _is_auth_error(retry_err):
raise
@ -4137,7 +4318,13 @@ async def _call_fallback_candidate_async(
base_url=fb_base, task=task)
try:
return _validate_llm_response(
await fb_client.chat.completions.create(**fb_kwargs), task)
await _relay_async_completion(
fb_client,
fb_kwargs,
provider=fb_label,
),
task,
)
except Exception as fb_err:
if not _is_auth_error(fb_err):
raise
@ -4155,7 +4342,13 @@ async def _call_fallback_candidate_async(
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
try:
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task)
await _relay_async_completion(
retry_client,
retry_kwargs,
provider=fb_provider,
),
task,
)
except Exception as retry_err:
if not _is_auth_error(retry_err):
raise
@ -7270,6 +7463,7 @@ def _validate_llm_response(
except (AttributeError, TypeError, IndexError) as exc:
recovered = _recover_aux_response_message(response)
if recovered is not None:
_complete_relay_auxiliary_call()
return recovered
response_type = type(response).__name__
response_preview = str(response)[:120]
@ -7279,9 +7473,34 @@ def _validate_llm_response(
f"Expected object with .choices[0].message — check provider "
f"adapter or custom endpoint compatibility."
) from exc
_complete_relay_auxiliary_call()
return response
def _complete_relay_auxiliary_call(*, outcome: str = "success") -> None:
"""Close one auxiliary logical call after acceptance or terminal failure."""
context = _RELAY_AUX_CALL_CONTEXT.get()
if context is None:
return
from agent import relay_llm
relay_llm.complete_logical_call(
str(context.get("request_id") or ""),
outcome=outcome,
)
def _fail_relay_auxiliary_call() -> None:
"""Close a terminally failed call without replacing its original error."""
try:
_complete_relay_auxiliary_call(outcome="failed")
except Exception:
logger.warning(
"Relay auxiliary failure finalization failed",
exc_info=True,
)
def _recover_aux_response_message(response: Any) -> Optional[Any]:
"""Synthesize chat-completions shape from Responses-style text fields.
@ -7680,6 +7899,7 @@ async def _acreate_with_stream(
)
@_relay_auxiliary_call
def call_llm(
task: str = None,
*,
@ -7820,6 +8040,11 @@ def call_llm(
f"Run: hermes setup")
effective_timeout = _effective_aux_timeout(task, timeout)
_set_relay_auxiliary_route(
resolved_provider,
final_model,
resolved_api_mode,
)
# Log what we're about to do — makes auxiliary operations visible
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
@ -7857,7 +8082,12 @@ def call_llm(
kwargs["stream"] = True
if stream_options:
kwargs["stream_options"] = stream_options
return client.chat.completions.create(**kwargs)
return _relay_sync_stream(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
)
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
# then payment fallback.
@ -7880,10 +8110,18 @@ def call_llm(
# for the transient retry every auxiliary task shares. (PR #16587)
try:
return _validate_llm_response(
_create_with_progress(
client, kwargs, task,
force_stream=_provider_requires_stream(
resolved_provider, _base_info or resolved_base_url,
_relay_sync_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
create=lambda request: _create_with_progress(
client,
request,
task,
force_stream=_provider_requires_stream(
resolved_provider, _base_info or resolved_base_url,
),
),
),
task,
@ -7919,10 +8157,19 @@ def call_llm(
time.sleep(_backoff)
try:
return _validate_llm_response(
_create_with_progress(
client, kwargs, task,
force_stream=_provider_requires_stream(
resolved_provider, _base_info or resolved_base_url,
_relay_sync_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
create=lambda request: _create_with_progress(
client,
request,
task,
force_stream=_provider_requires_stream(
resolved_provider,
_base_info or resolved_base_url,
),
),
),
task)
@ -7942,7 +8189,12 @@ def call_llm(
)
try:
return _validate_llm_response(
client.chat.completions.create(**retry_kwargs), task)
_relay_sync_completion(
client,
retry_kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
retry_err_str = str(retry_err)
# If retry still fails, fall through to the max_tokens /
@ -7980,7 +8232,12 @@ def call_llm(
kwargs.pop("max_completion_tokens", None)
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
_relay_sync_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
# If the max_tokens retry also hits a payment or connection
# error, fall through to the fallback chain below.
@ -8010,7 +8267,12 @@ def call_llm(
kwargs["model"] = healed_model
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
_relay_sync_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
first_err = retry_err
@ -8043,7 +8305,12 @@ def call_llm(
kwargs["model"] = refreshed_model
try:
return _validate_llm_response(
refreshed_client.chat.completions.create(**kwargs), task)
_relay_sync_completion(
refreshed_client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
if not (
_is_auth_error(retry_err)
@ -8071,7 +8338,12 @@ def call_llm(
if refreshed_model and refreshed_model != kwargs.get("model"):
kwargs["model"] = refreshed_model
return _validate_llm_response(
refreshed_client.chat.completions.create(**kwargs), task)
_relay_sync_completion(
refreshed_client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
# ── Auth refresh retry ───────────────────────────────────────
auth_refresh_provider = _auth_refresh_provider_for_route(
@ -8121,7 +8393,12 @@ def call_llm(
if _is_rate_limit_error(first_err) and not _is_payment_error(first_err):
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
_relay_sync_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)):
raise
@ -8379,6 +8656,7 @@ def extract_content_or_reasoning(response) -> str:
return ""
@_relay_auxiliary_call_async
async def async_call_llm(
task: str = None,
*,
@ -8470,6 +8748,11 @@ async def async_call_llm(
f"Run: hermes setup")
effective_timeout = _effective_aux_timeout(task, timeout)
_set_relay_auxiliary_route(
resolved_provider,
final_model,
resolved_api_mode,
)
# Pass the client's actual base_url (not just resolved_base_url) so
# endpoint-specific temperature overrides can distinguish
@ -8508,7 +8791,14 @@ async def async_call_llm(
try:
return _validate_llm_response(
await _acreate(kwargs), task,
await _relay_async_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
create=_acreate,
),
task,
provider=resolved_provider, base_url=_client_base)
except Exception as transient_err:
if not _is_transient_transport_error(transient_err):
@ -8529,7 +8819,14 @@ async def async_call_llm(
task or "call", transient_err,
)
return _validate_llm_response(
await _acreate(kwargs), task)
await _relay_async_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
create=_acreate,
),
task)
except Exception as first_err:
if "temperature" in kwargs and _is_unsupported_temperature_error(first_err):
retry_kwargs = dict(kwargs)
@ -8540,7 +8837,12 @@ async def async_call_llm(
)
try:
return _validate_llm_response(
await client.chat.completions.create(**retry_kwargs), task)
await _relay_async_completion(
client,
retry_kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
retry_err_str = str(retry_err)
if not (
@ -8574,7 +8876,12 @@ async def async_call_llm(
kwargs.pop("max_completion_tokens", None)
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
await _relay_async_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
# If the max_tokens retry also hits a payment or connection
# error, fall through to the fallback chain below.
@ -8603,7 +8910,12 @@ async def async_call_llm(
kwargs["model"] = healed_model
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
await _relay_async_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
first_err = retry_err
@ -8635,7 +8947,12 @@ async def async_call_llm(
kwargs["model"] = refreshed_model
try:
return _validate_llm_response(
await refreshed_client.chat.completions.create(**kwargs), task)
await _relay_async_completion(
refreshed_client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
if not (
_is_auth_error(retry_err)
@ -8662,7 +8979,12 @@ async def async_call_llm(
if refreshed_model and refreshed_model != kwargs.get("model"):
kwargs["model"] = refreshed_model
return _validate_llm_response(
await refreshed_client.chat.completions.create(**kwargs), task)
await _relay_async_completion(
refreshed_client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
# ── Auth refresh retry (mirrors sync call_llm) ───────────────
auth_refresh_provider = _auth_refresh_provider_for_route(
@ -8706,7 +9028,12 @@ async def async_call_llm(
if _is_rate_limit_error(first_err) and not _is_payment_error(first_err):
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
await _relay_async_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)):
raise

View file

@ -15,6 +15,7 @@ sites unchanged. Symbols that tests patch on ``run_agent`` (e.g.
from __future__ import annotations
import contextvars
import json
import logging
import math
@ -57,6 +58,12 @@ _OPENROUTER_PROVIDER_SORT_VALUES = {"throughput", "latency", "price"}
_FALLBACK_EXHAUSTED_COOLDOWN_S = 5.0
def _context_thread_target(callback):
"""Bind a no-argument thread target to the caller's ContextVars."""
context = contextvars.copy_context()
return lambda: context.run(callback)
def _ra():
"""Lazy ``run_agent`` reference.
@ -840,7 +847,7 @@ def interruptible_api_call(agent, api_kwargs: dict):
_call_start = time.time()
agent._touch_activity("waiting for non-streaming API response")
t = threading.Thread(target=_call, daemon=True)
t = threading.Thread(target=_context_thread_target(_call), daemon=True)
t.start()
_poll_count = 0
while t.is_alive():
@ -2019,6 +2026,28 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
"""Request a summary when max iterations are reached. Returns the final response text."""
print(f"⚠️ Reached maximum iterations ({agent.max_iterations}). Requesting summary...")
summary_api_request_id = f"iteration-summary:{uuid.uuid4()}"
summary_call_outcome = "failed"
def _managed_summary_call(request, callback, *, retry_count: int):
from agent import relay_llm
return relay_llm.execute_current(
request,
callback,
name=str(getattr(agent, "provider", "") or "provider"),
model_name=str(getattr(agent, "model", "") or ""),
metadata={
"api_mode": str(
getattr(agent, "api_mode", "") or "chat_completions"
),
"api_request_id": summary_api_request_id,
"call_role": "iteration_summary",
"retry_count": retry_count,
},
defer_logical_completion=True,
)
summary_request = (
"You've reached the maximum number of tool-calling iterations allowed. "
"Please provide a final response summarizing what you've found and accomplished so far, "
@ -2204,17 +2233,33 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
if agent.api_mode == "anthropic_messages":
_tsum = agent._get_transport()
_ant_kw = _tsum.build_kwargs(model=agent.model, messages=api_messages, tools=None,
max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config,
is_oauth=agent._is_anthropic_oauth,
preserve_dots=agent._anthropic_preserve_dots(),
base_url=getattr(agent, "_anthropic_base_url", None))
_ant_kw = _tsum.build_kwargs(
model=agent.model,
messages=api_messages,
tools=None,
max_tokens=agent.max_tokens,
reasoning_config=agent.reasoning_config,
is_oauth=agent._is_anthropic_oauth,
preserve_dots=agent._anthropic_preserve_dots(),
base_url=getattr(agent, "_anthropic_base_url", None),
)
_ant_kw = _merge_nous_portal_messages_extra_body(agent, _ant_kw)
summary_response = agent._anthropic_messages_create(_ant_kw)
summary_response = _managed_summary_call(
_ant_kw,
agent._anthropic_messages_create,
retry_count=0,
)
_summary_result = _tsum.normalize_response(summary_response, strip_tool_prefix=agent._is_anthropic_oauth)
final_response = (_summary_result.content or "").strip()
else:
summary_response = agent._ensure_primary_openai_client(reason="iteration_limit_summary").chat.completions.create(**summary_kwargs)
summary_client = agent._ensure_primary_openai_client(
reason="iteration_limit_summary"
)
summary_response = _managed_summary_call(
summary_kwargs,
lambda request: summary_client.chat.completions.create(**request),
retry_count=0,
)
_summary_result = agent._get_transport().normalize_response(summary_response)
final_response = (_summary_result.content or "").strip()
@ -2222,6 +2267,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
if "<think>" in final_response:
final_response = re.sub(r'<think>.*?</think>\s*', '', final_response, flags=re.DOTALL).strip()
if final_response:
summary_call_outcome = "success"
messages.append({"role": "assistant", "content": final_response})
else:
final_response = "I reached the iteration limit and couldn't generate a summary."
@ -2236,13 +2282,22 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
final_response = (_cnr_retry.content or "").strip()
elif agent.api_mode == "anthropic_messages":
_tretry = agent._get_transport()
_ant_kw2 = _tretry.build_kwargs(model=agent.model, messages=api_messages, tools=None,
is_oauth=agent._is_anthropic_oauth,
max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config,
preserve_dots=agent._anthropic_preserve_dots(),
base_url=getattr(agent, "_anthropic_base_url", None))
_ant_kw2 = _tretry.build_kwargs(
model=agent.model,
messages=api_messages,
tools=None,
is_oauth=agent._is_anthropic_oauth,
max_tokens=agent.max_tokens,
reasoning_config=agent.reasoning_config,
preserve_dots=agent._anthropic_preserve_dots(),
base_url=getattr(agent, "_anthropic_base_url", None),
)
_ant_kw2 = _merge_nous_portal_messages_extra_body(agent, _ant_kw2)
retry_response = agent._anthropic_messages_create(_ant_kw2)
retry_response = _managed_summary_call(
_ant_kw2,
agent._anthropic_messages_create,
retry_count=1,
)
_retry_result = _tretry.normalize_response(retry_response, strip_tool_prefix=agent._is_anthropic_oauth)
final_response = (_retry_result.content or "").strip()
else:
@ -2259,7 +2314,14 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
if summary_extra_body:
summary_kwargs["extra_body"] = summary_extra_body
summary_response = agent._ensure_primary_openai_client(reason="iteration_limit_summary_retry").chat.completions.create(**summary_kwargs)
summary_client = agent._ensure_primary_openai_client(
reason="iteration_limit_summary_retry"
)
summary_response = _managed_summary_call(
summary_kwargs,
lambda request: summary_client.chat.completions.create(**request),
retry_count=1,
)
_retry_result = agent._get_transport().normalize_response(summary_response)
final_response = (_retry_result.content or "").strip()
@ -2267,6 +2329,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
if "<think>" in final_response:
final_response = re.sub(r'<think>.*?</think>\s*', '', final_response, flags=re.DOTALL).strip()
if final_response:
summary_call_outcome = "success"
messages.append({"role": "assistant", "content": final_response})
else:
final_response = "I reached the iteration limit and couldn't generate a summary."
@ -2276,6 +2339,13 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
except Exception as e:
logger.warning(f"Failed to get summary response: {e}")
final_response = f"I reached the maximum iterations ({agent.max_iterations}) but couldn't summarize. Error: {str(e)}"
finally:
from agent import relay_llm
relay_llm.complete_logical_call(
summary_api_request_id,
outcome=summary_call_outcome,
)
return final_response
@ -2434,7 +2504,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
pass
def _bedrock_call():
stream = None
try:
from agent import relay_llm
from agent.bedrock_adapter import (
_get_bedrock_runtime_client,
invalidate_runtime_client,
@ -2443,44 +2515,40 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
normalize_converse_response,
stream_converse_with_callbacks,
)
region = api_kwargs.pop("__bedrock_region__", "us-east-1")
api_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
try:
raw_response = client.converse_stream(**api_kwargs)
except Exception as _bedrock_exc:
# IAM policies scoped to bedrock:InvokeModel only (no
# InvokeModelWithResponseStream) reject converse_stream()
# with AccessDeniedException. That denial is permanent for
# the session — fall back to the non-streaming converse()
# inline (it maps to bedrock:InvokeModel) and disable
# streaming for subsequent calls so we don't re-fail every
# turn.
if is_streaming_access_denied_error(_bedrock_exc):
agent._disable_streaming = True
agent._safe_print(
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
"falling back to non-streaming InvokeModel.\n"
" Grant that action to restore streaming output.\n"
)
logger.info(
"bedrock: converse_stream denied by IAM (%s) — "
"using non-streaming converse() for this session.",
type(_bedrock_exc).__name__,
)
result["response"] = normalize_converse_response(
client.converse(**api_kwargs)
)
return
# Evict the cached client on stale-connection failures
# so the outer retry loop builds a fresh client/pool.
if is_stale_connection_error(_bedrock_exc):
invalidate_runtime_client(region)
raise
intercepted_events = []
writer_token = {"value": None}
# Claim the delta sink for this bedrock stream (#65991) so a
# superseded attempt's callbacks are fenced by the sink guard.
claim_stream_writer(agent)
def _open_bedrock_stream(next_api_kwargs: dict[str, Any]):
final_kwargs = dict(next_api_kwargs)
region = final_kwargs.pop("__bedrock_region__", "us-east-1")
final_kwargs.pop("__bedrock_converse__", None)
client = _get_bedrock_runtime_client(region)
try:
raw_response = client.converse_stream(**final_kwargs)
except Exception as _bedrock_exc:
# InvokeModel-only policies cannot open a stream. Keep
# the fallback inside the same managed Relay attempt so
# the real provider request and terminal response still
# share one lifecycle boundary.
if is_streaming_access_denied_error(_bedrock_exc):
agent._disable_streaming = True
agent._safe_print(
"\n⚠ AWS IAM denied bedrock:InvokeModelWithResponseStream — "
"falling back to non-streaming InvokeModel.\n"
" Grant that action to restore streaming output.\n"
)
logger.info(
"bedrock: converse_stream denied by IAM (%s) — "
"using non-streaming converse() for this session.",
type(_bedrock_exc).__name__,
)
return normalize_converse_response(
client.converse(**final_kwargs)
)
if is_stale_connection_error(_bedrock_exc):
invalidate_runtime_client(region)
raise
return raw_response.get("stream", [])
def _on_text(text):
_fire_first()
@ -2495,18 +2563,65 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_fire_first()
agent._fire_reasoning_delta(text)
result["response"] = stream_converse_with_callbacks(
raw_response,
def _finalize_bedrock_stream():
return stream_converse_with_callbacks(
{"stream": list(intercepted_events)}
)
def _bedrock_stream_created(_stream: Any) -> None:
writer_token["value"] = claim_stream_writer(agent)
def _accept_bedrock_event(_event: Any) -> bool:
token = writer_token["value"]
return token is None or stream_writer_is_current(agent, token)
stream = relay_llm.stream(
dict(api_kwargs),
_open_bedrock_stream,
session_id=str(getattr(agent, "session_id", "") or ""),
name=str(getattr(agent, "provider", "") or "bedrock"),
model_name=str(getattr(agent, "model", "") or ""),
finalizer=_finalize_bedrock_stream,
on_stream_created=_bedrock_stream_created,
on_chunk=intercepted_events.append,
chunk_adapter=lambda chunk: chunk,
accept_chunk=_accept_bedrock_event,
completed_response_predicate=lambda response: bool(
getattr(response, "choices", None)
),
metadata={
"api_mode": "custom",
"api_request_id": getattr(
agent, "_current_api_request_id", None
),
"call_role": (
"delegated"
if getattr(agent, "is_subagent", False)
else "fallback"
if int(getattr(agent, "_fallback_index", 0) or 0) > 0
else "primary"
),
},
defer_logical_completion=True,
)
streamed_response = stream_converse_with_callbacks(
{"stream": stream},
on_text_delta=_on_text if agent._has_stream_consumers() else None,
on_tool_start=_on_tool,
on_reasoning_delta=_on_reasoning if agent.reasoning_callback or agent.stream_delta_callback else None,
on_interrupt_check=lambda: agent._interrupt_requested,
on_event=lambda: _bedrock_last_event.__setitem__("t", time.time()),
)
result["response"] = stream.final_response or streamed_response
except Exception as e:
result["error"] = e
finally:
if stream is not None:
stream.close()
t = threading.Thread(target=_bedrock_call, daemon=True)
t = threading.Thread(
target=_context_thread_target(_bedrock_call), daemon=True
)
t.start()
while t.is_alive():
t.join(timeout=0.3)
@ -2698,6 +2813,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
first_delta_fired = {"done": False}
deltas_were_sent = {"yes": False} # Track if any deltas were fired (for fallback)
provider_tool_in_flight = {"yes": False}
# Wall-clock timestamp of the last real streaming chunk. The outer
# poll loop uses this to detect stale connections that keep receiving
# SSE keep-alive pings but no actual data.
@ -2717,11 +2833,29 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"discarded_chunks": 0,
"discarded_bytes": 0,
}
managed_stream_holder = {"stream": None}
def _set_managed_stream(stream: Any) -> Any:
managed_stream_holder["stream"] = stream
return stream
def _close_managed_stream() -> None:
stream = managed_stream_holder.pop("stream", None)
if stream is None:
return
close = getattr(stream, "close", None)
if callable(close):
try:
close()
except Exception:
logger.debug("Managed provider stream cleanup failed", exc_info=True)
def _start_stream_attempt() -> int:
with stream_attempt_lock:
stream_attempt_state["current"] += 1
return int(stream_attempt_state["current"])
attempt_id = int(stream_attempt_state["current"])
provider_tool_in_flight["yes"] = False
return attempt_id
def _cancel_current_stream_attempt(reason: str) -> None:
with stream_attempt_lock:
@ -2831,107 +2965,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# Cap connect/pool at 60s even when provider timeout is higher.
# connect/pool cover TCP handshake, not model inference.
_conn_cap = min(_base_timeout, 60.0) if _provider_timeout_cfg is not None else 30.0
stream_kwargs = {
**api_kwargs,
"stream": True,
"timeout": _httpx.Timeout(
connect=_conn_cap,
read=_stream_read_timeout,
write=_base_timeout,
pool=_conn_cap,
),
}
# OpenAI's `stream_options={"include_usage": True}` drives usage
# accounting on OpenAI-compatible endpoints (incl. the Gemini OpenAI
# compat shim and aggregators like OpenRouter). Google's *native*
# Gemini REST endpoint rejects the keyword outright
# (`Completions.create() got an unexpected keyword argument
# 'stream_options'`), so omit it only for that endpoint.
if not is_native_gemini_base_url(agent.base_url):
stream_kwargs["stream_options"] = {"include_usage": True}
request_client = _set_request_client(
agent._create_request_openai_client(
reason="chat_completion_stream_request",
api_kwargs=stream_kwargs,
)
)
# Reset stale-stream timer so the detector measures from this
# attempt's start, not a previous attempt's last chunk.
last_chunk_time["t"] = time.time()
agent._touch_activity("waiting for provider response (streaming)")
# Initialize per-attempt stream diagnostics so the retry block can
# reach for them after the stream dies. Lives on
# ``request_client_holder["diag"]`` for closure access.
_diag = agent._stream_diag_init()
request_client_holder["diag"] = _diag
stream = request_client.chat.completions.create(**stream_kwargs)
if agent.provider == "moa":
# The MoA facade is a shared singleton — abort/close of the
# registered client is a no-op, so register the stream handle
# itself for interrupt teardown (#57354).
stream = _set_request_stream_handle(stream)
# Claim the delta sink for THIS attempt (#65991). If a prior attempt's
# stream is somehow still alive (a stale-stream reconnect whose socket
# abort raced), this claim supersedes it so its late chunks are fenced
# out of the turn instead of interleaving with ours.
_writer_token = claim_stream_writer(agent)
# Some OpenAI-compatible adapters (for example copilot-acp, and the MoA
# openai-codex aggregator) accept stream=True but still return a
# completed response object rather than an iterator of chunks. Treat
# that as "streaming unsupported" for the rest of this session instead
# of crashing on ``for chunk in stream`` with ``'types.SimpleNamespace'
# object is not iterable`` (#11732, #55933).
#
# Discriminate on the mere PRESENCE of a ``choices`` attribute, not on
# it being a non-empty list: an adapter may hand back a completed
# response whose ``choices`` is ``None`` or empty (an error /
# content-filter / terminal frame), and every such shape is still a
# whole response — not a token stream — that would crash iteration just
# the same. A genuine provider stream (SDK ``Stream`` object,
# generator) exposes no ``choices`` attribute, so it is left untouched.
if hasattr(stream, "choices"):
logger.info(
"Streaming request returned a final response object instead of "
"an iterator; switching %s/%s to non-streaming for this session.",
agent.provider or "unknown",
agent.model or "unknown",
)
agent._disable_streaming = True
# An empty/None ``choices`` carries no message to surface; return the
# completed object as-is so the outer loop's normal invalid-response
# validation (conversation_loop.py) handles it via the retry path,
# never ``for chunk in stream``.
choices = stream.choices
first_choice = choices[0] if isinstance(choices, (list, tuple)) and choices else None
message = getattr(first_choice, "message", None)
if message is not None:
reasoning_text = (
getattr(message, "reasoning_content", None)
or getattr(message, "reasoning", None)
)
if isinstance(reasoning_text, str) and reasoning_text:
_fire_first_delta()
agent._fire_reasoning_delta(reasoning_text)
content = getattr(message, "content", None)
if isinstance(content, str) and content:
_fire_first_delta()
agent._fire_stream_delta(content)
return stream
# Capture rate limit headers from the initial HTTP response.
# The OpenAI SDK Stream object exposes the underlying httpx
# response via .response before any chunks are consumed.
agent._capture_rate_limits(getattr(stream, "response", None))
agent._capture_credits(getattr(stream, "response", None))
# Snapshot diagnostic headers (cf-ray, x-openrouter-provider, etc.)
# so they survive even when the stream dies before any chunk
# arrives. Best-effort; never raises.
agent._stream_diag_capture_response(_diag, getattr(stream, "response", None))
# Log OpenRouter response cache status when present.
agent._check_openrouter_cache_status(getattr(stream, "response", None))
content_parts: list = []
tool_calls_acc: dict = {}
tool_gen_notified: set = set()
@ -2946,19 +2979,125 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
role = "assistant"
reasoning_parts: list = []
usage_obj = None
for chunk in stream:
# Stop the moment a newer attempt has claimed the delta sink
# (#65991): this attempt has been superseded, so it must neither
# fire deltas (incl. the tool-suppressed raw-callback path below)
# nor keep consuming a stream that would interleave into the turn.
if not stream_writer_is_current(agent, _writer_token):
_diag = agent._stream_diag_init()
request_client_holder["diag"] = _diag
_writer_token = {"value": None}
attempt_request_client = {"value": None}
def _open_stream(next_api_kwargs: dict[str, Any]):
stream_kwargs = {
**next_api_kwargs,
"stream": True,
"timeout": _httpx.Timeout(
connect=_conn_cap,
read=_stream_read_timeout,
write=_base_timeout,
pool=_conn_cap,
),
}
# Native Gemini rejects OpenAI's usage-streaming extension.
if not is_native_gemini_base_url(agent.base_url):
stream_kwargs["stream_options"] = {"include_usage": True}
request_client = _set_request_client(
agent._create_request_openai_client(
reason="chat_completion_stream_request",
api_kwargs=stream_kwargs,
)
)
attempt_request_client["value"] = request_client
last_chunk_time["t"] = time.time()
agent._touch_activity("waiting for provider response (streaming)")
return request_client.chat.completions.create(**stream_kwargs)
def _stream_created(raw_stream: Any) -> None:
response = getattr(raw_stream, "response", None)
agent._capture_rate_limits(response)
agent._capture_credits(response)
agent._stream_diag_capture_response(_diag, response)
agent._check_openrouter_cache_status(response)
_writer_token["value"] = claim_stream_writer(agent)
def _accept_stream_chunk(_chunk: Any) -> bool:
# A stale-attempt fence can win while Relay is handing an
# already-received tool-call chunk back to Hermes. Preserve only
# the fact that a tool call was in flight so retry policy does not
# misclassify the attempt as a partial text response. The chunk
# itself is still rejected below and never reaches callbacks.
try:
choices = getattr(_chunk, "choices", None)
delta = getattr(choices[0], "delta", None) if choices else None
if getattr(delta, "tool_calls", None):
provider_tool_in_flight["yes"] = True
except Exception:
pass
if not _stream_attempt_is_active(stream_attempt_id):
return False
token = _writer_token["value"]
if token is not None and not stream_writer_is_current(agent, token):
logger.warning(
"Streaming attempt superseded by a newer stream; stopping "
"consumption to preserve the single-writer invariant "
"(model=%s).",
api_kwargs.get("model", "unknown"),
)
break
return False
# Record provider activity before Relay processes the chunk. This
# prevents the stale watchdog from cancelling a live stream while
# an interceptor or codec is still handling an already-received
# event.
last_chunk_time["t"] = time.time()
return True
def _relay_final_response() -> dict[str, Any]:
tool_calls = [tool_calls_acc[index] for index in sorted(tool_calls_acc)]
return {
"model": model_name,
"choices": [
{
"message": {
"role": role,
"content": "".join(content_parts) or None,
"reasoning_content": "".join(reasoning_parts) or None,
"tool_calls": tool_calls or None,
},
"finish_reason": finish_reason or "stop",
}
],
"usage": usage_obj,
}
from agent import relay_llm
stream = _set_managed_stream(
relay_llm.stream(
api_kwargs,
_open_stream,
session_id=str(getattr(agent, "session_id", "") or ""),
name=str(getattr(agent, "provider", "") or "provider"),
model_name=str(getattr(agent, "model", "") or ""),
finalizer=_relay_final_response,
on_stream_created=_stream_created,
accept_chunk=_accept_stream_chunk,
completed_response_predicate=lambda value: hasattr(value, "choices"),
metadata={
"api_mode": "chat_completions",
"api_request_id": getattr(agent, "_current_api_request_id", None),
"call_role": (
"delegated"
if getattr(agent, "is_subagent", False)
else "fallback"
if int(getattr(agent, "_fallback_index", 0) or 0) > 0
else "primary"
),
},
defer_logical_completion=True,
)
)
if agent.provider == "moa":
# Hermes interrupts the managed stream; Relay retains sole
# ownership of closing the underlying provider stream.
_set_request_stream_handle(stream)
for chunk in stream:
last_chunk_time["t"] = time.time()
agent._touch_activity("receiving stream response")
@ -2995,9 +3134,12 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# the finally's close really closes the pool instead of
# caching it (owner-thread abort: shutdown is safe, and the
# FD release still happens in the finally below).
agent._abort_request_openai_client(
request_client, reason="interrupt_stream_close_failed"
)
request_client = attempt_request_client["value"]
if request_client is not None:
agent._abort_request_openai_client(
request_client,
reason="interrupt_stream_close_failed",
)
break
if not _stream_attempt_is_active(stream_attempt_id):
@ -3129,11 +3271,46 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if hasattr(chunk, "usage") and chunk.usage:
usage_obj = chunk.usage
_close_managed_stream()
if _stream_attempt_was_cancelled(stream_attempt_id):
raise _httpx.RemoteProtocolError(
f"stream attempt {stream_attempt_id} was superseded"
)
# Some OpenAI-compatible adapters accept ``stream=True`` but return a
# completed response. Relay records that attempt while Hermes preserves
# its existing switch-to-non-streaming behavior for later calls.
if stream.final_response is not None:
final_response = stream.final_response
logger.info(
"Streaming request returned a final response object instead of "
"an iterator; switching %s/%s to non-streaming for this session.",
agent.provider or "unknown",
agent.model or "unknown",
)
agent._disable_streaming = True
choices = final_response.choices
first_choice = (
choices[0]
if isinstance(choices, (list, tuple)) and choices
else None
)
message = getattr(first_choice, "message", None)
if message is not None:
reasoning_text = (
getattr(message, "reasoning_content", None)
or getattr(message, "reasoning", None)
)
if isinstance(reasoning_text, str) and reasoning_text:
_fire_first_delta()
agent._fire_reasoning_delta(reasoning_text)
content = getattr(message, "content", None)
if isinstance(content, str) and content:
_fire_first_delta()
agent._fire_stream_delta(content)
return final_response
# Build mock response matching non-streaming shape
full_content = "".join(content_parts) or None
mock_tool_calls = None
@ -3295,72 +3472,95 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# fabricated "successful" empty turn.
saw_stream_event = False
# Reset stale-stream timer for this attempt
last_chunk_time["t"] = time.time()
# Per-attempt diagnostic dict for the retry block to consume.
_diag = agent._stream_diag_init()
request_client_holder["diag"] = _diag
# Defensive: strip Responses-only kwargs (instructions, input, ...)
# that can leak in under an api_mode-flip race. The Anthropic SDK
# raises a non-retryable TypeError on them, killing the turn. See
# #31673 / sanitize_anthropic_kwargs().
_writer_token = {"value": None}
_stream_context = {"manager": None, "stream": None}
base_final_message = None
from agent import relay_llm
from agent.anthropic_adapter import sanitize_anthropic_kwargs
sanitize_anthropic_kwargs(
api_kwargs, log_prefix=getattr(agent, "log_prefix", "")
)
# Use the Anthropic SDK's streaming context manager
with request_client.messages.stream(**api_kwargs) as stream:
accumulator = relay_llm.AnthropicStreamAccumulator()
def _open_anthropic_stream(next_api_kwargs: dict[str, Any]):
final_kwargs = dict(next_api_kwargs)
sanitize_anthropic_kwargs(
final_kwargs,
log_prefix=getattr(agent, "log_prefix", ""),
)
manager = request_client.messages.stream(**final_kwargs)
_stream_context["manager"] = manager
return manager.__enter__()
def _anthropic_stream_created(raw_stream: Any) -> None:
_stream_context["stream"] = raw_stream
# The Anthropic SDK exposes the raw httpx response on
# ``stream.response``. Snapshot diagnostic headers
# immediately so they survive a stream that dies before the
# first event.
# ``stream.response``. Snapshot diagnostics immediately so they
# survive a stream that dies before the first event.
try:
agent._stream_diag_capture_response(
_diag, getattr(stream, "response", None)
_diag,
getattr(raw_stream, "response", None),
)
except Exception:
pass
# Claim the delta sink for THIS attempt (#65991) — parity with the
# chat_completions path so a superseded anthropic stream is fenced.
_writer_token = claim_stream_writer(agent)
_writer_token["value"] = claim_stream_writer(agent)
def _accept_anthropic_event(_event: Any) -> bool:
token = _writer_token["value"]
if token is None or stream_writer_is_current(agent, token):
return True
logger.warning(
"Anthropic streaming attempt superseded by a newer stream; "
"stopping consumption to preserve the single-writer "
"invariant (model=%s).",
api_kwargs.get("model", "unknown"),
)
return False
stream = _set_managed_stream(
relay_llm.stream(
api_kwargs,
_open_anthropic_stream,
session_id=str(getattr(agent, "session_id", "") or ""),
name=str(getattr(agent, "provider", "") or "anthropic"),
model_name=str(getattr(agent, "model", "") or ""),
finalizer=accumulator.finalize,
on_stream_created=_anthropic_stream_created,
on_chunk=accumulator.observe,
accept_chunk=_accept_anthropic_event,
metadata={
"api_mode": "anthropic_messages",
"api_request_id": getattr(agent, "_current_api_request_id", None),
"call_role": (
"delegated"
if getattr(agent, "is_subagent", False)
else "fallback"
if int(getattr(agent, "_fallback_index", 0) or 0) > 0
else "primary"
),
},
defer_logical_completion=True,
)
)
try:
for event in stream:
# Bail the instant a newer attempt supersedes this one so a
# stale stream can't interleave tokens into the turn.
if not stream_writer_is_current(agent, _writer_token):
logger.warning(
"Anthropic streaming attempt superseded by a newer "
"stream; stopping consumption to preserve the "
"single-writer invariant (model=%s).",
api_kwargs.get("model", "unknown"),
)
break
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
# Opus streams after 180 s even when events are
# actively arriving (the chat_completions path
# already does this at the top of its chunk loop).
last_chunk_time["t"] = time.time()
agent._touch_activity("receiving stream response")
# Update per-attempt diagnostic counters (best-effort).
try:
_diag["chunks"] = int(_diag.get("chunks", 0)) + 1
if _diag.get("first_chunk_at") is None:
_diag["first_chunk_at"] = last_chunk_time["t"]
try:
_diag["bytes"] = int(_diag.get("bytes", 0)) + len(repr(event))
except Exception:
pass
_diag["bytes"] = int(_diag.get("bytes", 0)) + len(repr(event))
except Exception:
pass
if agent._interrupt_requested:
break
event_type = getattr(event, "type", None)
if event_type == "content_block_start":
block = getattr(event, "content_block", None)
if block and getattr(block, "type", None) == "tool_use":
@ -3369,7 +3569,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if tool_name:
_fire_first_delta()
agent._fire_tool_gen_started(tool_name)
elif event_type == "content_block_delta":
delta = getattr(event, "delta", None)
if delta:
@ -3385,48 +3584,49 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if thinking_text:
_fire_first_delta()
agent._fire_reasoning_delta(thinking_text)
# Return the native Anthropic Message for downstream processing.
# If the stream was interrupted (the event loop broke out above on
# agent._interrupt_requested), do NOT call get_final_message() — on
# a partially-consumed stream the SDK may hang draining remaining
# events or return a Message with incomplete tool_use blocks (partial
# JSON in `input`). The outer poll loop raises InterruptedError, so
# this return value is discarded anyway.
if agent._interrupt_requested:
return None
# 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.
if not agent._interrupt_requested:
raw_stream = _stream_context["stream"]
if raw_stream is not None:
try:
base_final_message = raw_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
finally:
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
_close_managed_stream()
finally:
manager = _stream_context["manager"]
if manager is not None:
manager.__exit__(None, None, None)
if agent._interrupt_requested:
return None
if (
base_final_message is not None
and not getattr(base_final_message, "content", None)
and getattr(base_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)."
)
if base_final_message is not None and not stream.output_modified:
return base_final_message
final_message = accumulator.response(base_final_message)
if (
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
@ -3461,6 +3661,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
result["response"] = _call_chat_completions(stream_attempt_id)
return # success
except Exception as e:
_close_managed_stream()
# If the main poll loop force-closed this request because
# of an interrupt, the resulting transport error is the
# expected consequence of our own close — NOT a transient
@ -3500,7 +3701,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if deltas_were_sent["yes"]:
_partial_tool_in_flight = bool(
result.get("partial_tool_names")
)
) or provider_tool_in_flight["yes"]
_is_sse_conn_err_preview = False
if not _is_timeout and not _is_conn_err:
from openai import APIError as _APIError
@ -3749,6 +3950,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
result["error"] = e
return
finally:
_close_managed_stream()
# Reuse reason only on a clean stream; any other outcome (error,
# cancel-swallow) really closes so the next attempt builds a
# fresh pool (see _REQUEST_CLIENT_REUSE_REASONS).
@ -3818,7 +4020,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if _reasoning_floor is not None:
_stream_stale_timeout = max(_stream_stale_timeout, _reasoning_floor)
t = threading.Thread(target=_call, daemon=True)
t = threading.Thread(target=_context_thread_target(_call), daemon=True)
t.start()
_last_heartbeat = time.time()
_HEARTBEAT_INTERVAL = 30.0 # seconds between gateway activity touches

View file

@ -191,6 +191,28 @@ def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
return f"call_{digest}"
def _clamp_responses_call_id(call_id: str) -> str:
"""Keep a ``call_id`` within the Responses API's 64-char limit (#73492).
The codex app-server namespaces MCP tool call ids as
``codex_mcp__<server>__<tool>_<codex_call_id>``; with an ``exec-<uuid>``
component the built-in ``hermes-tools`` server already overflows 64 chars,
and the Responses API rejects the whole payload with a non-retryable HTTP
400 that then replays every turn permanently bricking the session.
Sibling defect to #10788 (which clamped ``input[*].id``), applied here to
``call_id``. The surrogate is a pure, deterministic function of the
original, so the ``function_call`` and its matching ``function_call_output``
which carry the same original id map to the same surrogate and stay
paired without correlating the two items. Short ids pass through unchanged,
preserving prompt-cache prefixes.
"""
if len(call_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
return call_id
digest = hashlib.sha256(call_id.encode("utf-8", errors="replace")).hexdigest()[:32]
return f"call_{digest}"
def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]:
"""Split a stored tool id into (call_id, response_item_id)."""
if not isinstance(raw_id, str):
@ -546,7 +568,7 @@ def _chat_messages_to_responses_input(
items.append({
"type": "function_call",
"call_id": call_id,
"call_id": _clamp_responses_call_id(call_id),
"name": fn_name,
"arguments": arguments,
})
@ -589,7 +611,7 @@ def _chat_messages_to_responses_input(
items.append({
"type": "function_call_output",
"call_id": call_id,
"call_id": _clamp_responses_call_id(call_id),
"output": output_value,
})

View file

@ -1240,6 +1240,8 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
"""
import httpx as _httpx
from agent import relay_llm
active_client = client or agent._ensure_primary_openai_client(reason="codex_stream_direct")
max_stream_retries = 1
# Accumulate streamed text so callers / compat shims can read it.
@ -1264,48 +1266,88 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted before Codex stream retry")
stream_kwargs = dict(api_kwargs)
stream_kwargs["stream"] = True
intercepted_events = []
writer_token = {"value": None}
def _open_codex_stream(next_api_kwargs: dict[str, Any]):
stream_kwargs = dict(next_api_kwargs)
stream_kwargs["stream"] = True
return active_client.responses.create(**stream_kwargs)
def _codex_stream_created(_raw_stream: Any) -> None:
# Claim the delta sink for THIS physical attempt. A newer attempt
# supersedes this token and fences late deltas out of the turn.
writer_token["value"] = claim_stream_writer(agent)
def _accept_codex_chunk(_chunk: Any) -> bool:
token = writer_token["value"]
if token is None or stream_writer_is_current(agent, token):
return True
logger.warning(
"Codex streaming attempt superseded by a newer stream; "
"stopping consumption to preserve the single-writer "
"invariant (model=%s).",
api_kwargs.get("model", "unknown"),
)
return False
def _finalize_codex_stream() -> Any:
return _consume_codex_event_stream(
list(intercepted_events),
model=api_kwargs.get("model"),
)
try:
event_stream = active_client.responses.create(**stream_kwargs)
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
event_stream = relay_llm.stream(
dict(api_kwargs),
_open_codex_stream,
session_id=str(getattr(agent, "session_id", "") or ""),
name=str(getattr(agent, "provider", "") or "codex"),
model_name=str(api_kwargs.get("model") or ""),
finalizer=_finalize_codex_stream,
on_stream_created=_codex_stream_created,
on_chunk=intercepted_events.append,
chunk_adapter=lambda chunk: chunk,
accept_chunk=_accept_codex_chunk,
completed_response_predicate=lambda response: bool(
hasattr(response, "output") and not hasattr(response, "__iter__")
),
metadata={
"api_mode": "codex_responses",
"api_request_id": getattr(agent, "_current_api_request_id", None),
"call_role": (
"delegated"
if getattr(agent, "is_subagent", False)
else "fallback"
if int(getattr(agent, "_fallback_index", 0) or 0) > 0
else "primary"
),
"retry_count": attempt,
},
defer_logical_completion=True,
)
except (
_httpx.RemoteProtocolError,
_httpx.ReadTimeout,
_httpx.ConnectError,
ConnectionError,
) as exc:
if attempt < max_stream_retries:
logger.debug(
"Codex Responses stream connect failed (attempt %s/%s); retrying. %s error=%s",
attempt + 1, max_stream_retries + 1,
agent._client_log_context(), exc,
"Codex Responses stream connect failed (attempt %s/%s); "
"retrying. %s error=%s",
attempt + 1,
max_stream_retries + 1,
agent._client_log_context(),
exc,
)
continue
raise
# Claim the delta sink for THIS attempt (#65991) — parity with the
# chat_completions/anthropic/bedrock paths. If a prior attempt's
# stream is somehow still alive, this claim supersedes it so its
# late deltas are fenced out of the turn; conversely, a newer
# attempt supersedes us and the interrupt_check below stops our
# consumption immediately.
_writer_token = claim_stream_writer(agent)
def _interrupt_or_superseded(_tok=_writer_token) -> bool:
if agent._interrupt_requested:
return True
if not stream_writer_is_current(agent, _tok):
logger.warning(
"Codex streaming attempt superseded by a newer stream; "
"stopping consumption to preserve the single-writer "
"invariant (model=%s).",
api_kwargs.get("model", "unknown"),
)
return True
return False
def _interrupt_or_superseded() -> bool:
return bool(agent._interrupt_requested)
try:
# Compatibility: some mocks/providers return a concrete response
# instead of an iterable. Pass it straight through.
if hasattr(event_stream, "output") and not hasattr(event_stream, "__iter__"):
return event_stream
try:
final = _consume_codex_event_stream(
event_stream,
@ -1324,6 +1366,12 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
on_event=_on_event,
interrupt_check=_interrupt_or_superseded,
)
# The terminal SSE frame is contractually last. Request the
# end-of-stream marker so Relay can run its response finalizer
# and close the physical attempt scope before Hermes returns.
if not agent._interrupt_requested:
for _ignored in event_stream:
pass
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
if attempt < max_stream_retries:
logger.debug(
@ -1334,6 +1382,10 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
)
continue
raise
except RuntimeError:
if event_stream.final_response is not None:
return event_stream.final_response
raise
if final.status in {"incomplete", "failed"}:
logger.warning(

View file

@ -520,7 +520,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
# session is created (not on continuation). Plugins can use this
# to initialise session-scoped state (e.g. warm a memory cache).
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_start",
session_id=agent.session_id,
@ -2100,7 +2100,7 @@ def run_conversation(
_llm_middleware_trace = []
try:
from hermes_cli.plugins import (
from hermes_cli.lifecycle import (
has_hook,
invoke_hook as _invoke_hook,
)
@ -2141,6 +2141,7 @@ def run_conversation(
base_url=agent.base_url,
api_mode=agent.api_mode,
api_call_count=api_call_count,
retry_count=retry_count,
request_messages=list(request_messages)
if isinstance(request_messages, list)
else [],
@ -2230,7 +2231,28 @@ def run_conversation(
return agent._interruptible_streaming_api_call(
next_api_kwargs, on_first_delta=_stop_spinner
)
return agent._interruptible_api_call(next_api_kwargs)
from agent import relay_llm
return relay_llm.execute(
next_api_kwargs,
agent._interruptible_api_call,
session_id=str(agent.session_id or ""),
name=str(agent.provider or "provider"),
model_name=str(agent.model or ""),
metadata={
"api_mode": agent.api_mode,
"api_request_id": api_request_id,
"call_role": (
"delegated"
if getattr(agent, "is_subagent", False)
else "fallback"
if int(getattr(agent, "_fallback_index", 0) or 0) > 0
else "primary"
),
"retry_count": retry_count,
},
defer_logical_completion=True,
)
from hermes_cli.middleware import run_llm_execution_middleware
@ -3288,6 +3310,12 @@ def run_conversation(
clear_nous_rate_limit()
except Exception:
pass
from agent import relay_llm
relay_llm.complete_logical_call(
api_request_id,
outcome="success",
)
agent._touch_activity(f"API call #{api_call_count} completed")
break # Success, exit retry loop
@ -5432,7 +5460,7 @@ def run_conversation(
assistant_message.content = str(raw)
try:
from hermes_cli.plugins import (
from hermes_cli.lifecycle import (
has_hook,
invoke_hook as _invoke_hook,
)
@ -6765,7 +6793,8 @@ def run_conversation(
_attempt = getattr(agent, "_pre_verify_nudges", 0)
try:
from agent.verify_hooks import max_verify_nudges
from hermes_cli.plugins import get_pre_verify_continue_message, has_hook
from hermes_cli.lifecycle import has_hook
from hermes_cli.plugins import get_pre_verify_continue_message
if _edited and has_hook("pre_verify") and _attempt < max_verify_nudges():
# Posture is fixed for the session — resolve once + cache.

View file

@ -288,20 +288,12 @@ def evaluate_credits_notices(
current_band = band
# Top-up suppression: when the account holds purchased (top-up) credits,
# the subscription-cap gauge is the wrong denominator — warning "90% used"
# at a user sitting on $50 of top-up is noise (and it previously stuck
# PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band
# entirely; the cap-reached case is covered by the grant_spent info notice
# below, which already names the remaining top-up balance. A top-up landing
# mid-session flips current_band → None and the clear path below removes
# any showing band line.
# at a user sitting on $50 of top-up is noise. Suppress the usage band
# entirely; /usage still reports the full balance breakdown. A top-up
# landing mid-session flips current_band → None and the clear path below
# removes any showing band line.
if state.purchased_micros > 0:
current_band = None
grant_cond = (
state.denominator_kind == "subscription_cap"
and uf is not None
and uf >= 1.0
and state.purchased_micros > 0
)
depleted_cond = not state.paid_access
# ── usage gauge (escalating single notice: 50 → 75 → 90) ──────────────────
@ -340,22 +332,6 @@ def evaluate_credits_notices(
active.add(CREDITS_USAGE_KEY)
latch["usage_band"] = target_band
# ── grant_spent ──────────────────────────────────────────────────────────
if grant_cond and "credits.grant_spent" not in active:
to_show.append(
AgentNotice(
text=f"• Grant spent · ${state.purchased_usd} top-up left",
level="info",
kind=CREDITS_NOTICE_KIND,
key="credits.grant_spent",
id="credits.grant_spent",
)
)
active.add("credits.grant_spent")
elif "credits.grant_spent" in active and not grant_cond:
to_clear.append("credits.grant_spent")
active.discard("credits.grant_spent")
# ── depleted ─────────────────────────────────────────────────────────────
# Suppressed while the active model is free: inference still works there,
# so the error banner would just alarm users (free-tier users especially,
@ -627,7 +603,7 @@ _DEV_FIXTURES: dict[str, dict] = {
subscription_limit_micros=20_000_000, subscription_limit_usd="20.00",
denominator_kind="subscription_cap", paid_access=True,
),
"grant_exhausted": dict( # used_fraction == 1.0 + purchased>0 → credits.grant_spent
"grant_exhausted": dict( # cap reached + purchased>0 → no notice (gauge suppressed by top-up)
remaining_micros=12_340_000, remaining_usd="12.34",
subscription_micros=0, subscription_usd="0.00",
subscription_limit_micros=20_000_000, subscription_limit_usd="20.00",

1130
agent/relay_llm.py Normal file

File diff suppressed because it is too large Load diff

1002
agent/relay_runtime.py Normal file

File diff suppressed because it is too large Load diff

123
agent/relay_tools.py Normal file
View file

@ -0,0 +1,123 @@
"""Core NeMo Relay adapter for Hermes tool execution."""
from __future__ import annotations
import asyncio
import contextvars
import inspect
import json
import logging
from collections.abc import Callable
from typing import Any
from agent import relay_runtime
logger = logging.getLogger(__name__)
def execute(
tool_name: str,
args: dict[str, Any],
callback: Callable[[dict[str, Any]], Any],
*,
session_id: str,
metadata: dict[str, Any] | None = None,
) -> tuple[Any, dict[str, Any]]:
"""Run one tool call through Relay and return its final arguments."""
runtime, session, parent = relay_runtime.resolve_execution_context(session_id)
if runtime is None or session is None or not runtime.managed_execution_enabled():
return callback(args), args
observed_args = args
raw_result: dict[str, Any] = {}
callback_error: BaseException | None = None
callback_context = contextvars.copy_context()
def invoke(next_args: Any) -> Any:
nonlocal callback_error, observed_args
observed_args = next_args if isinstance(next_args, dict) else args
try:
result = callback_context.copy().run(callback, observed_args)
except BaseException as exc:
callback_error = exc
raise
raw_result["value"] = result
raw_result["json"] = _jsonable(result)
return raw_result["json"]
try:
managed = _run_awaitable(
runtime.run_in_session_async(
session,
runtime.relay.tools.execute,
tool_name,
_jsonable(args),
invoke,
handle=parent,
metadata=_jsonable(metadata or {}),
)
)
except BaseException as exc:
if (
callback_error is not None
and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error)
):
raise callback_error
if (
isinstance(exc, Exception)
and callback_error is None
and "value" in raw_result
):
logger.warning(
"NeMo Relay tool post-processing failed after dispatch success; "
"returning the Hermes tool result",
exc_info=True,
)
return raw_result["value"], observed_args
raise
if "value" in raw_result and _json_equal(managed, raw_result["json"]):
return raw_result["value"], observed_args
if isinstance(managed, str):
return managed, observed_args
return json.dumps(_jsonable(managed), ensure_ascii=False), observed_args
def _jsonable(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [_jsonable(item) for item in value]
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
try:
return _jsonable(model_dump(mode="json"))
except Exception:
pass
try:
return _jsonable(vars(value))
except (TypeError, AttributeError):
return str(value)
def _json_equal(left: Any, right: Any) -> bool:
try:
return json.dumps(
_jsonable(left), sort_keys=True, separators=(",", ":")
) == json.dumps(_jsonable(right), sort_keys=True, separators=(",", ":"))
except (TypeError, ValueError):
return left == right
def _run_awaitable(value: Any) -> Any:
if not inspect.isawaitable(value):
return value
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(value)
raise RuntimeError(
"Synchronous Hermes Relay tool execution cannot run on an active event-loop thread"
)

File diff suppressed because it is too large Load diff

View file

@ -438,7 +438,12 @@ def build_turn_context(
# Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id
turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
turn_id = str(getattr(agent, "_relay_pending_turn_id", "") or "")
if not turn_id:
turn_id = (
f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
)
agent._relay_pending_turn_id = None
agent._current_turn_id = turn_id
agent._current_api_request_id = ""
# Tripwire: warn (with both turn ids) when this turn starts before the
@ -1045,7 +1050,7 @@ def build_turn_context(
# Plugin hook: pre_llm_call (context injected into user message, not system prompt).
plugin_user_context = ""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_pre_results = _invoke_hook(
"pre_llm_call",
session_id=agent.session_id,
@ -1056,6 +1061,7 @@ def build_turn_context(
is_first_turn=(not bool(conversation_history)),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
parent_session_id=getattr(agent, "_parent_session_id", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
)
_ctx_parts: list[str] = []

View file

@ -488,7 +488,7 @@ def finalize_turn(
# First hook to return a string wins; None/empty return leaves text unchanged.
if final_response and not interrupted:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_transform_results = _invoke_hook(
"transform_llm_output",
response_text=final_response,
@ -510,7 +510,7 @@ def finalize_turn(
# to an external memory system).
if final_response and not interrupted:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"post_llm_call",
session_id=agent.session_id,
@ -669,14 +669,16 @@ def finalize_turn(
# Fired at the very end of every run_conversation call.
# Plugins can use this for cleanup, flushing buffers, etc.
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_end",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
completed=completed,
failed=failed,
interrupted=interrupted,
turn_exit_reason=_turn_exit_reason,
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)

View file

@ -511,15 +511,93 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
pricing_version="deepseek-pricing-2026-07",
),
# Google Gemini
(
"google",
"gemini-3.6-flash",
): PricingEntry(
input_cost_per_million=Decimal("1.50"),
output_cost_per_million=Decimal("7.50"),
cache_read_cost_per_million=Decimal("0.15"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/gemini-api/docs/pricing",
pricing_version="google-pricing-2026-07-28",
),
(
"google",
"gemini-3.5-flash",
): PricingEntry(
input_cost_per_million=Decimal("1.50"),
output_cost_per_million=Decimal("9.00"),
cache_read_cost_per_million=Decimal("0.15"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
"gemini-3.5-flash-lite",
): PricingEntry(
input_cost_per_million=Decimal("0.30"),
output_cost_per_million=Decimal("2.50"),
cache_read_cost_per_million=Decimal("0.03"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/gemini-api/docs/pricing",
pricing_version="google-pricing-2026-07-28",
),
(
"google",
"gemini-3.1-pro",
): PricingEntry(
input_cost_per_million=Decimal("2.00"),
output_cost_per_million=Decimal("12.00"),
cache_read_cost_per_million=Decimal("0.20"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
"gemini-3.1-flash-lite",
): PricingEntry(
input_cost_per_million=Decimal("0.25"),
output_cost_per_million=Decimal("1.50"),
cache_read_cost_per_million=Decimal("0.025"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
"gemini-3-pro-preview",
): PricingEntry(
input_cost_per_million=Decimal("2.00"),
output_cost_per_million=Decimal("12.00"),
cache_read_cost_per_million=Decimal("0.20"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
"gemini-3-flash-preview",
): PricingEntry(
input_cost_per_million=Decimal("0.50"),
output_cost_per_million=Decimal("3.00"),
cache_read_cost_per_million=Decimal("0.05"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
"gemini-2.5-pro",
): PricingEntry(
input_cost_per_million=Decimal("1.25"),
output_cost_per_million=Decimal("10.00"),
cache_read_cost_per_million=Decimal("0.125"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-03-16",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
@ -527,9 +605,10 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
): PricingEntry(
input_cost_per_million=Decimal("0.15"),
output_cost_per_million=Decimal("0.60"),
cache_read_cost_per_million=Decimal("0.015"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-03-16",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
@ -537,9 +616,10 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
): PricingEntry(
input_cost_per_million=Decimal("0.10"),
output_cost_per_million=Decimal("0.40"),
cache_read_cost_per_million=Decimal("0.01"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-03-16",
pricing_version="google-pricing-2026-07-07",
),
# AWS Bedrock — pricing per the Bedrock pricing page.
# Bedrock charges the same per-token rates as the model provider but
@ -878,6 +958,18 @@ for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"):
]
del _base_56
# The direct Gemini provider currently exposes preview IDs for these two
# models. Keep the official snapshot keyed by both their documented stable
# names and the provider's emitted IDs so a catalog selection is billable.
for _alias, _canonical in {
"gemini-3.1-pro-preview": "gemini-3.1-pro",
"gemini-3.1-flash-lite-preview": "gemini-3.1-flash-lite",
}.items():
_OFFICIAL_DOCS_PRICING[("google", _alias)] = _OFFICIAL_DOCS_PRICING[
("google", _canonical)
]
del _alias, _canonical
def _to_decimal(value: Any) -> Optional[Decimal]:
if value is None:
@ -925,11 +1017,17 @@ def resolve_billing_route(
return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name in {"minimax", "minimax-cn"}:
return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
# Vertex AI hosts the same Gemini models as Google AI Studio; price them
# off the gemini official-docs snapshot. Strip the "google/" vendor prefix
# the OpenAI-compat endpoint requires so the pricing key matches.
if provider_name == "vertex" or base_url_host_matches(base_url or "", "aiplatform.googleapis.com"):
return BillingRoute(provider="gemini", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
# Google AI Studio (Gemini) and Vertex AI host the same Gemini models.
# Price them off the official docs snapshot — the pricing keys are
# keyed on provider='google', so normalize every Google-flavored
# provider name/host onto it. Strip the "google/" vendor prefix the
# Vertex OpenAI-compat endpoint requires so the pricing key matches.
if (
provider_name in {"google", "gemini", "vertex", "google-gemini", "google-ai-studio", "google-vertex", "vertex-ai"}
or base_url_host_matches(base_url or "", "aiplatform.googleapis.com")
or base_url_host_matches(base_url or "", "generativelanguage.googleapis.com")
):
return BillingRoute(provider="google", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name == "fireworks" or base_url_host_matches(base_url or "", "api.fireworks.ai"):
# Fireworks model ids look like accounts/fireworks/models/<name>;
# rsplit("/", 1)[-1] yields just <name> which is what the dict keys on.

View file

@ -9272,8 +9272,8 @@ ipcMain.handle('hermes:window:openInstance', async () => {
// shortcuts and the View menu. Reads and writes target the asking window.
ipcMain.handle('hermes:zoom:get', event => {
const window = BrowserWindow.fromWebContents(event.sender)
const level =
window && !window.isDestroyed() ? window.webContents.getZoomLevel() : DEFAULT_ZOOM_LEVEL
const level = window && !window.isDestroyed() ? window.webContents.getZoomLevel() : DEFAULT_ZOOM_LEVEL
return { level, percent: zoomLevelToPercent(level) }
})

View file

@ -11,6 +11,7 @@ import {
} from '@/lib/voice-playback'
import { isVoiceStopCommand } from '@/lib/voice-stop-word'
import { notify, notifyError } from '@/store/notifications'
import { $voicePlayback } from '@/store/voice-playback'
import { useMicRecorder } from './use-mic-recorder'
@ -61,6 +62,7 @@ export function useVoiceConversation({
const speechSessionRef = useRef<null | SpeechStreamSession>(null)
const stopBargeMonitorRef = useRef<(() => void) | null>(null)
const bargeCapturePendingRef = useRef(false)
const speechStartSequenceRef = useRef(0)
const enabledRef = useRef(enabled)
const mutedRef = useRef(muted)
const busyRef = useRef(busy)
@ -257,7 +259,16 @@ export function useVoiceConversation({
dropSpeechSession()
if (enabledRef.current) {
// If stopVoicePlayback() was called externally (Stop button, end), the
// voice-playback sequence has advanced past what we captured at speech
// start — don't auto-start the next sentence, the user chose to stop.
const stoppedByUser =
speechStartSequenceRef.current > 0 &&
$voicePlayback.get().sequence > speechStartSequenceRef.current
speechStartSequenceRef.current = 0
if (enabledRef.current && !stoppedByUser) {
pendingStartRef.current = true
}
@ -387,6 +398,8 @@ export function useVoiceConversation({
barged = true
})
speechStartSequenceRef.current = $voicePlayback.get().sequence
void playSpeechText(response.text, { source: 'voice-conversation' })
.catch(error => notifyError(error, voiceCopy.playbackFailed))
.finally(() => {
@ -411,6 +424,7 @@ export function useVoiceConversation({
(responseId: string) => {
responseIdRef.current = responseId
spokenSourceLengthRef.current = 0
speechStartSequenceRef.current = $voicePlayback.get().sequence
setStatus('speaking')
let barged = false

View file

@ -25,7 +25,7 @@ interface NoticeStep {
}
// Walks the same lifecycle the Nous credits tracker drives: usage escalates in
// place (50→75→90, one key), then grant-spent, then the depleted/restored pair.
// place (50→75→90, one key), then the depleted/restored pair.
// Wraps around. These are all separate SHOW steps; the stepper auto-clears the
// previous notice when the key changes, so the demo shows one toast at a time
// (real usage CAN stack these, but that's noise when you're eyeballing a single
@ -34,7 +34,6 @@ const STEPS: readonly NoticeStep[] = [
{ key: 'credits.usage', kind: 'sticky', level: 'info', text: "• You've used $110.00 of your $220.00 cap" },
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $165.00 of your $220.00 cap" },
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $198.00 of your $220.00 cap" },
{ key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $12.00 top-up left' },
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 }
]

View file

@ -558,6 +558,7 @@ export function useSessionActions({
resetViewSync()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
// A session is EITHER the main thread OR a tile — never both. openSessionTile
// enforces this from the tile side (it refuses to tile the selected session);
// this enforces it from the main side. Loading an existing session into main
@ -570,6 +571,7 @@ export function useSessionActions({
if ($sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) {
closeSessionTile(storedSessionId)
}
// Optimistically clear any prior resume-failure latch for this session:
// we're attempting a fresh resume, so the self-heal in use-route-resume
// must not keep treating it as stranded. It's re-armed below only if THIS

View file

@ -13,6 +13,7 @@ import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, FRE
import { FallbackModelsField } from './fallback-models-field'
import { fieldCopyForSchemaKey } from './field-copy'
import { ListRow } from './primitives'
import { SearchableSelect } from './searchable-select'
/**
* One generic config row: label + description resolved from the i18n field
@ -93,6 +94,23 @@ export function ConfigField({
const selectOptions = enumOptions ?? (schema.type === 'select' ? (schema.options ?? []).map(String) : undefined)
// Large closed-world lists (e.g. ~590 IANA timezones) get a searchable
// Popover + cmdk combobox instead of a closed Select dropdown. The schema
// opt-in via `searchable: true` keeps this deterministic — no field
// accidentally triggers based on dynamic option count.
if (selectOptions && schema.searchable) {
return row(
<SearchableSelect
clearLabel={schema.clearable ? c.systemDefault : undefined}
emptyMessage={c.noResults}
onChange={next => onChange(next)}
options={selectOptions.filter(o => o !== '')}
placeholder={c.searchPlaceholder}
value={String(value ?? '')}
/>
)
}
// Voice/model name fields are open-world (custom voice IDs, cloned voices,
// brand-new model names) — render a free-input combobox where the known
// options are datalist suggestions instead of a closed Select gate.

View file

@ -564,7 +564,7 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
repoScanRoots: 'Folders to scan. Leave empty to scan your home directory.',
repoScanExcludePaths: 'Folders and their descendants to skip during repository discovery.'
},
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
timezone: 'IANA timezone identifier. Blank uses the system timezone.',
agent: {
imageInputMode: 'Controls how image attachments are sent to the model.',
maxTurns: 'Upper bound for tool-calling turns before Hermes stops a run.'

View file

@ -0,0 +1,137 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { ConfigFieldSchema } from '@/types/hermes'
import { ConfigField } from './config-field'
import { rankSearchOption, SearchableSelect } from './searchable-select'
// Radix Popover + cmdk call scrollIntoView / pointer-capture / ResizeObserver
// APIs jsdom lacks.
class TestResizeObserver {
disconnect() {}
observe() {}
unobserve() {}
}
beforeAll(() => {
vi.stubGlobal('ResizeObserver', TestResizeObserver)
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('rankSearchOption', () => {
it('ranks a final-segment match above a mid-path match', () => {
// "york" hits the city segment of America/New_York (score 2) but only a
// mid-path segment of America/New_York/Special (score 1).
expect(rankSearchOption('America/New_York', 'york')).toBe(2)
expect(rankSearchOption('America/New_York/Special', 'york')).toBe(1)
expect(rankSearchOption('America/New_York', 'york')).toBeGreaterThan(
rankSearchOption('America/New_York/Special', 'york')
)
})
it('is case-insensitive', () => {
expect(rankSearchOption('Asia/Kolkata', 'KOLKATA')).toBe(2)
expect(rankSearchOption('ASIA/KOLKATA', 'kolkata')).toBe(2)
})
it('scores a substring match anywhere as 1', () => {
expect(rankSearchOption('America/New_York', 'amer')).toBe(1)
})
it('scores a slashless option by plain substring', () => {
expect(rankSearchOption('UTC', 'ut')).toBe(1)
expect(rankSearchOption('UTC', 'xyz')).toBe(0)
})
it('scores a non-match as 0', () => {
expect(rankSearchOption('Europe/Berlin', 'tokyo')).toBe(0)
})
})
describe('SearchableSelect', () => {
const options = ['America/New_York', 'Asia/Kolkata', 'Europe/Berlin', 'UTC']
it('opens, filters, and selects an option', () => {
const onChange = vi.fn()
render(<SearchableSelect onChange={onChange} options={options} placeholder="Search…" value="" />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.change(screen.getByPlaceholderText('Search…'), { target: { value: 'kolkata' } })
fireEvent.click(screen.getByText('Asia/Kolkata'))
expect(onChange).toHaveBeenCalledWith('Asia/Kolkata')
})
it('renders the clear item when clearLabel is set and selecting it resets to blank', () => {
const onChange = vi.fn()
render(<SearchableSelect clearLabel="System default" onChange={onChange} options={options} value="Asia/Kolkata" />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByText('System default'))
expect(onChange).toHaveBeenCalledWith('')
})
it('omits the clear item without clearLabel', () => {
render(<SearchableSelect onChange={vi.fn()} options={options} value="" />)
fireEvent.click(screen.getByRole('combobox'))
expect(screen.queryByText('System default')).toBeNull()
})
it('shows the placeholder when the value is blank', () => {
render(<SearchableSelect onChange={vi.fn()} options={options} placeholder="Search…" value="" />)
expect(screen.getByRole('combobox').textContent).toContain('Search…')
})
})
describe('ConfigField searchable routing', () => {
const searchableSchema: ConfigFieldSchema = {
type: 'select',
searchable: true,
clearable: true,
options: ['America/New_York', 'UTC']
}
it('routes searchable select schemas to SearchableSelect, not a free-text input', () => {
const { container } = render(
<ConfigField onChange={vi.fn()} schema={searchableSchema} schemaKey="timezone" value="UTC" />
)
// The searchable trigger renders; the generic free-text <Input> does not.
expect(container.querySelector('[data-slot="searchable-select-trigger"]')).not.toBeNull()
expect(container.querySelector('input[type="text"]')).toBeNull()
})
it('keeps plain string schemas on the free-text input', () => {
const { container } = render(
<ConfigField onChange={vi.fn()} schema={{ type: 'string' }} schemaKey="some.other.key" value="hello" />
)
expect(container.querySelector('[data-slot="searchable-select-trigger"]')).toBeNull()
expect(screen.getByDisplayValue('hello')).not.toBeNull()
})
it('surfaces the clear item via schema.clearable and resets to blank', () => {
const onChange = vi.fn()
render(<ConfigField onChange={onChange} schema={searchableSchema} schemaKey="timezone" value="UTC" />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByText('System default'))
expect(onChange).toHaveBeenCalledWith('')
})
})

View file

@ -0,0 +1,115 @@
import { useCallback, useRef, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { controlVariants } from '@/components/ui/control'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
/**
* cmdk filter score for one option. Case-insensitive substring match, with
* the final path segment (after the last "/") ranked above matches anywhere
* else so "york" ranks "America/New_York" over "America/New_York/Special".
* Exported for tests.
*/
export function rankSearchOption(option: string, search: string): number {
const lower = search.toLowerCase()
const itemLower = option.toLowerCase()
const slash = itemLower.lastIndexOf('/')
if (slash !== -1 && itemLower.slice(slash + 1).includes(lower)) {
return 2
}
if (itemLower.includes(lower)) {
return 1
}
return 0
}
/**
* Searchable select for large option lists (e.g. ~590 IANA timezones).
* Built on Popover + cmdk Command the same stack as Shadcn's Combobox.
*
* The trigger renders like the existing closed `<Select>` but opens into a
* searchable Command palette. Closed-world only: the user must pick from the
* list; arbitrary text entry is not supported.
*
* `ConfigField` routes here when `schema.searchable === true`.
*/
export function SearchableSelect({
value,
onChange,
options,
placeholder = 'Search…',
emptyMessage = 'No results found.',
clearLabel
}: {
value: string
onChange: (value: string) => void
options: string[]
placeholder?: string
emptyMessage?: string
/** When set, prepends a "clear" item that sets the value to ''.
* Matches the existing <Select> pattern of EMPTY_SELECT_VALUE + "(none)". */
clearLabel?: string
}) {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const handleSelect = useCallback(
(selected: string) => {
onChange(selected)
setOpen(false)
},
[onChange]
)
const displayValue = value !== '' && value !== undefined ? value : placeholder
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<button
aria-expanded={open}
aria-haspopup="listbox"
className={cn(
controlVariants(),
'flex items-center justify-between gap-2 whitespace-nowrap',
!value && 'text-muted-foreground'
)}
data-slot="searchable-select-trigger"
ref={triggerRef}
role="combobox"
type="button"
>
<span className="truncate">{displayValue}</span>
<Codicon className="shrink-0 opacity-60" name={open ? 'chevron-up' : 'chevron-down'} size="1rem" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
<Command filter={rankSearchOption}>
<CommandInput autoFocus placeholder={placeholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{clearLabel && (
<CommandItem onSelect={() => handleSelect('')} value={clearLabel}>
<Codicon className={cn('mr-2 size-4', value === '' ? 'opacity-100' : 'opacity-0')} name="check" />
{clearLabel}
</CommandItem>
)}
{options.map(option => (
<CommandItem key={option} onSelect={() => handleSelect(option)} value={option}>
<Codicon className={cn('mr-2 size-4', option === value ? 'opacity-100' : 'opacity-0')} name="check" />
{option}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View file

@ -259,7 +259,11 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
textValue=""
>
<span className="truncate">{group.provider.name}</span>
<DisclosureCaret className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100" open={!collapsed} size="0.625rem" />
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100"
open={!collapsed}
size="0.625rem"
/>
</DropdownMenuItem>
{!collapsed &&
group.families.map(family => {

View file

@ -1,9 +1,9 @@
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { MarkdownTextContent } from './markdown-text'
import { MarkdownImage, MarkdownTextContent } from './markdown-text'
const REMOTE_IMAGE_PATH = '/home/user/project/images/remote-preview.png'
const REMOTE_IMAGE_DATA_URL = 'data:image/png;base64,cmVtb3RlLWltYWdl'
@ -50,3 +50,32 @@ describe('MarkdownTextContent remote images', () => {
})
})
})
// Regression for #40896: generated media often arrives as image markdown
// (`![clip](clip.mp4)`). A raw <img> with a video/audio source paints a
// broken-image icon even though the file is valid, so MarkdownImage must route
// video/audio sources to the proper <video>/<audio> element.
describe('MarkdownImage media routing', () => {
afterEach(cleanup)
it('renders a <video> (not a broken <img>) for a video source', async () => {
const { container } = render(<MarkdownImage alt="clip" src="file:///tmp/clip.mp4" />)
await waitFor(() => expect(container.querySelector('video')).not.toBeNull())
expect(container.querySelector('img')).toBeNull()
})
it('renders an <audio> element for an audio source', async () => {
const { container } = render(<MarkdownImage alt="note" src="file:///tmp/note.mp3" />)
await waitFor(() => expect(container.querySelector('audio')).not.toBeNull())
expect(container.querySelector('img')).toBeNull()
})
it('still renders an <img> for an image source', () => {
const { container } = render(<MarkdownImage alt="pic" src="file:///tmp/pic.png" />)
expect(container.querySelector('video')).toBeNull()
expect(container.querySelector('audio')).toBeNull()
})
})

View file

@ -27,8 +27,8 @@ import {
mediaKind,
mediaName,
mediaPathFromMarkdownHref,
mediaStreamUrl,
resolveMediaDisplaySrc
resolveMediaDisplaySrc,
resolveMediaPlaybackSrc
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { sessionRefFromMarkdownHref } from '@/lib/session-refs'
@ -98,16 +98,6 @@ function preprocessWithTailRepair(text: string): string {
}
}
async function mediaSrc(path: string): Promise<string> {
// Stream audio/video through the custom protocol: data URLs are capped and
// load the whole file into memory, which broke playback for larger videos.
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
return mediaStreamUrl(path)
}
return resolveMediaDisplaySrc(path)
}
function useOpenMediaFile(path: string) {
const [openFailed, setOpenFailed] = useState(false)
@ -170,7 +160,7 @@ function MediaAttachment({ path }: { path: string }) {
}
}
void mediaSrc(path)
void resolveMediaPlaybackSrc(path)
.then(value => {
if (value.startsWith('blob:')) {
objectUrl = value
@ -313,7 +303,29 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
)
}
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
// Generated/inline media often arrives as image markdown — `![clip](clip.mp4)`.
// A raw <img> with a video/audio source renders a broken-image icon (the file is
// valid, the browser just can't paint it as an image), so route those sources to
// MediaAttachment, which picks the right <video>/<audio> element (streaming
// protocol + open-externally fallback) by media kind. Detection is
// extension-based via mediaKind(); an extension-less/data/blob video URL still
// resolves to 'file' and falls through to the image path as before.
//
// This is split from the image path because that path is built on hooks: a
// conditional return inside it would have to sit after every hook call, which
// would still fire an image resolve for media we never render as an image.
export function MarkdownImage(props: ComponentProps<'img'>) {
const rawSrc = typeof props.src === 'string' ? props.src : ''
const kind = rawSrc ? mediaKind(rawSrc) : 'file'
if (kind === 'video' || kind === 'audio') {
return <MediaAttachment path={rawSrc} />
}
return <MarkdownImageContent {...props} />
}
function MarkdownImageContent({ className, src, alt, ...props }: ComponentProps<'img'>) {
const rawSrc = typeof src === 'string' ? src : ''
const [resolvedSrc, setResolvedSrc] = useState(() => (rawSrc && isInlineMediaSrc(rawSrc) ? rawSrc : ''))
const [failed, setFailed] = useState(false)

View file

@ -108,9 +108,11 @@ export function ModelVisibilityDialog({
}
const allFamilies = collapseModelFamilies(provider.models ?? [])
const onCount = allFamilies.filter(family =>
visible.has(modelVisibilityKey(provider.slug, family.id))
).length
const checkState = onCount === 0 ? false : onCount === allFamilies.length ? true : 'indeterminate'
const collapsed = collapsedProviders.includes(provider.slug) && !q
@ -138,15 +140,16 @@ export function ModelVisibilityDialog({
const key = modelVisibilityKey(provider.slug, family.id)
return (
<label
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs"
key={key}
>
<label className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs" key={key}>
<span className="min-w-0 flex-1 truncate">
{name}
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
</span>
<Switch checked={visible.has(key)} onCheckedChange={() => toggle(provider, family.id)} size="xs" />
<Switch
checked={visible.has(key)}
onCheckedChange={() => toggle(provider, family.id)}
size="xs"
/>
</label>
)
})}

View file

@ -3,11 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
checkHermesUpdate,
getActionStatus,
getElevenLabsVoices,
getMemoryProviderConfig,
getStatus,
restartGateway,
saveMemoryProviderConfig,
setApiRequestProfile,
speakText,
transcribeAudio,
updateHermes
} from './hermes'
@ -59,4 +62,22 @@ describe('backend action helpers are profile-scoped', () => {
expect(call[0].profile).toBe('coder')
}
})
// Audio endpoints (transcribe / speak / voices) write to the active
// profile's config in the settings UI but historically called the backend
// without a profile scope, so playback used the default profile's TTS/voice
// config instead of the active one (#53441).
it('forwards the active profile to audio endpoints', () => {
setApiRequestProfile('jarvis')
void transcribeAudio('data:audio/webm;base64,AAAA', 'audio/webm')
void speakText('hello')
void getElevenLabsVoices()
expect(api.mock.calls).toHaveLength(3)
for (const call of api.mock.calls) {
expect(call[0].profile).toBe('jarvis')
}
})
})

View file

@ -19,6 +19,7 @@ import {
listSessions,
listSidebarSessions,
resetSidebarBatchCapability,
setApiRequestProfile,
speakText,
transcribeAudio
} from './hermes'
@ -44,6 +45,7 @@ describe('Hermes REST helpers', () => {
})
afterEach(() => {
setApiRequestProfile(null)
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'hermesDesktop')
})
@ -337,7 +339,8 @@ describe('Hermes REST helpers', () => {
expect(audioSpeakRequestTimeoutMs('x'.repeat(100_000))).toBe(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS)
})
it('uses an extended timeout for blocking TTS synthesis', async () => {
it('routes blocking TTS synthesis through the active profile backend', async () => {
setApiRequestProfile('rhaegal')
api.mockResolvedValueOnce({
data_url: 'data:audio/mpeg;base64,AA==',
mime_type: 'audio/mpeg',
@ -356,6 +359,7 @@ describe('Hermes REST helpers', () => {
body: { text: 'Read this aloud' },
method: 'POST',
path: '/api/audio/speak',
profile: 'rhaegal',
timeoutMs: AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS
})
})

View file

@ -249,6 +249,13 @@ function profileScoped(profile?: null | string): { profile?: string } {
return selected ? { profile: selected } : {}
}
/** Profile that profile-scoped REST/WS calls should target (null primary).
* Read-only twin of setApiRequestProfile for modules (e.g. voice playback)
* that build their own connection URLs and must stay on the same backend. */
export function getApiRequestProfile(): null | string {
return _apiProfile
}
/** Options for a plugin REST call mirrors the app's own `hermesDesktop.api`
* shape, minus the path (which is namespace-derived). */
export interface PluginRestOptions {
@ -1513,6 +1520,7 @@ export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<Aud
return window.hermesDesktop.api<AudioTranscriptionResponse>({
path: '/api/audio/transcribe',
method: 'POST',
...profileScoped(),
body: {
data_url: dataUrl,
mime_type: mimeType
@ -1526,6 +1534,7 @@ export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<Aud
export function speakText(text: string): Promise<AudioSpeakResponse> {
return window.hermesDesktop.api<AudioSpeakResponse>({
...profileScoped(),
path: '/api/audio/speak',
method: 'POST',
body: { text },
@ -1538,7 +1547,8 @@ export function speakText(text: string): Promise<AudioSpeakResponse> {
export function getElevenLabsVoices(): Promise<ElevenLabsVoicesResponse> {
return window.hermesDesktop.api<ElevenLabsVoicesResponse>({
path: '/api/audio/elevenlabs/voices'
path: '/api/audio/elevenlabs/voices',
...profileScoped()
})
}

View file

@ -610,6 +610,9 @@ export const ar = defineLocale({
noneParen: '(لا شيء)',
notSet: 'غير مضبوط',
commaSeparated: 'قيم مفصولة بفواصل',
searchPlaceholder: 'بحث…',
noResults: 'لا توجد نتائج',
systemDefault: 'إعداد النظام الافتراضي',
loading: 'جار تحميل إعدادات Hermes...',
emptyTitle: 'لا توجد إعدادات',
emptyDesc: 'لا يحتوي هذا القسم على إعدادات قابلة للتعديل.',

View file

@ -538,6 +538,9 @@ export const en: Translations = {
builtinOnly: 'Built-in only',
notSet: 'Not set',
commaSeparated: 'comma-separated values',
searchPlaceholder: 'Search…',
noResults: 'No results found',
systemDefault: 'System default',
loading: 'Loading Hermes configuration...',
emptyTitle: 'Nothing to configure',
emptyDesc: 'This section has no adjustable settings.',

View file

@ -639,6 +639,9 @@ export const ja = defineLocale({
builtinOnly: '内蔵のみ',
notSet: '未設定',
commaSeparated: 'カンマ区切りの値',
searchPlaceholder: '検索…',
noResults: '結果が見つかりません',
systemDefault: 'システムのデフォルト',
loading: 'Hermes の設定を読み込み中...',
emptyTitle: '設定項目がありません',
emptyDesc: 'このセクションには調整できる設定がありません。',

View file

@ -445,6 +445,9 @@ export interface Translations {
builtinOnly: string
notSet: string
commaSeparated: string
searchPlaceholder: string
noResults: string
systemDefault: string
loading: string
emptyTitle: string
emptyDesc: string

View file

@ -627,6 +627,9 @@ export const zhHant = defineLocale({
builtinOnly: '僅內建',
notSet: '未設定',
commaSeparated: '逗號分隔的值',
searchPlaceholder: '搜尋…',
noResults: '找不到結果',
systemDefault: '系統預設',
loading: '正在載入 Hermes 設定...',
emptyTitle: '無可設定項目',
emptyDesc: '此區段沒有可調整的設定。',

View file

@ -748,6 +748,9 @@ export const zh: Translations = {
builtinOnly: '仅内置',
notSet: '未设置',
commaSeparated: '逗号分隔的值',
searchPlaceholder: '搜索…',
noResults: '未找到结果',
systemDefault: '系统默认',
loading: '正在加载 Hermes 配置...',
emptyTitle: '无可配置项',
emptyDesc: '此分区没有可调整的设置。',

View file

@ -11,7 +11,8 @@ import {
isInlineMediaSrc,
isRemoteGateway,
mediaExternalUrl,
resolveMediaDisplaySrc
resolveMediaDisplaySrc,
resolveMediaPlaybackSrc
} from './media'
describe('isRemoteGateway', () => {
@ -139,6 +140,40 @@ describe('resolveMediaDisplaySrc', () => {
})
})
describe('resolveMediaPlaybackSrc', () => {
afterEach(() => {
vi.unstubAllGlobals()
$connection.set(null)
})
it('keeps a remote HTTPS video URL unchanged', async () => {
vi.stubGlobal('window', { hermesDesktop: { api: vi.fn() } })
$connection.set({ mode: 'remote', baseUrl: 'https://gateway.test', token: 'secret' } as never)
await expect(resolveMediaPlaybackSrc('https://cdn.example.com/render.mp4')).resolves.toBe(
'https://cdn.example.com/render.mp4'
)
})
it('routes gateway-local video through the authenticated download endpoint', async () => {
vi.stubGlobal('window', { hermesDesktop: { api: vi.fn() } })
$connection.set({ mode: 'remote', baseUrl: 'https://gateway.test', token: 's e/cret' } as never)
await expect(resolveMediaPlaybackSrc('/root/outputs/render.mp4')).resolves.toBe(
'https://gateway.test/api/files/download?path=%2Froot%2Foutputs%2Frender.mp4&token=s%20e%2Fcret'
)
})
it('uses the Electron streaming protocol for local desktop video', async () => {
vi.stubGlobal('window', { hermesDesktop: { api: vi.fn() } })
$connection.set({ mode: 'local' } as never)
await expect(resolveMediaPlaybackSrc('C:\\renders\\demo.mp4')).resolves.toBe(
'hermes-media://stream/C%3A%5Crenders%5Cdemo.mp4'
)
})
})
describe('gatewayMediaDataUrl', () => {
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/read-data-url?')) {

View file

@ -82,6 +82,22 @@ export async function resolveMediaDisplaySrc(path: string): Promise<string> {
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
}
// Audio/video need a seekable source instead of a whole-file data URL. Keep
// remote URLs untouched, route gateway-local files through the authenticated
// download endpoint, and reserve the Electron protocol for files on this
// desktop machine.
export async function resolveMediaPlaybackSrc(path: string): Promise<string> {
if (isInlineMediaSrc(path)) {
return path
}
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
return isRemoteGateway() ? mediaExternalUrl(path) : mediaStreamUrl(path)
}
return resolveMediaDisplaySrc(path)
}
// Resolve a media path to a URL the shell can open. Remote mode rewrites
// gateway-local paths to an authenticated /api/files/download URL (the file
// lives on the gateway, not this disk); local mode keeps the file:// form.

View file

@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest'
import { sanitizeTextForSpeech } from './speech-text'
describe('sanitizeTextForSpeech', () => {
it('summarizes fenced code blocks instead of reading them literally', () => {
expect(sanitizeTextForSpeech('Here is code:\n```ts\nconst x = 1\n```\nDone.')).toBe(
'Here is code: code block omitted Done.'
)
})
it('still keeps normal prose and inline code readable', () => {
expect(sanitizeTextForSpeech('Use `git status` after the change.')).toBe(
'Use git status after the change.'
)
})
it('skips markdown table data while preserving surrounding human text', () => {
const text = `Here is the quick takeaway: the totals remain unchanged.
| Item | Value | Notes |
| --- | ---: | --- |
| Example A | 10 | first row |
| Example B | 20 | second row |
Full detail stays visible on screen.`
expect(sanitizeTextForSpeech(text)).toBe(
'Here is the quick takeaway: the totals remain unchanged. Full detail stays visible on screen.'
)
})
it('does not strip prose that merely contains a pipe character', () => {
const text = 'Use the summary first | keep the table on screen when it matters.'
expect(sanitizeTextForSpeech(text)).toBe('Use the summary first | keep the table on screen when it matters.')
})
it('does not duplicate punctuation across paragraph breaks', () => {
const text = `First sentence.
Second sentence.`
expect(sanitizeTextForSpeech(text)).toBe('First sentence. Second sentence.')
})
it.each([
['markdown emphasis', '**First sentence.**\n\nSecond sentence.', 'First sentence. Second sentence.'],
['a closing quote', '“First sentence.”\n\nSecond sentence.', '“First sentence.” Second sentence.'],
['a closing parenthesis', '(First sentence.)\n\nSecond sentence.', '(First sentence.) Second sentence.']
])('does not duplicate punctuation after %s', (_label, text, expected) => {
expect(sanitizeTextForSpeech(text)).toBe(expected)
})
it('skips markdown tables without leading and trailing pipes', () => {
const text = `Main takeaway: total is unchanged.
Item | Value
--- | ---:
Example A | 10
Example B | 20
Done.`
expect(sanitizeTextForSpeech(text)).toBe('Main takeaway: total is unchanged. Done.')
})
it('skips markdown tables nested inside blockquotes', () => {
const text = `Before the table.
> | Item | Value |
> | --- | ---: |
> | Example A | 10 |
> | Example B | 20 |
After the table.`
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
})
it('allows marker padding plus three spaces in blockquoted tables', () => {
const text = `Before the table.
> | Item | Value |
> | --- | ---: |
> | Example A | 10 |
After the table.`
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
})
it('skips explicit single-column markdown tables', () => {
const text = `Before the table.
| Item |
| --- |
| Example A |
After the table.`
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
})
it('preserves rows outside a table blockquote', () => {
const text = `> | Item | Value |
> | --- | ---: |
> | Example A | 10 |
Outside | prose`
expect(sanitizeTextForSpeech(text)).toBe('Outside | prose')
})
it('preserves malformed tables with mismatched column counts', () => {
const text = `Heading | Detail
--- | --- | ---
Keep this prose.`
expect(sanitizeTextForSpeech(text)).toContain('Heading | Detail')
})
it('skips GFM body rows whose cell counts differ from the header', () => {
const text = `Before the table.
| Item | Value |
| --- | ---: |
| Example A |
| Example B | 20 | ignored |
After the table.`
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
})
it('skips tables containing escaped pipe characters', () => {
const text = `Before the table.
| Item \\| detail | Value |
| --- | ---: |
| Example A | 10 |
After the table.`
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
})
it('preserves indented code that resembles a table', () => {
const text = ` Item | Value
--- | ---
Example A | 10`
expect(sanitizeTextForSpeech(text)).toContain('Item | Value')
})
})

View file

@ -1,9 +1,11 @@
const EMOJI_RE = /(?:[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]|[\u{FE0F}\u{200D}]|[\u{E0020}-\u{E007F}])+/gu
const FENCED_CODE_RE = /```[\s\S]*?(?:```|$)/g
const CODE_BLOCK_SUMMARY = ' code block omitted '
const INLINE_CODE_RE = /`([^`]+)`/g
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g
const PARAGRAPH_BREAK_RE = /[ \t]*\n{2,}[ \t]*/g
const PUNCTUATED_PARAGRAPH_BREAK_RE = /([.!?])([*_~`>"'’”)}\]]*)[ \t]*\n{2,}[ \t]*/g
const SOFT_BREAK_RE = /[ \t]*\n[ \t]*/g
const THINKING_PREFIX_RE =
@ -11,17 +13,147 @@ const THINKING_PREFIX_RE =
const URL_RE = /\bhttps?:\/\/\S+/gi
const MARKDOWN_TABLE_DELIMITER_CELL_RE = /^:?-{3,}:?$/
interface MarkdownTableRow {
blockquoteDepth: number
cells: string[]
}
function isUnescapedPipe(row: string, index: number): boolean {
let backslashes = 0
for (let cursor = index - 1; cursor >= 0 && row[cursor] === '\\'; cursor -= 1) {
backslashes += 1
}
return backslashes % 2 === 0
}
function splitMarkdownTableCells(row: string): string[] {
const cells: string[] = []
let cellStart = 0
for (let index = 0; index < row.length; index += 1) {
if (row[index] === '|' && isUnescapedPipe(row, index)) {
cells.push(row.slice(cellStart, index).trim())
cellStart = index + 1
}
}
cells.push(row.slice(cellStart).trim())
return cells
}
function parseMarkdownTableRow(line: string): MarkdownTableRow | null {
let row = line
let blockquoteDepth = 0
while (true) {
const indentation = row.match(/^[ \t]*/)?.[0] ?? ''
if (indentation.includes('\t') || indentation.length > 3) {
return null
}
row = row.slice(indentation.length)
if (!row.startsWith('>')) {
break
}
blockquoteDepth += 1
row = row.slice(1)
if (row.startsWith(' ')) {
row = row.slice(1)
}
}
row = row.trimEnd()
const pipeIndexes = [...row.matchAll(/\|/g)].map(match => match.index).filter(index => isUnescapedPipe(row, index))
if (pipeIndexes.length === 0) {
return null
}
const hasLeadingPipe = pipeIndexes[0] === 0
const hasTrailingPipe = pipeIndexes.at(-1) === row.length - 1
if (hasLeadingPipe) {
row = row.slice(1)
}
if (hasTrailingPipe) {
row = row.slice(0, -1)
}
const cells = splitMarkdownTableCells(row)
if (cells.length < 2 && !(hasLeadingPipe && hasTrailingPipe && cells.length === 1)) {
return null
}
return { blockquoteDepth, cells }
}
function stripMarkdownTables(text: string): string {
const lines = text.replace(/\r\n?/g, '\n').split('\n')
const tableLines = new Set<number>()
let index = 1
while (index < lines.length) {
const delimiterRow = parseMarkdownTableRow(lines[index])
const headerRow = parseMarkdownTableRow(lines[index - 1])
if (
!delimiterRow ||
!headerRow ||
!delimiterRow.cells.every(cell => MARKDOWN_TABLE_DELIMITER_CELL_RE.test(cell)) ||
headerRow.cells.length !== delimiterRow.cells.length ||
headerRow.blockquoteDepth !== delimiterRow.blockquoteDepth
) {
index += 1
continue
}
tableLines.add(index - 1)
tableLines.add(index)
let rowIndex = index + 1
for (; rowIndex < lines.length; rowIndex += 1) {
const bodyRow = parseMarkdownTableRow(lines[rowIndex])
if (!bodyRow || bodyRow.blockquoteDepth !== delimiterRow.blockquoteDepth) {
break
}
tableLines.add(rowIndex)
}
index = rowIndex
}
return lines.filter((_, index) => !tableLines.has(index)).join('\n')
}
function normalizeLineBreaks(text: string): string {
return text
.replace(/\r\n?/g, '\n')
.replace(/(\p{L})-\n(\p{L})/gu, '$1$2')
.replace(PUNCTUATED_PARAGRAPH_BREAK_RE, '$1$2 ')
.replace(PARAGRAPH_BREAK_RE, '. ')
.replace(SOFT_BREAK_RE, ' ')
}
export function sanitizeTextForSpeech(text: string): string {
return normalizeLineBreaks(text)
.replace(FENCED_CODE_RE, ' ')
return normalizeLineBreaks(stripMarkdownTables(text))
.replace(FENCED_CODE_RE, CODE_BLOCK_SUMMARY)
.replace(THINKING_PREFIX_RE, ' ')
.replace(MARKDOWN_LINK_RE, '$1')
.replace(INLINE_CODE_RE, '$1')

View file

@ -1,6 +1,6 @@
import { resolveGatewayWsUrl } from '@hermes/shared'
import { speakText } from '@/hermes'
import { getApiRequestProfile, speakText } from '@/hermes'
import {
$voicePlayback,
setVoicePlaybackState,
@ -104,9 +104,11 @@ async function resolveSpeakStreamUrl(): Promise<null | string> {
}
try {
// Mint a fresh credential (single-use ticket in OAuth mode), then swap the
// gateway endpoint for the PCM one — auth is shared across WS routes.
const wsUrl = await resolveGatewayWsUrl(desktop, await desktop.getConnection())
// Mint a fresh credential (single-use ticket in OAuth mode) for the
// ACTIVE profile's backend, then swap the gateway endpoint for the PCM
// one — auth is shared across WS routes.
const profile = getApiRequestProfile()
const wsUrl = await resolveGatewayWsUrl(desktop, await desktop.getConnection(profile))
const url = new URL(wsUrl)
if (!url.pathname.endsWith('/api/ws')) {
@ -115,6 +117,12 @@ async function resolveSpeakStreamUrl(): Promise<null | string> {
url.pathname = url.pathname.replace(/\/api\/ws$/, '/api/audio/speak-stream')
// The backend resolves the TTS provider chain from this profile's
// config/.env (same seam as /api/pty?profile=).
if (profile) {
url.searchParams.set('profile', profile)
}
return url.toString()
} catch {
return null

View file

@ -83,10 +83,15 @@ test('the leading severity glyph is stripped from the toast message', () => {
})
test('the trailing "· detail" is split off as a secondary meta line, not inlined', () => {
// grant_spent still carries a `· detail` tail.
const grant = noticeToToast({ key: 'credits.grant_spent', level: 'info', text: '• Grant spent · $12.00 top-up left' })
expect(grant?.message).toBe('Grant spent')
expect(grant?.meta).toBe('$12.00 top-up left')
// Detail-carrying notices split on the first ` · `.
const paused = noticeToToast({
key: 'credits.depleted',
level: 'error',
text: '✕ Credit access paused · run /topup to top up'
})
expect(paused?.message).toBe('Credit access paused')
expect(paused?.meta).toBe('run /topup to top up')
// The usage line has no middot → whole line is the message, no meta.
const plain = noticeToToast(usage())
@ -95,7 +100,10 @@ test('the trailing "· detail" is split off as a secondary meta line, not inline
})
test('splitMeta splits on the first space-middot-space only', () => {
expect(splitMeta('Grant spent · $12.00 top-up left')).toEqual(['Grant spent', '$12.00 top-up left'])
expect(splitMeta('Credit access paused · run /topup to top up')).toEqual([
'Credit access paused',
'run /topup to top up'
])
expect(splitMeta('Credit access restored')).toEqual(['Credit access restored', undefined])
// Interior middots after the first split stay in the meta.
expect(splitMeta('a · b · c')).toEqual(['a', 'b · c'])
@ -117,7 +125,7 @@ test('usageFraction derives $used / $cap from the notice text', () => {
expect(usageFraction("You've used $15.00 of your $20.00 cap")).toBeCloseTo(0.75)
expect(usageFraction("You've used $198.00 of your $220.00 cap")).toBeCloseTo(0.9)
// Fewer than two amounts, or a zero cap → no fraction.
expect(usageFraction('Grant spent')).toBeNull()
expect(usageFraction('Credit access restored')).toBeNull()
expect(usageFraction("You've used $5.00 of your $0.00 cap")).toBeNull()
expect(usageFraction(undefined)).toBeNull()
})
@ -138,7 +146,7 @@ test('usage accent stays muted below 75%, then ramps orange → red', () => {
test('terminal credit states carry their own accent; others stay default', () => {
expect(noticeAccent({ key: 'credits.depleted', text: '✕ paused' })).toBe('var(--ui-red)')
expect(noticeAccent({ key: 'credits.restored', text: '✓ restored' })).toBe('var(--ui-green)')
expect(noticeAccent({ key: 'credits.grant_spent', text: '• Grant spent' })).toBeUndefined()
expect(noticeAccent({ key: 'credits.usage', text: '• Credits update' })).toBeUndefined()
expect(noticeAccent(undefined)).toBeUndefined()
})
@ -189,7 +197,7 @@ test('clearAgentNotice dismisses only the matching key', () => {
test('only credits.depleted and credits.restored map to a native notification', () => {
expect(nativeNoticeInput(usage({ key: 'credits.usage' }), 'Credits')).toBeNull()
expect(nativeNoticeInput(usage({ key: 'credits.grant_spent' }), 'Credits')).toBeNull()
expect(nativeNoticeInput(usage({ key: 'credits.other' }), 'Credits')).toBeNull()
expect(nativeNoticeInput({ text: 'x', key: undefined }, 'Credits')).toBeNull()
expect(nativeNoticeInput({ text: '', key: 'credits.depleted' }, 'Credits')).toBeNull()
})

View file

@ -175,8 +175,8 @@ export function clearAgentNotice(key: string | undefined): void {
// Only these two credit notices are urgent enough to break through as a native
// OS notification (when Hermes is backgrounded). The escalating usage line
// (`credits.usage`) and the grant-spent notice stay in-app toasts only — they
// aren't worth interrupting the user's OS for.
// (`credits.usage`) stays an in-app toast only — it isn't worth interrupting
// the user's OS for.
const NATIVE_NOTICE_KEYS = new Set(['credits.depleted', 'credits.restored'])
/**

View file

@ -121,6 +121,7 @@ function expandProviderDefaults(provider: ModelOptionProvider, target: Set<strin
const families = collapseModelFamilies(provider.models ?? [])
const featured = provider.featured_models ?? []
const defaults = featured.length
? families.filter(family => featured.includes(family.id))
: families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)

View file

@ -2,6 +2,12 @@ export interface ConfigFieldSchema {
category?: string
description?: string
options?: unknown[]
/** When true, renders a SearchableSelect (Popover + cmdk) instead of the
* closed `<Select>` dropdown. For large option lists like IANA timezones. */
searchable?: boolean
/** When true, a searchable select prepends a "clear" item that resets the
* value to ''. Matches the existing <Select> EMPTY_SELECT_VALUE pattern. */
clearable?: boolean
type?: 'boolean' | 'list' | 'number' | 'select' | 'string' | 'text'
}

View file

@ -1109,11 +1109,21 @@ platform_toolsets:
#
# tts:
# provider: "gemini"
# speed: 1.0 # global speed multiplier (provider-specific overrides this)
# gemini:
# model: "gemini-3.1-flash-tts-preview"
# voice: "Kore"
# audio_tags: false
# persona_prompt_file: "" # e.g. ~/.hermes/tts/radio-host.md
# xai:
# voice_id: "eve" # built-in or custom voice ID from xAI Console
# language: "en" # BCP-47 code ("en", "pt-BR") or "auto"
# speed: 1.0 # 0.7-1.5 playback speed
# auto_speech_tags: false # insert expressive audio tags via LLM rewrite
# text_normalization: false # normalize numbers/abbreviations/symbols
# optimize_streaming_latency: 0 # 0-2, trades quality for lower latency
# sample_rate: 24000 # 22050 / 24000 / 44100 / 48000
# bit_rate: 128000 # MP3 bitrate (codec=mp3 only)
# =============================================================================
# Voice Transcription (Speech-to-Text)
@ -1465,6 +1475,22 @@ display:
# hooks_auto_accept: false
# =============================================================================
# Telemetry
# =============================================================================
# Shared metrics are disabled by default. When enabled, Hermes writes only
# allowlisted aggregate counters and immutable JSON
# packages under $HERMES_HOME/telemetry/shared_metrics; it does not upload them.
# Packages include a random profile-scoped ID that stays stable until this
# directory is deleted. It is not derived from hardware, account, or host data.
# Successfully exported local history is retained for 30 days; pending deltas
# are retained until they can be exported.
# This profile-owned choice is not overridden by managed-scope configuration.
telemetry:
shared_metrics:
enabled: false
# =============================================================================
# Update Behavior
# =============================================================================

212
cli.py
View file

@ -1274,9 +1274,8 @@ def _notify_session_finalize(
reason: str = "shutdown",
) -> None:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_finalize",
from hermes_cli.lifecycle import finalize_session
finalize_session(
session_id=session_id,
platform=platform,
reason=reason,
@ -1304,7 +1303,7 @@ def _emit_interrupted_session_end(cli, *, reason: str = "keyboard_interrupt") ->
pass
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_end",
session_id=session_id,
@ -4093,6 +4092,23 @@ def _normalize_moa_model(model: Optional[str]) -> tuple[Optional[str], Optional[
return None, model
class _VoiceInputMessage:
"""Sentinel wrapper for voice-transcribed messages in ``_pending_input``.
Distinguishes STT output from manually typed text while voice mode is
active, so the concise-voice-response prefix is applied only to messages
that actually came from the microphone (#65827).
"""
__slots__ = ("text",)
def __init__(self, text: str):
self.text = text
def __str__(self) -> str:
return self.text
class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"""
Interactive CLI for the Hermes Agent.
@ -7721,13 +7737,21 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
lifecycle point (shutdown, /new, /reset).
"""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
event_type,
session_id=self.agent.session_id if self.agent else None,
platform=getattr(self, "platform", None) or "cli",
reason="new_session" if event_type == "on_session_reset" else "session_boundary",
)
from hermes_cli.lifecycle import finalize_session, invoke_hook
context = {
"session_id": self.agent.session_id if self.agent else None,
"platform": getattr(self, "platform", None) or "cli",
"reason": (
"new_session"
if event_type == "on_session_reset"
else "session_boundary"
),
}
if event_type == "on_session_finalize":
finalize_session(**context)
else:
invoke_hook(event_type, **context)
except Exception:
pass
@ -11778,6 +11802,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._voice_recorder._silence_duration = (
_duration if isinstance(_duration, (int, float)) and not isinstance(_duration, bool) else 3.0
)
# voice.max_recording_seconds — hard cap on a single recording's length.
# Same numeric guard as the silence params (bool excluded: a hand-edited
# ``max_recording_seconds: true`` must not become ``1`` — it falls back
# to the documented 120 default, mirroring the silence-param handling).
# An explicit numeric value <= 0 disables the cap. Previously this
# documented key was never read (dead config); wiring it here makes it
# take effect.
_max_rec = voice_cfg.get("max_recording_seconds")
self._voice_recorder._max_recording_seconds = (
(_max_rec if _max_rec > 0 else 0.0)
if isinstance(_max_rec, (int, float)) and not isinstance(_max_rec, bool)
else 120.0
)
def _on_silence():
"""Called by AudioRecorder when silence is detected after speech."""
@ -11825,14 +11862,37 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
threading.Thread(target=_refresh_level, daemon=True).start()
def _voice_stt_model(self) -> Optional[str]:
"""STT model override from config, or None for the provider default."""
"""STT model override from config, or None for the provider default.
For the local provider, prefer stt.local.model (default ``base``) so the
CLI passes a real model name into the local STT backend.
"""
try:
from hermes_cli.config import load_config
stt_config = load_config().get("stt", {})
return stt_config.get("model") if isinstance(stt_config, dict) else None
if not isinstance(stt_config, dict):
return None
provider = str(stt_config.get("provider") or "").strip().lower()
if provider == "local":
local_config = stt_config.get("local") or {}
if not isinstance(local_config, dict):
local_config = {}
return local_config.get("model") or "base"
return stt_config.get("model")
except Exception:
return None
def _voice_stt_provider(self) -> str:
"""Configured STT provider name (lowercased), or empty string."""
try:
from hermes_cli.config import load_config
stt_config = load_config().get("stt", {})
if not isinstance(stt_config, dict):
return ""
return str(stt_config.get("provider") or "").strip().lower()
except Exception:
return ""
def _voice_restart_recording_async(self) -> None:
"""Restart continuous-mode recording off-thread (start() can block)."""
def _restart_recording():
@ -11879,10 +11939,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# _voice_processing is already True (set atomically above)
if hasattr(self, '_app') and self._app:
self._app.invalidate()
_cprint(f"{_DIM}Transcribing...{_RST}")
stt_model = self._voice_stt_model()
if self._voice_stt_provider() == "local":
_cprint(
f"{_DIM}Preparing local STT model '{stt_model}' "
f"(first use may download it from Hugging Face)...{_RST}"
)
else:
_cprint(f"{_DIM}Transcribing...{_RST}")
from tools.voice_mode import transcribe_recording
result = transcribe_recording(wav_path, model=self._voice_stt_model())
result = transcribe_recording(wav_path, model=stt_model)
if result.get("success") and result.get("transcript", "").strip():
transcript = result["transcript"].strip()
@ -11896,7 +11964,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._attached_images.clear()
if hasattr(self, '_app') and self._app:
self._app.invalidate()
self._pending_input.put(transcript)
self._pending_input.put(_VoiceInputMessage(transcript))
submitted = True
elif result.get("success"):
_cprint(f"{_DIM}No speech detected.{_RST}")
@ -11925,13 +11993,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
pass
# Track consecutive no-speech cycles to avoid infinite restart loops.
stop_continuous_restart = False
if not submitted:
self._no_speech_count = getattr(self, '_no_speech_count', 0) + 1
if self._no_speech_count >= 3:
self._voice_continuous = False
self._no_speech_count = 0
_cprint(f"{_DIM}No speech detected 3 times, continuous mode stopped.{_RST}")
return
stop_continuous_restart = True
else:
self._no_speech_count = 0
@ -11939,7 +12008,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# restart recording so the user can keep talking.
# (When transcript IS submitted, process_loop handles restart
# after chat() completes.)
if self._voice_continuous and not submitted and not self._voice_recording:
if (
self._voice_continuous
and not submitted
and not self._voice_recording
and not stop_continuous_restart
):
self._voice_restart_recording_async()
def _voice_speak_response_async(self, text: str) -> None:
@ -11962,19 +12036,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
from tools.tts_tool import text_to_speech_tool
from tools.voice_mode import play_audio_file
# Strip markdown and non-speech content for cleaner TTS
tts_text = text[:4000] if len(text) > 4000 else text
tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks
tts_text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', tts_text) # [text](url) -> text
tts_text = re.sub(r'https?://\S+', '', tts_text) # URLs
tts_text = re.sub(r'\*\*(.+?)\*\*', r'\1', tts_text) # bold
tts_text = re.sub(r'\*(.+?)\*', r'\1', tts_text) # italic
tts_text = re.sub(r'`(.+?)`', r'\1', tts_text) # inline code
tts_text = re.sub(r'^#+\s*', '', tts_text, flags=re.MULTILINE) # headers
tts_text = re.sub(r'^\s*[-*]\s+', '', tts_text, flags=re.MULTILINE) # list items
tts_text = re.sub(r'---+', '', tts_text) # horizontal rules
tts_text = re.sub(r'\n{3,}', '\n\n', tts_text) # excessive newlines
tts_text = tts_text.strip()
# Strip markdown and non-speech content for cleaner TTS via the
# shared cleaner (tools/tts_text_normalize): markdown, emoji,
# <think> blocks, verifier footer, units, newline flattening.
try:
from tools.tts_text_normalize import prepare_spoken_text
tts_text = prepare_spoken_text(text, max_chars=4000)
except Exception:
# Legacy fallback pipeline — keep voice replies best-effort.
tts_text = text[:4000] if len(text) > 4000 else text
tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks
tts_text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', tts_text) # [text](url) -> text
tts_text = re.sub(r'https?://\S+', '', tts_text) # URLs
tts_text = re.sub(r'\*\*(.+?)\*\*', r'\1', tts_text) # bold
tts_text = re.sub(r'\*(.+?)\*', r'\1', tts_text) # italic
tts_text = re.sub(r'`(.+?)`', r'\1', tts_text) # inline code
tts_text = re.sub(r'^#+\s*', '', tts_text, flags=re.MULTILINE) # headers
tts_text = re.sub(r'^\s*[-*]\s+', '', tts_text, flags=re.MULTILINE) # list items
tts_text = re.sub(r'---+', '', tts_text) # horizontal rules
tts_text = re.sub(r'\n{3,}', '\n\n', tts_text) # excessive newlines
tts_text = tts_text.strip()
if not tts_text:
return
@ -11986,17 +12067,30 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
f"tts_{time.strftime('%Y%m%d_%H%M%S')}.mp3",
)
text_to_speech_tool(text=tts_text, output_path=mp3_path)
raw_result = text_to_speech_tool(text=tts_text, output_path=mp3_path)
try:
tts_result = json.loads(raw_result) if isinstance(raw_result, str) else {}
except Exception:
tts_result = {}
# Play the MP3 directly (the TTS tool returns OGG path but MP3 still exists)
if os.path.isfile(mp3_path) and os.path.getsize(mp3_path) > 0:
play_audio_file(mp3_path)
# Prefer the requested MP3 when the provider produced it. This
# preserves reliable local playback while still supporting
# providers that write to and return a different path.
audio_path = mp3_path
if not os.path.isfile(mp3_path) or os.path.getsize(mp3_path) == 0:
audio_path = tts_result.get("file_path") or mp3_path
if os.path.isfile(audio_path) and os.path.getsize(audio_path) > 0:
play_audio_file(audio_path)
# Clean up
try:
os.unlink(mp3_path)
ogg_path = mp3_path.rsplit(".", 1)[0] + ".ogg"
if os.path.isfile(ogg_path):
os.unlink(ogg_path)
cleanup_paths = {audio_path, mp3_path}
for path in list(cleanup_paths):
ogg_path = path.rsplit(".", 1)[0] + ".ogg"
cleanup_paths.add(ogg_path)
for path in cleanup_paths:
if os.path.isfile(path):
os.unlink(path)
except OSError:
pass
except Exception as e:
@ -12058,7 +12152,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_cprint(f"\n{_DIM}Stop phrase detected — ending voice chat.{_RST}")
self._disable_voice_mode()
return
self._pending_input.put(transcript)
self._pending_input.put(_VoiceInputMessage(transcript))
submitted = True
elif not result.get("success"):
_cprint(f"\n{_DIM}Transcription failed: {result.get('error', 'Unknown error')}{_RST}")
@ -12079,9 +12173,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"""Return whether CLI voice mode should play record start/stop beeps."""
try:
from hermes_cli.config import load_config
from utils import is_truthy_value
voice_cfg = load_config().get("voice", {})
if isinstance(voice_cfg, dict):
return bool(voice_cfg.get("beep_enabled", True))
# is_truthy_value handles quoted YAML strings like "false"
# which bool() would misread as True (#49883).
return is_truthy_value(voice_cfg.get("beep_enabled", True), default=True)
except Exception:
pass
return True
@ -12986,7 +13083,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
except Exception:
pass
def chat(self, message, images: list = None) -> Optional[str]:
def chat(self, message, images: list = None, voice_input: bool = False) -> Optional[str]:
"""
Send a message to the agent and get a response.
@ -13001,6 +13098,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
Args:
message: The user's message (str or multimodal content list)
images: Optional list of Path objects for attached images
voice_input: True when the message came from voice transcription
(gates the concise voice-response prefix, #65827)
Returns:
The agent's response, or None on error
@ -13228,7 +13327,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# model responds concisely. The prefix is API-call-local only —
# run_conversation persists the original clean user message.
_voice_prefix = ""
if self._voice_mode and isinstance(message, str):
if voice_input and isinstance(message, str):
_voice_prefix = (
"[Voice input — respond concisely and conversationally, "
"2-3 sentences max. No code blocks or markdown.] "
@ -15257,16 +15356,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
daemon=True,
).start()
else:
# Guard: don't START recording during agent run or interactive prompts
if cli_ref._agent_running:
# Allow disarming continuous mode even when the agent is
# running or transcribing — otherwise the user is stuck in
# an auto-restart loop until /voice off (#67545).
if cli_ref._agent_running or cli_ref._voice_processing:
with cli_ref._voice_lock:
cli_ref._voice_continuous = False
event.app.invalidate()
return
# Guard: don't START recording during interactive prompts
if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state or cli_ref._slash_confirm_state:
return
# Guard: don't start while a previous stop/transcribe cycle is
# still running — recorder.stop() holds AudioRecorder._lock and
# start() would block the event-loop thread waiting for it.
if cli_ref._voice_processing:
return
# Interrupt TTS if playing, so user can start talking.
# stop_playback() is fast (just terminates a subprocess);
@ -16391,7 +16491,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
except Exception:
pass
continue
# Voice-transcribed messages arrive wrapped in a sentinel
# so only genuine STT output gets the voice prefix (#65827).
is_voice_input = isinstance(user_input, _VoiceInputMessage)
if is_voice_input:
user_input = user_input.text
if not user_input:
continue
@ -16487,7 +16593,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
app.invalidate() # Refresh status line
try:
self.chat(user_input, images=submit_images or None)
self.chat(user_input, images=submit_images or None, voice_input=is_voice_input)
finally:
self._agent_running = False
self._spinner_text = ""
@ -16876,7 +16982,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# the exit occurred, meaning run_conversation's hook didn't fire.
if self.agent and getattr(self, '_agent_running', False):
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_end",
session_id=self.agent.session_id,

View file

@ -0,0 +1 @@
55nx954gn6-debug

View file

@ -0,0 +1 @@
LauraGPT

View file

@ -0,0 +1 @@
afourniernv

View file

@ -0,0 +1 @@
0xAlcibiades

View file

@ -0,0 +1 @@
AlexxRussell

View file

@ -0,0 +1 @@
AnthonyFrancis

View file

@ -0,0 +1 @@
toomij99

View file

@ -0,0 +1,2 @@
BenSheridanEdwards
# PR #68535 (stt: xAI OAuth retry)

View file

@ -0,0 +1 @@
bricelb

View file

@ -0,0 +1 @@
brunopirz

View file

@ -0,0 +1 @@
carljborg

View file

@ -0,0 +1 @@
eliemada

View file

@ -0,0 +1 @@
webtecnica

View file

@ -0,0 +1 @@
damiankluk

View file

@ -0,0 +1 @@
redsol-llc

View file

@ -0,0 +1 @@
ganzziani

View file

@ -0,0 +1 @@
gshall

View file

@ -0,0 +1 @@
hermz580

View file

@ -0,0 +1 @@
LLQWQ

View file

@ -0,0 +1 @@
kharitonov-ivan

View file

@ -0,0 +1 @@
ousiaresearch

View file

@ -0,0 +1 @@
chefboyrdave21

View file

@ -0,0 +1 @@
moeadham

View file

@ -0,0 +1 @@
namredips

View file

@ -0,0 +1 @@
RedClaus

View file

@ -0,0 +1 @@
IvanMiao

View file

@ -0,0 +1 @@
RichardHojunJang

View file

@ -0,0 +1 @@
shellybotmoyer

View file

@ -0,0 +1 @@
simonmmafs

View file

@ -0,0 +1 @@
simonmmafs

View file

@ -0,0 +1 @@
Vissirexa

View file

@ -0,0 +1 @@
muctobi

View file

@ -0,0 +1 @@
tusharui

View file

@ -0,0 +1 @@
zehuaw1

View file

@ -1119,6 +1119,13 @@ def _resolve_default_model_snapshot() -> Optional[str]:
except Exception:
pass
cfg = _expand_env_vars(cfg)
# Mirror run_job's precedence: the explicit cron-fleet default
# (cron.model) beats the global chat model for unpinned cron jobs.
cron_cfg = cfg.get("cron") or {}
if isinstance(cron_cfg, dict):
cron_model = cron_cfg.get("model")
if isinstance(cron_model, str) and cron_model.strip():
return cron_model.strip()
model_cfg = cfg.get("model") or {}
if isinstance(model_cfg, str):
return model_cfg.strip() or None

View file

@ -3150,13 +3150,21 @@ def run_job(
else str(delivery_target["thread_id"])
)
# Model resolution precedence: per-job override > HERMES_MODEL env >
# config.yaml ``model:`` (string or ``{default: ...}``). The per-job
# value is intentionally re-read from storage every tick so a
# ``cronjob action=update model=...`` after a failed run takes effect
# on the next tick — there is no in-memory cache.
# Model resolution precedence: per-job override > cron.model (the
# cron-fleet default) > HERMES_MODEL env > config.yaml ``model:``
# (string or ``{default: ...}``). The per-job value is intentionally
# re-read from storage every tick so a ``cronjob action=update
# model=...`` after a failed run takes effect on the next tick — there
# is no in-memory cache.
model = job.get("model") or os.getenv("HERMES_MODEL") or ""
# cron.model / cron.model_provider: a deliberate cron-fleet default
# so unattended jobs stop shadowing chat `/model` switches. When an
# axis resolves from here, the #44585 drift guard is skipped for that
# axis — following cron.model is explicit, not drift.
_cron_default_model = ""
_cron_default_provider = ""
# Load config.yaml for model, reasoning, prefill, toolsets, provider routing
_cfg = {}
_model_cfg = {}
@ -3179,8 +3187,20 @@ def run_job(
# Coerce null/missing to {} so a falsy default never
# clobbers an already-resolved env value with ``None``.
_model_cfg = _cfg.get("model") or {}
_cron_cfg_for_model = _cfg.get("cron") or {}
if isinstance(_cron_cfg_for_model, dict):
_cron_default_model = str(
_cron_cfg_for_model.get("model") or ""
).strip()
_cron_default_provider = str(
_cron_cfg_for_model.get("model_provider") or ""
).strip()
if not job.get("model"):
if isinstance(_model_cfg, str):
if _cron_default_model:
# Cron-fleet default beats the global chat model: it is
# the user's explicit "cron runs on this" setting.
model = _cron_default_model
elif isinstance(_model_cfg, str):
model = _model_cfg
elif isinstance(_model_cfg, dict):
# Mirror the CLI/oneshot resolution: prefer ``default``,
@ -3279,7 +3299,10 @@ def run_job(
# circuits that precedence and can resurrect old providers (for
# example DeepSeek) for cron jobs that do not pin provider/model.
runtime_kwargs = {
"requested": job.get("provider"),
# Per-job user pin wins; otherwise the cron-fleet default
# provider (cron.model_provider); otherwise resolve from
# persisted global config.
"requested": job.get("provider") or _cron_default_provider or None,
# Derive provider-specific api_mode from the model this job
# will actually run (per-job pin > env > config default), not
# the stale persisted default — mirrors the fallback path
@ -3363,10 +3386,18 @@ def run_job(
# Back-compat: an axis with no snapshot (pre-existing jobs, no_agent, or
# any axis whose creation-time resolution failed) behaves exactly as
# before — the guard never engages for it. Pinned axes are unaffected.
#
# cron.model / cron.model_provider: an axis resolved from the explicit
# cron-fleet default is NOT drift — the user deliberately routed
# unpinned cron jobs there, so the guard is skipped for that axis.
if cron_model_drift_guard_enabled(_cfg):
_drift: list[str] = []
_provider_snapshot = (job.get("provider_snapshot") or "").strip().lower()
if _provider_snapshot and not (job.get("provider") or "").strip():
if (
_provider_snapshot
and not (job.get("provider") or "").strip()
and not _cron_default_provider
):
_current_provider = str(
primary_provider_for_drift or runtime.get("provider") or ""
).strip().lower()
@ -3375,7 +3406,11 @@ def run_job(
f"provider '{_provider_snapshot}' -> '{_current_provider}'"
)
_model_snapshot = (job.get("model_snapshot") or "").strip().lower()
if _model_snapshot and not (job.get("model") or "").strip():
if (
_model_snapshot
and not (job.get("model") or "").strip()
and not _cron_default_model
):
_current_model = str(primary_model_for_drift or "").strip().lower()
if _current_model and _current_model != _model_snapshot:
_drift.append(

View file

@ -14,6 +14,10 @@ Behavior-changing request or execution wrappers are outside this observer
contract. Observer hooks should report what happened; they should not replace
provider requests, tool arguments, or execution callbacks.
Hermes also has a first-party NeMo Relay shared-metrics path. It uses these
lifecycle boundaries directly and does not require enabling an observability
plugin. See [Relay shared metrics](relay-shared-metrics.md).
## Contract
Plugins register observer callbacks from `register(ctx)`:

View file

@ -0,0 +1,127 @@
# NeMo Relay Shared Metrics
Hermes includes NeMo Relay as a normal runtime dependency on platforms for
which Relay publishes a native wheel. The shared-metrics integration is built
into Hermes and does not require `hermes plugins enable
observability/nemo_relay`. Hermes remains importable without Relay on other
native targets. Those targets use an explicit reduced-capability no-op host:
Hermes execution remains available, while Relay scopes, middleware, plugins,
and subscribers are unavailable. The `hermes-agent[nemo-relay]` extra remains
as a no-op compatibility alias for existing installation commands.
Hermes requires NeMo Relay 0.6.0 or later within the 0.6 release line. That
release establishes the lossless provider-codec contract used for Anthropic
Messages, OpenAI Chat Completions, and OpenAI Responses requests.
## Runtime Dependency and Data Boundary
Hermes installs the platform-specific `nemo-relay` native wheel from the
bounded `>=0.6.0,<0.7` dependency range. The published package is built from
the [NVIDIA NeMo Relay repository](https://github.com/NVIDIA/NeMo-Relay).
Unsupported platforms use the explicit no-op runtime described above rather
than downloading a different implementation.
When Relay managed execution is active, the provider request and response pass
through that native module in the Hermes process so configured interceptors can
operate on the real call. This is separate from the shared-metrics data
contract. Shared-metrics mode installs no network exporter and its subscriber
accepts only the versioned, allowlisted projection described below. Enabling a
separately configured rich-observability or dynamic plugin can create a
different data path and requires its own policy review.
Collection remains off unless Hermes policy enables it:
```yaml
telemetry:
shared_metrics:
enabled: true
```
This choice is read from the profile's own `config.yaml`. A machine-managed
configuration overlay cannot enable or disable shared metrics on the profile's
behalf.
The existing `observability/nemo_relay` plugin remains separate. Enable that
plugin only for its opt-in rich observability exporters, adaptive execution,
or dynamic Relay plugins.
Hermes core owns one Relay host and one isolated Relay session scope per Hermes
session. Core lifecycle producers use
`hermes_cli.observability.relay_runtime` to obtain the shared session handle or
run Relay scope, LLM, tool, and mark APIs in that session context. New product
marks do not require Hermes plugin registration. Shared-metrics marks must
still contain only fields approved by the versioned allowlist; the hard
dependency does not change the collection or privacy policy.
## Current Slices
The current vertical slices record logical model calls and top-level task runs:
```text
Hermes turn, API, and tool hooks
-> Relay session, task, and LLM lifecycle
-> Hermes shared-metrics subscriber
-> SQLite counters
-> immutable JSON delta package
```
Hermes sends an empty `LLMRequest` into the metrics-owned lifecycle. This does
not describe the separate managed-execution call through the native runtime
documented above. The terminal metrics event contains only bounded model
family, provider family, locality, call role, and outcome values. Prompts,
responses, exact model IDs, endpoints, errors, session IDs, task IDs, and
request IDs are not included in the metrics event or package.
Each task run is a Relay `Function` scope named `hermes.task_run`, parented to
the owning Hermes session. The start counter contains only bounded execution
surface and entrypoint values. The terminal counter contains bounded outcome,
end reason, termination status, duration, logical model-call count, terminal
tool-call count, and provider-retry count buckets. Retries are additional
provider attempts for the same Hermes API request ID; they do not inflate the
logical model-call count. Tool calls are deduplicated by their Hermes tool-call
ID after a terminal tool result is observed. The outer `AIAgent` execution
boundary closes the task for normal returns, early returns, exceptions, and
cancellations. Active task ownership follows the task ID if Hermes rotates its
conversation session during context compression.
Local state is written under:
```text
$HERMES_HOME/telemetry/shared_metrics/metrics.sqlite3
$HERMES_HOME/telemetry/shared_metrics/outbox/*.json
```
The database keeps transactional aggregate and package-outbox state. Package
files are immutable delta documents that conform to a closed JSON schema and
are written with atomic replacement. Fully packaged aggregate rows and
successfully exported package rows and files are retained locally for 30 days.
Pending package rows and counters with unexported deltas are never pruned.
Each package contains an `install_id` generated as a random UUID. Despite the
schema field name, its current scope is one `HERMES_HOME`, so it is more
precisely a persistent pseudonymous profile identifier. It is not derived from
hardware, account, host, path, or credential data. It remains stable across
packages from that profile and can therefore link those local packages.
Deleting `$HERMES_HOME/telemetry/shared_metrics` resets the identifier together
with all aggregates and package files.
This slice has no remote-delivery path. A future remote exporter must not reuse
the persistent local identifier by default. It requires a separate product and
privacy decision covering consent, identity scope, rotation or keyed
pseudonymization, reset behavior, retention, and deletion.
## Smoke Test
Run a real Hermes CLI turn against the deterministic local model server:
```bash
./.venv/bin/python scripts/smoke_nemo_relay_shared_metrics.py
```
The script uses the installed `nemo-relay` dependency by default. Pass
`--relay-python ../nemo-relay/python` only when testing a locally built Relay
binding.
The smoke verifies the model request reached the local server, model and task
counters were stored, one package was exported, and prompt, response, and
exact-model canaries are absent from the package.

View file

@ -15,6 +15,7 @@ import re
import socket as _socket
import subprocess
import sys
import tempfile
import time
import uuid
import weakref
@ -26,9 +27,18 @@ from utils import normalize_proxy_url
logger = logging.getLogger(__name__)
# Audio file extensions Hermes recognizes for native audio delivery.
# Kept in sync with tools/send_message_tool.py and cron/scheduler.py via
# should_send_media_as_audio() below.
_AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.flac'})
# Keep Telegram's narrower attachment/voice sets below separate: formats such
# as MPEG-2 Layer II are audio to Hermes but unsupported by sendAudio/sendVoice.
_AUDIO_MIME_TYPES = {
".ogg": "audio/ogg",
".opus": "audio/opus",
".mp3": "audio/mpeg",
".m2a": "audio/mpeg",
".wav": "audio/wav",
".m4a": "audio/m4a",
".flac": "audio/flac",
}
_AUDIO_EXTS = frozenset(_AUDIO_MIME_TYPES)
# Telegram's Bot API sendAudio only accepts MP3 / M4A. Other audio
# formats either need to go through sendVoice (Opus/OGG) or must be
# delivered as a regular document.
@ -151,6 +161,32 @@ def should_send_media_as_audio(platform, ext: str, is_voice: bool = False) -> bo
return True
def build_auto_tts_output_path(platform) -> str:
"""Return a unique temp output path for gateway auto-TTS synthesis.
Platform-awareness lives HERE (the caller knows its platform), not in the
TTS tool's ``HERMES_SESSION_PLATFORM`` contextvar — that contextvar is
cleared by ``_clear_session_env`` before the post-handler auto-TTS block
in ``BasePlatformAdapter`` runs, so relying on it always produced MP3
(#57049, #36685). Platforms whose native voice bubbles require Ogg/Opus
(``tools.tts_tool.OPUS_VOICE_PLATFORMS`` the single source of truth)
get an explicit ``.ogg`` path; the tool's central container repair
(``_repair_ogg_container``) then guarantees real Ogg/Opus bytes for every
provider, including MP3-only backends like Edge TTS. Everything else
keeps the MP3 default.
"""
from tools.tts_tool import OPUS_VOICE_PLATFORMS
ext = "ogg" if _platform_name(platform) in OPUS_VOICE_PLATFORMS else "mp3"
audio_path = os.path.join(
tempfile.gettempdir(),
"hermes_voice",
f"tts_reply_{uuid.uuid4().hex[:12]}.{ext}",
)
os.makedirs(os.path.dirname(audio_path), exist_ok=True)
return audio_path
def utf16_len(s: str) -> int:
"""Count UTF-16 code units in *s*.
@ -844,6 +880,18 @@ def get_audio_cache_dir() -> Path:
return d
def _sniff_audio_ext(data: bytes, fallback_ext: str) -> str:
"""Prefer a container-matching extension when audio magic bytes are obvious.
Thin wrapper around the shared sniffer in ``tools.audio_container``
ONE module owns container detection for both the outbound TTS repair
(``tools/tts_tool.py``) and this inbound cache path.
"""
from tools.audio_container import sniff_audio_ext
return sniff_audio_ext(data, fallback_ext)
def cache_audio_from_bytes(data: bytes, ext: str = ".ogg") -> str:
"""
Save raw audio bytes to the cache and return the absolute file path.
@ -857,7 +905,8 @@ def cache_audio_from_bytes(data: bytes, ext: str = ".ogg") -> str:
"""
validate_inbound_media_size(len(data), media_type="audio")
cache_dir = get_audio_cache_dir()
filename = f"audio_{uuid.uuid4().hex[:12]}{ext}"
sniffed_ext = _sniff_audio_ext(data, ext)
filename = f"audio_{uuid.uuid4().hex[:12]}{sniffed_ext}"
filepath = cache_dir / filename
filepath.write_bytes(data)
return str(filepath)
@ -1480,7 +1529,7 @@ MEDIA_DELIVERY_EXTS: Tuple[str, ...] = (
# Video (embed inline where supported)
".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp",
# Audio (delivered as voice/audio where supported)
".mp3", ".wav", ".ogg", ".opus", ".m4a", ".flac",
".mp3", ".m2a", ".wav", ".ogg", ".opus", ".m4a", ".flac",
# Documents (uploaded as file attachments)
".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md", ".epub",
# Spreadsheets / data
@ -1838,7 +1887,7 @@ def cache_media_bytes(
or default_kind == "image"
)
is_video = mime.startswith("video/") or ext in SUPPORTED_VIDEO_TYPES or default_kind == "video"
is_audio = mime.startswith("audio/") or default_kind == "audio"
is_audio = mime.startswith("audio/") or ext in _AUDIO_EXTS or default_kind == "audio"
if is_image:
img_ext = ext if ext in SUPPORTED_IMAGE_DOCUMENT_TYPES else ".jpg"
@ -1855,9 +1904,9 @@ def cache_media_bytes(
return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_VIDEO_TYPES.get(vid_ext, "video/mp4"), "video", display)
if is_audio:
aud_ext = ext if ext in {".ogg", ".mp3", ".wav", ".m4a", ".opus", ".flac"} else ".ogg"
aud_ext = ext if ext in _AUDIO_EXTS else ".ogg"
path = cache_audio_from_bytes(data, ext=aud_ext)
out_mime = mime if mime.startswith("audio/") else f"audio/{aud_ext.lstrip('.')}"
out_mime = mime if mime.startswith("audio/") else _AUDIO_MIME_TYPES[aud_ext]
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "audio", display)
# Any other file type is cached and surfaced to the agent as a local path
@ -2267,11 +2316,16 @@ def _invalidate_pending_stt_cache(event: MessageEvent) -> None:
``_transcribe_pending_audio_event_once``); if the cached event gains new
media after the cache was populated, the stale transcript must be
discarded so the next transcription call picks up the merged attachments.
Only the *derived* transcription cache is dropped. The echo ledger
(``_gateway_pending_stt_echoed``) records which transcripts were already
delivered to the user and must survive the merge: the re-run transcription
returns the earlier notes again, so clearing the ledger would echo them a
second time.
"""
for attr in (
"_gateway_pending_stt_text",
"_gateway_pending_stt_transcripts",
"_gateway_pending_stt_echo_sent",
):
if hasattr(event, attr):
delattr(event, attr)
@ -3809,11 +3863,21 @@ class BasePlatformAdapter(ABC):
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
def prepare_tts_text(self, text: str) -> str:
"""Prepare text for TTS. Override to filter tool output, code, etc.
"""Prepare a spoken script for TTS.
Default strips markdown formatting and truncates to 4000 chars.
Auto-TTS should not feed raw chat Markdown, ``<think>`` reasoning
blocks, or compact symbols to the speech provider. It should receive
a transcript-like script: reasoning blocks removed, headings and
bullets flattened into sentence pauses, and units like ``°C``
expanded to words such as ``degrees Celsius``.
"""
return re.sub(r'[*_`#\[\]()]', '', text)[:4000].strip()
try:
from tools.tts_text_normalize import prepare_spoken_text
return prepare_spoken_text(text, max_chars=4000)
except Exception:
# Keep auto-TTS best-effort if the normalizer ever fails.
text = re.sub(r'<think[\s>].*?</think>', ' ', text, flags=re.DOTALL)
return re.sub(r'[*_`#\[\]()]', '', text)[:4000].strip()
async def play_tts(
self,
@ -4138,6 +4202,15 @@ class BasePlatformAdapter(ABC):
for match in media_pattern.finditer(scan_content):
path = _normalize_media_tag_path(match.group("path"))
if path:
# ``[[audio_as_voice]]`` is message-global, but it must only
# affect audio files. Tagging a non-audio file (image, video,
# document) as is_voice taints it: an image flagged is_voice is
# excluded from the embedded-photo batch and falls through to
# send_document, arriving as a file attachment instead of an
# inline photo. Gating on the extension lets one message carry
# an embedded image AND a voice bubble together.
ext = os.path.splitext(path)[1].lower()
is_voice = has_voice_tag and ext in _AUDIO_EXTS
try:
expanded = os.path.expanduser(path)
except (OSError, RuntimeError, ValueError):
@ -4146,7 +4219,7 @@ class BasePlatformAdapter(ABC):
continue
if expanded not in seen_paths:
seen_paths.add(expanded)
media.append((expanded, has_voice_tag))
media.append((expanded, is_voice))
for match in MEDIA_EXTENSIONLESS_TAG_RE.finditer(scan_content):
path = _normalize_media_tag_path(match.group("path"))
@ -4157,7 +4230,8 @@ class BasePlatformAdapter(ABC):
continue
safe = resolved[0]
if safe not in seen_paths:
media.append((safe, has_voice_tag))
_safe_ext = os.path.splitext(safe)[1].lower()
media.append((safe, has_voice_tag and _safe_ext in _AUDIO_EXTS))
seen_paths.add(safe)
# Remove the delivered MEDIA tags from the user-visible text. Mask a
@ -5515,6 +5589,7 @@ class BasePlatformAdapter(ABC):
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
# True globally and no ``/voice off`` has been issued.
_tts_path = None
_tts_requested_path = None
if (self._should_auto_tts_for_chat(event.source.chat_id)
and event.message_type == MessageType.VOICE
and text_content
@ -5526,18 +5601,37 @@ class BasePlatformAdapter(ABC):
speech_text = self.prepare_tts_text(text_content)
if not speech_text:
raise ValueError("Empty text after markdown cleanup")
# Pass an explicit platform-aware output path: the
# HERMES_SESSION_PLATFORM contextvar the tool would
# otherwise consult is already cleared by the time
# this post-handler block runs, which silently
# produced MP3 (audio attachment, not a native
# voice bubble) on Opus platforms (#57049, #36685).
_tts_requested_path = build_auto_tts_output_path(
self.platform
)
tts_result_str = await asyncio.to_thread(
text_to_speech_tool, text=speech_text
text_to_speech_tool,
text=speech_text,
output_path=_tts_requested_path,
)
tts_data = _json.loads(tts_result_str)
_tts_path = tts_data.get("file_path")
if tts_data.get("success", True):
_tts_path = tts_data.get("file_path") or _tts_requested_path
except Exception as tts_err:
logger.warning("[%s] Auto-TTS failed: %s", self.name, tts_err)
# Play TTS audio before text (voice-first experience)
_tts_caption_delivered = False
_tts_cleanup_paths = {_tts_requested_path, _tts_path} - {None}
if _tts_path and Path(_tts_path).exists():
try:
# Caption eligibility and payload stay on the ORIGINAL
# reply text. The spoken script is for synthesis only:
# normalization can shrink a long reply below the
# 1024-char caption limit, and captioning that spoken
# form would suppress the full formatted reply the
# user is meant to receive as a separate message.
telegram_tts_caption = None
if (
self.platform == Platform.TELEGRAM
@ -5555,8 +5649,15 @@ class BasePlatformAdapter(ABC):
telegram_tts_caption and getattr(tts_result, "success", False)
)
finally:
for _cleanup_path in _tts_cleanup_paths:
try:
os.remove(_cleanup_path)
except OSError:
pass
elif _tts_cleanup_paths:
for _cleanup_path in _tts_cleanup_paths:
try:
os.remove(_tts_path)
os.remove(_cleanup_path)
except OSError:
pass

View file

@ -43,6 +43,7 @@ from gateway.platforms.base import (
cache_image_from_url,
)
from gateway.platforms.helpers import redact_phone
from tools.audio_container import CONTAINER_TO_EXT, sniff_container
from gateway.platforms.signal_format import markdown_to_signal
from gateway.platforms.signal_rate_limit import (
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD,
@ -99,18 +100,15 @@ def _guess_extension(data: bytes) -> str:
return ".webp"
if data[:4] == b"%PDF":
return ".pdf"
if len(data) >= 8 and data[4:8] == b"ftyp":
return ".mp4"
if data[:4] == b"OggS":
return ".ogg"
if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0:
# ``0xFF 0xFx`` is shared by MP3 and ADTS AAC. The discriminator
# is bits 3-1 of byte 1: ADTS has ``ID=0`` and ``layer=00`` (mask
# 0xF6, target 0xF0); MP3 has ``ID=1`` and ``layer`` in {01,10,11}
# (mask 0xF6, target in {0xF2, 0xF4, 0xF6}).
if (data[1] & 0xF6) == 0xF0:
return ".aac"
return ".mp3"
# Audio/AV containers: delegate to the shared central sniffer
# (tools/audio_container.py) — ONE module owns magic-byte container
# detection. It handles the brand/form-type disambiguations this
# function used to carry locally: RIFF/WAVE vs WEBP (WEBP is claimed
# above, before delegation), ftyp audio brands ("M4A ", "M4B ") vs
# video brands (isom/mp42/avc1/qt), and MP3 vs ADTS AAC sync words.
container = sniff_container(data)
if container is not None:
return CONTAINER_TO_EXT[container]
if data[:2] == b"PK":
return ".zip"
return ".bin"

View file

@ -2207,6 +2207,7 @@ from gateway.platforms.base import (
MessageType,
_prefix_within_utf16_limit,
_reply_anchor_for_event,
build_auto_tts_output_path,
merge_pending_message_event,
utf16_len,
)
@ -5978,10 +5979,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
active_count = len(getattr(self, "_running_agents", {}))
if active_count < max_sessions:
return None
return (
f"Hermes is at the active session limit ({active_count}/{max_sessions}). "
"Try again when another session finishes."
)
from hermes_cli.active_sessions import active_session_limit_message
return active_session_limit_message(active_count, max_sessions)
def _claim_active_session_slot(
self,
@ -6160,6 +6160,43 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
self._enqueue_fifo(session_key, event, adapter)
async def _prepare_busy_steer_text(self, event: MessageEvent) -> str:
"""Return steerable text for a busy follow-up, transcribing voice first.
Fresh and queued voice messages reach the normal inbound STT pipeline,
but successful steer messages intentionally bypass that queue. Without
preprocessing here, a media-only voice follow-up has an empty text
payload and steer mode silently degrades to queue mode.
Audio file attachments remain files; only voice-message media follows
the automatic STT contract used by ``_prepare_inbound_message_text``.
If transcription fails, preserve any caption and let the existing
steer fallback handle an otherwise empty event without losing it.
Routes through ``_transcribe_and_echo_pending_voice`` the single
out-of-band transcription choke point shared with the interrupt
monitor and the pending-drain path so the STT call is made at most
once per platform message (cached on the event) and the transcript
echo respects the count-based ledger. If steering later falls back
to queue mode, the drain path reuses the cached transcript instead of
paying for a second STT call or re-echoing the same line.
"""
text = (event.text or "").strip()
if not self._pending_event_audio_paths(event):
return text
adapter = self._adapter_for_source(event.source)
enriched_text, successful_transcripts = await self._transcribe_and_echo_pending_voice(
event,
adapter,
event.source,
text,
log_context="Busy-steer",
)
if not successful_transcripts:
return text
return (enriched_text or text).strip()
async def _handle_active_session_busy_message(self, event: MessageEvent, session_key: str) -> bool:
# --- Authorization gate (#17775) ---
# The cold path (_handle_message) checks _is_user_authorized before
@ -6347,12 +6384,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
steered = False
redirected = False
if effective_mode == "steer":
steer_text = (event.text or "").strip()
steer_text = await self._prepare_busy_steer_text(event)
# A follow-up qualifies for steering when it is plain text, OR
# when every attachment is STT-eligible voice media whose
# transcript was just folded into steer_text — otherwise a voice
# note in steer mode silently degrades to queue mode (#58780).
_steer_media_urls = getattr(event, "media_urls", None) or []
_steer_all_voice = bool(_steer_media_urls) and (
len(self._pending_event_audio_paths(event)) == len(_steer_media_urls)
)
can_steer = (
steer_text
and event.message_type == MessageType.TEXT
and not event.media_urls
and not event.media_types
and (
(
event.message_type == MessageType.TEXT
and not event.media_urls
and not event.media_types
)
or _steer_all_voice
)
and running_agent is not None
and running_agent is not _AGENT_PENDING_SENTINEL
and hasattr(running_agent, "steer")
@ -6893,9 +6943,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception as _e:
logger.debug("Shutdown transcript flush failed: %s", _e)
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_finalize",
from hermes_cli.lifecycle import finalize_session
finalize_session(
session_id=getattr(agent, "session_id", None),
platform="gateway",
reason="shutdown",
@ -8382,6 +8431,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if success:
self.adapters[platform] = adapter
self._sync_voice_mode_state_to_adapter(adapter)
# Wire voice input callback at connect time so voice
# transcription is forwarded without requiring /voice join.
if hasattr(adapter, "_voice_input_callback"):
adapter._voice_input_callback = self._handle_voice_channel_input
connected_count += 1
self._update_platform_runtime_status(
platform.value,
@ -9187,11 +9240,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
for key, entry in _expired_entries:
try:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import finalize_session
_parts = key.split(":")
_platform = _parts[2] if len(_parts) > 2 else ""
_invoke_hook(
"on_session_finalize",
finalize_session(
session_id=entry.session_id,
platform=_platform,
reason="session_expired",
@ -9453,6 +9505,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if success:
self.adapters[platform] = adapter
self._sync_voice_mode_state_to_adapter(adapter)
# Wire voice input callback on reconnect as well (#60623).
if hasattr(adapter, "_voice_input_callback"):
adapter._voice_input_callback = self._handle_voice_channel_input
self.delivery_router.adapters = self.adapters
del self._failed_platforms[platform]
self._update_platform_runtime_status(
@ -11017,12 +11072,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# (e.g. customer handover ingest) without triggering the pairing flow.
if not is_internal:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_hook_results = _invoke_hook(
"pre_gateway_dispatch",
event=event,
gateway=self,
session_store=self.session_store,
# getattr: bare-runner tests build GatewayRunner via
# object.__new__ without __init__ (pitfall #17), and the
# hook must not fail dispatch over a missing attribute.
session_store=getattr(self, "session_store", None),
)
except Exception as _hook_exc:
logger.warning("pre_gateway_dispatch invocation failed: %s", _hook_exc)
@ -11193,7 +11251,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception:
_pending_clarify = None
if _pending_clarify is not None and _clarify_mod is not None:
_raw_clarify_reply = (event.text or "").strip()
_clarify_has_audio = bool(self._pending_event_audio_paths(event))
_raw_clarify_reply = await self._prepare_clarify_reply_text(event)
if _clarify_has_audio and not _raw_clarify_reply:
logger.info(
"Gateway retained pending clarify after voice transcription "
"produced no usable text (session=%s, id=%s)",
_quick_key,
_pending_clarify.clarify_id,
)
return ""
# Skip slash commands — the user clearly wanted to issue a
# command, not answer the clarify. Leave the clarify pending
# so the user can retry; if it times out, the agent unblocks
@ -13022,6 +13089,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
session_key=session_key,
)
async def _prepare_clarify_reply_text(self, event) -> str:
"""Return raw text or successful voice transcripts for a clarify reply."""
if not self._pending_event_audio_paths(event):
return (event.text or "").strip()
_, successful_transcripts = await self._transcribe_pending_audio_event_once(
event, "",
)
return "\n\n".join(
transcript.strip()
for transcript in successful_transcripts
if transcript.strip()
)
def _consume_pending_native_image_paths(self, session_key: str) -> List[str]:
pending_native = getattr(self, "_pending_native_image_paths_by_session", None)
if not pending_native:
@ -15750,11 +15831,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Use SimpleNamespace as raw_message so _get_guild_id() can extract
# guild_id and _send_voice_reply() plays audio in the voice channel.
from types import SimpleNamespace
# Resolve the bound text channel's channel_prompt so voice input gets
# the same per-channel context as typed messages (#50149).
channel_prompt: Optional[str] = None
resolver = getattr(adapter, "_resolve_channel_prompt", None)
if callable(resolver):
try:
resolved = resolver(str(text_ch_id))
channel_prompt = resolved if isinstance(resolved, str) else None
except Exception:
channel_prompt = None
event = MessageEvent(
source=source,
text=transcript,
message_type=MessageType.VOICE,
raw_message=SimpleNamespace(guild_id=guild_id, guild=None),
channel_prompt=channel_prompt,
)
await adapter.handle_message(event)
@ -15781,14 +15873,33 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return False
chat_id = event.source.chat_id
voice_mode = self._voice_mode.get(self._voice_key(event.source.platform, chat_id), "off")
voice_key = self._voice_key(event.source.platform, chat_id)
voice_mode = self._voice_mode.get(voice_key)
is_voice_input = (event.message_type == MessageType.VOICE)
adapter = self.adapters.get(event.source.platform)
adapter_auto_tts = False
if adapter and hasattr(adapter, "_should_auto_tts_for_chat"):
try:
adapter_auto_tts = bool(adapter._should_auto_tts_for_chat(chat_id))
except Exception:
adapter_auto_tts = False
should = (
(voice_mode == "all")
or (voice_mode == "voice_only" and is_voice_input)
# ``voice.auto_tts`` is synced into the adapter on gateway startup.
# Treat it as "voice accompanies text replies" unless a chat was
# explicitly turned off. The base adapter's own auto-TTS path only
# covers voice-input replies, so final text replies need the runner
# path here.
or (voice_mode != "off" and adapter_auto_tts)
)
if not should:
logger.debug(
"Auto voice reply skipped: mode=%s adapter_auto_tts=%s chat=%s platform=%s",
voice_mode, adapter_auto_tts, chat_id, event.source.platform.value,
)
return False
# Dedup: agent already called TTS tool
@ -15819,7 +15930,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
async def _send_voice_reply(self, event: MessageEvent, text: str) -> None:
"""Generate TTS audio and send as a voice message before the text reply."""
import uuid as _uuid
audio_path = None
actual_path = None
try:
@ -15829,14 +15939,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if not tts_text:
return
# Telegram's adapter only sends native voice bubbles for OGG/Opus.
# Other platforms keep the existing MP3 default.
audio_ext = "ogg" if event.source.platform == Platform.TELEGRAM else "mp3"
audio_path = os.path.join(
tempfile.gettempdir(), "hermes_voice",
f"tts_reply_{_uuid.uuid4().hex[:12]}.{audio_ext}",
)
os.makedirs(os.path.dirname(audio_path), exist_ok=True)
# Platform-aware output path: platforms whose native voice
# bubbles require Ogg/Opus (OPUS_VOICE_PLATFORMS — Telegram,
# Matrix, Feishu, WhatsApp, Signal) get an explicit .ogg path;
# the TTS tool's central container repair guarantees real
# Ogg/Opus bytes for every provider. Others keep MP3.
audio_path = build_auto_tts_output_path(event.source.platform)
result_json = await asyncio.to_thread(
text_to_speech_tool, text=tts_text, output_path=audio_path
@ -17971,7 +18079,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return f"{prefix}\n\n{user_text}", []
return prefix, []
from tools.transcription_tools import transcribe_audio
try:
from tools.transcription_tools import transcribe_audio
except ModuleNotFoundError as e:
logger.error("Transcription module unavailable: %s", e)
unavailable_note = "[voice message could not be transcribed]"
_placeholder = "(The user sent a message with no text content)"
if user_text and user_text.strip() == _placeholder:
return unavailable_note, []
if user_text:
return f"{unavailable_note}\n\n{user_text}", []
return unavailable_note, []
enriched_parts = []
successful_transcripts: List[str] = []
@ -17981,6 +18099,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
result = await asyncio.to_thread(transcribe_audio, path)
if result["success"]:
transcript = result["transcript"]
# Speech-to-text can return success=True with an empty or
# whitespace-only transcript on silence, cut-off, or
# inaudible audio. Emitting empty quotes ('""') makes the
# agent reply to nothing and can loop, so that case gets a
# clear sentinel note instead (#41603).
if not (transcript or "").strip():
enriched_parts.append(
"[The user sent a voice message but it came through "
"empty or inaudible — speech-to-text returned no "
"words. Do not guess at the content; ask the user "
"to resend or type it out.]"
)
continue
successful_transcripts.append(transcript)
# Pass the transcript through as a plain quoted line. The
# earlier wording ("The user sent a voice message~ Here's
@ -18066,16 +18197,28 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
metadata=None,
log_context: str = "Transcript",
) -> None:
"""Echo pending-event STT transcripts to the chat at most once."""
"""Echo pending-event STT transcripts to the chat at most once.
The already-echoed transcripts are tracked as a COUNT rather than a
single boolean. ``merge_pending_message_event`` can append a second
voice note to an event whose first transcript was already echoed and
invalidates the transcription cache; the re-run transcription then
returns the earlier transcripts as a prefix of the new list, so
echoing only the unsent tail suppresses the repeat while still
surfacing the newly merged note. A count rather than a set of seen
values because two separate notes that transcribe identically are two
distinct deliveries and both must be echoed.
"""
if (
not transcripts
or not self._should_echo_stt_transcripts()
or adapter is None
or getattr(event, "_gateway_pending_stt_echo_sent", False)
):
return
setattr(event, "_gateway_pending_stt_echo_sent", True)
for tx in transcripts:
already_echoed = int(getattr(event, "_gateway_pending_stt_echoed", 0) or 0)
unsent = transcripts[already_echoed:]
setattr(event, "_gateway_pending_stt_echoed", already_echoed + len(unsent))
for tx in unsent:
try:
await adapter.send(
source.chat_id,

View file

@ -223,9 +223,8 @@ class GatewaySlashCommandsMixin:
# Fire plugin on_session_finalize hook (session boundary)
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_finalize",
from hermes_cli.lifecycle import finalize_session
finalize_session(
session_id=_old_sid,
platform=source.platform.value if source.platform else "",
reason="new_session",
@ -302,7 +301,7 @@ class GatewaySlashCommandsMixin:
# Fire plugin on_session_reset hook (new session guaranteed to exist)
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_new_sid = new_entry.session_id if new_entry else None
_invoke_hook(
"on_session_reset",

View file

@ -70,10 +70,47 @@ def resolve_max_concurrent_sessions(config: Any) -> Optional[int]:
return coerce_max_concurrent_sessions(raw, key=key)
def active_session_limit_message(active_count: int, max_sessions: int) -> str:
def format_age(seconds: float) -> str:
minutes = max(0, int(seconds // 60))
if minutes < 60:
return f"{minutes}m"
hours, minutes = divmod(minutes, 60)
return f"{hours}h" if not minutes else f"{hours}h{minutes}m"
def summarize_holders(entries: list[dict[str, Any]]) -> str:
"""Compact "who is holding the slots" phrase, e.g. ``desktop x4, cli``."""
if not entries:
return ""
counts: dict[str, int] = {}
for entry in entries:
surface = str(entry.get("surface") or "unknown")
counts[surface] = counts.get(surface, 0) + 1
held = ", ".join(
f"{surface} x{n}" if n > 1 else surface
for surface, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
)
started = [t for t in (_optional_float(e.get("started_at")) for e in entries) if t]
if started:
held += f", oldest {format_age(time.time() - min(started))} ago"
return held
def active_session_limit_message(
active_count: int,
max_sessions: int,
entries: Optional[list[dict[str, Any]]] = None,
) -> str:
# Name the holders: the slots are shared across CLI, desktop/TUI and the
# messaging gateway, so the surface that gets rejected is usually NOT the
# one squatting on them (idle desktop chats starving a Discord bot, say).
# Without this the message is unactionable and the only way to find out is
# reading runtime/active_sessions.json by hand.
held = summarize_holders(entries or [])
detail = f" Held by: {held}." if held else ""
return (
f"Hermes is at the active session limit ({active_count}/{max_sessions}). "
"Try again when another session finishes."
f"Hermes is at the active session limit ({active_count}/{max_sessions})."
f"{detail} Try again when another session finishes."
)
@ -284,7 +321,9 @@ def try_acquire_active_session(
max_sessions,
surface,
)
return None, active_session_limit_message(active_count, max_sessions)
return None, active_session_limit_message(
active_count, max_sessions, entries
)
entries.append(entry)
_write_entries(state_path, entries)
@ -348,6 +387,36 @@ def transfer_active_session(
return updated
def release_orphaned_leases(live_lease_ids: set[str]) -> int:
"""Drop this process's registry entries that no live session owns.
``_prune_dead`` only reclaims leases whose owning process died. A server
that runs for days (``hermes dashboard`` / ``serve``) never trips that
check, so a lease whose session skipped teardown is held until restart.
The owning process is the only authority on which of its own leases are
real, so it drops the rest itself exact, with no heartbeat write on the
turn path and no staleness threshold to tune.
"""
pid = os.getpid()
state_path = _state_path()
# With the cap disabled the registry is never written, so don't take a lock
# (or create its file) on the idle-reaper tick for the majority of installs.
if not state_path.exists():
return 0
with _FileLock(_lock_path()):
entries = _prune_dead(_read_entries(state_path))
kept = [
entry
for entry in entries
if entry.get("pid") != pid
or str(entry.get("lease_id") or "") in live_lease_ids
]
dropped = len(entries) - len(kept)
if dropped:
_write_entries(state_path, kept)
return dropped
def active_session_registry_snapshot() -> list[dict[str, Any]]:
"""Return the pruned active-session registry for diagnostics/tests."""
state_path = _state_path()

View file

@ -4452,7 +4452,16 @@ def _save_xai_oauth_tokens(
redirect_uri: str = "",
last_refresh: Optional[str] = None,
auth_mode: str = "oauth_device_code",
set_active: bool = True,
) -> None:
"""Persist xAI OAuth tokens into the auth store.
When *set_active* is True (default), also promote ``xai-oauth`` to
``active_provider`` appropriate for intentional model/auth login.
Pass ``set_active=False`` for side-tool credential bootstrap (TTS/setup,
tools config, dashboard token save, token refresh) so inference routing
is unchanged.
"""
if last_refresh is None:
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
with _auth_store_lock():
@ -4470,7 +4479,9 @@ def _save_xai_oauth_tokens(
state["discovery"] = discovery
if redirect_uri:
state["redirect_uri"] = redirect_uri
_save_provider_state(auth_store, "xai-oauth", state)
_store_provider_state(
auth_store, "xai-oauth", state, set_active=set_active
)
_save_auth_store(auth_store)
if write_through_to_root:
_write_through_xai_oauth_to_global_root(state)
@ -4808,6 +4819,9 @@ def _refresh_xai_oauth_tokens(
redirect_uri=redirect_uri,
last_refresh=refreshed["last_refresh"],
auth_mode=auth_mode,
# Refresh must not flip active_provider — TTS/side tools can refresh
# xAI tokens while chat still routes through another provider.
set_active=False,
)
return updated_tokens

View file

@ -1962,7 +1962,7 @@ DEFAULT_CONFIG = {
# class of over-claim that otherwise forces users to run
# `git status` to verify edits landed. Set false to suppress.
"file_mutation_verifier": True,
# Nous credits status-bar notices (usage bands, grant-spent, depleted /
# Nous credits status-bar notices (usage bands, depleted /
# restored). When false, no credits notices are emitted — balance data
# is still captured and /usage keeps working. Off switch for sub +
# top-up users who find the gauge noisy.
@ -2365,6 +2365,7 @@ DEFAULT_CONFIG = {
"max_recording_seconds": 120,
"auto_tts": False,
"beep_enabled": True, # Play record start/stop beeps in CLI voice mode
"beep_volume": 0.3, # Beep amplitude multiplier (0.0-1.0, default keeps prior hardcoded value)
"silence_threshold": 200, # RMS below this = silence (0-32767)
"silence_duration": 3.0, # Seconds of silence before auto-stop
"barge_in": True, # Stop TTS playback when the user starts talking
@ -2752,6 +2753,13 @@ DEFAULT_CONFIG = {
# override: DISCORD_APPROVAL_MENTIONS. Default false avoids surprise
# pings.
"approval_mentions": False,
# Discord voice-channel inactivity timeout, in seconds. Set to 0 to
# keep the bot in VC until an explicit `/voice leave` / disconnect.
"voice_channel_inactivity_timeout_seconds": 300,
# Minimum seconds to wait for a VC playback before force-stopping it.
# The adapter also probes clip duration and extends this floor by a
# padding window, so long TTS readbacks are not cut at exactly 120s.
"voice_playback_timeout_seconds": 120,
# Voice-channel audio effects (the continuous mixer). OFF by default.
# When enabled, the bot installs a software mixer on the outgoing voice
# stream so a low ambient "thinking" bed, verbal acknowledgements, and
@ -2947,6 +2955,16 @@ DEFAULT_CONFIG = {
# jobs from silently inheriting a paid default. Set to false only when
# jobs should deliberately track changing global inference defaults.
"model_drift_guard": True,
# Default inference model for cron jobs (Axis A — WHAT model an
# agent job runs on). Resolution at fire time: per-job user pin >
# cron.model > global model.default. When set, unpinned jobs follow
# this deliberately, so the #44585 model-drift fail-closed guard does
# not engage for the model axis — cron spend no longer shadows chat
# `/model` switches. Empty string = fall through to model.default.
"model": "",
# Inference provider paired with cron.model (NOT the scheduler
# provider below). Empty string = resolve from global config.
"model_provider": "",
# Active cron SCHEDULER provider (Axis B — the trigger that decides
# WHEN a due job fires). Empty string = the built-in in-process 60s
# ticker (default). Name an installed provider (plugins/cron_providers/<name>/ or
@ -3466,6 +3484,14 @@ DEFAULT_CONFIG = {
"profile_build": "ask",
},
# Privacy-safe aggregate metrics written only to this profile's local
# telemetry directory. Collection is opt-in and no remote sink exists.
"telemetry": {
"shared_metrics": {
"enabled": False,
},
},
# ``hermes update`` behaviour.
"updates": {
# Pre-update safety backup — ONE consolidated mechanism, three modes:
@ -8929,6 +8955,31 @@ def cron_model_drift_guard_enabled(
return cron_config.get("model_drift_guard", True) is not False
def _cron_fleet_default_covers_axis(
axis: str,
config: Optional[Dict[str, Any]] = None,
) -> bool:
"""True when cron.model / cron.model_provider covers *axis*.
An axis covered by the explicit cron-fleet default no longer follows the
global model/provider at fire time, so the drift guard never engages for
it and switch-time warnings would be false alarms.
"""
if config is None:
try:
config = load_config()
except Exception:
return False
if not isinstance(config, dict):
return False
cron_config = config.get("cron")
if not isinstance(cron_config, dict):
return False
key = "model" if axis == "model" else "model_provider"
value = cron_config.get(key)
return isinstance(value, str) and bool(value.strip())
def _load_cron_jobs_for_config_warning() -> List[Dict[str, Any]]:
"""Best-effort read of the active profile's cron jobs database.
@ -8960,6 +9011,12 @@ def warn_unpinned_cron_jobs_after_model_config_change(
return
if not cron_model_drift_guard_enabled(config):
return
# A cron-fleet default covering this axis (cron.model /
# cron.model_provider) means unpinned jobs no longer follow the global
# value at all — the drift guard will not engage, so warning here would
# be a false alarm.
if _cron_fleet_default_covers_axis(axis, config):
return
new_value = str(value or "").strip().lower()
if not new_value:

View file

@ -349,6 +349,8 @@ def cron_create(args):
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
script=getattr(args, "script", None),
workdir=getattr(args, "workdir", None),
model=getattr(args, "model", None),
provider=getattr(args, "model_provider", None),
no_agent=getattr(args, "no_agent", False) or None,
)
if not result.get("success"):
@ -412,6 +414,8 @@ def cron_edit(args):
skills=final_skills,
script=getattr(args, "script", None),
workdir=getattr(args, "workdir", None),
model=getattr(args, "model", None),
provider=getattr(args, "model_provider", None),
no_agent=getattr(args, "no_agent", None),
)
if not result.get("success"):

View file

@ -149,7 +149,17 @@ _DEFAULT_PAYLOADS = {
"changed_paths": ["src/app.tsx"],
},
"on_session_start": {"session_id": "test-session"},
"on_session_end": {"session_id": "test-session"},
"on_session_end": {
"session_id": "test-session",
"task_id": "test-task",
"turn_id": "test-turn",
"completed": True,
"failed": False,
"interrupted": False,
"turn_exit_reason": "text_response(stop)",
"model": "gpt-4",
"platform": "cli",
},
"on_session_finalize": {"session_id": "test-session"},
"on_session_reset": {"session_id": "test-session"},
"pre_api_request": {

View file

@ -175,7 +175,7 @@ def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None
it through.
"""
try:
from hermes_cli.plugins import invoke_hook
from hermes_cli.lifecycle import invoke_hook
from hermes_cli.profiles import get_active_profile_name
try:
profile_name = get_active_profile_name()

63
hermes_cli/lifecycle.py Normal file
View file

@ -0,0 +1,63 @@
"""Hermes lifecycle dispatch for first-party observers and plugins."""
from __future__ import annotations
import logging
from typing import Any, List
logger = logging.getLogger(__name__)
def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
"""Notify first-party observers, then invoke compatibility plugin hooks."""
try:
from hermes_cli.observability import observe_lifecycle
observe_lifecycle(hook_name, **kwargs)
except Exception:
logger.warning("Built-in observability hook failed", exc_info=True)
from hermes_cli import plugins
return plugins.invoke_hook(hook_name, **kwargs)
def has_hook(hook_name: str) -> bool:
"""Return whether a first-party observer or plugin consumes a hook."""
try:
from hermes_cli.observability import handles_hook
if handles_hook(hook_name):
return True
except Exception:
logger.warning("Unable to inspect built-in observability hooks", exc_info=True)
from hermes_cli import plugins
return plugins.has_hook(hook_name)
def finalize_session(**kwargs: Any) -> List[Any]:
"""Notify observers and hard-close one core-owned Relay conversation."""
try:
from hermes_cli.observability import observe_lifecycle
observe_lifecycle("on_session_finalize", **kwargs)
except Exception:
logger.warning("Built-in observability hook failed", exc_info=True)
session_id = str(kwargs.get("session_id") or "")
if session_id:
try:
from agent import relay_runtime
relay_runtime.SESSION_COORDINATOR.finalize_conversation(
profile_key=relay_runtime.current_profile_key(),
session_id=session_id,
)
except Exception:
logger.warning("Core Relay session finalization failed", exc_info=True)
from hermes_cli import plugins
return plugins.invoke_hook("on_session_finalize", **kwargs)

View file

@ -10986,6 +10986,54 @@ def _ensure_fhs_path_guard() -> None:
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")
def _ensure_acp_launcher() -> None:
"""Self-heal: install a ``hermes-acp`` launcher next to the ``hermes`` one.
Mirrors the launcher block in ``scripts/install.sh`` so existing installs
gain the ACP command on ``hermes update`` without a reinstall. ACP hosts
(Zed, JetBrains, Buzz Desktop) spawn the agent by resolving the
``hermes-acp`` command name against the login-shell PATH; the console
script of that name lives inside the install's venv, which is not on that
PATH, so those hosts report Hermes as not installed even when it is.
The shim simply delegates to the sibling ``hermes`` launcher with the
``acp`` subcommand, which makes it correct for every install layout
(venv wrapper, FHS symlink, pipx/pip console script) without having to
reconstruct interpreter/entrypoint paths.
No-op on Windows (install.ps1 puts ``venv\\Scripts`` on the user PATH, so
``hermes-acp.exe`` already resolves) and wherever a ``hermes-acp`` is
already present next to the ``hermes`` command. Unwritable directories
(e.g. ``/usr/local/bin`` as non-root) are skipped silently. Idempotent.
"""
if sys.platform == "win32":
return
for bin_dir in (Path.home() / ".local" / "bin", Path("/usr/local/bin")):
hermes_cmd = bin_dir / "hermes"
acp_cmd = bin_dir / "hermes-acp"
try:
if not (hermes_cmd.is_file() or hermes_cmd.is_symlink()):
continue
# Already present — a console script (pip/pipx install), an
# earlier shim, or a symlink. is_symlink() catches broken
# symlinks that exists() would miss; never follow-and-overwrite
# (the #21454 failure mode).
if acp_cmd.exists() or acp_cmd.is_symlink():
continue
shim = (
"#!/usr/bin/env bash\n"
"# Hermes Agent — ACP launcher (written by `hermes update`).\n"
"# ACP hosts (Zed, JetBrains, Buzz) resolve the agent by this\n"
"# command name on the login-shell PATH.\n"
f'exec "{hermes_cmd}" acp "$@"\n'
)
acp_cmd.write_text(shim, encoding="utf-8")
acp_cmd.chmod(acp_cmd.stat().st_mode | 0o755)
except OSError:
continue
print(f" ✓ Installed hermes-acp launcher → {acp_cmd}")
def _size_delta_label(saved_mb: float) -> str:
"""Human label for a before/after database size delta, in MB.
@ -12916,6 +12964,14 @@ def _cmd_update_impl(args, gateway_mode: bool):
except Exception as e:
logger.debug("FHS PATH guard check failed: %s", e)
# Self-heal the hermes-acp launcher for installs that predate it, so
# ACP hosts (Zed, JetBrains, Buzz) can resolve Hermes on PATH without
# a reinstall. No-op on Windows and when already present.
try:
_ensure_acp_launcher()
except Exception as e:
logger.debug("hermes-acp launcher self-heal failed: %s", e)
# Refresh the cua-driver binary used by the Computer Use toolset.
# The upstream installer is gated on supported platforms and on the
# binary already being on PATH, so this is a no-op for users who
@ -15245,6 +15301,35 @@ def _plugin_cli_discovery_needed() -> bool:
return True
def _resolve_deferred_platform_cli_command(command_name: str | None) -> None:
"""Materialize the deferred platform whose top-level CLI command matches.
Bundled platform plugins are cheap-registered as *deferred* entries to
avoid importing every gateway SDK during normal startup. A platform that
registers a top-level ``hermes <name>`` command (e.g. Photon ->
``ctx.register_cli_command(name="photon", ...)``) only runs that side
effect when its module is imported. On the unknown-top-level-command slow
path, ``discover_plugins()`` records the deferred loader but does not
import it, so the CLI registration never happens and ``hermes photon``
fails with argparse ``invalid choice`` (issue #54678).
Resolving only the platform whose name matches the first positional token
keeps normal startup cheap while making the targeted command available.
"""
if not command_name:
return
try:
from gateway.platform_registry import platform_registry
platform_registry.get(command_name)
except Exception as exc:
logging.getLogger(__name__).debug(
"Deferred platform CLI resolution failed for %s: %s",
command_name,
exc,
)
_AGENT_COMMANDS = {None, "chat", "acp", "rl"}
_AGENT_SUBCOMMANDS = {
"cron": ("cron_command", {"run", "tick"}),
@ -16105,6 +16190,11 @@ def main():
seen_plugin_commands.add(cmd_info["name"])
discover_plugins()
# A bundled platform whose top-level CLI command is the one being
# invoked is still only a deferred entry at this point; import it
# so its register_cli_command side effect runs before we read
# _cli_commands (issue #54678).
_resolve_deferred_platform_cli_command(_first_positional_argv())
for cmd_info in get_plugin_manager()._cli_commands.values():
if cmd_info["name"] in seen_plugin_commands:
continue

View file

@ -127,18 +127,32 @@ def apply_tool_request_middleware(
Middleware may return ``{"args": {...}}`` to replace the effective tool
arguments before hooks, guardrails, approvals, and execution see them.
"""
if not _has_middleware(TOOL_REQUEST_MIDDLEWARE):
return RequestMiddlewareResult(
payload=args,
original_payload=args,
changed=False,
trace=[],
)
original_args = _safe_copy(args)
current_args = _safe_copy(original_args)
trace: List[Dict[str, Any]] = []
session_id = str(context.get("session_id") or "")
skip_relay = bool(context.pop("skip_relay", False))
if session_id and not skip_relay:
from agent import relay_runtime
relay_args = relay_runtime.apply_tool_request_intercepts(
session_id=session_id,
tool_name=tool_name,
args=current_args,
)
if relay_args != current_args:
current_args = _safe_copy(relay_args)
trace.append({"source": "nemo_relay"})
if not _has_middleware(TOOL_REQUEST_MIDDLEWARE):
return RequestMiddlewareResult(
payload=args if not trace else current_args,
original_payload=args,
changed=bool(trace),
trace=trace,
)
for result in _invoke_middleware(
TOOL_REQUEST_MIDDLEWARE,
tool_name=tool_name,

View file

@ -58,9 +58,8 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("openai/gpt-5.5-pro", ""),
("openai/gpt-5.4-mini", ""),
# Google
("google/gemini-3-pro-preview", ""),
("google/gemini-3.1-pro-preview", ""),
("google/gemini-3.5-flash", ""),
("google/gemini-3.6-flash", ""),
# xAI
("x-ai/grok-4.5", ""),
# DeepSeek
@ -68,8 +67,6 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
("deepseek/deepseek-v4-flash", ""),
# Qwen
("qwen/qwen3.7-max", ""),
("qwen/qwen3.7-plus", ""),
("qwen/qwen3.6-35b-a3b", ""),
# MoonshotAI
("moonshotai/kimi-k3", "recommended"),
# MiniMax
@ -209,9 +206,8 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"openai/gpt-5.5-pro",
"openai/gpt-5.4-mini",
# Google
"google/gemini-3-pro-preview",
"google/gemini-3.1-pro-preview",
"google/gemini-3.5-flash",
"google/gemini-3.6-flash",
# xAI
"x-ai/grok-4.5",
# DeepSeek
@ -219,8 +215,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"deepseek/deepseek-v4-flash",
# Qwen
"qwen/qwen3.7-max",
"qwen/qwen3.7-plus",
"qwen/qwen3.6-35b-a3b",
# MoonshotAI
"moonshotai/kimi-k3",
# MiniMax
@ -296,7 +290,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"gemini": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3.5-flash",
"gemini-3.6-flash",
"gemini-3.1-flash-lite-preview",
],
"zai": [
@ -566,12 +560,17 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
# /model picker only ever shows the currently-configured model.
# Model IDs use the "google/" publisher prefix Vertex's openapi
# endpoint expects (see hermes_cli/model_setup_flows.py).
# Entries validated live against a GCP project (global region,
# HTTP 200) as of 2026-07-21 (PR #68767).
"vertex": [
"google/gemini-3.1-pro-preview",
"google/gemini-3-pro-preview",
"google/gemini-3.6-flash",
"google/gemini-3.5-flash",
"google/gemini-3.5-flash-lite",
"google/gemini-3-flash-preview",
"google/gemini-3.1-flash-lite-preview",
"google/gemini-3.1-flash-lite",
],
"novita": [
"moonshotai/kimi-k2.5",

View file

@ -0,0 +1,31 @@
"""First-party Hermes observability integrations."""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def observe_lifecycle(hook_name: str, **kwargs: Any) -> None:
"""Dispatch a Hermes lifecycle event to built-in observability features."""
from . import relay_shared_metrics
_safe_observe(relay_shared_metrics.observe_lifecycle, hook_name, kwargs)
def handles_hook(hook_name: str) -> bool:
"""Return whether any built-in observability feature handles a hook."""
from . import relay_shared_metrics
return relay_shared_metrics.handles_hook(hook_name)
def _safe_observe(callback: Any, hook_name: str, kwargs: dict[str, Any]) -> None:
try:
callback(hook_name, **kwargs)
except Exception:
logger.warning(
"Built-in observability hook failed: %s", hook_name, exc_info=True
)

View file

@ -0,0 +1,14 @@
"""Compatibility alias for the core Hermes Relay runtime.
New code should import :mod:`agent.relay_runtime`. This module remains an
alias, rather than a copy, so existing plugins and tests share the same
profile registry and test-reset state during the migration.
"""
from __future__ import annotations
import sys
from agent import relay_runtime as _core_relay_runtime
sys.modules[__name__] = _core_relay_runtime

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