Reapply "feat(observability): integrate NeMo Relay runtime and shared metrics"

Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
Alex Fournier 2026-07-27 21:10:51 -07:00
parent 3af7b867fd
commit 14bed44c8c
65 changed files with 14427 additions and 2514 deletions

View file

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

View file

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

View file

@ -15,6 +15,7 @@ sites unchanged. Symbols that tests patch on ``run_agent`` (e.g.
from __future__ import annotations
import contextvars
import json
import logging
import math
@ -57,6 +58,12 @@ _OPENROUTER_PROVIDER_SORT_VALUES = {"throughput", "latency", "price"}
_FALLBACK_EXHAUSTED_COOLDOWN_S = 5.0
def _context_thread_target(callback):
"""Bind a no-argument thread target to the caller's ContextVars."""
context = contextvars.copy_context()
return lambda: context.run(callback)
def _ra():
"""Lazy ``run_agent`` reference.
@ -810,7 +817,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():
@ -1989,6 +1996,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, "
@ -2174,17 +2203,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()
@ -2192,6 +2237,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."
@ -2206,13 +2252,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:
@ -2229,7 +2284,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()
@ -2237,6 +2299,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."
@ -2246,6 +2309,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
@ -2404,7 +2474,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,
@ -2413,44 +2485,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()
@ -2465,18 +2533,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)
@ -2661,6 +2776,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.
@ -2680,11 +2796,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:
@ -2794,107 +2928,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()
@ -2909,19 +2942,123 @@ 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}
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,
)
)
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")
@ -3075,11 +3212,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
@ -3241,72 +3413,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":
@ -3315,7 +3510,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:
@ -3331,48 +3525,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
@ -3407,6 +3602,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
@ -3446,7 +3642,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
@ -3695,6 +3891,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
result["error"] = e
return
finally:
_close_managed_stream()
_close_request_client_once("stream_request_complete")
# Provider-configured stale timeout takes priority over env default.
@ -3757,7 +3954,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if _reasoning_floor is not None:
_stream_stale_timeout = max(_stream_stale_timeout, _reasoning_floor)
t = threading.Thread(target=_call, daemon=True)
t = threading.Thread(target=_context_thread_target(_call), daemon=True)
t.start()
_last_heartbeat = time.time()
_HEARTBEAT_INTERVAL = 30.0 # seconds between gateway activity touches

View file

@ -1236,6 +1236,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.
@ -1260,48 +1262,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,
@ -1320,6 +1362,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(
@ -1330,6 +1378,10 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
)
continue
raise
except RuntimeError:
if event_stream.final_response is not None:
return event_stream.final_response
raise
if final.status in {"incomplete", "failed"}:
logger.warning(

View file

@ -494,7 +494,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,
@ -2063,7 +2063,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,
)
@ -2104,6 +2104,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 [],
@ -2193,7 +2194,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
@ -3233,6 +3255,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
@ -5360,7 +5388,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,
)
@ -6693,7 +6721,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.

1108
agent/relay_llm.py Normal file

File diff suppressed because it is too large Load diff

991
agent/relay_runtime.py Normal file
View file

@ -0,0 +1,991 @@
"""Profile-scoped NeMo Relay runtimes owned by the Hermes agent core."""
from __future__ import annotations
import atexit
import asyncio
import contextvars
import importlib
import inspect
import logging
import threading
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
SESSION_SCOPE = "hermes.session"
TURN_SCOPE = "hermes.turn"
LOGICAL_LLM_SCOPE = "hermes.logical_llm_call"
RUNTIME_SCHEMA_KEY = "hermes.relay.schema_version"
RUNTIME_SCHEMA_VERSION = "hermes.relay.runtime.v1"
RUNTIME_INSTANCE_KEY = "hermes.relay.runtime_instance"
_PROFILE_KEY_CACHE: dict[str, str] = {}
@dataclass
class RelaySession:
"""One isolated Relay scope stack owned by a Hermes session."""
session_id: str
parent_session_id: str = ""
lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
closing: bool = False
handle: Any = None
context: contextvars.Context | None = None
class RelayRuntime:
"""Own Relay session scopes independently of any exporter or plugin."""
def __init__(self, relay: Any = None, *, profile_key: str | None = None) -> None:
self.relay = relay or _load_nemo_relay()
self.profile_key = profile_key or current_profile_key()
self.runtime_id = uuid.uuid4().hex
self._sessions_lock = threading.RLock()
self._sessions: dict[str, RelaySession] = {}
self._subagent_parents: dict[str, str] = {}
self._subagent_parent_handles: dict[str, Any] = {}
self._execution_consumers_lock = threading.RLock()
self._execution_consumers: set[str] = set()
self._shutdown_registered = True
atexit.register(self.shutdown)
def retain_managed_execution(self, consumer: str) -> None:
"""Keep managed LLM and tool execution active for one consumer."""
if not consumer:
raise ValueError("Relay managed-execution consumer must not be empty")
with self._execution_consumers_lock:
self._execution_consumers.add(consumer)
def release_managed_execution(self, consumer: str) -> None:
"""Release a consumer's managed-execution requirement."""
with self._execution_consumers_lock:
self._execution_consumers.discard(consumer)
def managed_execution_enabled(self) -> bool:
"""Return whether a Hermes-managed consumer needs the Relay pipeline."""
with self._execution_consumers_lock:
return bool(self._execution_consumers)
def ensure_session(
self,
event: dict[str, Any],
*,
data: Any = None,
metadata: dict[str, Any] | None = None,
) -> RelaySession | None:
"""Return the existing session scope or create it once."""
session_id = _session_id(event)
if not session_id:
return None
with self._sessions_lock:
session = self._sessions.get(session_id)
if session is None:
parent_session_id = self._subagent_parents.get(session_id, "")
session = RelaySession(
session_id=session_id,
parent_session_id=parent_session_id,
)
self._sessions[session_id] = session
with session.lock:
if session.closing:
return None
if session.handle is None:
parent_handle = None
scope_metadata = {
**(metadata or {}),
RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION,
RUNTIME_INSTANCE_KEY: self.runtime_id,
}
if session.parent_session_id:
with self._sessions_lock:
parent_handle = self._subagent_parent_handles.get(session_id)
if parent_handle is None:
parent = self.ensure_session({
"session_id": session.parent_session_id
})
if parent is not None:
parent_handle = parent.handle
scope_metadata["nemo_relay_scope_role"] = "subagent"
context = contextvars.Context()
try:
session.handle = context.run(
self.relay.scope.push,
SESSION_SCOPE,
self.relay.ScopeType.Agent,
handle=parent_handle,
data=data,
input={},
metadata=scope_metadata,
)
except Exception:
session.context = None
raise
session.context = context
return session
def register_subagent(
self,
event: dict[str, Any],
*,
metadata: dict[str, Any] | None = None,
) -> RelaySession | None:
"""Open a child Agent scope under its spawning turn when available."""
parent_session_id = str(event.get("parent_session_id") or "")
child_session_id = str(event.get("child_session_id") or "")
if (
not parent_session_id
or not child_session_id
or parent_session_id == child_session_id
):
return None
parent = self.ensure_session({"session_id": parent_session_id})
parent_handle = None if parent is None else parent.handle
turn = active_turn(parent_session_id)
if (
turn is not None
and not turn.closed
and turn.handle is not None
and turn.lease.host is self
and turn.lease.session is not None
and turn.lease.session.session_id == parent_session_id
):
parent_handle = turn.handle
with self._sessions_lock:
self._subagent_parents[child_session_id] = parent_session_id
if parent_handle is not None:
self._subagent_parent_handles[child_session_id] = parent_handle
return self.ensure_session(
{"session_id": child_session_id},
metadata=metadata,
)
def unregister_subagent(self, event: dict[str, Any]) -> None:
"""Close a delegated session and forget its parent relationship."""
child_session_id = str(event.get("child_session_id") or "")
if not child_session_id:
return
self.close_session({"session_id": child_session_id})
with self._sessions_lock:
self._subagent_parents.pop(child_session_id, None)
self._subagent_parent_handles.pop(child_session_id, None)
def get_session(self, session_id: str) -> RelaySession | None:
"""Return an active Hermes Relay session without creating one."""
with self._sessions_lock:
session = self._sessions.get(str(session_id or ""))
if session is None:
return None
with session.lock:
return None if session.closing else session
def get_session_handle(self, session_id: str) -> Any:
"""Return the Relay parent handle for a Hermes session, if active."""
session = self.get_session(session_id)
return None if session is None else session.handle
def run_in_session(
self,
session: RelaySession,
callback: Callable[..., Any],
*args: Any,
allow_closing: bool = False,
**kwargs: Any,
) -> Any:
"""Run a Relay operation against a session's isolated scope stack."""
with session.lock:
if session.closing and not allow_closing:
raise RuntimeError("Hermes Relay session is closing")
if session.context is None or session.handle is None:
raise RuntimeError("Hermes Relay session context is unavailable")
relay_context = session.context.copy()
context = contextvars.copy_context()
for variable, value in relay_context.items():
context.run(variable.set, value)
def invoke() -> Any:
self.relay.get_scope_stack()
return callback(*args, **kwargs)
# A copy permits a helper called by an existing Relay callback to
# re-enter the same logical session without re-entering Context.
return context.run(invoke)
async def run_in_session_async(
self,
session: RelaySession,
callback: Callable[..., Any],
*args: Any,
allow_closing: bool = False,
**kwargs: Any,
) -> Any:
"""Create and await an operation inside the session's saved context."""
with session.lock:
if session.closing and not allow_closing:
raise RuntimeError("Hermes Relay session is closing")
if session.context is None or session.handle is None:
raise RuntimeError("Hermes Relay session context is unavailable")
relay_context = session.context.copy()
context = contextvars.copy_context()
for variable, value in relay_context.items():
context.run(variable.set, value)
async def invoke() -> Any:
self.relay.get_scope_stack()
result = callback(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
task = context.run(asyncio.create_task, invoke())
return await task
def emit_mark(
self,
name: str,
event: dict[str, Any],
*,
data: Any = None,
metadata: Any = None,
) -> bool:
"""Emit a mark parented to the Hermes session identified by ``event``."""
session = self.ensure_session(event)
if session is None:
return False
self.run_in_session(
session,
self.relay.scope.event,
name,
handle=session.handle,
data=data,
metadata=metadata,
)
return True
def apply_tool_request_intercepts(
self,
*,
session_id: str,
tool_name: str,
args: dict[str, Any],
) -> dict[str, Any]:
"""Apply Relay request rewriting before Hermes authorizes a tool call."""
if not self.managed_execution_enabled():
return args
request_intercepts = getattr(
getattr(self.relay, "tools", None),
"request_intercepts",
None,
)
if not callable(request_intercepts):
return args
session = self.ensure_session({"session_id": session_id})
if session is None:
return args
result = self.run_in_session(
session,
request_intercepts,
tool_name,
args,
)
return result if isinstance(result, dict) else args
def close_session(self, event: dict[str, Any]) -> None:
"""Close one session scope and remove it from the core registry."""
session_id = _session_id(event)
with self._sessions_lock:
session = self._sessions.get(session_id)
if session is None:
with self._sessions_lock:
self._subagent_parents.pop(session_id, None)
self._subagent_parent_handles.pop(session_id, None)
return
failures: list[str] = []
with session.lock:
if session.closing:
return
session.closing = True
if session.handle is not None:
try:
self.run_in_session(
session,
self.relay.scope.pop,
session.handle,
output={},
metadata={
RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION,
RUNTIME_INSTANCE_KEY: self.runtime_id,
},
allow_closing=True,
)
except Exception as exc:
failures.append(f"session scope close failed: {exc}")
try:
self.relay.subscribers.flush()
except Exception as exc:
failures.append(f"subscriber flush failed: {exc}")
with self._sessions_lock:
if self._sessions.get(session_id) is session:
self._sessions.pop(session_id, None)
self._subagent_parents.pop(session_id, None)
self._subagent_parent_handles.pop(session_id, None)
if failures:
logger.warning(
"Hermes Relay session %s closed with errors: %s",
session_id,
"; ".join(failures),
)
def shutdown(self) -> None:
"""Close all core-owned Relay session scopes."""
with self._sessions_lock:
session_ids = list(self._sessions)
for session_id in session_ids:
self._safe(self.close_session, {"session_id": session_id})
if self._shutdown_registered:
try:
atexit.unregister(self.shutdown)
except Exception:
pass
self._shutdown_registered = False
@staticmethod
def _safe(callback: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
try:
return callback(*args, **kwargs)
except Exception:
logger.warning("Hermes Relay runtime operation failed", exc_info=True)
return None
@dataclass(frozen=True)
class NoopRelayRuntime:
"""Explicit reduced-capability host for platforms without Relay wheels."""
profile_key: str
reason: str
@property
def available(self) -> bool:
return False
def apply_tool_request_intercepts(
self,
*,
session_id: str,
tool_name: str,
args: dict[str, Any],
) -> dict[str, Any]:
del session_id, tool_name
return args
@staticmethod
def retain_managed_execution(consumer: str) -> None:
del consumer
@staticmethod
def release_managed_execution(consumer: str) -> None:
del consumer
@staticmethod
def managed_execution_enabled() -> bool:
return False
def shutdown(self) -> None:
"""No resources are allocated on unsupported platforms."""
RelayHost = RelayRuntime | NoopRelayRuntime
class RelayHostRegistry:
"""Own exactly one Relay host for each canonical Hermes profile."""
def __init__(self) -> None:
self._lock = threading.RLock()
self._hosts: dict[str, RelayHost] = {}
def for_profile(
self,
profile_key: str | None = None,
*,
create: bool = True,
) -> RelayHost | None:
key = profile_key or current_profile_key()
host = self._hosts.get(key)
if host is not None or not create:
return host
with self._lock:
host = self._hosts.get(key)
if host is not None or not create:
return host
try:
host = RelayRuntime(profile_key=key)
except Exception as exc:
logger.warning(
"Hermes Relay runtime initialization failed", exc_info=True
)
host = NoopRelayRuntime(profile_key=key, reason=str(exc))
self._hosts[key] = host
return host
def shutdown_profile(self, profile_key: str) -> None:
with self._lock:
host = self._hosts.pop(profile_key, None)
if host is not None:
host.shutdown()
def shutdown_all(self) -> None:
with self._lock:
hosts = list(self._hosts.values())
self._hosts.clear()
for host in hosts:
host.shutdown()
HOST_REGISTRY = RelayHostRegistry()
@dataclass
class ConversationLease:
"""A resumable reference to one profile-scoped conversation scope."""
profile_key: str
session_id: str
platform: str
host: RelayHost
session: RelaySession | None
parent_session_id: str = ""
released: bool = False
@dataclass
class RelayTurnContext:
"""Runtime-only context for one Hermes turn or top-level task."""
lease: ConversationLease
turn_id: str
task_id: str
handle: Any = None
logical_llm_calls: dict[str, Any] = field(default_factory=dict, repr=False)
logical_llm_lock: threading.RLock = field(
default_factory=threading.RLock,
repr=False,
)
finalize_lock: threading.RLock = field(
default_factory=threading.RLock,
repr=False,
)
_token: contextvars.Token[RelayTurnContext | None] | None = field(
default=None,
repr=False,
)
_active_registered: bool = field(default=False, repr=False)
closed: bool = False
_CURRENT_TURN: contextvars.ContextVar[RelayTurnContext | None] = contextvars.ContextVar(
"hermes_relay_turn", default=None
)
class RelaySessionCoordinator:
"""Own semantic conversation and turn lifetimes for Hermes core."""
def __init__(self, registry: RelayHostRegistry = HOST_REGISTRY) -> None:
self.registry = registry
self._initializer_lock = threading.RLock()
self._session_initializers: dict[
str,
Callable[[RelayRuntime, dict[str, Any]], None],
] = {}
self._active_turns_lock = threading.RLock()
self._active_turns: dict[tuple[str, str], set[int]] = {}
def register_session_initializer(
self,
name: str,
callback: Callable[[RelayRuntime, dict[str, Any]], None],
) -> None:
"""Register idempotent profile/session preparation before scope creation."""
with self._initializer_lock:
self._session_initializers[name] = callback
def unregister_session_initializer(self, name: str) -> None:
"""Remove a previously registered session initializer."""
with self._initializer_lock:
self._session_initializers.pop(name, None)
def _prepare_session(
self,
host: RelayRuntime,
context: dict[str, Any],
) -> None:
with self._initializer_lock:
initializers = list(self._session_initializers.items())
for name, callback in initializers:
try:
callback(host, context)
except Exception:
logger.warning(
"Hermes Relay session initializer failed: %s",
name,
exc_info=True,
)
def acquire_conversation(
self,
*,
profile_key: str,
session_id: str,
platform: str,
parent_session_id: str = "",
model: str = "",
) -> ConversationLease:
host = self.registry.for_profile(profile_key)
if host is None:
host = NoopRelayRuntime(profile_key, "Relay host creation was disabled")
session = None
if isinstance(host, RelayRuntime):
try:
session_context = {
"profile_key": profile_key,
"session_id": session_id,
"platform": platform,
"parent_session_id": parent_session_id,
"model": model,
}
self._prepare_session(host, session_context)
metadata = {"hermes.execution_surface": platform or "unknown"}
if parent_session_id and parent_session_id != session_id:
session = host.register_subagent(
{
"parent_session_id": parent_session_id,
"child_session_id": session_id,
},
metadata=metadata,
)
else:
session = host.ensure_session(
{"session_id": session_id},
metadata=metadata,
)
except Exception:
logger.warning(
"Hermes Relay conversation initialization failed",
exc_info=True,
)
return ConversationLease(
profile_key=profile_key,
session_id=session_id,
platform=platform,
host=host,
session=session,
parent_session_id=parent_session_id,
)
def begin_turn(
self,
lease: ConversationLease,
*,
turn_id: str,
task_id: str,
) -> RelayTurnContext:
if lease.released:
raise RuntimeError("Hermes Relay conversation lease is released")
turn = RelayTurnContext(lease=lease, turn_id=turn_id, task_id=task_id)
if isinstance(lease.host, RelayRuntime) and lease.session is not None:
try:
turn.handle = lease.host.run_in_session(
lease.session,
lease.host.relay.scope.push,
TURN_SCOPE,
lease.host.relay.ScopeType.Function,
handle=lease.session.handle,
input={},
metadata={
RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION,
RUNTIME_INSTANCE_KEY: lease.host.runtime_id,
"hermes.execution_surface": lease.platform or "unknown",
},
)
except Exception:
logger.warning("Hermes Relay turn initialization failed", exc_info=True)
turn._token = _CURRENT_TURN.set(turn)
key = (lease.profile_key, lease.session_id)
with self._active_turns_lock:
self._active_turns.setdefault(key, set()).add(id(turn))
turn._active_registered = True
return turn
def end_turn(
self,
turn: RelayTurnContext,
*,
outcome: str,
) -> None:
with turn.finalize_lock:
if turn.closed:
self._reset_turn_context(turn)
return
turn.closed = True
lease = turn.lease
try:
if isinstance(lease.host, RelayRuntime) and lease.session is not None:
self._finish_logical_calls(turn, outcome=outcome)
if turn.handle is not None:
try:
lease.host.run_in_session(
lease.session,
lease.host.relay.scope.pop,
turn.handle,
output={"outcome": outcome},
metadata={
RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION,
RUNTIME_INSTANCE_KEY: lease.host.runtime_id,
},
)
except Exception:
logger.warning(
"Hermes Relay turn finalization failed", exc_info=True
)
finally:
try:
# Delegated agents own one turn. Close their conversation
# while the active-turn guard is still held so a parent
# timeout fallback cannot race this terminal boundary.
if (
lease.parent_session_id
and isinstance(lease.host, RelayRuntime)
):
lease.host.unregister_subagent({
"child_session_id": lease.session_id
})
except Exception:
logger.warning(
"Hermes Relay child conversation finalization failed",
exc_info=True,
)
finally:
self._unregister_active_turn(turn)
self._reset_turn_context(turn)
def has_active_turn(self, *, profile_key: str, session_id: str) -> bool:
"""Return whether a turn is still running for one profile/session."""
key = (profile_key, session_id)
with self._active_turns_lock:
return bool(self._active_turns.get(key))
def _unregister_active_turn(self, turn: RelayTurnContext) -> None:
if not turn._active_registered:
return
key = (turn.lease.profile_key, turn.lease.session_id)
with self._active_turns_lock:
active = self._active_turns.get(key)
if active is not None:
active.discard(id(turn))
if not active:
self._active_turns.pop(key, None)
turn._active_registered = False
def _reset_active_turns_for_tests(self) -> None:
with self._active_turns_lock:
self._active_turns.clear()
def finish_logical_calls(
self,
turn: RelayTurnContext,
*,
outcome: str,
) -> None:
"""Close logical LLM children before sibling task aggregation scopes."""
with turn.finalize_lock:
if turn.closed:
return
self._finish_logical_calls(turn, outcome=outcome)
@staticmethod
def _finish_logical_calls(
turn: RelayTurnContext,
*,
outcome: str,
) -> None:
lease = turn.lease
if not isinstance(lease.host, RelayRuntime) or lease.session is None:
return
with turn.logical_llm_lock:
logical_calls = list(turn.logical_llm_calls.items())
turn.logical_llm_calls.clear()
for request_id, logical_handle in logical_calls:
try:
lease.host.run_in_session(
lease.session,
lease.host.relay.scope.pop,
logical_handle,
output={"outcome": outcome},
metadata={
RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION,
RUNTIME_INSTANCE_KEY: lease.host.runtime_id,
},
)
except Exception:
with turn.logical_llm_lock:
turn.logical_llm_calls.setdefault(request_id, logical_handle)
logger.warning(
"Hermes Relay logical LLM finalization failed",
exc_info=True,
)
@staticmethod
def _reset_turn_context(turn: RelayTurnContext) -> None:
"""Reset the originating ContextVar token when called in that context."""
if turn._token is None:
return
try:
_CURRENT_TURN.reset(turn._token)
except ValueError:
# A copied async/thread context may own terminal cleanup. Keep the
# token so the originating context can clear its stale reference.
return
turn._token = None
@staticmethod
def release_conversation(lease: ConversationLease) -> None:
"""Release a caller lease without closing a resumable conversation."""
lease.released = True
def finalize_conversation(
self,
*,
profile_key: str,
session_id: str,
) -> None:
host = self.registry.for_profile(profile_key, create=False)
if isinstance(host, RelayRuntime):
host.close_session({"session_id": session_id})
def shutdown_profile(self, profile_key: str) -> None:
self.registry.shutdown_profile(profile_key)
SESSION_COORDINATOR = RelaySessionCoordinator()
def current_turn() -> RelayTurnContext | None:
"""Return the turn context inherited by current async and thread work."""
return _CURRENT_TURN.get()
def active_turn(session_id: str | None = None) -> RelayTurnContext | None:
"""Return a live turn only when it belongs to the active profile/session."""
turn = current_turn()
if turn is None or turn.closed or turn.lease.released:
return None
if turn.lease.profile_key != current_profile_key():
return None
if session_id is not None and turn.lease.session_id != session_id:
return None
if isinstance(turn.lease.host, RelayRuntime):
if turn.lease.session is None:
return None
if turn.lease.host.get_session(turn.lease.session_id) is not turn.lease.session:
return None
return turn
def resolve_execution_context(
session_id: str,
) -> tuple[RelayRuntime | None, RelaySession | None, Any]:
"""Resolve one active turn/session parent for managed Relay execution."""
turn = active_turn(session_id)
if (
turn is not None
and isinstance(turn.lease.host, RelayRuntime)
and turn.lease.session is not None
):
session = turn.lease.session
return turn.lease.host, session, turn.handle or session.handle
# Managed-execution consumers create and retain the profile host before
# reaching an out-of-turn adapter. Do not initialize Relay for the default
# no-consumer path.
runtime = get_runtime(create=False)
if runtime is None:
return None, None, None
if not runtime.managed_execution_enabled():
return None, None, None
session = runtime.get_session(session_id)
if session is None:
session = runtime.ensure_session({"session_id": session_id})
return runtime, session, None if session is None else session.handle
def emit_mark(
name: str,
*,
session_id: str,
data: Any = None,
metadata: Any = None,
) -> bool:
"""Emit a fail-open Relay mark under a Hermes session."""
runtime = get_runtime(create=False)
if runtime is None:
return False
try:
return runtime.emit_mark(
name,
{"session_id": session_id},
data=data,
metadata=metadata,
)
except Exception:
logger.warning("Hermes Relay mark failed: %s", name, exc_info=True)
return False
def apply_tool_request_intercepts(
*,
session_id: str,
tool_name: str,
args: dict[str, Any],
) -> dict[str, Any]:
"""Return Relay-rewritten arguments at Hermes's authorization boundary."""
if not session_id:
return args
runtime = get_runtime(create=False)
if runtime is None:
return args
return runtime.apply_tool_request_intercepts(
session_id=session_id,
tool_name=tool_name,
args=args,
)
def ensure_session(*, session_id: str, **context: Any) -> RelaySession | None:
"""Create or return the shared Relay session used by Hermes core."""
runtime = get_runtime()
if runtime is None:
return None
try:
return runtime.ensure_session({"session_id": session_id, **context})
except Exception:
logger.warning("Hermes Relay session initialization failed", exc_info=True)
return None
def run_in_session(
session_id: str,
callback: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
"""Run a scope, LLM, or tool API against a shared Hermes session."""
runtime = get_runtime()
if runtime is None:
raise RuntimeError("Hermes Relay runtime is unavailable")
session = runtime.get_session(session_id)
if session is None:
session = runtime.ensure_session({"session_id": session_id})
if session is None:
raise RuntimeError("Hermes Relay session is unavailable")
return runtime.run_in_session(session, callback, *args, **kwargs)
async def run_in_session_async(
session_id: str,
callback: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
"""Await a Relay operation inside a shared Hermes session context."""
runtime = get_runtime()
if runtime is None:
raise RuntimeError("Hermes Relay runtime is unavailable")
session = runtime.get_session(session_id)
if session is None:
session = runtime.ensure_session({"session_id": session_id})
if session is None:
raise RuntimeError("Hermes Relay session is unavailable")
return await runtime.run_in_session_async(session, callback, *args, **kwargs)
def get_session_handle(session_id: str) -> Any:
"""Return the shared Relay handle for direct core instrumentation."""
runtime = get_runtime(create=False)
return None if runtime is None else runtime.get_session_handle(session_id)
def _is_relay_wrapped_callback_error(
relay_error: BaseException,
callback_error: BaseException,
) -> bool:
"""Match Relay's native callback wrapper without masking policy errors."""
if relay_error is callback_error:
return True
if not isinstance(relay_error, RuntimeError):
return False
callback_type = callback_error.__class__
type_names = {
callback_type.__name__,
callback_type.__qualname__,
f"{callback_type.__module__}.{callback_type.__qualname__}",
}
message = str(relay_error)
return any(
message.startswith(f"internal error: {type_name}: {callback_error}")
for type_name in type_names
)
def get_runtime(
*,
create: bool = True,
profile_key: str | None = None,
) -> RelayRuntime | None:
"""Return the Relay host for the active Hermes profile."""
host = HOST_REGISTRY.for_profile(profile_key, create=create)
return host if isinstance(host, RelayRuntime) else None
def get_host(
*,
create: bool = True,
profile_key: str | None = None,
) -> RelayHost | None:
"""Return the explicit real or reduced-capability host for a profile."""
return HOST_REGISTRY.for_profile(profile_key, create=create)
def current_profile_key() -> str:
"""Return the canonical profile identity used for runtime isolation."""
home = get_hermes_home().expanduser()
if not home.is_absolute():
return str(home.resolve())
raw = str(home)
cached = _PROFILE_KEY_CACHE.get(raw)
if cached is not None:
return cached
resolved = str(home.resolve())
return _PROFILE_KEY_CACHE.setdefault(raw, resolved)
def _load_nemo_relay() -> Any:
"""Load the binding only when a producer or consumer needs Relay."""
return importlib.import_module("nemo_relay")
def _session_id(event: dict[str, Any]) -> str:
return str(event.get("session_id") or "")
def _reset_for_tests() -> None:
"""Reset all profile-scoped Relay hosts for isolated tests."""
SESSION_COORDINATOR._reset_active_turns_for_tests()
HOST_REGISTRY.shutdown_all()
_PROFILE_KEY_CACHE.clear()

123
agent/relay_tools.py Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

@ -436,7 +436,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
@ -1030,7 +1035,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,
@ -1041,6 +1046,7 @@ def build_turn_context(
is_first_turn=(not bool(conversation_history)),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
parent_session_id=getattr(agent, "_parent_session_id", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
)
_ctx_parts: list[str] = []

View file

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

View file

@ -1465,6 +1465,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
# =============================================================================

31
cli.py
View file

@ -1267,9 +1267,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,
@ -1297,7 +1296,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,
@ -7695,13 +7694,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
@ -16596,7 +16603,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# the exit occurred, meaning run_conversation's hook didn't fire.
if self.agent and getattr(self, '_agent_running', False):
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_end",
session_id=self.agent.session_id,

View file

@ -0,0 +1 @@
afourniernv

View file

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

View file

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

View file

@ -6868,9 +6868,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",
@ -9162,11 +9161,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",
@ -10983,7 +10981,7 @@ 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,

View file

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

View file

@ -3418,6 +3418,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:

View file

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

View file

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

63
hermes_cli/lifecycle.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,817 @@
"""Direct NeMo Relay integration for Hermes shared client metrics."""
from __future__ import annotations
import atexit
import contextvars
import logging
import threading
from dataclasses import dataclass, field
from time import monotonic_ns
from typing import Any, Callable
from agent import relay_runtime
from hermes_cli import __version__
from .shared_metrics import SharedMetricsStore
from .shared_metrics_contract import (
MODEL_CALL_SCOPE,
SCHEMA_KEY,
SCHEMA_VERSION,
SUBSCRIBER_NAME,
TASK_SCOPE,
model_call_fields,
model_call_outcome,
task_start_fields,
task_terminal_fields,
)
from .shared_metrics_subscriber import SharedMetricsSubscriber
logger = logging.getLogger(__name__)
HANDLED_HOOKS = frozenset({
"on_session_start",
"on_session_end",
"on_session_finalize",
"on_session_reset",
"pre_llm_call",
"pre_api_request",
"post_tool_call",
"post_api_request",
"api_request_error",
"subagent_stop",
})
_RUNTIME_FAILED = object()
_RUNTIMES: dict[str, _Runtime | object] = {}
_RUNTIME_LOCK = threading.RLock()
def _retry_ordinal(event: dict[str, Any]) -> int | None:
value = event.get("retry_count")
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
return value
return None
@dataclass
class _ModelCall:
handle: Any
task_id: str
fields: dict[str, str]
retry_ordinal: int | None = None
@dataclass
class _TaskRun:
handle: Any
context: contextvars.Context
started_ns: int
start_fields: dict[str, str]
model_call_ids: set[str] = field(default_factory=set)
tool_call_ids: set[str] = field(default_factory=set)
turn_ids: set[str] = field(default_factory=set)
unidentified_tool_calls: int = 0
retry_count: int = 0
@dataclass
class _MetricsSession:
session_id: str
relay_session: relay_runtime.RelaySession
lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
closing: bool = False
model_calls: dict[str, _ModelCall] = field(default_factory=dict)
tasks: dict[str, _TaskRun] = field(default_factory=dict)
class _Runtime:
"""Own shared-metrics state layered on the Hermes core Relay host."""
def __init__(self, host: relay_runtime.RelayRuntime | None = None) -> None:
resolved_host = host or relay_runtime.get_runtime()
if resolved_host is None:
raise RuntimeError("Hermes core Relay runtime is unavailable")
self.host: relay_runtime.RelayRuntime = resolved_host
self.relay = self.host.relay
self._sessions_lock = threading.RLock()
self._active = True
self._sessions: dict[str, _MetricsSession] = {}
self._task_creation_lock = threading.RLock()
self._task_sessions_lock = threading.RLock()
self._task_sessions: dict[tuple[str, str], _MetricsSession] = {}
self._turn_sessions: dict[tuple[str, str], _MetricsSession] = {}
self._subscriber_name = f"{SUBSCRIBER_NAME}.{self.host.runtime_id}"
self.subscriber = SharedMetricsSubscriber(
SharedMetricsStore(),
__version__,
runtime_id=self.host.runtime_id,
)
self.relay.subscribers.register(self._subscriber_name, self.subscriber)
self.host.retain_managed_execution(self._subscriber_name)
self._registered = True
atexit.register(self.shutdown)
def ensure_session(self, event: dict[str, Any]) -> _MetricsSession | None:
session_id = str(event.get("session_id") or "")
if not session_id:
return None
with self._sessions_lock:
if not self._active:
return None
relay_session = self.host.ensure_session(event)
if relay_session is None:
return None
session = self._sessions.get(session_id)
if session is None:
session = _MetricsSession(
session_id=session_id,
relay_session=relay_session,
)
self._sessions[session_id] = session
with session.lock:
if session.closing:
return None
return session
def _run_in_session(
self,
session: _MetricsSession,
callback: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
return self.host.run_in_session(
session.relay_session,
callback,
*args,
**kwargs,
)
def start_task(self, event: dict[str, Any]) -> _TaskRun | None:
"""Open one Relay function scope for a Hermes task run."""
task_key = self._task_key(event)
if task_key is None:
return None
_, task_id = task_key
with self._task_creation_lock:
owner = self._task_session(event)
if owner is not None:
with owner.lock:
if owner.closing:
return None
task = owner.tasks.get(task_id)
if task is not None:
self._remember_turn(owner, task, event)
return task
session = self.ensure_session(event)
if session is None:
return None
with session.lock:
if session.closing or session.relay_session.context is None:
return None
task_context = session.relay_session.context.copy()
start_fields = task_start_fields(event)
active_turn = relay_runtime.active_turn(session.session_id)
parent_handle = session.relay_session.handle
if (
active_turn is not None
and active_turn.lease.session_id == session.session_id
and active_turn.task_id == task_id
and active_turn.handle is not None
):
parent_handle = active_turn.handle
def push_task() -> Any:
self.relay.get_scope_stack()
return self.relay.scope.push(
TASK_SCOPE,
self.relay.ScopeType.Function,
handle=parent_handle,
input=start_fields,
metadata=self._event_metadata(),
)
handle = task_context.run(push_task)
task = _TaskRun(
handle=handle,
context=task_context,
started_ns=monotonic_ns(),
start_fields=start_fields,
)
session.tasks[task_id] = task
with self._task_sessions_lock:
self._task_sessions[task_key] = session
self._remember_turn(session, task, event)
return task
def _run_in_task(
self,
task: _TaskRun,
callback: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
def invoke() -> Any:
self.relay.get_scope_stack()
return callback(*args, **kwargs)
return task.context.copy().run(invoke)
def start_model_call(self, event: dict[str, Any]) -> None:
task_id = str(event.get("task_id") or "")
session = self._task_session(event, allow_task_id_fallback=True)
task = session.tasks.get(task_id) if session is not None else None
if task is None:
task = self.start_task(event)
session = self._task_session(event) if task is not None else None
if session is None:
session = self.ensure_session(event)
if session is None:
return
request_id = str(event.get("api_request_id") or "")
if not request_id:
return
fields = model_call_fields(event)
retry_ordinal = _retry_ordinal(event)
model_family = fields["model_family"]
with session.lock:
if session.closing:
return
if task is not None:
self._remember_turn(session, task, event)
existing = session.model_calls.get(request_id)
if existing is not None:
existing.fields = fields
if task is not None:
if retry_ordinal is None or existing.retry_ordinal is None:
task.retry_count += 1
elif retry_ordinal > existing.retry_ordinal:
task.retry_count += retry_ordinal - existing.retry_ordinal
if retry_ordinal is not None:
existing.retry_ordinal = max(
existing.retry_ordinal or 0,
retry_ordinal,
)
return
if task is not None:
task.model_call_ids.add(request_id)
if retry_ordinal is not None and retry_ordinal > 0:
# A real Hermes retry can advance api_request_id while
# carrying the retry ordinal. Count that physical attempt.
task.retry_count += 1
handle = self._run_in_task(
task,
self.relay.llm.call,
MODEL_CALL_SCOPE,
self.relay.LLMRequest({}, {}),
handle=task.handle,
metadata=self._event_metadata(),
model_name=model_family,
)
else:
handle = self._run_in_session(
session,
self.relay.llm.call,
MODEL_CALL_SCOPE,
self.relay.LLMRequest({}, {}),
handle=session.relay_session.handle,
metadata=self._event_metadata(),
model_name=model_family,
)
session.model_calls[request_id] = _ModelCall(
handle=handle,
task_id=str(event.get("task_id") or ""),
fields=fields,
retry_ordinal=retry_ordinal,
)
def record_tool_call(self, event: dict[str, Any]) -> None:
"""Count one unique tool invocation under its owning task."""
task_id = str(event.get("task_id") or "")
session = self._task_session(event, allow_task_id_fallback=True)
task = session.tasks.get(task_id) if session is not None else None
if task is None:
task = self.start_task(event)
session = self._task_session(event) if task is not None else None
if session is None or task is None:
return
tool_call_id = str(event.get("tool_call_id") or "")
with session.lock:
if session.closing:
return
self._remember_turn(session, task, event)
if tool_call_id:
task.tool_call_ids.add(tool_call_id)
else:
task.unidentified_tool_calls += 1
def end_model_call(self, event: dict[str, Any], outcome: str | None = None) -> None:
session = self._task_session(event, allow_task_id_fallback=True)
if session is None:
session = self._session(event)
if session is None:
return
request_id = str(event.get("api_request_id") or "")
with session.lock:
if session.closing:
return
model_call = session.model_calls.get(request_id)
if model_call is None:
return
fields = model_call_fields(event)
model_call.fields = fields
self._finish_model_call(
session,
request_id,
outcome or model_call_outcome(event),
)
def end_pending_model_calls(self, event: dict[str, Any]) -> None:
session = self._task_session(event, allow_task_id_fallback=True)
if session is None:
session = self._session(event)
if session is None:
return
with session.lock:
if session.closing:
return
self._end_pending_model_calls(session, event)
def finish_task(self, event: dict[str, Any]) -> None:
"""Close one task scope exactly once with bounded terminal fields."""
task_id = str(event.get("task_id") or "")
session = self._task_session(
event,
allow_task_id_fallback=True,
) or self._session(event)
if session is None:
return
with session.lock:
if session.closing:
return
self._finish_task(session, task_id, event)
def close_session(self, event: dict[str, Any]) -> None:
session = self._session(event)
if session is None:
return
failures: list[str] = []
with session.lock:
if session.closing:
return
session.closing = True
for task_id in list(session.tasks):
self._finish_task(
session,
task_id,
{
**event,
"task_id": task_id,
"completed": False,
"failed": True,
"interrupted": False,
"turn_exit_reason": "system_aborted",
},
)
self._end_pending_model_calls(session, event)
try:
self.relay.subscribers.flush()
except Exception as exc:
failures.append(f"subscriber flush failed: {exc}")
self._export()
with self._sessions_lock:
if self._sessions.get(session.session_id) is session:
self._sessions.pop(session.session_id, None)
if failures:
logger.warning(
"Hermes shared-metrics session %s closed with errors: %s",
session.session_id,
"; ".join(failures),
)
def shutdown(self) -> None:
with self._sessions_lock:
self._active = False
session_ids = list(self._sessions)
for session_id in session_ids:
self._safe(self.close_session, {"session_id": session_id})
if not self._registered:
return
self._safe(self.relay.subscribers.flush)
self._export()
self._safe(self.relay.subscribers.deregister, self._subscriber_name)
self.host.release_managed_execution(self._subscriber_name)
self._registered = False
try:
atexit.unregister(self.shutdown)
except Exception:
pass
def deactivate(self) -> None:
"""Stop collection without exporting locally aggregated metrics."""
with self._sessions_lock:
self._active = False
self.subscriber.deactivate()
if self._registered:
self._safe(self.relay.subscribers.deregister, self._subscriber_name)
self.host.release_managed_execution(self._subscriber_name)
self._registered = False
with self._sessions_lock:
sessions = list(self._sessions.values())
for session in sessions:
with session.lock:
if session.closing:
continue
session.closing = True
for task_id in list(session.tasks):
self._finish_task(
session,
task_id,
{
"session_id": session.session_id,
"task_id": task_id,
"failed": True,
"turn_exit_reason": "system_aborted",
},
)
self._end_pending_model_calls(session, {})
with self._sessions_lock:
self._sessions.clear()
with self._task_sessions_lock:
self._task_sessions.clear()
self._turn_sessions.clear()
try:
atexit.unregister(self.shutdown)
except Exception:
pass
def _session(self, event: dict[str, Any]) -> _MetricsSession | None:
session_id = str(event.get("session_id") or "")
with self._sessions_lock:
return self._sessions.get(session_id)
@staticmethod
def _task_key(event: dict[str, Any]) -> tuple[str, str] | None:
session_id = str(event.get("session_id") or "")
task_id = str(event.get("task_id") or "")
if not session_id or not task_id:
return None
return session_id, task_id
def _task_session(
self,
event: dict[str, Any],
*,
allow_task_id_fallback: bool = False,
) -> _MetricsSession | None:
task_key = self._task_key(event)
if task_key is None:
return None
turn_key = self._turn_key(event)
with self._task_sessions_lock:
if turn_key is not None:
owner = self._turn_sessions.get(turn_key)
if owner is not None:
return owner
owner = self._task_sessions.get(task_key)
if owner is not None or not allow_task_id_fallback:
return owner
task_id = task_key[1]
candidates: list[_MetricsSession] = []
for (_, candidate_task_id), session in self._task_sessions.items():
if candidate_task_id != task_id:
continue
if not any(candidate is session for candidate in candidates):
candidates.append(session)
return candidates[0] if len(candidates) == 1 else None
@staticmethod
def _turn_key(event: dict[str, Any]) -> tuple[str, str] | None:
session_id = str(event.get("session_id") or "")
turn_id = str(event.get("turn_id") or "")
if not session_id or not turn_id:
return None
return session_id, turn_id
def _remember_turn(
self,
session: _MetricsSession,
task: _TaskRun,
event: dict[str, Any],
) -> None:
turn_id = str(event.get("turn_id") or "")
if not turn_id:
return
task.turn_ids.add(turn_id)
with self._task_sessions_lock:
self._turn_sessions[(session.session_id, turn_id)] = session
def _finish_model_call(
self,
session: _MetricsSession,
request_id: str,
outcome: str,
) -> None:
model_call = session.model_calls.pop(request_id, None)
if model_call is None:
return
try:
task = session.tasks.get(model_call.task_id)
if task is not None:
self._run_in_task(
task,
self.relay.llm.call_end,
model_call.handle,
{**model_call.fields, "outcome": outcome},
metadata=self._event_metadata(),
)
else:
self._run_in_session(
session,
self.relay.llm.call_end,
model_call.handle,
{**model_call.fields, "outcome": outcome},
metadata=self._event_metadata(),
)
except Exception:
logger.warning(
"Hermes shared-metrics model call close failed", exc_info=True
)
def _end_pending_model_calls(
self,
session: _MetricsSession,
event: dict[str, Any],
) -> None:
task_id = str(event.get("task_id") or "")
request_ids = [
request_id
for request_id, model_call in session.model_calls.items()
if not task_id or model_call.task_id == task_id
]
outcome = "cancelled" if event.get("interrupted") else "failed"
for request_id in request_ids:
self._finish_model_call(session, request_id, outcome)
def _finish_task(
self,
session: _MetricsSession,
task_id: str,
event: dict[str, Any],
) -> None:
task = session.tasks.get(task_id)
if task is None:
return
self._end_pending_model_calls(session, {**event, "task_id": task_id})
fields = task_terminal_fields(
{**task.start_fields, **event},
duration_ms=max(0, (monotonic_ns() - task.started_ns) // 1_000_000),
model_call_count=len(task.model_call_ids),
tool_call_count=len(task.tool_call_ids) + task.unidentified_tool_calls,
retry_count=task.retry_count,
)
try:
self._run_in_task(
task,
self.relay.scope.pop,
task.handle,
output=fields,
metadata=self._event_metadata(),
)
except Exception:
logger.warning("Hermes shared-metrics task close failed", exc_info=True)
finally:
session.tasks.pop(task_id, None)
with self._task_sessions_lock:
task_key = (session.session_id, task_id)
if self._task_sessions.get(task_key) is session:
self._task_sessions.pop(task_key, None)
for turn_id in task.turn_ids:
turn_key = (session.session_id, turn_id)
if self._turn_sessions.get(turn_key) is session:
self._turn_sessions.pop(turn_key, None)
def _export(self) -> None:
self._safe(self.subscriber.store.create_and_export_package)
def _event_metadata(self) -> dict[str, str]:
return {
SCHEMA_KEY: SCHEMA_VERSION,
relay_runtime.RUNTIME_INSTANCE_KEY: self.host.runtime_id,
}
@staticmethod
def _safe(callback: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
try:
return callback(*args, **kwargs)
except Exception:
logger.warning("Hermes shared metrics operation failed", exc_info=True)
return None
def enabled() -> bool:
"""Return the shared-metrics policy for the active Hermes profile."""
profile_key = relay_runtime.current_profile_key()
try:
from hermes_cli.config import read_raw_config
# Collection consent is profile-owned. Managed config overlays may
# control runtime policy, but cannot opt a profile into or out of
# shared metrics.
config = read_raw_config() or {}
except Exception:
logger.debug("Unable to read Hermes shared-metrics policy", exc_info=True)
value = False
else:
telemetry = config.get("telemetry") if isinstance(config, dict) else None
shared_metrics = (
telemetry.get("shared_metrics") if isinstance(telemetry, dict) else None
)
value = (
isinstance(shared_metrics, dict)
and shared_metrics.get("enabled") is True
)
if value:
return True
with _RUNTIME_LOCK:
runtime = _RUNTIMES.pop(profile_key, None)
if isinstance(runtime, _Runtime):
runtime.deactivate()
return False
def handles_hook(hook_name: str) -> bool:
return hook_name in HANDLED_HOOKS and enabled()
def observe_lifecycle(hook_name: str, **kwargs: Any) -> None:
"""Project one Hermes lifecycle event into the core Relay integration."""
if not handles_hook(hook_name):
return
runtime = _get_runtime()
if runtime is None:
return
try:
if hook_name == "on_session_start":
runtime.ensure_session(kwargs)
elif hook_name == "pre_llm_call":
runtime.start_task(kwargs)
elif hook_name == "pre_api_request":
runtime.start_model_call(kwargs)
elif hook_name == "post_tool_call":
runtime.record_tool_call(kwargs)
elif hook_name == "post_api_request":
runtime.end_model_call(kwargs, "success")
elif hook_name == "api_request_error":
if kwargs.get("retryable") is False:
runtime.end_model_call(kwargs, "failed")
elif hook_name == "on_session_end":
runtime.finish_task(kwargs)
elif hook_name == "subagent_stop":
child_session_id = str(kwargs.get("child_session_id") or "")
if child_session_id:
runtime.close_session({"session_id": child_session_id})
elif hook_name in {"on_session_finalize", "on_session_reset"}:
runtime.close_session(kwargs)
except Exception:
logger.warning(
"Hermes shared metrics hook failed: %s", hook_name, exc_info=True
)
def prepare_session_start() -> None:
"""Register the subscriber before any producer opens the session scope."""
if enabled():
_get_runtime(retry_failed=True)
def _prepare_core_session(
host: relay_runtime.RelayRuntime,
context: dict[str, Any],
) -> None:
"""Prepare the profile subscriber before the coordinator opens a scope."""
del context
if host.profile_key == relay_runtime.current_profile_key():
if enabled():
_get_runtime(retry_failed=True, host=host)
def start_task_run(
*,
session_id: str,
task_id: str,
platform: str,
parent_session_id: str = "",
) -> None:
"""Start task metrics at the outer Hermes execution boundary."""
if not enabled():
return
runtime = _get_runtime(retry_failed=True)
if runtime is None:
return
runtime._safe(
runtime.start_task,
{
"session_id": session_id,
"task_id": task_id,
"platform": platform,
"parent_session_id": parent_session_id,
},
)
def finish_task_run(
*,
session_id: str,
task_id: str,
platform: str,
result: dict[str, Any] | None = None,
error: BaseException | None = None,
) -> None:
"""Finish task metrics for every return or exception path."""
if not enabled():
return
runtime = _get_runtime()
if runtime is None:
return
terminal = result if isinstance(result, dict) else {}
interrupted = terminal.get("interrupted") is True
completed = terminal.get("completed") is True
failed = terminal.get("failed") is True
reason = str(
terminal.get("turn_exit_reason") or terminal.get("failure_reason") or ""
)
if error is not None:
interrupted = isinstance(error, (KeyboardInterrupt, InterruptedError)) or (
type(error).__name__ == "CancelledError"
)
timed_out = isinstance(error, TimeoutError)
completed = False
failed = not interrupted
if interrupted:
reason = "interrupted_by_user"
elif timed_out:
reason = "timed_out"
else:
reason = "system_aborted"
elif not reason:
reason = "failed" if failed else "unknown"
runtime._safe(
runtime.finish_task,
{
"session_id": session_id,
"task_id": task_id,
"platform": platform,
"completed": completed,
"failed": failed,
"interrupted": interrupted,
"turn_exit_reason": reason,
},
)
def _get_runtime(
*,
retry_failed: bool = False,
host: relay_runtime.RelayRuntime | None = None,
) -> _Runtime | None:
profile_key = relay_runtime.current_profile_key()
with _RUNTIME_LOCK:
runtime = _RUNTIMES.get(profile_key)
if isinstance(runtime, _Runtime):
if host is None or runtime.host is host:
return runtime
runtime.deactivate()
_RUNTIMES.pop(profile_key, None)
if runtime is _RUNTIME_FAILED and not retry_failed:
return None
if runtime is _RUNTIME_FAILED:
_RUNTIMES.pop(profile_key, None)
try:
runtime = _Runtime(host=host)
except Exception:
logger.warning("Hermes shared metrics initialization failed", exc_info=True)
_RUNTIMES[profile_key] = _RUNTIME_FAILED
return None
_RUNTIMES[profile_key] = runtime
return runtime
relay_runtime.SESSION_COORDINATOR.register_session_initializer(
SUBSCRIBER_NAME,
_prepare_core_session,
)
def _reset_for_tests() -> None:
"""Reset all profile-scoped shared-metrics state for isolated tests."""
with _RUNTIME_LOCK:
runtimes = list(_RUNTIMES.values())
_RUNTIMES.clear()
for runtime in runtimes:
if isinstance(runtime, _Runtime):
runtime.shutdown()

View file

@ -0,0 +1,338 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:hermes-agent:schema:shared-metrics:v1",
"title": "Hermes Shared Metrics Package v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"package_id",
"install_id",
"period_start",
"period_end",
"generated_at",
"resource",
"metrics"
],
"properties": {
"schema_version": {
"const": "hermes.shared_metrics.v1"
},
"package_id": {
"$ref": "#/$defs/uuid"
},
"install_id": {
"description": "Random persistent identifier scoped to one HERMES_HOME; local-only in schema v1.",
"$ref": "#/$defs/uuid"
},
"period_start": {
"type": "string",
"format": "date-time"
},
"period_end": {
"type": "string",
"format": "date-time"
},
"generated_at": {
"type": "string",
"format": "date-time"
},
"resource": {
"type": "object",
"additionalProperties": false,
"required": [
"hermes_version"
],
"properties": {
"hermes_version": {
"type": "string",
"minLength": 1,
"maxLength": 64
}
}
},
"metrics": {
"type": "array",
"minItems": 1,
"items": {
"oneOf": [
{
"$ref": "#/$defs/model_call_counter"
},
{
"$ref": "#/$defs/task_started_counter"
},
{
"$ref": "#/$defs/task_finished_counter"
}
]
}
}
},
"$defs": {
"uuid": {
"type": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
},
"model_call_counter": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"type",
"dimensions",
"value"
],
"properties": {
"name": {
"const": "hermes.model_call.count"
},
"type": {
"const": "counter"
},
"dimensions": {
"type": "object",
"additionalProperties": false,
"required": [
"call_role",
"locality",
"model_family",
"outcome",
"provider_family"
],
"properties": {
"call_role": {
"const": "primary"
},
"locality": {
"enum": [
"local",
"remote",
"unknown"
]
},
"model_family": {
"enum": [
"claude",
"deepseek",
"gemini",
"gemma",
"glm",
"gpt",
"grok",
"kimi",
"llama",
"minimax",
"mimo",
"mistral",
"nemotron",
"nova",
"o1",
"o3",
"o4",
"qwen",
"step",
"trinity",
"unknown"
]
},
"outcome": {
"enum": [
"cancelled",
"failed",
"success"
]
},
"provider_family": {
"enum": [
"aggregator",
"custom",
"direct",
"local",
"unknown"
]
}
}
},
"value": {
"type": "integer",
"minimum": 1
}
}
},
"task_started_counter": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"type",
"dimensions",
"value"
],
"properties": {
"name": {
"const": "hermes.task_run.started"
},
"type": {
"const": "counter"
},
"dimensions": {
"type": "object",
"additionalProperties": false,
"required": [
"entrypoint",
"execution_surface"
],
"properties": {
"entrypoint": {
"$ref": "#/$defs/task_entrypoint"
},
"execution_surface": {
"$ref": "#/$defs/execution_surface"
}
}
},
"value": {
"type": "integer",
"minimum": 1
}
}
},
"task_finished_counter": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"type",
"dimensions",
"value"
],
"properties": {
"name": {
"const": "hermes.task_run.finished"
},
"type": {
"const": "counter"
},
"dimensions": {
"type": "object",
"additionalProperties": false,
"required": [
"duration_bucket",
"end_reason",
"entrypoint",
"execution_surface",
"model_call_count_bucket",
"outcome",
"retry_count_bucket",
"termination",
"tool_call_count_bucket"
],
"properties": {
"duration_bucket": {
"$ref": "#/$defs/duration_bucket"
},
"end_reason": {
"enum": [
"approval_denied",
"completed",
"failed",
"guardrail_blocked",
"iteration_limit",
"system_aborted",
"timed_out",
"unknown",
"user_cancelled"
]
},
"entrypoint": {
"$ref": "#/$defs/task_entrypoint"
},
"execution_surface": {
"$ref": "#/$defs/execution_surface"
},
"model_call_count_bucket": {
"$ref": "#/$defs/count_bucket"
},
"outcome": {
"enum": [
"cancelled",
"failed",
"success",
"timed_out",
"unknown"
]
},
"retry_count_bucket": {
"$ref": "#/$defs/count_bucket"
},
"termination": {
"enum": [
"none",
"system_aborted",
"timed_out",
"unknown",
"user_cancelled"
]
},
"tool_call_count_bucket": {
"$ref": "#/$defs/count_bucket"
}
}
},
"value": {
"type": "integer",
"minimum": 1
}
}
},
"execution_surface": {
"enum": [
"api",
"batch",
"cli",
"desktop",
"gateway",
"other",
"python",
"scheduled_task",
"tui",
"unknown"
]
},
"task_entrypoint": {
"enum": [
"api",
"background",
"batch",
"delegated",
"gateway_message",
"interactive",
"other",
"python",
"scheduled_task",
"unknown"
]
},
"duration_bucket": {
"enum": [
"1s_to_5s",
"2m_to_10m",
"30s_to_2m",
"5s_to_30s",
"gte_10m",
"lt_1s"
]
},
"count_bucket": {
"enum": [
"0",
"1",
"2",
"3_to_5",
"6_to_10",
"gte_11"
]
}
}
}

View file

@ -0,0 +1,486 @@
"""Durable aggregation and local export for Hermes shared metrics."""
from __future__ import annotations
import json
import logging
import sqlite3
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from hermes_cli.sqlite_util import write_txn
from hermes_constants import get_hermes_home
from utils import atomic_json_write
from .shared_metrics_contract import (
COUNTER_METRICS,
MODEL_CALL_METRIC,
counter_dimensions_are_valid,
)
_PACKAGE_SCHEMA_VERSION = "hermes.shared_metrics.v1"
_STORE_SCHEMA_VERSION = "1"
_BUSY_TIMEOUT_MS = 250
_SCHEMA_BUSY_TIMEOUT_MS = 5_000
_LOCAL_HISTORY_RETENTION_DAYS = 30
logger = logging.getLogger(__name__)
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _isoformat(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
class SharedMetricsStore:
"""Persist allowlisted counters and export immutable delta packages."""
def __init__(
self,
database_path: Path | None = None,
outbox_directory: Path | None = None,
) -> None:
root = get_hermes_home() / "telemetry" / "shared_metrics"
self.database_path = database_path or root / "metrics.sqlite3"
self.outbox_directory = outbox_directory or root / "outbox"
self._ensure_private_directory(self.database_path.parent)
self._ensure_private_directory(self.outbox_directory)
self._ensure_private_file(self.database_path)
self._ensure_schema()
def record_model_call(
self,
dimensions: dict[str, str],
hermes_version: str,
) -> None:
"""Increment the terminal model-call counter for the current UTC day."""
self.record_counter(MODEL_CALL_METRIC, dimensions, hermes_version)
def record_counter(
self,
metric_name: str,
dimensions: dict[str, str],
hermes_version: str,
) -> None:
"""Increment one allowlisted counter for the current UTC day."""
if metric_name not in COUNTER_METRICS:
raise ValueError(f"Unsupported shared metric: {metric_name}")
if not counter_dimensions_are_valid(metric_name, dimensions):
raise ValueError(f"Unsupported dimensions for shared metric: {metric_name}")
dimensions_json = json.dumps(
dimensions,
sort_keys=True,
separators=(",", ":"),
)
period_start = _utc_now().date().isoformat()
with self._connection() as connection:
connection.execute(
"""
INSERT INTO counter_aggregates(
period_start,
metric_name,
hermes_version,
dimensions_json,
value,
packaged_value
) VALUES (?, ?, ?, ?, 1, 0)
ON CONFLICT(
period_start,
metric_name,
hermes_version,
dimensions_json
)
DO UPDATE SET value = value + 1
""",
(
period_start,
metric_name,
hermes_version or "unknown",
dimensions_json,
),
)
def create_and_export_package(self) -> list[Path]:
"""Commit one pending delta package, then atomically export the outbox."""
pending_periods = self._pending_period_count()
for _ in range(pending_periods):
if self._create_package() is None:
break
exported = self._export_pending_packages()
try:
self._prune_expired_history()
except Exception:
logger.warning(
"Unable to prune expired shared-metrics history",
exc_info=True,
)
return exported
def counter_snapshot(self) -> list[dict[str, Any]]:
"""Return cumulative counters for focused tests and local inspection."""
with self._connection() as connection:
rows = connection.execute(
"""
SELECT
period_start,
metric_name,
hermes_version,
dimensions_json,
value,
packaged_value
FROM counter_aggregates
ORDER BY period_start, hermes_version, metric_name, dimensions_json
"""
).fetchall()
return [
{
"period_start": row["period_start"],
"metric_name": row["metric_name"],
"hermes_version": row["hermes_version"],
"dimensions": json.loads(row["dimensions_json"]),
"value": row["value"],
"packaged_value": row["packaged_value"],
}
for row in rows
]
@contextmanager
def _connection(
self,
*,
busy_timeout_ms: int = _BUSY_TIMEOUT_MS,
) -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(
self.database_path,
timeout=busy_timeout_ms / 1000,
)
try:
connection.row_factory = sqlite3.Row
connection.execute(f"PRAGMA busy_timeout={busy_timeout_ms}")
with connection:
yield connection
finally:
connection.close()
@staticmethod
def _ensure_private_directory(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True, mode=0o700)
try:
path.chmod(0o700)
except OSError:
pass
@staticmethod
def _ensure_private_file(path: Path) -> None:
path.touch(mode=0o600, exist_ok=True)
try:
path.chmod(0o600)
except OSError:
pass
def _ensure_schema(self) -> None:
with self._connection(busy_timeout_ms=_SCHEMA_BUSY_TIMEOUT_MS) as connection:
# Serialize first-run creation and upgrades across Hermes processes.
with write_txn(connection):
self._ensure_schema_in_transaction(connection)
@staticmethod
def _ensure_schema_in_transaction(connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS telemetry_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"""
)
schema_row = connection.execute(
"SELECT value FROM telemetry_state WHERE key = 'schema_version'"
).fetchone()
if schema_row is not None and str(schema_row["value"]) != _STORE_SCHEMA_VERSION:
raise RuntimeError(
"Unsupported shared-metrics store schema version: "
f"{schema_row['value']}"
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS counter_aggregates (
period_start TEXT NOT NULL,
metric_name TEXT NOT NULL,
hermes_version TEXT NOT NULL,
dimensions_json TEXT NOT NULL,
value INTEGER NOT NULL,
packaged_value INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (
period_start,
metric_name,
hermes_version,
dimensions_json
)
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS package_outbox (
package_id TEXT PRIMARY KEY,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL,
exported_at TEXT
)
"""
)
connection.execute(
"""
INSERT OR IGNORE INTO telemetry_state(key, value)
VALUES ('schema_version', ?)
""",
(_STORE_SCHEMA_VERSION,),
)
def _install_id(self, connection: sqlite3.Connection) -> str:
row = connection.execute(
"SELECT value FROM telemetry_state WHERE key = 'install_id'"
).fetchone()
if row is not None:
return str(row["value"])
candidate = str(uuid.uuid4())
connection.execute(
"INSERT OR IGNORE INTO telemetry_state(key, value) VALUES ('install_id', ?)",
(candidate,),
)
row = connection.execute(
"SELECT value FROM telemetry_state WHERE key = 'install_id'"
).fetchone()
if row is None:
raise RuntimeError("Unable to create the shared-metrics install identity")
return str(row["value"])
def _pending_period_count(self) -> int:
with self._connection() as connection:
row = connection.execute(
"""
SELECT COUNT(*) AS period_count
FROM (
SELECT period_start, hermes_version
FROM counter_aggregates
WHERE value > packaged_value
GROUP BY period_start, hermes_version
)
"""
).fetchone()
return int(row["period_count"]) if row is not None else 0
def _create_package(self) -> dict[str, Any] | None:
now = _utc_now()
with self._connection() as connection:
with write_txn(connection):
return self._create_package_in_transaction(connection, now)
def _create_package_in_transaction(
self,
connection: sqlite3.Connection,
now: datetime,
) -> dict[str, Any] | None:
period_row = connection.execute(
"""
SELECT period_start, hermes_version
FROM counter_aggregates
WHERE value > packaged_value
ORDER BY period_start, hermes_version
LIMIT 1
"""
).fetchone()
period_value = period_row["period_start"] if period_row is not None else None
if not period_value:
return None
rows = connection.execute(
"""
SELECT metric_name, dimensions_json, value, packaged_value
FROM counter_aggregates
WHERE period_start = ?
AND hermes_version = ?
AND value > packaged_value
ORDER BY metric_name, dimensions_json
""",
(period_value, period_row["hermes_version"]),
).fetchall()
period_start = datetime.fromisoformat(str(period_value)).replace(
tzinfo=timezone.utc
)
period_end = period_start + timedelta(days=1)
package_id = str(uuid.uuid4())
payload = {
"schema_version": _PACKAGE_SCHEMA_VERSION,
"package_id": package_id,
"install_id": self._install_id(connection),
"period_start": _isoformat(period_start),
"period_end": _isoformat(period_end),
"generated_at": _isoformat(now),
"resource": {"hermes_version": period_row["hermes_version"]},
"metrics": [self._package_metric(row) for row in rows],
}
payload_json = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
)
connection.execute(
"""
INSERT INTO package_outbox(
package_id,
period_start,
period_end,
payload_json,
created_at
) VALUES (?, ?, ?, ?, ?)
""",
(
package_id,
payload["period_start"],
payload["period_end"],
payload_json,
payload["generated_at"],
),
)
for row in rows:
connection.execute(
"""
UPDATE counter_aggregates
SET packaged_value = value
WHERE period_start = ?
AND metric_name = ?
AND hermes_version = ?
AND dimensions_json = ?
""",
(
period_value,
row["metric_name"],
period_row["hermes_version"],
row["dimensions_json"],
),
)
return payload
@staticmethod
def _package_metric(row: sqlite3.Row) -> dict[str, Any]:
metric_name = str(row["metric_name"])
dimensions = json.loads(row["dimensions_json"])
if not isinstance(dimensions, dict) or not counter_dimensions_are_valid(
metric_name, dimensions
):
raise ValueError(f"Unsupported dimensions for shared metric: {metric_name}")
return {
"name": metric_name,
"type": "counter",
"dimensions": dimensions,
"value": row["value"] - row["packaged_value"],
}
def _export_pending_packages(self) -> list[Path]:
with self._connection() as connection:
rows = connection.execute(
"""
SELECT package_id, payload_json
FROM package_outbox
WHERE exported_at IS NULL
ORDER BY created_at, package_id
"""
).fetchall()
exported: list[Path] = []
for row in rows:
package_id = str(row["package_id"])
path = self.outbox_directory / f"{package_id}.json"
atomic_json_write(
path,
json.loads(row["payload_json"]),
indent=2,
sort_keys=True,
mode=0o600,
)
with self._connection() as connection:
connection.execute(
"""
UPDATE package_outbox
SET exported_at = ?
WHERE package_id = ? AND exported_at IS NULL
""",
(_isoformat(_utc_now()), package_id),
)
exported.append(path)
return exported
def _prune_expired_history(self, *, now: datetime | None = None) -> None:
"""Remove exported local history after the bounded retention window."""
cutoff = (now or _utc_now()) - timedelta(
days=_LOCAL_HISTORY_RETENTION_DAYS
)
cutoff_timestamp = _isoformat(cutoff)
cutoff_period = cutoff.date().isoformat()
with self._connection() as connection:
rows = connection.execute(
"""
SELECT package_id
FROM package_outbox
WHERE exported_at IS NOT NULL
AND exported_at < ?
ORDER BY exported_at, package_id
""",
(cutoff_timestamp,),
).fetchall()
removable_package_ids: list[str] = []
for row in rows:
package_id = str(row["package_id"])
try:
(self.outbox_directory / f"{package_id}.json").unlink(
missing_ok=True
)
except OSError:
logger.warning(
"Unable to prune expired shared-metrics package %s",
package_id,
exc_info=True,
)
continue
removable_package_ids.append(package_id)
with self._connection() as connection:
with write_txn(connection):
for package_id in removable_package_ids:
connection.execute(
"""
DELETE FROM package_outbox
WHERE package_id = ?
AND exported_at IS NOT NULL
AND exported_at < ?
""",
(package_id, cutoff_timestamp),
)
connection.execute(
"""
DELETE FROM counter_aggregates
WHERE period_start < ?
AND value = packaged_value
AND NOT EXISTS (
SELECT 1
FROM package_outbox
WHERE exported_at IS NULL
AND substr(package_outbox.period_start, 1, 10)
= counter_aggregates.period_start
)
""",
(cutoff_period,),
)

View file

@ -0,0 +1,516 @@
"""Bounded product contract for the first Hermes shared-metrics slice."""
from __future__ import annotations
import re
from functools import lru_cache
from typing import Any
from agent.relay_runtime import RUNTIME_INSTANCE_KEY
SCHEMA_KEY = "hermes.metrics.schema_version"
SCHEMA_VERSION = "hermes.metrics.event.v1"
MODEL_CALL_SCOPE = "hermes.model_call"
TASK_SCOPE = "hermes.task_run"
SUBSCRIBER_NAME = "hermes.nemo_relay.shared_metrics"
PRIMARY_MODEL_CALL_ROLE = "primary"
MODEL_CALL_METRIC = "hermes.model_call.count"
TASK_STARTED_METRIC = "hermes.task_run.started"
TASK_FINISHED_METRIC = "hermes.task_run.finished"
EXECUTION_SURFACES: frozenset[str] = frozenset({
"api",
"batch",
"cli",
"desktop",
"gateway",
"python",
"scheduled_task",
"tui",
"other",
"unknown",
})
PROVIDER_FAMILIES: frozenset[str] = frozenset({
"aggregator",
"custom",
"direct",
"local",
"unknown",
})
MODEL_LOCALITIES: frozenset[str] = frozenset({"local", "remote", "unknown"})
MODEL_OUTCOMES: frozenset[str] = frozenset({"cancelled", "failed", "success"})
TASK_OUTCOMES: frozenset[str] = frozenset({
"cancelled",
"failed",
"success",
"timed_out",
"unknown",
})
TASK_END_REASONS: frozenset[str] = frozenset({
"approval_denied",
"completed",
"failed",
"guardrail_blocked",
"iteration_limit",
"system_aborted",
"timed_out",
"unknown",
"user_cancelled",
})
TASK_TERMINATIONS: frozenset[str] = frozenset({
"none",
"system_aborted",
"timed_out",
"unknown",
"user_cancelled",
})
TASK_ENTRYPOINTS: frozenset[str] = frozenset({
"api",
"background",
"batch",
"delegated",
"gateway_message",
"interactive",
"other",
"python",
"scheduled_task",
"unknown",
})
DURATION_BUCKETS: frozenset[str] = frozenset({
"1s_to_5s",
"2m_to_10m",
"30s_to_2m",
"5s_to_30s",
"gte_10m",
"lt_1s",
})
COUNT_BUCKETS: frozenset[str] = frozenset({
"0",
"1",
"2",
"3_to_5",
"6_to_10",
"gte_11",
})
# Shared metrics use an explicit family allowlist rather than raw model IDs or
# dynamically sourced catalog values. The latter would make the exported schema
# drift independently of this contract.
MODEL_FAMILIES: frozenset[str] = frozenset({
"claude",
"deepseek",
"gemini",
"gemma",
"glm",
"gpt",
"grok",
"kimi",
"llama",
"minimax",
"mimo",
"mistral",
"nemotron",
"nova",
"qwen",
"step",
"trinity",
"o1",
"o3",
"o4",
"unknown",
})
_COUNTER_DIMENSION_VALUES: dict[str, dict[str, frozenset[str]]] = {
MODEL_CALL_METRIC: {
"call_role": frozenset({PRIMARY_MODEL_CALL_ROLE}),
"locality": MODEL_LOCALITIES,
"model_family": MODEL_FAMILIES,
"outcome": MODEL_OUTCOMES,
"provider_family": PROVIDER_FAMILIES,
},
TASK_STARTED_METRIC: {
"entrypoint": TASK_ENTRYPOINTS,
"execution_surface": EXECUTION_SURFACES,
},
TASK_FINISHED_METRIC: {
"duration_bucket": DURATION_BUCKETS,
"end_reason": TASK_END_REASONS,
"entrypoint": TASK_ENTRYPOINTS,
"execution_surface": EXECUTION_SURFACES,
"model_call_count_bucket": COUNT_BUCKETS,
"outcome": TASK_OUTCOMES,
"retry_count_bucket": COUNT_BUCKETS,
"termination": TASK_TERMINATIONS,
"tool_call_count_bucket": COUNT_BUCKETS,
},
}
COUNTER_METRICS: frozenset[str] = frozenset(_COUNTER_DIMENSION_VALUES)
_MODEL_FAMILY_PATTERN = re.compile(
r"(?:^|[/_.:-])("
+ "|".join(
re.escape(family)
for family in sorted(
MODEL_FAMILIES - {"unknown"},
key=lambda value: len(value),
reverse=True,
)
)
+ r")(?=$|[/_.:-]|\d)"
)
# These providers route across model families but are not marked as aggregators
# in Hermes's execution metadata because that flag has narrower routing/catalog
# semantics there.
_TELEMETRY_AGGREGATOR_OVERRIDES = frozenset({
"copilot-acp",
"github-copilot",
"moa",
"nous",
})
# Hermes intentionally resolves these local runtimes through the generic custom
# provider path, so canonical provider metadata cannot distinguish them alone.
_LOCAL_CUSTOM_PROVIDER_ALIASES = frozenset({"mlx", "ollama"})
def counter_dimensions_are_valid(
metric_name: str,
dimensions: dict[str, Any],
) -> bool:
"""Return whether dimensions match one closed shared-metric contract."""
contract = _COUNTER_DIMENSION_VALUES.get(metric_name)
if contract is None or set(dimensions) != set(contract):
return False
return all(
isinstance(dimensions[field], str)
and dimensions[field] in allowed_values
for field, allowed_values in contract.items()
)
def model_call_dimensions(event: Any) -> dict[str, str] | None:
"""Return package dimensions for one valid primary model-call end event."""
metadata = getattr(event, "metadata", None)
if not isinstance(metadata, dict) or metadata.get(SCHEMA_KEY) != SCHEMA_VERSION:
return None
relay_metadata = set(metadata) - {SCHEMA_KEY, RUNTIME_INSTANCE_KEY}
if relay_metadata - {"otel.status_code"} or metadata.get(
"otel.status_code", "OK"
) not in {"OK", "ERROR"}:
return None
if (
str(getattr(event, "kind", "") or "") != "scope"
or str(getattr(event, "category", "") or "") != "llm"
or str(getattr(event, "name", "") or "") != MODEL_CALL_SCOPE
or str(getattr(event, "scope_category", "") or "") != "end"
):
return None
category_profile = getattr(event, "category_profile", None)
if not isinstance(category_profile, dict) or set(category_profile) != {
"model_name"
}:
return None
event_model_family = category_profile.get("model_name")
if event_model_family not in MODEL_FAMILIES:
return None
data = getattr(event, "data", None)
expected_fields = {
"call_role",
"locality",
"model_family",
"outcome",
"provider_family",
}
if not isinstance(data, dict) or set(data) != expected_fields:
return None
dimensions = {
"call_role": data.get("call_role"),
"locality": data.get("locality"),
"model_family": data.get("model_family"),
"outcome": data.get("outcome"),
"provider_family": data.get("provider_family"),
}
if not counter_dimensions_are_valid(MODEL_CALL_METRIC, dimensions):
return None
return dimensions
def task_counter(event: Any) -> tuple[str, dict[str, str]] | None:
"""Return one validated task counter from a task scope event."""
metadata = getattr(event, "metadata", None)
if not isinstance(metadata, dict) or metadata.get(SCHEMA_KEY) != SCHEMA_VERSION:
return None
relay_metadata = set(metadata) - {SCHEMA_KEY, RUNTIME_INSTANCE_KEY}
if relay_metadata - {"otel.status_code"} or metadata.get(
"otel.status_code", "OK"
) not in {"OK", "ERROR"}:
return None
if (
str(getattr(event, "kind", "") or "") != "scope"
or str(getattr(event, "category", "") or "") != "function"
or str(getattr(event, "name", "") or "") != TASK_SCOPE
):
return None
if getattr(event, "category_profile", None) is not None:
return None
scope_category = str(getattr(event, "scope_category", "") or "")
data = getattr(event, "data", None)
if scope_category == "start":
expected_fields = {"entrypoint", "execution_surface"}
if not isinstance(data, dict) or set(data) != expected_fields:
return None
dimensions = {
"entrypoint": data.get("entrypoint"),
"execution_surface": data.get("execution_surface"),
}
if not counter_dimensions_are_valid(TASK_STARTED_METRIC, dimensions):
return None
return TASK_STARTED_METRIC, dimensions
expected_fields = {
"duration_bucket",
"end_reason",
"entrypoint",
"execution_surface",
"model_call_count_bucket",
"outcome",
"retry_count_bucket",
"termination",
"tool_call_count_bucket",
}
if (
scope_category != "end"
or not isinstance(data, dict)
or set(data) != expected_fields
):
return None
dimensions = {field: data.get(field) for field in sorted(expected_fields)}
if not counter_dimensions_are_valid(TASK_FINISHED_METRIC, dimensions):
return None
return TASK_FINISHED_METRIC, dimensions
def execution_surface(kwargs: dict[str, Any]) -> str:
"""Normalize the safe session surface carried by the parent Relay scope."""
value = (
str(kwargs.get("execution_surface") or kwargs.get("platform") or "unknown")
.strip()
.lower()
)
if value in EXECUTION_SURFACES:
return value
if value == "api_server":
return "api"
if value in {"cron", "scheduler", "scheduled"}:
return "scheduled_task"
try:
from hermes_cli.platforms import get_all_platforms
if value in get_all_platforms():
return "gateway"
except Exception:
pass
if value in {"discord", "email", "slack", "telegram", "teams", "whatsapp"}:
return "gateway"
return "unknown" if value == "unknown" else "other"
def task_start_fields(kwargs: dict[str, Any]) -> dict[str, str]:
"""Build the bounded fields recorded on a task scope start event."""
surface = execution_surface(kwargs)
return {
"entrypoint": task_entrypoint(kwargs, surface),
"execution_surface": surface,
}
def task_entrypoint(kwargs: dict[str, Any], surface: str | None = None) -> str:
"""Normalize the task dispatch owner without exporting source strings."""
declared = str(kwargs.get("entrypoint") or "").strip().lower()
if declared in TASK_ENTRYPOINTS:
return declared
resolved_surface = surface or execution_surface(kwargs)
if kwargs.get("parent_task_id") or kwargs.get("parent_session_id"):
return "delegated"
return {
"api": "api",
"batch": "batch",
"cli": "interactive",
"desktop": "interactive",
"gateway": "gateway_message",
"python": "python",
"scheduled_task": "scheduled_task",
"tui": "interactive",
"unknown": "unknown",
}.get(resolved_surface, "other")
def task_terminal_fields(
kwargs: dict[str, Any],
*,
duration_ms: int,
model_call_count: int,
tool_call_count: int,
retry_count: int,
) -> dict[str, str]:
"""Build the bounded terminal payload for one task scope."""
start_fields = task_start_fields(kwargs)
outcome, end_reason, termination = task_terminal_state(kwargs)
return {
**start_fields,
"duration_bucket": duration_bucket(duration_ms),
"end_reason": end_reason,
"model_call_count_bucket": count_bucket(model_call_count),
"outcome": outcome,
"retry_count_bucket": count_bucket(retry_count),
"termination": termination,
"tool_call_count_bucket": count_bucket(tool_call_count),
}
def task_terminal_state(kwargs: dict[str, Any]) -> tuple[str, str, str]:
"""Map Hermes terminal state to bounded task outcome dimensions."""
reason = str(kwargs.get("turn_exit_reason") or "").strip().lower()
if kwargs.get("interrupted") or "interrupt" in reason or "cancel" in reason:
return "cancelled", "user_cancelled", "user_cancelled"
if "timeout" in reason or "timed_out" in reason:
return "timed_out", "timed_out", "timed_out"
if "max_iterations" in reason or "budget_exhausted" in reason:
return "failed", "iteration_limit", "system_aborted"
if "approval" in reason and ("denied" in reason or "rejected" in reason):
return "failed", "approval_denied", "none"
if "guardrail" in reason:
return "failed", "guardrail_blocked", "system_aborted"
if reason == "system_aborted":
return "failed", "system_aborted", "system_aborted"
if kwargs.get("completed") is True:
return "success", "completed", "none"
if kwargs.get("failed") is True or (reason and reason != "unknown"):
return "failed", "failed", "none"
return "unknown", "unknown", "unknown"
def duration_bucket(duration_ms: int) -> str:
"""Bucket a non-negative task duration into a fixed low-cardinality range."""
value = max(0, int(duration_ms))
if value < 1_000:
return "lt_1s"
if value < 5_000:
return "1s_to_5s"
if value < 30_000:
return "5s_to_30s"
if value < 120_000:
return "30s_to_2m"
if value < 600_000:
return "2m_to_10m"
return "gte_10m"
def count_bucket(count: int) -> str:
"""Bucket a non-negative per-task count into a fixed range."""
value = max(0, int(count))
if value <= 2:
return str(value)
if value <= 5:
return "3_to_5"
if value <= 10:
return "6_to_10"
return "gte_11"
def provider_family(kwargs: dict[str, Any]) -> str:
"""Map a Hermes provider to a bounded product category."""
raw_provider = str(kwargs.get("provider") or "").strip().lower().replace("_", "-")
if not raw_provider:
return "unknown"
if raw_provider in _LOCAL_CUSTOM_PROVIDER_ALIASES:
return "local"
if raw_provider == "custom" or raw_provider.startswith(("custom-", "custom:")):
return "custom"
provider, is_aggregator, is_known = _provider_metadata(raw_provider)
if provider in {"lmstudio", "local"}:
return "local"
if is_aggregator or provider in _TELEMETRY_AGGREGATOR_OVERRIDES:
return "aggregator"
if provider == "custom":
return "custom"
return "direct" if is_known else "unknown"
def _provider_metadata(provider: str) -> tuple[str, bool, bool]:
"""Resolve provider identity without refreshing remote provider metadata."""
try:
from hermes_cli.models import normalize_provider as normalize_model_provider
from hermes_cli.providers import HERMES_OVERLAYS, normalize_provider
canonical = normalize_provider(normalize_model_provider(provider))
overlay = HERMES_OVERLAYS.get(canonical)
return (
canonical,
bool(overlay and overlay.is_aggregator),
canonical in _known_provider_ids(),
)
except Exception:
return provider, False, False
@lru_cache(maxsize=1)
def _known_provider_ids() -> frozenset[str]:
"""Cache Hermes's static provider catalog for the process lifetime."""
try:
from hermes_cli.provider_catalog import provider_catalog_by_slug
return frozenset(provider_catalog_by_slug())
except Exception:
return frozenset()
def model_locality(kwargs: dict[str, Any]) -> str:
"""Classify local endpoints without exporting their URL."""
return _model_locality(kwargs, provider_family(kwargs))
def _model_locality(kwargs: dict[str, Any], provider_category: str) -> str:
base_url = kwargs.get("base_url")
if isinstance(base_url, str) and base_url:
try:
from agent.model_metadata import is_local_endpoint
if is_local_endpoint(base_url):
return "local"
except Exception:
pass
if provider_category == "local":
return "local"
if provider_category in {"aggregator", "direct"}:
return "remote"
return "unknown"
def model_call_fields(kwargs: dict[str, Any]) -> dict[str, str]:
"""Build the bounded producer fields for one logical model call."""
provider_category = provider_family(kwargs)
return {
"call_role": PRIMARY_MODEL_CALL_ROLE,
"locality": _model_locality(kwargs, provider_category),
"model_family": model_family(kwargs),
"provider_family": provider_category,
}
def model_family(kwargs: dict[str, Any]) -> str:
"""Map a raw model identifier to an allowlisted family."""
declared_family = str(kwargs.get("model_family") or "").strip().lower()
if declared_family in MODEL_FAMILIES - {"unknown"}:
return declared_family
model = str(kwargs.get("response_model") or kwargs.get("model") or "").lower()
match = _MODEL_FAMILY_PATTERN.search(model)
return match.group(1) if match is not None else "unknown"
def model_call_outcome(kwargs: dict[str, Any]) -> str:
"""Fail closed when a terminal model-call outcome is not recognized."""
value = str(kwargs.get("outcome") or "").lower()
return value if value in MODEL_OUTCOMES else "failed"

View file

@ -0,0 +1,67 @@
"""Relay subscriber for the persisted Hermes shared-metrics slice."""
from __future__ import annotations
import logging
import threading
from typing import Any
from agent.relay_runtime import RUNTIME_INSTANCE_KEY
from .shared_metrics import SharedMetricsStore
from .shared_metrics_contract import MODEL_CALL_METRIC, model_call_dimensions, task_counter
logger = logging.getLogger(__name__)
class SharedMetricsSubscriber:
"""Persist validated Hermes counters from Relay lifecycle events."""
def __init__(
self,
store: SharedMetricsStore,
hermes_version: str,
*,
runtime_id: str | None = None,
) -> None:
self.store = store
self._hermes_version = hermes_version or "unknown"
self._runtime_id = runtime_id
self._active = True
self._lock = threading.RLock()
def deactivate(self) -> None:
"""Stop accepting events before telemetry is disabled or torn down."""
with self._lock:
self._active = False
def __call__(self, event: Any) -> None:
if self._runtime_id is not None:
metadata = getattr(event, "metadata", None)
if (
not isinstance(metadata, dict)
or metadata.get(RUNTIME_INSTANCE_KEY) != self._runtime_id
):
return
dimensions = model_call_dimensions(event)
metric_name = MODEL_CALL_METRIC
if dimensions is None:
task_metric = task_counter(event)
if task_metric is None:
return
metric_name, dimensions = task_metric
with self._lock:
if not self._active:
return
try:
self.store.record_counter(
metric_name,
dimensions,
self._hermes_version,
)
except Exception:
logger.warning(
"Unable to persist the Hermes shared metric: %s",
metric_name,
exc_info=True,
)

View file

@ -2066,7 +2066,7 @@ def discover_plugins(force: bool = False) -> None:
def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
"""Invoke a lifecycle hook on all loaded plugins.
"""Invoke a lifecycle hook on loaded plugins.
Returns a list of non-``None`` return values from plugin callbacks.
"""
@ -2091,7 +2091,7 @@ def has_middleware(kind: str) -> bool:
def has_hook(hook_name: str) -> bool:
"""Return True when a hook has registered callbacks."""
"""Return True when a loaded plugin handles a hook."""
return get_plugin_manager().has_hook(hook_name)

View file

@ -2221,6 +2221,37 @@ def setup_tools(config: dict, first_install: bool = False):
tools_command(first_install=first_install, config=config)
# =============================================================================
# Shared Metrics
# =============================================================================
def setup_telemetry(config: dict):
"""Configure the local, privacy-safe shared-metrics subscriber."""
print_header("Shared Metrics")
print_info("Shared metrics contain only bounded counters and histograms.")
print_info("Packages stay under this Hermes profile and are not uploaded.")
telemetry = config.get("telemetry")
if not isinstance(telemetry, dict):
telemetry = {}
config["telemetry"] = telemetry
shared_metrics = telemetry.get("shared_metrics")
if not isinstance(shared_metrics, dict):
shared_metrics = {}
telemetry["shared_metrics"] = shared_metrics
current = shared_metrics.get("enabled") is True
shared_metrics["enabled"] = prompt_yes_no(
"Enable local shared metrics?",
default=current,
)
if shared_metrics["enabled"]:
print_success("Local shared metrics enabled.")
else:
print_info("Local shared metrics disabled.")
# =============================================================================
# Post-Migration Section Skip Logic
# =============================================================================
@ -2629,6 +2660,7 @@ SETUP_SECTIONS = [
("terminal", "Terminal Backend", setup_terminal_backend),
("gateway", "Messaging Platforms (Gateway)", setup_gateway),
("tools", "Tools", setup_tools),
("telemetry", "Shared Metrics", setup_telemetry),
("agent", "Agent Settings", setup_agent_settings),
]
@ -2727,6 +2759,7 @@ def run_setup_wizard(args):
hermes setup terminal just terminal backend
hermes setup gateway just messaging platforms
hermes setup tools just tool configuration
hermes setup telemetry just local shared metrics
hermes setup agent just agent settings
"""
from hermes_cli.config import is_managed, managed_error

View file

@ -18,12 +18,21 @@ def build_setup_parser(subparsers, *, cmd_setup: Callable) -> None:
"setup",
help="Interactive setup wizard",
description="Configure Hermes Agent with an interactive wizard. "
"Run a specific section: hermes setup model|tts|terminal|gateway|tools|agent",
"Run a specific section: "
"hermes setup model|tts|terminal|gateway|tools|telemetry|agent",
)
setup_parser.add_argument(
"section",
nargs="?",
choices=["model", "tts", "terminal", "gateway", "tools", "agent"],
choices=[
"model",
"tts",
"terminal",
"gateway",
"tools",
"telemetry",
"agent",
],
default=None,
help="Run a specific setup section instead of the full wizard",
)

View file

@ -967,6 +967,9 @@ _CATEGORY_MERGE: Dict[str, str] = {
# field — fold it into the agent tab rather than spawning a one-field
# orphan category.
"computer_use": "agent",
# `telemetry.shared_metrics.enabled` is the only schema-surfaced telemetry
# field — fold it into security alongside the other privacy-posture toggles.
"telemetry": "security",
}
# Display order for tabs — unlisted categories sort alphabetically after these.

View file

@ -1056,7 +1056,7 @@ def _emit_post_tool_call_hook(
listener will actually consume it).
"""
try:
from hermes_cli.plugins import has_hook, invoke_hook
from hermes_cli.lifecycle import has_hook, invoke_hook
if not has_hook("post_tool_call"):
return
if status is None:
@ -1093,6 +1093,7 @@ def handle_function_call(
enabled_tools: Optional[List[str]] = None,
skip_pre_tool_call_hook: bool = False,
skip_tool_request_middleware: bool = False,
skip_tool_execution_middleware: bool = False,
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
enabled_toolsets: Optional[List[str]] = None,
disabled_toolsets: Optional[List[str]] = None,
@ -1203,6 +1204,7 @@ def handle_function_call(
enabled_tools=enabled_tools,
skip_pre_tool_call_hook=skip_pre_tool_call_hook,
skip_tool_request_middleware=skip_tool_request_middleware,
skip_tool_execution_middleware=skip_tool_execution_middleware,
tool_request_middleware_trace=list(_tool_middleware_trace),
enabled_toolsets=enabled_toolsets,
disabled_toolsets=disabled_toolsets,
@ -1341,19 +1343,22 @@ def handle_function_call(
session_id=session_id,
user_task=user_task,
)
from hermes_cli.middleware import run_tool_execution_middleware
if skip_tool_execution_middleware:
result = _dispatch(function_args)
else:
from hermes_cli.middleware import run_tool_execution_middleware
result = run_tool_execution_middleware(
function_name,
function_args,
_dispatch,
original_args=_tool_original_args,
task_id=task_id or "",
session_id=session_id or "",
tool_call_id=tool_call_id or "",
turn_id=turn_id or "",
api_request_id=api_request_id or "",
)
result = run_tool_execution_middleware(
function_name,
function_args,
_dispatch,
original_args=_tool_original_args,
task_id=task_id or "",
session_id=session_id or "",
tool_call_id=tool_call_id or "",
turn_id=turn_id or "",
api_request_id=api_request_id or "",
)
finally:
if _approval_tokens is not None and reset_current_observability_context is not None:
try:
@ -1384,7 +1389,7 @@ def handle_function_call(
# Gated on has_hook so the no-listener path skips both the result
# field derivation and the payload dispatch.
try:
from hermes_cli.plugins import has_hook, invoke_hook
from hermes_cli.lifecycle import has_hook, invoke_hook
if has_hook("transform_tool_result"):
status, error_type, error_message = _tool_result_observer_fields(result)
hook_results = invoke_hook(

View file

@ -1,18 +1,19 @@
# NeMo Relay Observability
Optional Hermes observability plugin that maps Hermes observer hooks to
NeMo Relay scopes, LLM spans, tool spans, marks, ATOF, and ATIF.
Optional Hermes observability plugin that configures exporters and maps
Hermes-specific observer hooks to NeMo Relay marks and ATIF state. Hermes core
owns Relay session, turn, LLM, and tool execution scopes.
NeMo Relay is NVIDIA's runtime layer for agent execution boundaries. It does
not replace Hermes Agent's planner, tools, memory, model provider routing, or
CLI UX. Instead, this plugin lets Hermes emit NeMo Relay lifecycle events for
the work Hermes already owns: sessions, turns, provider/API calls, tool calls,
approval prompts, and delegated subagents.
CLI UX. Hermes core emits NeMo Relay lifecycle events for provider and tool
execution, while this plugin enables rich exporters and observer marks for
sessions, turns, approval prompts, and delegated subagents.
With this plugin enabled, Hermes Agent can:
- Preserve Hermes execution as NeMo Relay scopes, LLM spans, tool spans, and
mark events.
- Export the Relay scopes and LLM/tool lifecycles emitted by Hermes core.
- Add Hermes session, turn, approval, and subagent mark events.
- Export raw lifecycle events as Agent Trajectory Observability Format (ATOF)
JSONL for debugging and offline inspection.
- Export Agent Trajectory Interchange Format (ATIF) trajectories for replay,
@ -73,27 +74,24 @@ checkout that contains this plugin. A globally installed older CLI will not see
new bundled plugins from your working tree.
```bash
uv sync --extra nemo-relay
uv sync
uv run hermes plugins enable observability/nemo_relay
uv run hermes chat --query 'Reply exactly ok' --provider custom --model qwen3.6:35b
```
To ship the updated CLI into another environment, build and install a fresh
wheel from this checkout, then install the official NeMo Relay runtime extra:
wheel from this checkout. On platforms for which Relay publishes a native
wheel, Hermes installs its supported NeMo Relay runtime as a normal dependency:
```bash
uv build --wheel
python -m pip install --force-reinstall dist/hermes_agent-*.whl
python -m pip install "nemo-relay>=0.5,<1.0"
hermes plugins enable observability/nemo_relay
```
The plugin fails open when `nemo-relay` is not installed. Install a supported
NeMo Relay 0.x distribution beginning with 0.5:
```bash
pip install "nemo-relay>=0.5,<1.0"
```
The plugin remains opt-in even though the runtime dependency is installed by
default. Enabling this plugin controls rich observability and adaptive
behavior; it does not control Hermes shared client metrics.
## Export Configuration
@ -170,8 +168,9 @@ Relay owns exporter lifecycle through that config. The direct
double-export trajectories on teardown. If `plugins.toml` initialization fails,
Hermes keeps the direct env-var fallbacks active for that run.
To enable NeMo Relay managed execution intercepts for provider and tool calls,
include an adaptive component in the same `plugins.toml`:
Hermes core routes provider and tool execution through NeMo Relay managed APIs
regardless of whether this plugin is enabled. To install adaptive interceptors
on those boundaries, include an adaptive component in the same `plugins.toml`:
```toml
[[components]]
@ -182,18 +181,15 @@ enabled = true
mode = "observe_only"
```
When the adaptive component is enabled and the installed NeMo Relay runtime
exposes `llm.execute(...)` / `tools.execute(...)`, Hermes routes LLM and tool
execution through those middleware boundaries. The observer hooks still emit
session, turn, approval, and subagent marks; the plugin skips its manual
`llm.call` and `tools.call` spans for executions that are already managed by
NeMo Relay. `tool_parallelism.mode = "observe_only"` keeps tool scheduling
observational while still wrapping the real execution boundary.
The observer hooks emit session, turn, approval, and subagent marks. They do not
create a second LLM or tool lifecycle. `tool_parallelism.mode = "observe_only"`
keeps tool scheduling observational while still intercepting the core-managed
execution boundary.
### Dynamic Plugins
Hermes feature-detects the dynamic-plugin activation API available in NeMo Relay
0.6 and later. Configure native or worker plugins with Hermes-owned
Hermes uses the dynamic-plugin activation API available in NeMo Relay 0.6 and
later. Configure native or worker plugins with Hermes-owned
`[[dynamic_plugins]]` entries that match the Python binding's activation-spec
fields:
@ -242,24 +238,15 @@ During shutdown it closes session exporters, flushes Relay subscribers, and
then closes the activation so callbacks are removed before plugin code is
unloaded.
NeMo Relay 0.5 does not expose dynamic activation through its Python binding.
When dynamic plugin configuration is present with a binding that lacks the
activation API, Hermes logs an actionable warning and continues with the
ordinary static component configuration, so ATOF and ATIF observability remain
available. No dynamic plugin is loaded in that degraded mode.
For the full generic Hermes middleware contract, see
[`docs/middleware/README.md`](../../../docs/middleware/README.md).
## Canonical Local Examples
The observe-only examples in this section use a supported NeMo Relay 0.x
distribution beginning with 0.5 and a local Ollama model served through the
OpenAI-compatible API.
The observe-only examples in this section use the NeMo Relay runtime installed
with Hermes and a local Ollama model served through the OpenAI-compatible API.
```bash
pip install "nemo-relay>=0.5,<1.0"
export HERMES_HOME=/tmp/hermes-nemo-relay-docs/hermes-home
mkdir -p "$HERMES_HOME"
@ -443,8 +430,8 @@ Sanitized ATIF excerpt:
The plugin keeps NeMo Relay's native event model:
- Hermes sessions map to `agent` scopes.
- Hermes API request hooks map to `llm` scope start/end events.
- Hermes tool hooks map to `tool` scope start/end events.
- Hermes core managed provider calls map to `llm` scope start/end events.
- Hermes core managed tool calls map to `tool` scope start/end events.
- Turn, approval, subagent, and diagnostic fallback events map to `mark`
events.
@ -454,11 +441,13 @@ subagent IDs, role/status fields when present, and derived
stream lossless for later ATIF conversion that can compact subagents into
separate trajectories.
## Adaptive Middleware Example
## Adaptive Execution Example
The `observability/nemo_relay` plugin uses Hermes execution middleware to hand
LLM and tool calls to NeMo Relay managed execution when an adaptive component is
enabled.
Hermes core owns the LLM and tool boundaries and enters NeMo Relay managed
execution while a Hermes-managed Relay consumer is active. With no shared
metrics subscriber or explicitly configured Relay plugin, Hermes calls the
provider or tool directly. The `observability/nemo_relay` plugin retains the
managed path while its adaptive components are installed on those boundaries.
Minimal `plugins.toml`:
@ -479,33 +468,28 @@ Enable it for Hermes:
export HERMES_NEMO_RELAY_PLUGINS_TOML=/tmp/hermes-middleware-test/plugins.toml
```
When the adaptive component is enabled and the installed NeMo Relay runtime
exposes `llm.execute(...)` and `tools.execute(...)`, Hermes routes execution
through these boundaries:
Execution follows these boundaries with or without an adaptive component:
```text
Hermes provider call
-> llm_execution middleware
-> nemo_relay.llm.execute(...)
-> Hermes provider adapter next_call(...)
-> nemo_relay.llm.execute(...)
-> Hermes provider adapter callback(...)
Hermes tool call
-> tool_execution middleware
-> nemo_relay.tools.execute(...)
-> Hermes tool dispatcher next_call(...)
-> nemo_relay.tools.execute(...)
-> Hermes authorization and dispatch callback(...)
```
The plugin still emits observer marks for sessions, turns, approvals, and
subagents. When adaptive managed execution is active, it skips manual
`llm.call` and `tools.call` observer spans to avoid duplicate LLM/tool events
for the same execution.
The plugin emits observer marks for sessions, turns, approvals, and subagents.
It does not register provider or tool lifecycle hooks, so each managed call
produces one Relay lifecycle.
### Local Adaptive E2E
This example enables both NeMo Relay observability export and adaptive execution
middleware for a local Hermes run. This path requires a NeMo Relay runtime that
supports `[components.config.tool_parallelism]`, as provided by the supported
0.x release range beginning with 0.5.
supports `[components.config.tool_parallelism]`, as provided by NeMo Relay 0.6
and later.
```bash
export HERMES_HOME=/tmp/hermes-middleware-test/hermes-home

File diff suppressed because it is too large Load diff

View file

@ -9,11 +9,6 @@ hooks:
- on_session_reset
- pre_llm_call
- post_llm_call
- pre_api_request
- post_api_request
- api_request_error
- pre_tool_call
- post_tool_call
- pre_approval_request
- post_approval_response
- subagent_start

View file

@ -137,6 +137,11 @@ dependencies = [
# Hence the ``sys_platform == 'win32'`` marker: the dep (and its portalocker
# / pywin32 tree) ships only where it's actually used.
"concurrent-log-handler==0.9.29; sys_platform == 'win32'",
# First-party lifecycle and shared-metrics runtime. Relay 0.6 is the minimum
# lossless provider-codec contract. Managed calls pass request/response data
# through this native module in-process; shared metrics installs no network
# exporter and consumes only its bounded projection.
"nemo-relay>=0.6.0,<0.7; (sys_platform == 'darwin' and platform_machine == 'arm64') or (sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'linux' and platform_machine == 'aarch64') or (sys_platform == 'win32' and platform_machine == 'AMD64') or (sys_platform == 'win32' and platform_machine == 'ARM64')",
]
[project.optional-dependencies]
@ -202,7 +207,9 @@ pty = []
# extra that exposes a Starlette-backed server surface so pip/uv can't resolve
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
mcp = ["mcp==1.26.0", "starlette==1.3.1"] # starlette: CVE-2026-48710
nemo-relay = ["nemo-relay>=0.5,<1.0"]
# Backwards-compatible no-op alias. Relay is a core dependency on supported
# wheel targets and intentionally unavailable on other platforms.
nemo-relay = []
homeassistant = ["aiohttp==3.14.1"]
sms = ["aiohttp==3.14.1"]
teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.1"] # aiohttp 3.14.1: CVE-2026-34993(RCE)/47265 + 34513/34518/34519/34520/34525
@ -319,6 +326,7 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
[tool.setuptools.package-data]
hermes_cli = ["observability/schemas/*.json"]
# gateway/assets/ ships status_phrases.yaml and the Telegram BotFather
# screenshot. Without this, sealed venvs (uv2nix) silently lose both —
# status phrases fall back to the tiny hardcoded set and the Telegram

View file

@ -2685,17 +2685,16 @@ class AIAgent:
retryable: Optional[bool] = None,
reason: Optional[str] = None,
) -> None:
# Lazy module import (not from-import) so tests that
# ``monkeypatch.setattr("hermes_cli.plugins.has_hook", ...)`` still
# take effect on this call site. After first call the import is a
# Lazy module import (not from-import) so tests can replace lifecycle
# dispatch at this call site. After first call the import is a
# ``sys.modules`` dict lookup, so retries don't repay any real cost.
try:
from hermes_cli import plugins as _plugins
from hermes_cli import lifecycle as _lifecycle
if not _plugins.has_hook("api_request_error"):
if not _lifecycle.has_hook("api_request_error"):
return
ended_at = time.time()
_plugins.invoke_hook(
_lifecycle.invoke_hook(
"api_request_error",
task_id=task_id,
turn_id=turn_id,
@ -6749,7 +6748,8 @@ class AIAgent:
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:
"""Forwarder — see ``agent.agent_runtime_helpers.invoke_tool``."""
from agent.agent_runtime_helpers import invoke_tool
return invoke_tool(
@ -6762,6 +6762,7 @@ class AIAgent:
pre_tool_block_checked,
skip_tool_request_middleware,
tool_request_middleware_trace,
skip_tool_execution_middleware,
)
@staticmethod
@ -6849,48 +6850,140 @@ class AIAgent:
reset_accounting_context,
set_accounting_context,
)
from agent import relay_runtime
from agent.conversation_loop import run_conversation
from agent.portal_tags import (
reset_conversation_context,
set_conversation_context,
)
from agent.subagent_lifecycle import bind_subagent_parent
# Publish the conversation id for ambient Nous Portal tagging. Every
# LLM call made inside this turn — main loop, compression, vision,
# web_extract, session_search, MoA slots, background-review forks
# (which copy this Context into their thread) — inherits the
# ``conversation=<root>`` tag with zero per-call-site plumbing.
token = set_conversation_context(self._conversation_root_id())
# Publish the session accounting handles the same way so auxiliary
# calls record their token usage into session_model_usage (task
# dimension) — the fix for aux spend being invisible in analytics
# (issue #23270).
acct_token = set_accounting_context(
getattr(self, "_session_db", None), getattr(self, "session_id", None)
from hermes_cli.observability.relay_shared_metrics import (
finish_task_run,
start_task_run,
)
from agent.auxiliary_client import scoped_runtime_main
from agent.subagent_lifecycle import bind_subagent_parent
effective_task_id = task_id or str(uuid.uuid4())
session_id = str(getattr(self, "session_id", None) or "")
task_context = {
"session_id": session_id,
"task_id": effective_task_id,
"platform": getattr(self, "platform", None) or "",
}
relay_turn_id = (
f"{session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
)
self._relay_pending_turn_id = relay_turn_id
relay_parent_session_id = (
str(getattr(self, "_parent_session_id", None) or "")
if task_context["platform"] == "subagent"
else ""
)
relay_lease = None
relay_turn = None
token = None
acct_token = None
task_started = False
task_finished = False
relay_outcome = "failed"
try:
relay_lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation(
profile_key=relay_runtime.current_profile_key(),
session_id=task_context["session_id"],
platform=task_context["platform"],
parent_session_id=relay_parent_session_id,
model=str(getattr(self, "model", None) or ""),
)
relay_turn = relay_runtime.SESSION_COORDINATOR.begin_turn(
relay_lease,
turn_id=relay_turn_id,
task_id=effective_task_id,
)
start_task_run(
**task_context,
parent_session_id=getattr(self, "_parent_session_id", None) or "",
)
task_started = True
# Publish the conversation id for ambient Nous Portal tagging. Every
# LLM call made inside this turn — main loop, compression, vision,
# web_extract, session_search, MoA slots, background-review forks
# (which copy this Context into their thread) — inherits the
# ``conversation=<root>`` tag with zero per-call-site plumbing.
token = set_conversation_context(self._conversation_root_id())
# Publish the session accounting handles the same way so auxiliary
# calls record their token usage into session_model_usage (task
# dimension) — the fix for aux spend being invisible in analytics
# (issue #23270).
acct_token = set_accounting_context(
getattr(self, "_session_db", None),
getattr(self, "session_id", None),
)
from agent.auxiliary_client import scoped_runtime_main
# The outer token restores the caller's Context even though turn setup
# replaces the value with the live runtime after fallback restoration.
# Keep the scope local instead of storing ContextVar tokens on the agent,
# which may be observed from another thread.
with bind_subagent_parent(self), scoped_runtime_main({}):
try:
return run_conversation(
# The outer token restores the caller's Context even though turn setup
# replaces the value with the live runtime after fallback restoration.
# Keep the scope local instead of storing ContextVar tokens on the agent,
# which may be observed from another thread.
with bind_subagent_parent(self), scoped_runtime_main({}):
result = run_conversation(
self,
user_message,
system_message,
conversation_history,
task_id,
effective_task_id,
stream_callback,
persist_user_message,
persist_user_timestamp=persist_user_timestamp,
moa_config=moa_config,
)
terminal = result if isinstance(result, dict) else {}
if terminal.get("interrupted") is True:
relay_outcome = "cancelled"
elif terminal.get("failed") is True:
relay_outcome = "failed"
else:
relay_outcome = "success"
relay_runtime.SESSION_COORDINATOR.finish_logical_calls(
relay_turn,
outcome=relay_outcome,
)
task_finished = True
finish_task_run(**task_context, result=result)
return result
except BaseException as exc:
if isinstance(exc, (KeyboardInterrupt, InterruptedError)) or (
type(exc).__name__ == "CancelledError"
):
relay_outcome = "cancelled"
elif isinstance(exc, TimeoutError):
relay_outcome = "timed_out"
if relay_turn is not None:
relay_runtime.SESSION_COORDINATOR.finish_logical_calls(
relay_turn,
outcome=relay_outcome,
)
if task_started and not task_finished:
task_finished = True
finish_task_run(**task_context, error=exc)
raise
finally:
try:
if relay_turn is not None:
relay_runtime.SESSION_COORDINATOR.end_turn(
relay_turn,
outcome=relay_outcome,
)
finally:
reset_accounting_context(acct_token)
reset_conversation_context(token)
try:
if relay_lease is not None:
relay_runtime.SESSION_COORDINATOR.release_conversation(
relay_lease
)
finally:
if getattr(self, "_relay_pending_turn_id", None) == relay_turn_id:
self._relay_pending_turn_id = None
if acct_token is not None:
reset_accounting_context(acct_token)
if token is not None:
reset_conversation_context(token)
def chat(self, message: str, stream_callback: Optional[callable] = None) -> str:
"""

View file

@ -0,0 +1,443 @@
"""Run a real Hermes CLI turn and validate the Relay shared-metrics output."""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
PROMPT_CANARY = "relay-smoke-sensitive-prompt"
MODEL_CANARY = "gpt-relay-smoke-sensitive-model"
RESPONSE_CANARY = "relay-smoke-sensitive-response"
def _resolve_hermes_executable(hermes_repo: Path) -> Path:
for relative_path in (
Path(".venv") / "bin" / "hermes",
Path(".venv") / "Scripts" / "hermes.exe",
):
candidate = hermes_repo / relative_path
if candidate.is_file():
return candidate
discovered = shutil.which("hermes")
if discovered:
return Path(discovered)
raise SystemExit(
"Hermes executable not found in the repository virtual environment "
"or on PATH"
)
class _ModelHandler(BaseHTTPRequestHandler):
"""Minimal OpenAI-compatible model server for one deterministic turn."""
protocol_version = "HTTP/1.1"
requests: list[dict[str, Any]] = []
def do_GET(self) -> None: # noqa: N802
if self.path.rstrip("/") != "/v1/models":
self.send_error(404)
return
self._write_json({
"object": "list",
"data": [
{
"id": MODEL_CANARY,
"object": "model",
"created": 0,
"owned_by": "smoke-test",
}
],
})
def do_POST(self) -> None: # noqa: N802
if self.path.rstrip("/") != "/v1/chat/completions":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
request = json.loads(self.rfile.read(length) or b"{}")
type(self).requests.append(request)
if request.get("stream"):
self._write_stream()
else:
self._write_json({
"id": "chatcmpl-relay-smoke",
"object": "chat.completion",
"created": int(time.time()),
"model": MODEL_CANARY,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": RESPONSE_CANARY,
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 1,
"total_tokens": 11,
},
})
def log_message(self, format: str, *args: Any) -> None:
return
def _write_json(self, payload: dict[str, Any]) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)
self.close_connection = True
def _write_stream(self) -> None:
now = int(time.time())
chunks = [
{
"id": "chatcmpl-relay-smoke",
"object": "chat.completion.chunk",
"created": now,
"model": MODEL_CANARY,
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"content": RESPONSE_CANARY,
},
"finish_reason": None,
}
],
},
{
"id": "chatcmpl-relay-smoke",
"object": "chat.completion.chunk",
"created": now,
"model": MODEL_CANARY,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
},
{
"id": "chatcmpl-relay-smoke",
"object": "chat.completion.chunk",
"created": now,
"model": MODEL_CANARY,
"choices": [],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 1,
"total_tokens": 11,
},
},
]
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.end_headers()
for chunk in chunks:
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode("utf-8"))
self.wfile.flush()
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
self.close_connection = True
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--hermes-repo",
type=Path,
default=Path.cwd(),
help="Hermes source checkout containing .venv/bin/hermes",
)
parser.add_argument(
"--relay-python",
type=Path,
default=None,
help="Optional NeMo Relay checkout's python directory",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory for the isolated HERMES_HOME and captured output",
)
return parser.parse_args()
def _write_config(home: Path, port: int) -> None:
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
f"""model:
default: {MODEL_CANARY}
provider: custom
base_url: http://127.0.0.1:{port}/v1
api_mode: chat_completions
api_key: no-key-required
security:
tirith_enabled: false
telemetry:
shared_metrics:
enabled: true
""",
encoding="utf-8",
)
def _validate_store(database_path: Path) -> list[dict[str, Any]]:
if not database_path.is_file():
raise AssertionError(f"Metrics database was not created: {database_path}")
with sqlite3.connect(database_path) as connection:
rows = connection.execute(
"""
SELECT metric_name, dimensions_json, value, packaged_value
FROM counter_aggregates
ORDER BY metric_name, dimensions_json
"""
).fetchall()
counters = [
{
"name": name,
"dimensions": json.loads(dimensions),
"value": value,
"packaged_value": packaged_value,
}
for name, dimensions, value, packaged_value in rows
]
by_name = {counter["name"]: counter for counter in counters}
if set(by_name) != {
"hermes.model_call.count",
"hermes.task_run.finished",
"hermes.task_run.started",
}:
raise AssertionError(
f"Unexpected SQLite counters:\n{json.dumps(counters, indent=2)}"
)
expected_model = {
"name": "hermes.model_call.count",
"dimensions": {
"call_role": "primary",
"locality": "local",
"model_family": "gpt",
"outcome": "success",
"provider_family": "custom",
},
"value": 1,
"packaged_value": 1,
}
if by_name["hermes.model_call.count"] != expected_model:
raise AssertionError(
f"Unexpected model counter: {by_name['hermes.model_call.count']}"
)
expected_start = {
"name": "hermes.task_run.started",
"dimensions": {
"entrypoint": "interactive",
"execution_surface": "cli",
},
"value": 1,
"packaged_value": 1,
}
if by_name["hermes.task_run.started"] != expected_start:
raise AssertionError(
f"Unexpected task start: {by_name['hermes.task_run.started']}"
)
terminal = by_name["hermes.task_run.finished"]
expected_terminal_dimensions = {
"duration_bucket": terminal["dimensions"].get("duration_bucket"),
"end_reason": "completed",
"entrypoint": "interactive",
"execution_surface": "cli",
"model_call_count_bucket": "1",
"outcome": "success",
"retry_count_bucket": "0",
"termination": "none",
"tool_call_count_bucket": "0",
}
if (
terminal["dimensions"] != expected_terminal_dimensions
or terminal["value"] != 1
or terminal["packaged_value"] != 1
):
raise AssertionError(f"Unexpected task terminal counter: {terminal}")
return counters
def _validate_package(outbox: Path, schema_path: Path) -> tuple[Path, dict[str, Any]]:
packages = sorted(outbox.glob("*.json"))
if len(packages) != 1:
raise AssertionError(f"Expected one package in {outbox}, found {len(packages)}")
package_path = packages[0]
package = json.loads(package_path.read_text(encoding="utf-8"))
try:
import jsonschema
except ImportError as exc:
raise RuntimeError(
"The Hermes development environment requires jsonschema"
) from exc
schema = json.loads(schema_path.read_text(encoding="utf-8"))
jsonschema.validate(package, schema)
serialized = json.dumps(package)
for prohibited in (PROMPT_CANARY, MODEL_CANARY, RESPONSE_CANARY):
if prohibited in serialized:
raise AssertionError(
f"Exported package leaked prohibited value: {prohibited!r}"
)
metrics = {metric["name"]: metric for metric in package.get("metrics", [])}
if set(metrics) != {
"hermes.model_call.count",
"hermes.task_run.finished",
"hermes.task_run.started",
}:
raise AssertionError(
f"Unexpected package metrics:\n{json.dumps(package.get('metrics'), indent=2)}"
)
if metrics["hermes.model_call.count"]["dimensions"] != {
"call_role": "primary",
"locality": "local",
"model_family": "gpt",
"outcome": "success",
"provider_family": "custom",
}:
raise AssertionError(
f"Unexpected model metric: {metrics['hermes.model_call.count']}"
)
terminal = metrics["hermes.task_run.finished"]
if terminal["dimensions"] != {
"duration_bucket": terminal["dimensions"].get("duration_bucket"),
"end_reason": "completed",
"entrypoint": "interactive",
"execution_surface": "cli",
"model_call_count_bucket": "1",
"outcome": "success",
"retry_count_bucket": "0",
"termination": "none",
"tool_call_count_bucket": "0",
}:
raise AssertionError(f"Unexpected task terminal metric: {terminal}")
return package_path, package
def main() -> int:
args = _arguments()
hermes_repo = args.hermes_repo.resolve()
relay_python = args.relay_python.resolve() if args.relay_python else None
hermes = _resolve_hermes_executable(hermes_repo)
if relay_python is not None and not any(
(relay_python / "nemo_relay").glob("_native.*")
):
raise SystemExit(
"Built NeMo Relay Python binding not found under "
f"{relay_python}; run the Relay Python build first"
)
if args.output_dir:
root = args.output_dir.resolve()
if root.exists():
raise SystemExit(f"Refusing to replace existing output directory: {root}")
root.mkdir(parents=True)
else:
root = Path(tempfile.mkdtemp(prefix="hermes-relay-shared-metrics-"))
home = root / "hermes-home"
workdir = root / "workspace"
workdir.mkdir()
home.mkdir()
(home / ".no-bundled-skills").touch()
_ModelHandler.requests = []
server = ThreadingHTTPServer(("127.0.0.1", 0), _ModelHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
_write_config(home, server.server_port)
env = os.environ.copy()
env["HERMES_HOME"] = str(home)
if relay_python is not None:
env["PYTHONPATH"] = os.pathsep.join([
str(relay_python),
env.get("PYTHONPATH", ""),
]).rstrip(os.pathsep)
result = subprocess.run(
[
str(hermes),
"chat",
"--query",
PROMPT_CANARY,
"--provider",
"custom",
"--model",
MODEL_CANARY,
"--quiet",
"--ignore-rules",
"--toolsets",
"search",
"--max-turns",
"2",
],
cwd=workdir,
env=env,
text=True,
capture_output=True,
timeout=120,
)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
(root / "hermes.stdout.txt").write_text(result.stdout, encoding="utf-8")
(root / "hermes.stderr.txt").write_text(result.stderr, encoding="utf-8")
if result.returncode != 0:
raise AssertionError(
f"Hermes exited with {result.returncode}\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
if not _ModelHandler.requests:
raise AssertionError("Hermes did not call the local model endpoint")
request = _ModelHandler.requests[0]
if request.get("model") != MODEL_CANARY:
raise AssertionError(f"Unexpected model request: {request.get('model')!r}")
if PROMPT_CANARY not in json.dumps(request.get("messages", [])):
raise AssertionError("Hermes model request did not contain the prompt canary")
if RESPONSE_CANARY not in result.stdout:
raise AssertionError("Hermes did not print the mock model response")
telemetry = home / "telemetry" / "shared_metrics"
counters = _validate_store(telemetry / "metrics.sqlite3")
package_path, package = _validate_package(
telemetry / "outbox",
hermes_repo
/ "hermes_cli"
/ "observability"
/ "schemas"
/ "hermes.shared_metrics.v1.schema.json",
)
print("Hermes -> NeMo Relay shared-metrics smoke test passed")
print(f"Artifact directory: {root}")
print(f"Model requests: {len(_ModelHandler.requests)}")
print(f"SQLite counters: {json.dumps(counters, indent=2)}")
print(f"Export package: {package_path}")
print(json.dumps(package, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,445 @@
from types import SimpleNamespace
import pytest
pytest.importorskip("nemo_relay")
from agent import auxiliary_client, relay_llm, relay_runtime
@pytest.fixture()
def relay_turn(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profile"))
relay_runtime._reset_for_tests()
lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation(
profile_key=relay_runtime.current_profile_key(),
session_id="session-1",
platform="cli",
)
turn = relay_runtime.SESSION_COORDINATOR.begin_turn(
lease,
turn_id="turn-1",
task_id="task-1",
)
try:
yield lease.host.relay, turn
finally:
relay_runtime.SESSION_COORDINATOR.end_turn(turn, outcome="success")
relay_runtime.SESSION_COORDINATOR.release_conversation(lease)
relay_runtime._reset_for_tests()
def test_auxiliary_retries_share_logical_relay_identity(monkeypatch):
attempts = []
logical_completions = []
responses = iter([
SimpleNamespace(choices=[]),
SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))]
),
])
client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_kwargs: next(responses),
)
)
)
def execute_current(request, callback, **kwargs):
attempts.append(kwargs)
return callback(request)
monkeypatch.setattr(relay_llm, "execute_current", execute_current)
monkeypatch.setattr(
relay_llm,
"complete_logical_call",
lambda request_id, *, outcome: logical_completions.append(
(request_id, outcome)
),
)
@auxiliary_client._relay_auxiliary_call
def run(task):
auxiliary_client._set_relay_auxiliary_route(
"openrouter",
"test-model",
"chat_completions",
)
with pytest.raises(RuntimeError, match="invalid response"):
auxiliary_client._validate_llm_response(
auxiliary_client._relay_sync_completion(
client,
{"model": "test-model", "messages": []},
),
task,
)
return auxiliary_client._validate_llm_response(
auxiliary_client._relay_sync_completion(
client,
{"model": "test-model", "messages": []},
),
task,
)
result = run("compression")
assert result.choices[0].message.content == "ok"
assert attempts[0]["metadata"]["api_request_id"] == (
attempts[1]["metadata"]["api_request_id"]
)
assert [attempt["metadata"]["retry_count"] for attempt in attempts] == [0, 1]
assert attempts[0]["metadata"]["call_role"] == "auxiliary:compression"
assert all(attempt["defer_logical_completion"] is True for attempt in attempts)
assert logical_completions == [
(attempts[0]["metadata"]["api_request_id"], "success")
]
@pytest.mark.asyncio
async def test_async_auxiliary_attempt_uses_inherited_relay_adapter(monkeypatch):
captured = {}
logical_completions = []
async def create(**kwargs):
return SimpleNamespace(
request=kwargs,
choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))],
)
client = SimpleNamespace(
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
)
async def execute_current_async(request, callback, **kwargs):
captured.update(kwargs)
return await callback(request)
monkeypatch.setattr(
relay_llm,
"execute_current_async",
execute_current_async,
)
monkeypatch.setattr(
relay_llm,
"complete_logical_call",
lambda request_id, *, outcome: logical_completions.append(
(request_id, outcome)
),
)
@auxiliary_client._relay_auxiliary_call_async
async def run(task):
auxiliary_client._set_relay_auxiliary_route(
"anthropic",
"claude-test",
"chat_completions",
)
return auxiliary_client._validate_llm_response(
await auxiliary_client._relay_async_completion(
client,
{"model": "claude-test", "messages": []},
),
task,
)
result = await run("title_generation")
assert result.request["model"] == "claude-test"
assert captured["name"] == "anthropic"
assert captured["metadata"]["call_role"] == "auxiliary:title_generation"
assert captured["defer_logical_completion"] is True
assert logical_completions == [
(captured["metadata"]["api_request_id"], "success")
]
def test_terminal_auxiliary_failure_stays_failed_when_caller_catches_it(
relay_turn, monkeypatch
):
_relay, turn = relay_turn
consumer = "test.terminal-auxiliary-failure"
turn.lease.host.retain_managed_execution(consumer)
outcomes = []
original_pop = turn.lease.host.relay.scope.pop
def record_pop(*args, **kwargs):
outcomes.append((kwargs.get("output") or {}).get("outcome"))
return original_pop(*args, **kwargs)
monkeypatch.setattr(turn.lease.host.relay.scope, "pop", record_pop)
client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_kwargs: SimpleNamespace(choices=[]),
)
)
)
@auxiliary_client._relay_auxiliary_call
def run(task):
auxiliary_client._set_relay_auxiliary_route(
"openrouter",
"test-model",
"chat_completions",
)
with pytest.raises(RuntimeError, match="invalid response"):
auxiliary_client._validate_llm_response(
auxiliary_client._relay_sync_completion(
client,
{"model": "test-model", "messages": []},
),
task,
)
assert len(turn.logical_llm_calls) == 1
return auxiliary_client._validate_llm_response(
auxiliary_client._relay_sync_completion(
client,
{"model": "test-model", "messages": []},
),
task,
)
try:
with pytest.raises(RuntimeError, match="invalid response"):
run("compression")
assert outcomes == ["failed"]
assert turn.logical_llm_calls == {}
relay_runtime.SESSION_COORDINATOR.end_turn(turn, outcome="success")
assert outcomes == ["failed", "success"]
finally:
turn.lease.host.release_managed_execution(consumer)
@pytest.mark.asyncio
async def test_async_terminal_auxiliary_failure_closes_logical_call(relay_turn):
_relay, turn = relay_turn
consumer = "test.async-terminal-auxiliary-failure"
turn.lease.host.retain_managed_execution(consumer)
async def create(**_kwargs):
return SimpleNamespace(choices=[])
client = SimpleNamespace(
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
)
@auxiliary_client._relay_auxiliary_call_async
async def run(task):
auxiliary_client._set_relay_auxiliary_route(
"anthropic",
"claude-test",
"chat_completions",
)
with pytest.raises(RuntimeError, match="invalid response"):
auxiliary_client._validate_llm_response(
await auxiliary_client._relay_async_completion(
client,
{"model": "claude-test", "messages": []},
),
task,
)
assert len(turn.logical_llm_calls) == 1
return auxiliary_client._validate_llm_response(
await auxiliary_client._relay_async_completion(
client,
{"model": "claude-test", "messages": []},
),
task,
)
try:
with pytest.raises(RuntimeError, match="invalid response"):
await run("title_generation")
assert turn.logical_llm_calls == {}
finally:
turn.lease.host.release_managed_execution(consumer)
def test_auxiliary_stream_uses_streaming_relay_primitive(monkeypatch):
captured = {}
raw_stream = iter([{"delta": "one"}, {"delta": "two"}])
client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(create=lambda **_kwargs: raw_stream)
)
)
def stream_current(request, stream_factory, **kwargs):
captured.update(kwargs)
return stream_factory(request)
monkeypatch.setattr(relay_llm, "stream_current", stream_current)
@auxiliary_client._relay_auxiliary_call
def run(task):
auxiliary_client._set_relay_auxiliary_route(
"openrouter",
"moa-model",
"chat_completions",
)
return auxiliary_client._relay_sync_stream(
client,
{"model": "moa-model", "messages": [], "stream": True},
)
assert list(run("moa")) == [{"delta": "one"}, {"delta": "two"}]
assert captured["metadata"]["call_role"] == "auxiliary:moa"
def test_partial_auxiliary_stream_failure_closes_before_recovery(
relay_turn, monkeypatch
):
_relay, turn = relay_turn
consumer = "test.partial-auxiliary-stream-failure"
turn.lease.host.retain_managed_execution(consumer)
outcomes = []
original_pop = turn.lease.host.relay.scope.pop
def record_pop(*args, **kwargs):
outcomes.append((kwargs.get("output") or {}).get("outcome"))
return original_pop(*args, **kwargs)
monkeypatch.setattr(turn.lease.host.relay.scope, "pop", record_pop)
class ProviderError(Exception):
pass
provider_error = ProviderError("stream failed")
partial_chunk = SimpleNamespace(
model="test-model",
choices=[
SimpleNamespace(
delta=SimpleNamespace(content="partial", tool_calls=None),
finish_reason=None,
)
],
usage=None,
)
def partial_stream():
yield partial_chunk
raise provider_error
stream_client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_kwargs: partial_stream(),
)
)
)
recovery_client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_kwargs: SimpleNamespace(
choices=[
SimpleNamespace(message=SimpleNamespace(content="recovered"))
]
),
)
)
)
@auxiliary_client._relay_auxiliary_call
def start_stream(task):
auxiliary_client._set_relay_auxiliary_route(
"openrouter",
"test-model",
"chat_completions",
)
return auxiliary_client._relay_sync_stream(
stream_client,
{"model": "test-model", "messages": [], "stream": True},
)
@auxiliary_client._relay_auxiliary_call
def recover(task):
auxiliary_client._set_relay_auxiliary_route(
"openrouter",
"test-model",
"chat_completions",
)
return auxiliary_client._validate_llm_response(
auxiliary_client._relay_sync_completion(
recovery_client,
{"model": "test-model", "messages": []},
),
task,
)
try:
stream = start_stream("moa")
assert next(stream) is partial_chunk
with pytest.raises(ProviderError) as caught:
next(stream)
assert caught.value is provider_error
assert outcomes == ["failed"]
assert turn.logical_llm_calls == {}
result = recover("moa")
assert result.choices[0].message.content == "recovered"
assert outcomes == ["failed", "success"]
assert turn.logical_llm_calls == {}
finally:
turn.lease.host.release_managed_execution(consumer)
def test_auxiliary_attempt_uses_real_relay_request_intercepts(relay_turn):
relay, turn = relay_turn
consumer = "test.auxiliary-request-intercept"
turn.lease.host.retain_managed_execution(consumer)
captured_requests = []
client = SimpleNamespace(
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **kwargs: captured_requests.append(kwargs)
or SimpleNamespace(
choices=[
SimpleNamespace(message=SimpleNamespace(content="ok"))
]
),
)
)
)
def rewrite_request(_name, request, annotated):
annotated.params = {**(annotated.params or {}), "temperature": 0.25}
return relay.LLMRequestInterceptOutcome(request, annotated)
relay.intercepts.register_llm_request(
"hermes-auxiliary-request",
1,
False,
rewrite_request,
)
try:
@auxiliary_client._relay_auxiliary_call
def run(task):
auxiliary_client._set_relay_auxiliary_route(
"openrouter",
"test-model",
"chat_completions",
)
return auxiliary_client._validate_llm_response(
auxiliary_client._relay_sync_completion(
client,
{"model": "test-model", "messages": []},
),
task,
)
result = run("compression")
finally:
relay.intercepts.deregister_llm_request("hermes-auxiliary-request")
turn.lease.host.release_managed_execution(consumer)
assert result.choices[0].message.content == "ok"
assert captured_requests[0]["temperature"] == 0.25
assert turn.logical_llm_calls == {}

View file

@ -86,7 +86,21 @@ def test_bedrock_stream_returns_normally_when_not_interrupted():
agent._interrupt_requested = False
resp = SimpleNamespace(choices=[], usage=None, stop_reason="end_turn")
fake_client = SimpleNamespace(converse_stream=lambda **kw: {"stream": []})
class ProviderStream:
def __init__(self):
self.closed = False
def __iter__(self):
return iter(())
def close(self):
self.closed = True
provider_stream = ProviderStream()
fake_client = SimpleNamespace(
converse_stream=lambda **kw: {"stream": provider_stream}
)
with patch("agent.bedrock_adapter._get_bedrock_runtime_client", return_value=fake_client), \
patch("agent.bedrock_adapter.stream_converse_with_callbacks", return_value=resp), \
@ -97,3 +111,4 @@ def test_bedrock_stream_returns_normally_when_not_interrupted():
api_kwargs = {"__bedrock_region__": "us-east-1", "__bedrock_converse__": True}
out = cch.interruptible_streaming_api_call(agent, api_kwargs)
assert out is resp
assert provider_stream.closed is True

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,262 @@
"""Tests for the core Relay-managed Hermes tool adapter."""
from __future__ import annotations
import contextvars
import json
import pytest
pytest.importorskip("nemo_relay")
from agent import relay_runtime, relay_tools
@pytest.fixture()
def relay_turn(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profile"))
relay_runtime._reset_for_tests()
lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation(
profile_key=relay_runtime.current_profile_key(),
session_id="session-1",
platform="cli",
)
turn = relay_runtime.SESSION_COORDINATOR.begin_turn(
lease,
turn_id="turn-1",
task_id="task-1",
)
lease.host.retain_managed_execution("test.relay_tools")
try:
yield lease.host.relay
finally:
lease.host.release_managed_execution("test.relay_tools")
relay_runtime.SESSION_COORDINATOR.end_turn(turn, outcome="success")
relay_runtime.SESSION_COORDINATOR.release_conversation(lease)
relay_runtime._reset_for_tests()
def test_tool_adapter_bypasses_relay_without_an_active_consumer(
relay_turn, monkeypatch
):
relay = relay_turn
runtime = relay_runtime.get_runtime()
assert runtime is not None
runtime.release_managed_execution("test.relay_tools")
args = {"command": "pwd"}
monkeypatch.setattr(
relay.tools,
"execute",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("inactive Relay must not manage the tool call")
),
)
result, final_args = relay_tools.execute(
"terminal",
args,
lambda value: value,
session_id="session-1",
)
assert result is args
assert final_args is args
def test_tool_request_intercepts_bypass_relay_without_an_active_consumer(
relay_turn, monkeypatch
):
relay = relay_turn
runtime = relay_runtime.get_runtime()
assert runtime is not None
runtime.release_managed_execution("test.relay_tools")
args = {"command": "pwd"}
monkeypatch.setattr(
relay.tools,
"request_intercepts",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("inactive Relay must not run tool request intercepts")
),
)
final_args = runtime.apply_tool_request_intercepts(
session_id="session-1",
tool_name="terminal",
args=args,
)
assert final_args is args
def test_request_rewrite_reaches_authorized_callback_once(relay_turn):
relay = relay_turn
callback_args = []
def rewrite_request(_name, args):
return {**args, "path": "/approved/path"}
async def wrap_execution(_name, args, next_call):
result = await next_call(args)
return relay.ToolExecutionInterceptOutcome({**result, "wrapped": True})
relay.intercepts.register_tool_request(
"hermes-test-tool-request", 1, False, rewrite_request
)
relay.intercepts.register_tool_execution(
"hermes-test-tool-execution", 1, wrap_execution
)
try:
result, observed_args = relay_tools.execute(
"write_file",
{"path": "/original/path"},
lambda args: callback_args.append(args) or {"ok": True},
session_id="session-1",
metadata={"tool_call_id": "call-1"},
)
finally:
relay.intercepts.deregister_tool_execution("hermes-test-tool-execution")
relay.intercepts.deregister_tool_request("hermes-test-tool-request")
assert callback_args == [{"path": "/approved/path"}]
assert observed_args == {"path": "/approved/path"}
assert isinstance(result, str)
assert json.loads(result) == {"ok": True, "wrapped": True}
def test_authorized_tool_callback_preserves_caller_context(relay_turn):
del relay_turn
caller_value = contextvars.ContextVar("tool_caller_value", default="default")
caller_value.set("caller")
result, _observed_args = relay_tools.execute(
"terminal",
{"command": "true"},
lambda _args: caller_value.get(),
session_id="session-1",
)
assert result == "caller"
def test_provider_error_identity_is_preserved(relay_turn):
del relay_turn
class ToolError(Exception):
pass
tool_error = ToolError("dispatch failed")
def fail(_args):
raise tool_error
with pytest.raises(ToolError) as caught:
relay_tools.execute(
"terminal",
{"command": "false"},
fail,
session_id="session-1",
)
assert caught.value is tool_error
def test_tool_error_is_preserved_from_relay_wrapper_suffix(relay_turn, monkeypatch):
relay = relay_turn
class ToolError(Exception):
pass
tool_error = ToolError("dispatch failed")
async def wrapping_execute(_name, args, callback, **_kwargs):
try:
return callback(args)
except Exception as exc:
raise RuntimeError(
f"internal error: {type(exc).__name__}: {exc} (worker trace)"
) from None
monkeypatch.setattr(relay.tools, "execute", wrapping_execute)
with pytest.raises(ToolError) as caught:
relay_tools.execute(
"terminal",
{"command": "false"},
lambda _args: (_ for _ in ()).throw(tool_error),
session_id="session-1",
)
assert caught.value is tool_error
def test_tool_adapter_does_not_mask_relay_error_after_callback_failure(
relay_turn, monkeypatch
):
relay = relay_turn
tool_error = RuntimeError("dispatch failed")
relay_error = RuntimeError("internal error: RelayPolicyError: policy blocked")
async def translating_execute(_name, args, callback, **_kwargs):
try:
callback(args)
except Exception:
raise relay_error
monkeypatch.setattr(relay.tools, "execute", translating_execute)
with pytest.raises(RuntimeError) as caught:
relay_tools.execute(
"terminal",
{"command": "false"},
lambda _args: (_ for _ in ()).throw(tool_error),
session_id="session-1",
)
assert caught.value is relay_error
def test_tool_adapter_returns_dispatch_result_after_relay_post_processing_failure(
relay_turn, monkeypatch, caplog
):
relay = relay_turn
raw_result = '{"ok":true}'
async def fail_after_callback(_name, args, callback, **_kwargs):
callback(args)
raise RuntimeError("simulated Relay post-processing failure")
monkeypatch.setattr(relay.tools, "execute", fail_after_callback)
with caplog.at_level("WARNING", logger="agent.relay_tools"):
result, observed_args = relay_tools.execute(
"terminal",
{"command": "pwd"},
lambda _args: raw_result,
session_id="session-1",
)
assert result is raw_result
assert observed_args == {"command": "pwd"}
assert "returning the Hermes tool result" in caplog.text
def test_tool_adapter_does_not_swallow_interrupt_after_dispatch_success(
relay_turn, monkeypatch
):
relay = relay_turn
async def interrupt_after_callback(_name, args, callback, **_kwargs):
callback(args)
raise KeyboardInterrupt
monkeypatch.setattr(relay.tools, "execute", interrupt_after_callback)
with pytest.raises(KeyboardInterrupt):
relay_tools.execute(
"terminal",
{"command": "pwd"},
lambda _args: '{"ok":true}',
session_id="session-1",
)

View file

@ -10,8 +10,12 @@ def test_session_hooks_in_valid_hooks():
assert "on_session_reset" in VALID_HOOKS
@patch("hermes_cli.plugins.invoke_hook")
def test_session_finalize_on_reset(mock_invoke_hook):
# These tests pin CLI ownership of the finalize request. The end-to-end
# built-in/core/plugin dispatch order is exercised by
# tests/hermes_cli/test_lifecycle.py::test_finalize_session_closes_core_before_plugin_export.
@patch("hermes_cli.lifecycle.invoke_hook")
@patch("hermes_cli.lifecycle.finalize_session")
def test_session_finalize_on_reset(mock_finalize_session, mock_invoke_hook):
"""Verify on_session_finalize fires when /new or /reset is used."""
cli = HermesCLI()
cli.agent = MagicMock()
@ -22,10 +26,10 @@ def test_session_finalize_on_reset(mock_invoke_hook):
# Check if on_session_finalize was called for the old session
assert any(
c.args == ("on_session_finalize",)
not c.args
and c.kwargs["session_id"] == "test-session-id"
and c.kwargs["platform"] == "cli"
for c in mock_invoke_hook.call_args_list
for c in mock_finalize_session.call_args_list
)
# Check if on_session_reset was called for the new session
assert any(
@ -36,8 +40,8 @@ def test_session_finalize_on_reset(mock_invoke_hook):
)
@patch("hermes_cli.plugins.invoke_hook")
def test_session_finalize_on_cleanup(mock_invoke_hook):
@patch("hermes_cli.lifecycle.finalize_session")
def test_session_finalize_on_cleanup(mock_finalize_session):
"""Verify on_session_finalize fires during CLI exit cleanup."""
import cli as cli_mod
@ -49,15 +53,15 @@ def test_session_finalize_on_cleanup(mock_invoke_hook):
cli_mod._run_cleanup()
assert any(
c.args == ("on_session_finalize",)
not c.args
and c.kwargs["session_id"] == "cleanup-session-id"
and c.kwargs["platform"] == "cli"
and c.kwargs["reason"] == "shutdown"
for c in mock_invoke_hook.call_args_list
for c in mock_finalize_session.call_args_list
)
@patch("hermes_cli.plugins.invoke_hook")
@patch("hermes_cli.lifecycle.invoke_hook")
def test_interrupted_session_end_helper_emits_observer_shape(mock_invoke_hook):
"""Verify quiet single-query interruption emits a correlated session end."""
import cli as cli_mod

View file

@ -0,0 +1,60 @@
from types import SimpleNamespace
from agent import relay_runtime
from hermes_cli import lifecycle, observability, plugins
def test_invoke_hook_notifies_builtin_observers_before_plugins(monkeypatch):
calls = []
manager = SimpleNamespace(
invoke_hook=lambda name, **kwargs: calls.append(("plugin", name, kwargs)) or ["ok"]
)
monkeypatch.setattr(
observability,
"observe_lifecycle",
lambda name, **kwargs: calls.append(("builtin", name, kwargs)),
)
monkeypatch.setattr(plugins, "invoke_hook", manager.invoke_hook)
result = lifecycle.invoke_hook("on_session_start", session_id="session-1")
assert result == ["ok"]
assert [call[0] for call in calls] == ["builtin", "plugin"]
def test_finalize_session_closes_core_before_plugin_export(monkeypatch):
calls = []
manager = SimpleNamespace(
invoke_hook=lambda name, **kwargs: calls.append(("plugin", name, kwargs)) or []
)
coordinator = SimpleNamespace(
finalize_conversation=lambda **kwargs: calls.append(("core", kwargs))
)
monkeypatch.setattr(
observability,
"observe_lifecycle",
lambda name, **kwargs: calls.append(("builtin", name, kwargs)),
)
monkeypatch.setattr(plugins, "invoke_hook", manager.invoke_hook)
monkeypatch.setattr(relay_runtime, "SESSION_COORDINATOR", coordinator)
monkeypatch.setattr(relay_runtime, "current_profile_key", lambda: "profile-1")
lifecycle.finalize_session(session_id="session-1", platform="cli")
assert [call[0] for call in calls] == ["builtin", "core", "plugin"]
assert calls[1][1] == {
"profile_key": "profile-1",
"session_id": "session-1",
}
def test_plugin_only_dispatch_does_not_reenter_builtin_observers(monkeypatch):
manager = SimpleNamespace(invoke_hook=lambda name, **kwargs: [name, kwargs])
monkeypatch.setattr(plugins, "get_plugin_manager", lambda: manager)
monkeypatch.setattr(
observability,
"observe_lifecycle",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("unexpected")),
)
assert plugins.invoke_hook("custom", value=1) == ["custom", {"value": 1}]

View file

@ -189,6 +189,32 @@ class TestPluginDiscovery:
assert result.changed is True
assert result.trace == [{"source": "same-payload"}]
def test_tool_request_middleware_hides_internal_skip_relay_flag(
self,
monkeypatch,
):
observed = []
def middleware(**kwargs):
observed.append(kwargs)
return {"args": kwargs["args"]}
manager = types.SimpleNamespace(
_middleware={"tool_request": [middleware]},
invoke_middleware=lambda kind, **kwargs: [middleware(**kwargs)],
)
monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager)
apply_tool_request_middleware(
"read_file",
{"path": "README.md"},
session_id="",
skip_relay=True,
)
assert len(observed) == 1
assert "skip_relay" not in observed[0]
def test_execution_middleware_post_next_call_error_does_not_retry(self, monkeypatch):
calls = []

View file

@ -0,0 +1,839 @@
"""Focused tests for the Hermes shared-metrics durable store."""
from __future__ import annotations
import json
import multiprocessing as mp
import os
import sqlite3
import stat
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from hermes_cli.observability.shared_metrics_contract import (
COUNT_BUCKETS,
DURATION_BUCKETS,
EXECUTION_SURFACES,
MODEL_FAMILIES,
MODEL_LOCALITIES,
MODEL_OUTCOMES,
PRIMARY_MODEL_CALL_ROLE,
PROVIDER_FAMILIES,
TASK_END_REASONS,
TASK_ENTRYPOINTS,
TASK_OUTCOMES,
TASK_TERMINATIONS,
count_bucket,
duration_bucket,
execution_surface,
model_call_outcome,
model_call_dimensions,
model_family,
model_locality,
provider_family,
task_counter,
task_start_fields,
task_terminal_fields,
task_terminal_state,
)
SCHEMA_PATH = (
Path(__file__).resolve().parents[2]
/ "hermes_cli"
/ "observability"
/ "schemas"
/ "hermes.shared_metrics.v1.schema.json"
)
def _schema_validator():
jsonschema = pytest.importorskip("jsonschema")
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
jsonschema.Draft202012Validator.check_schema(schema)
return jsonschema.Draft202012Validator(
schema,
format_checker=jsonschema.FormatChecker(),
)
def _package_dimension_schema() -> dict[str, object]:
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
return schema["$defs"]["model_call_counter"]["properties"]["dimensions"]
def _task_dimension_schema(kind: str) -> dict[str, object]:
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
return schema["$defs"][kind]["properties"]["dimensions"]
def _dimensions() -> dict[str, str]:
return {
"call_role": PRIMARY_MODEL_CALL_ROLE,
"locality": "remote",
"model_family": "claude",
"outcome": "success",
"provider_family": "direct",
}
def _record_model_calls_in_process(
database_path: str,
outbox_directory: str,
count: int,
start_barrier: Any | None = None,
) -> None:
if start_barrier is not None:
start_barrier.wait()
store = SharedMetricsStore(Path(database_path), Path(outbox_directory))
for _ in range(count):
store.record_model_call(_dimensions(), "test-version")
def test_model_call_counter_survives_restart_and_exports_only_new_deltas(tmp_path):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"
store = SharedMetricsStore(database_path, outbox_directory)
store.record_model_call(_dimensions(), "test-version")
store.record_model_call(_dimensions(), "test-version")
first_paths = store.create_and_export_package()
assert len(first_paths) == 1
first_package = json.loads(first_paths[0].read_text(encoding="utf-8"))
_schema_validator().validate(first_package)
uuid.UUID(first_package["package_id"])
uuid.UUID(first_package["install_id"])
assert first_package["schema_version"] == "hermes.shared_metrics.v1"
assert first_package["resource"] == {"hermes_version": "test-version"}
assert first_package["metrics"] == [
{
"name": "hermes.model_call.count",
"type": "counter",
"dimensions": _dimensions(),
"value": 2,
}
]
restarted = SharedMetricsStore(database_path, outbox_directory)
assert restarted.counter_snapshot()[0]["value"] == 2
assert restarted.counter_snapshot()[0]["packaged_value"] == 2
assert restarted.create_and_export_package() == []
assert len(list(outbox_directory.glob("*.json"))) == 1
restarted.record_model_call(_dimensions(), "test-version")
second_paths = restarted.create_and_export_package()
assert len(second_paths) == 1
second_package = json.loads(second_paths[0].read_text(encoding="utf-8"))
assert second_package["package_id"] != first_package["package_id"]
assert second_package["install_id"] == first_package["install_id"]
assert second_package["metrics"][0]["value"] == 1
assert restarted.counter_snapshot()[0]["value"] == 3
assert restarted.counter_snapshot()[0]["packaged_value"] == 3
def test_package_schema_matches_the_model_call_contract():
properties = _package_dimension_schema()["properties"]
assert properties["call_role"] == {"const": PRIMARY_MODEL_CALL_ROLE}
assert set(properties["locality"]["enum"]) == MODEL_LOCALITIES
assert set(properties["model_family"]["enum"]) == MODEL_FAMILIES
assert set(properties["outcome"]["enum"]) == MODEL_OUTCOMES
assert set(properties["provider_family"]["enum"]) == PROVIDER_FAMILIES
def test_package_schema_matches_the_task_contract():
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
start = _task_dimension_schema("task_started_counter")["properties"]
terminal = _task_dimension_schema("task_finished_counter")["properties"]
assert set(schema["$defs"]["execution_surface"]["enum"]) == EXECUTION_SURFACES
assert set(schema["$defs"]["task_entrypoint"]["enum"]) == TASK_ENTRYPOINTS
assert set(schema["$defs"]["duration_bucket"]["enum"]) == DURATION_BUCKETS
assert set(schema["$defs"]["count_bucket"]["enum"]) == COUNT_BUCKETS
assert start["entrypoint"] == {"$ref": "#/$defs/task_entrypoint"}
assert set(terminal["end_reason"]["enum"]) == TASK_END_REASONS
assert set(terminal["outcome"]["enum"]) == TASK_OUTCOMES
assert set(terminal["termination"]["enum"]) == TASK_TERMINATIONS
@pytest.mark.parametrize(
("provider", "expected"),
[
("", "unknown"),
("not-a-hermes-provider", "unknown"),
("custom", "custom"),
("custom-local", "custom"),
("custom:private-endpoint", "custom"),
("lmstudio", "local"),
("lm_studio", "local"),
("ollama", "local"),
("nous", "aggregator"),
("openrouter", "aggregator"),
("kilo", "aggregator"),
("copilot-acp", "aggregator"),
("huggingface", "aggregator"),
("novita", "aggregator"),
("anthropic", "direct"),
("google", "direct"),
("openai-api", "direct"),
],
)
def test_provider_family_uses_bounded_product_categories(provider, expected):
assert provider_family({"provider": provider}) == expected
def test_provider_family_does_not_resolve_live_provider_metadata(monkeypatch):
def fail_live_lookup(_provider):
raise AssertionError("telemetry must not refresh provider metadata")
monkeypatch.setattr("hermes_cli.providers.get_provider", fail_live_lookup)
assert provider_family({"provider": "anthropic"}) == "direct"
def test_locality_uses_the_endpoint_only_for_local_classification():
kwargs = {
"provider": "custom",
"base_url": "http://127.0.0.1:11434/v1",
}
assert provider_family(kwargs) == "custom"
assert model_locality(kwargs) == "local"
@pytest.mark.parametrize(
("model", "expected"),
[
("google/gemma-3", "gemma"),
("x-ai/grok-4", "grok"),
("minimax/minimax-m2.5", "minimax"),
("xiaomi/mimo-v2", "mimo"),
("amazon/nova-pro", "nova"),
("stepfun/step-3.5", "step"),
("arcee-ai/trinity-large", "trinity"),
],
)
def test_model_family_covers_families_evidenced_by_the_hermes_catalog(model, expected):
assert model_family({"model": model}) == expected
@pytest.mark.parametrize(
"model",
[
"private-gptish-model",
"innovation-private",
"mimosa-private",
"stepstone-private",
"supernova-private",
],
)
def test_model_family_requires_identifier_boundaries(model):
assert model_family({"model": model}) == "unknown"
def test_model_family_accepts_only_allowlisted_declared_metadata():
assert model_family({"model": "private", "model_family": "qwen"}) == "qwen"
assert model_family({"model": "private", "model_family": "private"}) == "unknown"
def test_model_family_prefers_the_provider_reported_terminal_model():
assert (
model_family({"model": "gpt-5", "response_model": "claude-sonnet"}) == "claude"
)
@pytest.mark.parametrize(
("platform", "expected"),
[
("", "unknown"),
("cli", "cli"),
("api_server", "api"),
("cron", "scheduled_task"),
("whatsapp_cloud", "gateway"),
("private-surface", "other"),
],
)
def test_execution_surface_uses_the_hermes_platform_registry(platform, expected):
assert execution_surface({"platform": platform}) == expected
@pytest.mark.parametrize(
("platform", "expected"),
[
("cli", "interactive"),
("tui", "interactive"),
("whatsapp_cloud", "gateway_message"),
("cron", "scheduled_task"),
("api_server", "api"),
("private-surface", "other"),
],
)
def test_task_start_fields_use_bounded_surface_and_entrypoint(platform, expected):
fields = task_start_fields({"platform": platform})
assert fields["entrypoint"] == expected
assert fields["execution_surface"] in EXECUTION_SURFACES
def test_task_start_fields_identify_delegated_work_without_exporting_parent_id():
fields = task_start_fields({
"platform": "cli",
"parent_session_id": "private-parent-session",
})
assert fields == {
"entrypoint": "delegated",
"execution_surface": "cli",
}
assert "private-parent-session" not in json.dumps(fields)
@pytest.mark.parametrize(
("duration_ms", "expected"),
[
(0, "lt_1s"),
(999, "lt_1s"),
(1_000, "1s_to_5s"),
(5_000, "5s_to_30s"),
(30_000, "30s_to_2m"),
(120_000, "2m_to_10m"),
(600_000, "gte_10m"),
],
)
def test_duration_bucket_boundaries(duration_ms, expected):
assert duration_bucket(duration_ms) == expected
@pytest.mark.parametrize(
("count", "expected"),
[
(0, "0"),
(1, "1"),
(2, "2"),
(3, "3_to_5"),
(6, "6_to_10"),
(11, "gte_11"),
],
)
def test_count_bucket_boundaries(count, expected):
assert count_bucket(count) == expected
@pytest.mark.parametrize(
("event", "expected"),
[
(
{"completed": True, "turn_exit_reason": "text_response(stop)"},
("success", "completed", "none"),
),
(
{"failed": True, "turn_exit_reason": "all_retries_exhausted_no_response"},
("failed", "failed", "none"),
),
(
{"interrupted": True, "turn_exit_reason": "interrupted_by_user"},
("cancelled", "user_cancelled", "user_cancelled"),
),
(
{"turn_exit_reason": "budget_exhausted"},
("failed", "iteration_limit", "system_aborted"),
),
(
{"turn_exit_reason": "guardrail_halt"},
("failed", "guardrail_blocked", "system_aborted"),
),
(
{"failed": True, "turn_exit_reason": "provider_timeout"},
("timed_out", "timed_out", "timed_out"),
),
(
{"failed": True, "turn_exit_reason": "approval_denied"},
("failed", "approval_denied", "none"),
),
],
)
def test_task_terminal_state_is_bounded(event, expected):
assert task_terminal_state(event) == expected
def test_model_outcome_fails_closed_to_a_bounded_value():
assert model_call_outcome({"outcome": "private"}) == "failed"
def test_unlisted_model_collapses_to_a_bounded_value():
assert model_family({"model": "private-model-name"}) == "unknown"
def test_subscriber_contract_rejects_unknown_fields_and_dimension_values():
event = SimpleNamespace(
kind="scope",
category="llm",
category_profile={"model_name": "gpt"},
name="hermes.model_call",
scope_category="end",
metadata={"hermes.metrics.schema_version": "hermes.metrics.event.v1"},
data={
"call_role": "primary",
"locality": "remote",
"model_family": "gpt",
"outcome": "success",
"provider_family": "direct",
},
)
assert model_call_dimensions(event) == {
"call_role": "primary",
"locality": "remote",
"model_family": "gpt",
"outcome": "success",
"provider_family": "direct",
}
event.category_profile["model_name"] = "private-model-name"
assert model_call_dimensions(event) is None
event.category_profile["model_name"] = "gpt"
event.data["prompt"] = "must-not-pass"
assert model_call_dimensions(event) is None
event.data.pop("prompt")
event.metadata["prompt"] = "must-not-pass"
assert model_call_dimensions(event) is None
event.metadata.pop("prompt")
event.category_profile["private"] = "must-not-pass"
assert model_call_dimensions(event) is None
event.category_profile.pop("private")
event.category = "function"
assert model_call_dimensions(event) is None
def test_task_subscriber_contract_accepts_only_bounded_scope_events():
start = SimpleNamespace(
kind="scope",
category="function",
category_profile=None,
name="hermes.task_run",
scope_category="start",
metadata={"hermes.metrics.schema_version": "hermes.metrics.event.v1"},
data={"entrypoint": "interactive", "execution_surface": "cli"},
)
assert task_counter(start) == (
"hermes.task_run.started",
{"entrypoint": "interactive", "execution_surface": "cli"},
)
terminal_fields = task_terminal_fields(
{
"platform": "cli",
"completed": True,
"turn_exit_reason": "text_response(stop)",
},
duration_ms=6_000,
model_call_count=2,
tool_call_count=3,
retry_count=1,
)
end = SimpleNamespace(**{
**start.__dict__,
"scope_category": "end",
"data": terminal_fields,
})
assert task_counter(end) == (
"hermes.task_run.finished",
terminal_fields,
)
end.data["task_id"] = "must-not-pass"
assert task_counter(end) is None
end.data.pop("task_id")
end.data["outcome"] = "private"
assert task_counter(end) is None
end.data["outcome"] = "success"
end.metadata["prompt"] = "must-not-pass"
assert task_counter(end) is None
def test_store_rejects_an_unsupported_schema_version(tmp_path):
database_path = tmp_path / "metrics.sqlite3"
with sqlite3.connect(database_path) as connection:
connection.execute(
"CREATE TABLE telemetry_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
)
connection.execute(
"INSERT INTO telemetry_state(key, value) VALUES ('schema_version', '999')"
)
with pytest.raises(RuntimeError, match="Unsupported shared-metrics store schema"):
SharedMetricsStore(database_path, tmp_path / "outbox")
with sqlite3.connect(database_path) as connection:
[schema_version] = connection.execute(
"SELECT value FROM telemetry_state WHERE key = 'schema_version'"
).fetchone()
assert schema_version == "999"
def test_pending_metrics_keep_the_version_recorded_at_event_time(tmp_path):
store = SharedMetricsStore(tmp_path / "metrics.sqlite3", tmp_path / "outbox")
store.record_model_call(_dimensions(), "version-a")
store.record_model_call(_dimensions(), "version-b")
packages = [
json.loads(path.read_text(encoding="utf-8"))
for path in store.create_and_export_package()
]
assert {package["resource"]["hermes_version"] for package in packages} == {
"version-a",
"version-b",
}
assert all(package["metrics"][0]["value"] == 1 for package in packages)
def test_store_exports_task_started_and_terminal_counters(tmp_path):
store = SharedMetricsStore(tmp_path / "metrics.sqlite3", tmp_path / "outbox")
store.record_counter(
"hermes.task_run.started",
{"entrypoint": "interactive", "execution_surface": "cli"},
"test-version",
)
terminal = task_terminal_fields(
{
"platform": "cli",
"completed": True,
"turn_exit_reason": "text_response(stop)",
},
duration_ms=2_000,
model_call_count=1,
tool_call_count=2,
retry_count=0,
)
store.record_counter("hermes.task_run.finished", terminal, "test-version")
[package_path] = store.create_and_export_package()
package = json.loads(package_path.read_text(encoding="utf-8"))
_schema_validator().validate(package)
assert {metric["name"] for metric in package["metrics"]} == {
"hermes.task_run.finished",
"hermes.task_run.started",
}
def test_package_schema_rejects_unknown_fields(tmp_path):
store = SharedMetricsStore(tmp_path / "metrics.sqlite3", tmp_path / "outbox")
store.record_model_call(_dimensions(), "test-version")
[package_path] = store.create_and_export_package()
package = json.loads(package_path.read_text(encoding="utf-8"))
invalid_package = deepcopy(package)
invalid_package["prompt"] = "must-not-be-accepted"
jsonschema = pytest.importorskip("jsonschema")
with pytest.raises(jsonschema.ValidationError):
_schema_validator().validate(invalid_package)
def test_store_rejects_dimensions_outside_the_metric_contract(tmp_path):
store = SharedMetricsStore(tmp_path / "metrics.sqlite3", tmp_path / "outbox")
with pytest.raises(ValueError, match="Unsupported dimensions"):
store.record_counter(
"hermes.model_call.count",
{"prompt": "must-not-be-persisted"},
"test-version",
)
assert store.counter_snapshot() == []
def test_package_builder_rejects_tampered_dimensions(tmp_path):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"
store = SharedMetricsStore(database_path, outbox_directory)
store.record_model_call(_dimensions(), "test-version")
with sqlite3.connect(database_path) as connection:
connection.execute(
"UPDATE counter_aggregates SET dimensions_json = ?",
(json.dumps({"prompt": "must-not-be-exported"}),),
)
with pytest.raises(ValueError, match="Unsupported dimensions"):
store.create_and_export_package()
assert list(outbox_directory.glob("*.json")) == []
def test_pending_package_retry_reuses_the_same_package_and_file(tmp_path):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"
store = SharedMetricsStore(database_path, outbox_directory)
store.record_model_call(_dimensions(), "test-version")
[package_path] = store.create_and_export_package()
original_payload = package_path.read_bytes()
with sqlite3.connect(database_path) as connection:
connection.execute("UPDATE package_outbox SET exported_at = NULL")
restarted = SharedMetricsStore(database_path, outbox_directory)
assert restarted.create_and_export_package() == [package_path]
assert package_path.read_bytes() == original_payload
assert list(outbox_directory.glob("*.json")) == [package_path]
def test_retention_prunes_only_expired_exported_history(tmp_path):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"
store = SharedMetricsStore(database_path, outbox_directory)
store.record_model_call(_dimensions(), "expired-version")
[expired_path] = store.create_and_export_package()
store.record_model_call(_dimensions(), "current-version")
[current_path] = store.create_and_export_package()
store.record_model_call(_dimensions(), "pending-version")
pending_package = store._create_package()
assert pending_package is not None
with sqlite3.connect(database_path) as connection:
connection.execute(
"""
UPDATE counter_aggregates
SET period_start = '2026-05-01'
WHERE hermes_version = 'expired-version'
"""
)
connection.execute(
"""
UPDATE package_outbox
SET period_start = '2026-05-01T00:00:00Z',
period_end = '2026-05-02T00:00:00Z',
exported_at = '2026-05-02T00:00:00Z'
WHERE package_id = ?
""",
(expired_path.stem,),
)
store._prune_expired_history(
now=datetime(2026, 7, 23, tzinfo=timezone.utc)
)
assert not expired_path.exists()
assert current_path.exists()
assert not (outbox_directory / f"{pending_package['package_id']}.json").exists()
with sqlite3.connect(database_path) as connection:
outbox_rows = connection.execute(
"SELECT package_id, exported_at FROM package_outbox ORDER BY package_id"
).fetchall()
aggregate_versions = {
row[0]
for row in connection.execute(
"SELECT hermes_version FROM counter_aggregates"
).fetchall()
}
assert {row[0] for row in outbox_rows} == {
current_path.stem,
pending_package["package_id"],
}
assert next(
row[1] for row in outbox_rows if row[0] == pending_package["package_id"]
) is None
assert aggregate_versions == {"current-version", "pending-version"}
def test_retention_failure_does_not_fail_a_committed_export(tmp_path, monkeypatch):
store = SharedMetricsStore(
tmp_path / "metrics.sqlite3",
tmp_path / "outbox",
)
store.record_model_call(_dimensions(), "test-version")
def fail_pruning():
raise OSError("retention unavailable")
monkeypatch.setattr(store, "_prune_expired_history", fail_pruning)
[package_path] = store.create_and_export_package()
assert package_path.exists()
assert store.counter_snapshot()[0]["packaged_value"] == 1
def test_file_export_failure_retries_committed_outbox_without_duplicate_delta(
tmp_path, monkeypatch
):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"
store = SharedMetricsStore(database_path, outbox_directory)
store.record_model_call(_dimensions(), "test-version")
def fail_write(*_args, **_kwargs):
raise OSError("simulated atomic export failure")
module_globals = SharedMetricsStore._export_pending_packages.__globals__
original_write = module_globals["atomic_json_write"]
monkeypatch.setitem(module_globals, "atomic_json_write", fail_write)
with pytest.raises(OSError, match="simulated atomic export failure"):
store.create_and_export_package()
with sqlite3.connect(database_path) as connection:
package_id, exported_at = connection.execute(
"SELECT package_id, exported_at FROM package_outbox"
).fetchone()
assert exported_at is None
assert store.counter_snapshot()[0]["packaged_value"] == 1
assert list(outbox_directory.glob("*.json")) == []
monkeypatch.setitem(module_globals, "atomic_json_write", original_write)
assert store.create_and_export_package() == [
outbox_directory / f"{package_id}.json"
]
assert len(list(outbox_directory.glob("*.json"))) == 1
assert store.create_and_export_package() == []
def test_package_export_does_not_chase_concurrent_updates(tmp_path, monkeypatch):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"
store = SharedMetricsStore(database_path, outbox_directory)
store.record_model_call(_dimensions(), "test-version")
original_create = store._create_package
create_calls = 0
def create_and_record_another():
nonlocal create_calls
create_calls += 1
package = original_create()
if create_calls == 1:
store.record_model_call(_dimensions(), "test-version")
return package
monkeypatch.setattr(store, "_create_package", create_and_record_another)
first_paths = store.create_and_export_package()
assert create_calls == 1
assert len(first_paths) == 1
[counter] = store.counter_snapshot()
assert counter["metric_name"] == "hermes.model_call.count"
assert counter["dimensions"] == _dimensions()
assert counter["value"] == 2
assert counter["packaged_value"] == 1
second_paths = store.create_and_export_package()
assert len(second_paths) == 1
assert store.counter_snapshot()[0]["packaged_value"] == 2
def test_concurrent_package_builders_commit_one_delta(tmp_path):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"
store = SharedMetricsStore(database_path, outbox_directory)
store.record_model_call(_dimensions(), "test-version")
ready = threading.Barrier(2)
def export() -> list[Path]:
worker_store = SharedMetricsStore(database_path, outbox_directory)
ready.wait(timeout=5)
return worker_store.create_and_export_package()
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(export) for _ in range(2)]
for future in futures:
future.result()
with sqlite3.connect(database_path) as connection:
[outbox_count] = connection.execute(
"SELECT COUNT(*) FROM package_outbox"
).fetchone()
[package_path] = list(outbox_directory.glob("*.json"))
package = json.loads(package_path.read_text(encoding="utf-8"))
assert outbox_count == 1
assert package["metrics"][0]["value"] == 1
assert store.counter_snapshot()[0]["packaged_value"] == 1
def test_concurrent_model_call_updates_are_transactional(tmp_path):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"
SharedMetricsStore(database_path, outbox_directory)
def record_calls(count: int) -> None:
store = SharedMetricsStore(database_path, outbox_directory)
for _ in range(count):
store.record_model_call(_dimensions(), "test-version")
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(record_calls, 10) for _ in range(2)]
for future in futures:
future.result()
restarted = SharedMetricsStore(database_path, outbox_directory)
assert restarted.counter_snapshot()[0]["value"] == 20
def test_cross_process_model_call_updates_are_transactional(tmp_path):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"
context = mp.get_context("spawn")
start_barrier = context.Barrier(2)
processes = [
context.Process(
target=_record_model_calls_in_process,
args=(str(database_path), str(outbox_directory), 10, start_barrier),
)
for _ in range(2)
]
for process in processes:
process.start()
for process in processes:
process.join(timeout=15)
assert not process.is_alive()
assert process.exitcode == 0
restarted = SharedMetricsStore(database_path, outbox_directory)
assert restarted.counter_snapshot()[0]["value"] == 20
def test_schema_initialization_waits_for_an_existing_writer(tmp_path):
database_path = tmp_path / "metrics.sqlite3"
outbox_directory = tmp_path / "outbox"
database_path.touch()
blocker = sqlite3.connect(database_path)
blocker.execute("BEGIN IMMEDIATE")
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(
SharedMetricsStore,
database_path,
outbox_directory,
)
try:
time.sleep(0.4)
assert not future.done()
finally:
blocker.rollback()
blocker.close()
store = future.result(timeout=2)
assert store.counter_snapshot() == []
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission modes are unavailable")
def test_store_and_export_are_owner_only(tmp_path):
database_path = tmp_path / "private-store" / "metrics.sqlite3"
outbox_directory = tmp_path / "private-outbox"
store = SharedMetricsStore(database_path, outbox_directory)
store.record_model_call(_dimensions(), "test-version")
[package_path] = store.create_and_export_package()
assert stat.S_IMODE(database_path.parent.stat().st_mode) == 0o700
assert stat.S_IMODE(outbox_directory.stat().st_mode) == 0o700
assert stat.S_IMODE(database_path.stat().st_mode) == 0o600
assert stat.S_IMODE(package_path.stat().st_mode) == 0o600

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
"""Tests for shared-metrics configuration discovery and setup."""
from __future__ import annotations
import argparse
from hermes_cli.config import DEFAULT_CONFIG
from hermes_cli.setup import setup_telemetry
from hermes_cli.subcommands.setup import build_setup_parser
def test_shared_metrics_are_registered_disabled_by_default():
assert DEFAULT_CONFIG["telemetry"]["shared_metrics"]["enabled"] is False
def test_setup_telemetry_enables_shared_metrics(monkeypatch):
config = {}
monkeypatch.setattr(
"hermes_cli.setup.prompt_yes_no",
lambda _question, default: not default,
)
setup_telemetry(config)
assert config["telemetry"]["shared_metrics"]["enabled"] is True
def test_setup_parser_accepts_telemetry_section():
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command")
handler = object()
build_setup_parser(subparsers, cmd_setup=handler)
args = parser.parse_args(["setup", "telemetry"])
assert args.section == "telemetry"
assert args.func is handler

File diff suppressed because it is too large Load diff

View file

@ -2445,8 +2445,8 @@ class TestExecuteToolCalls:
hook_calls.append((hook_name, kwargs))
return []
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", _capture_hook)
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
monkeypatch.setattr("hermes_cli.lifecycle.invoke_hook", _capture_hook)
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True)
with (
patch("run_agent.handle_function_call", side_effect=KeyboardInterrupt),
@ -3040,6 +3040,69 @@ class TestConcurrentToolExecution:
assert "real-a" in messages[0]["content"]
assert "real-b" in messages[1]["content"]
def test_concurrent_serializes_post_rewrite_authorization(self, agent, monkeypatch):
tc1 = _mock_tool_call(
name="web_search", arguments='{"q": "a"}', call_id="c1"
)
tc2 = _mock_tool_call(
name="web_search", arguments='{"q": "b"}', call_id="c2"
)
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
messages = []
state_lock = threading.Lock()
active = 0
max_active = 0
def authorize(*_args, **_kwargs):
nonlocal active, max_active
with state_lock:
active += 1
max_active = max(max_active, active)
try:
time.sleep(0.05)
return None
finally:
with state_lock:
active -= 1
monkeypatch.setattr(
"hermes_cli.plugins.resolve_pre_tool_block",
authorize,
)
with patch(
"run_agent.handle_function_call",
side_effect=lambda _name, args, _task_id, **_kwargs: f"result-{args['q']}",
):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
assert max_active == 1
assert [message["tool_call_id"] for message in messages] == ["c1", "c2"]
def test_concurrent_timeout_excludes_authorization_wait(self, agent, monkeypatch):
monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "0.05")
tool_call = _mock_tool_call(
name="web_search", arguments='{"q": "approved"}', call_id="c1"
)
mock_msg = _mock_assistant_msg(content="", tool_calls=[tool_call])
messages = []
def authorize(*_args, **_kwargs):
time.sleep(0.15)
return None
monkeypatch.setattr(
"hermes_cli.plugins.resolve_pre_tool_block",
authorize,
)
with patch("run_agent.handle_function_call", return_value="approved-result"):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
assert len(messages) == 1
assert "approved-result" in messages[0]["content"]
assert "timed out after" not in messages[0]["content"]
def test_concurrent_interrupt_before_start(self, agent):
"""If interrupt is requested before concurrent execution, all tools are skipped."""
tc1 = _mock_tool_call(name="web_search", arguments='{}', call_id="c1")
@ -3107,6 +3170,55 @@ class TestConcurrentToolExecution:
assert starts == [("c1", "web_search", {"query": "hello"})]
assert completes == [("c1", "web_search", {"query": "hello"}, '{"success": true}')]
@pytest.mark.parametrize("quiet_mode", [True, False])
def test_sequential_registry_tool_forwards_request_middleware_trace(
self,
agent,
monkeypatch,
quiet_mode,
):
from hermes_cli.middleware import RequestMiddlewareResult
trace = [{"source": "test-middleware"}]
observed = []
agent.quiet_mode = quiet_mode
tool_call = _mock_tool_call(
name="web_search",
arguments='{"query":"hello"}',
call_id="c1",
)
mock_msg = _mock_assistant_msg(content="", tool_calls=[tool_call])
monkeypatch.setattr(
"hermes_cli.middleware.apply_tool_request_middleware",
lambda _name, args, **_kwargs: RequestMiddlewareResult(
payload=args,
original_payload=args,
changed=True,
trace=trace,
),
)
monkeypatch.setattr(
"hermes_cli.middleware.run_tool_execution_middleware",
lambda _name, args, callback, **_kwargs: callback(args),
)
monkeypatch.setattr(
"hermes_cli.plugins.resolve_pre_tool_block",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(
"agent.tool_executor._begin_tool_execution",
lambda *_args, **_kwargs: None,
)
def handle_function_call(*_args, **kwargs):
observed.append(kwargs)
return '{"success": true}'
with patch("run_agent.handle_function_call", side_effect=handle_function_call):
agent._execute_tool_calls_sequential(mock_msg, [], "task-1")
assert observed[0]["tool_request_middleware_trace"] == trace
def test_sequential_browser_type_callbacks_redact_api_key(self, agent):
secret = "sk-proj-ABCD1234567890EFGH"
tool_call = _mock_tool_call(
@ -3191,10 +3303,10 @@ class TestConcurrentToolExecution:
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
"hermes_cli.lifecycle.invoke_hook",
lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [],
)
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True)
with patch("tools.todo_tool.todo_tool", return_value='{"ok":true}') as mock_todo:
result = agent._invoke_tool("todo", {"todos": []}, "task-1", tool_call_id="todo-1")
@ -3274,10 +3386,10 @@ class TestConcurrentToolExecution:
lambda *args, **kwargs: "Blocked by policy",
)
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
"hermes_cli.lifecycle.invoke_hook",
lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [],
)
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True)
with patch("run_agent.handle_function_call", side_effect=AssertionError("should not run")):
agent._execute_tool_calls_sequential(mock_msg, messages, "task-1")
@ -3301,10 +3413,10 @@ class TestConcurrentToolExecution:
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
"hermes_cli.lifecycle.invoke_hook",
lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [],
)
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True)
with patch("tools.todo_tool.todo_tool", return_value='{"ok":true}') as mock_todo:
agent._execute_tool_calls_sequential(mock_msg, messages, "task-1")
@ -3348,10 +3460,10 @@ class TestConcurrentToolExecution:
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
"hermes_cli.lifecycle.invoke_hook",
lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [],
)
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True)
with patch("tools.todo_tool.todo_tool", return_value='{"ok":true}') as mock_todo:
agent._execute_tool_calls_sequential(mock_msg, messages, "task-1")
@ -3386,10 +3498,10 @@ class TestConcurrentToolExecution:
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
"hermes_cli.lifecycle.invoke_hook",
lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [],
)
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True)
with patch("tools.todo_tool.todo_tool", return_value='{"ok":true}'):
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
@ -3606,134 +3718,212 @@ class TestConcurrentToolExecution:
# Second (allowed) write must checkpoint even though first was blocked.
cp_mock.assert_called_once()
def test_managed_tool_pipeline_rejects_second_dispatch(self, agent, monkeypatch):
from agent import relay_tools, tool_executor
dispatched = []
duplicate_errors = []
monkeypatch.setattr(
"hermes_cli.middleware.apply_tool_request_middleware",
lambda _name, args, **_kwargs: SimpleNamespace(
payload=args,
trace=[],
),
)
monkeypatch.setattr(
"hermes_cli.middleware.run_tool_execution_middleware",
lambda _name, args, callback, **_kwargs: callback(args),
)
monkeypatch.setattr(
"hermes_cli.plugins.resolve_pre_tool_block",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(tool_executor, "_begin_tool_execution", lambda *_a, **_k: None)
def invoke_twice(name, args, callback, **kwargs):
del name, kwargs
result = callback(args)
try:
callback(args)
except RuntimeError as exc:
duplicate_errors.append(str(exc))
return result, args
monkeypatch.setattr(relay_tools, "execute", invoke_twice)
outcome = tool_executor._run_agent_tool_execution_middleware(
agent,
function_name="terminal",
function_args={"command": "true"},
effective_task_id="task-1",
tool_call_id="call-1",
execute=lambda args: dispatched.append(args) or "ok",
)
assert outcome.result == "ok"
assert dispatched == [{"command": "true"}]
assert duplicate_errors == [
"Hermes tool execution callback invoked more than once"
]
assert outcome.blocked is False
def test_managed_tool_pipeline_allows_one_concurrent_dispatch(
self,
agent,
monkeypatch,
):
from agent import relay_tools, tool_executor
dispatched = []
results = []
errors = []
barrier = threading.Barrier(2)
monkeypatch.setattr(
"hermes_cli.middleware.apply_tool_request_middleware",
lambda _name, args, **_kwargs: SimpleNamespace(
payload=args,
trace=[],
),
)
monkeypatch.setattr(
"hermes_cli.middleware.run_tool_execution_middleware",
lambda _name, args, callback, **_kwargs: callback(args),
)
monkeypatch.setattr(
"hermes_cli.plugins.resolve_pre_tool_block",
lambda *_args, **_kwargs: None,
)
monkeypatch.setattr(tool_executor, "_begin_tool_execution", lambda *_a, **_k: None)
def invoke_concurrently(name, args, callback, **kwargs):
del name, kwargs
def invoke():
barrier.wait(timeout=2)
try:
results.append(callback(args))
except RuntimeError as exc:
errors.append(str(exc))
threads = [threading.Thread(target=invoke) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=2)
return results[0], args
monkeypatch.setattr(relay_tools, "execute", invoke_concurrently)
outcome = tool_executor._run_agent_tool_execution_middleware(
agent,
function_name="terminal",
function_args={"command": "true"},
effective_task_id="task-1",
tool_call_id="call-1",
execute=lambda args: dispatched.append(args) or "ok",
)
assert outcome.result == "ok"
assert dispatched == [{"command": "true"}]
assert errors == ["Hermes tool execution callback invoked more than once"]
assert outcome.blocked is False
class TestAgentRuntimePostHookOwnershipSync:
"""Pin the inline-dispatch tool list against the post-hook ownership set.
"""Exercise post-hook ownership through both agent-runtime tool paths."""
The post_tool_call hook fires from two places: the inline dispatcher in
agent/tool_executor.py:execute_tool_calls_sequential (for agent-runtime
tools that never reach handle_function_call) and
model_tools.handle_function_call itself (for registry-dispatched tools).
To prevent the executor from silently dropping or double-emitting,
AGENT_RUNTIME_POST_HOOK_TOOL_NAMES has to match exactly the static
`function_name == "..."` branches in the inline dispatch chain.
_CASES = (
("todo", {"todos": []}),
("session_search", {"query": "needle"}),
("memory", {"action": "view", "target": "memory"}),
("clarify", {"question": "Continue?"}),
("read_terminal", {}),
("delegate_task", {"goal": "Check the child path"}),
)
The chain is the if/elif tower anchored on `_block_msg is not None`.
Pre-dispatch `function_name == "..."` checks (counter resets, checkpoint
triggers) live outside the dispatch chain and are explicitly skipped.
"""
_DISPATCH_ANCHOR_LEFT = "_block_msg"
@classmethod
def _is_dispatch_anchor(cls, test_node) -> bool:
# Looking for `_block_msg is not None`.
if not isinstance(test_node, ast.Compare):
return False
if not (isinstance(test_node.left, ast.Name) and test_node.left.id == cls._DISPATCH_ANCHOR_LEFT):
return False
if not (len(test_node.ops) == 1 and isinstance(test_node.ops[0], ast.IsNot)):
return False
comparator = test_node.comparators[0]
return isinstance(comparator, ast.Constant) and comparator.value is None
@staticmethod
def _function_name_literal(test_node) -> str | None:
"""Return the string literal X for `function_name == "X"`, else None."""
if not isinstance(test_node, ast.Compare):
return None
if not (isinstance(test_node.left, ast.Name) and test_node.left.id == "function_name"):
return None
if not (len(test_node.ops) == 1 and isinstance(test_node.ops[0], ast.Eq)):
return None
comparator = test_node.comparators[0]
if isinstance(comparator, ast.Constant) and isinstance(comparator.value, str):
return comparator.value
return None
@classmethod
def _extract_dispatch_chain_names(cls, func) -> set[str]:
"""Find the if/elif chain anchored on `_block_msg is not None`, return its
`function_name == "..."` literals."""
source = inspect.cleandoc("\n" + inspect.getsource(func))
tree = ast.parse(source)
names: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.If):
continue
if not cls._is_dispatch_anchor(node.test):
continue
current = node
while current is not None:
literal = cls._function_name_literal(current.test)
if literal is not None:
names.add(literal)
if current.orelse and len(current.orelse) == 1 and isinstance(current.orelse[0], ast.If):
current = current.orelse[0]
else:
current = None
break
return names
@classmethod
def _extract_invoke_tool_names(cls, func) -> set[str]:
"""invoke_tool uses a flat if/elif on function_name directly; walk every
Compare in the function body (no other static `function_name == "..."`
checks live there)."""
source = inspect.cleandoc("\n" + inspect.getsource(func))
tree = ast.parse(source)
names: set[str] = set()
for node in ast.walk(tree):
literal = cls._function_name_literal(node)
if literal is not None:
names.add(literal)
return names
def test_frozenset_matches_inline_dispatch_chain(self):
from agent import tool_executor
@pytest.mark.parametrize(("tool_name", "tool_args"), _CASES)
def test_agent_runtime_tools_emit_once_per_executor_path(
self,
agent,
monkeypatch,
tool_name,
tool_args,
):
from agent.agent_runtime_helpers import AGENT_RUNTIME_POST_HOOK_TOOL_NAMES
inline_names = self._extract_dispatch_chain_names(
tool_executor.execute_tool_calls_sequential
hook_calls = []
monkeypatch.setattr(
"hermes_cli.plugins.resolve_pre_tool_block",
lambda *args, **kwargs: None,
)
assert inline_names, (
"Could not find the dispatch chain (anchored on "
"`_block_msg is not None`) in execute_tool_calls_sequential. "
"If the dispatcher was refactored, update _DISPATCH_ANCHOR_LEFT "
"and the walker in this test."
monkeypatch.setattr(
"hermes_cli.lifecycle.invoke_hook",
lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [],
)
assert inline_names == set(AGENT_RUNTIME_POST_HOOK_TOOL_NAMES), (
"Inline dispatch chain in "
"agent/tool_executor.py:execute_tool_calls_sequential has drifted "
"from AGENT_RUNTIME_POST_HOOK_TOOL_NAMES in "
"agent/agent_runtime_helpers.py.\n"
f" Inline branches: {sorted(inline_names)}\n"
f" Ownership frozenset: {sorted(AGENT_RUNTIME_POST_HOOK_TOOL_NAMES)}\n"
"Update both together so post_tool_call fires exactly once per "
"tool execution."
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True)
monkeypatch.setattr(
"tools.todo_tool.todo_tool",
lambda **kwargs: '{"ok":true}',
)
monkeypatch.setattr(
"tools.memory_tool.memory_tool",
lambda **kwargs: '{"ok":true}',
)
monkeypatch.setattr(
"tools.clarify_tool.clarify_tool",
lambda **kwargs: '{"ok":true}',
)
monkeypatch.setattr(
"tools.read_terminal_tool.read_terminal_tool",
lambda **kwargs: '{"ok":true}',
)
monkeypatch.setattr(agent, "_get_session_db_for_recall", lambda: None)
monkeypatch.setattr(
agent,
"_dispatch_delegate_task",
lambda args: '{"ok":true}',
)
agent._memory_manager = None
def test_invoke_tool_dispatch_matches_inline_dispatch_chain(self):
"""invoke_tool (concurrent path) and the inline dispatcher (sequential
path) must cover the same set of agent-runtime tools otherwise
post_tool_call fires inconsistently depending on which executor ran
the tool."""
from agent import agent_runtime_helpers, tool_executor
assert tool_name in AGENT_RUNTIME_POST_HOOK_TOOL_NAMES
with patch(
"run_agent.handle_function_call",
side_effect=AssertionError("agent-runtime tools must stay inline"),
):
agent._invoke_tool(
tool_name,
dict(tool_args),
"task-concurrent",
tool_call_id=f"{tool_name}-concurrent",
)
tool_call = _mock_tool_call(
name=tool_name,
arguments=json.dumps(tool_args),
call_id=f"{tool_name}-sequential",
)
agent._execute_tool_calls_sequential(
_mock_assistant_msg(content="", tool_calls=[tool_call]),
[],
"task-sequential",
)
invoke_tool_names = self._extract_invoke_tool_names(
agent_runtime_helpers.invoke_tool
)
inline_names = self._extract_dispatch_chain_names(
tool_executor.execute_tool_calls_sequential
)
assert invoke_tool_names == inline_names, (
"Static `function_name == \"...\"` branches diverged between "
"agent/agent_runtime_helpers.py:invoke_tool (concurrent path) "
"and agent/tool_executor.py:execute_tool_calls_sequential "
"(sequential path).\n"
f" invoke_tool: {sorted(invoke_tool_names)}\n"
f" execute_tool_calls_sequential: {sorted(inline_names)}"
)
post_calls = [
kwargs
for hook_name, kwargs in hook_calls
if hook_name == "post_tool_call"
]
assert [call["tool_call_id"] for call in post_calls] == [
f"{tool_name}-concurrent",
f"{tool_name}-sequential",
]
assert all(call["tool_name"] == tool_name for call in post_calls)
def test_post_hook_ownership_contract_lists_exercised_tools(self):
from agent.agent_runtime_helpers import AGENT_RUNTIME_POST_HOOK_TOOL_NAMES
assert AGENT_RUNTIME_POST_HOOK_TOOL_NAMES == {
tool_name for tool_name, _ in self._CASES
}
class TestPathsOverlap:
@ -3876,14 +4066,50 @@ class TestHandleMaxIterations:
assert len(result) > 0
assert "summary" in result.lower()
def test_summary_retries_share_relay_identity(self, agent):
agent.client.chat.completions.create.side_effect = [
_mock_response(content=""),
_mock_response(content="Summary"),
]
agent._cached_system_prompt = "You are helpful."
relay_calls = []
def execute_current(request, callback, **kwargs):
relay_calls.append(kwargs)
return callback(request)
with (
patch("agent.relay_llm.execute_current", side_effect=execute_current),
patch("agent.relay_llm.complete_logical_call") as complete_logical,
):
result = agent._handle_max_iterations(
[{"role": "user", "content": "do stuff"}],
60,
)
assert result == "Summary"
assert [call["metadata"]["retry_count"] for call in relay_calls] == [0, 1]
assert relay_calls[0]["metadata"]["api_request_id"] == (
relay_calls[1]["metadata"]["api_request_id"]
)
assert relay_calls[0]["metadata"]["call_role"] == "iteration_summary"
assert all(call["defer_logical_completion"] is True for call in relay_calls)
complete_logical.assert_called_once_with(
relay_calls[0]["metadata"]["api_request_id"],
outcome="success",
)
def test_api_failure_returns_error(self, agent):
agent.client.chat.completions.create.side_effect = Exception("API down")
agent._cached_system_prompt = "You are helpful."
messages = [{"role": "user", "content": "do stuff"}]
result = agent._handle_max_iterations(messages, 60)
with patch("agent.relay_llm.complete_logical_call") as complete_logical:
result = agent._handle_max_iterations(messages, 60)
assert isinstance(result, str)
assert "error" in result.lower()
assert "API down" in result
complete_logical.assert_called_once()
assert complete_logical.call_args.kwargs == {"outcome": "failed"}
def test_summary_skips_reasoning_for_unsupported_openrouter_model(self, agent):
agent.base_url = "https://openrouter.ai/api/v1"
@ -4197,6 +4423,50 @@ class TestRunConversation:
agent.compression_enabled = False
agent.save_trajectories = False
def test_task_start_failure_closes_relay_turn_and_lease(self, agent):
relay_lease = SimpleNamespace(
parent_session_id="",
profile_key="/profile",
session_id=agent.session_id or "",
)
relay_turn = object()
coordinator = MagicMock()
coordinator.acquire_conversation.return_value = relay_lease
coordinator.begin_turn.return_value = relay_turn
start_error = RuntimeError("task metrics start failed")
with (
patch("agent.relay_runtime.SESSION_COORDINATOR", coordinator),
patch(
"agent.relay_runtime.current_profile_key",
return_value="/profile",
),
patch(
"hermes_cli.observability.relay_shared_metrics.start_task_run",
side_effect=start_error,
),
patch(
"hermes_cli.observability.relay_shared_metrics.finish_task_run"
) as finish_task_run,
patch("agent.conversation_loop.run_conversation") as run_conversation,
):
with pytest.raises(RuntimeError) as caught:
agent.run_conversation("hello", task_id="task-1")
assert caught.value is start_error
run_conversation.assert_not_called()
finish_task_run.assert_not_called()
coordinator.finish_logical_calls.assert_called_once_with(
relay_turn,
outcome="failed",
)
coordinator.end_turn.assert_called_once_with(
relay_turn,
outcome="failed",
)
coordinator.release_conversation.assert_called_once_with(relay_lease)
assert agent._relay_pending_turn_id is None
def test_stop_finish_reason_returns_response(self, agent):
self._setup_agent(agent)
resp = _mock_response(content="Final answer", finish_reason="stop")
@ -4280,6 +4550,7 @@ class TestRunConversation:
usage=None,
)
hook_events = []
logical_completions = []
def _fake_activate(reason=None):
agent._fallback_index = len(agent._fallback_chain)
@ -4291,6 +4562,12 @@ class TestRunConversation:
patch.object(agent, "_run_codex_stream", side_effect=[content_filter_response, fallback_response]) as mock_run_codex_stream,
patch.object(agent, "_try_activate_fallback", side_effect=_fake_activate) as mock_try_activate_fallback,
patch.object(agent, "_invoke_api_request_error_hook", side_effect=lambda **kw: hook_events.append(kw)),
patch(
"agent.relay_llm.complete_logical_call",
side_effect=lambda request_id, *, outcome: logical_completions.append(
(request_id, outcome)
),
),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
@ -4304,6 +4581,9 @@ class TestRunConversation:
assert hook_events[0]["error_type"] == "ContentPolicyBlocked"
assert hook_events[0]["retryable"] is False
assert hook_events[0]["reason"] == FailoverReason.content_policy_blocked.value
assert logical_completions == [
(hook_events[0]["api_request_id"], "success")
]
def test_ollama_small_runtime_context_fails_before_api_call(self, agent, caplog):
self._setup_agent(agent)
@ -4383,10 +4663,10 @@ class TestRunConversation:
with (
patch("run_agent.handle_function_call", return_value="search result"),
patch(
"hermes_cli.plugins.has_hook",
"hermes_cli.lifecycle.has_hook",
side_effect=lambda name: name in {"pre_api_request", "post_api_request"},
),
patch("hermes_cli.plugins.invoke_hook", side_effect=_record_hook),
patch("hermes_cli.lifecycle.invoke_hook", side_effect=_record_hook),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
@ -4399,6 +4679,7 @@ class TestRunConversation:
assert len(pre_request_calls) == 2
assert len(post_request_calls) == 2
assert [call["api_call_count"] for call in pre_request_calls] == [1, 2]
assert [call["retry_count"] for call in pre_request_calls] == [0, 0]
assert [call["api_call_count"] for call in post_request_calls] == [1, 2]
assert all(call["session_id"] == agent.session_id for call in pre_request_calls)
assert all(call["turn_id"] == pre_request_calls[0]["turn_id"] for call in pre_request_calls + post_request_calls)
@ -4411,6 +4692,41 @@ class TestRunConversation:
assert all("usage" in c and "response" in c for c in post_request_calls)
assert all("assistant_message" in c["response"] for c in post_request_calls)
def test_terminal_task_closes_logical_calls_before_metrics_scope(self, agent):
from agent import relay_runtime
order = []
failed_result = {
"final_response": "provider failed",
"messages": [],
"completed": False,
"failed": True,
"interrupted": False,
}
with (
patch(
"agent.conversation_loop.run_conversation",
return_value=failed_result,
),
patch(
"hermes_cli.observability.relay_shared_metrics.start_task_run",
),
patch(
"hermes_cli.observability.relay_shared_metrics.finish_task_run",
side_effect=lambda **_kwargs: order.append("metrics"),
),
patch.object(
relay_runtime.SESSION_COORDINATOR,
"finish_logical_calls",
side_effect=lambda *_args, **_kwargs: order.append("logical"),
),
):
result = agent.run_conversation("private prompt")
assert result is failed_result
assert order == ["logical", "metrics"]
def test_api_request_error_hook_skips_payload_work_without_listener(self, agent, monkeypatch):
payload_built = False
hook_called = False
@ -4425,8 +4741,8 @@ class TestRunConversation:
hook_called = True
return []
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: False)
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", _invoke_hook)
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: False)
monkeypatch.setattr("hermes_cli.lifecycle.invoke_hook", _invoke_hook)
monkeypatch.setattr(agent, "_api_request_payload_for_hook", _payload_for_hook)
agent._invoke_api_request_error_hook(
@ -4465,12 +4781,12 @@ class TestRunConversation:
payload_counts["response"] += 1
return {}
monkeypatch.setattr("hermes_cli.plugins.has_hook", _has_hook)
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", _has_hook)
monkeypatch.setattr(agent, "_api_request_payload_for_hook", _request_payload)
monkeypatch.setattr(agent, "_api_response_payload_for_hook", _response_payload)
with (
patch("hermes_cli.plugins.invoke_hook", return_value=[]),
patch("hermes_cli.lifecycle.invoke_hook", return_value=[]),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
@ -6436,6 +6752,50 @@ class TestRetryExhaustion:
assert "Invalid API response" in result["error"]
assert result.get("final_response") == result["error"]
def test_invalid_response_retry_completes_one_logical_call(self, agent):
self._setup_agent(agent)
agent.client.chat.completions.create.side_effect = [
SimpleNamespace(choices=[], model="test/model", usage=None),
_mock_response(content="recovered"),
]
relay_attempts = []
logical_completions = []
def execute(request, callback, **kwargs):
relay_attempts.append(kwargs)
return callback(request)
from agent import conversation_loop as _conv_loop
with (
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
patch("run_agent.time", self._make_fast_time_mock()),
patch.object(_conv_loop, "time", self._make_fast_time_mock()),
patch.object(_conv_loop, "jittered_backoff", lambda *a, **k: 0.0),
patch("agent.relay_llm.execute", side_effect=execute),
patch(
"agent.relay_llm.complete_logical_call",
side_effect=lambda request_id, *, outcome: logical_completions.append(
(request_id, outcome)
),
),
):
result = agent.run_conversation("hello")
assert result["completed"] is True
assert len(relay_attempts) == 2
assert all(
attempt["defer_logical_completion"] is True
for attempt in relay_attempts
)
request_ids = {
attempt["metadata"]["api_request_id"] for attempt in relay_attempts
}
assert len(request_ids) == 1
assert logical_completions == [(request_ids.pop(), "success")]
def test_content_filter_refusal_surfaced_not_retried(self, agent):
"""A model refusal must be surfaced immediately, NOT laundered into
the empty-response retry loop and reported as "rate limited" / "no

View file

@ -137,6 +137,21 @@ class TestSingleWriterLoop:
assert "".join(delivered) == "first"
assert "-stale-tail" not in "".join(delivered)
def test_chat_parser_failure_closes_managed_stream(self):
agent = _make_agent()
managed_stream = MagicMock()
managed_stream.__iter__.return_value = iter([object()])
managed_stream.final_response = None
with patch(
"agent.relay_llm.stream",
return_value=managed_stream,
):
with pytest.raises(AttributeError):
agent._interruptible_streaming_api_call({})
managed_stream.close.assert_called_once()
class TestCodexSingleWriter:
"""The codex_responses path claims the sink and stops when superseded,
@ -208,6 +223,55 @@ class TestCodexSingleWriter:
assert "".join(delivered) == "hello world"
def test_codex_interrupt_closes_stream_without_draining_provider(self):
from agent.codex_runtime import run_codex_stream
agent = _make_agent()
agent.api_mode = "codex_responses"
produced = []
stream_closed = threading.Event()
def interrupt_after_first_delta(_text):
agent._interrupt_requested = True
agent.stream_delta_callback = interrupt_after_first_delta
agent._stream_callback = None
def event_gen():
try:
produced.append("first")
yield self._codex_event(
"response.output_text.delta",
delta="first",
item_id="i1",
)
produced.append("lookahead")
yield self._codex_event(
"response.output_text.delta",
delta="-unused",
item_id="i1",
)
produced.append("terminal")
yield self._codex_event(
"response.completed",
response=SimpleNamespace(
id="r1",
status="completed",
output=[],
usage=None,
),
)
finally:
stream_closed.set()
mock_client = MagicMock()
mock_client.responses.create.return_value = event_gen()
run_codex_stream(agent, {"model": "gpt-5.3-codex"}, client=mock_client)
assert produced == ["first", "lookahead"]
assert stream_closed.is_set()
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-q"]))

View file

@ -96,6 +96,51 @@ class TestStreamingAccumulator:
assert response.usage is not None
assert response.usage.completion_tokens == 3
@patch("run_agent.AIAgent._create_request_openai_client")
@patch("run_agent.AIAgent._close_request_openai_client")
def test_chat_stream_closes_original_provider_resource(
self,
mock_close,
mock_create,
):
from run_agent import AIAgent
class ProviderStream:
def __init__(self):
self.closed = False
def __iter__(self):
return iter([
_make_stream_chunk(
content="Hello",
finish_reason="stop",
model="test-model",
)
])
def close(self):
self.closed = True
provider_stream = ProviderStream()
mock_client = MagicMock()
mock_client.chat.completions.create.return_value = provider_stream
mock_create.return_value = mock_client
agent = AIAgent(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
model="test/model",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
agent.api_mode = "chat_completions"
agent._interrupt_requested = False
response = agent._interruptible_streaming_api_call({})
assert response.choices[0].message.content == "Hello"
assert provider_stream.closed is True
@patch("run_agent.AIAgent._create_request_openai_client")
@patch("run_agent.AIAgent._close_request_openai_client")
def test_native_gemini_endpoint_omits_stream_options(self, mock_close, mock_create):
@ -1266,6 +1311,7 @@ class TestAnthropicStreamCallbacks:
agent._interruptible_streaming_api_call({})
assert touch_calls.count("receiving stream response") == len(events)
mock_stream.close.assert_called_once()
@patch("run_agent.AIAgent._rebuild_anthropic_client")
@patch("run_agent.AIAgent._replace_primary_openai_client")

View file

@ -220,6 +220,109 @@ def test_config_enabled_hard_stop_concurrent_path_does_not_submit_blocked_calls_
assert completed_events[0][1] == "web_search"
def test_relay_rewrite_precedes_sequential_policy_approval_checkpoint_and_dispatch():
agent = _make_agent("write_file")
original_args = {"path": "/original/path", "content": "old"}
final_args = {"path": "/approved/path", "content": "new"}
tc = _mock_tool_call("write_file", json.dumps(original_args), "c-rewrite")
msg = SimpleNamespace(content="", tool_calls=[tc])
messages = []
observed = {
"plugin": [],
"guardrail": [],
"approval": [],
"checkpoint": [],
"start": [],
"dispatch": [],
}
original_before_call = agent._tool_guardrails.before_call
def observe_guardrail(name, args):
observed["guardrail"].append((name, dict(args)))
return original_before_call(name, args)
def relay_execute(name, args, callback, **kwargs):
del name, args, kwargs
return callback(dict(final_args)), dict(final_args)
def observe_plugin(name, args, **kwargs):
del kwargs
observed["plugin"].append((name, dict(args)))
return None
def observe_approval(name, args):
observed["approval"].append((name, dict(args)))
return None
def dispatch(name, args, task_id, **kwargs):
del task_id, kwargs
observed["dispatch"].append((name, dict(args)))
return json.dumps({"ok": True})
agent._checkpoint_mgr = SimpleNamespace(
enabled=True,
get_working_dir_for_path=lambda path: path,
ensure_checkpoint=lambda path, reason: observed["checkpoint"].append(
(path, reason)
),
)
agent.tool_start_callback = lambda _call_id, name, args: observed["start"].append(
(name, dict(args))
)
with (
patch("agent.relay_tools.execute", side_effect=relay_execute),
patch(
"hermes_cli.plugins.resolve_pre_tool_block",
side_effect=observe_plugin,
),
patch.object(agent._tool_guardrails, "before_call", side_effect=observe_guardrail),
patch(
"acp_adapter.edit_approval.maybe_require_edit_approval",
side_effect=observe_approval,
),
patch("model_tools.registry.dispatch", side_effect=dispatch),
):
agent._execute_tool_calls_sequential(msg, messages, "task-1")
expected = [("write_file", final_args)]
assert observed["plugin"] == expected
assert observed["guardrail"] == expected
assert observed["approval"] == expected
assert observed["start"] == expected
assert observed["dispatch"] == expected
assert observed["checkpoint"] == [
("/approved/path", "before write_file")
]
def test_relay_rewrite_is_guarded_before_dispatch_in_concurrent_path():
agent = _make_agent("web_search", config=_hard_stop_config())
original_args = {"query": "original"}
blocked_args = {"query": "blocked"}
_seed_exact_failures(agent, "web_search", blocked_args)
tc = _mock_tool_call("web_search", json.dumps(original_args), "c-rewrite-block")
msg = SimpleNamespace(content="", tool_calls=[tc])
messages = []
starts = []
def relay_execute(name, args, callback, **kwargs):
del name, args, kwargs
return callback(dict(blocked_args)), dict(blocked_args)
agent.tool_start_callback = lambda *args: starts.append(args)
with (
patch("agent.relay_tools.execute", side_effect=relay_execute),
patch("run_agent.handle_function_call", return_value="SHOULD_NOT_RUN") as dispatch,
):
agent._execute_tool_calls_concurrent(msg, messages, "task-1")
dispatch.assert_not_called()
assert starts == []
assert "repeated_exact_failure_block" in messages[0]["content"]
def test_plugin_pre_tool_block_wins_without_counting_as_toolguard_block():
agent = _make_agent("web_search")
args = {"query": "same"}

View file

@ -0,0 +1,41 @@
"""Tests for the shared-metrics smoke artifact."""
from pathlib import Path
import pytest
from scripts import smoke_nemo_relay_shared_metrics as smoke
@pytest.mark.parametrize(
"relative_path",
[
Path(".venv") / "bin" / "hermes",
Path(".venv") / "Scripts" / "hermes.exe",
],
)
def test_resolve_hermes_executable_from_repository_venv(
tmp_path,
monkeypatch,
relative_path,
):
executable = tmp_path / relative_path
executable.parent.mkdir(parents=True)
executable.touch()
monkeypatch.setattr(smoke.shutil, "which", lambda _name: None)
assert smoke._resolve_hermes_executable(tmp_path) == executable
def test_resolve_hermes_executable_falls_back_to_path(tmp_path, monkeypatch):
executable = tmp_path / "bin" / "hermes"
monkeypatch.setattr(smoke.shutil, "which", lambda _name: str(executable))
assert smoke._resolve_hermes_executable(tmp_path / "repo") == executable
def test_resolve_hermes_executable_reports_missing_binary(tmp_path, monkeypatch):
monkeypatch.setattr(smoke.shutil, "which", lambda _name: None)
with pytest.raises(SystemExit, match="or on PATH"):
smoke._resolve_hermes_executable(tmp_path)

View file

@ -326,6 +326,40 @@ class TestPreToolCallBlocking:
assert "post_tool_call" in hook_calls
assert "transform_tool_result" in hook_calls
def test_relay_rewrite_is_visible_to_pre_tool_authorization(self, monkeypatch):
observed = {}
def rewrite(**kwargs):
assert kwargs["tool_name"] == "read_file"
return {**kwargs["args"], "path": "approved.txt"}
def fake_invoke_hook(hook_name, **kwargs):
if hook_name == "pre_tool_call":
observed["pre_tool_args"] = kwargs["args"]
return []
def dispatch(_name, args, **_kwargs):
observed["dispatch_args"] = args
return json.dumps({"ok": True})
monkeypatch.setattr(
"hermes_cli.observability.relay_runtime.apply_tool_request_intercepts",
rewrite,
)
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", fake_invoke_hook)
monkeypatch.setattr("hermes_cli.plugins.has_hook", lambda name: True)
monkeypatch.setattr("model_tools.registry.dispatch", dispatch)
handle_function_call(
"read_file",
{"path": "original.txt"},
task_id="t1",
session_id="s1",
)
assert observed["pre_tool_args"]["path"] == "approved.txt"
assert observed["dispatch_args"]["path"] == "approved.txt"
def test_run_agent_pattern_fires_pre_tool_call_exactly_once(self, monkeypatch):
"""End-to-end regression for the double-fire bug.

View file

@ -3,7 +3,6 @@
from pathlib import Path
import tomllib
def _load_optional_dependencies():
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
with pyproject_path.open("rb") as handle:
@ -11,6 +10,13 @@ def _load_optional_dependencies():
return project["optional-dependencies"]
def _load_package_data():
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
with pyproject_path.open("rb") as handle:
tool = tomllib.load(handle)["tool"]
return tool["setuptools"]["package-data"]
def test_matrix_extra_not_in_all():
"""The [matrix] extra pulls `mautrix[encryption]` -> `python-olm`,
which has Linux-only wheels and no native build path on Windows or
@ -215,14 +221,10 @@ def test_feishu_extra_includes_qrcode_for_qr_login():
assert any(dep.startswith("qrcode") for dep in feishu_extra)
def test_nemo_relay_extra_uses_supported_official_distribution_range():
optional_dependencies = _load_optional_dependencies()
def test_shared_metrics_schema_is_packaged():
package_data = _load_package_data()
assert optional_dependencies["nemo-relay"] == ["nemo-relay>=0.5,<1.0"]
assert not any(
spec == "hermes-agent[nemo-relay]"
for spec in optional_dependencies["all"]
)
assert "observability/schemas/*.json" in package_data["hermes_cli"]
def _uv_lock_version(package: str) -> str:

View file

@ -317,9 +317,15 @@ class TestGatewayCleanupWiring:
class TestDelegationCleanup:
"""Verify subagent delegation cleans up child agents."""
def test_run_single_child_calls_close(self):
def test_run_single_child_calls_close(self, monkeypatch, tmp_path):
"""_run_single_child finally block should call close() on child."""
from unittest.mock import MagicMock
from hermes_constants import (
get_hermes_home,
reset_hermes_home_override,
set_hermes_home_override,
)
from hermes_cli.observability import relay_runtime
from tools.delegate_tool import _run_single_child
parent = MagicMock()
@ -327,18 +333,155 @@ class TestDelegationCleanup:
parent._active_children_lock = threading.Lock()
child = MagicMock()
child.session_id = "child-session"
child._delegate_saved_tool_names = ["tool1"]
child.run_conversation.side_effect = RuntimeError("test abort")
observed = {}
def run_conversation(**_kwargs):
observed["hermes_home"] = get_hermes_home()
raise RuntimeError("test abort")
child.run_conversation.side_effect = run_conversation
relay_host = MagicMock()
monkeypatch.setattr(relay_runtime, "get_runtime", lambda **kwargs: relay_host)
parent._active_children.append(child)
profile_home = tmp_path / "profile-a"
token = set_hermes_home_override(profile_home)
try:
result = _run_single_child(
task_index=0,
goal="test goal",
child=child,
parent_agent=parent,
)
finally:
reset_hermes_home_override(token)
child.close.assert_called_once()
assert observed["hermes_home"] == profile_home
relay_host.unregister_subagent.assert_called_once_with(
{"child_session_id": "child-session"}
)
assert child not in parent._active_children
assert result["status"] == "error"
def test_active_child_turn_owns_relay_scope_cleanup(self, monkeypatch):
from unittest.mock import MagicMock
from hermes_cli.observability import relay_runtime
from tools.delegate_tool import _run_single_child
parent = MagicMock()
parent._active_children = []
parent._active_children_lock = threading.Lock()
child = MagicMock()
child.session_id = "active-child-session"
child._delegate_saved_tool_names = ["tool1"]
child.run_conversation.side_effect = RuntimeError("test abort")
parent._active_children.append(child)
relay_host = MagicMock()
monkeypatch.setattr(relay_runtime, "get_runtime", lambda **kwargs: relay_host)
monkeypatch.setattr(
relay_runtime.SESSION_COORDINATOR,
"has_active_turn",
lambda **_kwargs: True,
)
result = _run_single_child(
task_index=0,
goal="test goal",
goal="test active turn cleanup",
child=child,
parent_agent=parent,
)
child.close.assert_called_once()
assert child not in parent._active_children
assert result["status"] == "error"
relay_host.unregister_subagent.assert_not_called()
def test_timed_out_child_keeps_relay_session_until_its_turn_exits(
self, monkeypatch, tmp_path
):
from unittest.mock import MagicMock
from agent import relay_runtime
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
from tools.delegate_tool import _run_single_child
relay_runtime._reset_for_tests()
profile_home = tmp_path / "profile-timeout"
profile_token = set_hermes_home_override(profile_home)
child_started = threading.Event()
release_child = threading.Event()
child_finished = threading.Event()
parent = MagicMock()
parent._active_children = []
parent._active_children_lock = threading.Lock()
child = MagicMock()
child.session_id = "timed-out-child"
child._delegate_saved_tool_names = ["tool1"]
child.get_activity_summary.return_value = {"api_call_count": 1}
parent._active_children.append(child)
relay_host = MagicMock()
monkeypatch.setattr(relay_runtime, "get_runtime", lambda **_kwargs: relay_host)
monkeypatch.setattr("tools.delegate_tool._get_child_timeout", lambda: 0.1)
def run_conversation(**kwargs):
lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation(
profile_key=relay_runtime.current_profile_key(),
session_id=child.session_id,
platform="subagent",
)
turn = relay_runtime.SESSION_COORDINATOR.begin_turn(
lease,
turn_id="timed-out-child-turn",
task_id=kwargs["task_id"],
)
child_started.set()
try:
release_child.wait(timeout=5)
return {
"final_response": "late result",
"completed": True,
"interrupted": False,
"api_calls": 1,
"messages": [],
}
finally:
relay_runtime.SESSION_COORDINATOR.end_turn(
turn,
outcome="cancelled",
)
relay_runtime.SESSION_COORDINATOR.release_conversation(lease)
child_finished.set()
child.run_conversation.side_effect = run_conversation
try:
result = _run_single_child(
task_index=0,
goal="test timed-out turn cleanup",
child=child,
parent_agent=parent,
)
assert child_started.is_set()
assert result["status"] == "timeout"
assert relay_runtime.SESSION_COORDINATOR.has_active_turn(
profile_key=str(profile_home),
session_id=child.session_id,
)
relay_host.unregister_subagent.assert_not_called()
release_child.set()
assert child_finished.wait(timeout=5)
assert not relay_runtime.SESSION_COORDINATOR.has_active_turn(
profile_key=str(profile_home),
session_id=child.session_id,
)
finally:
release_child.set()
reset_hermes_home_override(profile_token)
relay_runtime._reset_for_tests()

View file

@ -104,7 +104,7 @@ def _fire_approval_hook(hook_name: str, **kwargs) -> None:
pre_approval_request, post_approval_response.
"""
try:
from hermes_cli.plugins import invoke_hook
from hermes_cli.lifecycle import invoke_hook
except Exception:
# Plugin system not available in this execution context
# (e.g. bare tool-only imports, minimal test environments).

View file

@ -18,6 +18,7 @@ never the child's intermediate tool calls or reasoning.
"""
import enum
import contextvars
import json
import logging
@ -1592,7 +1593,7 @@ def _build_child_agent(
logger.debug("spawn_requested relay failed: %s", exc)
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"subagent_start",
parent_session_id=getattr(parent_agent, "session_id", None),
@ -2183,7 +2184,11 @@ def _run_single_child(
stream_callback=_relay_child_text,
)
_child_future = _timeout_executor.submit(_run_with_thread_capture)
_child_context = contextvars.copy_context()
_child_future = _timeout_executor.submit(
_child_context.run,
_run_with_thread_capture,
)
try:
result = _child_future.result(timeout=child_timeout)
except Exception as _timeout_exc:
@ -2586,6 +2591,23 @@ def _run_single_child(
except Exception:
logger.debug("Failed to close child agent after delegation")
# The AIAgent turn boundary normally closes the child scope itself. This
# fallback covers failures before that boundary starts, but must not pop
# a scope while a timed-out child worker is still unwinding.
try:
from agent import relay_runtime
runtime = relay_runtime.get_runtime(create=False)
child_session_id = str(getattr(child, "session_id", "") or "")
child_turn_is_active = relay_runtime.SESSION_COORDINATOR.has_active_turn(
profile_key=relay_runtime.current_profile_key(),
session_id=child_session_id,
)
if runtime is not None and child_session_id and not child_turn_is_active:
runtime.unregister_subagent({"child_session_id": child_session_id})
except Exception:
logger.debug("Failed to close child Relay session after delegation")
_PARENT_FINALIZATION_LOCK_GUARD = threading.Lock()
_PARENT_FINALIZATION_FALLBACK_LOCK = threading.RLock()
@ -2984,7 +3006,9 @@ def delegate_task(
with DaemonThreadPoolExecutor(max_workers=max_children) as executor:
futures = {}
for i, t, child in children:
child_context = contextvars.copy_context()
future = executor.submit(
child_context.run,
_run_single_child,
task_index=i,
goal=t["goal"],

View file

@ -2798,7 +2798,7 @@ def terminal_tool(
# still subject to the final output limit below.
# The hook is fail-open, and the first valid string return wins.
try:
from hermes_cli.plugins import invoke_hook
from hermes_cli.lifecycle import invoke_hook
hook_results = invoke_hook(
"transform_terminal_output",
command=command,

View file

@ -473,13 +473,19 @@ def _notify_session_boundary(
) -> None:
"""Fire session lifecycle hooks with CLI parity."""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import finalize_session, invoke_hook
_invoke_hook(
event_type,
session_id=session_id,
platform=_resolve_agent_platform(platform),
)
if event_type == "on_session_finalize":
finalize_session(
session_id=session_id,
platform=_resolve_agent_platform(platform),
)
else:
invoke_hook(
event_type,
session_id=session_id,
platform=_resolve_agent_platform(platform),
)
except Exception:
pass
@ -658,7 +664,7 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
# the user CtrlC's midturn.
if agent is not None:
try:
from hermes_cli.plugins import invoke_hook
from hermes_cli.lifecycle import invoke_hook
invoke_hook(
"on_session_end",

18
uv.lock generated
View file

@ -1530,6 +1530,7 @@ dependencies = [
{ name = "httpx", extra = ["socks"] },
{ name = "jinja2" },
{ name = "markdown" },
{ name = "nemo-relay", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'ARM64' and sys_platform == 'win32')" },
{ name = "openai" },
{ name = "packaging" },
{ name = "pathspec" },
@ -1665,9 +1666,6 @@ mistral = [
modal = [
{ name = "modal" },
]
nemo-relay = [
{ name = "nemo-relay" },
]
parallel-web = [
{ name = "parallel-web" },
]
@ -1807,7 +1805,7 @@ requires-dist = [
{ name = "microsoft-teams-apps", marker = "extra == 'teams'", specifier = "==2.0.13.4" },
{ name = "mistralai", marker = "extra == 'mistral'", specifier = "==2.4.8" },
{ name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" },
{ name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = ">=0.5,<1.0" },
{ name = "nemo-relay", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'ARM64' and sys_platform == 'win32')", specifier = ">=0.6.0,<0.7" },
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
{ name = "openai", specifier = "==2.24.0" },
{ name = "packaging", specifier = "==26.0" },
@ -2603,14 +2601,14 @@ wheels = [
[[package]]
name = "nemo-relay"
version = "0.5.0"
version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/fc/6892859ffbfb60f8cc09a4181c8d407b574d49490e35cd563e7f0a61bf54/nemo_relay-0.5.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:203409e0f392fc4f40f52277347ed75ba9969b2af28ddb3b6ae53f789185a0ed", size = 7769721, upload-time = "2026-07-08T18:43:57.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/13/25c4fcf6fb979af07c9562238819183588a910c2ae09767603500d6948c2/nemo_relay-0.5.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de61ad0644c5e8c300de814f6884c5de7ae51db14694f5057f938b4da54bfaaf", size = 7037083, upload-time = "2026-07-08T18:44:00.135Z" },
{ url = "https://files.pythonhosted.org/packages/49/97/bd4c77e975d50f7d09f8bfcb74d7e704e05a8b2205f555bf557f84edc5fe/nemo_relay-0.5.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb323569f104506f4f1a0e2104b22d74c9a0bbf3280ec4d58ad3d768de76fb15", size = 7430817, upload-time = "2026-07-08T18:44:02.165Z" },
{ url = "https://files.pythonhosted.org/packages/da/8b/5681f3430e6d25bf7419dbe576da14599e1cfadc083edb74e691a0c3a980/nemo_relay-0.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:da1bd1e1dec79b7bda3984dd8c59b32ba97abf96c37e85cd1f38d208a29f42b9", size = 7359048, upload-time = "2026-07-08T18:44:04.169Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f2/65c3ef0435e671c829063d4872897f9054f369f0aeacab12c5aefe486705/nemo_relay-0.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:87462585f17b40ce82c54347acef5ace96ee11de3015f234e0b902f23b3793fb", size = 6934235, upload-time = "2026-07-08T18:44:06.078Z" },
{ url = "https://files.pythonhosted.org/packages/25/65/d320016505457cc30971f575e8dadffb923b7cfc780ab8bb25a4ce9d305c/nemo_relay-0.6.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:ad5dae6febf6532d7b113abc2a404679c8feffc499df3034b93d9a078185d2bb", size = 9917779, upload-time = "2026-07-22T20:07:48.961Z" },
{ url = "https://files.pythonhosted.org/packages/ae/c0/f33250e71c4206da1b339072893f9a1e39295fe1aceb9a2fef4b8620a0f2/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0cd9570f64c6956fe3bfb82af1cdb3ee70cb50b51098cdb0de831c3f9b4e904", size = 8888375, upload-time = "2026-07-22T20:07:51.049Z" },
{ url = "https://files.pythonhosted.org/packages/a3/f4/d1dfaed022da0f6f14765a122867f976a69cc520fe1faaf99757f5719d1f/nemo_relay-0.6.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:849daa9e45158ac581e54506e0fcc7a24f557d1ed06dbdc074f5de7a00393cbc", size = 9336372, upload-time = "2026-07-22T20:07:53.224Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b0/908d77f75b9054e1e403da78f7d9430249a45939070d4298abbb41a04b5b/nemo_relay-0.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:bfbbedfd130fa95c9b8c04643c30910df850e0ed3500beeab82b00ca2d94e7ea", size = 9613425, upload-time = "2026-07-22T20:07:55.175Z" },
{ url = "https://files.pythonhosted.org/packages/cd/71/c438b9d746303ff7f270d99f13b250bf947cdac3e52de2a83fd132cbca0b/nemo_relay-0.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:82fe132943399d89e6ec34dc28df0be7bbe41b84f6698c545928b8816b6010f6", size = 9034810, upload-time = "2026-07-22T20:07:57.467Z" },
]
[[package]]