mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Reapply "feat(observability): integrate NeMo Relay runtime and shared metrics"
Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
parent
3af7b867fd
commit
14bed44c8c
65 changed files with 14427 additions and 2514 deletions
|
|
@ -2475,7 +2475,8 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||
tool_call_id: Optional[str] = None, messages: list = None,
|
||||
pre_tool_block_checked: bool = False,
|
||||
skip_tool_request_middleware: bool = False,
|
||||
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None) -> str:
|
||||
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
|
||||
skip_tool_execution_middleware: bool = False) -> str:
|
||||
"""Invoke a single tool and return the result string. No display logic.
|
||||
|
||||
Handles both agent-level tools (todo, memory, etc.) and registry-dispatched
|
||||
|
|
@ -2654,8 +2655,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
|
||||
else:
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return _ra().handle_function_call(
|
||||
function_name, next_args, effective_task_id,
|
||||
dispatch_kwargs = dict(
|
||||
tool_call_id=tool_call_id,
|
||||
session_id=agent.session_id or "",
|
||||
turn_id=getattr(agent, "_current_turn_id", "") or "",
|
||||
|
|
@ -2667,6 +2667,17 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
|
|||
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
|
||||
tool_request_middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
if skip_tool_execution_middleware:
|
||||
dispatch_kwargs["skip_tool_execution_middleware"] = True
|
||||
return _ra().handle_function_call(
|
||||
function_name,
|
||||
next_args,
|
||||
effective_task_id,
|
||||
**dispatch_kwargs,
|
||||
)
|
||||
|
||||
if skip_tool_execution_middleware:
|
||||
return _execute(function_args)
|
||||
|
||||
from hermes_cli.middleware import run_tool_execution_middleware
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ Payment / credit exhaustion fallback:
|
|||
|
||||
import contextlib
|
||||
import contextvars
|
||||
import functools
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
|
|
@ -50,9 +51,10 @@ import os
|
|||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path # noqa: F401 — used by test mocks
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the
|
||||
|
|
@ -2492,6 +2494,167 @@ _RUNTIME_MAIN_AUTH_MODE: str = ""
|
|||
_RUNTIME_MAIN_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = (
|
||||
contextvars.ContextVar("auxiliary_runtime_main", default=None)
|
||||
)
|
||||
|
||||
_RELAY_AUX_CALL_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = (
|
||||
contextvars.ContextVar("auxiliary_relay_call", default=None)
|
||||
)
|
||||
|
||||
|
||||
def _relay_auxiliary_call(callback):
|
||||
"""Give every physical retry in one auxiliary call a shared Relay identity."""
|
||||
|
||||
@functools.wraps(callback)
|
||||
def wrapped(*args, **kwargs):
|
||||
task = args[0] if args else kwargs.get("task")
|
||||
token = _RELAY_AUX_CALL_CONTEXT.set({
|
||||
"task": str(task or "unknown"),
|
||||
"request_id": f"aux-{uuid.uuid4().hex}",
|
||||
"attempt_count": 0,
|
||||
"provider": "",
|
||||
"model": "",
|
||||
"api_mode": "chat_completions",
|
||||
})
|
||||
try:
|
||||
return callback(*args, **kwargs)
|
||||
except BaseException:
|
||||
_fail_relay_auxiliary_call()
|
||||
raise
|
||||
finally:
|
||||
_RELAY_AUX_CALL_CONTEXT.reset(token)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _relay_auxiliary_call_async(callback):
|
||||
"""Async counterpart to :func:`_relay_auxiliary_call`."""
|
||||
|
||||
@functools.wraps(callback)
|
||||
async def wrapped(*args, **kwargs):
|
||||
task = args[0] if args else kwargs.get("task")
|
||||
token = _RELAY_AUX_CALL_CONTEXT.set({
|
||||
"task": str(task or "unknown"),
|
||||
"request_id": f"aux-{uuid.uuid4().hex}",
|
||||
"attempt_count": 0,
|
||||
"provider": "",
|
||||
"model": "",
|
||||
"api_mode": "chat_completions",
|
||||
})
|
||||
try:
|
||||
return await callback(*args, **kwargs)
|
||||
except BaseException:
|
||||
_fail_relay_auxiliary_call()
|
||||
raise
|
||||
finally:
|
||||
_RELAY_AUX_CALL_CONTEXT.reset(token)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _set_relay_auxiliary_route(
|
||||
provider: str | None,
|
||||
model: str | None,
|
||||
api_mode: str | None,
|
||||
) -> None:
|
||||
context = _RELAY_AUX_CALL_CONTEXT.get()
|
||||
if context is None:
|
||||
return
|
||||
context["provider"] = str(provider or "auxiliary")
|
||||
context["model"] = str(model or "unknown")
|
||||
context["api_mode"] = str(api_mode or "chat_completions")
|
||||
|
||||
|
||||
def _relay_auxiliary_metadata(
|
||||
*,
|
||||
provider: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
) -> tuple[str, str, dict[str, Any]] | None:
|
||||
context = _RELAY_AUX_CALL_CONTEXT.get()
|
||||
if context is None:
|
||||
return None
|
||||
attempt_count = int(context.get("attempt_count") or 0)
|
||||
context["attempt_count"] = attempt_count + 1
|
||||
provider_name = str(provider or context.get("provider") or "auxiliary")
|
||||
model_name = str(context.get("model") or "unknown")
|
||||
return provider_name, model_name, {
|
||||
"api_mode": str(api_mode or context.get("api_mode") or "chat_completions"),
|
||||
"api_request_id": str(context["request_id"]),
|
||||
"call_role": f"auxiliary:{context['task']}",
|
||||
"retry_count": attempt_count,
|
||||
"auxiliary_task": str(context["task"]),
|
||||
}
|
||||
|
||||
|
||||
def _relay_sync_completion(
|
||||
client: Any,
|
||||
kwargs: dict[str, Any],
|
||||
*,
|
||||
provider: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
create: Callable[[dict[str, Any]], Any] | None = None,
|
||||
) -> Any:
|
||||
callback = create or (lambda request: client.chat.completions.create(**request))
|
||||
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
|
||||
if route is None:
|
||||
return callback(kwargs)
|
||||
provider_name, fallback_model, metadata = route
|
||||
from agent import relay_llm
|
||||
|
||||
return relay_llm.execute_current(
|
||||
kwargs,
|
||||
callback,
|
||||
name=provider_name,
|
||||
model_name=str(kwargs.get("model") or fallback_model),
|
||||
metadata=metadata,
|
||||
defer_logical_completion=True,
|
||||
)
|
||||
|
||||
|
||||
async def _relay_async_completion(
|
||||
client: Any,
|
||||
kwargs: dict[str, Any],
|
||||
*,
|
||||
provider: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
create: Callable[[dict[str, Any]], Any] | None = None,
|
||||
) -> Any:
|
||||
callback = create or (lambda request: client.chat.completions.create(**request))
|
||||
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
|
||||
if route is None:
|
||||
return await callback(kwargs)
|
||||
provider_name, fallback_model, metadata = route
|
||||
from agent import relay_llm
|
||||
|
||||
return await relay_llm.execute_current_async(
|
||||
kwargs,
|
||||
callback,
|
||||
name=provider_name,
|
||||
model_name=str(kwargs.get("model") or fallback_model),
|
||||
metadata=metadata,
|
||||
defer_logical_completion=True,
|
||||
)
|
||||
|
||||
|
||||
def _relay_sync_stream(
|
||||
client: Any,
|
||||
kwargs: dict[str, Any],
|
||||
*,
|
||||
provider: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
) -> Any:
|
||||
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
|
||||
if route is None:
|
||||
return client.chat.completions.create(**kwargs)
|
||||
provider_name, fallback_model, metadata = route
|
||||
from agent import relay_llm
|
||||
|
||||
return relay_llm.stream_current(
|
||||
kwargs,
|
||||
lambda request: client.chat.completions.create(**request),
|
||||
name=provider_name,
|
||||
model_name=str(kwargs.get("model") or fallback_model),
|
||||
finalizer=dict,
|
||||
metadata=metadata,
|
||||
)
|
||||
_RUNTIME_MAIN_COMPAT_SNAPSHOT: Tuple[Any, ...] = ("", "", "", "", "", "")
|
||||
_RUNTIME_MAIN_COMPAT_LOCK = threading.Lock()
|
||||
|
||||
|
|
@ -3801,7 +3964,13 @@ def _retry_same_provider_sync(
|
|||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
return _validate_llm_response(
|
||||
retry_client.chat.completions.create(**retry_kwargs), task,
|
||||
_relay_sync_completion(
|
||||
retry_client,
|
||||
retry_kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
),
|
||||
task,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3866,7 +4035,13 @@ async def _retry_same_provider_async(
|
|||
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
|
||||
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
|
||||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task,
|
||||
await _relay_async_completion(
|
||||
retry_client,
|
||||
retry_kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
),
|
||||
task,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -4071,7 +4246,7 @@ def _call_fallback_candidate_sync(
|
|||
base_url=fb_base, task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
_relay_sync_completion(fb_client, fb_kwargs, provider=fb_label), task)
|
||||
except Exception as fb_err:
|
||||
if not _is_auth_error(fb_err):
|
||||
raise
|
||||
|
|
@ -4088,7 +4263,13 @@ def _call_fallback_candidate_sync(
|
|||
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
_relay_sync_completion(
|
||||
retry_client,
|
||||
retry_kwargs,
|
||||
provider=fb_provider,
|
||||
),
|
||||
task,
|
||||
)
|
||||
except Exception as retry_err:
|
||||
if not _is_auth_error(retry_err):
|
||||
raise
|
||||
|
|
@ -4137,7 +4318,13 @@ async def _call_fallback_candidate_async(
|
|||
base_url=fb_base, task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await fb_client.chat.completions.create(**fb_kwargs), task)
|
||||
await _relay_async_completion(
|
||||
fb_client,
|
||||
fb_kwargs,
|
||||
provider=fb_label,
|
||||
),
|
||||
task,
|
||||
)
|
||||
except Exception as fb_err:
|
||||
if not _is_auth_error(fb_err):
|
||||
raise
|
||||
|
|
@ -4155,7 +4342,13 @@ async def _call_fallback_candidate_async(
|
|||
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await retry_client.chat.completions.create(**retry_kwargs), task)
|
||||
await _relay_async_completion(
|
||||
retry_client,
|
||||
retry_kwargs,
|
||||
provider=fb_provider,
|
||||
),
|
||||
task,
|
||||
)
|
||||
except Exception as retry_err:
|
||||
if not _is_auth_error(retry_err):
|
||||
raise
|
||||
|
|
@ -7270,6 +7463,7 @@ def _validate_llm_response(
|
|||
except (AttributeError, TypeError, IndexError) as exc:
|
||||
recovered = _recover_aux_response_message(response)
|
||||
if recovered is not None:
|
||||
_complete_relay_auxiliary_call()
|
||||
return recovered
|
||||
response_type = type(response).__name__
|
||||
response_preview = str(response)[:120]
|
||||
|
|
@ -7279,9 +7473,34 @@ def _validate_llm_response(
|
|||
f"Expected object with .choices[0].message — check provider "
|
||||
f"adapter or custom endpoint compatibility."
|
||||
) from exc
|
||||
_complete_relay_auxiliary_call()
|
||||
return response
|
||||
|
||||
|
||||
def _complete_relay_auxiliary_call(*, outcome: str = "success") -> None:
|
||||
"""Close one auxiliary logical call after acceptance or terminal failure."""
|
||||
context = _RELAY_AUX_CALL_CONTEXT.get()
|
||||
if context is None:
|
||||
return
|
||||
from agent import relay_llm
|
||||
|
||||
relay_llm.complete_logical_call(
|
||||
str(context.get("request_id") or ""),
|
||||
outcome=outcome,
|
||||
)
|
||||
|
||||
|
||||
def _fail_relay_auxiliary_call() -> None:
|
||||
"""Close a terminally failed call without replacing its original error."""
|
||||
try:
|
||||
_complete_relay_auxiliary_call(outcome="failed")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Relay auxiliary failure finalization failed",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _recover_aux_response_message(response: Any) -> Optional[Any]:
|
||||
"""Synthesize chat-completions shape from Responses-style text fields.
|
||||
|
||||
|
|
@ -7680,6 +7899,7 @@ async def _acreate_with_stream(
|
|||
)
|
||||
|
||||
|
||||
@_relay_auxiliary_call
|
||||
def call_llm(
|
||||
task: str = None,
|
||||
*,
|
||||
|
|
@ -7820,6 +8040,11 @@ def call_llm(
|
|||
f"Run: hermes setup")
|
||||
|
||||
effective_timeout = _effective_aux_timeout(task, timeout)
|
||||
_set_relay_auxiliary_route(
|
||||
resolved_provider,
|
||||
final_model,
|
||||
resolved_api_mode,
|
||||
)
|
||||
|
||||
# Log what we're about to do — makes auxiliary operations visible
|
||||
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
|
||||
|
|
@ -7857,7 +8082,12 @@ def call_llm(
|
|||
kwargs["stream"] = True
|
||||
if stream_options:
|
||||
kwargs["stream_options"] = stream_options
|
||||
return client.chat.completions.create(**kwargs)
|
||||
return _relay_sync_stream(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
)
|
||||
|
||||
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
|
||||
# then payment fallback.
|
||||
|
|
@ -7880,10 +8110,18 @@ def call_llm(
|
|||
# for the transient retry every auxiliary task shares. (PR #16587)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
_create_with_progress(
|
||||
client, kwargs, task,
|
||||
force_stream=_provider_requires_stream(
|
||||
resolved_provider, _base_info or resolved_base_url,
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
create=lambda request: _create_with_progress(
|
||||
client,
|
||||
request,
|
||||
task,
|
||||
force_stream=_provider_requires_stream(
|
||||
resolved_provider, _base_info or resolved_base_url,
|
||||
),
|
||||
),
|
||||
),
|
||||
task,
|
||||
|
|
@ -7919,10 +8157,19 @@ def call_llm(
|
|||
time.sleep(_backoff)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
_create_with_progress(
|
||||
client, kwargs, task,
|
||||
force_stream=_provider_requires_stream(
|
||||
resolved_provider, _base_info or resolved_base_url,
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
create=lambda request: _create_with_progress(
|
||||
client,
|
||||
request,
|
||||
task,
|
||||
force_stream=_provider_requires_stream(
|
||||
resolved_provider,
|
||||
_base_info or resolved_base_url,
|
||||
),
|
||||
),
|
||||
),
|
||||
task)
|
||||
|
|
@ -7942,7 +8189,12 @@ def call_llm(
|
|||
)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**retry_kwargs), task)
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
retry_kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
retry_err_str = str(retry_err)
|
||||
# If retry still fails, fall through to the max_tokens /
|
||||
|
|
@ -7980,7 +8232,12 @@ def call_llm(
|
|||
kwargs.pop("max_completion_tokens", None)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
# If the max_tokens retry also hits a payment or connection
|
||||
# error, fall through to the fallback chain below.
|
||||
|
|
@ -8010,7 +8267,12 @@ def call_llm(
|
|||
kwargs["model"] = healed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
first_err = retry_err
|
||||
|
||||
|
|
@ -8043,7 +8305,12 @@ def call_llm(
|
|||
kwargs["model"] = refreshed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
refreshed_client.chat.completions.create(**kwargs), task)
|
||||
_relay_sync_completion(
|
||||
refreshed_client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
if not (
|
||||
_is_auth_error(retry_err)
|
||||
|
|
@ -8071,7 +8338,12 @@ def call_llm(
|
|||
if refreshed_model and refreshed_model != kwargs.get("model"):
|
||||
kwargs["model"] = refreshed_model
|
||||
return _validate_llm_response(
|
||||
refreshed_client.chat.completions.create(**kwargs), task)
|
||||
_relay_sync_completion(
|
||||
refreshed_client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
|
||||
# ── Auth refresh retry ───────────────────────────────────────
|
||||
auth_refresh_provider = _auth_refresh_provider_for_route(
|
||||
|
|
@ -8121,7 +8393,12 @@ def call_llm(
|
|||
if _is_rate_limit_error(first_err) and not _is_payment_error(first_err):
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
client.chat.completions.create(**kwargs), task)
|
||||
_relay_sync_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)):
|
||||
raise
|
||||
|
|
@ -8379,6 +8656,7 @@ def extract_content_or_reasoning(response) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
@_relay_auxiliary_call_async
|
||||
async def async_call_llm(
|
||||
task: str = None,
|
||||
*,
|
||||
|
|
@ -8470,6 +8748,11 @@ async def async_call_llm(
|
|||
f"Run: hermes setup")
|
||||
|
||||
effective_timeout = _effective_aux_timeout(task, timeout)
|
||||
_set_relay_auxiliary_route(
|
||||
resolved_provider,
|
||||
final_model,
|
||||
resolved_api_mode,
|
||||
)
|
||||
|
||||
# Pass the client's actual base_url (not just resolved_base_url) so
|
||||
# endpoint-specific temperature overrides can distinguish
|
||||
|
|
@ -8508,7 +8791,14 @@ async def async_call_llm(
|
|||
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await _acreate(kwargs), task,
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
create=_acreate,
|
||||
),
|
||||
task,
|
||||
provider=resolved_provider, base_url=_client_base)
|
||||
except Exception as transient_err:
|
||||
if not _is_transient_transport_error(transient_err):
|
||||
|
|
@ -8529,7 +8819,14 @@ async def async_call_llm(
|
|||
task or "call", transient_err,
|
||||
)
|
||||
return _validate_llm_response(
|
||||
await _acreate(kwargs), task)
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
create=_acreate,
|
||||
),
|
||||
task)
|
||||
except Exception as first_err:
|
||||
if "temperature" in kwargs and _is_unsupported_temperature_error(first_err):
|
||||
retry_kwargs = dict(kwargs)
|
||||
|
|
@ -8540,7 +8837,12 @@ async def async_call_llm(
|
|||
)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**retry_kwargs), task)
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
retry_kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
retry_err_str = str(retry_err)
|
||||
if not (
|
||||
|
|
@ -8574,7 +8876,12 @@ async def async_call_llm(
|
|||
kwargs.pop("max_completion_tokens", None)
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
# If the max_tokens retry also hits a payment or connection
|
||||
# error, fall through to the fallback chain below.
|
||||
|
|
@ -8603,7 +8910,12 @@ async def async_call_llm(
|
|||
kwargs["model"] = healed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
first_err = retry_err
|
||||
|
||||
|
|
@ -8635,7 +8947,12 @@ async def async_call_llm(
|
|||
kwargs["model"] = refreshed_model
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await refreshed_client.chat.completions.create(**kwargs), task)
|
||||
await _relay_async_completion(
|
||||
refreshed_client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
if not (
|
||||
_is_auth_error(retry_err)
|
||||
|
|
@ -8662,7 +8979,12 @@ async def async_call_llm(
|
|||
if refreshed_model and refreshed_model != kwargs.get("model"):
|
||||
kwargs["model"] = refreshed_model
|
||||
return _validate_llm_response(
|
||||
await refreshed_client.chat.completions.create(**kwargs), task)
|
||||
await _relay_async_completion(
|
||||
refreshed_client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
|
||||
# ── Auth refresh retry (mirrors sync call_llm) ───────────────
|
||||
auth_refresh_provider = _auth_refresh_provider_for_route(
|
||||
|
|
@ -8706,7 +9028,12 @@ async def async_call_llm(
|
|||
if _is_rate_limit_error(first_err) and not _is_payment_error(first_err):
|
||||
try:
|
||||
return _validate_llm_response(
|
||||
await client.chat.completions.create(**kwargs), task)
|
||||
await _relay_async_completion(
|
||||
client,
|
||||
kwargs,
|
||||
provider=resolved_provider,
|
||||
api_mode=resolved_api_mode,
|
||||
), task)
|
||||
except Exception as retry_err:
|
||||
if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)):
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ sites unchanged. Symbols that tests patch on ``run_agent`` (e.g.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
|
|
@ -57,6 +58,12 @@ _OPENROUTER_PROVIDER_SORT_VALUES = {"throughput", "latency", "price"}
|
|||
_FALLBACK_EXHAUSTED_COOLDOWN_S = 5.0
|
||||
|
||||
|
||||
def _context_thread_target(callback):
|
||||
"""Bind a no-argument thread target to the caller's ContextVars."""
|
||||
context = contextvars.copy_context()
|
||||
return lambda: context.run(callback)
|
||||
|
||||
|
||||
def _ra():
|
||||
"""Lazy ``run_agent`` reference.
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
1108
agent/relay_llm.py
Normal file
File diff suppressed because it is too large
Load diff
991
agent/relay_runtime.py
Normal file
991
agent/relay_runtime.py
Normal 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
123
agent/relay_tools.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Core NeMo Relay adapter for Hermes tool execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from agent import relay_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def execute(
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
callback: Callable[[dict[str, Any]], Any],
|
||||
*,
|
||||
session_id: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> tuple[Any, dict[str, Any]]:
|
||||
"""Run one tool call through Relay and return its final arguments."""
|
||||
runtime, session, parent = relay_runtime.resolve_execution_context(session_id)
|
||||
if runtime is None or session is None or not runtime.managed_execution_enabled():
|
||||
return callback(args), args
|
||||
|
||||
observed_args = args
|
||||
raw_result: dict[str, Any] = {}
|
||||
callback_error: BaseException | None = None
|
||||
callback_context = contextvars.copy_context()
|
||||
|
||||
def invoke(next_args: Any) -> Any:
|
||||
nonlocal callback_error, observed_args
|
||||
observed_args = next_args if isinstance(next_args, dict) else args
|
||||
try:
|
||||
result = callback_context.copy().run(callback, observed_args)
|
||||
except BaseException as exc:
|
||||
callback_error = exc
|
||||
raise
|
||||
raw_result["value"] = result
|
||||
raw_result["json"] = _jsonable(result)
|
||||
return raw_result["json"]
|
||||
|
||||
try:
|
||||
managed = _run_awaitable(
|
||||
runtime.run_in_session_async(
|
||||
session,
|
||||
runtime.relay.tools.execute,
|
||||
tool_name,
|
||||
_jsonable(args),
|
||||
invoke,
|
||||
handle=parent,
|
||||
metadata=_jsonable(metadata or {}),
|
||||
)
|
||||
)
|
||||
except BaseException as exc:
|
||||
if (
|
||||
callback_error is not None
|
||||
and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error)
|
||||
):
|
||||
raise callback_error
|
||||
if (
|
||||
isinstance(exc, Exception)
|
||||
and callback_error is None
|
||||
and "value" in raw_result
|
||||
):
|
||||
logger.warning(
|
||||
"NeMo Relay tool post-processing failed after dispatch success; "
|
||||
"returning the Hermes tool result",
|
||||
exc_info=True,
|
||||
)
|
||||
return raw_result["value"], observed_args
|
||||
raise
|
||||
|
||||
if "value" in raw_result and _json_equal(managed, raw_result["json"]):
|
||||
return raw_result["value"], observed_args
|
||||
if isinstance(managed, str):
|
||||
return managed, observed_args
|
||||
return json.dumps(_jsonable(managed), ensure_ascii=False), observed_args
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_jsonable(item) for item in value]
|
||||
model_dump = getattr(value, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
try:
|
||||
return _jsonable(model_dump(mode="json"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return _jsonable(vars(value))
|
||||
except (TypeError, AttributeError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def _json_equal(left: Any, right: Any) -> bool:
|
||||
try:
|
||||
return json.dumps(
|
||||
_jsonable(left), sort_keys=True, separators=(",", ":")
|
||||
) == json.dumps(_jsonable(right), sort_keys=True, separators=(",", ":"))
|
||||
except (TypeError, ValueError):
|
||||
return left == right
|
||||
|
||||
|
||||
def _run_awaitable(value: Any) -> Any:
|
||||
if not inspect.isawaitable(value):
|
||||
return value
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(value)
|
||||
raise RuntimeError(
|
||||
"Synchronous Hermes Relay tool execution cannot run on an active event-loop thread"
|
||||
)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
31
cli.py
|
|
@ -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,
|
||||
|
|
|
|||
1
contributors/emails/afournier@nvidia.com
Normal file
1
contributors/emails/afournier@nvidia.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
afourniernv
|
||||
|
|
@ -14,6 +14,10 @@ Behavior-changing request or execution wrappers are outside this observer
|
|||
contract. Observer hooks should report what happened; they should not replace
|
||||
provider requests, tool arguments, or execution callbacks.
|
||||
|
||||
Hermes also has a first-party NeMo Relay shared-metrics path. It uses these
|
||||
lifecycle boundaries directly and does not require enabling an observability
|
||||
plugin. See [Relay shared metrics](relay-shared-metrics.md).
|
||||
|
||||
## Contract
|
||||
|
||||
Plugins register observer callbacks from `register(ctx)`:
|
||||
|
|
|
|||
127
docs/observability/relay-shared-metrics.md
Normal file
127
docs/observability/relay-shared-metrics.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# NeMo Relay Shared Metrics
|
||||
|
||||
Hermes includes NeMo Relay as a normal runtime dependency on platforms for
|
||||
which Relay publishes a native wheel. The shared-metrics integration is built
|
||||
into Hermes and does not require `hermes plugins enable
|
||||
observability/nemo_relay`. Hermes remains importable without Relay on other
|
||||
native targets. Those targets use an explicit reduced-capability no-op host:
|
||||
Hermes execution remains available, while Relay scopes, middleware, plugins,
|
||||
and subscribers are unavailable. The `hermes-agent[nemo-relay]` extra remains
|
||||
as a no-op compatibility alias for existing installation commands.
|
||||
|
||||
Hermes requires NeMo Relay 0.6.0 or later within the 0.6 release line. That
|
||||
release establishes the lossless provider-codec contract used for Anthropic
|
||||
Messages, OpenAI Chat Completions, and OpenAI Responses requests.
|
||||
|
||||
## Runtime Dependency and Data Boundary
|
||||
|
||||
Hermes installs the platform-specific `nemo-relay` native wheel from the
|
||||
bounded `>=0.6.0,<0.7` dependency range. The published package is built from
|
||||
the [NVIDIA NeMo Relay repository](https://github.com/NVIDIA/NeMo-Relay).
|
||||
Unsupported platforms use the explicit no-op runtime described above rather
|
||||
than downloading a different implementation.
|
||||
|
||||
When Relay managed execution is active, the provider request and response pass
|
||||
through that native module in the Hermes process so configured interceptors can
|
||||
operate on the real call. This is separate from the shared-metrics data
|
||||
contract. Shared-metrics mode installs no network exporter and its subscriber
|
||||
accepts only the versioned, allowlisted projection described below. Enabling a
|
||||
separately configured rich-observability or dynamic plugin can create a
|
||||
different data path and requires its own policy review.
|
||||
|
||||
Collection remains off unless Hermes policy enables it:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
shared_metrics:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
This choice is read from the profile's own `config.yaml`. A machine-managed
|
||||
configuration overlay cannot enable or disable shared metrics on the profile's
|
||||
behalf.
|
||||
|
||||
The existing `observability/nemo_relay` plugin remains separate. Enable that
|
||||
plugin only for its opt-in rich observability exporters, adaptive execution,
|
||||
or dynamic Relay plugins.
|
||||
|
||||
Hermes core owns one Relay host and one isolated Relay session scope per Hermes
|
||||
session. Core lifecycle producers use
|
||||
`hermes_cli.observability.relay_runtime` to obtain the shared session handle or
|
||||
run Relay scope, LLM, tool, and mark APIs in that session context. New product
|
||||
marks do not require Hermes plugin registration. Shared-metrics marks must
|
||||
still contain only fields approved by the versioned allowlist; the hard
|
||||
dependency does not change the collection or privacy policy.
|
||||
|
||||
## Current Slices
|
||||
|
||||
The current vertical slices record logical model calls and top-level task runs:
|
||||
|
||||
```text
|
||||
Hermes turn, API, and tool hooks
|
||||
-> Relay session, task, and LLM lifecycle
|
||||
-> Hermes shared-metrics subscriber
|
||||
-> SQLite counters
|
||||
-> immutable JSON delta package
|
||||
```
|
||||
|
||||
Hermes sends an empty `LLMRequest` into the metrics-owned lifecycle. This does
|
||||
not describe the separate managed-execution call through the native runtime
|
||||
documented above. The terminal metrics event contains only bounded model
|
||||
family, provider family, locality, call role, and outcome values. Prompts,
|
||||
responses, exact model IDs, endpoints, errors, session IDs, task IDs, and
|
||||
request IDs are not included in the metrics event or package.
|
||||
|
||||
Each task run is a Relay `Function` scope named `hermes.task_run`, parented to
|
||||
the owning Hermes session. The start counter contains only bounded execution
|
||||
surface and entrypoint values. The terminal counter contains bounded outcome,
|
||||
end reason, termination status, duration, logical model-call count, terminal
|
||||
tool-call count, and provider-retry count buckets. Retries are additional
|
||||
provider attempts for the same Hermes API request ID; they do not inflate the
|
||||
logical model-call count. Tool calls are deduplicated by their Hermes tool-call
|
||||
ID after a terminal tool result is observed. The outer `AIAgent` execution
|
||||
boundary closes the task for normal returns, early returns, exceptions, and
|
||||
cancellations. Active task ownership follows the task ID if Hermes rotates its
|
||||
conversation session during context compression.
|
||||
|
||||
Local state is written under:
|
||||
|
||||
```text
|
||||
$HERMES_HOME/telemetry/shared_metrics/metrics.sqlite3
|
||||
$HERMES_HOME/telemetry/shared_metrics/outbox/*.json
|
||||
```
|
||||
|
||||
The database keeps transactional aggregate and package-outbox state. Package
|
||||
files are immutable delta documents that conform to a closed JSON schema and
|
||||
are written with atomic replacement. Fully packaged aggregate rows and
|
||||
successfully exported package rows and files are retained locally for 30 days.
|
||||
Pending package rows and counters with unexported deltas are never pruned.
|
||||
|
||||
Each package contains an `install_id` generated as a random UUID. Despite the
|
||||
schema field name, its current scope is one `HERMES_HOME`, so it is more
|
||||
precisely a persistent pseudonymous profile identifier. It is not derived from
|
||||
hardware, account, host, path, or credential data. It remains stable across
|
||||
packages from that profile and can therefore link those local packages.
|
||||
Deleting `$HERMES_HOME/telemetry/shared_metrics` resets the identifier together
|
||||
with all aggregates and package files.
|
||||
|
||||
This slice has no remote-delivery path. A future remote exporter must not reuse
|
||||
the persistent local identifier by default. It requires a separate product and
|
||||
privacy decision covering consent, identity scope, rotation or keyed
|
||||
pseudonymization, reset behavior, retention, and deletion.
|
||||
|
||||
## Smoke Test
|
||||
|
||||
Run a real Hermes CLI turn against the deterministic local model server:
|
||||
|
||||
```bash
|
||||
./.venv/bin/python scripts/smoke_nemo_relay_shared_metrics.py
|
||||
```
|
||||
|
||||
The script uses the installed `nemo-relay` dependency by default. Pass
|
||||
`--relay-python ../nemo-relay/python` only when testing a locally built Relay
|
||||
binding.
|
||||
|
||||
The smoke verifies the model request reached the local server, model and task
|
||||
counters were stored, one package was exported, and prompt, response, and
|
||||
exact-model canaries are absent from the package.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -149,7 +149,17 @@ _DEFAULT_PAYLOADS = {
|
|||
"changed_paths": ["src/app.tsx"],
|
||||
},
|
||||
"on_session_start": {"session_id": "test-session"},
|
||||
"on_session_end": {"session_id": "test-session"},
|
||||
"on_session_end": {
|
||||
"session_id": "test-session",
|
||||
"task_id": "test-task",
|
||||
"turn_id": "test-turn",
|
||||
"completed": True,
|
||||
"failed": False,
|
||||
"interrupted": False,
|
||||
"turn_exit_reason": "text_response(stop)",
|
||||
"model": "gpt-4",
|
||||
"platform": "cli",
|
||||
},
|
||||
"on_session_finalize": {"session_id": "test-session"},
|
||||
"on_session_reset": {"session_id": "test-session"},
|
||||
"pre_api_request": {
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ def _fire_kanban_lifecycle_hook(event: str, task_id: str, **fields: Any) -> None
|
|||
it through.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook
|
||||
from hermes_cli.lifecycle import invoke_hook
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
try:
|
||||
profile_name = get_active_profile_name()
|
||||
|
|
|
|||
63
hermes_cli/lifecycle.py
Normal file
63
hermes_cli/lifecycle.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Hermes lifecycle dispatch for first-party observers and plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
|
||||
"""Notify first-party observers, then invoke compatibility plugin hooks."""
|
||||
try:
|
||||
from hermes_cli.observability import observe_lifecycle
|
||||
|
||||
observe_lifecycle(hook_name, **kwargs)
|
||||
except Exception:
|
||||
logger.warning("Built-in observability hook failed", exc_info=True)
|
||||
|
||||
from hermes_cli import plugins
|
||||
|
||||
return plugins.invoke_hook(hook_name, **kwargs)
|
||||
|
||||
|
||||
def has_hook(hook_name: str) -> bool:
|
||||
"""Return whether a first-party observer or plugin consumes a hook."""
|
||||
try:
|
||||
from hermes_cli.observability import handles_hook
|
||||
|
||||
if handles_hook(hook_name):
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("Unable to inspect built-in observability hooks", exc_info=True)
|
||||
|
||||
from hermes_cli import plugins
|
||||
|
||||
return plugins.has_hook(hook_name)
|
||||
|
||||
|
||||
def finalize_session(**kwargs: Any) -> List[Any]:
|
||||
"""Notify observers and hard-close one core-owned Relay conversation."""
|
||||
try:
|
||||
from hermes_cli.observability import observe_lifecycle
|
||||
|
||||
observe_lifecycle("on_session_finalize", **kwargs)
|
||||
except Exception:
|
||||
logger.warning("Built-in observability hook failed", exc_info=True)
|
||||
|
||||
session_id = str(kwargs.get("session_id") or "")
|
||||
if session_id:
|
||||
try:
|
||||
from agent import relay_runtime
|
||||
|
||||
relay_runtime.SESSION_COORDINATOR.finalize_conversation(
|
||||
profile_key=relay_runtime.current_profile_key(),
|
||||
session_id=session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Core Relay session finalization failed", exc_info=True)
|
||||
|
||||
from hermes_cli import plugins
|
||||
|
||||
return plugins.invoke_hook("on_session_finalize", **kwargs)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
31
hermes_cli/observability/__init__.py
Normal file
31
hermes_cli/observability/__init__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""First-party Hermes observability integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def observe_lifecycle(hook_name: str, **kwargs: Any) -> None:
|
||||
"""Dispatch a Hermes lifecycle event to built-in observability features."""
|
||||
from . import relay_shared_metrics
|
||||
|
||||
_safe_observe(relay_shared_metrics.observe_lifecycle, hook_name, kwargs)
|
||||
|
||||
|
||||
def handles_hook(hook_name: str) -> bool:
|
||||
"""Return whether any built-in observability feature handles a hook."""
|
||||
from . import relay_shared_metrics
|
||||
|
||||
return relay_shared_metrics.handles_hook(hook_name)
|
||||
|
||||
|
||||
def _safe_observe(callback: Any, hook_name: str, kwargs: dict[str, Any]) -> None:
|
||||
try:
|
||||
callback(hook_name, **kwargs)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Built-in observability hook failed: %s", hook_name, exc_info=True
|
||||
)
|
||||
14
hermes_cli/observability/relay_runtime.py
Normal file
14
hermes_cli/observability/relay_runtime.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"""Compatibility alias for the core Hermes Relay runtime.
|
||||
|
||||
New code should import :mod:`agent.relay_runtime`. This module remains an
|
||||
alias, rather than a copy, so existing plugins and tests share the same
|
||||
profile registry and test-reset state during the migration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from agent import relay_runtime as _core_relay_runtime
|
||||
|
||||
sys.modules[__name__] = _core_relay_runtime
|
||||
817
hermes_cli/observability/relay_shared_metrics.py
Normal file
817
hermes_cli/observability/relay_shared_metrics.py
Normal 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()
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
486
hermes_cli/observability/shared_metrics.py
Normal file
486
hermes_cli/observability/shared_metrics.py
Normal 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,),
|
||||
)
|
||||
516
hermes_cli/observability/shared_metrics_contract.py
Normal file
516
hermes_cli/observability/shared_metrics_contract.py
Normal 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"
|
||||
67
hermes_cli/observability/shared_metrics_subscriber.py
Normal file
67
hermes_cli/observability/shared_metrics_subscriber.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
157
run_agent.py
157
run_agent.py
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
443
scripts/smoke_nemo_relay_shared_metrics.py
Normal file
443
scripts/smoke_nemo_relay_shared_metrics.py
Normal 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())
|
||||
445
tests/agent/test_auxiliary_relay.py
Normal file
445
tests/agent/test_auxiliary_relay.py
Normal 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 == {}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
1498
tests/agent/test_relay_llm.py
Normal file
1498
tests/agent/test_relay_llm.py
Normal file
File diff suppressed because it is too large
Load diff
262
tests/agent/test_relay_tools.py
Normal file
262
tests/agent/test_relay_tools.py
Normal 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",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
60
tests/hermes_cli/test_lifecycle.py
Normal file
60
tests/hermes_cli/test_lifecycle.py
Normal 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}]
|
||||
|
|
@ -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 = []
|
||||
|
||||
|
|
|
|||
839
tests/hermes_cli/test_relay_shared_metrics.py
Normal file
839
tests/hermes_cli/test_relay_shared_metrics.py
Normal 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
|
||||
2403
tests/hermes_cli/test_relay_shared_metrics_runtime.py
Normal file
2403
tests/hermes_cli/test_relay_shared_metrics_runtime.py
Normal file
File diff suppressed because it is too large
Load diff
37
tests/hermes_cli/test_setup_telemetry.py
Normal file
37
tests/hermes_cli/test_setup_telemetry.py
Normal 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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
41
tests/scripts/test_smoke_nemo_relay_shared_metrics.py
Normal file
41
tests/scripts/test_smoke_nemo_relay_shared_metrics.py
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 Ctrl‑C's mid‑turn.
|
||||
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
18
uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue