mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
commit
0cf58de85e
251 changed files with 27112 additions and 3467 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
1130
agent/relay_llm.py
Normal file
File diff suppressed because it is too large
Load diff
1002
agent/relay_runtime.py
Normal file
1002
agent/relay_runtime.py
Normal file
File diff suppressed because it is too large
Load diff
123
agent/relay_tools.py
Normal file
123
agent/relay_tools.py
Normal 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
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
137
apps/desktop/src/app/settings/searchable-select.test.tsx
Normal file
137
apps/desktop/src/app/settings/searchable-select.test.tsx
Normal 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('')
|
||||
})
|
||||
})
|
||||
115
apps/desktop/src/app/settings/searchable-select.tsx
Normal file
115
apps/desktop/src/app/settings/searchable-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// (``). 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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 — ``.
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -610,6 +610,9 @@ export const ar = defineLocale({
|
|||
noneParen: '(لا شيء)',
|
||||
notSet: 'غير مضبوط',
|
||||
commaSeparated: 'قيم مفصولة بفواصل',
|
||||
searchPlaceholder: 'بحث…',
|
||||
noResults: 'لا توجد نتائج',
|
||||
systemDefault: 'إعداد النظام الافتراضي',
|
||||
loading: 'جار تحميل إعدادات Hermes...',
|
||||
emptyTitle: 'لا توجد إعدادات',
|
||||
emptyDesc: 'لا يحتوي هذا القسم على إعدادات قابلة للتعديل.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -639,6 +639,9 @@ export const ja = defineLocale({
|
|||
builtinOnly: '内蔵のみ',
|
||||
notSet: '未設定',
|
||||
commaSeparated: 'カンマ区切りの値',
|
||||
searchPlaceholder: '検索…',
|
||||
noResults: '結果が見つかりません',
|
||||
systemDefault: 'システムのデフォルト',
|
||||
loading: 'Hermes の設定を読み込み中...',
|
||||
emptyTitle: '設定項目がありません',
|
||||
emptyDesc: 'このセクションには調整できる設定がありません。',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -627,6 +627,9 @@ export const zhHant = defineLocale({
|
|||
builtinOnly: '僅內建',
|
||||
notSet: '未設定',
|
||||
commaSeparated: '逗號分隔的值',
|
||||
searchPlaceholder: '搜尋…',
|
||||
noResults: '找不到結果',
|
||||
systemDefault: '系統預設',
|
||||
loading: '正在載入 Hermes 設定...',
|
||||
emptyTitle: '無可設定項目',
|
||||
emptyDesc: '此區段沒有可調整的設定。',
|
||||
|
|
|
|||
|
|
@ -748,6 +748,9 @@ export const zh: Translations = {
|
|||
builtinOnly: '仅内置',
|
||||
notSet: '未设置',
|
||||
commaSeparated: '逗号分隔的值',
|
||||
searchPlaceholder: '搜索…',
|
||||
noResults: '未找到结果',
|
||||
systemDefault: '系统默认',
|
||||
loading: '正在加载 Hermes 配置...',
|
||||
emptyTitle: '无可配置项',
|
||||
emptyDesc: '此分区没有可调整的设置。',
|
||||
|
|
|
|||
|
|
@ -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?')) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
154
apps/desktop/src/lib/speech-text.test.ts
Normal file
154
apps/desktop/src/lib/speech-text.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
212
cli.py
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
55nx954gn6-debug
|
||||
1
contributors/emails/LauraGPT@users.noreply.github.com
Normal file
1
contributors/emails/LauraGPT@users.noreply.github.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
LauraGPT
|
||||
1
contributors/emails/afournier@nvidia.com
Normal file
1
contributors/emails/afournier@nvidia.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
afourniernv
|
||||
1
contributors/emails/alcibiades.eth@protonmail.com
Normal file
1
contributors/emails/alcibiades.eth@protonmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
0xAlcibiades
|
||||
1
contributors/emails/alex-secure@tuta.io
Normal file
1
contributors/emails/alex-secure@tuta.io
Normal file
|
|
@ -0,0 +1 @@
|
|||
AlexxRussell
|
||||
1
contributors/emails/anthony.ai.assistant@gmail.com
Normal file
1
contributors/emails/anthony.ai.assistant@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
AnthonyFrancis
|
||||
1
contributors/emails/at@aisec.co.il
Normal file
1
contributors/emails/at@aisec.co.il
Normal file
|
|
@ -0,0 +1 @@
|
|||
toomij99
|
||||
2
contributors/emails/bensheridanedwards@gmail.com
Normal file
2
contributors/emails/bensheridanedwards@gmail.com
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
BenSheridanEdwards
|
||||
# PR #68535 (stt: xAI OAuth retry)
|
||||
1
contributors/emails/brice@brice.net
Normal file
1
contributors/emails/brice@brice.net
Normal file
|
|
@ -0,0 +1 @@
|
|||
bricelb
|
||||
1
contributors/emails/brunopira@gmail.com
Normal file
1
contributors/emails/brunopira@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
brunopirz
|
||||
1
contributors/emails/carl@sempervirens.no
Normal file
1
contributors/emails/carl@sempervirens.no
Normal file
|
|
@ -0,0 +1 @@
|
|||
carljborg
|
||||
1
contributors/emails/contact@eliebruno.com
Normal file
1
contributors/emails/contact@eliebruno.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
eliemada
|
||||
1
contributors/emails/contato@webtecnica.com.br
Normal file
1
contributors/emails/contato@webtecnica.com.br
Normal file
|
|
@ -0,0 +1 @@
|
|||
webtecnica
|
||||
1
contributors/emails/damian.kluk.92@gmail.com
Normal file
1
contributors/emails/damian.kluk.92@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
damiankluk
|
||||
1
contributors/emails/dev@redeyesolutions.dev
Normal file
1
contributors/emails/dev@redeyesolutions.dev
Normal file
|
|
@ -0,0 +1 @@
|
|||
redsol-llc
|
||||
1
contributors/emails/gabriel@gabotronics.com
Normal file
1
contributors/emails/gabriel@gabotronics.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
ganzziani
|
||||
1
contributors/emails/gshall@pm.me
Normal file
1
contributors/emails/gshall@pm.me
Normal file
|
|
@ -0,0 +1 @@
|
|||
gshall
|
||||
1
contributors/emails/harp@hermz580.dev
Normal file
1
contributors/emails/harp@hermz580.dev
Normal file
|
|
@ -0,0 +1 @@
|
|||
hermz580
|
||||
1
contributors/emails/hubin-ll@foxmail.com
Normal file
1
contributors/emails/hubin-ll@foxmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
LLQWQ
|
||||
1
contributors/emails/ipkharitonov@gmail.com
Normal file
1
contributors/emails/ipkharitonov@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
kharitonov-ivan
|
||||
1
contributors/emails/johann@Mac.lan
Normal file
1
contributors/emails/johann@Mac.lan
Normal file
|
|
@ -0,0 +1 @@
|
|||
ousiaresearch
|
||||
1
contributors/emails/lumina@douno.it
Normal file
1
contributors/emails/lumina@douno.it
Normal file
|
|
@ -0,0 +1 @@
|
|||
chefboyrdave21
|
||||
1
contributors/emails/moeadham@gmail.com
Normal file
1
contributors/emails/moeadham@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
moeadham
|
||||
1
contributors/emails/namredips@gmail.com
Normal file
1
contributors/emails/namredips@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
namredips
|
||||
1
contributors/emails/normanking@me.com
Normal file
1
contributors/emails/normanking@me.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
RedClaus
|
||||
1
contributors/emails/reneisaipa@gmail.com
Normal file
1
contributors/emails/reneisaipa@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
IvanMiao
|
||||
1
contributors/emails/richardhojunjang@gmail.com
Normal file
1
contributors/emails/richardhojunjang@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
RichardHojunJang
|
||||
|
|
@ -0,0 +1 @@
|
|||
shellybotmoyer
|
||||
1
contributors/emails/simon@everythingmma.com.au
Normal file
1
contributors/emails/simon@everythingmma.com.au
Normal file
|
|
@ -0,0 +1 @@
|
|||
simonmmafs
|
||||
1
contributors/emails/simonmmafs@users.noreply.github.com
Normal file
1
contributors/emails/simonmmafs@users.noreply.github.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
simonmmafs
|
||||
1
contributors/emails/tikkanadityajyothi@gmail.com
Normal file
1
contributors/emails/tikkanadityajyothi@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
Vissirexa
|
||||
|
|
@ -0,0 +1 @@
|
|||
muctobi
|
||||
1
contributors/emails/tusharanshu18@gmail.com
Normal file
1
contributors/emails/tusharanshu18@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
tusharui
|
||||
1
contributors/emails/zehuaw@mit.edu
Normal file
1
contributors/emails/zehuaw@mit.edu
Normal file
|
|
@ -0,0 +1 @@
|
|||
zehuaw1
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)`:
|
||||
|
|
|
|||
127
docs/observability/relay-shared-metrics.md
Normal file
127
docs/observability/relay-shared-metrics.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
207
gateway/run.py
207
gateway/run.py
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
63
hermes_cli/lifecycle.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
31
hermes_cli/observability/__init__.py
Normal file
31
hermes_cli/observability/__init__.py
Normal 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
|
||||
)
|
||||
14
hermes_cli/observability/relay_runtime.py
Normal file
14
hermes_cli/observability/relay_runtime.py
Normal 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
Loading…
Add table
Add a link
Reference in a new issue