From 3bd338d2a9e572e85c77fbfff57b5d319d72c83f Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 08:54:27 -0400 Subject: [PATCH 001/526] feat(observability): add Relay shared metrics pipeline Signed-off-by: Alex Fournier --- plugins/observability/nemo_relay/README.md | 74 +- plugins/observability/nemo_relay/__init__.py | 689 ++++++++++++++---- .../hermes.shared_metrics.v1.schema.json | 153 ++++ .../nemo_relay/shared_metrics.py | 376 ++++++++++ .../nemo_relay/shared_metrics_contract.py | 251 +++++++ .../nemo_relay/shared_metrics_subscriber.py | 31 + pyproject.toml | 3 +- scripts/smoke_nemo_relay_shared_metrics.py | 379 ++++++++++ tests/plugins/test_nemo_relay_plugin.py | 609 ++++++++++++++-- .../plugins/test_nemo_relay_shared_metrics.py | 462 ++++++++++++ uv.lock | 2 +- 11 files changed, 2824 insertions(+), 205 deletions(-) create mode 100644 plugins/observability/nemo_relay/schemas/hermes.shared_metrics.v1.schema.json create mode 100644 plugins/observability/nemo_relay/shared_metrics.py create mode 100644 plugins/observability/nemo_relay/shared_metrics_contract.py create mode 100644 plugins/observability/nemo_relay/shared_metrics_subscriber.py create mode 100644 scripts/smoke_nemo_relay_shared_metrics.py create mode 100644 tests/plugins/test_nemo_relay_shared_metrics.py diff --git a/plugins/observability/nemo_relay/README.md b/plugins/observability/nemo_relay/README.md index a1b90c4da4f..60db027072e 100644 --- a/plugins/observability/nemo_relay/README.md +++ b/plugins/observability/nemo_relay/README.md @@ -84,17 +84,79 @@ wheel from this checkout, then install the official NeMo Relay runtime extra: ```bash uv build --wheel python -m pip install --force-reinstall dist/hermes_agent-*.whl -python -m pip install "nemo-relay>=0.5,<1.0" +python -m pip install "nemo-relay>=0.5.0,<0.6.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: +NeMo Relay 0.5.x distribution: ```bash -pip install "nemo-relay>=0.5,<1.0" +pip install "nemo-relay>=0.5.0,<0.6.0" ``` +## Shared Metrics Proof Mode + +The Phase 1 telemetry proof adds a separately gated metrics-only mode. Enable +the bundled plugin and the mode in the same `config.yaml`: + +```yaml +plugins: + enabled: + - observability/nemo_relay + entries: + observability/nemo_relay: + shared_metrics: + enabled: true +``` + +The proof reads this setting when the plugin runtime initializes. Restart a +long-running Hermes agent or gateway after changing it. Live revocation and +local-state reset controls are follow-on requirements before production +rollout. + +This first vertical slice maps Hermes's existing API-request hooks to one Relay +LLM lifecycle per logical model call and aggregates terminal primary calls into +`hermes.model_call.count`. Retries sharing an API request ID remain one logical +call. The counter uses bounded provider family, model family, locality, and +outcome dimensions; raw model IDs and request or response content are never +included in the metrics event. + +The subscriber stores cumulative counters and a random opaque install ID in +`$HERMES_HOME/telemetry/shared_metrics/metrics.sqlite3`. After Relay's flush +barrier, Hermes commits un-packaged deltas to an immutable outbox record and +atomically writes a `hermes.shared_metrics.v1` JSON package under +`$HERMES_HOME/telemetry/shared_metrics/outbox/`. Repeating export without new +model calls reuses pending package IDs and does not package a count twice. The +proof mode does not perform network I/O. Task, tool, approval, and skill metrics +remain follow-on slices. The closed package contract ships with the plugin at +`schemas/hermes.shared_metrics.v1.schema.json`. + +When shared metrics are enabled without a separate rich-observability +configuration, the plugin does not emit its existing content-bearing turn, +model, tool, approval, or subagent events. ATOF, ATIF, adaptive components, and +dynamic plugins remain separate explicit configuration choices. + +### End-to-End Smoke Test + +The repository includes a deterministic smoke runner that exercises the real +Hermes CLI and native NeMo Relay binding without external model credentials. It +starts a loopback OpenAI-compatible model server, creates an isolated +`HERMES_HOME`, runs one `hermes chat` turn, and validates the resulting SQLite +counter and schema-conformant JSON package: + +```bash +./.venv/bin/python scripts/smoke_nemo_relay_shared_metrics.py \ + --relay-python ../nemo-relay/python +``` + +The NeMo Relay Python binding must already be built. By default, the runner +expects the Hermes checkout as its working directory and looks for a sibling +`nemo-relay` checkout. Use `--hermes-repo`, `--relay-python`, or `--output-dir` +to override those locations. The generated profile, captured CLI output, +SQLite database, and immutable package are retained in the artifact directory +printed after a successful run. + ## Export Configuration The plugin can configure exporters directly from `HERMES_NEMO_RELAY_*` @@ -253,12 +315,12 @@ For the full generic Hermes middleware contract, see ## 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 +The observe-only examples in this section use a supported NeMo Relay 0.5.x +distribution and a local Ollama model served through the OpenAI-compatible API. ```bash -pip install "nemo-relay>=0.5,<1.0" +pip install "nemo-relay>=0.5.0,<0.6.0" export HERMES_HOME=/tmp/hermes-nemo-relay-docs/hermes-home mkdir -p "$HERMES_HOME" diff --git a/plugins/observability/nemo_relay/__init__.py b/plugins/observability/nemo_relay/__init__.py index 56c6140ca58..895320b1883 100644 --- a/plugins/observability/nemo_relay/__init__.py +++ b/plugins/observability/nemo_relay/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import atexit import asyncio +import contextvars import inspect import json import logging @@ -12,9 +13,23 @@ import threading import tomllib from collections.abc import Callable from dataclasses import dataclass, field +from functools import wraps from pathlib import Path from typing import Any, Optional +from .shared_metrics import SharedMetricsStore +from .shared_metrics_contract import ( + MODEL_CALL_SCOPE as _SHARED_METRICS_MODEL_CALL_SCOPE, + SCHEMA_KEY as _SHARED_METRICS_SCHEMA_KEY, + SCHEMA_VERSION as _SHARED_METRICS_SCHEMA_VERSION, + SESSION_SCOPE as _SHARED_METRICS_SESSION_SCOPE, + SUBSCRIBER_NAME as _SHARED_METRICS_SUBSCRIBER_NAME, + execution_surface as _execution_surface, + model_call_fields as _model_call_fields, + model_call_outcome as _model_call_outcome, +) +from .shared_metrics_subscriber import SharedMetricsSubscriber + logger = logging.getLogger(__name__) _INIT_FAILED = object() @@ -30,13 +45,27 @@ _RELAY_LLM_SURFACE_BY_API_MODE = { @dataclass class _SessionState: session_id: str + lock: threading.RLock = field(default_factory=threading.RLock, repr=False) + closing: bool = False handle: Any = None + metrics_handle: Any = None + metrics_context: contextvars.Context | None = None atif_exporter: Any = None atif_subscriber_name: str = "" is_embedded_subagent: bool = False parent_session_id: str = "" llm_spans: dict[str, Any] = field(default_factory=dict) tool_spans: dict[str, Any] = field(default_factory=dict) + metrics_model_calls: dict[str, "_SharedMetricsModelCall"] = field( + default_factory=dict + ) + + +@dataclass +class _SharedMetricsModelCall: + handle: Any + task_id: str + fields: dict[str, str] @dataclass @@ -48,6 +77,7 @@ class _SubagentParent: @dataclass class _Settings: + shared_metrics_enabled: bool = False plugins_toml_path: str = "" plugins_config: dict[str, Any] | None = None dynamic_plugins: list[dict[str, Any]] = field(default_factory=list) @@ -66,21 +96,125 @@ class _Settings: atif_model_name: str = "unknown" +def _with_session_state_lock(method: Callable[..., Any]) -> Callable[..., Any]: + """Serialize one session without blocking unrelated sessions.""" + + @wraps(method) + def wrapped( + self: "_Runtime", + event: dict[str, Any], + *args: Any, + **kwargs: Any, + ) -> Any: + with self._state_lock: + state = self.sessions.get(_session_id(event)) + if state is None: + return None + with state.lock: + if state.closing: + return None + return method(self, event, state, *args, **kwargs) + + return wrapped + + +def _with_ensured_session_lock(method: Callable[..., Any]) -> Callable[..., Any]: + """Create a missing session, then serialize work scoped to it.""" + + @wraps(method) + def wrapped( + self: "_Runtime", + event: dict[str, Any], + *args: Any, + **kwargs: Any, + ) -> Any: + state = self.ensure_session(event) + with state.lock: + if state.closing: + return None + return method(self, event, state, *args, **kwargs) + + return wrapped + + class _Runtime: def __init__(self, nemo_relay: Any, settings: _Settings) -> None: self.nemo_relay = nemo_relay self.settings = settings + self._state_lock = threading.RLock() + self._plugin_lifecycle_lock = threading.RLock() self.sessions: dict[str, _SessionState] = {} self.subagent_parents: dict[str, _SubagentParent] = {} self.atof_exporter: Any = None self._atof_subscriber_name = "hermes.nemo_relay.atof" self._plugin_activation: Any = None self._shutdown_registered = False + self.shared_metrics: SharedMetricsSubscriber | None = None + if settings.shared_metrics_enabled: + try: + from hermes_cli import __version__ + + self.shared_metrics = SharedMetricsSubscriber( + SharedMetricsStore(), + __version__, + ) + except Exception: + logger.warning( + "NeMo Relay shared metrics disabled: local store initialization failed", + exc_info=True, + ) + self._shared_metrics_registered = False + self._configure_shared_metrics() self._plugin_config_initialized = self._configure_plugins_toml() self._plugin_config_needs_reinit = False if not self._plugin_config_initialized: self._activate_direct_fallbacks() + def _configure_shared_metrics(self) -> None: + if self.shared_metrics is None: + return + subscribers = getattr(self.nemo_relay, "subscribers", None) + register = getattr(subscribers, "register", None) + if not callable(register): + logger.warning( + "NeMo Relay shared metrics disabled: subscriber registration is unavailable" + ) + self.shared_metrics = None + return + try: + register(_SHARED_METRICS_SUBSCRIBER_NAME, self.shared_metrics) + except Exception as exc: + logger.warning( + "NeMo Relay shared metrics subscriber registration failed: %s", exc + ) + self.shared_metrics = None + return + self._shared_metrics_registered = True + self._ensure_shutdown_registered() + + def export_shared_metrics(self) -> list[Path]: + """Commit and export model-call deltas after Relay subscriber flush.""" + if self.shared_metrics is None: + return [] + try: + return self.shared_metrics.store.create_and_export_package() + except Exception: + logger.warning("Hermes shared-metrics package export failed", exc_info=True) + return [] + + def rich_observability_enabled(self) -> bool: + if not self.settings.shared_metrics_enabled: + return True + return bool( + self.settings.atof_enabled + or self.settings.atif_enabled + or _enabled_component_config( + self.settings.plugins_config, + "observability", + ) + is not None + ) + def _configure_plugins_toml(self) -> bool: if not self.settings.plugins_config: return False @@ -150,7 +284,9 @@ class _Runtime: # before its awaitable resolves, including error results. self._plugin_activation = None self._plugin_config_initialized = False - self._plugin_config_needs_reinit = bool(self.settings.plugins_config) + self._plugin_config_needs_reinit = bool( + self.settings.plugins_config + ) else: failures.append("dynamic plugin activation has no close method") else: @@ -234,71 +370,268 @@ class _Runtime: self.atof_exporter = None def ensure_session(self, kwargs: dict[str, Any]) -> _SessionState: - self._maybe_reinitialize_plugins_toml() - session_id = _session_id(kwargs) - state = self.sessions.get(session_id) - if state is not None: - return state + with self._plugin_lifecycle_lock: + self._maybe_reinitialize_plugins_toml() + with self._state_lock: + session_id = _session_id(kwargs) + state = self.sessions.get(session_id) + if state is not None: + self._ensure_shared_metrics_session(state, kwargs) + return state - state = _SessionState(session_id=session_id) - if self.settings.atif_enabled and not self._plugins_toml_owns_exporter("atif"): - state.atif_exporter = self.nemo_relay.AtifExporter( - session_id, - self.settings.atif_agent_name, - self.settings.atif_agent_version, - model_name=str(kwargs.get("model") or self.settings.atif_model_name), - extra={"source": "hermes-agent", "plugin": "observability/nemo_relay"}, + state = _SessionState(session_id=session_id) + if self.rich_observability_enabled(): + if ( + self.settings.atif_enabled + and not self._plugins_toml_owns_exporter("atif") + ): + state.atif_exporter = self.nemo_relay.AtifExporter( + session_id, + self.settings.atif_agent_name, + self.settings.atif_agent_version, + model_name=str( + kwargs.get("model") or self.settings.atif_model_name + ), + extra={ + "source": "hermes-agent", + "plugin": "observability/nemo_relay", + }, + ) + state.atif_subscriber_name = ( + f"hermes.nemo_relay.atif.{session_id}" + ) + state.atif_exporter.register(state.atif_subscriber_name) + + subagent_parent = self.subagent_parents.get(session_id) + metadata = _metadata(kwargs) + parent_handle = None + if subagent_parent is not None: + parent_handle = subagent_parent.parent_handle + metadata = {**metadata, **subagent_parent.metadata} + state.is_embedded_subagent = True + state.parent_session_id = subagent_parent.parent_session_id + + state.handle = self.nemo_relay.scope.push( + f"hermes-session-{session_id}", + self.nemo_relay.ScopeType.Agent, + handle=parent_handle, + data={"session_id": session_id}, + metadata=metadata, + ) + + self._ensure_shared_metrics_session(state, kwargs) + self.sessions[session_id] = state + return state + + def _ensure_shared_metrics_session( + self, + state: _SessionState, + kwargs: dict[str, Any], + ) -> None: + if ( + state.closing + or not self._shared_metrics_registered + or state.metrics_handle is not None + ): + return + metrics_context = contextvars.Context() + try: + state.metrics_handle = metrics_context.run( + self.nemo_relay.scope.push, + _SHARED_METRICS_SESSION_SCOPE, + self.nemo_relay.ScopeType.Agent, + input={"execution_surface": _execution_surface(kwargs)}, + metadata={_SHARED_METRICS_SCHEMA_KEY: _SHARED_METRICS_SCHEMA_VERSION}, ) - state.atif_subscriber_name = f"hermes.nemo_relay.atif.{session_id}" - state.atif_exporter.register(state.atif_subscriber_name) + except Exception: + logger.warning( + "NeMo Relay shared-metrics session start failed", exc_info=True + ) + return + state.metrics_context = metrics_context - subagent_parent = self.subagent_parents.get(session_id) - metadata = _metadata(kwargs) - parent_handle = None - if subagent_parent is not None: - parent_handle = subagent_parent.parent_handle - metadata = {**metadata, **subagent_parent.metadata} - state.is_embedded_subagent = True - state.parent_session_id = subagent_parent.parent_session_id + def _run_in_metrics_context( + self, + state: _SessionState, + callback: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> Any: + """Run a native lifecycle call against the isolated metrics stack.""" + if state.metrics_context is None: + raise RuntimeError("shared-metrics scope context is unavailable") - state.handle = self.nemo_relay.scope.push( - f"hermes-session-{session_id}", - self.nemo_relay.ScopeType.Agent, - handle=parent_handle, - data={"session_id": session_id}, - metadata=metadata, + def invoke() -> Any: + # Relay's manual LLM helpers call the native API directly. Re-sync + # this Context's stack before entering them so scope-local policy + # cannot leak in from the rich-observability stack on this thread. + self.nemo_relay.get_scope_stack() + return callback(*args, **kwargs) + + return state.metrics_context.run(invoke) + + @_with_ensured_session_lock + def start_model_call(self, kwargs: dict[str, Any], state: _SessionState) -> None: + if not self._shared_metrics_registered or state.metrics_handle is None: + return + request_id = str(kwargs.get("api_request_id") or "") + if not request_id: + return + fields = _model_call_fields(kwargs) + model_family = fields.pop("model_family") + existing = state.metrics_model_calls.get(request_id) + if existing is not None: + # A logical call can span retries or provider fallback. Attribute its + # terminal provider fields to the most recent attempt without opening + # a second Relay lifecycle. Relay's model_name remains the logical + # model family recorded when the lifecycle started. + existing.fields = fields + return + request = self.nemo_relay.LLMRequest({}, {}) + try: + handle = self._run_in_metrics_context( + state, + self.nemo_relay.llm.call, + _SHARED_METRICS_MODEL_CALL_SCOPE, + request, + handle=state.metrics_handle, + metadata={_SHARED_METRICS_SCHEMA_KEY: _SHARED_METRICS_SCHEMA_VERSION}, + model_name=model_family, + ) + except Exception: + logger.warning( + "NeMo Relay shared-metrics model-call start failed", exc_info=True + ) + return + state.metrics_model_calls[request_id] = _SharedMetricsModelCall( + handle=handle, + task_id=str(kwargs.get("task_id") or ""), + fields=fields, ) - self.sessions[session_id] = state - return state + + def _finish_model_call( + self, + state: _SessionState, + request_id: str, + outcome: str, + ) -> None: + model_call = state.metrics_model_calls.pop(request_id, None) + if model_call is None: + return + try: + self._run_in_metrics_context( + state, + self.nemo_relay.llm.call_end, + model_call.handle, + {**model_call.fields, "outcome": outcome}, + metadata={_SHARED_METRICS_SCHEMA_KEY: _SHARED_METRICS_SCHEMA_VERSION}, + ) + except Exception: + logger.warning( + "NeMo Relay shared-metrics model-call end failed", exc_info=True + ) + + @_with_session_state_lock + def end_model_call(self, kwargs: dict[str, Any], state: _SessionState) -> None: + request_id = str(kwargs.get("api_request_id") or "") + model_call = state.metrics_model_calls.get(request_id) + if model_call is None: + return + fields = _model_call_fields(kwargs) + fields.pop("model_family") + model_call.fields = fields + self._finish_model_call(state, request_id, _model_call_outcome(kwargs)) + + @_with_session_state_lock + def end_pending_model_calls( + self, + kwargs: dict[str, Any], + state: _SessionState, + ) -> None: + self._end_pending_model_calls(state, kwargs) + + def _end_pending_model_calls( + self, + state: _SessionState, + kwargs: dict[str, Any], + ) -> None: + task_id = str(kwargs.get("task_id") or "") + request_ids = [ + request_id + for request_id, model_call in state.metrics_model_calls.items() + if not task_id or model_call.task_id == task_id + ] + outcome = "cancelled" if kwargs.get("interrupted") else "failed" + for request_id in request_ids: + self._finish_model_call(state, request_id, outcome) def export_atif(self, state: _SessionState) -> None: if not self.settings.atif_enabled or state.atif_exporter is None: return - if state.is_embedded_subagent and self.settings.atif_subagent_export_mode != "all": + if ( + state.is_embedded_subagent + and self.settings.atif_subagent_export_mode != "all" + ): return output_dir = self.settings.atif_output_directory if not output_dir: return Path(output_dir).mkdir(parents=True, exist_ok=True) - filename = self.settings.atif_filename_template.format(session_id=state.session_id) - Path(output_dir, filename).write_text(state.atif_exporter.export_json(), encoding="utf-8") + filename = self.settings.atif_filename_template.format( + session_id=state.session_id + ) + Path(output_dir, filename).write_text( + state.atif_exporter.export_json(), encoding="utf-8" + ) + + def _clear_static_plugins_after_last_session(self) -> None: + """Clear static plugin state without holding the session-map lock.""" + with self._plugin_lifecycle_lock: + with self._state_lock: + if self.sessions or self._plugin_activation is not None: + return + should_clear = self._plugin_config_initialized + if not should_clear and self.settings.plugins_config: + self._plugin_config_needs_reinit = True + if should_clear: + self._clear_plugins_toml() def close_session(self, kwargs: dict[str, Any]) -> None: session_id = _session_id(kwargs) - self.subagent_parents.pop(session_id, None) - state = self.sessions.pop(session_id, None) + with self._state_lock: + self.subagent_parents.pop(session_id, None) + state = self.sessions.get(session_id) if state is None: return failures: list[str] = [] - if state.handle is not None: - try: - self.nemo_relay.scope.pop(state.handle, output=_jsonable(kwargs)) - except Exception as exc: - failures.append(f"session scope pop failed: {exc}") + with state.lock: + if state.closing: + return + state.closing = True + self._end_pending_model_calls(state, {"session_id": session_id}) + if state.metrics_handle is not None and state.metrics_context is not None: + try: + self._run_in_metrics_context( + state, + self.nemo_relay.scope.pop, + state.metrics_handle, + output={}, + metadata={ + _SHARED_METRICS_SCHEMA_KEY: _SHARED_METRICS_SCHEMA_VERSION + }, + ) + except Exception as exc: + failures.append(f"shared-metrics session scope pop failed: {exc}") + if state.handle is not None: + try: + self.nemo_relay.scope.pop(state.handle, output=_jsonable(kwargs)) + except Exception as exc: + failures.append(f"session scope pop failed: {exc}") try: _flush_relay_subscribers(self.nemo_relay) except Exception as exc: failures.append(f"subscriber flush failed: {exc}") + self.export_shared_metrics() try: self.export_atif(state) except Exception as exc: @@ -308,21 +641,13 @@ class _Runtime: state.atif_exporter.deregister(state.atif_subscriber_name) except Exception as exc: failures.append(f"ATIF deregister failed: {exc}") - if ( - self._plugin_config_initialized - and self._plugin_activation is None - and not self.sessions - ): - try: - self._clear_plugins_toml() - except Exception as exc: - failures.append(f"plugin configuration clear failed: {exc}") - elif ( - self.settings.plugins_config - and self._plugin_activation is None - and not self.sessions - ): - self._plugin_config_needs_reinit = True + with self._state_lock: + if self.sessions.get(session_id) is state: + self.sessions.pop(session_id, None) + try: + self._clear_static_plugins_after_last_session() + except Exception as exc: + failures.append(f"plugin configuration clear failed: {exc}") if failures: logger.warning( "NeMo Relay session %s teardown completed with errors: %s", @@ -333,17 +658,38 @@ class _Runtime: def shutdown(self) -> None: """Close active sessions and the process-lifetime plugin activation.""" failures: list[str] = [] - for session_id in list(self.sessions): + with self._state_lock: + session_ids = list(self.sessions) + for session_id in session_ids: try: - self.close_session({"session_id": session_id, "reason": "runtime_shutdown"}) + self.close_session({ + "session_id": session_id, + "reason": "runtime_shutdown", + }) except Exception as exc: failures.append(f"session {session_id} close failed: {exc}") - if self._plugin_config_initialized: - try: - self._clear_plugins_toml() - except Exception as exc: - failures.append(f"plugin runtime close failed: {exc}") + with self._plugin_lifecycle_lock: + if self._plugin_config_initialized: + try: + self._clear_plugins_toml() + except Exception as exc: + failures.append(f"plugin runtime close failed: {exc}") self._clear_atof() + if self._shared_metrics_registered: + try: + _flush_relay_subscribers(self.nemo_relay) + except Exception as exc: + failures.append(f"shared-metrics subscriber flush failed: {exc}") + self.export_shared_metrics() + try: + subscribers = getattr(self.nemo_relay, "subscribers", None) + deregister = getattr(subscribers, "deregister", None) + if callable(deregister): + deregister(_SHARED_METRICS_SUBSCRIBER_NAME) + except Exception as exc: + failures.append(f"shared-metrics subscriber deregister failed: {exc}") + finally: + self._shared_metrics_registered = False if self._shutdown_registered and self._plugin_activation is None: atexit.unregister(self.shutdown) self._shutdown_registered = False @@ -388,14 +734,17 @@ class _Runtime: def managed_llm_enabled(self) -> bool: return ( (self.settings.adaptive_enabled or self._plugin_activation is not None) - and callable(getattr(getattr(self.nemo_relay, "llm", None), "execute", None)) + and callable( + getattr(getattr(self.nemo_relay, "llm", None), "execute", None) + ) and callable(getattr(self.nemo_relay, "LLMRequest", None)) ) def managed_tool_enabled(self) -> bool: return ( - (self.settings.adaptive_enabled or self._plugin_activation is not None) - and callable(getattr(getattr(self.nemo_relay, "tools", None), "execute", None)) + self.settings.adaptive_enabled or self._plugin_activation is not None + ) and callable( + getattr(getattr(self.nemo_relay, "tools", None), "execute", None) ) def _run_managed_with_downstream_preservation( @@ -433,7 +782,9 @@ class _Runtime: try: managed_result = _resolve_awaitable(make_managed_execute(_impl)) except Exception as exc: - if downstream_error is not None and _is_relay_wrapped_callback_error(exc, callback_error): + if downstream_error is not None and _is_relay_wrapped_callback_error( + exc, callback_error + ): raise downstream_error raise if ( @@ -463,14 +814,12 @@ class _Runtime: request, impl, handle=state.handle, - data=_jsonable( - { - "turn_id": kwargs.get("turn_id"), - "api_request_id": kwargs.get("api_request_id"), - "api_call_count": kwargs.get("api_call_count"), - "mode": self.settings.adaptive_mode, - } - ), + data=_jsonable({ + "turn_id": kwargs.get("turn_id"), + "api_request_id": kwargs.get("api_request_id"), + "api_call_count": kwargs.get("api_call_count"), + "mode": self.settings.adaptive_mode, + }), metadata=_metadata(kwargs), model_name=str(kwargs.get("model") or ""), ) @@ -481,7 +830,11 @@ class _Runtime: return _managed_execute() return self._run_managed_with_downstream_preservation( - next_call, _normalize, _llm_response_payload, _make_managed, preserve_raw_response=True + next_call, + _normalize, + _llm_response_payload, + _make_managed, + preserve_raw_response=True, ) def execute_tool(self, kwargs: dict[str, Any]) -> Any: @@ -502,14 +855,12 @@ class _Runtime: args, impl, handle=state.handle, - data=_jsonable( - { - "turn_id": kwargs.get("turn_id"), - "api_request_id": kwargs.get("api_request_id"), - "tool_call_id": kwargs.get("tool_call_id"), - "mode": self.settings.adaptive_mode, - } - ), + data=_jsonable({ + "turn_id": kwargs.get("turn_id"), + "api_request_id": kwargs.get("api_request_id"), + "tool_call_id": kwargs.get("tool_call_id"), + "mode": self.settings.adaptive_mode, + }), metadata=_metadata(kwargs), ) if inspect.isawaitable(result): @@ -555,8 +906,16 @@ def on_session_start(**kwargs: Any) -> None: def on_session_end(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is not None: - _safe(lambda: (runtime.mark("hermes.session.end", kwargs), runtime.export_atif(runtime.ensure_session(kwargs)))) + if runtime is None: + return + _safe(lambda: runtime.end_pending_model_calls(kwargs)) + if runtime.rich_observability_enabled(): + _safe( + lambda: ( + runtime.mark("hermes.session.end", kwargs), + runtime.export_atif(runtime.ensure_session(kwargs)), + ) + ) def on_session_finalize(**kwargs: Any) -> None: @@ -573,13 +932,13 @@ def on_session_reset(**kwargs: Any) -> None: def on_pre_llm_call(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is not None: + if runtime is not None and runtime.rich_observability_enabled(): _safe(lambda: runtime.mark("hermes.turn.start", kwargs)) def on_post_llm_call(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is not None: + if runtime is not None and runtime.rich_observability_enabled(): _safe(lambda: runtime.mark("hermes.turn.end", kwargs)) @@ -587,19 +946,27 @@ def on_pre_api_request(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return + _safe(lambda: runtime.start_model_call(kwargs)) + if not runtime.rich_observability_enabled(): + return if runtime.managed_llm_enabled(): return def _record() -> None: state = runtime.ensure_session(kwargs) request_payload = kwargs.get("request") - request_body = request_payload.get("body") if isinstance(request_payload, dict) else {} + request_body = ( + request_payload.get("body") if isinstance(request_payload, dict) else {} + ) request = runtime.nemo_relay.LLMRequest({}, _jsonable(request_body)) span = runtime.nemo_relay.llm.call( str(kwargs.get("provider") or "llm"), request, handle=state.handle, - data=_jsonable({"turn_id": kwargs.get("turn_id"), "api_request_id": kwargs.get("api_request_id")}), + data=_jsonable({ + "turn_id": kwargs.get("turn_id"), + "api_request_id": kwargs.get("api_request_id"), + }), metadata=_metadata(kwargs), model_name=str(kwargs.get("model") or ""), ) @@ -612,6 +979,9 @@ def on_post_api_request(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return + _safe(lambda: runtime.end_model_call({**kwargs, "outcome": "success"})) + if not runtime.rich_observability_enabled(): + return if runtime.managed_llm_enabled(): return @@ -624,7 +994,10 @@ def on_post_api_request(**kwargs: Any) -> None: runtime.nemo_relay.llm.call_end( span, _jsonable(kwargs.get("response") or {}), - data=_jsonable({"usage": kwargs.get("usage"), "finish_reason": kwargs.get("finish_reason")}), + data=_jsonable({ + "usage": kwargs.get("usage"), + "finish_reason": kwargs.get("finish_reason"), + }), metadata=_metadata(kwargs), ) @@ -633,7 +1006,7 @@ def on_post_api_request(**kwargs: Any) -> None: def on_api_request_error(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is None: + if runtime is None or not runtime.rich_observability_enabled(): return if runtime.managed_llm_enabled(): return @@ -658,6 +1031,8 @@ def on_pre_tool_call(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return + if not runtime.rich_observability_enabled(): + return if runtime.managed_tool_enabled(): return @@ -667,7 +1042,10 @@ def on_pre_tool_call(**kwargs: Any) -> None: str(kwargs.get("tool_name") or "tool"), _jsonable(kwargs.get("args") or {}), handle=state.handle, - data=_jsonable({"turn_id": kwargs.get("turn_id"), "api_request_id": kwargs.get("api_request_id")}), + data=_jsonable({ + "turn_id": kwargs.get("turn_id"), + "api_request_id": kwargs.get("api_request_id"), + }), metadata=_metadata(kwargs), tool_call_id=str(kwargs.get("tool_call_id") or ""), ) @@ -680,6 +1058,8 @@ def on_post_tool_call(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return + if not runtime.rich_observability_enabled(): + return if runtime.managed_tool_enabled(): return @@ -692,7 +1072,10 @@ def on_post_tool_call(**kwargs: Any) -> None: runtime.nemo_relay.tools.call_end( span, _jsonable(kwargs.get("result")), - data=_jsonable({"status": kwargs.get("status"), "duration_ms": kwargs.get("duration_ms")}), + data=_jsonable({ + "status": kwargs.get("status"), + "duration_ms": kwargs.get("duration_ms"), + }), metadata=_metadata(kwargs), ) @@ -701,25 +1084,29 @@ def on_post_tool_call(**kwargs: Any) -> None: def on_pre_approval_request(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is not None: + if runtime is None: + return + if runtime.rich_observability_enabled(): _safe(lambda: runtime.mark("hermes.approval.request", kwargs)) def on_post_approval_response(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is not None: + if runtime is None: + return + if runtime.rich_observability_enabled(): _safe(lambda: runtime.mark("hermes.approval.response", kwargs)) def on_subagent_start(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is not None: + if runtime is not None and runtime.rich_observability_enabled(): _safe(lambda: runtime.mark_subagent_start(kwargs)) def on_subagent_stop(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is not None: + if runtime is not None and runtime.rich_observability_enabled(): _safe(lambda: runtime.mark_subagent_stop(kwargs)) @@ -761,7 +1148,9 @@ def _get_runtime() -> Optional[_Runtime]: try: _RUNTIME = _Runtime(nemo_relay=nemo_runtime, settings=_load_settings()) except Exception as exc: - logger.debug("NeMo Relay plugin disabled: init failed: %s", exc, exc_info=True) + logger.debug( + "NeMo Relay plugin disabled: init failed: %s", exc, exc_info=True + ) _RUNTIME = _INIT_FAILED return None return _RUNTIME @@ -772,6 +1161,7 @@ def _load_settings() -> _Settings: plugins_config = _load_plugins_config(plugins_toml_path) adaptive_config = _enabled_component_config(plugins_config, "adaptive") return _Settings( + shared_metrics_enabled=_shared_metrics_enabled(), plugins_toml_path=plugins_toml_path, plugins_config=plugins_config, dynamic_plugins=_dynamic_plugin_specs(plugins_config, plugins_toml_path), @@ -783,7 +1173,8 @@ def _load_settings() -> _Settings: atof_mode=_env("HERMES_NEMO_RELAY_ATOF_MODE") or "append", atif_enabled=_env_bool("HERMES_NEMO_RELAY_ATIF_ENABLED"), atif_output_directory=_env("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY"), - atif_filename_template=_env("HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE") or "hermes-atif-{session_id}.json", + atif_filename_template=_env("HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE") + or "hermes-atif-{session_id}.json", atif_subagent_export_mode=_atif_subagent_export_mode(), atif_agent_name=_env("HERMES_NEMO_RELAY_ATIF_AGENT_NAME") or "Hermes Agent", atif_agent_version=_env("HERMES_NEMO_RELAY_ATIF_AGENT_VERSION") or "unknown", @@ -791,6 +1182,31 @@ def _load_settings() -> _Settings: ) +def _shared_metrics_enabled() -> bool: + try: + from hermes_cli.config import load_config_readonly + + config = load_config_readonly() or {} + except Exception: + logger.debug( + "Unable to read Hermes shared-metrics configuration", exc_info=True + ) + return False + if not isinstance(config, dict): + return False + plugins = config.get("plugins") + if not isinstance(plugins, dict): + return False + entries = plugins.get("entries") + if not isinstance(entries, dict): + return False + entry = entries.get("observability/nemo_relay") or entries.get("nemo_relay") + if not isinstance(entry, dict): + return False + shared_metrics = entry.get("shared_metrics") + return isinstance(shared_metrics, dict) and shared_metrics.get("enabled") is True + + def _static_plugin_config(plugins_config: dict[str, Any]) -> dict[str, Any]: """Return Relay's base config without embedding- or gateway-host fields.""" return { @@ -863,13 +1279,15 @@ def _dynamic_plugin_specs( continue if not isinstance(manifest_ref, str) or not manifest_ref.strip(): logger.warning( - "Invalid NeMo Relay dynamic_plugins[%d]: manifest_ref is required", index + "Invalid NeMo Relay dynamic_plugins[%d]: manifest_ref is required", + index, ) invalid = True continue if not isinstance(config, dict): logger.warning( - "Invalid NeMo Relay dynamic_plugins[%d]: config must be an object", index + "Invalid NeMo Relay dynamic_plugins[%d]: config must be an object", + index, ) invalid = True continue @@ -886,7 +1304,9 @@ def _dynamic_plugin_specs( spec: dict[str, Any] = { "plugin_id": plugin_id.strip(), "kind": kind, - "manifest_ref": _config_relative_path(manifest_ref.strip(), plugins_toml_path), + "manifest_ref": _config_relative_path( + manifest_ref.strip(), plugins_toml_path + ), "config": config, } if environment_ref is not None: @@ -908,7 +1328,9 @@ def _config_relative_path(value: str, plugins_toml_path: str) -> str: path = Path(value) if path.is_absolute(): return str(path) - config_path = Path(plugins_toml_path) if plugins_toml_path else Path.cwd() / "plugins.toml" + config_path = ( + Path(plugins_toml_path) if plugins_toml_path else Path.cwd() / "plugins.toml" + ) if not config_path.is_absolute(): config_path = Path.cwd() / config_path return os.path.abspath(config_path.parent / path) @@ -998,7 +1420,9 @@ def _child_session_id(kwargs: dict[str, Any]) -> str: return str(kwargs.get("child_session_id") or "") -def _subagent_child_metadata(kwargs: dict[str, Any], parent_metadata: dict[str, Any]) -> dict[str, Any]: +def _subagent_child_metadata( + kwargs: dict[str, Any], parent_metadata: dict[str, Any] +) -> dict[str, Any]: child_session_id = _child_session_id(kwargs) metadata = { "session_id": child_session_id, @@ -1023,7 +1447,10 @@ def _subagent_child_metadata(kwargs: dict[str, Any], parent_metadata: dict[str, def _api_key(kwargs: dict[str, Any]) -> str: - return str(kwargs.get("api_request_id") or f"{_session_id(kwargs)}:{kwargs.get('api_call_count') or 'api'}") + return str( + kwargs.get("api_request_id") + or f"{_session_id(kwargs)}:{kwargs.get('api_call_count') or 'api'}" + ) def _tool_key(kwargs: dict[str, Any]) -> str: @@ -1104,7 +1531,9 @@ def _json_semantically_equal(left: Any, right: Any) -> bool: """Compare JSON-compatible values without conflating booleans and numbers.""" try: options = {"ensure_ascii": False, "sort_keys": True, "separators": (",", ":")} - return json.dumps(_jsonable(left), **options) == json.dumps(_jsonable(right), **options) + return json.dumps(_jsonable(left), **options) == json.dumps( + _jsonable(right), **options + ) except (TypeError, ValueError): return False @@ -1119,12 +1548,16 @@ def _original_downstream_error(exc: Exception) -> BaseException: # Hermes wraps downstream execution failures in a local/private exception # class, so detect the wrapper by shape instead of importing it here. original = getattr(exc, "original", None) - if exc.__class__.__name__ == "_DownstreamExecutionError" and isinstance(original, BaseException): + if exc.__class__.__name__ == "_DownstreamExecutionError" and isinstance( + original, BaseException + ): return original return exc -def _is_relay_wrapped_callback_error(exc: Exception, callback_error: Exception | None) -> bool: +def _is_relay_wrapped_callback_error( + exc: Exception, callback_error: Exception | None +) -> bool: # NeMo Relay re-wraps a failing callback as ``RuntimeError("internal error: # : ")``. Match by prefix rather than exact equality so a # trailing traceback/suffix in a future Relay version doesn't silently defeat @@ -1165,13 +1598,25 @@ def _llm_response_payload(response: Any) -> Any: if reasoning is not None: assistant_message["reasoning_content"] = _jsonable(reasoning) elif isinstance(payload, dict): - assistant_message["content"] = payload.get("content") or payload.get("output_text") or "" + assistant_message["content"] = ( + payload.get("content") or payload.get("output_text") or "" + ) return { - "model": _value(response, "model", payload.get("model") if isinstance(payload, dict) else None), + "model": _value( + response, + "model", + payload.get("model") if isinstance(payload, dict) else None, + ), "assistant_message": assistant_message, "finish_reason": finish_reason, - "usage": _jsonable(_value(response, "usage", payload.get("usage") if isinstance(payload, dict) else None)), + "usage": _jsonable( + _value( + response, + "usage", + payload.get("usage") if isinstance(payload, dict) else None, + ) + ), } @@ -1181,16 +1626,14 @@ def _tool_calls_payload(tool_calls: Any) -> list[dict[str, Any]]: normalized: list[dict[str, Any]] = [] for call in tool_calls: function = _value(call, "function") - normalized.append( - { - "id": _value(call, "id"), - "type": _value(call, "type", "function") or "function", - "function": { - "name": _value(function, "name"), - "arguments": _value(function, "arguments"), - }, - } - ) + normalized.append({ + "id": _value(call, "id"), + "type": _value(call, "type", "function") or "function", + "function": { + "name": _value(function, "name"), + "arguments": _value(function, "arguments"), + }, + }) return normalized diff --git a/plugins/observability/nemo_relay/schemas/hermes.shared_metrics.v1.schema.json b/plugins/observability/nemo_relay/schemas/hermes.shared_metrics.v1.schema.json new file mode 100644 index 00000000000..f7306597bc6 --- /dev/null +++ b/plugins/observability/nemo_relay/schemas/hermes.shared_metrics.v1.schema.json @@ -0,0 +1,153 @@ +{ + "$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": { + "$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": { + "$ref": "#/$defs/model_call_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 + } + } + } + } +} diff --git a/plugins/observability/nemo_relay/shared_metrics.py b/plugins/observability/nemo_relay/shared_metrics.py new file mode 100644 index 00000000000..4848ba82759 --- /dev/null +++ b/plugins/observability/nemo_relay/shared_metrics.py @@ -0,0 +1,376 @@ +"""Durable aggregation and local export for Hermes shared metrics.""" + +from __future__ import annotations + +import json +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 + + +_PACKAGE_SCHEMA_VERSION = "hermes.shared_metrics.v1" +_MODEL_CALL_METRIC = "hermes.model_call.count" +_STORE_SCHEMA_VERSION = "1" +_BUSY_TIMEOUT_MS = 250 + + +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 model-call 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_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.""" + 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, + _MODEL_CALL_METRIC, + 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 + return self._export_pending_packages() + + 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) -> Iterator[sqlite3.Connection]: + connection = sqlite3.connect( + self.database_path, + timeout=_BUSY_TIMEOUT_MS / 1000, + ) + try: + try: + self.database_path.chmod(0o600) + except OSError: + pass + 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 + + def _ensure_schema(self) -> None: + with self._connection() 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": [ + { + "name": row["metric_name"], + "type": "counter", + "dimensions": json.loads(row["dimensions_json"]), + "value": row["value"] - row["packaged_value"], + } + 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 + + 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 diff --git a/plugins/observability/nemo_relay/shared_metrics_contract.py b/plugins/observability/nemo_relay/shared_metrics_contract.py new file mode 100644 index 00000000000..d7937a6483a --- /dev/null +++ b/plugins/observability/nemo_relay/shared_metrics_contract.py @@ -0,0 +1,251 @@ +"""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 + +SCHEMA_KEY = "hermes.metrics.schema_version" +SCHEMA_VERSION = "hermes.metrics.event.v1" +SESSION_SCOPE = "hermes.session" +MODEL_CALL_SCOPE = "hermes.model_call" +SUBSCRIBER_NAME = "hermes.nemo_relay.shared_metrics" +PRIMARY_MODEL_CALL_ROLE = "primary" + +EXECUTION_SURFACES = frozenset({ + "api", + "batch", + "cli", + "desktop", + "gateway", + "python", + "scheduled_task", + "tui", + "other", + "unknown", +}) +PROVIDER_FAMILIES = frozenset({"aggregator", "custom", "direct", "local", "unknown"}) +MODEL_LOCALITIES = frozenset({"local", "remote", "unknown"}) +MODEL_OUTCOMES = frozenset({"cancelled", "failed", "success"}) + +# 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({ + "claude", + "deepseek", + "gemini", + "gemma", + "glm", + "gpt", + "grok", + "kimi", + "llama", + "minimax", + "mimo", + "mistral", + "nemotron", + "nova", + "qwen", + "step", + "trinity", + "o1", + "o3", + "o4", + "unknown", +}) + +_MODEL_FAMILY_PATTERN = re.compile( + r"(?:^|[/_.:-])(" + + "|".join( + re.escape(family) + for family in sorted(MODEL_FAMILIES - {"unknown"}, key=len, 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 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} + 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", + "outcome", + "provider_family", + } + if not isinstance(data, dict) or set(data) != expected_fields: + return None + if ( + data.get("call_role") != PRIMARY_MODEL_CALL_ROLE + or data.get("locality") not in MODEL_LOCALITIES + or data.get("outcome") not in MODEL_OUTCOMES + or data.get("provider_family") not in PROVIDER_FAMILIES + ): + return None + return { + "call_role": PRIMARY_MODEL_CALL_ROLE, + "locality": data["locality"], + "model_family": event_model_family, + "outcome": data["outcome"], + "provider_family": data["provider_family"], + } + + +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 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("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" diff --git a/plugins/observability/nemo_relay/shared_metrics_subscriber.py b/plugins/observability/nemo_relay/shared_metrics_subscriber.py new file mode 100644 index 00000000000..be5aa6b53b0 --- /dev/null +++ b/plugins/observability/nemo_relay/shared_metrics_subscriber.py @@ -0,0 +1,31 @@ +"""Relay subscriber for the persisted Hermes shared-metrics slice.""" + +from __future__ import annotations + +import logging +from typing import Any + +from .shared_metrics import SharedMetricsStore +from .shared_metrics_contract import model_call_dimensions + +logger = logging.getLogger(__name__) + + +class SharedMetricsSubscriber: + """Persist validated primary model-call counters from Relay events.""" + + def __init__(self, store: SharedMetricsStore, hermes_version: str) -> None: + self.store = store + self._hermes_version = hermes_version or "unknown" + + def __call__(self, event: Any) -> None: + dimensions = model_call_dimensions(event) + if dimensions is None: + return + try: + self.store.record_model_call(dimensions, self._hermes_version) + except Exception: + logger.warning( + "Unable to persist the Hermes model-call metric", + exc_info=True, + ) diff --git a/pyproject.toml b/pyproject.toml index faf5b6efcf4..19950019f4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,7 @@ vision = [] # 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.0.1"] # starlette: CVE-2026-48710 -nemo-relay = ["nemo-relay>=0.5,<1.0"] +nemo-relay = ["nemo-relay>=0.5.0,<0.6.0"] 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 @@ -351,6 +351,7 @@ plugins = [ "**/plugin.yaml", "**/plugin.yml", "**/README.md", + "**/schemas/*.json", ] [tool.setuptools.packages.find] diff --git a/scripts/smoke_nemo_relay_shared_metrics.py b/scripts/smoke_nemo_relay_shared_metrics.py new file mode 100644 index 00000000000..8ea4dde1b20 --- /dev/null +++ b/scripts/smoke_nemo_relay_shared_metrics.py @@ -0,0 +1,379 @@ +"""Run a real Hermes CLI turn and validate the Relay shared-metrics output.""" + +from __future__ import annotations + +import argparse +import json +import os +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" + + +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="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 +plugins: + enabled: + - observability/nemo_relay + entries: + observability/nemo_relay: + 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 + ] + expected = [ + { + "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 counters != expected: + raise AssertionError( + f"Unexpected SQLite counters:\n{json.dumps(counters, indent=2)}" + ) + 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}" + ) + expected_metric = { + "name": "hermes.model_call.count", + "type": "counter", + "dimensions": { + "call_role": "primary", + "locality": "local", + "model_family": "gpt", + "outcome": "success", + "provider_family": "custom", + }, + "value": 1, + } + if package.get("metrics") != [expected_metric]: + raise AssertionError( + f"Unexpected package metrics:\n{json.dumps(package.get('metrics'), indent=2)}" + ) + 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 (hermes_repo.parent / "nemo-relay" / "python").resolve() + ) + hermes = hermes_repo / ".venv" / "bin" / "hermes" + if not hermes.is_file(): + raise SystemExit(f"Hermes executable not found: {hermes}") + if 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) + 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 + / "plugins" + / "observability" + / "nemo_relay" + / "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()) diff --git a/tests/plugins/test_nemo_relay_plugin.py b/tests/plugins/test_nemo_relay_plugin.py index 61958194e58..eabb1a510b4 100644 --- a/tests/plugins/test_nemo_relay_plugin.py +++ b/tests/plugins/test_nemo_relay_plugin.py @@ -4,10 +4,12 @@ from __future__ import annotations import asyncio import builtins +import contextvars import gc import importlib import json import sys +import threading import warnings from pathlib import Path from types import SimpleNamespace @@ -25,6 +27,10 @@ PLUGIN_DIR = REPO_ROOT / "plugins" / "observability" / "nemo_relay" class _FakeNemoRelay: def __init__(self): self.events = [] + self._llm_handles = {} + self._subscriber_callbacks = {} + self._handle_serial = 0 + self._scope_context = contextvars.ContextVar("fake_relay_scope", default=None) self.ScopeType = SimpleNamespace(Agent="agent") self.scope = SimpleNamespace( push=self._scope_push, @@ -46,14 +52,20 @@ class _FakeNemoRelay: clear=self._plugin_clear, activate_dynamic_plugins=self._plugin_activate_dynamic, ) - self.subscribers = SimpleNamespace(flush=self._flush_subscribers) + self.subscribers = SimpleNamespace( + register=self._register_subscriber, + deregister=self._deregister_subscriber, + flush=self._flush_subscribers, + ) self.LLMRequest = _FakeLLMRequest self.AtofExporterConfig = _FakeAtofExporterConfig self.AtofExporterMode = SimpleNamespace(Append="append", Overwrite="overwrite") self.AtofExporter = self._make_atof_exporter self.AtifExporter = self._make_atif_exporter + self.get_scope_stack = self._get_scope_stack def _scope_push(self, name, scope_type, **kwargs): + self._scope_context.set(name) handle = ("scope", name) self.events.append(("scope.push", name, scope_type, kwargs)) return handle @@ -64,17 +76,46 @@ class _FakeNemoRelay: def _scope_event(self, name, **kwargs): self.events.append(("scope.event", name, kwargs)) + def _get_scope_stack(self): + current = self._scope_context.get() + self.events.append(("scope_stack.sync", current)) + return current + def _llm_call(self, name, request, **kwargs): + self.events.append(("llm.call.context", self._scope_context.get())) handle = ("llm", name) + if handle in self._llm_handles: + self._handle_serial += 1 + handle = ("llm", name, self._handle_serial) + self._llm_handles[handle] = kwargs self.events.append(("llm.call", name, request.content, kwargs)) return handle def _llm_call_end(self, handle, response, **kwargs): + self.events.append(("llm.call_end.context", self._scope_context.get())) self.events.append(("llm.call_end", handle, response, kwargs)) + started = self._llm_handles.pop(handle, {}) + self._emit( + _FakeEvent( + kind="scope", + category="llm", + name=handle[1], + scope_category="end", + data=response, + category_profile={"model_name": started.get("model_name")}, + metadata={ + **(started.get("metadata") or {}), + **(kwargs.get("metadata") or {}), + "otel.status_code": "OK", + }, + ) + ) def _llm_execute(self, name, request, func, **kwargs): self.events.append(("llm.execute.start", name, request.content, kwargs)) - result = func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) + result = func( + _FakeLLMRequest(request.headers, {"intercepted": True, **request.content}) + ) self.events.append(("llm.execute.end", name, result, kwargs)) return result @@ -96,7 +137,9 @@ class _FakeNemoRelay: return _FakeAtofExporter(self.events, config) def _make_atif_exporter(self, session_id, agent_name, agent_version, **kwargs): - return _FakeAtifExporter(self.events, session_id, agent_name, agent_version, kwargs) + return _FakeAtifExporter( + self.events, session_id, agent_name, agent_version, kwargs + ) async def _plugin_initialize(self, config): self.events.append(("plugin.initialize", config)) @@ -112,6 +155,39 @@ class _FakeNemoRelay: def _flush_subscribers(self): self.events.append(("subscribers.flush",)) + def _register_subscriber(self, name, callback): + self.events.append(("subscribers.register", name)) + self._subscriber_callbacks[name] = callback + + def _deregister_subscriber(self, name): + self.events.append(("subscribers.deregister", name)) + return self._subscriber_callbacks.pop(name, None) is not None + + def _emit(self, event): + for callback in list(self._subscriber_callbacks.values()): + callback(event) + + +class _FakeEvent: + def __init__( + self, + *, + kind, + name, + category=None, + category_profile=None, + data=None, + metadata=None, + scope_category=None, + ): + self.kind = kind + self.name = name + self.category = category + self.category_profile = category_profile + self.data = data + self.metadata = metadata + self.scope_category = scope_category + class _FakePluginActivation: def __init__(self, events): @@ -141,10 +217,20 @@ class _FakeAtofExporter: self.config = config def register(self, name): - self.events.append(("atof.register", name, self.config.output_directory, self.config.filename)) + self.events.append(( + "atof.register", + name, + self.config.output_directory, + self.config.filename, + )) def deregister(self, name): - self.events.append(("atof.deregister", name, self.config.output_directory, self.config.filename)) + self.events.append(( + "atof.deregister", + name, + self.config.output_directory, + self.config.filename, + )) return True @@ -165,7 +251,10 @@ class _FakeAtifExporter: def export_json(self): self.events.append(("atif.export", self.session_id)) - return json.dumps({"session_id": self.session_id, "agent_name": self.agent_name}) + return json.dumps({ + "session_id": self.session_id, + "agent_name": self.agent_name, + }) def _fresh_plugin(monkeypatch, fake): @@ -223,6 +312,25 @@ mode = "test" return plugins_toml +def _enable_shared_metrics(tmp_path, monkeypatch) -> Path: + hermes_home = tmp_path / "hermes-home" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + """ +plugins: + enabled: + - observability/nemo_relay + entries: + observability/nemo_relay: + shared_metrics: + enabled: true +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + return hermes_home + + def test_manifest_fields(): data = yaml.safe_load((PLUGIN_DIR / "plugin.yaml").read_text()) assert data["name"] == "nemo_relay" @@ -266,13 +374,218 @@ def test_nemo_relay_plugin_uses_nemo_relay_runtime(monkeypatch): assert any(event[0] == "scope.push" for event in fake_relay.events) +def test_shared_metrics_default_off_does_not_create_state(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes-home" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + + plugin.on_session_start(session_id="sensitive-session", model="sensitive-model") + + assert not any(event[0] == "subscribers.register" for event in fake.events) + assert not (hermes_home / "telemetry").exists() + + +def test_shared_metrics_counts_one_logical_model_call_across_retries( + tmp_path, monkeypatch +): + hermes_home = _enable_shared_metrics(tmp_path, monkeypatch) + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + base = { + "session_id": "sensitive-session", + "task_id": "task-1", + "api_request_id": "request-1", + "platform": "cli", + "provider": "custom", + "model": "gpt-sensitive-model-id", + "base_url": "http://127.0.0.1:11434/v1", + } + + plugin.on_session_start(**base) + plugin.on_pre_api_request( + **base, + request={"body": {"messages": ["sensitive-prompt"]}}, + ) + plugin.on_api_request_error( + **base, + retryable=True, + error={"message": "sensitive-error"}, + ) + plugin.on_pre_api_request( + **base, + request={"body": {"messages": ["sensitive-prompt"]}}, + ) + plugin.on_post_api_request( + **base, + response={"content": "sensitive-response"}, + ) + plugin.on_session_finalize(session_id=base["session_id"]) + + model_starts = [ + event for event in fake.events if event[:2] == ("llm.call", "hermes.model_call") + ] + model_ends = [ + event + for event in fake.events + if event[0] == "llm.call_end" and event[1][1] == "hermes.model_call" + ] + assert len(model_starts) == 1 + assert len(model_ends) == 1 + assert [ + event[1] + for event in fake.events + if event[0] in {"llm.call.context", "llm.call_end.context"} + ] == ["hermes.session", "hermes.session"] + assert model_starts[0][2] == {} + assert model_starts[0][3]["model_name"] == "gpt" + assert model_ends[0][2] == { + "call_role": "primary", + "locality": "local", + "outcome": "success", + "provider_family": "custom", + } + serialized_events = json.dumps(fake.events) + assert "sensitive-prompt" not in serialized_events + assert "sensitive-response" not in serialized_events + assert "sensitive-error" not in serialized_events + assert "gpt-sensitive-model-id" not in serialized_events + runtime = plugin._get_runtime() + assert runtime is not None + assert runtime.shared_metrics is not None + assert runtime.shared_metrics.store.counter_snapshot()[0]["value"] == 1 + assert ( + len( + list( + (hermes_home / "telemetry" / "shared_metrics" / "outbox").glob("*.json") + ) + ) + == 1 + ) + + +def test_shared_metrics_closes_unfinished_model_call_as_failed(tmp_path, monkeypatch): + _enable_shared_metrics(tmp_path, monkeypatch) + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + base = { + "session_id": "s1", + "task_id": "task-1", + "api_request_id": "request-1", + "provider": "anthropic", + "model": "claude-sonnet", + } + + plugin.on_pre_api_request(**base) + plugin.on_api_request_error(**base, retryable=False, error={"message": "private"}) + plugin.on_session_end(session_id="s1", task_id="task-1", interrupted=False) + plugin.on_session_finalize(session_id="s1") + + [model_end] = [ + event + for event in fake.events + if event[0] == "llm.call_end" and event[1][1] == "hermes.model_call" + ] + assert model_end[2]["outcome"] == "failed" + runtime = plugin._get_runtime() + assert runtime is not None + assert ( + runtime.shared_metrics.store.counter_snapshot()[0]["dimensions"]["outcome"] + == "failed" + ) + + +def test_shared_metrics_persistence_failure_is_fail_open(tmp_path, monkeypatch, caplog): + _enable_shared_metrics(tmp_path, monkeypatch) + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + runtime = plugin._get_runtime() + assert runtime is not None + assert runtime.shared_metrics is not None + + def fail_record(*_args, **_kwargs): + raise OSError("store unavailable") + + monkeypatch.setattr(runtime.shared_metrics.store, "record_model_call", fail_record) + plugin.on_pre_api_request( + session_id="s1", + task_id="t1", + api_request_id="r1", + provider="openai", + model="gpt-5", + ) + plugin.on_post_api_request( + session_id="s1", + task_id="t1", + api_request_id="r1", + provider="openai", + model="gpt-5", + ) + + assert "Unable to persist the Hermes model-call metric" in caplog.text + + +def test_shared_metrics_close_does_not_reopen_a_failed_session_scope( + tmp_path, monkeypatch +): + _enable_shared_metrics(tmp_path, monkeypatch) + fake = _FakeNemoRelay() + original_push = fake.scope.push + push_attempts = 0 + + def fail_first_metrics_scope(*args, **kwargs): + nonlocal push_attempts + push_attempts += 1 + if push_attempts == 1: + raise RuntimeError("simulated metrics scope failure") + return original_push(*args, **kwargs) + + fake.scope.push = fail_first_metrics_scope + plugin = _fresh_plugin(monkeypatch, fake) + runtime = plugin._get_runtime() + assert runtime is not None + runtime.ensure_session({"session_id": "s1"}) + state = runtime.sessions["s1"] + assert state.metrics_handle is None + + close_started = threading.Event() + allow_close = threading.Event() + original_drain = runtime._end_pending_model_calls + + def block_drain(current_state, kwargs): + assert current_state.closing is True + close_started.set() + assert allow_close.wait(timeout=5) + original_drain(current_state, kwargs) + + monkeypatch.setattr(runtime, "_end_pending_model_calls", block_drain) + close_thread = threading.Thread( + target=runtime.close_session, + args=({"session_id": "s1"},), + ) + close_thread.start() + assert close_started.wait(timeout=5) + + runtime.ensure_session({"session_id": "s1"}) + assert push_attempts == 1 + + allow_close.set() + close_thread.join(timeout=5) + assert not close_thread.is_alive() + assert "s1" not in runtime.sessions + + def test_nemo_relay_plugin_emits_llm_tool_and_exports_atif(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_ENABLED", "1") - monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", str(tmp_path / "atof")) + monkeypatch.setenv( + "HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", str(tmp_path / "atof") + ) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) + monkeypatch.setenv( + "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") + ) base = { "session_id": "s1", @@ -286,15 +599,26 @@ def test_nemo_relay_plugin_emits_llm_tool_and_exports_atif(tmp_path, monkeypatch api_request_id="api-1", provider="openai", model="demo-model", - request={"method": "POST", "body": {"messages": [{"role": "user", "content": "hi"}]}}, + request={ + "method": "POST", + "body": {"messages": [{"role": "user", "content": "hi"}]}, + }, ) plugin.on_post_api_request( **base, api_request_id="api-1", response={"assistant_message": {"role": "assistant", "content": "hello"}}, ) - plugin.on_pre_tool_call(**base, tool_name="read_file", tool_call_id="tool-1", args={"path": "x"}) - plugin.on_post_tool_call(**base, tool_name="read_file", tool_call_id="tool-1", result='{"ok": true}', status="ok") + plugin.on_pre_tool_call( + **base, tool_name="read_file", tool_call_id="tool-1", args={"path": "x"} + ) + plugin.on_post_tool_call( + **base, + tool_name="read_file", + tool_call_id="tool-1", + result='{"ok": true}', + status="ok", + ) plugin.on_session_end(**base, completed=True, interrupted=False) plugin.on_session_finalize(**base, reason="shutdown") @@ -336,7 +660,9 @@ def test_nemo_relay_plugin_closes_api_span_on_error(monkeypatch): call_end = next(event for event in fake.events if event[0] == "llm.call_end") assert call_end[1] == ("llm", "openai") - assert call_end[2] == {"error": {"type": "RateLimitError", "message": "rate limited"}} + assert call_end[2] == { + "error": {"type": "RateLimitError", "message": "rate limited"} + } assert call_end[3]["data"]["reason"] == "rate_limit" assert not plugin._get_runtime().sessions["s1"].llm_spans @@ -345,8 +671,12 @@ def test_nemo_relay_plugin_emits_approval_marks(monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) - plugin.on_pre_approval_request(session_id="s1", approval_id="approval-1", tool_name="shell") - plugin.on_post_approval_response(session_id="s1", approval_id="approval-1", approved=True) + plugin.on_pre_approval_request( + session_id="s1", approval_id="approval-1", tool_name="shell" + ) + plugin.on_post_approval_response( + session_id="s1", approval_id="approval-1", approved=True + ) mark_names = [event[1] for event in fake.events if event[0] == "scope.event"] assert "hermes.approval.request" in mark_names @@ -357,13 +687,17 @@ def test_nemo_relay_plugin_emits_unmatched_fallback_marks(monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) - plugin.on_post_api_request(session_id="s1", api_request_id="missing-api", response={"ok": True}) + plugin.on_post_api_request( + session_id="s1", api_request_id="missing-api", response={"ok": True} + ) plugin.on_api_request_error( session_id="s1", api_request_id="missing-api", error={"type": "TimeoutError", "message": "timed out"}, ) - plugin.on_post_tool_call(session_id="s1", tool_call_id="missing-tool", result={"ok": True}) + plugin.on_post_tool_call( + session_id="s1", tool_call_id="missing-tool", result={"ok": True} + ) mark_names = [event[1] for event in fake.events if event[0] == "scope.event"] assert "hermes.api.response.unmatched" in mark_names @@ -399,12 +733,20 @@ def test_nemo_relay_plugin_metadata_promotes_trajectory_and_subagent_ids(monkeyp telemetry_schema_version="hermes.observer.v1", ) - turn_mark = next(event for event in fake.events if event[0] == "scope.event" and event[1] == "hermes.turn.start") + turn_mark = next( + event + for event in fake.events + if event[0] == "scope.event" and event[1] == "hermes.turn.start" + ) turn_metadata = turn_mark[2]["metadata"] assert turn_metadata["session_id"] == "parent-session" assert turn_metadata["trajectory_id"] == "parent-session" - start_mark = next(event for event in fake.events if event[0] == "scope.event" and event[1] == "hermes.subagent.start") + start_mark = next( + event + for event in fake.events + if event[0] == "scope.event" and event[1] == "hermes.subagent.start" + ) start_metadata = start_mark[2]["metadata"] assert start_metadata["parent_session_id"] == "parent-session" assert start_metadata["parent_trajectory_id"] == "parent-session" @@ -413,7 +755,11 @@ def test_nemo_relay_plugin_metadata_promotes_trajectory_and_subagent_ids(monkeyp assert start_metadata["child_subagent_id"] == "child-sa" assert start_metadata["child_role"] == "leaf" - stop_mark = next(event for event in fake.events if event[0] == "scope.event" and event[1] == "hermes.subagent.stop") + stop_mark = next( + event + for event in fake.events + if event[0] == "scope.event" and event[1] == "hermes.subagent.stop" + ) assert stop_mark[2]["metadata"]["child_status"] == "completed" @@ -446,11 +792,15 @@ def test_nemo_relay_plugin_reparents_child_session_scope_for_embedded_atif(monke assert child_kwargs["metadata"]["parent_session_id"] == "parent-session" -def test_nemo_relay_plugin_skips_embedded_child_atif_file_by_default(tmp_path, monkeypatch): +def test_nemo_relay_plugin_skips_embedded_child_atif_file_by_default( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) + monkeypatch.setenv( + "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") + ) plugin.on_session_start(session_id="parent-session") plugin.on_subagent_start( @@ -468,11 +818,15 @@ def test_nemo_relay_plugin_skips_embedded_child_atif_file_by_default(tmp_path, m assert not (tmp_path / "atif" / "hermes-atif-child-session.json").exists() -def test_nemo_relay_plugin_can_write_embedded_child_atif_file_in_all_mode(tmp_path, monkeypatch): +def test_nemo_relay_plugin_can_write_embedded_child_atif_file_in_all_mode( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) + monkeypatch.setenv( + "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") + ) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_SUBAGENT_EXPORT_MODE", "all") plugin.on_session_start(session_id="parent-session") @@ -525,7 +879,9 @@ output_directory = "{atif_dir}" assert atif_dir.is_dir() -def test_nemo_relay_plugin_clears_plugins_toml_on_final_session_finalize_and_reinitializes(tmp_path, monkeypatch): +def test_nemo_relay_plugin_clears_plugins_toml_on_final_session_finalize_and_reinitializes( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -555,7 +911,9 @@ def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypa plugin = _fresh_plugin(monkeypatch, fake) _enable_dynamic_plugin(tmp_path, monkeypatch) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) + monkeypatch.setenv( + "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") + ) plugin.on_session_start(session_id="s1") runtime = plugin._get_runtime() @@ -583,7 +941,9 @@ def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypa plugin.on_session_finalize(session_id="s2", reason="shutdown") assert sum(event[0] == "plugin.activate_dynamic" for event in fake.events) == 1 - activation = next(event for event in fake.events if event[0] == "plugin.activate_dynamic") + activation = next( + event for event in fake.events if event[0] == "plugin.activate_dynamic" + ) assert "dynamic_plugins" not in activation[1] assert activation[2] == [ { @@ -600,7 +960,9 @@ def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypa runtime.shutdown() event_names = [event[0] for event in fake.events] - assert event_names.index("atif.deregister") < event_names.index("plugin.activation.close") + assert event_names.index("atif.deregister") < event_names.index( + "plugin.activation.close" + ) def test_nemo_relay_rejects_gateway_dynamic_config_with_actionable_diagnostic( @@ -633,7 +995,9 @@ mode = "test" assert "Use Hermes-owned [[dynamic_plugins]]" in caplog.text -def test_nemo_relay_explicit_dynamic_paths_resolve_from_plugins_toml(tmp_path, monkeypatch): +def test_nemo_relay_explicit_dynamic_paths_resolve_from_plugins_toml( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) config_dir = tmp_path / "config" @@ -655,7 +1019,9 @@ environment_ref = "../environments/worker-fixture" plugin.on_session_start(session_id="s1") - activation = next(event for event in fake.events if event[0] == "plugin.activate_dynamic") + activation = next( + event for event in fake.events if event[0] == "plugin.activate_dynamic" + ) assert activation[2] == [ { "plugin_id": "worker-fixture", @@ -729,7 +1095,9 @@ def test_nemo_relay_managed_llm_uses_wire_protocol_for_interceptor_dispatch( assert "rewritten_for" not in result -def test_nemo_relay_managed_llm_returns_post_next_interceptor_result(tmp_path, monkeypatch): +def test_nemo_relay_managed_llm_returns_post_next_interceptor_result( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() raw_response = SimpleNamespace( model="fixture", @@ -792,7 +1160,9 @@ def test_nemo_relay_managed_tool_returns_post_interceptor_result(tmp_path, monke } -def test_nemo_relay_plugin_activates_before_registering_managed_middleware(tmp_path, monkeypatch): +def test_nemo_relay_plugin_activates_before_registering_managed_middleware( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) _enable_dynamic_plugin(tmp_path, monkeypatch) @@ -885,16 +1255,21 @@ manifest_ref = "{(tmp_path / "invalid" / "relay-plugin.toml").as_posix()}" assert "no dynamic plugins will be activated" in caplog.text -def test_nemo_relay_plugin_registers_shutdown_after_dynamic_retry(tmp_path, monkeypatch): +def test_nemo_relay_plugin_registers_shutdown_after_dynamic_retry( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() activation_attempts = 0 async def _flaky_activate(config, dynamic_plugins): nonlocal activation_attempts activation_attempts += 1 - fake.events.append( - ("plugin.activate_dynamic.attempt", activation_attempts, config, dynamic_plugins) - ) + fake.events.append(( + "plugin.activate_dynamic.attempt", + activation_attempts, + config, + dynamic_plugins, + )) if activation_attempts == 1: raise RuntimeError("temporary activation failure") return _FakePluginActivation(fake.events) @@ -938,7 +1313,9 @@ def test_nemo_relay_plugin_attempts_activation_close_after_subscriber_flush_fail event_names = [event[0] for event in fake.events] assert event_names.count("subscribers.flush.failed") == 2 flush_indices = [ - index for index, name in enumerate(event_names) if name == "subscribers.flush.failed" + index + for index, name in enumerate(event_names) + if name == "subscribers.flush.failed" ] assert max(flush_indices) < event_names.index("plugin.activation.close") assert runtime._plugin_activation is None @@ -961,7 +1338,9 @@ def test_nemo_relay_plugin_continues_shutdown_after_atif_export_failure( plugin = _fresh_plugin(monkeypatch, fake) _enable_dynamic_plugin(tmp_path, monkeypatch) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) + monkeypatch.setenv( + "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") + ) plugin.on_session_start(session_id="s1") runtime = plugin._get_runtime() assert runtime is not None @@ -970,13 +1349,19 @@ def test_nemo_relay_plugin_continues_shutdown_after_atif_export_failure( runtime.shutdown() event_names = [event[0] for event in fake.events] - assert event_names.index("atif.export.failed") < event_names.index("atif.deregister") - assert event_names.index("atif.deregister") < event_names.index("plugin.activation.close") + assert event_names.index("atif.export.failed") < event_names.index( + "atif.deregister" + ) + assert event_names.index("atif.deregister") < event_names.index( + "plugin.activation.close" + ) assert runtime._plugin_activation is None assert "ATIF export failed: disk full" in caplog.text -def test_nemo_relay_plugin_keeps_plugins_toml_active_while_other_sessions_remain(tmp_path, monkeypatch): +def test_nemo_relay_plugin_keeps_plugins_toml_active_while_other_sessions_remain( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -1002,7 +1387,9 @@ enabled = true assert event_names.count("plugin.clear") == 1 -def test_nemo_relay_plugin_reinitializes_plugins_toml_inside_active_event_loop(tmp_path, monkeypatch): +def test_nemo_relay_plugin_reinitializes_plugins_toml_inside_active_event_loop( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -1037,7 +1424,9 @@ enabled = true assert "hermes-session-s2" in scope_push_names -def test_nemo_relay_plugin_retries_plugins_toml_after_clear_failure(tmp_path, monkeypatch): +def test_nemo_relay_plugin_retries_plugins_toml_after_clear_failure( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() initialize_calls = 0 @@ -1078,7 +1467,9 @@ enabled = true assert "hermes-session-s2" in scope_push_names -def test_nemo_relay_plugin_disables_direct_atif_when_plugins_toml_owns_atif(tmp_path, monkeypatch): +def test_nemo_relay_plugin_disables_direct_atif_when_plugins_toml_owns_atif( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -1098,7 +1489,9 @@ output_directory = "{(tmp_path / "managed-atif").as_posix()}" ) monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atif")) + monkeypatch.setenv( + "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atif") + ) plugin.on_session_start(session_id="s1") plugin.on_session_finalize(session_id="s1", reason="shutdown") @@ -1110,7 +1503,9 @@ output_directory = "{(tmp_path / "managed-atif").as_posix()}" assert not (tmp_path / "direct-atif" / "hermes-atif-s1.json").exists() -def test_nemo_relay_plugin_keeps_direct_atif_when_plugins_toml_init_fails(tmp_path, monkeypatch): +def test_nemo_relay_plugin_keeps_direct_atif_when_plugins_toml_init_fails( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() async def _failing_initialize(config): @@ -1136,7 +1531,9 @@ output_directory = "{(tmp_path / "managed-atif").as_posix()}" ) monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atif")) + monkeypatch.setenv( + "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atif") + ) plugin.on_session_start(session_id="s1") plugin.on_session_finalize(session_id="s1", reason="shutdown") @@ -1182,7 +1579,9 @@ output_directory = "{(tmp_path / "managed-atof").as_posix()}" ) monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_ENABLED", "1") - monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atof")) + monkeypatch.setenv( + "HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atof") + ) plugin.on_session_start(session_id="s1") plugin.on_session_finalize(session_id="s1", reason="shutdown") @@ -1197,7 +1596,9 @@ output_directory = "{(tmp_path / "managed-atof").as_posix()}" assert event_names.count("atof.deregister") == 1 -def test_nemo_relay_adaptive_llm_execution_middleware_preserves_raw_response(tmp_path, monkeypatch): +def test_nemo_relay_adaptive_llm_execution_middleware_preserves_raw_response( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -1225,7 +1626,9 @@ mode = "observe_only" SimpleNamespace( id="tool-1", type="function", - function=SimpleNamespace(name="terminal", arguments='{"command":"pwd"}'), + function=SimpleNamespace( + name="terminal", arguments='{"command":"pwd"}' + ), ) ], reasoning_content="need a tool", @@ -1259,7 +1662,9 @@ mode = "observe_only" assert response.model == "demo-model" assert response.choices == [raw_choice] assert seen_request["intercepted"] is True - execute_start = next(event for event in fake.events if event[0] == "llm.execute.start") + execute_start = next( + event for event in fake.events if event[0] == "llm.execute.start" + ) assert execute_start[3]["data"]["mode"] == "observe_only" execute_end = next(event for event in fake.events if event[0] == "llm.execute.end") assert execute_end[2] == { @@ -1281,13 +1686,19 @@ mode = "observe_only" } -def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error(tmp_path, monkeypatch): +def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() def native_like_execute(name, request, func, **kwargs): fake.events.append(("llm.execute.start", name, request.content, kwargs)) try: - return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) + return func( + _FakeLLMRequest( + request.headers, {"intercepted": True, **request.content} + ) + ) except Exception as exc: raise RuntimeError(f"internal error: {type(exc).__name__}: {exc}") from None @@ -1327,9 +1738,15 @@ def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error_with_relay def native_like_execute(name, request, func, **kwargs): try: - return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) + return func( + _FakeLLMRequest( + request.headers, {"intercepted": True, **request.content} + ) + ) except Exception as exc: - raise RuntimeError(f"internal error: {type(exc).__name__}: {exc} (retried 3x)") from None + raise RuntimeError( + f"internal error: {type(exc).__name__}: {exc} (retried 3x)" + ) from None fake.llm.execute = native_like_execute plugin = _fresh_plugin(monkeypatch, fake) @@ -1356,7 +1773,9 @@ def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error_with_relay assert caught.value.status_code == 403 -def test_nemo_relay_adaptive_llm_execution_keeps_unrelated_internal_error(tmp_path, monkeypatch): +def test_nemo_relay_adaptive_llm_execution_keeps_unrelated_internal_error( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() relay_error = RuntimeError("internal error: relay setup failed") @@ -1384,11 +1803,17 @@ def test_nemo_relay_adaptive_llm_execution_keeps_wrapped_relay_error_after_downs tmp_path, monkeypatch ): fake = _FakeNemoRelay() - relay_error = RuntimeError("internal error: RuntimeError: relay policy blocked after downstream") + relay_error = RuntimeError( + "internal error: RuntimeError: relay policy blocked after downstream" + ) def translated_execute(name, request, func, **kwargs): try: - return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) + return func( + _FakeLLMRequest( + request.headers, {"intercepted": True, **request.content} + ) + ) except Exception: raise relay_error @@ -1411,7 +1836,9 @@ def test_nemo_relay_adaptive_llm_execution_keeps_wrapped_relay_error_after_downs assert caught.value is relay_error -def test_nemo_relay_adaptive_llm_execution_keeps_relay_translated_error(tmp_path, monkeypatch): +def test_nemo_relay_adaptive_llm_execution_keeps_relay_translated_error( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() class RelayPolicyError(Exception): @@ -1421,7 +1848,11 @@ def test_nemo_relay_adaptive_llm_execution_keeps_relay_translated_error(tmp_path def translated_execute(name, request, func, **kwargs): try: - return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) + return func( + _FakeLLMRequest( + request.headers, {"intercepted": True, **request.content} + ) + ) except Exception: raise relay_error @@ -1446,7 +1877,9 @@ def test_nemo_relay_adaptive_llm_execution_keeps_relay_translated_error(tmp_path assert caught.value is relay_error -def test_nemo_relay_downstream_unwrap_matches_real_middleware_wrapper_shape(monkeypatch): +def test_nemo_relay_downstream_unwrap_matches_real_middleware_wrapper_shape( + monkeypatch, +): # Regression guard against core/plugin drift. The synthetic tests above model # the downstream-error wrapper with a local class, so they keep passing even # if core middleware renames its private ``_DownstreamExecutionError`` or drops @@ -1510,7 +1943,9 @@ def _adaptive_llm_execute_mode(tmp_path, monkeypatch, plugins_toml_text: str) -> next_call=lambda request: {"raw": request}, ) - execute_start = next(event for event in fake.events if event[0] == "llm.execute.start") + execute_start = next( + event for event in fake.events if event[0] == "llm.execute.start" + ) return execute_start[3]["data"]["mode"] @@ -1534,7 +1969,9 @@ version = 1 assert mode == "observe_only" -def test_nemo_relay_adaptive_llm_execution_middleware_accepts_legacy_top_level_mode(tmp_path, monkeypatch): +def test_nemo_relay_adaptive_llm_execution_middleware_accepts_legacy_top_level_mode( + tmp_path, monkeypatch +): mode = _adaptive_llm_execute_mode( tmp_path, monkeypatch, @@ -1552,7 +1989,9 @@ mode = "route" assert mode == "route" -def test_nemo_relay_adaptive_llm_execution_middleware_prefers_tool_parallelism_mode(tmp_path, monkeypatch): +def test_nemo_relay_adaptive_llm_execution_middleware_prefers_tool_parallelism_mode( + tmp_path, monkeypatch +): mode = _adaptive_llm_execute_mode( tmp_path, monkeypatch, @@ -1573,7 +2012,9 @@ mode = "schedule" assert mode == "schedule" -def test_nemo_relay_llm_execution_middleware_calls_through_without_adaptive(monkeypatch): +def test_nemo_relay_llm_execution_middleware_calls_through_without_adaptive( + monkeypatch, +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) @@ -1589,7 +2030,9 @@ def test_nemo_relay_llm_execution_middleware_calls_through_without_adaptive(monk assert not any(event[0] == "llm.execute.start" for event in fake.events) -def test_nemo_relay_adaptive_tool_execution_middleware_preserves_raw_response(tmp_path, monkeypatch): +def test_nemo_relay_adaptive_tool_execution_middleware_preserves_raw_response( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -1627,12 +2070,16 @@ mode = "observe_only" assert response == {"raw": True, "args": {"command": "pwd", "intercepted": True}} assert seen_args["intercepted"] is True - execute_start = next(event for event in fake.events if event[0] == "tool.execute.start") + execute_start = next( + event for event in fake.events if event[0] == "tool.execute.start" + ) assert execute_start[3]["data"]["mode"] == "observe_only" assert execute_start[3]["data"]["tool_call_id"] == "tool-1" -def test_nemo_relay_adaptive_tool_execution_preserves_downstream_error(tmp_path, monkeypatch): +def test_nemo_relay_adaptive_tool_execution_preserves_downstream_error( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() def native_like_execute(name, args, func, **kwargs): @@ -1666,7 +2113,9 @@ def test_nemo_relay_adaptive_tool_execution_preserves_downstream_error(tmp_path, assert caught.value.status_code == 403 -def test_nemo_relay_adaptive_tool_execution_keeps_unrelated_internal_error(tmp_path, monkeypatch): +def test_nemo_relay_adaptive_tool_execution_keeps_unrelated_internal_error( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() relay_error = RuntimeError("internal error: relay setup failed") @@ -1693,7 +2142,9 @@ def test_nemo_relay_adaptive_tool_execution_keeps_wrapped_relay_error_after_down tmp_path, monkeypatch ): fake = _FakeNemoRelay() - relay_error = RuntimeError("internal error: RuntimeError: relay policy blocked after downstream") + relay_error = RuntimeError( + "internal error: RuntimeError: relay policy blocked after downstream" + ) def translated_execute(name, args, func, **kwargs): try: @@ -1719,7 +2170,9 @@ def test_nemo_relay_adaptive_tool_execution_keeps_wrapped_relay_error_after_down assert caught.value is relay_error -def test_nemo_relay_adaptive_tool_execution_keeps_relay_translated_error(tmp_path, monkeypatch): +def test_nemo_relay_adaptive_tool_execution_keeps_relay_translated_error( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() class RelayPolicyError(Exception): @@ -1753,7 +2206,9 @@ def test_nemo_relay_adaptive_tool_execution_keeps_relay_translated_error(tmp_pat assert caught.value is relay_error -def test_nemo_relay_tool_execution_middleware_calls_through_without_adaptive(monkeypatch): +def test_nemo_relay_tool_execution_middleware_calls_through_without_adaptive( + monkeypatch, +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) @@ -1768,7 +2223,9 @@ def test_nemo_relay_tool_execution_middleware_calls_through_without_adaptive(mon assert not any(event[0] == "tool.execute.start" for event in fake.events) -def test_nemo_relay_adaptive_execution_skips_duplicate_observer_spans(tmp_path, monkeypatch): +def test_nemo_relay_adaptive_execution_skips_duplicate_observer_spans( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -1800,8 +2257,12 @@ mode = "observe_only" request={"body": {"messages": [{"role": "user", "content": "hi"}]}}, ) plugin.on_post_api_request(**base, response={"ok": True}) - plugin.on_pre_tool_call(**base, tool_name="terminal", tool_call_id="tool-1", args={"command": "pwd"}) - plugin.on_post_tool_call(**base, tool_name="terminal", tool_call_id="tool-1", result={"ok": True}) + plugin.on_pre_tool_call( + **base, tool_name="terminal", tool_call_id="tool-1", args={"command": "pwd"} + ) + plugin.on_post_tool_call( + **base, tool_name="terminal", tool_call_id="tool-1", result={"ok": True} + ) plugin.on_llm_execution_middleware( **base, diff --git a/tests/plugins/test_nemo_relay_shared_metrics.py b/tests/plugins/test_nemo_relay_shared_metrics.py new file mode 100644 index 00000000000..5c2c1a87b81 --- /dev/null +++ b/tests/plugins/test_nemo_relay_shared_metrics.py @@ -0,0 +1,462 @@ +"""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 uuid +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from plugins.observability.nemo_relay.shared_metrics import SharedMetricsStore +from plugins.observability.nemo_relay.shared_metrics_contract import ( + MODEL_FAMILIES, + MODEL_LOCALITIES, + MODEL_OUTCOMES, + PRIMARY_MODEL_CALL_ROLE, + PROVIDER_FAMILIES, + execution_surface, + model_call_outcome, + model_call_dimensions, + model_family, + model_locality, + provider_family, +) + + +SCHEMA_PATH = ( + Path(__file__).resolve().parents[2] + / "plugins" + / "observability" + / "nemo_relay" + / "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 _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 + + +@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" + + +@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 + + +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", + "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_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_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_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_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_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 + + +@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 diff --git a/uv.lock b/uv.lock index 59f9d8e2628..f0daf9798e5 100644 --- a/uv.lock +++ b/uv.lock @@ -1804,7 +1804,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 = "extra == 'nemo-relay'", specifier = ">=0.5.0,<0.6.0" }, { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, { name = "openai", specifier = "==2.24.0" }, { name = "packaging", specifier = "==26.0" }, From 64faff6768ffac903b84ab6abd94e1ba3b3cc58b Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 08:54:49 -0400 Subject: [PATCH 002/526] refactor(observability): move Relay runtime ownership into core Signed-off-by: Alex Fournier --- MANIFEST.in | 3 + docs/observability/README.md | 4 + docs/observability/relay-shared-metrics.md | 72 ++ hermes_cli/observability/__init__.py | 51 ++ hermes_cli/observability/relay_runtime.py | 372 +++++++++ .../observability/relay_shared_metrics.py | 342 ++++++++ .../hermes.shared_metrics.v1.schema.json | 0 .../observability}/shared_metrics.py | 0 .../observability}/shared_metrics_contract.py | 29 +- .../shared_metrics_subscriber.py | 0 hermes_cli/plugins.py | 33 +- plugins/observability/nemo_relay/README.md | 84 +- plugins/observability/nemo_relay/__init__.py | 770 +++++------------- pyproject.toml | 8 +- scripts/smoke_nemo_relay_shared_metrics.py | 34 +- .../test_relay_shared_metrics.py} | 14 +- .../test_relay_shared_metrics_runtime.py | 463 +++++++++++ tests/plugins/test_nemo_relay_plugin.py | 721 +++++----------- tests/test_project_metadata.py | 52 +- uv.lock | 8 +- 20 files changed, 1826 insertions(+), 1234 deletions(-) create mode 100644 docs/observability/relay-shared-metrics.md create mode 100644 hermes_cli/observability/__init__.py create mode 100644 hermes_cli/observability/relay_runtime.py create mode 100644 hermes_cli/observability/relay_shared_metrics.py rename {plugins/observability/nemo_relay => hermes_cli/observability}/schemas/hermes.shared_metrics.v1.schema.json (100%) rename {plugins/observability/nemo_relay => hermes_cli/observability}/shared_metrics.py (100%) rename {plugins/observability/nemo_relay => hermes_cli/observability}/shared_metrics_contract.py (91%) rename {plugins/observability/nemo_relay => hermes_cli/observability}/shared_metrics_subscriber.py (100%) rename tests/{plugins/test_nemo_relay_shared_metrics.py => hermes_cli/test_relay_shared_metrics.py} (97%) create mode 100644 tests/hermes_cli/test_relay_shared_metrics_runtime.py diff --git a/MANIFEST.in b/MANIFEST.in index 159c215ff6b..c5c33210632 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,6 +7,9 @@ graft locales # built from the sdist (e.g. Homebrew, downstream packagers). package-data # below covers the wheel; this covers the sdist. See #34034 / #28149. recursive-include plugins plugin.yaml plugin.yml +# The built-in shared-metrics package validates exported JSON against this +# closed schema. Include it when downstream packagers build from the sdist. +recursive-include hermes_cli/observability/schemas *.json # Gateway assets include images plus YAML catalogs such as status_phrases.yaml. recursive-include gateway/assets * global-exclude __pycache__ diff --git a/docs/observability/README.md b/docs/observability/README.md index 9040929ca48..7ecbd2e7bd6 100644 --- a/docs/observability/README.md +++ b/docs/observability/README.md @@ -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)`: diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md new file mode 100644 index 00000000000..6c85c84cda4 --- /dev/null +++ b/docs/observability/relay-shared-metrics.md @@ -0,0 +1,72 @@ +# 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, but Relay-backed instrumentation is unavailable there. + +Collection remains off unless Hermes policy enables it: + +```yaml +telemetry: + shared_metrics: + enabled: true +``` + +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 Slice + +The first vertical slice records one logical model-call counter: + +```text +Hermes API hooks + -> Relay session and LLM lifecycle + -> Hermes shared-metrics subscriber + -> SQLite counter + -> immutable JSON delta package +``` + +Hermes sends an empty `LLMRequest` into this metrics lifecycle. The terminal +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. + +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. + +## 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, one counter was +stored, one package was exported, and prompt, response, and exact-model +canaries are absent from the package. diff --git a/hermes_cli/observability/__init__.py b/hermes_cli/observability/__init__.py new file mode 100644 index 00000000000..ce01554d93e --- /dev/null +++ b/hermes_cli/observability/__init__.py @@ -0,0 +1,51 @@ +"""First-party Hermes observability integrations.""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def prepare_lifecycle(hook_name: str, **kwargs: Any) -> None: + """Prepare subscribers that must observe a session's start event.""" + from . import relay_runtime, relay_shared_metrics + + if hook_name in relay_runtime.SESSION_START_HOOKS: + try: + relay_shared_metrics.prepare_session_start() + except Exception: + logger.warning("Built-in observability preparation failed", exc_info=True) + + +def observe_lifecycle(hook_name: str, **kwargs: Any) -> None: + """Dispatch a Hermes lifecycle event to built-in observability features.""" + from . import relay_runtime, relay_shared_metrics + + # Session-start plugin callbacks register optional per-session subscribers + # before this completion step opens the shared core scope. On teardown, + # metrics finish child LLM scopes before the neutral host closes the owner. + if hook_name not in relay_runtime.SESSION_CLOSE_HOOKS: + _safe_observe(relay_runtime.observe_lifecycle, hook_name, kwargs) + _safe_observe(relay_shared_metrics.observe_lifecycle, hook_name, kwargs) + if hook_name in relay_runtime.SESSION_CLOSE_HOOKS: + _safe_observe(relay_runtime.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_runtime, relay_shared_metrics + + return relay_runtime.handles_hook(hook_name) or 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 + ) diff --git a/hermes_cli/observability/relay_runtime.py b/hermes_cli/observability/relay_runtime.py new file mode 100644 index 00000000000..d52b44d07cb --- /dev/null +++ b/hermes_cli/observability/relay_runtime.py @@ -0,0 +1,372 @@ +"""Process-wide NeMo Relay runtime owned by Hermes core.""" + +from __future__ import annotations + +import atexit +import contextvars +import importlib +import logging +import threading +from dataclasses import dataclass, field +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +SESSION_SCOPE = "hermes.session" +RUNTIME_SCHEMA_KEY = "hermes.relay.schema_version" +RUNTIME_SCHEMA_VERSION = "hermes.relay.runtime.v1" + +SESSION_START_HOOKS = frozenset({"on_session_start"}) +SESSION_CLOSE_HOOKS = frozenset({"on_session_finalize", "on_session_reset"}) +SUBAGENT_START_HOOKS = frozenset({"subagent_start"}) +SUBAGENT_STOP_HOOKS = frozenset({"subagent_stop"}) +HANDLED_HOOKS = ( + SESSION_START_HOOKS + | SESSION_CLOSE_HOOKS + | SUBAGENT_START_HOOKS + | SUBAGENT_STOP_HOOKS +) + +_RUNTIME_FAILED = object() +_RUNTIME: RelayRuntime | object | None = None +_RUNTIME_LOCK = threading.RLock() + + +@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) -> None: + self.relay = relay or _load_nemo_relay() + self._sessions_lock = threading.RLock() + self._sessions: dict[str, RelaySession] = {} + self._subagent_parents: dict[str, str] = {} + self._shutdown_registered = True + atexit.register(self.shutdown) + + 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, + } + if session.parent_session_id: + 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]) -> None: + """Record the parent used when a delegated Hermes session starts.""" + 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 + self.ensure_session({"session_id": parent_session_id}) + with self._sessions_lock: + self._subagent_parents[child_session_id] = parent_session_id + + def unregister_subagent(self, event: dict[str, Any]) -> None: + """Forget a delegated-session relationship after its terminal hook.""" + child_session_id = str(event.get("child_session_id") or "") + if not child_session_id: + return + with self._sessions_lock: + self._subagent_parents.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") + + 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 session.context.copy().run(invoke) + + 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 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: + 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}, + 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) + 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 + + +def handles_hook(hook_name: str) -> bool: + """Return whether the core Relay host consumes this lifecycle hook.""" + return hook_name in HANDLED_HOOKS + + +def observe_lifecycle(hook_name: str, **kwargs: Any) -> None: + """Apply session lifecycle events to the core Relay host.""" + if not handles_hook(hook_name): + return + # Session hooks do not activate Relay by themselves. A direct core + # producer or an enabled built-in consumer creates the host lazily, after + # which these hooks keep its session lifetime correct. + runtime = get_runtime(create=False) + if runtime is None: + return + try: + if hook_name in SESSION_START_HOOKS: + runtime.ensure_session(kwargs) + elif hook_name in SESSION_CLOSE_HOOKS: + runtime.close_session(kwargs) + elif hook_name in SUBAGENT_START_HOOKS: + runtime.register_subagent(kwargs) + else: + runtime.unregister_subagent(kwargs) + except Exception: + logger.warning("Hermes Relay lifecycle failed: %s", hook_name, exc_info=True) + + +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() + 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 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) + + +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 get_runtime(*, create: bool = True) -> RelayRuntime | None: + """Return the process-wide Hermes Relay host.""" + global _RUNTIME + with _RUNTIME_LOCK: + if isinstance(_RUNTIME, RelayRuntime): + return _RUNTIME + if _RUNTIME is _RUNTIME_FAILED or not create: + return None + try: + _RUNTIME = RelayRuntime() + except Exception: + logger.warning("Hermes Relay runtime initialization failed", exc_info=True) + _RUNTIME = _RUNTIME_FAILED + return None + return _RUNTIME + + +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 process-global core Relay state for isolated tests.""" + global _RUNTIME + with _RUNTIME_LOCK: + if isinstance(_RUNTIME, RelayRuntime): + _RUNTIME.shutdown() + _RUNTIME = None diff --git a/hermes_cli/observability/relay_shared_metrics.py b/hermes_cli/observability/relay_shared_metrics.py new file mode 100644 index 00000000000..c8306966f1e --- /dev/null +++ b/hermes_cli/observability/relay_shared_metrics.py @@ -0,0 +1,342 @@ +"""Direct NeMo Relay integration for Hermes shared client metrics.""" + +from __future__ import annotations + +import atexit +import logging +import threading +from dataclasses import dataclass, field +from functools import lru_cache +from typing import Any, Callable + +from hermes_cli import __version__ + +from . import relay_runtime +from .shared_metrics import SharedMetricsStore +from .shared_metrics_contract import ( + MODEL_CALL_SCOPE, + SCHEMA_KEY, + SCHEMA_VERSION, + SUBSCRIBER_NAME, + model_call_fields, + model_call_outcome, +) +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_api_request", + "post_api_request", + "api_request_error", +}) + +_RUNTIME_FAILED = object() +_RUNTIME: _Runtime | object | None = None +_RUNTIME_LOCK = threading.RLock() + + +@dataclass +class _ModelCall: + handle: Any + task_id: str + fields: dict[str, str] + + +@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) + + +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._sessions: dict[str, _MetricsSession] = {} + self.subscriber = SharedMetricsSubscriber(SharedMetricsStore(), __version__) + self.relay.subscribers.register(SUBSCRIBER_NAME, self.subscriber) + 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 + relay_session = self.host.ensure_session(event) + if relay_session is None: + return None + with self._sessions_lock: + 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_model_call(self, event: dict[str, Any]) -> 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) + model_family = fields["model_family"] + with session.lock: + if session.closing: + return + existing = session.model_calls.get(request_id) + if existing is not None: + existing.fields = fields + return + handle = self._run_in_session( + session, + self.relay.llm.call, + MODEL_CALL_SCOPE, + self.relay.LLMRequest({}, {}), + handle=session.relay_session.handle, + metadata={SCHEMA_KEY: SCHEMA_VERSION}, + model_name=model_family, + ) + session.model_calls[request_id] = _ModelCall( + handle=handle, + task_id=str(event.get("task_id") or ""), + fields=fields, + ) + + def end_model_call(self, event: dict[str, Any], outcome: str | None = None) -> 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._session(event) + if session is None: + return + with session.lock: + if session.closing: + return + self._end_pending_model_calls(session, 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 + 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: + 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, SUBSCRIBER_NAME) + self._registered = False + 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) + + 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: + self._run_in_session( + session, + self.relay.llm.call_end, + model_call.handle, + {**model_call.fields, "outcome": outcome}, + metadata={SCHEMA_KEY: SCHEMA_VERSION}, + ) + 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 _export(self) -> None: + self._safe(self.subscriber.store.create_and_export_package) + + @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 + + +@lru_cache(maxsize=1) +def enabled() -> bool: + """Return the process-lifetime Hermes shared-metrics policy.""" + try: + from hermes_cli.config import load_config_readonly + + config = load_config_readonly() or {} + except Exception: + logger.debug("Unable to read Hermes shared-metrics policy", exc_info=True) + return False + if not isinstance(config, dict): + return False + telemetry = config.get("telemetry") + if not isinstance(telemetry, dict): + return False + shared_metrics = telemetry.get("shared_metrics") + return isinstance(shared_metrics, dict) and shared_metrics.get("enabled") is True + + +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_api_request": + runtime.start_model_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.end_pending_model_calls(kwargs) + 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() + + +def _get_runtime() -> _Runtime | None: + global _RUNTIME + with _RUNTIME_LOCK: + if isinstance(_RUNTIME, _Runtime): + return _RUNTIME + if _RUNTIME is _RUNTIME_FAILED: + return None + try: + _RUNTIME = _Runtime() + except Exception: + logger.warning("Hermes shared metrics initialization failed", exc_info=True) + _RUNTIME = _RUNTIME_FAILED + return None + return _RUNTIME + + +def _reset_for_tests() -> None: + """Reset process-global state for isolated tests.""" + global _RUNTIME + with _RUNTIME_LOCK: + if isinstance(_RUNTIME, _Runtime): + _RUNTIME.shutdown() + _RUNTIME = None + enabled.cache_clear() diff --git a/plugins/observability/nemo_relay/schemas/hermes.shared_metrics.v1.schema.json b/hermes_cli/observability/schemas/hermes.shared_metrics.v1.schema.json similarity index 100% rename from plugins/observability/nemo_relay/schemas/hermes.shared_metrics.v1.schema.json rename to hermes_cli/observability/schemas/hermes.shared_metrics.v1.schema.json diff --git a/plugins/observability/nemo_relay/shared_metrics.py b/hermes_cli/observability/shared_metrics.py similarity index 100% rename from plugins/observability/nemo_relay/shared_metrics.py rename to hermes_cli/observability/shared_metrics.py diff --git a/plugins/observability/nemo_relay/shared_metrics_contract.py b/hermes_cli/observability/shared_metrics_contract.py similarity index 91% rename from plugins/observability/nemo_relay/shared_metrics_contract.py rename to hermes_cli/observability/shared_metrics_contract.py index d7937a6483a..7e3c5d336e8 100644 --- a/plugins/observability/nemo_relay/shared_metrics_contract.py +++ b/hermes_cli/observability/shared_metrics_contract.py @@ -8,12 +8,11 @@ from typing import Any SCHEMA_KEY = "hermes.metrics.schema_version" SCHEMA_VERSION = "hermes.metrics.event.v1" -SESSION_SCOPE = "hermes.session" MODEL_CALL_SCOPE = "hermes.model_call" SUBSCRIBER_NAME = "hermes.nemo_relay.shared_metrics" PRIMARY_MODEL_CALL_ROLE = "primary" -EXECUTION_SURFACES = frozenset({ +EXECUTION_SURFACES: frozenset[str] = frozenset({ "api", "batch", "cli", @@ -25,14 +24,20 @@ EXECUTION_SURFACES = frozenset({ "other", "unknown", }) -PROVIDER_FAMILIES = frozenset({"aggregator", "custom", "direct", "local", "unknown"}) -MODEL_LOCALITIES = frozenset({"local", "remote", "unknown"}) -MODEL_OUTCOMES = frozenset({"cancelled", "failed", "success"}) +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"}) # 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({ +MODEL_FAMILIES: frozenset[str] = frozenset({ "claude", "deepseek", "gemini", @@ -60,7 +65,11 @@ _MODEL_FAMILY_PATTERN = re.compile( r"(?:^|[/_.:-])(" + "|".join( re.escape(family) - for family in sorted(MODEL_FAMILIES - {"unknown"}, key=len, reverse=True) + for family in sorted( + MODEL_FAMILIES - {"unknown"}, + key=lambda value: len(value), + reverse=True, + ) ) + r")(?=$|[/_.:-]|\d)" ) @@ -109,6 +118,7 @@ def model_call_dimensions(event: Any) -> dict[str, str] | None: expected_fields = { "call_role", "locality", + "model_family", "outcome", "provider_family", } @@ -117,6 +127,7 @@ def model_call_dimensions(event: Any) -> dict[str, str] | None: if ( data.get("call_role") != PRIMARY_MODEL_CALL_ROLE or data.get("locality") not in MODEL_LOCALITIES + or data.get("model_family") not in MODEL_FAMILIES or data.get("outcome") not in MODEL_OUTCOMES or data.get("provider_family") not in PROVIDER_FAMILIES ): @@ -124,7 +135,7 @@ def model_call_dimensions(event: Any) -> dict[str, str] | None: return { "call_role": PRIMARY_MODEL_CALL_ROLE, "locality": data["locality"], - "model_family": event_model_family, + "model_family": data["model_family"], "outcome": data["outcome"], "provider_family": data["provider_family"], } @@ -240,7 +251,7 @@ def model_family(kwargs: dict[str, Any]) -> str: declared_family = str(kwargs.get("model_family") or "").strip().lower() if declared_family in MODEL_FAMILIES - {"unknown"}: return declared_family - model = str(kwargs.get("model") or "").lower() + 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" diff --git a/plugins/observability/nemo_relay/shared_metrics_subscriber.py b/hermes_cli/observability/shared_metrics_subscriber.py similarity index 100% rename from plugins/observability/nemo_relay/shared_metrics_subscriber.py rename to hermes_cli/observability/shared_metrics_subscriber.py diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 6ca393fca53..9b1d2488081 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -2047,10 +2047,32 @@ 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 built-in observers and loaded plugins. Returns a list of non-``None`` return values from plugin callbacks. """ + if hook_name == "on_session_start": + try: + from hermes_cli.observability import prepare_lifecycle + + prepare_lifecycle(hook_name, **kwargs) + except Exception: + logger.warning("Built-in observability preparation failed", exc_info=True) + results = get_plugin_manager().invoke_hook(hook_name, **kwargs) + 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) + return results + + 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) return get_plugin_manager().invoke_hook(hook_name, **kwargs) @@ -2072,7 +2094,14 @@ 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 built-in observer or plugin handles 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 shared-metrics hooks", exc_info=True) return get_plugin_manager().has_hook(hook_name) diff --git a/plugins/observability/nemo_relay/README.md b/plugins/observability/nemo_relay/README.md index 60db027072e..52d31bb7890 100644 --- a/plugins/observability/nemo_relay/README.md +++ b/plugins/observability/nemo_relay/README.md @@ -73,89 +73,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.0,<0.6.0" hermes plugins enable observability/nemo_relay ``` -The plugin fails open when `nemo-relay` is not installed. Install a supported -NeMo Relay 0.5.x distribution: - -```bash -pip install "nemo-relay>=0.5.0,<0.6.0" -``` - -## Shared Metrics Proof Mode - -The Phase 1 telemetry proof adds a separately gated metrics-only mode. Enable -the bundled plugin and the mode in the same `config.yaml`: - -```yaml -plugins: - enabled: - - observability/nemo_relay - entries: - observability/nemo_relay: - shared_metrics: - enabled: true -``` - -The proof reads this setting when the plugin runtime initializes. Restart a -long-running Hermes agent or gateway after changing it. Live revocation and -local-state reset controls are follow-on requirements before production -rollout. - -This first vertical slice maps Hermes's existing API-request hooks to one Relay -LLM lifecycle per logical model call and aggregates terminal primary calls into -`hermes.model_call.count`. Retries sharing an API request ID remain one logical -call. The counter uses bounded provider family, model family, locality, and -outcome dimensions; raw model IDs and request or response content are never -included in the metrics event. - -The subscriber stores cumulative counters and a random opaque install ID in -`$HERMES_HOME/telemetry/shared_metrics/metrics.sqlite3`. After Relay's flush -barrier, Hermes commits un-packaged deltas to an immutable outbox record and -atomically writes a `hermes.shared_metrics.v1` JSON package under -`$HERMES_HOME/telemetry/shared_metrics/outbox/`. Repeating export without new -model calls reuses pending package IDs and does not package a count twice. The -proof mode does not perform network I/O. Task, tool, approval, and skill metrics -remain follow-on slices. The closed package contract ships with the plugin at -`schemas/hermes.shared_metrics.v1.schema.json`. - -When shared metrics are enabled without a separate rich-observability -configuration, the plugin does not emit its existing content-bearing turn, -model, tool, approval, or subagent events. ATOF, ATIF, adaptive components, and -dynamic plugins remain separate explicit configuration choices. - -### End-to-End Smoke Test - -The repository includes a deterministic smoke runner that exercises the real -Hermes CLI and native NeMo Relay binding without external model credentials. It -starts a loopback OpenAI-compatible model server, creates an isolated -`HERMES_HOME`, runs one `hermes chat` turn, and validates the resulting SQLite -counter and schema-conformant JSON package: - -```bash -./.venv/bin/python scripts/smoke_nemo_relay_shared_metrics.py \ - --relay-python ../nemo-relay/python -``` - -The NeMo Relay Python binding must already be built. By default, the runner -expects the Hermes checkout as its working directory and looks for a sibling -`nemo-relay` checkout. Use `--hermes-repo`, `--relay-python`, or `--output-dir` -to override those locations. The generated profile, captured CLI output, -SQLite database, and immutable package are retained in the artifact directory -printed after a successful run. +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 @@ -315,13 +250,10 @@ For the full generic Hermes middleware contract, see ## Canonical Local Examples -The observe-only examples in this section use a supported NeMo Relay 0.5.x -distribution 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.0,<0.6.0" - export HERMES_HOME=/tmp/hermes-nemo-relay-docs/hermes-home mkdir -p "$HERMES_HOME" diff --git a/plugins/observability/nemo_relay/__init__.py b/plugins/observability/nemo_relay/__init__.py index 895320b1883..d539e0eb198 100644 --- a/plugins/observability/nemo_relay/__init__.py +++ b/plugins/observability/nemo_relay/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import atexit import asyncio -import contextvars import inspect import json import logging @@ -13,22 +12,10 @@ import threading import tomllib from collections.abc import Callable from dataclasses import dataclass, field -from functools import wraps from pathlib import Path from typing import Any, Optional -from .shared_metrics import SharedMetricsStore -from .shared_metrics_contract import ( - MODEL_CALL_SCOPE as _SHARED_METRICS_MODEL_CALL_SCOPE, - SCHEMA_KEY as _SHARED_METRICS_SCHEMA_KEY, - SCHEMA_VERSION as _SHARED_METRICS_SCHEMA_VERSION, - SESSION_SCOPE as _SHARED_METRICS_SESSION_SCOPE, - SUBSCRIBER_NAME as _SHARED_METRICS_SUBSCRIBER_NAME, - execution_surface as _execution_surface, - model_call_fields as _model_call_fields, - model_call_outcome as _model_call_outcome, -) -from .shared_metrics_subscriber import SharedMetricsSubscriber +from hermes_cli.observability import relay_runtime logger = logging.getLogger(__name__) @@ -45,39 +32,24 @@ _RELAY_LLM_SURFACE_BY_API_MODE = { @dataclass class _SessionState: session_id: str - lock: threading.RLock = field(default_factory=threading.RLock, repr=False) - closing: bool = False + relay_session: relay_runtime.RelaySession | None = None handle: Any = None - metrics_handle: Any = None - metrics_context: contextvars.Context | None = None atif_exporter: Any = None atif_subscriber_name: str = "" is_embedded_subagent: bool = False parent_session_id: str = "" llm_spans: dict[str, Any] = field(default_factory=dict) tool_spans: dict[str, Any] = field(default_factory=dict) - metrics_model_calls: dict[str, "_SharedMetricsModelCall"] = field( - default_factory=dict - ) @dataclass -class _SharedMetricsModelCall: - handle: Any - task_id: str - fields: dict[str, str] - - -@dataclass -class _SubagentParent: +class _SubagentContext: parent_session_id: str - parent_handle: Any metadata: dict[str, Any] @dataclass class _Settings: - shared_metrics_enabled: bool = False plugins_toml_path: str = "" plugins_config: dict[str, Any] | None = None dynamic_plugins: list[dict[str, Any]] = field(default_factory=list) @@ -96,125 +68,27 @@ class _Settings: atif_model_name: str = "unknown" -def _with_session_state_lock(method: Callable[..., Any]) -> Callable[..., Any]: - """Serialize one session without blocking unrelated sessions.""" - - @wraps(method) - def wrapped( - self: "_Runtime", - event: dict[str, Any], - *args: Any, - **kwargs: Any, - ) -> Any: - with self._state_lock: - state = self.sessions.get(_session_id(event)) - if state is None: - return None - with state.lock: - if state.closing: - return None - return method(self, event, state, *args, **kwargs) - - return wrapped - - -def _with_ensured_session_lock(method: Callable[..., Any]) -> Callable[..., Any]: - """Create a missing session, then serialize work scoped to it.""" - - @wraps(method) - def wrapped( - self: "_Runtime", - event: dict[str, Any], - *args: Any, - **kwargs: Any, - ) -> Any: - state = self.ensure_session(event) - with state.lock: - if state.closing: - return None - return method(self, event, state, *args, **kwargs) - - return wrapped - - class _Runtime: - def __init__(self, nemo_relay: Any, settings: _Settings) -> None: + def __init__( + self, + nemo_relay: Any, + settings: _Settings, + host: relay_runtime.RelayRuntime, + ) -> None: self.nemo_relay = nemo_relay self.settings = settings - self._state_lock = threading.RLock() - self._plugin_lifecycle_lock = threading.RLock() + self.host = host self.sessions: dict[str, _SessionState] = {} - self.subagent_parents: dict[str, _SubagentParent] = {} + self.subagent_contexts: dict[str, _SubagentContext] = {} self.atof_exporter: Any = None self._atof_subscriber_name = "hermes.nemo_relay.atof" self._plugin_activation: Any = None self._shutdown_registered = False - self.shared_metrics: SharedMetricsSubscriber | None = None - if settings.shared_metrics_enabled: - try: - from hermes_cli import __version__ - - self.shared_metrics = SharedMetricsSubscriber( - SharedMetricsStore(), - __version__, - ) - except Exception: - logger.warning( - "NeMo Relay shared metrics disabled: local store initialization failed", - exc_info=True, - ) - self._shared_metrics_registered = False - self._configure_shared_metrics() self._plugin_config_initialized = self._configure_plugins_toml() self._plugin_config_needs_reinit = False if not self._plugin_config_initialized: self._activate_direct_fallbacks() - def _configure_shared_metrics(self) -> None: - if self.shared_metrics is None: - return - subscribers = getattr(self.nemo_relay, "subscribers", None) - register = getattr(subscribers, "register", None) - if not callable(register): - logger.warning( - "NeMo Relay shared metrics disabled: subscriber registration is unavailable" - ) - self.shared_metrics = None - return - try: - register(_SHARED_METRICS_SUBSCRIBER_NAME, self.shared_metrics) - except Exception as exc: - logger.warning( - "NeMo Relay shared metrics subscriber registration failed: %s", exc - ) - self.shared_metrics = None - return - self._shared_metrics_registered = True - self._ensure_shutdown_registered() - - def export_shared_metrics(self) -> list[Path]: - """Commit and export model-call deltas after Relay subscriber flush.""" - if self.shared_metrics is None: - return [] - try: - return self.shared_metrics.store.create_and_export_package() - except Exception: - logger.warning("Hermes shared-metrics package export failed", exc_info=True) - return [] - - def rich_observability_enabled(self) -> bool: - if not self.settings.shared_metrics_enabled: - return True - return bool( - self.settings.atof_enabled - or self.settings.atif_enabled - or _enabled_component_config( - self.settings.plugins_config, - "observability", - ) - is not None - ) - def _configure_plugins_toml(self) -> bool: if not self.settings.plugins_config: return False @@ -284,9 +158,7 @@ class _Runtime: # before its awaitable resolves, including error results. self._plugin_activation = None self._plugin_config_initialized = False - self._plugin_config_needs_reinit = bool( - self.settings.plugins_config - ) + self._plugin_config_needs_reinit = bool(self.settings.plugins_config) else: failures.append("dynamic plugin activation has no close method") else: @@ -370,268 +242,82 @@ class _Runtime: self.atof_exporter = None def ensure_session(self, kwargs: dict[str, Any]) -> _SessionState: - with self._plugin_lifecycle_lock: - self._maybe_reinitialize_plugins_toml() - with self._state_lock: - session_id = _session_id(kwargs) - state = self.sessions.get(session_id) - if state is not None: - self._ensure_shared_metrics_session(state, kwargs) - return state + self._maybe_reinitialize_plugins_toml() + session_id = _session_id(kwargs) + state = self.sessions.get(session_id) + if state is not None: + return state - state = _SessionState(session_id=session_id) - if self.rich_observability_enabled(): - if ( - self.settings.atif_enabled - and not self._plugins_toml_owns_exporter("atif") - ): - state.atif_exporter = self.nemo_relay.AtifExporter( - session_id, - self.settings.atif_agent_name, - self.settings.atif_agent_version, - model_name=str( - kwargs.get("model") or self.settings.atif_model_name - ), - extra={ - "source": "hermes-agent", - "plugin": "observability/nemo_relay", - }, - ) - state.atif_subscriber_name = ( - f"hermes.nemo_relay.atif.{session_id}" - ) - state.atif_exporter.register(state.atif_subscriber_name) - - subagent_parent = self.subagent_parents.get(session_id) - metadata = _metadata(kwargs) - parent_handle = None - if subagent_parent is not None: - parent_handle = subagent_parent.parent_handle - metadata = {**metadata, **subagent_parent.metadata} - state.is_embedded_subagent = True - state.parent_session_id = subagent_parent.parent_session_id - - state.handle = self.nemo_relay.scope.push( - f"hermes-session-{session_id}", - self.nemo_relay.ScopeType.Agent, - handle=parent_handle, - data={"session_id": session_id}, - metadata=metadata, - ) - - self._ensure_shared_metrics_session(state, kwargs) - self.sessions[session_id] = state - return state - - def _ensure_shared_metrics_session( - self, - state: _SessionState, - kwargs: dict[str, Any], - ) -> None: - if ( - state.closing - or not self._shared_metrics_registered - or state.metrics_handle is not None - ): - return - metrics_context = contextvars.Context() - try: - state.metrics_handle = metrics_context.run( - self.nemo_relay.scope.push, - _SHARED_METRICS_SESSION_SCOPE, - self.nemo_relay.ScopeType.Agent, - input={"execution_surface": _execution_surface(kwargs)}, - metadata={_SHARED_METRICS_SCHEMA_KEY: _SHARED_METRICS_SCHEMA_VERSION}, + state = _SessionState(session_id=session_id) + if self.settings.atif_enabled and not self._plugins_toml_owns_exporter("atif"): + state.atif_exporter = self.nemo_relay.AtifExporter( + session_id, + self.settings.atif_agent_name, + self.settings.atif_agent_version, + model_name=str(kwargs.get("model") or self.settings.atif_model_name), + extra={"source": "hermes-agent", "plugin": "observability/nemo_relay"}, ) - except Exception: - logger.warning( - "NeMo Relay shared-metrics session start failed", exc_info=True - ) - return - state.metrics_context = metrics_context + state.atif_subscriber_name = f"hermes.nemo_relay.atif.{session_id}" + state.atif_exporter.register(state.atif_subscriber_name) - def _run_in_metrics_context( + rich_metadata = _metadata(kwargs) + subagent_context = self.subagent_contexts.get(session_id) + if subagent_context is not None: + rich_metadata = {**rich_metadata, **subagent_context.metadata} + relay_session = self.host.ensure_session( + kwargs, + data={"session_id": session_id}, + metadata=rich_metadata, + ) + if relay_session is None: + raise RuntimeError("Hermes core Relay session is unavailable") + state.relay_session = relay_session + state.handle = relay_session.handle + if subagent_context is not None: + state.is_embedded_subagent = True + state.parent_session_id = subagent_context.parent_session_id + self.sessions[session_id] = state + return state + + def run_in_session( self, state: _SessionState, callback: Callable[..., Any], *args: Any, **kwargs: Any, ) -> Any: - """Run a native lifecycle call against the isolated metrics stack.""" - if state.metrics_context is None: - raise RuntimeError("shared-metrics scope context is unavailable") - - def invoke() -> Any: - # Relay's manual LLM helpers call the native API directly. Re-sync - # this Context's stack before entering them so scope-local policy - # cannot leak in from the rich-observability stack on this thread. - self.nemo_relay.get_scope_stack() - return callback(*args, **kwargs) - - return state.metrics_context.run(invoke) - - @_with_ensured_session_lock - def start_model_call(self, kwargs: dict[str, Any], state: _SessionState) -> None: - if not self._shared_metrics_registered or state.metrics_handle is None: - return - request_id = str(kwargs.get("api_request_id") or "") - if not request_id: - return - fields = _model_call_fields(kwargs) - model_family = fields.pop("model_family") - existing = state.metrics_model_calls.get(request_id) - if existing is not None: - # A logical call can span retries or provider fallback. Attribute its - # terminal provider fields to the most recent attempt without opening - # a second Relay lifecycle. Relay's model_name remains the logical - # model family recorded when the lifecycle started. - existing.fields = fields - return - request = self.nemo_relay.LLMRequest({}, {}) - try: - handle = self._run_in_metrics_context( - state, - self.nemo_relay.llm.call, - _SHARED_METRICS_MODEL_CALL_SCOPE, - request, - handle=state.metrics_handle, - metadata={_SHARED_METRICS_SCHEMA_KEY: _SHARED_METRICS_SCHEMA_VERSION}, - model_name=model_family, - ) - except Exception: - logger.warning( - "NeMo Relay shared-metrics model-call start failed", exc_info=True - ) - return - state.metrics_model_calls[request_id] = _SharedMetricsModelCall( - handle=handle, - task_id=str(kwargs.get("task_id") or ""), - fields=fields, + if state.relay_session is None: + raise RuntimeError("Hermes core Relay session is unavailable") + return self.host.run_in_session( + state.relay_session, + callback, + *args, + **kwargs, ) - def _finish_model_call( - self, - state: _SessionState, - request_id: str, - outcome: str, - ) -> None: - model_call = state.metrics_model_calls.pop(request_id, None) - if model_call is None: - return - try: - self._run_in_metrics_context( - state, - self.nemo_relay.llm.call_end, - model_call.handle, - {**model_call.fields, "outcome": outcome}, - metadata={_SHARED_METRICS_SCHEMA_KEY: _SHARED_METRICS_SCHEMA_VERSION}, - ) - except Exception: - logger.warning( - "NeMo Relay shared-metrics model-call end failed", exc_info=True - ) - - @_with_session_state_lock - def end_model_call(self, kwargs: dict[str, Any], state: _SessionState) -> None: - request_id = str(kwargs.get("api_request_id") or "") - model_call = state.metrics_model_calls.get(request_id) - if model_call is None: - return - fields = _model_call_fields(kwargs) - fields.pop("model_family") - model_call.fields = fields - self._finish_model_call(state, request_id, _model_call_outcome(kwargs)) - - @_with_session_state_lock - def end_pending_model_calls( - self, - kwargs: dict[str, Any], - state: _SessionState, - ) -> None: - self._end_pending_model_calls(state, kwargs) - - def _end_pending_model_calls( - self, - state: _SessionState, - kwargs: dict[str, Any], - ) -> None: - task_id = str(kwargs.get("task_id") or "") - request_ids = [ - request_id - for request_id, model_call in state.metrics_model_calls.items() - if not task_id or model_call.task_id == task_id - ] - outcome = "cancelled" if kwargs.get("interrupted") else "failed" - for request_id in request_ids: - self._finish_model_call(state, request_id, outcome) - def export_atif(self, state: _SessionState) -> None: if not self.settings.atif_enabled or state.atif_exporter is None: return - if ( - state.is_embedded_subagent - and self.settings.atif_subagent_export_mode != "all" - ): + if state.is_embedded_subagent and self.settings.atif_subagent_export_mode != "all": return output_dir = self.settings.atif_output_directory if not output_dir: return Path(output_dir).mkdir(parents=True, exist_ok=True) - filename = self.settings.atif_filename_template.format( - session_id=state.session_id - ) - Path(output_dir, filename).write_text( - state.atif_exporter.export_json(), encoding="utf-8" - ) - - def _clear_static_plugins_after_last_session(self) -> None: - """Clear static plugin state without holding the session-map lock.""" - with self._plugin_lifecycle_lock: - with self._state_lock: - if self.sessions or self._plugin_activation is not None: - return - should_clear = self._plugin_config_initialized - if not should_clear and self.settings.plugins_config: - self._plugin_config_needs_reinit = True - if should_clear: - self._clear_plugins_toml() + filename = self.settings.atif_filename_template.format(session_id=state.session_id) + Path(output_dir, filename).write_text(state.atif_exporter.export_json(), encoding="utf-8") def close_session(self, kwargs: dict[str, Any]) -> None: session_id = _session_id(kwargs) - with self._state_lock: - self.subagent_parents.pop(session_id, None) - state = self.sessions.get(session_id) + self.subagent_contexts.pop(session_id, None) + state = self.sessions.pop(session_id, None) if state is None: return failures: list[str] = [] - with state.lock: - if state.closing: - return - state.closing = True - self._end_pending_model_calls(state, {"session_id": session_id}) - if state.metrics_handle is not None and state.metrics_context is not None: - try: - self._run_in_metrics_context( - state, - self.nemo_relay.scope.pop, - state.metrics_handle, - output={}, - metadata={ - _SHARED_METRICS_SCHEMA_KEY: _SHARED_METRICS_SCHEMA_VERSION - }, - ) - except Exception as exc: - failures.append(f"shared-metrics session scope pop failed: {exc}") - if state.handle is not None: - try: - self.nemo_relay.scope.pop(state.handle, output=_jsonable(kwargs)) - except Exception as exc: - failures.append(f"session scope pop failed: {exc}") try: - _flush_relay_subscribers(self.nemo_relay) + self.host.close_session(kwargs) except Exception as exc: - failures.append(f"subscriber flush failed: {exc}") - self.export_shared_metrics() + failures.append(f"core session close failed: {exc}") try: self.export_atif(state) except Exception as exc: @@ -641,13 +327,21 @@ class _Runtime: state.atif_exporter.deregister(state.atif_subscriber_name) except Exception as exc: failures.append(f"ATIF deregister failed: {exc}") - with self._state_lock: - if self.sessions.get(session_id) is state: - self.sessions.pop(session_id, None) - try: - self._clear_static_plugins_after_last_session() - except Exception as exc: - failures.append(f"plugin configuration clear failed: {exc}") + if ( + self._plugin_config_initialized + and self._plugin_activation is None + and not self.sessions + ): + try: + self._clear_plugins_toml() + except Exception as exc: + failures.append(f"plugin configuration clear failed: {exc}") + elif ( + self.settings.plugins_config + and self._plugin_activation is None + and not self.sessions + ): + self._plugin_config_needs_reinit = True if failures: logger.warning( "NeMo Relay session %s teardown completed with errors: %s", @@ -658,38 +352,17 @@ class _Runtime: def shutdown(self) -> None: """Close active sessions and the process-lifetime plugin activation.""" failures: list[str] = [] - with self._state_lock: - session_ids = list(self.sessions) - for session_id in session_ids: + for session_id in list(self.sessions): try: - self.close_session({ - "session_id": session_id, - "reason": "runtime_shutdown", - }) + self.close_session({"session_id": session_id, "reason": "runtime_shutdown"}) except Exception as exc: failures.append(f"session {session_id} close failed: {exc}") - with self._plugin_lifecycle_lock: - if self._plugin_config_initialized: - try: - self._clear_plugins_toml() - except Exception as exc: - failures.append(f"plugin runtime close failed: {exc}") + if self._plugin_config_initialized: + try: + self._clear_plugins_toml() + except Exception as exc: + failures.append(f"plugin runtime close failed: {exc}") self._clear_atof() - if self._shared_metrics_registered: - try: - _flush_relay_subscribers(self.nemo_relay) - except Exception as exc: - failures.append(f"shared-metrics subscriber flush failed: {exc}") - self.export_shared_metrics() - try: - subscribers = getattr(self.nemo_relay, "subscribers", None) - deregister = getattr(subscribers, "deregister", None) - if callable(deregister): - deregister(_SHARED_METRICS_SUBSCRIBER_NAME) - except Exception as exc: - failures.append(f"shared-metrics subscriber deregister failed: {exc}") - finally: - self._shared_metrics_registered = False if self._shutdown_registered and self._plugin_activation is None: atexit.unregister(self.shutdown) self._shutdown_registered = False @@ -701,7 +374,9 @@ class _Runtime: def mark(self, name: str, kwargs: dict[str, Any]) -> None: state = self.ensure_session(kwargs) - self.nemo_relay.scope.event( + self.run_in_session( + state, + self.nemo_relay.scope.event, name, handle=state.handle, data=_jsonable(kwargs), @@ -709,16 +384,18 @@ class _Runtime: ) def mark_subagent_start(self, kwargs: dict[str, Any]) -> None: + self.host.register_subagent(kwargs) parent_state = self.ensure_session(kwargs) metadata = _metadata(kwargs) child_session_id = _child_session_id(kwargs) if child_session_id: - self.subagent_parents[child_session_id] = _SubagentParent( + self.subagent_contexts[child_session_id] = _SubagentContext( parent_session_id=parent_state.session_id, - parent_handle=parent_state.handle, metadata=_subagent_child_metadata(kwargs, metadata), ) - self.nemo_relay.scope.event( + self.run_in_session( + parent_state, + self.nemo_relay.scope.event, "hermes.subagent.start", handle=parent_state.handle, data=_jsonable(kwargs), @@ -726,25 +403,23 @@ class _Runtime: ) def mark_subagent_stop(self, kwargs: dict[str, Any]) -> None: + self.host.unregister_subagent(kwargs) child_session_id = _child_session_id(kwargs) if child_session_id: - self.subagent_parents.pop(child_session_id, None) + self.subagent_contexts.pop(child_session_id, None) self.mark("hermes.subagent.stop", kwargs) def managed_llm_enabled(self) -> bool: return ( (self.settings.adaptive_enabled or self._plugin_activation is not None) - and callable( - getattr(getattr(self.nemo_relay, "llm", None), "execute", None) - ) + and callable(getattr(getattr(self.nemo_relay, "llm", None), "execute", None)) and callable(getattr(self.nemo_relay, "LLMRequest", None)) ) def managed_tool_enabled(self) -> bool: return ( - self.settings.adaptive_enabled or self._plugin_activation is not None - ) and callable( - getattr(getattr(self.nemo_relay, "tools", None), "execute", None) + (self.settings.adaptive_enabled or self._plugin_activation is not None) + and callable(getattr(getattr(self.nemo_relay, "tools", None), "execute", None)) ) def _run_managed_with_downstream_preservation( @@ -782,9 +457,7 @@ class _Runtime: try: managed_result = _resolve_awaitable(make_managed_execute(_impl)) except Exception as exc: - if downstream_error is not None and _is_relay_wrapped_callback_error( - exc, callback_error - ): + if downstream_error is not None and _is_relay_wrapped_callback_error(exc, callback_error): raise downstream_error raise if ( @@ -809,17 +482,21 @@ class _Runtime: def _make_managed(impl: Callable[[Any], Any]) -> Any: async def _managed_execute() -> Any: - result = self.nemo_relay.llm.execute( + result = self.run_in_session( + state, + self.nemo_relay.llm.execute, _relay_llm_surface(kwargs), request, impl, handle=state.handle, - data=_jsonable({ - "turn_id": kwargs.get("turn_id"), - "api_request_id": kwargs.get("api_request_id"), - "api_call_count": kwargs.get("api_call_count"), - "mode": self.settings.adaptive_mode, - }), + data=_jsonable( + { + "turn_id": kwargs.get("turn_id"), + "api_request_id": kwargs.get("api_request_id"), + "api_call_count": kwargs.get("api_call_count"), + "mode": self.settings.adaptive_mode, + } + ), metadata=_metadata(kwargs), model_name=str(kwargs.get("model") or ""), ) @@ -830,11 +507,7 @@ class _Runtime: return _managed_execute() return self._run_managed_with_downstream_preservation( - next_call, - _normalize, - _llm_response_payload, - _make_managed, - preserve_raw_response=True, + next_call, _normalize, _llm_response_payload, _make_managed, preserve_raw_response=True ) def execute_tool(self, kwargs: dict[str, Any]) -> Any: @@ -850,17 +523,21 @@ class _Runtime: def _make_managed(impl: Callable[[Any], Any]) -> Any: async def _managed_execute() -> Any: - result = self.nemo_relay.tools.execute( + result = self.run_in_session( + state, + self.nemo_relay.tools.execute, tool_name, args, impl, handle=state.handle, - data=_jsonable({ - "turn_id": kwargs.get("turn_id"), - "api_request_id": kwargs.get("api_request_id"), - "tool_call_id": kwargs.get("tool_call_id"), - "mode": self.settings.adaptive_mode, - }), + data=_jsonable( + { + "turn_id": kwargs.get("turn_id"), + "api_request_id": kwargs.get("api_request_id"), + "tool_call_id": kwargs.get("tool_call_id"), + "mode": self.settings.adaptive_mode, + } + ), metadata=_metadata(kwargs), ) if inspect.isawaitable(result): @@ -906,16 +583,8 @@ def on_session_start(**kwargs: Any) -> None: def on_session_end(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is None: - return - _safe(lambda: runtime.end_pending_model_calls(kwargs)) - if runtime.rich_observability_enabled(): - _safe( - lambda: ( - runtime.mark("hermes.session.end", kwargs), - runtime.export_atif(runtime.ensure_session(kwargs)), - ) - ) + if runtime is not None: + _safe(lambda: (runtime.mark("hermes.session.end", kwargs), runtime.export_atif(runtime.ensure_session(kwargs)))) def on_session_finalize(**kwargs: Any) -> None: @@ -932,13 +601,13 @@ def on_session_reset(**kwargs: Any) -> None: def on_pre_llm_call(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is not None and runtime.rich_observability_enabled(): + if runtime is not None: _safe(lambda: runtime.mark("hermes.turn.start", kwargs)) def on_post_llm_call(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is not None and runtime.rich_observability_enabled(): + if runtime is not None: _safe(lambda: runtime.mark("hermes.turn.end", kwargs)) @@ -946,27 +615,21 @@ def on_pre_api_request(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return - _safe(lambda: runtime.start_model_call(kwargs)) - if not runtime.rich_observability_enabled(): - return if runtime.managed_llm_enabled(): return def _record() -> None: state = runtime.ensure_session(kwargs) request_payload = kwargs.get("request") - request_body = ( - request_payload.get("body") if isinstance(request_payload, dict) else {} - ) + request_body = request_payload.get("body") if isinstance(request_payload, dict) else {} request = runtime.nemo_relay.LLMRequest({}, _jsonable(request_body)) - span = runtime.nemo_relay.llm.call( + span = runtime.run_in_session( + state, + runtime.nemo_relay.llm.call, str(kwargs.get("provider") or "llm"), request, handle=state.handle, - data=_jsonable({ - "turn_id": kwargs.get("turn_id"), - "api_request_id": kwargs.get("api_request_id"), - }), + data=_jsonable({"turn_id": kwargs.get("turn_id"), "api_request_id": kwargs.get("api_request_id")}), metadata=_metadata(kwargs), model_name=str(kwargs.get("model") or ""), ) @@ -979,9 +642,6 @@ def on_post_api_request(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return - _safe(lambda: runtime.end_model_call({**kwargs, "outcome": "success"})) - if not runtime.rich_observability_enabled(): - return if runtime.managed_llm_enabled(): return @@ -991,13 +651,12 @@ def on_post_api_request(**kwargs: Any) -> None: if span is None: runtime.mark("hermes.api.response.unmatched", kwargs) return - runtime.nemo_relay.llm.call_end( + runtime.run_in_session( + state, + runtime.nemo_relay.llm.call_end, span, _jsonable(kwargs.get("response") or {}), - data=_jsonable({ - "usage": kwargs.get("usage"), - "finish_reason": kwargs.get("finish_reason"), - }), + data=_jsonable({"usage": kwargs.get("usage"), "finish_reason": kwargs.get("finish_reason")}), metadata=_metadata(kwargs), ) @@ -1006,7 +665,7 @@ def on_post_api_request(**kwargs: Any) -> None: def on_api_request_error(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is None or not runtime.rich_observability_enabled(): + if runtime is None: return if runtime.managed_llm_enabled(): return @@ -1017,7 +676,9 @@ def on_api_request_error(**kwargs: Any) -> None: if span is None: runtime.mark("hermes.api.error", kwargs) return - runtime.nemo_relay.llm.call_end( + runtime.run_in_session( + state, + runtime.nemo_relay.llm.call_end, span, {"error": _jsonable(kwargs.get("error") or {})}, data=_jsonable(kwargs), @@ -1031,21 +692,18 @@ def on_pre_tool_call(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return - if not runtime.rich_observability_enabled(): - return if runtime.managed_tool_enabled(): return def _record() -> None: state = runtime.ensure_session(kwargs) - span = runtime.nemo_relay.tools.call( + span = runtime.run_in_session( + state, + runtime.nemo_relay.tools.call, str(kwargs.get("tool_name") or "tool"), _jsonable(kwargs.get("args") or {}), handle=state.handle, - data=_jsonable({ - "turn_id": kwargs.get("turn_id"), - "api_request_id": kwargs.get("api_request_id"), - }), + data=_jsonable({"turn_id": kwargs.get("turn_id"), "api_request_id": kwargs.get("api_request_id")}), metadata=_metadata(kwargs), tool_call_id=str(kwargs.get("tool_call_id") or ""), ) @@ -1058,8 +716,6 @@ def on_post_tool_call(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is None: return - if not runtime.rich_observability_enabled(): - return if runtime.managed_tool_enabled(): return @@ -1069,13 +725,12 @@ def on_post_tool_call(**kwargs: Any) -> None: if span is None: runtime.mark("hermes.tool.response.unmatched", kwargs) return - runtime.nemo_relay.tools.call_end( + runtime.run_in_session( + state, + runtime.nemo_relay.tools.call_end, span, _jsonable(kwargs.get("result")), - data=_jsonable({ - "status": kwargs.get("status"), - "duration_ms": kwargs.get("duration_ms"), - }), + data=_jsonable({"status": kwargs.get("status"), "duration_ms": kwargs.get("duration_ms")}), metadata=_metadata(kwargs), ) @@ -1084,29 +739,25 @@ def on_post_tool_call(**kwargs: Any) -> None: def on_pre_approval_request(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is None: - return - if runtime.rich_observability_enabled(): + if runtime is not None: _safe(lambda: runtime.mark("hermes.approval.request", kwargs)) def on_post_approval_response(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is None: - return - if runtime.rich_observability_enabled(): + if runtime is not None: _safe(lambda: runtime.mark("hermes.approval.response", kwargs)) def on_subagent_start(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is not None and runtime.rich_observability_enabled(): + if runtime is not None: _safe(lambda: runtime.mark_subagent_start(kwargs)) def on_subagent_stop(**kwargs: Any) -> None: runtime = _get_runtime() - if runtime is not None and runtime.rich_observability_enabled(): + if runtime is not None: _safe(lambda: runtime.mark_subagent_stop(kwargs)) @@ -1140,17 +791,16 @@ def _get_runtime() -> Optional[_Runtime]: if isinstance(_RUNTIME, _Runtime): return _RUNTIME try: - import nemo_relay as nemo_runtime - except Exception as exc: - logger.debug("NeMo Relay plugin disabled: import failed: %s", exc) - _RUNTIME = _INIT_FAILED - return None - try: - _RUNTIME = _Runtime(nemo_relay=nemo_runtime, settings=_load_settings()) - except Exception as exc: - logger.debug( - "NeMo Relay plugin disabled: init failed: %s", exc, exc_info=True + host = relay_runtime.get_runtime() + if host is None: + raise RuntimeError("Hermes core Relay runtime is unavailable") + _RUNTIME = _Runtime( + nemo_relay=host.relay, + settings=_load_settings(), + host=host, ) + except Exception as exc: + logger.debug("NeMo Relay plugin disabled: init failed: %s", exc, exc_info=True) _RUNTIME = _INIT_FAILED return None return _RUNTIME @@ -1161,7 +811,6 @@ def _load_settings() -> _Settings: plugins_config = _load_plugins_config(plugins_toml_path) adaptive_config = _enabled_component_config(plugins_config, "adaptive") return _Settings( - shared_metrics_enabled=_shared_metrics_enabled(), plugins_toml_path=plugins_toml_path, plugins_config=plugins_config, dynamic_plugins=_dynamic_plugin_specs(plugins_config, plugins_toml_path), @@ -1173,8 +822,7 @@ def _load_settings() -> _Settings: atof_mode=_env("HERMES_NEMO_RELAY_ATOF_MODE") or "append", atif_enabled=_env_bool("HERMES_NEMO_RELAY_ATIF_ENABLED"), atif_output_directory=_env("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY"), - atif_filename_template=_env("HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE") - or "hermes-atif-{session_id}.json", + atif_filename_template=_env("HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE") or "hermes-atif-{session_id}.json", atif_subagent_export_mode=_atif_subagent_export_mode(), atif_agent_name=_env("HERMES_NEMO_RELAY_ATIF_AGENT_NAME") or "Hermes Agent", atif_agent_version=_env("HERMES_NEMO_RELAY_ATIF_AGENT_VERSION") or "unknown", @@ -1182,31 +830,6 @@ def _load_settings() -> _Settings: ) -def _shared_metrics_enabled() -> bool: - try: - from hermes_cli.config import load_config_readonly - - config = load_config_readonly() or {} - except Exception: - logger.debug( - "Unable to read Hermes shared-metrics configuration", exc_info=True - ) - return False - if not isinstance(config, dict): - return False - plugins = config.get("plugins") - if not isinstance(plugins, dict): - return False - entries = plugins.get("entries") - if not isinstance(entries, dict): - return False - entry = entries.get("observability/nemo_relay") or entries.get("nemo_relay") - if not isinstance(entry, dict): - return False - shared_metrics = entry.get("shared_metrics") - return isinstance(shared_metrics, dict) and shared_metrics.get("enabled") is True - - def _static_plugin_config(plugins_config: dict[str, Any]) -> dict[str, Any]: """Return Relay's base config without embedding- or gateway-host fields.""" return { @@ -1279,15 +902,13 @@ def _dynamic_plugin_specs( continue if not isinstance(manifest_ref, str) or not manifest_ref.strip(): logger.warning( - "Invalid NeMo Relay dynamic_plugins[%d]: manifest_ref is required", - index, + "Invalid NeMo Relay dynamic_plugins[%d]: manifest_ref is required", index ) invalid = True continue if not isinstance(config, dict): logger.warning( - "Invalid NeMo Relay dynamic_plugins[%d]: config must be an object", - index, + "Invalid NeMo Relay dynamic_plugins[%d]: config must be an object", index ) invalid = True continue @@ -1304,9 +925,7 @@ def _dynamic_plugin_specs( spec: dict[str, Any] = { "plugin_id": plugin_id.strip(), "kind": kind, - "manifest_ref": _config_relative_path( - manifest_ref.strip(), plugins_toml_path - ), + "manifest_ref": _config_relative_path(manifest_ref.strip(), plugins_toml_path), "config": config, } if environment_ref is not None: @@ -1328,9 +947,7 @@ def _config_relative_path(value: str, plugins_toml_path: str) -> str: path = Path(value) if path.is_absolute(): return str(path) - config_path = ( - Path(plugins_toml_path) if plugins_toml_path else Path.cwd() / "plugins.toml" - ) + config_path = Path(plugins_toml_path) if plugins_toml_path else Path.cwd() / "plugins.toml" if not config_path.is_absolute(): config_path = Path.cwd() / config_path return os.path.abspath(config_path.parent / path) @@ -1421,7 +1038,8 @@ def _child_session_id(kwargs: dict[str, Any]) -> str: def _subagent_child_metadata( - kwargs: dict[str, Any], parent_metadata: dict[str, Any] + kwargs: dict[str, Any], + parent_metadata: dict[str, Any], ) -> dict[str, Any]: child_session_id = _child_session_id(kwargs) metadata = { @@ -1447,10 +1065,7 @@ def _subagent_child_metadata( def _api_key(kwargs: dict[str, Any]) -> str: - return str( - kwargs.get("api_request_id") - or f"{_session_id(kwargs)}:{kwargs.get('api_call_count') or 'api'}" - ) + return str(kwargs.get("api_request_id") or f"{_session_id(kwargs)}:{kwargs.get('api_call_count') or 'api'}") def _tool_key(kwargs: dict[str, Any]) -> str: @@ -1530,10 +1145,13 @@ def _jsonable(value: Any) -> Any: def _json_semantically_equal(left: Any, right: Any) -> bool: """Compare JSON-compatible values without conflating booleans and numbers.""" try: - options = {"ensure_ascii": False, "sort_keys": True, "separators": (",", ":")} - return json.dumps(_jsonable(left), **options) == json.dumps( - _jsonable(right), **options + left_json = json.dumps( + _jsonable(left), ensure_ascii=False, sort_keys=True, separators=(",", ":") ) + right_json = json.dumps( + _jsonable(right), ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + return left_json == right_json except (TypeError, ValueError): return False @@ -1548,16 +1166,12 @@ def _original_downstream_error(exc: Exception) -> BaseException: # Hermes wraps downstream execution failures in a local/private exception # class, so detect the wrapper by shape instead of importing it here. original = getattr(exc, "original", None) - if exc.__class__.__name__ == "_DownstreamExecutionError" and isinstance( - original, BaseException - ): + if exc.__class__.__name__ == "_DownstreamExecutionError" and isinstance(original, BaseException): return original return exc -def _is_relay_wrapped_callback_error( - exc: Exception, callback_error: Exception | None -) -> bool: +def _is_relay_wrapped_callback_error(exc: Exception, callback_error: Exception | None) -> bool: # NeMo Relay re-wraps a failing callback as ``RuntimeError("internal error: # : ")``. Match by prefix rather than exact equality so a # trailing traceback/suffix in a future Relay version doesn't silently defeat @@ -1598,25 +1212,13 @@ def _llm_response_payload(response: Any) -> Any: if reasoning is not None: assistant_message["reasoning_content"] = _jsonable(reasoning) elif isinstance(payload, dict): - assistant_message["content"] = ( - payload.get("content") or payload.get("output_text") or "" - ) + assistant_message["content"] = payload.get("content") or payload.get("output_text") or "" return { - "model": _value( - response, - "model", - payload.get("model") if isinstance(payload, dict) else None, - ), + "model": _value(response, "model", payload.get("model") if isinstance(payload, dict) else None), "assistant_message": assistant_message, "finish_reason": finish_reason, - "usage": _jsonable( - _value( - response, - "usage", - payload.get("usage") if isinstance(payload, dict) else None, - ) - ), + "usage": _jsonable(_value(response, "usage", payload.get("usage") if isinstance(payload, dict) else None)), } @@ -1626,14 +1228,16 @@ def _tool_calls_payload(tool_calls: Any) -> list[dict[str, Any]]: normalized: list[dict[str, Any]] = [] for call in tool_calls: function = _value(call, "function") - normalized.append({ - "id": _value(call, "id"), - "type": _value(call, "type", "function") or "function", - "function": { - "name": _value(function, "name"), - "arguments": _value(function, "arguments"), - }, - }) + normalized.append( + { + "id": _value(call, "id"), + "type": _value(call, "type", "function") or "function", + "function": { + "name": _value(function, "name"), + "arguments": _value(function, "arguments"), + }, + } + ) return normalized diff --git a/pyproject.toml b/pyproject.toml index 19950019f4c..5031122d16c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,6 +138,10 @@ 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.5 publishes + # native wheels for these targets only; keep unsupported Hermes targets + # installable without moving Relay back behind a user-selected extra. + "nemo-relay==0.5.0; (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] @@ -205,7 +209,6 @@ vision = [] # 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.0.1"] # starlette: CVE-2026-48710 -nemo-relay = ["nemo-relay>=0.5.0,<0.6.0"] 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 @@ -336,7 +339,7 @@ locales = ["locales/*.yaml"] "optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"] [tool.setuptools.package-data] -hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"] +hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1", "observability/schemas/*.json"] gateway = ["assets/**/*"] plugins = [ "*/dashboard/manifest.json", @@ -351,7 +354,6 @@ plugins = [ "**/plugin.yaml", "**/plugin.yml", "**/README.md", - "**/schemas/*.json", ] [tool.setuptools.packages.find] diff --git a/scripts/smoke_nemo_relay_shared_metrics.py b/scripts/smoke_nemo_relay_shared_metrics.py index 8ea4dde1b20..76e0a59538d 100644 --- a/scripts/smoke_nemo_relay_shared_metrics.py +++ b/scripts/smoke_nemo_relay_shared_metrics.py @@ -152,7 +152,7 @@ def _arguments() -> argparse.Namespace: "--relay-python", type=Path, default=None, - help="NeMo Relay checkout's python directory", + help="Optional NeMo Relay checkout's python directory", ) parser.add_argument( "--output-dir", @@ -174,13 +174,9 @@ def _write_config(home: Path, port: int) -> None: api_key: no-key-required security: tirith_enabled: false -plugins: - enabled: - - observability/nemo_relay - entries: - observability/nemo_relay: - shared_metrics: - enabled: true +telemetry: + shared_metrics: + enabled: true """, encoding="utf-8", ) @@ -270,15 +266,13 @@ def _validate_package(outbox: Path, schema_path: Path) -> tuple[Path, dict[str, def main() -> int: args = _arguments() hermes_repo = args.hermes_repo.resolve() - relay_python = ( - args.relay_python.resolve() - if args.relay_python - else (hermes_repo.parent / "nemo-relay" / "python").resolve() - ) + relay_python = args.relay_python.resolve() if args.relay_python else None hermes = hermes_repo / ".venv" / "bin" / "hermes" if not hermes.is_file(): raise SystemExit(f"Hermes executable not found: {hermes}") - if not any((relay_python / "nemo_relay").glob("_native.*")): + 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" @@ -305,10 +299,11 @@ def main() -> int: _write_config(home, server.server_port) env = os.environ.copy() env["HERMES_HOME"] = str(home) - env["PYTHONPATH"] = os.pathsep.join([ - str(relay_python), - env.get("PYTHONPATH", ""), - ]).rstrip(os.pathsep) + 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), @@ -359,9 +354,8 @@ def main() -> int: package_path, package = _validate_package( telemetry / "outbox", hermes_repo - / "plugins" + / "hermes_cli" / "observability" - / "nemo_relay" / "schemas" / "hermes.shared_metrics.v1.schema.json", ) diff --git a/tests/plugins/test_nemo_relay_shared_metrics.py b/tests/hermes_cli/test_relay_shared_metrics.py similarity index 97% rename from tests/plugins/test_nemo_relay_shared_metrics.py rename to tests/hermes_cli/test_relay_shared_metrics.py index 5c2c1a87b81..a3bc18e8fbf 100644 --- a/tests/plugins/test_nemo_relay_shared_metrics.py +++ b/tests/hermes_cli/test_relay_shared_metrics.py @@ -15,8 +15,8 @@ from types import SimpleNamespace from typing import Any import pytest -from plugins.observability.nemo_relay.shared_metrics import SharedMetricsStore -from plugins.observability.nemo_relay.shared_metrics_contract import ( +from hermes_cli.observability.shared_metrics import SharedMetricsStore +from hermes_cli.observability.shared_metrics_contract import ( MODEL_FAMILIES, MODEL_LOCALITIES, MODEL_OUTCOMES, @@ -33,9 +33,8 @@ from plugins.observability.nemo_relay.shared_metrics_contract import ( SCHEMA_PATH = ( Path(__file__).resolve().parents[2] - / "plugins" + / "hermes_cli" / "observability" - / "nemo_relay" / "schemas" / "hermes.shared_metrics.v1.schema.json" ) @@ -211,6 +210,12 @@ def test_model_family_accepts_only_allowlisted_declared_metadata(): 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"), [ @@ -245,6 +250,7 @@ def test_subscriber_contract_rejects_unknown_fields_and_dimension_values(): data={ "call_role": "primary", "locality": "remote", + "model_family": "gpt", "outcome": "success", "provider_family": "direct", }, diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py new file mode 100644 index 00000000000..bbd612a4d5c --- /dev/null +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -0,0 +1,463 @@ +"""Tests for the direct Hermes-to-Relay shared-metrics runtime.""" + +from __future__ import annotations + +import contextvars +import json +import threading +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from hermes_cli import plugins +from hermes_cli.observability import relay_runtime, relay_shared_metrics +from hermes_cli.plugins import PluginManager + + +class _Request: + def __init__(self, headers: dict[str, Any], content: dict[str, Any]) -> None: + self.headers = headers + self.content = content + + +class _Relay: + def __init__(self) -> None: + self.events: list[tuple[Any, ...]] = [] + self._callbacks: dict[str, Any] = {} + self._starts: dict[Any, dict[str, Any]] = {} + self._scope = contextvars.ContextVar("relay_scope", default=None) + self._scope_serial = 0 + self.ScopeType = SimpleNamespace(Agent="agent") + self.LLMRequest = _Request + self.scope = SimpleNamespace( + push=self._scope_push, + pop=self._scope_pop, + event=self._scope_event, + ) + self.llm = SimpleNamespace(call=self._llm_call, call_end=self._llm_call_end) + self.subscribers = SimpleNamespace( + register=self._register, + deregister=self._deregister, + flush=self._flush, + ) + self.get_scope_stack = self._get_scope_stack + + def _scope_push(self, name: str, scope_type: Any, **kwargs: Any) -> Any: + self._scope_serial += 1 + handle = ("scope", name, self._scope_serial) + self._scope.set(handle) + self.events.append(("scope.push", name, scope_type, kwargs)) + return handle + + def _scope_pop(self, handle: Any, **kwargs: Any) -> None: + self.events.append(("scope.pop", handle, kwargs)) + + def _scope_event(self, name: str, **kwargs: Any) -> None: + self.events.append(("scope.event", name, kwargs)) + + def _get_scope_stack(self) -> Any: + current = self._scope.get() + self.events.append(("scope.sync", current)) + return current + + def _llm_call( + self, + name: str, + request: _Request, + **kwargs: Any, + ) -> Any: + handle = ("llm", name, len(self._starts)) + self._starts[handle] = kwargs + self.events.append(("llm.call", name, request.content, kwargs)) + return handle + + def _llm_call_end( + self, + handle: Any, + response: dict[str, Any], + **kwargs: Any, + ) -> None: + start = self._starts.pop(handle) + self.events.append(("llm.call_end", handle, response, kwargs)) + event = SimpleNamespace( + kind="scope", + category="llm", + name=handle[1], + scope_category="end", + category_profile={"model_name": start["model_name"]}, + metadata={ + **start["metadata"], + **kwargs["metadata"], + "otel.status_code": "OK", + }, + data=response, + ) + for callback in list(self._callbacks.values()): + callback(event) + + def _register(self, name: str, callback: Any) -> None: + self._callbacks[name] = callback + self.events.append(("subscribers.register", name)) + + def _deregister(self, name: str) -> None: + self._callbacks.pop(name, None) + self.events.append(("subscribers.deregister", name)) + + def _flush(self) -> None: + self.events.append(("subscribers.flush",)) + + +@pytest.fixture +def direct_runtime(tmp_path, monkeypatch): + fake = _Relay() + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) + monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", + lambda: {"telemetry": {"shared_metrics": {"enabled": True}}}, + ) + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + monkeypatch.setattr(plugins, "_plugin_manager", PluginManager()) + yield fake + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + + +def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_path): + base = { + "session_id": "sensitive-session", + "task_id": "task-1", + "api_request_id": "request-1", + "platform": "cli", + "provider": "custom", + "model": "gpt-sensitive-model-id", + "base_url": "http://127.0.0.1:11434/v1", + } + + assert plugins.has_hook("pre_api_request") + plugins.invoke_hook("on_session_start", **base) + plugins.invoke_hook( + "pre_api_request", + **base, + request={"body": {"messages": ["sensitive-prompt"]}}, + ) + plugins.invoke_hook( + "api_request_error", + **base, + retryable=True, + error={"message": "sensitive-error"}, + ) + plugins.invoke_hook( + "pre_api_request", + **{ + **base, + "provider": "anthropic", + "model": "claude-sonnet", + "base_url": "https://api.anthropic.com", + }, + request={"body": {"messages": ["sensitive-prompt"]}}, + ) + plugins.invoke_hook( + "post_api_request", + **{ + **base, + "provider": "anthropic", + "model": "claude-sonnet", + "base_url": "https://api.anthropic.com", + }, + response={"content": "sensitive-response"}, + ) + plugins.invoke_hook("on_session_finalize", session_id=base["session_id"]) + + starts = [event for event in direct_runtime.events if event[0] == "llm.call"] + ends = [event for event in direct_runtime.events if event[0] == "llm.call_end"] + session_starts = [ + event for event in direct_runtime.events if event[0] == "scope.push" + ] + assert len(session_starts) == 1 + assert len(starts) == 1 + assert len(ends) == 1 + assert starts[0][2] == {} + assert starts[0][3]["model_name"] == "gpt" + assert ends[0][2] == { + "call_role": "primary", + "locality": "remote", + "model_family": "claude", + "outcome": "success", + "provider_family": "direct", + } + serialized_events = json.dumps(direct_runtime.events) + assert "sensitive-prompt" not in serialized_events + assert "sensitive-response" not in serialized_events + assert "sensitive-error" not in serialized_events + assert "gpt-sensitive-model-id" not in serialized_events + assert plugins.get_plugin_manager().list_plugins() == [] + + root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics" + packages = list((root / "outbox").glob("*.json")) + assert len(packages) == 1 + package = json.loads(packages[0].read_text(encoding="utf-8")) + assert package["metrics"][0]["name"] == "hermes.model_call.count" + assert package["metrics"][0]["dimensions"]["model_family"] == "claude" + assert package["metrics"][0]["value"] == 1 + + +def test_direct_runtime_is_disabled_by_default(tmp_path, monkeypatch): + fake = _Relay() + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) + monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {}) + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + monkeypatch.setattr(plugins, "_plugin_manager", PluginManager()) + + assert not plugins.has_hook("pre_api_request") + plugins.invoke_hook("on_session_start", session_id="s1", platform="cli") + plugins.invoke_hook("on_session_finalize", session_id="s1") + + assert fake.events == [] + assert not (tmp_path / "hermes-home" / "telemetry").exists() + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + + +def test_core_runtime_is_fail_open_without_a_published_binding(monkeypatch, caplog): + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + def missing_relay(name: str): + assert name == "nemo_relay" + raise ModuleNotFoundError(name) + + monkeypatch.setattr(relay_runtime.importlib, "import_module", missing_relay) + + assert relay_runtime.get_runtime() is None + assert not relay_runtime.emit_mark("hermes.probe", session_id="s1") + assert "Hermes Relay runtime initialization failed" in caplog.text + relay_runtime._reset_for_tests() + + +def test_core_mark_uses_the_shared_session_handle_without_a_plugin(direct_runtime): + plugins.invoke_hook("on_session_start", session_id="s1", platform="cli") + + handle = relay_runtime.get_session_handle("s1") + assert handle is not None + assert relay_runtime.emit_mark( + "hermes.skill.created", + session_id="s1", + data={"provenance": "agent_created"}, + metadata={"data_schema": "hermes.skill.lifecycle.v1"}, + ) + + [mark] = [event for event in direct_runtime.events if event[0] == "scope.event"] + assert mark[1] == "hermes.skill.created" + assert mark[2]["handle"] == handle + assert plugins.get_plugin_manager().list_plugins() == [] + + +def test_core_mark_lazily_starts_relay_without_metrics_or_a_plugin( + tmp_path, + monkeypatch, +): + fake = _Relay() + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) + monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {}) + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + monkeypatch.setattr(plugins, "_plugin_manager", PluginManager()) + + assert relay_runtime.emit_mark( + "hermes.skill.created", + session_id="s1", + data={"provenance": "agent_created"}, + ) + plugins.invoke_hook("on_session_finalize", session_id="s1") + + assert [event[0] for event in fake.events] == [ + "scope.push", + "scope.sync", + "scope.event", + "scope.sync", + "scope.pop", + "subscribers.flush", + ] + assert not any(event[0] == "subscribers.register" for event in fake.events) + assert not (tmp_path / "hermes-home" / "telemetry").exists() + relay_runtime._reset_for_tests() + + +def test_core_runtime_creates_one_session_under_concurrent_access(direct_runtime): + runtime = relay_runtime.get_runtime() + assert runtime is not None + ready = threading.Barrier(8) + sessions: list[Any] = [] + + def ensure() -> None: + ready.wait(timeout=5) + sessions.append(runtime.ensure_session({"session_id": "shared"})) + + threads = [threading.Thread(target=ensure) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert all(not thread.is_alive() for thread in threads) + assert len({id(session) for session in sessions}) == 1 + assert ( + len([event for event in direct_runtime.events if event[0] == "scope.push"]) == 1 + ) + + +def test_core_runtime_parents_subagent_session_without_exposing_ids( + direct_runtime, +): + plugins.invoke_hook("on_session_start", session_id="parent", platform="cli") + parent_handle = relay_runtime.get_session_handle("parent") + + plugins.invoke_hook( + "subagent_start", + parent_session_id="parent", + child_session_id="sensitive-child", + child_subagent_id="sensitive-subagent", + ) + plugins.invoke_hook( + "on_session_start", + session_id="sensitive-child", + platform="cli", + ) + + runtime = relay_runtime.get_runtime() + assert runtime is not None + child = runtime.get_session("sensitive-child") + assert child is not None + assert child.parent_session_id == "parent" + pushes = [event for event in direct_runtime.events if event[0] == "scope.push"] + assert len(pushes) == 2 + child_kwargs = pushes[1][3] + assert child_kwargs["handle"] == parent_handle + assert child_kwargs["metadata"] == { + relay_runtime.RUNTIME_SCHEMA_KEY: relay_runtime.RUNTIME_SCHEMA_VERSION, + "nemo_relay_scope_role": "subagent", + } + assert "sensitive-child" not in json.dumps(pushes) + assert "sensitive-subagent" not in json.dumps(pushes) + + +def test_core_runtime_ignores_self_parenting_subagent_event(direct_runtime): + runtime = relay_runtime.get_runtime() + assert runtime is not None + + runtime.register_subagent( + {"parent_session_id": "same", "child_session_id": "same"} + ) + session = runtime.ensure_session({"session_id": "same"}) + + assert session is not None + assert session.parent_session_id == "" + + +def test_terminal_model_error_is_counted_as_failed(direct_runtime): + base = { + "session_id": "s1", + "task_id": "t1", + "api_request_id": "r1", + "provider": "anthropic", + "model": "claude-sonnet", + } + + plugins.invoke_hook("pre_api_request", **base) + plugins.invoke_hook("api_request_error", **base, retryable=False) + plugins.invoke_hook("on_session_finalize", session_id="s1") + + [end] = [event for event in direct_runtime.events if event[0] == "llm.call_end"] + assert end[2]["outcome"] == "failed" + + +def test_persistence_failure_does_not_escape_the_hook( + direct_runtime, + monkeypatch, + caplog, +): + runtime = relay_shared_metrics._get_runtime() + assert runtime is not None + + def fail_record(*_args: Any, **_kwargs: Any) -> None: + raise OSError("store unavailable") + + monkeypatch.setattr(runtime.subscriber.store, "record_model_call", fail_record) + plugins.invoke_hook( + "pre_api_request", + session_id="s1", + task_id="t1", + api_request_id="r1", + provider="openai", + model="gpt-5", + ) + plugins.invoke_hook( + "post_api_request", + session_id="s1", + task_id="t1", + api_request_id="r1", + provider="openai", + model="gpt-5", + ) + + assert "Unable to persist the Hermes model-call metric" in caplog.text + + +def test_close_does_not_reopen_a_session_after_scope_start_failure( + direct_runtime, + monkeypatch, +): + runtime = relay_runtime.get_runtime() + assert runtime is not None + original_push = direct_runtime.scope.push + push_attempts = 0 + + def fail_first_push(*args: Any, **kwargs: Any) -> Any: + nonlocal push_attempts + push_attempts += 1 + if push_attempts == 1: + raise RuntimeError("simulated scope failure") + return original_push(*args, **kwargs) + + direct_runtime.scope.push = fail_first_push + with pytest.raises(RuntimeError, match="simulated scope failure"): + runtime.ensure_session({"session_id": "s1"}) + + close_started = threading.Event() + allow_close = threading.Event() + original_flush = direct_runtime.subscribers.flush + + def block_flush(): + session = runtime._sessions["s1"] + assert session.closing is True + close_started.set() + assert allow_close.wait(timeout=5) + original_flush() + + direct_runtime.subscribers.flush = block_flush + close_thread = threading.Thread( + target=runtime.close_session, + args=({"session_id": "s1"},), + ) + close_thread.start() + assert close_started.wait(timeout=5) + + ensure_thread = threading.Thread( + target=runtime.ensure_session, + args=({"session_id": "s1"},), + ) + ensure_thread.start() + allow_close.set() + close_thread.join(timeout=5) + ensure_thread.join(timeout=5) + + assert not close_thread.is_alive() + assert not ensure_thread.is_alive() + assert push_attempts == 1 + assert "s1" not in runtime._sessions diff --git a/tests/plugins/test_nemo_relay_plugin.py b/tests/plugins/test_nemo_relay_plugin.py index eabb1a510b4..c752c2fe6e0 100644 --- a/tests/plugins/test_nemo_relay_plugin.py +++ b/tests/plugins/test_nemo_relay_plugin.py @@ -9,7 +9,6 @@ import gc import importlib import json import sys -import threading import warnings from pathlib import Path from types import SimpleNamespace @@ -17,6 +16,8 @@ from types import SimpleNamespace import pytest import yaml +from hermes_cli import plugins as plugin_api +from hermes_cli.observability import relay_runtime, relay_shared_metrics from hermes_cli.plugins import PluginManager @@ -27,10 +28,12 @@ PLUGIN_DIR = REPO_ROOT / "plugins" / "observability" / "nemo_relay" class _FakeNemoRelay: def __init__(self): self.events = [] - self._llm_handles = {} - self._subscriber_callbacks = {} - self._handle_serial = 0 - self._scope_context = contextvars.ContextVar("fake_relay_scope", default=None) + self._callbacks = {} + self._llm_starts = {} + self._scope_serial = 0 + self._scope_context = contextvars.ContextVar( + "fake_nemo_relay_scope", default=None + ) self.ScopeType = SimpleNamespace(Agent="agent") self.scope = SimpleNamespace( push=self._scope_push, @@ -65,8 +68,9 @@ class _FakeNemoRelay: self.get_scope_stack = self._get_scope_stack def _scope_push(self, name, scope_type, **kwargs): - self._scope_context.set(name) - handle = ("scope", name) + self._scope_serial += 1 + handle = ("scope", name, self._scope_serial) + self._scope_context.set(handle) self.events.append(("scope.push", name, scope_type, kwargs)) return handle @@ -78,44 +82,37 @@ class _FakeNemoRelay: def _get_scope_stack(self): current = self._scope_context.get() - self.events.append(("scope_stack.sync", current)) + self.events.append(("scope.sync", current)) return current def _llm_call(self, name, request, **kwargs): - self.events.append(("llm.call.context", self._scope_context.get())) handle = ("llm", name) - if handle in self._llm_handles: - self._handle_serial += 1 - handle = ("llm", name, self._handle_serial) - self._llm_handles[handle] = kwargs + self._llm_starts[handle] = kwargs self.events.append(("llm.call", name, request.content, kwargs)) return handle def _llm_call_end(self, handle, response, **kwargs): - self.events.append(("llm.call_end.context", self._scope_context.get())) self.events.append(("llm.call_end", handle, response, kwargs)) - started = self._llm_handles.pop(handle, {}) - self._emit( - _FakeEvent( - kind="scope", - category="llm", - name=handle[1], - scope_category="end", - data=response, - category_profile={"model_name": started.get("model_name")}, - metadata={ - **(started.get("metadata") or {}), - **(kwargs.get("metadata") or {}), - "otel.status_code": "OK", - }, - ) + start = self._llm_starts.pop(handle, {}) + event = SimpleNamespace( + kind="scope", + category="llm", + name=handle[1], + scope_category="end", + category_profile={"model_name": start.get("model_name")}, + metadata={ + **(start.get("metadata") or {}), + **(kwargs.get("metadata") or {}), + "otel.status_code": "OK", + }, + data=response, ) + for callback in list(self._callbacks.values()): + callback(event) def _llm_execute(self, name, request, func, **kwargs): self.events.append(("llm.execute.start", name, request.content, kwargs)) - result = func( - _FakeLLMRequest(request.headers, {"intercepted": True, **request.content}) - ) + result = func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) self.events.append(("llm.execute.end", name, result, kwargs)) return result @@ -137,9 +134,7 @@ class _FakeNemoRelay: return _FakeAtofExporter(self.events, config) def _make_atif_exporter(self, session_id, agent_name, agent_version, **kwargs): - return _FakeAtifExporter( - self.events, session_id, agent_name, agent_version, kwargs - ) + return _FakeAtifExporter(self.events, session_id, agent_name, agent_version, kwargs) async def _plugin_initialize(self, config): self.events.append(("plugin.initialize", config)) @@ -152,41 +147,16 @@ class _FakeNemoRelay: self.events.append(("plugin.activate_dynamic", config, dynamic_plugins)) return _FakePluginActivation(self.events) - def _flush_subscribers(self): - self.events.append(("subscribers.flush",)) - def _register_subscriber(self, name, callback): + self._callbacks[name] = callback self.events.append(("subscribers.register", name)) - self._subscriber_callbacks[name] = callback def _deregister_subscriber(self, name): + self._callbacks.pop(name, None) self.events.append(("subscribers.deregister", name)) - return self._subscriber_callbacks.pop(name, None) is not None - def _emit(self, event): - for callback in list(self._subscriber_callbacks.values()): - callback(event) - - -class _FakeEvent: - def __init__( - self, - *, - kind, - name, - category=None, - category_profile=None, - data=None, - metadata=None, - scope_category=None, - ): - self.kind = kind - self.name = name - self.category = category - self.category_profile = category_profile - self.data = data - self.metadata = metadata - self.scope_category = scope_category + def _flush_subscribers(self): + self.events.append(("subscribers.flush",)) class _FakePluginActivation: @@ -217,20 +187,10 @@ class _FakeAtofExporter: self.config = config def register(self, name): - self.events.append(( - "atof.register", - name, - self.config.output_directory, - self.config.filename, - )) + self.events.append(("atof.register", name, self.config.output_directory, self.config.filename)) def deregister(self, name): - self.events.append(( - "atof.deregister", - name, - self.config.output_directory, - self.config.filename, - )) + self.events.append(("atof.deregister", name, self.config.output_directory, self.config.filename)) return True @@ -251,13 +211,16 @@ class _FakeAtifExporter: def export_json(self): self.events.append(("atif.export", self.session_id)) - return json.dumps({ - "session_id": self.session_id, - "agent_name": self.agent_name, - }) + return json.dumps({"session_id": self.session_id, "agent_name": self.agent_name}) def _fresh_plugin(monkeypatch, fake): + existing = sys.modules.get("plugins.observability.nemo_relay") + if existing is not None: + existing.reset_for_tests() + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) monkeypatch.setitem(sys.modules, "nemo_relay", fake) sys.modules.pop("plugins.observability.nemo_relay", None) plugin = importlib.import_module("plugins.observability.nemo_relay") @@ -312,25 +275,6 @@ mode = "test" return plugins_toml -def _enable_shared_metrics(tmp_path, monkeypatch) -> Path: - hermes_home = tmp_path / "hermes-home" - hermes_home.mkdir() - (hermes_home / "config.yaml").write_text( - """ -plugins: - enabled: - - observability/nemo_relay - entries: - observability/nemo_relay: - shared_metrics: - enabled: true -""", - encoding="utf-8", - ) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - return hermes_home - - def test_manifest_fields(): data = yaml.safe_load((PLUGIN_DIR / "plugin.yaml").read_text()) assert data["name"] == "nemo_relay" @@ -374,218 +318,13 @@ def test_nemo_relay_plugin_uses_nemo_relay_runtime(monkeypatch): assert any(event[0] == "scope.push" for event in fake_relay.events) -def test_shared_metrics_default_off_does_not_create_state(tmp_path, monkeypatch): - hermes_home = tmp_path / "hermes-home" - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - - plugin.on_session_start(session_id="sensitive-session", model="sensitive-model") - - assert not any(event[0] == "subscribers.register" for event in fake.events) - assert not (hermes_home / "telemetry").exists() - - -def test_shared_metrics_counts_one_logical_model_call_across_retries( - tmp_path, monkeypatch -): - hermes_home = _enable_shared_metrics(tmp_path, monkeypatch) - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - base = { - "session_id": "sensitive-session", - "task_id": "task-1", - "api_request_id": "request-1", - "platform": "cli", - "provider": "custom", - "model": "gpt-sensitive-model-id", - "base_url": "http://127.0.0.1:11434/v1", - } - - plugin.on_session_start(**base) - plugin.on_pre_api_request( - **base, - request={"body": {"messages": ["sensitive-prompt"]}}, - ) - plugin.on_api_request_error( - **base, - retryable=True, - error={"message": "sensitive-error"}, - ) - plugin.on_pre_api_request( - **base, - request={"body": {"messages": ["sensitive-prompt"]}}, - ) - plugin.on_post_api_request( - **base, - response={"content": "sensitive-response"}, - ) - plugin.on_session_finalize(session_id=base["session_id"]) - - model_starts = [ - event for event in fake.events if event[:2] == ("llm.call", "hermes.model_call") - ] - model_ends = [ - event - for event in fake.events - if event[0] == "llm.call_end" and event[1][1] == "hermes.model_call" - ] - assert len(model_starts) == 1 - assert len(model_ends) == 1 - assert [ - event[1] - for event in fake.events - if event[0] in {"llm.call.context", "llm.call_end.context"} - ] == ["hermes.session", "hermes.session"] - assert model_starts[0][2] == {} - assert model_starts[0][3]["model_name"] == "gpt" - assert model_ends[0][2] == { - "call_role": "primary", - "locality": "local", - "outcome": "success", - "provider_family": "custom", - } - serialized_events = json.dumps(fake.events) - assert "sensitive-prompt" not in serialized_events - assert "sensitive-response" not in serialized_events - assert "sensitive-error" not in serialized_events - assert "gpt-sensitive-model-id" not in serialized_events - runtime = plugin._get_runtime() - assert runtime is not None - assert runtime.shared_metrics is not None - assert runtime.shared_metrics.store.counter_snapshot()[0]["value"] == 1 - assert ( - len( - list( - (hermes_home / "telemetry" / "shared_metrics" / "outbox").glob("*.json") - ) - ) - == 1 - ) - - -def test_shared_metrics_closes_unfinished_model_call_as_failed(tmp_path, monkeypatch): - _enable_shared_metrics(tmp_path, monkeypatch) - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - base = { - "session_id": "s1", - "task_id": "task-1", - "api_request_id": "request-1", - "provider": "anthropic", - "model": "claude-sonnet", - } - - plugin.on_pre_api_request(**base) - plugin.on_api_request_error(**base, retryable=False, error={"message": "private"}) - plugin.on_session_end(session_id="s1", task_id="task-1", interrupted=False) - plugin.on_session_finalize(session_id="s1") - - [model_end] = [ - event - for event in fake.events - if event[0] == "llm.call_end" and event[1][1] == "hermes.model_call" - ] - assert model_end[2]["outcome"] == "failed" - runtime = plugin._get_runtime() - assert runtime is not None - assert ( - runtime.shared_metrics.store.counter_snapshot()[0]["dimensions"]["outcome"] - == "failed" - ) - - -def test_shared_metrics_persistence_failure_is_fail_open(tmp_path, monkeypatch, caplog): - _enable_shared_metrics(tmp_path, monkeypatch) - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - runtime = plugin._get_runtime() - assert runtime is not None - assert runtime.shared_metrics is not None - - def fail_record(*_args, **_kwargs): - raise OSError("store unavailable") - - monkeypatch.setattr(runtime.shared_metrics.store, "record_model_call", fail_record) - plugin.on_pre_api_request( - session_id="s1", - task_id="t1", - api_request_id="r1", - provider="openai", - model="gpt-5", - ) - plugin.on_post_api_request( - session_id="s1", - task_id="t1", - api_request_id="r1", - provider="openai", - model="gpt-5", - ) - - assert "Unable to persist the Hermes model-call metric" in caplog.text - - -def test_shared_metrics_close_does_not_reopen_a_failed_session_scope( - tmp_path, monkeypatch -): - _enable_shared_metrics(tmp_path, monkeypatch) - fake = _FakeNemoRelay() - original_push = fake.scope.push - push_attempts = 0 - - def fail_first_metrics_scope(*args, **kwargs): - nonlocal push_attempts - push_attempts += 1 - if push_attempts == 1: - raise RuntimeError("simulated metrics scope failure") - return original_push(*args, **kwargs) - - fake.scope.push = fail_first_metrics_scope - plugin = _fresh_plugin(monkeypatch, fake) - runtime = plugin._get_runtime() - assert runtime is not None - runtime.ensure_session({"session_id": "s1"}) - state = runtime.sessions["s1"] - assert state.metrics_handle is None - - close_started = threading.Event() - allow_close = threading.Event() - original_drain = runtime._end_pending_model_calls - - def block_drain(current_state, kwargs): - assert current_state.closing is True - close_started.set() - assert allow_close.wait(timeout=5) - original_drain(current_state, kwargs) - - monkeypatch.setattr(runtime, "_end_pending_model_calls", block_drain) - close_thread = threading.Thread( - target=runtime.close_session, - args=({"session_id": "s1"},), - ) - close_thread.start() - assert close_started.wait(timeout=5) - - runtime.ensure_session({"session_id": "s1"}) - assert push_attempts == 1 - - allow_close.set() - close_thread.join(timeout=5) - assert not close_thread.is_alive() - assert "s1" not in runtime.sessions - - def test_nemo_relay_plugin_emits_llm_tool_and_exports_atif(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_ENABLED", "1") - monkeypatch.setenv( - "HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", str(tmp_path / "atof") - ) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", str(tmp_path / "atof")) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv( - "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") - ) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) base = { "session_id": "s1", @@ -599,26 +338,15 @@ def test_nemo_relay_plugin_emits_llm_tool_and_exports_atif(tmp_path, monkeypatch api_request_id="api-1", provider="openai", model="demo-model", - request={ - "method": "POST", - "body": {"messages": [{"role": "user", "content": "hi"}]}, - }, + request={"method": "POST", "body": {"messages": [{"role": "user", "content": "hi"}]}}, ) plugin.on_post_api_request( **base, api_request_id="api-1", response={"assistant_message": {"role": "assistant", "content": "hello"}}, ) - plugin.on_pre_tool_call( - **base, tool_name="read_file", tool_call_id="tool-1", args={"path": "x"} - ) - plugin.on_post_tool_call( - **base, - tool_name="read_file", - tool_call_id="tool-1", - result='{"ok": true}', - status="ok", - ) + plugin.on_pre_tool_call(**base, tool_name="read_file", tool_call_id="tool-1", args={"path": "x"}) + plugin.on_post_tool_call(**base, tool_name="read_file", tool_call_id="tool-1", result='{"ok": true}', status="ok") plugin.on_session_end(**base, completed=True, interrupted=False) plugin.on_session_finalize(**base, reason="shutdown") @@ -633,6 +361,79 @@ def test_nemo_relay_plugin_emits_llm_tool_and_exports_atif(tmp_path, monkeypatch assert (tmp_path / "atif" / "hermes-atif-s1.json").exists() +def test_shared_metrics_and_rich_plugin_share_one_core_session( + tmp_path, + monkeypatch, +): + fake = _FakeNemoRelay() + hermes_home = tmp_path / "hermes-home" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") + monkeypatch.setenv( + "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") + ) + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", + lambda: {"telemetry": {"shared_metrics": {"enabled": True}}}, + ) + plugin = _fresh_plugin(monkeypatch, fake) + manager = PluginManager() + manager._hooks["on_session_start"] = [plugin.on_session_start] + manager._hooks["pre_api_request"] = [plugin.on_pre_api_request] + manager._hooks["post_api_request"] = [plugin.on_post_api_request] + manager._hooks["on_session_finalize"] = [plugin.on_session_finalize] + monkeypatch.setattr(plugin_api, "_plugin_manager", manager) + + event = { + "session_id": "s1", + "task_id": "t1", + "api_request_id": "api-1", + "provider": "anthropic", + "model": "claude-sonnet", + "platform": "cli", + } + plugin_api.invoke_hook("on_session_start", **event) + plugin_api.invoke_hook( + "pre_api_request", + **event, + request={"body": {"messages": [{"role": "user", "content": "hi"}]}}, + ) + plugin_api.invoke_hook( + "post_api_request", + **event, + response={"assistant_message": {"role": "assistant", "content": "hello"}}, + ) + plugin_api.invoke_hook("on_session_finalize", session_id="s1") + + session_pushes = [ + item + for item in fake.events + if item[0] == "scope.push" and item[1] == relay_runtime.SESSION_SCOPE + ] + assert len(session_pushes) == 1 + register_metrics = fake.events.index( + ("subscribers.register", "hermes.nemo_relay.shared_metrics") + ) + register_atif = next( + index for index, item in enumerate(fake.events) if item[0] == "atif.register" + ) + open_session = fake.events.index(session_pushes[0]) + assert register_metrics < register_atif < open_session + + plugin_runtime = plugin._get_runtime() + assert plugin_runtime is not None + assert not plugin_runtime.sessions + assert relay_runtime.get_session_handle("s1") is None + packages = list( + (hermes_home / "telemetry" / "shared_metrics" / "outbox").glob("*.json") + ) + assert len(packages) == 1 + package = json.loads(packages[0].read_text(encoding="utf-8")) + assert package["metrics"][0]["name"] == "hermes.model_call.count" + assert package["metrics"][0]["value"] == 1 + assert (tmp_path / "atif" / "hermes-atif-s1.json").exists() + + def test_nemo_relay_plugin_closes_api_span_on_error(monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) @@ -660,9 +461,7 @@ def test_nemo_relay_plugin_closes_api_span_on_error(monkeypatch): call_end = next(event for event in fake.events if event[0] == "llm.call_end") assert call_end[1] == ("llm", "openai") - assert call_end[2] == { - "error": {"type": "RateLimitError", "message": "rate limited"} - } + assert call_end[2] == {"error": {"type": "RateLimitError", "message": "rate limited"}} assert call_end[3]["data"]["reason"] == "rate_limit" assert not plugin._get_runtime().sessions["s1"].llm_spans @@ -671,12 +470,8 @@ def test_nemo_relay_plugin_emits_approval_marks(monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) - plugin.on_pre_approval_request( - session_id="s1", approval_id="approval-1", tool_name="shell" - ) - plugin.on_post_approval_response( - session_id="s1", approval_id="approval-1", approved=True - ) + plugin.on_pre_approval_request(session_id="s1", approval_id="approval-1", tool_name="shell") + plugin.on_post_approval_response(session_id="s1", approval_id="approval-1", approved=True) mark_names = [event[1] for event in fake.events if event[0] == "scope.event"] assert "hermes.approval.request" in mark_names @@ -687,17 +482,13 @@ def test_nemo_relay_plugin_emits_unmatched_fallback_marks(monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) - plugin.on_post_api_request( - session_id="s1", api_request_id="missing-api", response={"ok": True} - ) + plugin.on_post_api_request(session_id="s1", api_request_id="missing-api", response={"ok": True}) plugin.on_api_request_error( session_id="s1", api_request_id="missing-api", error={"type": "TimeoutError", "message": "timed out"}, ) - plugin.on_post_tool_call( - session_id="s1", tool_call_id="missing-tool", result={"ok": True} - ) + plugin.on_post_tool_call(session_id="s1", tool_call_id="missing-tool", result={"ok": True}) mark_names = [event[1] for event in fake.events if event[0] == "scope.event"] assert "hermes.api.response.unmatched" in mark_names @@ -733,20 +524,12 @@ def test_nemo_relay_plugin_metadata_promotes_trajectory_and_subagent_ids(monkeyp telemetry_schema_version="hermes.observer.v1", ) - turn_mark = next( - event - for event in fake.events - if event[0] == "scope.event" and event[1] == "hermes.turn.start" - ) + turn_mark = next(event for event in fake.events if event[0] == "scope.event" and event[1] == "hermes.turn.start") turn_metadata = turn_mark[2]["metadata"] assert turn_metadata["session_id"] == "parent-session" assert turn_metadata["trajectory_id"] == "parent-session" - start_mark = next( - event - for event in fake.events - if event[0] == "scope.event" and event[1] == "hermes.subagent.start" - ) + start_mark = next(event for event in fake.events if event[0] == "scope.event" and event[1] == "hermes.subagent.start") start_metadata = start_mark[2]["metadata"] assert start_metadata["parent_session_id"] == "parent-session" assert start_metadata["parent_trajectory_id"] == "parent-session" @@ -755,11 +538,7 @@ def test_nemo_relay_plugin_metadata_promotes_trajectory_and_subagent_ids(monkeyp assert start_metadata["child_subagent_id"] == "child-sa" assert start_metadata["child_role"] == "leaf" - stop_mark = next( - event - for event in fake.events - if event[0] == "scope.event" and event[1] == "hermes.subagent.stop" - ) + stop_mark = next(event for event in fake.events if event[0] == "scope.event" and event[1] == "hermes.subagent.stop") assert stop_mark[2]["metadata"]["child_status"] == "completed" @@ -778,29 +557,30 @@ def test_nemo_relay_plugin_reparents_child_session_scope_for_embedded_atif(monke ) plugin.on_session_start(session_id="child-session") - child_push = next( + session_pushes = [ event for event in fake.events - if event[0] == "scope.push" and event[1] == "hermes-session-child-session" - ) + if event[0] == "scope.push" and event[1] == relay_runtime.SESSION_SCOPE + ] + assert len(session_pushes) == 2 + child_push = session_pushes[1] child_kwargs = child_push[3] - assert child_kwargs["handle"] == ("scope", "hermes-session-parent-session") + runtime = plugin._get_runtime() + assert runtime is not None + assert child_kwargs["handle"] == runtime.sessions["parent-session"].handle assert child_kwargs["metadata"]["session_id"] == "child-session" assert child_kwargs["metadata"]["trajectory_id"] == "child-session" assert child_kwargs["metadata"]["nemo_relay_scope_role"] == "subagent" assert child_kwargs["metadata"]["subagent_id"] == "child-sa" assert child_kwargs["metadata"]["parent_session_id"] == "parent-session" + assert runtime.sessions["child-session"].parent_session_id == "parent-session" -def test_nemo_relay_plugin_skips_embedded_child_atif_file_by_default( - tmp_path, monkeypatch -): +def test_nemo_relay_plugin_skips_embedded_child_atif_file_by_default(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv( - "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") - ) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) plugin.on_session_start(session_id="parent-session") plugin.on_subagent_start( @@ -818,15 +598,11 @@ def test_nemo_relay_plugin_skips_embedded_child_atif_file_by_default( assert not (tmp_path / "atif" / "hermes-atif-child-session.json").exists() -def test_nemo_relay_plugin_can_write_embedded_child_atif_file_in_all_mode( - tmp_path, monkeypatch -): +def test_nemo_relay_plugin_can_write_embedded_child_atif_file_in_all_mode(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv( - "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") - ) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_SUBAGENT_EXPORT_MODE", "all") plugin.on_session_start(session_id="parent-session") @@ -879,9 +655,7 @@ output_directory = "{atif_dir}" assert atif_dir.is_dir() -def test_nemo_relay_plugin_clears_plugins_toml_on_final_session_finalize_and_reinitializes( - tmp_path, monkeypatch -): +def test_nemo_relay_plugin_clears_plugins_toml_on_final_session_finalize_and_reinitializes(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -911,9 +685,7 @@ def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypa plugin = _fresh_plugin(monkeypatch, fake) _enable_dynamic_plugin(tmp_path, monkeypatch) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv( - "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") - ) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) plugin.on_session_start(session_id="s1") runtime = plugin._get_runtime() @@ -941,9 +713,7 @@ def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypa plugin.on_session_finalize(session_id="s2", reason="shutdown") assert sum(event[0] == "plugin.activate_dynamic" for event in fake.events) == 1 - activation = next( - event for event in fake.events if event[0] == "plugin.activate_dynamic" - ) + activation = next(event for event in fake.events if event[0] == "plugin.activate_dynamic") assert "dynamic_plugins" not in activation[1] assert activation[2] == [ { @@ -960,9 +730,7 @@ def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypa runtime.shutdown() event_names = [event[0] for event in fake.events] - assert event_names.index("atif.deregister") < event_names.index( - "plugin.activation.close" - ) + assert event_names.index("atif.deregister") < event_names.index("plugin.activation.close") def test_nemo_relay_rejects_gateway_dynamic_config_with_actionable_diagnostic( @@ -995,9 +763,7 @@ mode = "test" assert "Use Hermes-owned [[dynamic_plugins]]" in caplog.text -def test_nemo_relay_explicit_dynamic_paths_resolve_from_plugins_toml( - tmp_path, monkeypatch -): +def test_nemo_relay_explicit_dynamic_paths_resolve_from_plugins_toml(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) config_dir = tmp_path / "config" @@ -1019,9 +785,7 @@ environment_ref = "../environments/worker-fixture" plugin.on_session_start(session_id="s1") - activation = next( - event for event in fake.events if event[0] == "plugin.activate_dynamic" - ) + activation = next(event for event in fake.events if event[0] == "plugin.activate_dynamic") assert activation[2] == [ { "plugin_id": "worker-fixture", @@ -1095,9 +859,7 @@ def test_nemo_relay_managed_llm_uses_wire_protocol_for_interceptor_dispatch( assert "rewritten_for" not in result -def test_nemo_relay_managed_llm_returns_post_next_interceptor_result( - tmp_path, monkeypatch -): +def test_nemo_relay_managed_llm_returns_post_next_interceptor_result(tmp_path, monkeypatch): fake = _FakeNemoRelay() raw_response = SimpleNamespace( model="fixture", @@ -1160,9 +922,7 @@ def test_nemo_relay_managed_tool_returns_post_interceptor_result(tmp_path, monke } -def test_nemo_relay_plugin_activates_before_registering_managed_middleware( - tmp_path, monkeypatch -): +def test_nemo_relay_plugin_activates_before_registering_managed_middleware(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) _enable_dynamic_plugin(tmp_path, monkeypatch) @@ -1255,21 +1015,16 @@ manifest_ref = "{(tmp_path / "invalid" / "relay-plugin.toml").as_posix()}" assert "no dynamic plugins will be activated" in caplog.text -def test_nemo_relay_plugin_registers_shutdown_after_dynamic_retry( - tmp_path, monkeypatch -): +def test_nemo_relay_plugin_registers_shutdown_after_dynamic_retry(tmp_path, monkeypatch): fake = _FakeNemoRelay() activation_attempts = 0 async def _flaky_activate(config, dynamic_plugins): nonlocal activation_attempts activation_attempts += 1 - fake.events.append(( - "plugin.activate_dynamic.attempt", - activation_attempts, - config, - dynamic_plugins, - )) + fake.events.append( + ("plugin.activate_dynamic.attempt", activation_attempts, config, dynamic_plugins) + ) if activation_attempts == 1: raise RuntimeError("temporary activation failure") return _FakePluginActivation(fake.events) @@ -1313,9 +1068,7 @@ def test_nemo_relay_plugin_attempts_activation_close_after_subscriber_flush_fail event_names = [event[0] for event in fake.events] assert event_names.count("subscribers.flush.failed") == 2 flush_indices = [ - index - for index, name in enumerate(event_names) - if name == "subscribers.flush.failed" + index for index, name in enumerate(event_names) if name == "subscribers.flush.failed" ] assert max(flush_indices) < event_names.index("plugin.activation.close") assert runtime._plugin_activation is None @@ -1338,9 +1091,7 @@ def test_nemo_relay_plugin_continues_shutdown_after_atif_export_failure( plugin = _fresh_plugin(monkeypatch, fake) _enable_dynamic_plugin(tmp_path, monkeypatch) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv( - "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") - ) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif")) plugin.on_session_start(session_id="s1") runtime = plugin._get_runtime() assert runtime is not None @@ -1349,19 +1100,13 @@ def test_nemo_relay_plugin_continues_shutdown_after_atif_export_failure( runtime.shutdown() event_names = [event[0] for event in fake.events] - assert event_names.index("atif.export.failed") < event_names.index( - "atif.deregister" - ) - assert event_names.index("atif.deregister") < event_names.index( - "plugin.activation.close" - ) + assert event_names.index("atif.export.failed") < event_names.index("atif.deregister") + assert event_names.index("atif.deregister") < event_names.index("plugin.activation.close") assert runtime._plugin_activation is None assert "ATIF export failed: disk full" in caplog.text -def test_nemo_relay_plugin_keeps_plugins_toml_active_while_other_sessions_remain( - tmp_path, monkeypatch -): +def test_nemo_relay_plugin_keeps_plugins_toml_active_while_other_sessions_remain(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -1387,9 +1132,7 @@ enabled = true assert event_names.count("plugin.clear") == 1 -def test_nemo_relay_plugin_reinitializes_plugins_toml_inside_active_event_loop( - tmp_path, monkeypatch -): +def test_nemo_relay_plugin_reinitializes_plugins_toml_inside_active_event_loop(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -1421,12 +1164,10 @@ enabled = true assert runtime is not None assert runtime._plugin_config_initialized is True scope_push_names = [event[1] for event in fake.events if event[0] == "scope.push"] - assert "hermes-session-s2" in scope_push_names + assert relay_runtime.SESSION_SCOPE in scope_push_names -def test_nemo_relay_plugin_retries_plugins_toml_after_clear_failure( - tmp_path, monkeypatch -): +def test_nemo_relay_plugin_retries_plugins_toml_after_clear_failure(tmp_path, monkeypatch): fake = _FakeNemoRelay() initialize_calls = 0 @@ -1464,12 +1205,10 @@ enabled = true assert event_names.count("plugin.initialize.attempt") == 2 assert event_names.count("plugin.clear.failed") == 1 scope_push_names = [event[1] for event in fake.events if event[0] == "scope.push"] - assert "hermes-session-s2" in scope_push_names + assert relay_runtime.SESSION_SCOPE in scope_push_names -def test_nemo_relay_plugin_disables_direct_atif_when_plugins_toml_owns_atif( - tmp_path, monkeypatch -): +def test_nemo_relay_plugin_disables_direct_atif_when_plugins_toml_owns_atif(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -1489,9 +1228,7 @@ output_directory = "{(tmp_path / "managed-atif").as_posix()}" ) monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv( - "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atif") - ) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atif")) plugin.on_session_start(session_id="s1") plugin.on_session_finalize(session_id="s1", reason="shutdown") @@ -1503,9 +1240,7 @@ output_directory = "{(tmp_path / "managed-atif").as_posix()}" assert not (tmp_path / "direct-atif" / "hermes-atif-s1.json").exists() -def test_nemo_relay_plugin_keeps_direct_atif_when_plugins_toml_init_fails( - tmp_path, monkeypatch -): +def test_nemo_relay_plugin_keeps_direct_atif_when_plugins_toml_init_fails(tmp_path, monkeypatch): fake = _FakeNemoRelay() async def _failing_initialize(config): @@ -1531,9 +1266,7 @@ output_directory = "{(tmp_path / "managed-atif").as_posix()}" ) monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1") - monkeypatch.setenv( - "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atif") - ) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atif")) plugin.on_session_start(session_id="s1") plugin.on_session_finalize(session_id="s1", reason="shutdown") @@ -1579,9 +1312,7 @@ output_directory = "{(tmp_path / "managed-atof").as_posix()}" ) monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_ENABLED", "1") - monkeypatch.setenv( - "HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atof") - ) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", str(tmp_path / "direct-atof")) plugin.on_session_start(session_id="s1") plugin.on_session_finalize(session_id="s1", reason="shutdown") @@ -1596,9 +1327,7 @@ output_directory = "{(tmp_path / "managed-atof").as_posix()}" assert event_names.count("atof.deregister") == 1 -def test_nemo_relay_adaptive_llm_execution_middleware_preserves_raw_response( - tmp_path, monkeypatch -): +def test_nemo_relay_adaptive_llm_execution_middleware_preserves_raw_response(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -1626,9 +1355,7 @@ mode = "observe_only" SimpleNamespace( id="tool-1", type="function", - function=SimpleNamespace( - name="terminal", arguments='{"command":"pwd"}' - ), + function=SimpleNamespace(name="terminal", arguments='{"command":"pwd"}'), ) ], reasoning_content="need a tool", @@ -1662,9 +1389,7 @@ mode = "observe_only" assert response.model == "demo-model" assert response.choices == [raw_choice] assert seen_request["intercepted"] is True - execute_start = next( - event for event in fake.events if event[0] == "llm.execute.start" - ) + execute_start = next(event for event in fake.events if event[0] == "llm.execute.start") assert execute_start[3]["data"]["mode"] == "observe_only" execute_end = next(event for event in fake.events if event[0] == "llm.execute.end") assert execute_end[2] == { @@ -1686,19 +1411,13 @@ mode = "observe_only" } -def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error( - tmp_path, monkeypatch -): +def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error(tmp_path, monkeypatch): fake = _FakeNemoRelay() def native_like_execute(name, request, func, **kwargs): fake.events.append(("llm.execute.start", name, request.content, kwargs)) try: - return func( - _FakeLLMRequest( - request.headers, {"intercepted": True, **request.content} - ) - ) + return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) except Exception as exc: raise RuntimeError(f"internal error: {type(exc).__name__}: {exc}") from None @@ -1738,15 +1457,9 @@ def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error_with_relay def native_like_execute(name, request, func, **kwargs): try: - return func( - _FakeLLMRequest( - request.headers, {"intercepted": True, **request.content} - ) - ) + return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) except Exception as exc: - raise RuntimeError( - f"internal error: {type(exc).__name__}: {exc} (retried 3x)" - ) from None + raise RuntimeError(f"internal error: {type(exc).__name__}: {exc} (retried 3x)") from None fake.llm.execute = native_like_execute plugin = _fresh_plugin(monkeypatch, fake) @@ -1773,9 +1486,7 @@ def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error_with_relay assert caught.value.status_code == 403 -def test_nemo_relay_adaptive_llm_execution_keeps_unrelated_internal_error( - tmp_path, monkeypatch -): +def test_nemo_relay_adaptive_llm_execution_keeps_unrelated_internal_error(tmp_path, monkeypatch): fake = _FakeNemoRelay() relay_error = RuntimeError("internal error: relay setup failed") @@ -1803,17 +1514,11 @@ def test_nemo_relay_adaptive_llm_execution_keeps_wrapped_relay_error_after_downs tmp_path, monkeypatch ): fake = _FakeNemoRelay() - relay_error = RuntimeError( - "internal error: RuntimeError: relay policy blocked after downstream" - ) + relay_error = RuntimeError("internal error: RuntimeError: relay policy blocked after downstream") def translated_execute(name, request, func, **kwargs): try: - return func( - _FakeLLMRequest( - request.headers, {"intercepted": True, **request.content} - ) - ) + return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) except Exception: raise relay_error @@ -1836,9 +1541,7 @@ def test_nemo_relay_adaptive_llm_execution_keeps_wrapped_relay_error_after_downs assert caught.value is relay_error -def test_nemo_relay_adaptive_llm_execution_keeps_relay_translated_error( - tmp_path, monkeypatch -): +def test_nemo_relay_adaptive_llm_execution_keeps_relay_translated_error(tmp_path, monkeypatch): fake = _FakeNemoRelay() class RelayPolicyError(Exception): @@ -1848,11 +1551,7 @@ def test_nemo_relay_adaptive_llm_execution_keeps_relay_translated_error( def translated_execute(name, request, func, **kwargs): try: - return func( - _FakeLLMRequest( - request.headers, {"intercepted": True, **request.content} - ) - ) + return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) except Exception: raise relay_error @@ -1877,9 +1576,7 @@ def test_nemo_relay_adaptive_llm_execution_keeps_relay_translated_error( assert caught.value is relay_error -def test_nemo_relay_downstream_unwrap_matches_real_middleware_wrapper_shape( - monkeypatch, -): +def test_nemo_relay_downstream_unwrap_matches_real_middleware_wrapper_shape(monkeypatch): # Regression guard against core/plugin drift. The synthetic tests above model # the downstream-error wrapper with a local class, so they keep passing even # if core middleware renames its private ``_DownstreamExecutionError`` or drops @@ -1943,9 +1640,7 @@ def _adaptive_llm_execute_mode(tmp_path, monkeypatch, plugins_toml_text: str) -> next_call=lambda request: {"raw": request}, ) - execute_start = next( - event for event in fake.events if event[0] == "llm.execute.start" - ) + execute_start = next(event for event in fake.events if event[0] == "llm.execute.start") return execute_start[3]["data"]["mode"] @@ -1969,9 +1664,7 @@ version = 1 assert mode == "observe_only" -def test_nemo_relay_adaptive_llm_execution_middleware_accepts_legacy_top_level_mode( - tmp_path, monkeypatch -): +def test_nemo_relay_adaptive_llm_execution_middleware_accepts_legacy_top_level_mode(tmp_path, monkeypatch): mode = _adaptive_llm_execute_mode( tmp_path, monkeypatch, @@ -1989,9 +1682,7 @@ mode = "route" assert mode == "route" -def test_nemo_relay_adaptive_llm_execution_middleware_prefers_tool_parallelism_mode( - tmp_path, monkeypatch -): +def test_nemo_relay_adaptive_llm_execution_middleware_prefers_tool_parallelism_mode(tmp_path, monkeypatch): mode = _adaptive_llm_execute_mode( tmp_path, monkeypatch, @@ -2012,9 +1703,7 @@ mode = "schedule" assert mode == "schedule" -def test_nemo_relay_llm_execution_middleware_calls_through_without_adaptive( - monkeypatch, -): +def test_nemo_relay_llm_execution_middleware_calls_through_without_adaptive(monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) @@ -2030,9 +1719,7 @@ def test_nemo_relay_llm_execution_middleware_calls_through_without_adaptive( assert not any(event[0] == "llm.execute.start" for event in fake.events) -def test_nemo_relay_adaptive_tool_execution_middleware_preserves_raw_response( - tmp_path, monkeypatch -): +def test_nemo_relay_adaptive_tool_execution_middleware_preserves_raw_response(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -2070,16 +1757,12 @@ mode = "observe_only" assert response == {"raw": True, "args": {"command": "pwd", "intercepted": True}} assert seen_args["intercepted"] is True - execute_start = next( - event for event in fake.events if event[0] == "tool.execute.start" - ) + execute_start = next(event for event in fake.events if event[0] == "tool.execute.start") assert execute_start[3]["data"]["mode"] == "observe_only" assert execute_start[3]["data"]["tool_call_id"] == "tool-1" -def test_nemo_relay_adaptive_tool_execution_preserves_downstream_error( - tmp_path, monkeypatch -): +def test_nemo_relay_adaptive_tool_execution_preserves_downstream_error(tmp_path, monkeypatch): fake = _FakeNemoRelay() def native_like_execute(name, args, func, **kwargs): @@ -2113,9 +1796,7 @@ def test_nemo_relay_adaptive_tool_execution_preserves_downstream_error( assert caught.value.status_code == 403 -def test_nemo_relay_adaptive_tool_execution_keeps_unrelated_internal_error( - tmp_path, monkeypatch -): +def test_nemo_relay_adaptive_tool_execution_keeps_unrelated_internal_error(tmp_path, monkeypatch): fake = _FakeNemoRelay() relay_error = RuntimeError("internal error: relay setup failed") @@ -2142,9 +1823,7 @@ def test_nemo_relay_adaptive_tool_execution_keeps_wrapped_relay_error_after_down tmp_path, monkeypatch ): fake = _FakeNemoRelay() - relay_error = RuntimeError( - "internal error: RuntimeError: relay policy blocked after downstream" - ) + relay_error = RuntimeError("internal error: RuntimeError: relay policy blocked after downstream") def translated_execute(name, args, func, **kwargs): try: @@ -2170,9 +1849,7 @@ def test_nemo_relay_adaptive_tool_execution_keeps_wrapped_relay_error_after_down assert caught.value is relay_error -def test_nemo_relay_adaptive_tool_execution_keeps_relay_translated_error( - tmp_path, monkeypatch -): +def test_nemo_relay_adaptive_tool_execution_keeps_relay_translated_error(tmp_path, monkeypatch): fake = _FakeNemoRelay() class RelayPolicyError(Exception): @@ -2206,9 +1883,7 @@ def test_nemo_relay_adaptive_tool_execution_keeps_relay_translated_error( assert caught.value is relay_error -def test_nemo_relay_tool_execution_middleware_calls_through_without_adaptive( - monkeypatch, -): +def test_nemo_relay_tool_execution_middleware_calls_through_without_adaptive(monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) @@ -2223,9 +1898,7 @@ def test_nemo_relay_tool_execution_middleware_calls_through_without_adaptive( assert not any(event[0] == "tool.execute.start" for event in fake.events) -def test_nemo_relay_adaptive_execution_skips_duplicate_observer_spans( - tmp_path, monkeypatch -): +def test_nemo_relay_adaptive_execution_skips_duplicate_observer_spans(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) plugins_toml = tmp_path / "plugins.toml" @@ -2257,12 +1930,8 @@ mode = "observe_only" request={"body": {"messages": [{"role": "user", "content": "hi"}]}}, ) plugin.on_post_api_request(**base, response={"ok": True}) - plugin.on_pre_tool_call( - **base, tool_name="terminal", tool_call_id="tool-1", args={"command": "pwd"} - ) - plugin.on_post_tool_call( - **base, tool_name="terminal", tool_call_id="tool-1", result={"ok": True} - ) + plugin.on_pre_tool_call(**base, tool_name="terminal", tool_call_id="tool-1", args={"command": "pwd"}) + plugin.on_post_tool_call(**base, tool_name="terminal", tool_call_id="tool-1", result={"ok": True}) plugin.on_llm_execution_middleware( **base, diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 8c0836e9059..a29bb19d83f 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -3,6 +3,8 @@ from pathlib import Path import tomllib +from packaging.requirements import Requirement + def _load_optional_dependencies(): pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" @@ -11,6 +13,12 @@ def _load_optional_dependencies(): return project["optional-dependencies"] +def _load_project(): + pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" + with pyproject_path.open("rb") as handle: + return tomllib.load(handle)["project"] + + def _load_package_data(): pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" with pyproject_path.open("rb") as handle: @@ -222,14 +230,40 @@ 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_nemo_relay_is_a_pinned_core_dependency(): + metadata = _load_project() - 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"] + relay_dependencies = [ + dependency + for dependency in metadata["dependencies"] + if dependency.startswith("nemo-relay==") + ] + assert len(relay_dependencies) == 1 + requirement = Requirement(relay_dependencies[0]) + assert str(requirement.specifier) == "==0.5.0" + assert requirement.marker is not None + assert requirement.marker.evaluate( + {"sys_platform": "darwin", "platform_machine": "arm64"} ) + assert requirement.marker.evaluate( + {"sys_platform": "linux", "platform_machine": "x86_64"} + ) + assert requirement.marker.evaluate( + {"sys_platform": "linux", "platform_machine": "aarch64"} + ) + assert requirement.marker.evaluate( + {"sys_platform": "win32", "platform_machine": "AMD64"} + ) + assert requirement.marker.evaluate( + {"sys_platform": "win32", "platform_machine": "ARM64"} + ) + assert not requirement.marker.evaluate( + {"sys_platform": "android", "platform_machine": "aarch64"} + ) + assert not requirement.marker.evaluate( + {"sys_platform": "darwin", "platform_machine": "x86_64"} + ) + assert "nemo-relay" not in metadata["optional-dependencies"] def test_dashboard_plugin_manifests_and_assets_are_packaged(): @@ -244,6 +278,12 @@ def test_dashboard_plugin_manifests_and_assets_are_packaged(): assert "*/dashboard/dist/**/*" in plugin_data +def test_shared_metrics_schema_is_packaged(): + package_data = _load_package_data() + + assert "observability/schemas/*.json" in package_data["hermes_cli"] + + def test_nested_bundled_plugin_metadata_is_packaged(): """Nested opt-in plugins need manifests and READMEs in wheel installs.""" package_data = _load_package_data() diff --git a/uv.lock b/uv.lock index f0daf9798e5..b993bf9d926 100644 --- a/uv.lock +++ b/uv.lock @@ -1528,6 +1528,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" }, @@ -1662,9 +1663,6 @@ mistral = [ modal = [ { name = "modal" }, ] -nemo-relay = [ - { name = "nemo-relay" }, -] parallel-web = [ { name = "parallel-web" }, ] @@ -1804,7 +1802,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.0,<0.6.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.5.0" }, { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, { name = "openai", specifier = "==2.24.0" }, { name = "packaging", specifier = "==26.0" }, @@ -1853,7 +1851,7 @@ requires-dist = [ { name = "websockets", specifier = "==15.0.1" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] [[package]] name = "hf-xet" From 056e7df0e0c34f298aa8e20e8d46162b63be1b33 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 08:55:07 -0400 Subject: [PATCH 003/526] fix(observability): harden Relay metrics isolation and aggregation Signed-off-by: Alex Fournier --- agent/turn_context.py | 1 + agent/turn_finalizer.py | 2 + docs/observability/relay-shared-metrics.md | 28 +- hermes_cli/hooks.py | 12 +- hermes_cli/middleware.py | 29 +- hermes_cli/observability/relay_runtime.py | 152 +++- .../observability/relay_shared_metrics.py | 511 +++++++++-- .../hermes.shared_metrics.v1.schema.json | 186 +++- hermes_cli/observability/shared_metrics.py | 22 +- .../observability/shared_metrics_contract.py | 230 ++++- .../shared_metrics_subscriber.py | 57 +- plugins/observability/nemo_relay/__init__.py | 67 +- run_agent.py | 24 +- scripts/smoke_nemo_relay_shared_metrics.py | 110 ++- tests/hermes_cli/test_relay_shared_metrics.py | 238 ++++++ .../test_relay_shared_metrics_runtime.py | 802 +++++++++++++++++- tests/plugins/test_nemo_relay_plugin.py | 105 ++- tests/test_model_tools.py | 34 + tests/tools/test_zombie_process_cleanup.py | 38 +- tools/delegate_tool.py | 21 +- 20 files changed, 2481 insertions(+), 188 deletions(-) diff --git a/agent/turn_context.py b/agent/turn_context.py index 8807407278c..8f703383d61 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -703,6 +703,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] = [] diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index ccbaac9f619..d4293bebd52 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -571,7 +571,9 @@ def finalize_turn( 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 "", ) diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md index 6c85c84cda4..3943ee5596e 100644 --- a/docs/observability/relay-shared-metrics.md +++ b/docs/observability/relay-shared-metrics.md @@ -26,15 +26,15 @@ 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 Slice +## Current Slices -The first vertical slice records one logical model-call counter: +The current vertical slices record logical model calls and top-level task runs: ```text -Hermes API hooks - -> Relay session and LLM lifecycle +Hermes turn, API, and tool hooks + -> Relay session, task, and LLM lifecycle -> Hermes shared-metrics subscriber - -> SQLite counter + -> SQLite counters -> immutable JSON delta package ``` @@ -44,6 +44,18 @@ 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 @@ -67,6 +79,6 @@ 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, one counter was -stored, one package was exported, and prompt, response, and exact-model -canaries are absent from the package. +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. diff --git a/hermes_cli/hooks.py b/hermes_cli/hooks.py index d3f86bd00e8..2ba0857c93c 100644 --- a/hermes_cli/hooks.py +++ b/hermes_cli/hooks.py @@ -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": { diff --git a/hermes_cli/middleware.py b/hermes_cli/middleware.py index 8795952a2b7..8045595c35d 100644 --- a/hermes_cli/middleware.py +++ b/hermes_cli/middleware.py @@ -127,18 +127,31 @@ 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 "") + if session_id: + from hermes_cli.observability 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, diff --git a/hermes_cli/observability/relay_runtime.py b/hermes_cli/observability/relay_runtime.py index d52b44d07cb..b0d78c976bb 100644 --- a/hermes_cli/observability/relay_runtime.py +++ b/hermes_cli/observability/relay_runtime.py @@ -1,20 +1,26 @@ -"""Process-wide NeMo Relay runtime owned by Hermes core.""" +"""Profile-scoped NeMo Relay runtimes owned by Hermes 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" RUNTIME_SCHEMA_KEY = "hermes.relay.schema_version" RUNTIME_SCHEMA_VERSION = "hermes.relay.runtime.v1" +RUNTIME_INSTANCE_KEY = "hermes.relay.runtime_instance" SESSION_START_HOOKS = frozenset({"on_session_start"}) SESSION_CLOSE_HOOKS = frozenset({"on_session_finalize", "on_session_reset"}) @@ -28,7 +34,7 @@ HANDLED_HOOKS = ( ) _RUNTIME_FAILED = object() -_RUNTIME: RelayRuntime | object | None = None +_RUNTIMES: dict[str, RelayRuntime | object] = {} _RUNTIME_LOCK = threading.RLock() @@ -47,8 +53,10 @@ class RelaySession: class RelayRuntime: """Own Relay session scopes independently of any exporter or plugin.""" - def __init__(self, relay: Any = None) -> None: + 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] = {} @@ -83,6 +91,7 @@ class RelayRuntime: scope_metadata = { **(metadata or {}), RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION, + RUNTIME_INSTANCE_KEY: self.runtime_id, } if session.parent_session_id: parent = self.ensure_session({ @@ -123,10 +132,11 @@ class RelayRuntime: self._subagent_parents[child_session_id] = parent_session_id def unregister_subagent(self, event: dict[str, Any]) -> None: - """Forget a delegated-session relationship after its terminal hook.""" + """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) @@ -167,6 +177,32 @@ class RelayRuntime: # re-enter the same logical session without re-entering Context. return session.context.copy().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") + context = session.context.copy() + + 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, @@ -189,6 +225,32 @@ class RelayRuntime: ) 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.""" + 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) @@ -208,7 +270,10 @@ class RelayRuntime: self.relay.scope.pop, session.handle, output={}, - metadata={RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION}, + metadata={ + RUNTIME_SCHEMA_KEY: RUNTIME_SCHEMA_VERSION, + RUNTIME_INSTANCE_KEY: self.runtime_id, + }, allow_closing=True, ) except Exception as exc: @@ -301,6 +366,25 @@ def emit_mark( 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() + 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() @@ -331,27 +415,56 @@ def run_in_session( 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 get_runtime(*, create: bool = True) -> RelayRuntime | None: - """Return the process-wide Hermes Relay host.""" - global _RUNTIME +def get_runtime( + *, + create: bool = True, + profile_key: str | None = None, +) -> RelayRuntime | None: + """Return the Relay host for the active Hermes profile.""" + key = profile_key or current_profile_key() with _RUNTIME_LOCK: - if isinstance(_RUNTIME, RelayRuntime): - return _RUNTIME - if _RUNTIME is _RUNTIME_FAILED or not create: + runtime = _RUNTIMES.get(key) + if isinstance(runtime, RelayRuntime): + return runtime + if runtime is _RUNTIME_FAILED or not create: return None try: - _RUNTIME = RelayRuntime() + runtime = RelayRuntime(profile_key=key) except Exception: logger.warning("Hermes Relay runtime initialization failed", exc_info=True) - _RUNTIME = _RUNTIME_FAILED + _RUNTIMES[key] = _RUNTIME_FAILED return None - return _RUNTIME + _RUNTIMES[key] = runtime + return runtime + + +def current_profile_key() -> str: + """Return the canonical profile identity used for runtime isolation.""" + return str(get_hermes_home().expanduser().resolve()) def _load_nemo_relay() -> Any: @@ -364,9 +477,10 @@ def _session_id(event: dict[str, Any]) -> str: def _reset_for_tests() -> None: - """Reset process-global core Relay state for isolated tests.""" - global _RUNTIME + """Reset all profile-scoped Relay hosts for isolated tests.""" with _RUNTIME_LOCK: - if isinstance(_RUNTIME, RelayRuntime): - _RUNTIME.shutdown() - _RUNTIME = None + runtimes = list(_RUNTIMES.values()) + _RUNTIMES.clear() + for runtime in runtimes: + if isinstance(runtime, RelayRuntime): + runtime.shutdown() diff --git a/hermes_cli/observability/relay_shared_metrics.py b/hermes_cli/observability/relay_shared_metrics.py index c8306966f1e..ba1b4bb507e 100644 --- a/hermes_cli/observability/relay_shared_metrics.py +++ b/hermes_cli/observability/relay_shared_metrics.py @@ -3,10 +3,11 @@ from __future__ import annotations import atexit +import contextvars import logging import threading from dataclasses import dataclass, field -from functools import lru_cache +from time import monotonic_ns from typing import Any, Callable from hermes_cli import __version__ @@ -18,8 +19,11 @@ from .shared_metrics_contract import ( 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 @@ -30,13 +34,16 @@ HANDLED_HOOKS = frozenset({ "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() -_RUNTIME: _Runtime | object | None = None +_RUNTIMES: dict[str, _Runtime | object] = {} _RUNTIME_LOCK = threading.RLock() @@ -47,6 +54,19 @@ class _ModelCall: fields: dict[str, str] +@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 @@ -54,6 +74,7 @@ class _MetricsSession: 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: @@ -66,9 +87,19 @@ class _Runtime: 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.subscriber = SharedMetricsSubscriber(SharedMetricsStore(), __version__) - self.relay.subscribers.register(SUBSCRIBER_NAME, self.subscriber) + self._task_creation_lock = threading.RLock() + self._task_sessions_lock = threading.RLock() + self._task_sessions: dict[tuple[str, str], _MetricsSession] = {} + self._turn_sessions: dict[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._registered = True atexit.register(self.shutdown) @@ -76,10 +107,12 @@ class _Runtime: session_id = str(event.get("session_id") or "") if not session_id: return None - relay_session = self.host.ensure_session(event) - if relay_session is None: - 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( @@ -106,8 +139,77 @@ class _Runtime: **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) + + def push_task() -> Any: + self.relay.get_scope_stack() + return self.relay.scope.push( + TASK_SCOPE, + self.relay.ScopeType.Function, + handle=session.relay_session.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: - session = self.ensure_session(event) + 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 "") @@ -118,27 +220,65 @@ class _Runtime: 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: + task.retry_count += 1 return - handle = self._run_in_session( - session, - self.relay.llm.call, - MODEL_CALL_SCOPE, - self.relay.LLMRequest({}, {}), - handle=session.relay_session.handle, - metadata={SCHEMA_KEY: SCHEMA_VERSION}, - model_name=model_family, - ) + if task is not None: + task.model_call_ids.add(request_id) + 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, ) + 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._session(event) + 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 "") @@ -157,7 +297,9 @@ class _Runtime: ) def end_pending_model_calls(self, event: dict[str, Any]) -> None: - session = self._session(event) + 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: @@ -165,6 +307,20 @@ class _Runtime: 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: @@ -174,6 +330,19 @@ class _Runtime: 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() @@ -192,6 +361,7 @@ class _Runtime: 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}) @@ -199,18 +369,103 @@ class _Runtime: return self._safe(self.relay.subscribers.flush) self._export() - self._safe(self.relay.subscribers.deregister, SUBSCRIBER_NAME) + self._safe(self.relay.subscribers.deregister, 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._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_id = str(event.get("turn_id") or "") + with self._task_sessions_lock: + if turn_id: + owner = self._turn_sessions.get(turn_id) + 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 + + 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[turn_id] = session + def _finish_model_call( self, session: _MetricsSession, @@ -221,13 +476,23 @@ class _Runtime: if model_call is None: return try: - self._run_in_session( - session, - self.relay.llm.call_end, - model_call.handle, - {**model_call.fields, "outcome": outcome}, - metadata={SCHEMA_KEY: SCHEMA_VERSION}, - ) + 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 @@ -248,9 +513,52 @@ class _Runtime: 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: + if self._turn_sessions.get(turn_id) is session: + self._turn_sessions.pop(turn_id, 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: @@ -260,23 +568,32 @@ class _Runtime: return None -@lru_cache(maxsize=1) def enabled() -> bool: - """Return the process-lifetime Hermes shared-metrics policy.""" + """Return the shared-metrics policy for the active Hermes profile.""" + profile_key = relay_runtime.current_profile_key() try: from hermes_cli.config import load_config_readonly config = load_config_readonly() or {} except Exception: logger.debug("Unable to read Hermes shared-metrics policy", exc_info=True) - return False - if not isinstance(config, dict): - return False - telemetry = config.get("telemetry") - if not isinstance(telemetry, dict): - return False - shared_metrics = telemetry.get("shared_metrics") - return isinstance(shared_metrics, dict) and shared_metrics.get("enabled") is 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: @@ -293,15 +610,23 @@ def observe_lifecycle(hook_name: str, **kwargs: Any) -> None: 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.end_pending_model_calls(kwargs) + 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: @@ -313,30 +638,110 @@ def observe_lifecycle(hook_name: str, **kwargs: Any) -> None: def prepare_session_start() -> None: """Register the subscriber before any producer opens the session scope.""" if enabled(): - _get_runtime() + _get_runtime(retry_failed=True) -def _get_runtime() -> _Runtime | None: - global _RUNTIME +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) -> _Runtime | None: + profile_key = relay_runtime.current_profile_key() with _RUNTIME_LOCK: - if isinstance(_RUNTIME, _Runtime): - return _RUNTIME - if _RUNTIME is _RUNTIME_FAILED: + runtime = _RUNTIMES.get(profile_key) + if isinstance(runtime, _Runtime): + return runtime + if runtime is _RUNTIME_FAILED and not retry_failed: return None + if runtime is _RUNTIME_FAILED: + _RUNTIMES.pop(profile_key, None) try: - _RUNTIME = _Runtime() + runtime = _Runtime() except Exception: logger.warning("Hermes shared metrics initialization failed", exc_info=True) - _RUNTIME = _RUNTIME_FAILED + _RUNTIMES[profile_key] = _RUNTIME_FAILED return None - return _RUNTIME + _RUNTIMES[profile_key] = runtime + return runtime def _reset_for_tests() -> None: - """Reset process-global state for isolated tests.""" - global _RUNTIME + """Reset all profile-scoped shared-metrics state for isolated tests.""" with _RUNTIME_LOCK: - if isinstance(_RUNTIME, _Runtime): - _RUNTIME.shutdown() - _RUNTIME = None - enabled.cache_clear() + runtimes = list(_RUNTIMES.values()) + _RUNTIMES.clear() + for runtime in runtimes: + if isinstance(runtime, _Runtime): + runtime.shutdown() diff --git a/hermes_cli/observability/schemas/hermes.shared_metrics.v1.schema.json b/hermes_cli/observability/schemas/hermes.shared_metrics.v1.schema.json index f7306597bc6..6c067d0eb8e 100644 --- a/hermes_cli/observability/schemas/hermes.shared_metrics.v1.schema.json +++ b/hermes_cli/observability/schemas/hermes.shared_metrics.v1.schema.json @@ -54,7 +54,17 @@ "type": "array", "minItems": 1, "items": { - "$ref": "#/$defs/model_call_counter" + "oneOf": [ + { + "$ref": "#/$defs/model_call_counter" + }, + { + "$ref": "#/$defs/task_started_counter" + }, + { + "$ref": "#/$defs/task_finished_counter" + } + ] } } }, @@ -148,6 +158,180 @@ "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" + ] } } } diff --git a/hermes_cli/observability/shared_metrics.py b/hermes_cli/observability/shared_metrics.py index 4848ba82759..228b2393420 100644 --- a/hermes_cli/observability/shared_metrics.py +++ b/hermes_cli/observability/shared_metrics.py @@ -18,6 +18,13 @@ from utils import atomic_json_write _PACKAGE_SCHEMA_VERSION = "hermes.shared_metrics.v1" _MODEL_CALL_METRIC = "hermes.model_call.count" +_TASK_STARTED_METRIC = "hermes.task_run.started" +_TASK_FINISHED_METRIC = "hermes.task_run.finished" +_COUNTER_METRICS = frozenset({ + _MODEL_CALL_METRIC, + _TASK_FINISHED_METRIC, + _TASK_STARTED_METRIC, +}) _STORE_SCHEMA_VERSION = "1" _BUSY_TIMEOUT_MS = 250 @@ -31,7 +38,7 @@ def _isoformat(value: datetime) -> str: class SharedMetricsStore: - """Persist model-call counters and export immutable delta packages.""" + """Persist allowlisted counters and export immutable delta packages.""" def __init__( self, @@ -51,6 +58,17 @@ class SharedMetricsStore: 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}") dimensions_json = json.dumps( dimensions, sort_keys=True, @@ -78,7 +96,7 @@ class SharedMetricsStore: """, ( period_start, - _MODEL_CALL_METRIC, + metric_name, hermes_version or "unknown", dimensions_json, ), diff --git a/hermes_cli/observability/shared_metrics_contract.py b/hermes_cli/observability/shared_metrics_contract.py index 7e3c5d336e8..41cfc2eaa5c 100644 --- a/hermes_cli/observability/shared_metrics_contract.py +++ b/hermes_cli/observability/shared_metrics_contract.py @@ -6,9 +6,12 @@ import re from functools import lru_cache from typing import Any +from .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" @@ -33,6 +36,59 @@ PROVIDER_FAMILIES: frozenset[str] = frozenset({ }) 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 @@ -94,7 +150,7 @@ def model_call_dimensions(event: Any) -> dict[str, str] | None: 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} + 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"}: @@ -141,6 +197,75 @@ def model_call_dimensions(event: Any) -> dict[str, str] | None: } +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 + if ( + data.get("entrypoint") not in TASK_ENTRYPOINTS + or data.get("execution_surface") not in EXECUTION_SURFACES + ): + return None + return "hermes.task_run.started", { + "entrypoint": data["entrypoint"], + "execution_surface": data["execution_surface"], + } + + 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 + if ( + data.get("duration_bucket") not in DURATION_BUCKETS + or data.get("end_reason") not in TASK_END_REASONS + or data.get("entrypoint") not in TASK_ENTRYPOINTS + or data.get("execution_surface") not in EXECUTION_SURFACES + or data.get("model_call_count_bucket") not in COUNT_BUCKETS + or data.get("outcome") not in TASK_OUTCOMES + or data.get("retry_count_bucket") not in COUNT_BUCKETS + or data.get("termination") not in TASK_TERMINATIONS + or data.get("tool_call_count_bucket") not in COUNT_BUCKETS + ): + return None + return "hermes.task_run.finished", { + field: data[field] for field in sorted(expected_fields) + } + + def execution_surface(kwargs: dict[str, Any]) -> str: """Normalize the safe session surface carried by the parent Relay scope.""" value = ( @@ -166,6 +291,109 @@ def execution_surface(kwargs: dict[str, Any]) -> str: 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("_", "-") diff --git a/hermes_cli/observability/shared_metrics_subscriber.py b/hermes_cli/observability/shared_metrics_subscriber.py index be5aa6b53b0..bbd0442a520 100644 --- a/hermes_cli/observability/shared_metrics_subscriber.py +++ b/hermes_cli/observability/shared_metrics_subscriber.py @@ -3,29 +3,64 @@ from __future__ import annotations import logging +import threading from typing import Any from .shared_metrics import SharedMetricsStore -from .shared_metrics_contract import model_call_dimensions +from .shared_metrics_contract import model_call_dimensions, task_counter +from .relay_runtime import RUNTIME_INSTANCE_KEY logger = logging.getLogger(__name__) class SharedMetricsSubscriber: - """Persist validated primary model-call counters from Relay events.""" + """Persist validated Hermes counters from Relay lifecycle events.""" - def __init__(self, store: SharedMetricsStore, hermes_version: str) -> None: + 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 = "hermes.model_call.count" if dimensions is None: - return - try: - self.store.record_model_call(dimensions, self._hermes_version) - except Exception: - logger.warning( - "Unable to persist the Hermes model-call metric", - exc_info=True, - ) + 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, + ) diff --git a/plugins/observability/nemo_relay/__init__.py b/plugins/observability/nemo_relay/__init__.py index d539e0eb198..f33359d7299 100644 --- a/plugins/observability/nemo_relay/__init__.py +++ b/plugins/observability/nemo_relay/__init__.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) _INIT_FAILED = object() _LOCK = threading.RLock() -_RUNTIME: "_Runtime | object | None" = None +_RUNTIMES: dict[str, "_Runtime | object"] = {} _RELAY_LLM_SURFACE_BY_API_MODE = { "anthropic_messages": "anthropic.messages", "chat_completions": "openai.chat_completions", @@ -81,7 +81,7 @@ class _Runtime: self.sessions: dict[str, _SessionState] = {} self.subagent_contexts: dict[str, _SubagentContext] = {} self.atof_exporter: Any = None - self._atof_subscriber_name = "hermes.nemo_relay.atof" + self._atof_subscriber_name = f"hermes.nemo_relay.atof.{self.host.runtime_id}" self._plugin_activation: Any = None self._shutdown_registered = False self._plugin_config_initialized = self._configure_plugins_toml() @@ -257,7 +257,9 @@ class _Runtime: model_name=str(kwargs.get("model") or self.settings.atif_model_name), extra={"source": "hermes-agent", "plugin": "observability/nemo_relay"}, ) - state.atif_subscriber_name = f"hermes.nemo_relay.atif.{session_id}" + state.atif_subscriber_name = ( + f"hermes.nemo_relay.atif.{self.host.runtime_id}.{session_id}" + ) state.atif_exporter.register(state.atif_subscriber_name) rich_metadata = _metadata(kwargs) @@ -295,6 +297,22 @@ class _Runtime: **kwargs, ) + async def run_in_session_async( + self, + state: _SessionState, + callback: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> Any: + if state.relay_session is None: + raise RuntimeError("Hermes core Relay session is unavailable") + return await self.host.run_in_session_async( + state.relay_session, + callback, + *args, + **kwargs, + ) + def export_atif(self, state: _SessionState) -> None: if not self.settings.atif_enabled or state.atif_exporter is None: return @@ -406,6 +424,7 @@ class _Runtime: self.host.unregister_subagent(kwargs) child_session_id = _child_session_id(kwargs) if child_session_id: + self.close_session({"session_id": child_session_id}) self.subagent_contexts.pop(child_session_id, None) self.mark("hermes.subagent.stop", kwargs) @@ -482,7 +501,7 @@ class _Runtime: def _make_managed(impl: Callable[[Any], Any]) -> Any: async def _managed_execute() -> Any: - result = self.run_in_session( + return await self.run_in_session_async( state, self.nemo_relay.llm.execute, _relay_llm_surface(kwargs), @@ -500,9 +519,6 @@ class _Runtime: metadata=_metadata(kwargs), model_name=str(kwargs.get("model") or ""), ) - if inspect.isawaitable(result): - return await result - return result return _managed_execute() @@ -519,11 +535,16 @@ class _Runtime: return args def _normalize(next_args: Any) -> Any: - return next_args if isinstance(next_args, dict) else args + normalized = next_args if isinstance(next_args, dict) else args + if not _json_semantically_equal(normalized, args): + raise RuntimeError( + "NeMo Relay changed tool arguments after Hermes authorization" + ) + return args def _make_managed(impl: Callable[[Any], Any]) -> Any: async def _managed_execute() -> Any: - result = self.run_in_session( + return await self.run_in_session_async( state, self.nemo_relay.tools.execute, tool_name, @@ -540,9 +561,6 @@ class _Runtime: ), metadata=_metadata(kwargs), ) - if inspect.isawaitable(result): - return await result - return result return _managed_execute() @@ -784,26 +802,28 @@ def on_tool_execution_middleware(**kwargs: Any) -> Any: def _get_runtime() -> Optional[_Runtime]: - global _RUNTIME + profile_key = relay_runtime.current_profile_key() with _LOCK: - if _RUNTIME is _INIT_FAILED: + runtime = _RUNTIMES.get(profile_key) + if runtime is _INIT_FAILED: return None - if isinstance(_RUNTIME, _Runtime): - return _RUNTIME + if isinstance(runtime, _Runtime): + return runtime try: host = relay_runtime.get_runtime() if host is None: raise RuntimeError("Hermes core Relay runtime is unavailable") - _RUNTIME = _Runtime( + runtime = _Runtime( nemo_relay=host.relay, settings=_load_settings(), host=host, ) except Exception as exc: logger.debug("NeMo Relay plugin disabled: init failed: %s", exc, exc_info=True) - _RUNTIME = _INIT_FAILED + _RUNTIMES[profile_key] = _INIT_FAILED return None - return _RUNTIME + _RUNTIMES[profile_key] = runtime + return runtime def _load_settings() -> _Settings: @@ -1278,8 +1298,9 @@ def _resolve_awaitable(value: Any) -> Any: def reset_for_tests() -> None: - global _RUNTIME with _LOCK: - if isinstance(_RUNTIME, _Runtime): - _RUNTIME.shutdown() - _RUNTIME = None + runtimes = list(_RUNTIMES.values()) + _RUNTIMES.clear() + for runtime in runtimes: + if isinstance(runtime, _Runtime): + runtime.shutdown() diff --git a/run_agent.py b/run_agent.py index 7280e294226..8a42d08a6f5 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6347,6 +6347,20 @@ class AIAgent: reset_conversation_context, set_conversation_context, ) + from hermes_cli.observability.relay_shared_metrics import ( + finish_task_run, + start_task_run, + ) + effective_task_id = task_id or str(uuid.uuid4()) + task_context = { + "session_id": self.session_id or "", + "task_id": effective_task_id, + "platform": getattr(self, "platform", None) or "", + } + start_task_run( + **task_context, + parent_session_id=getattr(self, "_parent_session_id", None) or "", + ) # 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 @@ -6368,17 +6382,23 @@ class AIAgent: # which may be observed from another thread. with scoped_runtime_main({}): try: - return run_conversation( + 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, ) + except BaseException as exc: + finish_task_run(**task_context, error=exc) + raise + else: + finish_task_run(**task_context, result=result) + return result finally: reset_accounting_context(acct_token) reset_conversation_context(token) diff --git a/scripts/smoke_nemo_relay_shared_metrics.py b/scripts/smoke_nemo_relay_shared_metrics.py index 76e0a59538d..93f4562a99d 100644 --- a/scripts/smoke_nemo_relay_shared_metrics.py +++ b/scripts/smoke_nemo_relay_shared_metrics.py @@ -202,24 +202,62 @@ def _validate_store(database_path: Path) -> list[dict[str, Any]]: } for name, dimensions, value, packaged_value in rows ] - expected = [ - { - "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 counters != expected: + 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 @@ -244,22 +282,38 @@ def _validate_package(outbox: Path, schema_path: Path) -> tuple[Path, dict[str, raise AssertionError( f"Exported package leaked prohibited value: {prohibited!r}" ) - expected_metric = { - "name": "hermes.model_call.count", - "type": "counter", - "dimensions": { - "call_role": "primary", - "locality": "local", - "model_family": "gpt", - "outcome": "success", - "provider_family": "custom", - }, - "value": 1, - } - if package.get("metrics") != [expected_metric]: + 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 diff --git a/tests/hermes_cli/test_relay_shared_metrics.py b/tests/hermes_cli/test_relay_shared_metrics.py index a3bc18e8fbf..2a6ba2b2b90 100644 --- a/tests/hermes_cli/test_relay_shared_metrics.py +++ b/tests/hermes_cli/test_relay_shared_metrics.py @@ -7,6 +7,7 @@ import multiprocessing as mp import os import sqlite3 import stat +import threading import uuid from concurrent.futures import ThreadPoolExecutor from copy import deepcopy @@ -17,17 +18,30 @@ 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, ) @@ -55,6 +69,11 @@ def _package_dimension_schema() -> dict[str, object]: 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, @@ -131,6 +150,21 @@ def test_package_schema_matches_the_model_call_contract(): 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"), [ @@ -231,6 +265,105 @@ 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" @@ -279,6 +412,52 @@ def test_subscriber_contract_rejects_unknown_fields_and_dimension_values(): 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: @@ -316,6 +495,36 @@ def test_pending_metrics_keep_the_version_recorded_at_event_time(tmp_path): 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") @@ -411,6 +620,35 @@ def test_package_export_does_not_chase_concurrent_updates(tmp_path, monkeypatch) 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" diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index bbd612a4d5c..bb87cf7d00d 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextvars +import asyncio import json import threading from pathlib import Path @@ -27,9 +28,10 @@ class _Relay: self.events: list[tuple[Any, ...]] = [] self._callbacks: dict[str, Any] = {} self._starts: dict[Any, dict[str, Any]] = {} + self._scope_starts: dict[Any, dict[str, Any]] = {} self._scope = contextvars.ContextVar("relay_scope", default=None) self._scope_serial = 0 - self.ScopeType = SimpleNamespace(Agent="agent") + self.ScopeType = SimpleNamespace(Agent="agent", Function="function") self.LLMRequest = _Request self.scope = SimpleNamespace( push=self._scope_push, @@ -49,10 +51,39 @@ class _Relay: handle = ("scope", name, self._scope_serial) self._scope.set(handle) self.events.append(("scope.push", name, scope_type, kwargs)) + if scope_type == self.ScopeType.Function: + self._scope_starts[handle] = kwargs + event = SimpleNamespace( + kind="scope", + category="function", + name=name, + scope_category="start", + category_profile=None, + metadata=kwargs.get("metadata"), + data=kwargs.get("input"), + ) + for callback in list(self._callbacks.values()): + callback(event) return handle def _scope_pop(self, handle: Any, **kwargs: Any) -> None: self.events.append(("scope.pop", handle, kwargs)) + start = self._scope_starts.pop(handle, None) + if start is not None: + event = SimpleNamespace( + kind="scope", + category="function", + name=handle[1], + scope_category="end", + category_profile=None, + metadata={ + **(start.get("metadata") or {}), + **(kwargs.get("metadata") or {}), + }, + data=kwargs.get("output"), + ) + for callback in list(self._callbacks.values()): + callback(event) def _scope_event(self, name: str, **kwargs: Any) -> None: self.events.append(("scope.event", name, kwargs)) @@ -139,11 +170,21 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa assert plugins.has_hook("pre_api_request") plugins.invoke_hook("on_session_start", **base) + plugins.invoke_hook("pre_llm_call", **base) plugins.invoke_hook( "pre_api_request", **base, request={"body": {"messages": ["sensitive-prompt"]}}, ) + plugins.invoke_hook( + "post_tool_call", + **base, + tool_call_id="sensitive-tool-call", + tool_name="terminal", + args={"command": "sensitive-command"}, + result={"output": "sensitive-tool-result"}, + status="ok", + ) plugins.invoke_hook( "api_request_error", **base, @@ -170,14 +211,30 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa }, response={"content": "sensitive-response"}, ) + plugins.invoke_hook( + "on_session_end", + **base, + completed=True, + failed=False, + interrupted=False, + turn_exit_reason="text_response(stop)", + ) plugins.invoke_hook("on_session_finalize", session_id=base["session_id"]) starts = [event for event in direct_runtime.events if event[0] == "llm.call"] ends = [event for event in direct_runtime.events if event[0] == "llm.call_end"] - session_starts = [ + scope_starts = [ event for event in direct_runtime.events if event[0] == "scope.push" ] - assert len(session_starts) == 1 + assert len(scope_starts) == 2 + assert scope_starts[0][2] == direct_runtime.ScopeType.Agent + assert scope_starts[1][1] == "hermes.task_run" + assert scope_starts[1][2] == direct_runtime.ScopeType.Function + assert scope_starts[1][3]["handle"][1] == relay_runtime.SESSION_SCOPE + assert scope_starts[1][3]["input"] == { + "entrypoint": "interactive", + "execution_surface": "cli", + } assert len(starts) == 1 assert len(ends) == 1 assert starts[0][2] == {} @@ -193,6 +250,9 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa assert "sensitive-prompt" not in serialized_events assert "sensitive-response" not in serialized_events assert "sensitive-error" not in serialized_events + assert "sensitive-command" not in serialized_events + assert "sensitive-tool-result" not in serialized_events + assert "sensitive-tool-call" not in serialized_events assert "gpt-sensitive-model-id" not in serialized_events assert plugins.get_plugin_manager().list_plugins() == [] @@ -200,9 +260,44 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa packages = list((root / "outbox").glob("*.json")) assert len(packages) == 1 package = json.loads(packages[0].read_text(encoding="utf-8")) - assert package["metrics"][0]["name"] == "hermes.model_call.count" - assert package["metrics"][0]["dimensions"]["model_family"] == "claude" - assert package["metrics"][0]["value"] == 1 + metrics = {metric["name"]: metric for metric in package["metrics"]} + assert set(metrics) == { + "hermes.model_call.count", + "hermes.task_run.finished", + "hermes.task_run.started", + } + assert metrics["hermes.model_call.count"]["dimensions"]["model_family"] == "claude" + assert metrics["hermes.model_call.count"]["value"] == 1 + assert metrics["hermes.task_run.started"] == { + "name": "hermes.task_run.started", + "type": "counter", + "dimensions": { + "entrypoint": "interactive", + "execution_surface": "cli", + }, + "value": 1, + } + terminal = metrics["hermes.task_run.finished"]["dimensions"] + assert terminal["duration_bucket"] in { + "lt_1s", + "1s_to_5s", + "5s_to_30s", + "30s_to_2m", + "2m_to_10m", + "gte_10m", + } + assert { + key: value for key, value in terminal.items() if key != "duration_bucket" + } == { + "end_reason": "completed", + "entrypoint": "interactive", + "execution_surface": "cli", + "model_call_count_bucket": "1", + "outcome": "success", + "retry_count_bucket": "1", + "termination": "none", + "tool_call_count_bucket": "1", + } def test_direct_runtime_is_disabled_by_default(tmp_path, monkeypatch): @@ -227,6 +322,7 @@ def test_direct_runtime_is_disabled_by_default(tmp_path, monkeypatch): def test_core_runtime_is_fail_open_without_a_published_binding(monkeypatch, caplog): relay_shared_metrics._reset_for_tests() relay_runtime._reset_for_tests() + def missing_relay(name: str): assert name == "nemo_relay" raise ModuleNotFoundError(name) @@ -312,6 +408,334 @@ def test_core_runtime_creates_one_session_under_concurrent_access(direct_runtime ) +def test_core_runtime_isolates_same_session_id_by_profile(direct_runtime, tmp_path): + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + profile_a = tmp_path / "profile-a" + profile_b = tmp_path / "profile-b" + + token = set_hermes_home_override(profile_a) + try: + runtime_a = relay_runtime.get_runtime() + session_a = runtime_a.ensure_session({"session_id": "shared"}) + finally: + reset_hermes_home_override(token) + + token = set_hermes_home_override(profile_b) + try: + runtime_b = relay_runtime.get_runtime() + session_b = runtime_b.ensure_session({"session_id": "shared"}) + finally: + reset_hermes_home_override(token) + + assert runtime_a is not None + assert runtime_b is not None + assert runtime_a is not runtime_b + assert runtime_a.profile_key == str(profile_a.resolve()) + assert runtime_b.profile_key == str(profile_b.resolve()) + assert session_a is not session_b + assert session_a.handle != session_b.handle + + +def test_shared_metrics_policy_and_store_are_profile_scoped(tmp_path, monkeypatch): + from hermes_constants import ( + get_hermes_home, + reset_hermes_home_override, + set_hermes_home_override, + ) + + fake = _Relay() + profile_a = tmp_path / "profile-a" + profile_b = tmp_path / "profile-b" + monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", + lambda: { + "telemetry": { + "shared_metrics": {"enabled": get_hermes_home() == profile_a} + } + }, + ) + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + + token = set_hermes_home_override(profile_a) + try: + assert relay_shared_metrics.enabled() + relay_shared_metrics.start_task_run( + session_id="shared", + task_id="task-a", + platform="cli", + ) + relay_shared_metrics.finish_task_run( + session_id="shared", + task_id="task-a", + platform="cli", + result={"completed": True}, + ) + relay_shared_metrics._get_runtime().close_session({"session_id": "shared"}) + finally: + reset_hermes_home_override(token) + + token = set_hermes_home_override(profile_b) + try: + assert not relay_shared_metrics.enabled() + relay_shared_metrics.start_task_run( + session_id="shared", + task_id="task-b", + platform="cli", + ) + finally: + reset_hermes_home_override(token) + + assert list((profile_a / "telemetry" / "shared_metrics" / "outbox").glob("*.json")) + assert not (profile_b / "telemetry").exists() + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + + +def test_shared_metrics_subscribers_isolate_two_enabled_profiles(tmp_path, monkeypatch): + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + fake = _Relay() + profile_a = tmp_path / "profile-a" + profile_b = tmp_path / "profile-b" + monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", + lambda: {"telemetry": {"shared_metrics": {"enabled": True}}}, + ) + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + + for profile, task_id in ((profile_a, "task-a"), (profile_b, "task-b")): + token = set_hermes_home_override(profile) + try: + relay_shared_metrics.start_task_run( + session_id="shared", + task_id=task_id, + platform="cli", + ) + relay_shared_metrics.finish_task_run( + session_id="shared", + task_id=task_id, + platform="cli", + result={"completed": True}, + ) + relay_shared_metrics._get_runtime().close_session( + {"session_id": "shared"} + ) + finally: + reset_hermes_home_override(token) + + for profile in (profile_a, profile_b): + packages = list( + (profile / "telemetry" / "shared_metrics" / "outbox").glob("*.json") + ) + assert len(packages) == 1 + package = json.loads(packages[0].read_text(encoding="utf-8")) + metrics = {metric["name"]: metric for metric in package["metrics"]} + assert metrics["hermes.task_run.started"]["value"] == 1 + assert metrics["hermes.task_run.finished"]["value"] == 1 + + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + + +def test_shared_metrics_isolates_same_task_id_across_sessions(direct_runtime): + runtime = relay_shared_metrics._get_runtime() + assert runtime is not None + + task_a = runtime.start_task({ + "session_id": "session-a", + "task_id": "shared-task", + "platform": "cli", + }) + task_b = runtime.start_task({ + "session_id": "session-b", + "task_id": "shared-task", + "platform": "gateway", + }) + + assert task_a is not None + assert task_b is not None + assert task_a is not task_b + assert task_a.handle != task_b.handle + + runtime.finish_task({ + "session_id": "session-a", + "task_id": "shared-task", + "platform": "cli", + "completed": True, + }) + runtime.finish_task({ + "session_id": "session-b", + "task_id": "shared-task", + "platform": "gateway", + "completed": True, + }) + + task_starts = [ + event + for event in direct_runtime.events + if event[0] == "scope.push" and event[1] == "hermes.task_run" + ] + task_ends = [ + event + for event in direct_runtime.events + if event[0] == "scope.pop" and event[1][1] == "hermes.task_run" + ] + assert len(task_starts) == 2 + assert len(task_ends) == 2 + + +def test_disabling_shared_metrics_stops_collection_and_shutdown_export( + tmp_path, monkeypatch +): + from hermes_cli.observability.shared_metrics import SharedMetricsStore + + fake = _Relay() + profile = tmp_path / "profile" + policy = {"enabled": True} + monkeypatch.setenv("HERMES_HOME", str(profile)) + monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", + lambda: {"telemetry": {"shared_metrics": dict(policy)}}, + ) + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + + relay_shared_metrics.start_task_run( + session_id="session", + task_id="task", + platform="cli", + ) + runtime = relay_shared_metrics._get_runtime() + assert runtime is not None + policy["enabled"] = False + + assert not relay_shared_metrics.enabled() + counters_before_stale_event = runtime.subscriber.store.counter_snapshot() + runtime.subscriber(SimpleNamespace( + kind="scope", + category="function", + category_profile=None, + name="hermes.task_run", + scope_category="start", + metadata={ + "hermes.metrics.schema_version": "hermes.metrics.event.v1", + relay_runtime.RUNTIME_INSTANCE_KEY: runtime.host.runtime_id, + }, + data={"entrypoint": "interactive", "execution_surface": "cli"}, + )) + assert runtime.subscriber.store.counter_snapshot() == counters_before_stale_event + assert runtime.start_task({ + "session_id": "session", + "task_id": "stale-runtime-task", + "platform": "cli", + }) is None + relay_shared_metrics.finish_task_run( + session_id="session", + task_id="task", + platform="cli", + result={"completed": True}, + ) + relay_shared_metrics._reset_for_tests() + + root = profile / "telemetry" / "shared_metrics" + store = SharedMetricsStore(root / "metrics.sqlite3", root / "outbox") + assert [row["metric_name"] for row in store.counter_snapshot()] == [ + "hermes.task_run.started" + ] + assert list((root / "outbox").glob("*.json")) == [] + relay_runtime._reset_for_tests() + + +def test_shared_metrics_retries_transient_initialization_failure( + direct_runtime, monkeypatch +): + real_store = relay_shared_metrics.SharedMetricsStore + attempts = 0 + + def flaky_store(): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise OSError("transient store failure") + return real_store() + + monkeypatch.setattr(relay_shared_metrics, "SharedMetricsStore", flaky_store) + + relay_shared_metrics.start_task_run( + session_id="session", + task_id="first", + platform="cli", + ) + relay_shared_metrics.start_task_run( + session_id="session", + task_id="second", + platform="cli", + ) + + assert attempts == 2 + task_starts = [ + event + for event in direct_runtime.events + if event[0] == "scope.push" and event[1] == "hermes.task_run" + ] + assert len(task_starts) == 1 + + +def test_async_session_runner_awaits_inside_saved_relay_context(direct_runtime): + runtime = relay_runtime.get_runtime() + assert runtime is not None + session = runtime.ensure_session({"session_id": "async-session"}) + assert session is not None + + async def probe() -> Any: + await asyncio.sleep(0) + return direct_runtime._scope.get() + + result = asyncio.run(runtime.run_in_session_async(session, probe)) + + assert result == session.handle + + +def test_shared_metrics_creates_one_task_under_concurrent_access(direct_runtime): + runtime = relay_shared_metrics._get_runtime() + assert runtime is not None + ready = threading.Barrier(8) + tasks: list[Any] = [] + + def start() -> None: + ready.wait(timeout=5) + tasks.append( + runtime.start_task({"session_id": "s1", "task_id": "t1", "platform": "cli"}) + ) + + threads = [threading.Thread(target=start) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert all(not thread.is_alive() for thread in threads) + assert len({id(task) for task in tasks}) == 1 + task_starts = [ + event + for event in direct_runtime.events + if event[0] == "scope.push" and event[1] == "hermes.task_run" + ] + assert len(task_starts) == 1 + + def test_core_runtime_parents_subagent_session_without_exposing_ids( direct_runtime, ): @@ -341,19 +765,39 @@ def test_core_runtime_parents_subagent_session_without_exposing_ids( assert child_kwargs["handle"] == parent_handle assert child_kwargs["metadata"] == { relay_runtime.RUNTIME_SCHEMA_KEY: relay_runtime.RUNTIME_SCHEMA_VERSION, + relay_runtime.RUNTIME_INSTANCE_KEY: runtime.runtime_id, "nemo_relay_scope_role": "subagent", } assert "sensitive-child" not in json.dumps(pushes) assert "sensitive-subagent" not in json.dumps(pushes) +def test_core_runtime_closes_child_session_on_subagent_stop(direct_runtime): + runtime = relay_runtime.get_runtime() + assert runtime is not None + runtime.register_subagent({ + "parent_session_id": "parent", + "child_session_id": "child", + }) + child = runtime.ensure_session({"session_id": "child"}) + assert child is not None + + runtime.unregister_subagent({"child_session_id": "child"}) + + assert runtime.get_session("child") is None + child_closes = [ + event + for event in direct_runtime.events + if event[0] == "scope.pop" and event[1] == child.handle + ] + assert len(child_closes) == 1 + + def test_core_runtime_ignores_self_parenting_subagent_event(direct_runtime): runtime = relay_runtime.get_runtime() assert runtime is not None - runtime.register_subagent( - {"parent_session_id": "same", "child_session_id": "same"} - ) + runtime.register_subagent({"parent_session_id": "same", "child_session_id": "same"}) session = runtime.ensure_session({"session_id": "same"}) assert session is not None @@ -377,6 +821,342 @@ def test_terminal_model_error_is_counted_as_failed(direct_runtime): assert end[2]["outcome"] == "failed" +def test_task_terminal_counts_logical_calls_retries_and_unique_tools(direct_runtime): + base = { + "session_id": "s1", + "task_id": "t1", + "api_request_id": "r1", + "platform": "cli", + "provider": "nvidia", + "model": "nvidia/nemotron-3-super-120b-a12b", + } + + plugins.invoke_hook("pre_llm_call", **base) + plugins.invoke_hook("pre_api_request", **base) + plugins.invoke_hook("api_request_error", **base, retryable=True) + plugins.invoke_hook("pre_api_request", **base) + plugins.invoke_hook("api_request_error", **base, retryable=True) + plugins.invoke_hook("pre_api_request", **base) + plugins.invoke_hook("api_request_error", **base, retryable=False) + for tool_call_id in ("tool-1", "tool-1", "tool-2"): + plugins.invoke_hook( + "post_tool_call", + **base, + tool_call_id=tool_call_id, + tool_name="terminal", + result={"output": "private"}, + status="ok", + ) + plugins.invoke_hook( + "on_session_end", + **base, + completed=False, + failed=True, + interrupted=False, + turn_exit_reason="all_retries_exhausted_no_response", + ) + plugins.invoke_hook("on_session_finalize", session_id="s1") + + model_starts = [event for event in direct_runtime.events if event[0] == "llm.call"] + model_ends = [ + event for event in direct_runtime.events if event[0] == "llm.call_end" + ] + assert len(model_starts) == 1 + assert len(model_ends) == 1 + assert model_ends[0][2]["outcome"] == "failed" + [task_end] = [ + event + for event in direct_runtime.events + if event[0] == "scope.pop" and event[1][1] == "hermes.task_run" + ] + assert task_end[2]["output"] == { + "duration_bucket": task_end[2]["output"]["duration_bucket"], + "end_reason": "failed", + "entrypoint": "interactive", + "execution_surface": "cli", + "model_call_count_bucket": "1", + "outcome": "failed", + "retry_count_bucket": "2", + "termination": "none", + "tool_call_count_bucket": "2", + } + + +def test_outer_agent_boundary_closes_early_returns_and_exceptions( + direct_runtime, + monkeypatch, +): + from run_agent import AIAgent + + agent = SimpleNamespace( + session_id="s1", + platform="cli", + _parent_session_id=None, + _session_db=None, + _conversation_root_id=lambda: "s1", + ) + + def early_failure(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + return { + "final_response": "private failure detail", + "completed": False, + "failed": True, + "interrupted": False, + } + + monkeypatch.setattr( + "agent.conversation_loop.run_conversation", + early_failure, + ) + result = AIAgent.run_conversation(agent, "private prompt", task_id="early") + assert result["failed"] is True + + def raise_failure(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + raise RuntimeError("private exception detail") + + monkeypatch.setattr( + "agent.conversation_loop.run_conversation", + raise_failure, + ) + with pytest.raises(RuntimeError, match="private exception detail"): + AIAgent.run_conversation(agent, "private prompt", task_id="exception") + + def raise_interrupt(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + raise KeyboardInterrupt + + monkeypatch.setattr( + "agent.conversation_loop.run_conversation", + raise_interrupt, + ) + with pytest.raises(KeyboardInterrupt): + AIAgent.run_conversation(agent, "private prompt", task_id="cancelled") + + def raise_timeout(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + raise TimeoutError("private timeout detail") + + monkeypatch.setattr( + "agent.conversation_loop.run_conversation", + raise_timeout, + ) + with pytest.raises(TimeoutError, match="private timeout detail"): + AIAgent.run_conversation(agent, "private prompt", task_id="timed-out") + + plugins.invoke_hook("on_session_finalize", session_id="s1") + + task_ends = [ + event[2]["output"] + for event in direct_runtime.events + if event[0] == "scope.pop" and event[1][1] == "hermes.task_run" + ] + assert len(task_ends) == 4 + assert task_ends[0]["outcome"] == "failed" + assert task_ends[0]["end_reason"] == "failed" + assert task_ends[0]["termination"] == "none" + assert task_ends[1]["outcome"] == "failed" + assert task_ends[1]["end_reason"] == "system_aborted" + assert task_ends[1]["termination"] == "system_aborted" + assert task_ends[2]["outcome"] == "cancelled" + assert task_ends[2]["end_reason"] == "user_cancelled" + assert task_ends[2]["termination"] == "user_cancelled" + assert task_ends[3]["outcome"] == "timed_out" + assert task_ends[3]["end_reason"] == "timed_out" + assert task_ends[3]["termination"] == "timed_out" + serialized = json.dumps(direct_runtime.events) + assert "private prompt" not in serialized + assert "private failure detail" not in serialized + assert "private exception detail" not in serialized + assert "private timeout detail" not in serialized + + +def test_outer_agent_boundary_preserves_a_returned_timeout_reason( + direct_runtime, + monkeypatch, +): + from run_agent import AIAgent + + agent = SimpleNamespace( + session_id="s1", + platform="cli", + _parent_session_id=None, + _session_db=None, + _conversation_root_id=lambda: "s1", + ) + + monkeypatch.setattr( + "agent.conversation_loop.run_conversation", + lambda *_args, **_kwargs: { + "final_response": "private timeout response", + "completed": False, + "failed": True, + "failure_reason": "timeout", + }, + ) + AIAgent.run_conversation(agent, "private prompt", task_id="timed-out") + + [task_end] = [ + event[2]["output"] + for event in direct_runtime.events + if event[0] == "scope.pop" and event[1][1] == "hermes.task_run" + ] + assert task_end["outcome"] == "timed_out" + assert task_end["end_reason"] == "timed_out" + assert task_end["termination"] == "timed_out" + serialized = json.dumps(direct_runtime.events) + assert "private prompt" not in serialized + assert "private timeout response" not in serialized + + +def test_session_finalize_closes_a_pending_task_as_system_aborted(direct_runtime): + plugins.invoke_hook( + "pre_llm_call", + session_id="s1", + task_id="t1", + platform="cli", + ) + + plugins.invoke_hook("on_session_finalize", session_id="s1") + + [task_end] = [ + event + for event in direct_runtime.events + if event[0] == "scope.pop" and event[1][1] == "hermes.task_run" + ] + assert task_end[2]["output"] == { + "duration_bucket": task_end[2]["output"]["duration_bucket"], + "end_reason": "system_aborted", + "entrypoint": "interactive", + "execution_surface": "cli", + "model_call_count_bucket": "0", + "outcome": "failed", + "retry_count_bucket": "0", + "termination": "system_aborted", + "tool_call_count_bucket": "0", + } + + +def test_sequential_tasks_in_one_session_aggregate_once_each(direct_runtime, tmp_path): + for task_id in ("t1", "t2"): + plugins.invoke_hook( + "pre_llm_call", + session_id="s1", + task_id=task_id, + platform="cli", + ) + plugins.invoke_hook( + "on_session_end", + session_id="s1", + task_id=task_id, + platform="cli", + completed=True, + failed=False, + interrupted=False, + turn_exit_reason="text_response(stop)", + ) + plugins.invoke_hook("on_session_finalize", session_id="s1") + + outbox = tmp_path / "hermes-home" / "telemetry" / "shared_metrics" / "outbox" + [package_path] = list(outbox.glob("*.json")) + package = json.loads(package_path.read_text(encoding="utf-8")) + metrics = {metric["name"]: metric for metric in package["metrics"]} + assert metrics["hermes.task_run.started"]["value"] == 2 + assert metrics["hermes.task_run.finished"]["value"] == 2 + + +def test_task_ownership_survives_session_id_rotation(direct_runtime): + plugins.invoke_hook( + "pre_llm_call", + session_id="before-compression", + task_id="t1", + platform="cli", + ) + plugins.invoke_hook( + "pre_api_request", + session_id="after-compression", + task_id="t1", + api_request_id="r1", + platform="cli", + provider="nvidia", + model="nvidia/nemotron-3-super-120b-a12b", + ) + plugins.invoke_hook( + "post_api_request", + session_id="after-compression", + task_id="t1", + api_request_id="r1", + platform="cli", + provider="nvidia", + model="nvidia/nemotron-3-super-120b-a12b", + ) + plugins.invoke_hook( + "on_session_end", + session_id="after-compression", + task_id="t1", + platform="cli", + completed=True, + failed=False, + interrupted=False, + turn_exit_reason="text_response(stop)", + ) + plugins.invoke_hook("on_session_finalize", session_id="before-compression") + + task_starts = [ + event + for event in direct_runtime.events + if event[0] == "scope.push" and event[1] == "hermes.task_run" + ] + task_ends = [ + event + for event in direct_runtime.events + if event[0] == "scope.pop" and event[1][1] == "hermes.task_run" + ] + model_ends = [ + event for event in direct_runtime.events if event[0] == "llm.call_end" + ] + assert len(task_starts) == 1 + assert len(task_ends) == 1 + assert len(model_ends) == 1 + assert model_ends[0][2]["outcome"] == "success" + assert task_ends[0][2]["output"]["model_call_count_bucket"] == "1" + assert task_ends[0][2]["output"]["outcome"] == "success" + + +def test_gateway_and_delegated_entrypoints_flow_through_relay(direct_runtime): + tasks = [ + { + "session_id": "gateway-session", + "task_id": "gateway-task", + "platform": "whatsapp_cloud", + }, + { + "session_id": "child-session", + "task_id": "delegated-task", + "platform": "cli", + "parent_session_id": "private-parent-session", + }, + ] + for task in tasks: + plugins.invoke_hook("pre_llm_call", **task) + plugins.invoke_hook( + "on_session_end", + **task, + completed=True, + failed=False, + interrupted=False, + turn_exit_reason="text_response(stop)", + ) + + starts = [ + event[3]["input"] + for event in direct_runtime.events + if event[0] == "scope.push" and event[1] == "hermes.task_run" + ] + assert starts == [ + {"entrypoint": "gateway_message", "execution_surface": "gateway"}, + {"entrypoint": "delegated", "execution_surface": "cli"}, + ] + assert "private-parent-session" not in json.dumps(direct_runtime.events) + + def test_persistence_failure_does_not_escape_the_hook( direct_runtime, monkeypatch, @@ -388,7 +1168,7 @@ def test_persistence_failure_does_not_escape_the_hook( def fail_record(*_args: Any, **_kwargs: Any) -> None: raise OSError("store unavailable") - monkeypatch.setattr(runtime.subscriber.store, "record_model_call", fail_record) + monkeypatch.setattr(runtime.subscriber.store, "record_counter", fail_record) plugins.invoke_hook( "pre_api_request", session_id="s1", @@ -406,7 +1186,7 @@ def test_persistence_failure_does_not_escape_the_hook( model="gpt-5", ) - assert "Unable to persist the Hermes model-call metric" in caplog.text + assert "Unable to persist the Hermes shared metric" in caplog.text def test_close_does_not_reopen_a_session_after_scope_start_failure( diff --git a/tests/plugins/test_nemo_relay_plugin.py b/tests/plugins/test_nemo_relay_plugin.py index c752c2fe6e0..e8599b54f1a 100644 --- a/tests/plugins/test_nemo_relay_plugin.py +++ b/tests/plugins/test_nemo_relay_plugin.py @@ -34,7 +34,7 @@ class _FakeNemoRelay: self._scope_context = contextvars.ContextVar( "fake_nemo_relay_scope", default=None ) - self.ScopeType = SimpleNamespace(Agent="agent") + self.ScopeType = SimpleNamespace(Agent="agent", Function="function") self.scope = SimpleNamespace( push=self._scope_push, pop=self._scope_pop, @@ -49,6 +49,7 @@ class _FakeNemoRelay: call=self._tool_call, call_end=self._tool_call_end, execute=self._tool_execute, + request_intercepts=self._tool_request_intercepts, ) self.plugin = SimpleNamespace( initialize=self._plugin_initialize, @@ -126,10 +127,14 @@ class _FakeNemoRelay: def _tool_execute(self, name, args, func, **kwargs): self.events.append(("tool.execute.start", name, args, kwargs)) - result = func({"intercepted": True, **args}) + result = func(args) self.events.append(("tool.execute.end", name, result, kwargs)) return result + def _tool_request_intercepts(self, name, args): + self.events.append(("tool.request_intercepts", name, args)) + return {"intercepted": True, **args} + def _make_atof_exporter(self, config): return _FakeAtofExporter(self.events, config) @@ -411,8 +416,11 @@ def test_shared_metrics_and_rich_plugin_share_one_core_session( if item[0] == "scope.push" and item[1] == relay_runtime.SESSION_SCOPE ] assert len(session_pushes) == 1 - register_metrics = fake.events.index( - ("subscribers.register", "hermes.nemo_relay.shared_metrics") + register_metrics = next( + index + for index, item in enumerate(fake.events) + if item[0] == "subscribers.register" + and item[1].startswith("hermes.nemo_relay.shared_metrics.") ) register_atif = next( index for index, item in enumerate(fake.events) if item[0] == "atif.register" @@ -575,6 +583,15 @@ def test_nemo_relay_plugin_reparents_child_session_scope_for_embedded_atif(monke assert child_kwargs["metadata"]["parent_session_id"] == "parent-session" assert runtime.sessions["child-session"].parent_session_id == "parent-session" + plugin.on_subagent_stop( + parent_session_id="parent-session", + child_session_id="child-session", + child_status="completed", + ) + + assert "child-session" not in runtime.sessions + assert runtime.host.get_session("child-session") is None + def test_nemo_relay_plugin_skips_embedded_child_atif_file_by_default(tmp_path, monkeypatch): fake = _FakeNemoRelay() @@ -698,10 +715,15 @@ def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypa request={"messages": []}, next_call=lambda request: {"request": request}, ) - tool_result = plugin.on_tool_execution_middleware( + tool_args = relay_runtime.apply_tool_request_intercepts( session_id="s1", tool_name="fixture-tool", args={"value": 1}, + ) + tool_result = plugin.on_tool_execution_middleware( + session_id="s1", + tool_name="fixture-tool", + args=tool_args, next_call=lambda args: {"args": args}, ) assert llm_result["request"]["intercepted"] is True @@ -900,7 +922,7 @@ def test_nemo_relay_managed_tool_returns_post_interceptor_result(tmp_path, monke def execute(name, args, func, **kwargs): fake.events.append(("tool.execute.start", name, args, kwargs)) - raw = func({"intercepted": True, **args}) + raw = func(args) result = {"compressed": True, "raw": raw} fake.events.append(("tool.execute.end", name, result, kwargs)) return result @@ -918,10 +940,66 @@ def test_nemo_relay_managed_tool_returns_post_interceptor_result(tmp_path, monke assert result == { "compressed": True, - "raw": {"tool_output": {"intercepted": True, "value": 1}}, + "raw": {"tool_output": {"value": 1}}, } +def test_relay_tool_request_rewrite_precedes_hermes_authorization_boundary( + tmp_path, + monkeypatch, +): + from hermes_cli.middleware import apply_tool_request_middleware + + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + _enable_dynamic_plugin(tmp_path, monkeypatch) + plugin.on_session_start(session_id="s1") + + result = apply_tool_request_middleware( + "fixture-tool", + {"value": 1}, + session_id="s1", + tool_call_id="tool-1", + ) + + assert result.payload == {"intercepted": True, "value": 1} + assert result.trace[0] == {"source": "nemo_relay"} + + +def test_managed_tool_refuses_post_authorization_argument_rewrite( + tmp_path, + monkeypatch, +): + fake = _FakeNemoRelay() + + def execute(name, args, func, **kwargs): + del name, kwargs + return func({**args, "after_approval": True}) + + fake.tools.execute = execute + plugin = _fresh_plugin(monkeypatch, fake) + _enable_dynamic_plugin(tmp_path, monkeypatch) + dispatched = False + + def next_call(args): + nonlocal dispatched + dispatched = True + return args + + with pytest.raises( + RuntimeError, + match="changed tool arguments after Hermes authorization", + ): + plugin.on_tool_execution_middleware( + session_id="s1", + tool_name="fixture-tool", + args={"value": 1}, + next_call=next_call, + ) + + assert not dispatched + + def test_nemo_relay_plugin_activates_before_registering_managed_middleware(tmp_path, monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) @@ -1744,6 +1822,11 @@ mode = "observe_only" seen_args.update(args) return {"raw": True, "args": args} + approved_args = relay_runtime.apply_tool_request_intercepts( + session_id="s1", + tool_name="terminal", + args={"command": "pwd"}, + ) response = plugin.on_tool_execution_middleware( session_id="s1", task_id="t1", @@ -1751,7 +1834,7 @@ mode = "observe_only" api_request_id="api-1", tool_name="terminal", tool_call_id="tool-1", - args={"command": "pwd"}, + args=approved_args, next_call=next_call, ) @@ -1768,7 +1851,7 @@ def test_nemo_relay_adaptive_tool_execution_preserves_downstream_error(tmp_path, def native_like_execute(name, args, func, **kwargs): fake.events.append(("tool.execute.start", name, args, kwargs)) try: - return func({"intercepted": True, **args}) + return func(args) except Exception as exc: raise RuntimeError(f"internal error: {type(exc).__name__}: {exc}") from None @@ -1827,7 +1910,7 @@ def test_nemo_relay_adaptive_tool_execution_keeps_wrapped_relay_error_after_down def translated_execute(name, args, func, **kwargs): try: - return func({"intercepted": True, **args}) + return func(args) except Exception: raise relay_error @@ -1859,7 +1942,7 @@ def test_nemo_relay_adaptive_tool_execution_keeps_relay_translated_error(tmp_pat def translated_execute(name, args, func, **kwargs): try: - return func({"intercepted": True, **args}) + return func(args) except Exception: raise relay_error diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index 469b8a6921e..e6b0bfd51f3 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -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. diff --git a/tests/tools/test_zombie_process_cleanup.py b/tests/tools/test_zombie_process_cleanup.py index a8b745f541a..6e0d41148d0 100644 --- a/tests/tools/test_zombie_process_cleanup.py +++ b/tests/tools/test_zombie_process_cleanup.py @@ -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,34 @@ 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) - result = _run_single_child( - task_index=0, - goal="test goal", - child=child, - parent_agent=parent, - ) + 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.close_session.assert_called_once_with({"session_id": "child-session"}) assert child not in parent._active_children assert result["status"] == "error" diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 12f94a180a8..94eab58fb60 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -18,6 +18,7 @@ never the child's intermediate tool calls or reasoning. """ import enum +import contextvars import json import logging @@ -2008,7 +2009,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: @@ -2399,6 +2404,18 @@ def _run_single_child( except Exception: logger.debug("Failed to close child agent after delegation") + # The child owns its Relay scope lifetime. Close it here, on the worker + # that ran the child, before the parent emits its terminal report. + try: + from hermes_cli.observability import relay_runtime + + runtime = relay_runtime.get_runtime(create=False) + child_session_id = str(getattr(child, "session_id", "") or "") + if runtime is not None and child_session_id: + runtime.close_session({"session_id": child_session_id}) + except Exception: + logger.debug("Failed to close child Relay session after delegation") + def _recover_tasks_from_json_string( tasks: Any, @@ -2626,7 +2643,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"], From 9bc521b1384f5a7a19c5441059225ae8e9a24a09 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 08:57:13 -0400 Subject: [PATCH 004/526] refactor(runtime): manage Relay LLM tool and subagent execution Signed-off-by: Alex Fournier --- agent/agent_runtime_helpers.py | 17 +- agent/chat_completion_helpers.py | 536 ++++++----- agent/codex_runtime.py | 102 +- agent/conversation_loop.py | 22 +- agent/relay_llm.py | 683 ++++++++++++++ agent/relay_runtime.py | 767 +++++++++++++++ agent/relay_tools.py | 133 +++ agent/tool_executor.py | 879 ++++++++++-------- agent/turn_context.py | 7 +- docs/observability/relay-shared-metrics.md | 5 +- hermes_cli/middleware.py | 4 +- hermes_cli/observability/__init__.py | 12 +- hermes_cli/observability/relay_runtime.py | 490 +--------- .../observability/relay_shared_metrics.py | 13 +- .../observability/shared_metrics_contract.py | 2 +- .../shared_metrics_subscriber.py | 3 +- model_tools.py | 29 +- plugins/observability/nemo_relay/__init__.py | 27 +- pyproject.toml | 3 + run_agent.py | 53 +- tests/agent/test_relay_llm.py | 225 +++++ tests/agent/test_relay_tools.py | 85 ++ .../test_relay_shared_metrics_runtime.py | 356 ++++++- tests/plugins/test_nemo_relay_plugin.py | 41 +- tests/run_agent/test_run_agent.py | 31 +- .../test_tool_call_guardrail_runtime.py | 103 ++ tests/test_project_metadata.py | 2 +- tests/tools/test_zombie_process_cleanup.py | 33 +- tools/delegate_tool.py | 13 +- uv.lock | 2 +- 30 files changed, 3433 insertions(+), 1245 deletions(-) create mode 100644 agent/relay_llm.py create mode 100644 agent/relay_runtime.py create mode 100644 agent/relay_tools.py create mode 100644 tests/agent/test_relay_llm.py create mode 100644 tests/agent/test_relay_tools.py diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index bb0cac50831..b1603130243 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2330,7 +2330,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 @@ -2508,8 +2509,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 "", @@ -2521,6 +2521,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 diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 04c59ae7fd1..2ec624179a1 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -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. @@ -752,7 +759,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(): @@ -2273,6 +2280,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= def _bedrock_call(): try: + from agent import relay_llm from agent.bedrock_adapter import ( _get_bedrock_runtime_client, invalidate_runtime_client, @@ -2281,44 +2289,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() @@ -2333,18 +2337,61 @@ 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" + ), + }, + ) + 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 - 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) @@ -2613,102 +2660,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) - # 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() @@ -2723,19 +2674,97 @@ 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): - logger.warning( - "Streaming attempt superseded by a newer stream; stopping " - "consumption to preserve the single-writer invariant " - "(model=%s).", - api_kwargs.get("model", "unknown"), + _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, ) - break + ) + 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: + token = _writer_token["value"] + if token is None or stream_writer_is_current(agent, token): + return True + logger.warning( + "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 _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 = 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" + ), + }, + ) + for chunk in stream: last_chunk_time["t"] = time.time() agent._touch_activity("receiving stream response") @@ -2894,6 +2923,39 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= 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 @@ -3047,72 +3109,92 @@ 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 = 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" + ), + }, + ) + 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": @@ -3121,7 +3203,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: @@ -3137,48 +3218,35 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= if thinking_text: _fire_first_delta() agent._fire_reasoning_delta(thinking_text) + 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: + manager = _stream_context["manager"] + if manager is not None: + manager.__exit__(None, None, None) - # 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. - 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 + if agent._interrupt_requested: + return None + 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 @@ -3571,7 +3639,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 diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 91c2af3e995..e0ea0680808 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -1187,6 +1187,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. @@ -1211,48 +1213,69 @@ 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} - try: - event_stream = active_client.responses.create(**stream_kwargs) - 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, - ) - continue - raise + 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) - # 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 _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 _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"), - ) + 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 - 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 + def _finalize_codex_stream() -> Any: + return _consume_codex_event_stream( + list(intercepted_events), + model=api_kwargs.get("model"), + ) + 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, + }, + ) + + def _interrupt_or_superseded() -> bool: + return bool(agent._interrupt_requested) + + try: try: final = _consume_codex_event_stream( event_stream, @@ -1271,6 +1294,11 @@ 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. + for _ignored in event_stream: + pass except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc: if attempt < max_stream_retries: logger.debug( @@ -1281,6 +1309,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( diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 83c53408777..039c3ba73a4 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1444,7 +1444,27 @@ 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, + }, + ) from hermes_cli.middleware import run_llm_execution_middleware diff --git a/agent/relay_llm.py b/agent/relay_llm.py new file mode 100644 index 00000000000..98d0f83baea --- /dev/null +++ b/agent/relay_llm.py @@ -0,0 +1,683 @@ +"""Core NeMo Relay adapters for physical Hermes provider attempts.""" + +from __future__ import annotations + +import asyncio +import inspect +import json +from collections.abc import Callable, Iterator +from types import SimpleNamespace +from typing import Any + +from agent import relay_runtime + + +_PROVIDER_MESSAGE_EXTENSION_KEYS = frozenset( + {"reasoning_content", "reasoning_details"} +) + + +def execute( + request: dict[str, Any], + callback: Callable[[dict[str, Any]], Any], + *, + session_id: str, + name: str, + model_name: str, + metadata: dict[str, Any] | None = None, +) -> Any: + """Run one non-streaming physical provider attempt through Relay.""" + runtime, session, parent = _execution_context(session_id) + if runtime is None or session is None: + return callback(request) + logical = _logical_parent(runtime, session, parent, metadata) + parent = logical[1] if logical is not None else parent + + relay_request_body = _relay_request_body(request, metadata) + relay_request = runtime.relay.LLMRequest({}, relay_request_body) + raw_response: dict[str, Any] = {} + callback_error: BaseException | None = None + + def invoke(next_request: Any) -> Any: + nonlocal callback_error + try: + final_request = _provider_request( + request, + next_request, + relay_request_body=relay_request_body, + metadata=metadata, + ) + raw = callback(final_request) + except BaseException as exc: + callback_error = exc + raise + raw_response["value"] = raw + raw_response["json"] = _jsonable(raw) + return raw_response["json"] + + try: + managed = _run_awaitable( + runtime.run_in_session_async( + session, + runtime.relay.llm.execute, + name, + relay_request, + invoke, + handle=parent, + metadata=_jsonable(metadata or {}), + model_name=model_name, + codec=_codec(runtime.relay, metadata), + response_codec=_codec(runtime.relay, metadata), + ) + ) + except BaseException as exc: + if callback_error is not None and _relay_wrapped_callback_error( + exc, callback_error + ): + raise callback_error + raise + + if "value" in raw_response and _json_equal(managed, raw_response["json"]): + _complete_logical(logical, outcome="success") + return raw_response["value"] + _complete_logical(logical, outcome="success") + return managed + + +def stream( + request: dict[str, Any], + stream_factory: Callable[[dict[str, Any]], Any], + *, + session_id: str, + name: str, + model_name: str, + finalizer: Callable[[], Any], + on_stream_created: Callable[[Any], None] | None = None, + on_chunk: Callable[[Any], None] | None = None, + chunk_adapter: Callable[[Any], Any] | None = None, + accept_chunk: Callable[[Any], bool] | None = None, + completed_response_predicate: Callable[[Any], bool] | None = None, + metadata: dict[str, Any] | None = None, +) -> "ManagedLlmStream": + """Return a synchronous view of one Relay-managed provider stream.""" + return ManagedLlmStream( + request, + stream_factory, + session_id=session_id, + name=name, + model_name=model_name, + finalizer=finalizer, + on_stream_created=on_stream_created, + on_chunk=on_chunk, + chunk_adapter=chunk_adapter, + accept_chunk=accept_chunk, + completed_response_predicate=completed_response_predicate, + metadata=metadata, + ) + + +class ManagedLlmStream(Iterator[Any]): + """Drive Relay's async stream from Hermes's provider worker thread.""" + + def __init__( + self, + request: dict[str, Any], + stream_factory: Callable[[dict[str, Any]], Any], + *, + session_id: str, + name: str, + model_name: str, + finalizer: Callable[[], Any], + on_stream_created: Callable[[Any], None] | None, + on_chunk: Callable[[Any], None] | None, + chunk_adapter: Callable[[Any], Any] | None, + accept_chunk: Callable[[Any], bool] | None, + completed_response_predicate: Callable[[Any], bool] | None, + metadata: dict[str, Any] | None, + ) -> None: + self.final_response: Any = None + self._loop: asyncio.AbstractEventLoop | None = None + self._stream: Any = None + self._closed = False + self._callback_error: BaseException | None = None + self._logical: tuple[relay_runtime.RelayTurnContext, Any, str] | None = None + self._on_chunk = on_chunk + self._chunk_adapter = chunk_adapter or _namespace + self._accept_chunk = accept_chunk + self._relay_observes_chunks = False + self._raw_chunks: list[tuple[Any, Any]] = [] + + runtime, session, parent = _execution_context(session_id) + if runtime is None or session is None: + raw_stream = stream_factory(request) + if completed_response_predicate is not None and completed_response_predicate( + raw_stream + ): + self.final_response = raw_stream + self._stream = iter(()) + else: + if on_stream_created is not None: + on_stream_created(raw_stream) + self._stream = iter(raw_stream) + return + + self._logical = _logical_parent(runtime, session, parent, metadata) + if self._logical is not None: + parent = self._logical[1] + relay_request_body = _relay_request_body(request, metadata) + relay_request = runtime.relay.LLMRequest({}, relay_request_body) + + async def provider_stream(next_request: Any): + raw_stream = None + try: + raw_stream = stream_factory( + _provider_request( + request, + next_request, + relay_request_body=relay_request_body, + metadata=metadata, + ) + ) + if ( + completed_response_predicate is not None + and completed_response_predicate(raw_stream) + ): + self.final_response = raw_stream + return + if on_stream_created is not None: + on_stream_created(raw_stream) + for chunk in raw_stream: + if self._accept_chunk is not None and not self._accept_chunk( + chunk + ): + break + encoded_chunk = _jsonable(chunk) + self._raw_chunks.append((encoded_chunk, chunk)) + yield encoded_chunk + except BaseException as exc: + self._callback_error = exc + raise + finally: + close = getattr(raw_stream, "close", None) + if callable(close): + close() + + def observe_chunk(chunk: Any) -> None: + if self._on_chunk is not None: + self._on_chunk(_jsonable(chunk)) + + def relay_finalizer() -> Any: + if self.final_response is not None: + return _jsonable(self.final_response) + return _jsonable(finalizer()) + + loop = asyncio.new_event_loop() + self._loop = loop + self._relay_observes_chunks = True + try: + self._stream = loop.run_until_complete( + runtime.run_in_session_async( + session, + runtime.relay.llm.stream_execute, + name, + relay_request, + provider_stream, + observe_chunk, + relay_finalizer, + handle=parent, + metadata=_jsonable(metadata or {}), + model_name=model_name, + codec=_codec(runtime.relay, metadata), + response_codec=_codec(runtime.relay, metadata), + ) + ) + except BaseException: + loop.close() + self._loop = None + raise + + def __iter__(self) -> "ManagedLlmStream": + return self + + def __next__(self) -> Any: + if self._closed: + raise StopIteration + if self._loop is None: + try: + return next(self._stream) + except StopIteration: + self.close() + raise + + async def next_chunk() -> Any: + return await anext(self._stream) + + try: + chunk = self._loop.run_until_complete(next_chunk()) + except StopAsyncIteration: + _complete_logical(self._logical, outcome="success") + self._logical = None + self.close() + raise StopIteration from None + except BaseException as exc: + callback_error = self._callback_error + self.close() + if callback_error is not None and _relay_wrapped_callback_error( + exc, callback_error + ): + raise callback_error + raise + if not self._relay_observes_chunks and self._on_chunk is not None: + self._on_chunk(chunk) + for index, (encoded, raw) in enumerate(self._raw_chunks): + if _json_equal(chunk, encoded): + del self._raw_chunks[: index + 1] + return raw + return self._chunk_adapter(chunk) + + def close(self) -> None: + if self._closed: + return + self._closed = True + loop = self._loop + self._loop = None + if loop is None: + close = getattr(self._stream, "close", None) + if callable(close): + close() + return + close = getattr(self._stream, "aclose", None) + if callable(close): + + async def close_stream() -> None: + await close() + + try: + loop.run_until_complete(close_stream()) + except Exception: + pass + loop.close() + + def __del__(self) -> None: + self.close() + + +class AnthropicStreamAccumulator: + """Rebuild an Anthropic Message from post-intercept SSE events.""" + + def __init__(self) -> None: + self._message: dict[str, Any] = {} + self._blocks: dict[int, dict[str, Any]] = {} + + def observe(self, event: Any) -> None: + payload = _jsonable(event) + if not isinstance(payload, dict): + return + event_type = payload.get("type") + if event_type == "message_start": + message = payload.get("message") + if isinstance(message, dict): + for key in ("id", "type", "role", "model", "usage"): + if key in message: + self._message[key] = message[key] + return + if event_type == "content_block_start": + index = payload.get("index") + block = payload.get("content_block") + if isinstance(index, int) and isinstance(block, dict): + self._blocks[index] = dict(block) + return + if event_type == "content_block_delta": + index = payload.get("index") + delta = payload.get("delta") + if not isinstance(index, int) or not isinstance(delta, dict): + return + block = self._blocks.setdefault(index, {}) + delta_type = delta.get("type") + if delta_type == "text_delta": + block["text"] = str(block.get("text") or "") + str( + delta.get("text") or "" + ) + elif delta_type == "thinking_delta": + block["thinking"] = str(block.get("thinking") or "") + str( + delta.get("thinking") or "" + ) + elif delta_type == "signature_delta": + block["signature"] = str(block.get("signature") or "") + str( + delta.get("signature") or "" + ) + elif delta_type == "input_json_delta": + partial = str(block.pop("_partial_json", "")) + str( + delta.get("partial_json") or "" + ) + block["_partial_json"] = partial + elif delta_type == "citations_delta" and "citation" in delta: + block.setdefault("citations", []).append(delta["citation"]) + return + if event_type == "message_delta": + delta = payload.get("delta") + if isinstance(delta, dict): + for key in ("stop_reason", "stop_sequence"): + if key in delta: + self._message[key] = delta[key] + if "usage" in payload: + self._message["usage"] = payload["usage"] + + def finalize(self) -> dict[str, Any]: + blocks = [] + for index in sorted(self._blocks): + block = dict(self._blocks[index]) + partial = block.pop("_partial_json", None) + if partial is not None: + try: + block["input"] = json.loads(partial) + except (TypeError, ValueError): + block["input"] = partial + blocks.append(block) + return {**self._message, "content": blocks} + + def response(self, base: Any = None) -> Any: + """Return the attribute-shaped response consumed by Hermes.""" + assembled = self.finalize() + if base is not None and base.__class__.__module__ == "unittest.mock": + base_payload = {} + for key in ( + "id", + "type", + "role", + "model", + "content", + "stop_reason", + "stop_sequence", + "usage", + ): + value = getattr(base, key, None) + if value is not None and value.__class__.__module__ != "unittest.mock": + base_payload[key] = _jsonable(value) + else: + base_payload = _jsonable(base) + if not isinstance(base_payload, dict): + base_payload = {} + content = assembled.pop("content", []) + merged = {**base_payload, **assembled} + if content or "content" not in merged: + merged["content"] = content + return _namespace(merged) + + +def _execution_context( + session_id: str, +) -> tuple[relay_runtime.RelayRuntime | None, Any, Any]: + turn = relay_runtime.current_turn() + if turn is not None and isinstance(turn.lease.host, relay_runtime.RelayRuntime): + return turn.lease.host, turn.lease.session, turn.handle + runtime = relay_runtime.get_runtime() + if runtime is None: + 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 _logical_parent( + runtime: relay_runtime.RelayRuntime, + session: Any, + parent: Any, + metadata: dict[str, Any] | None, +) -> tuple[relay_runtime.RelayTurnContext, Any, str] | None: + turn = relay_runtime.current_turn() + request_id = str((metadata or {}).get("api_request_id") or "") + if turn is None or not request_id or turn.lease.host is not runtime: + return None + with turn.logical_llm_lock: + handle = turn.logical_llm_calls.get(request_id) + if handle is None: + handle = runtime.run_in_session( + session, + runtime.relay.scope.push, + relay_runtime.LOGICAL_LLM_SCOPE, + runtime.relay.ScopeType.Function, + handle=parent, + input={}, + metadata={ + relay_runtime.RUNTIME_SCHEMA_KEY: relay_runtime.RUNTIME_SCHEMA_VERSION, + relay_runtime.RUNTIME_INSTANCE_KEY: runtime.runtime_id, + "hermes.call_role": str( + (metadata or {}).get("call_role") or "primary" + ), + }, + ) + turn.logical_llm_calls[request_id] = handle + return turn, handle, request_id + + +def _complete_logical( + logical: tuple[relay_runtime.RelayTurnContext, Any, str] | None, + *, + outcome: str, +) -> None: + if logical is None: + return + turn, handle, request_id = logical + lease = turn.lease + if not isinstance(lease.host, relay_runtime.RelayRuntime): + return + with turn.logical_llm_lock: + if turn.logical_llm_calls.get(request_id) is not handle: + return + turn.logical_llm_calls.pop(request_id, None) + if lease.session is None: + return + lease.host.run_in_session( + lease.session, + lease.host.relay.scope.pop, + handle, + output={"outcome": outcome}, + metadata={ + relay_runtime.RUNTIME_SCHEMA_KEY: relay_runtime.RUNTIME_SCHEMA_VERSION, + relay_runtime.RUNTIME_INSTANCE_KEY: lease.host.runtime_id, + }, + ) + + +def _provider_request( + original: dict[str, Any], + request: Any, + *, + relay_request_body: dict[str, Any], + metadata: dict[str, Any] | None, +) -> dict[str, Any]: + content = getattr(request, "content", request) + if not isinstance(content, dict): + content = relay_request_body + if _json_equal(content, relay_request_body): + final = dict(original) + else: + final = _provider_request_body(content, metadata) + # Codec-only normalization must not silently change the provider wire + # request when an unrelated interceptor edits another field. + for key, value in original.items(): + if key not in relay_request_body and value is None: + final.setdefault(key, value) + _restore_provider_message_extensions(original, final) + headers = getattr(request, "headers", None) + if isinstance(headers, dict) and headers: + final["extra_headers"] = { + **dict(final.get("extra_headers") or {}), + **headers, + } + return final + + +def _relay_request_body( + request: dict[str, Any], metadata: dict[str, Any] | None +) -> dict[str, Any]: + body = _jsonable(request) + if not isinstance(body, dict): + return {} + # The Responses SDK accepts ``tools=None`` as "no tools", while Relay's + # typed Responses codec correctly expects either an array or an absent + # field. Normalize only the codec-facing copy; the original provider + # request is restored when no interceptor changes it. + if str((metadata or {}).get("api_mode") or "") == "codex_responses": + body = dict(body) + if body.get("tools") is None: + body.pop("tools", None) + elif isinstance(body.get("tools"), list): + body["tools"] = [ + { + "type": "function", + "function": { + key: value + for key, value in tool.items() + if key != "type" + }, + } + if isinstance(tool, dict) + and tool.get("type") == "function" + and "function" not in tool + else tool + for tool in body["tools"] + ] + elif str((metadata or {}).get("api_mode") or "") == "chat_completions": + tools = body.get("tools") + if isinstance(tools, list): + body = dict(body) + body["tools"] = [ + {"type": "function", **tool} + if isinstance(tool, dict) + and "function" in tool + and "type" not in tool + else tool + for tool in tools + ] + return body + + +def _restore_provider_message_extensions( + original: dict[str, Any], final: dict[str, Any] +) -> None: + """Restore provider wire fields that Relay's typed codec cannot represent.""" + original_messages = original.get("messages") + final_messages = final.get("messages") + if not isinstance(original_messages, list) or not isinstance(final_messages, list): + return + if len(original_messages) != len(final_messages): + return + for original_message, final_message in zip( + original_messages, final_messages, strict=True + ): + if not isinstance(original_message, dict) or not isinstance(final_message, dict): + continue + for key in _PROVIDER_MESSAGE_EXTENSION_KEYS: + if key in original_message and key not in final_message: + final_message[key] = original_message[key] + + +def _provider_request_body( + content: dict[str, Any], metadata: dict[str, Any] | None +) -> dict[str, Any]: + body = dict(content) + if str((metadata or {}).get("api_mode") or "") != "codex_responses": + return body + tools = body.get("tools") + if not isinstance(tools, list): + return body + body["tools"] = [ + { + "type": "function", + **dict(tool["function"]), + } + if isinstance(tool, dict) + and tool.get("type") == "function" + and isinstance(tool.get("function"), dict) + else tool + for tool in tools + ] + return body + + +def _codec(relay: Any, metadata: dict[str, Any] | None) -> Any: + api_mode = str((metadata or {}).get("api_mode") or "") + codecs = getattr(relay, "codecs", None) + if codecs is None: + return None + if api_mode == "chat_completions": + codec = getattr(codecs, "OpenAIChatCodec", None) + elif api_mode == "anthropic_messages": + codec = getattr(codecs, "AnthropicMessagesCodec", None) + elif api_mode == "codex_responses": + codec = getattr(codecs, "OpenAIResponsesCodec", None) + else: + codec = None + return codec() if callable(codec) else None + + +def _jsonable(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + # Test doubles synthesize arbitrary callable attributes such as + # ``model_dump``. Treat them as opaque instead of recursively invoking an + # endless chain of child mocks. + if value.__class__.__module__ == "unittest.mock": + return str(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 _namespace(value: Any) -> Any: + if isinstance(value, dict): + return SimpleNamespace(**{ + str(key): _namespace(item) for key, item in value.items() + }) + if isinstance(value, list): + return [_namespace(item) for item in value] + return 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 False + + +def _relay_wrapped_callback_error( + exc: BaseException, + callback_error: BaseException, +) -> bool: + if exc is callback_error: + return True + expected_suffix = f"{callback_error.__class__.__name__}: {callback_error}" + return ( + isinstance(exc, RuntimeError) + and str(exc).startswith("internal error: ") + and str(exc).endswith(expected_suffix) + ) + + +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 Relay LLM execution cannot run on an event-loop thread" + ) diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py new file mode 100644 index 00000000000..c7e78403403 --- /dev/null +++ b/agent/relay_runtime.py @@ -0,0 +1,767 @@ +"""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" + +SESSION_START_HOOKS = frozenset({"on_session_start"}) +SESSION_CLOSE_HOOKS = frozenset({"on_session_finalize", "on_session_reset"}) +HANDLED_HOOKS = SESSION_START_HOOKS | SESSION_CLOSE_HOOKS + + +@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._shutdown_registered = True + atexit.register(self.shutdown) + + 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 = current_turn() + 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") + + 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 session.context.copy().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") + context = session.context.copy() + + 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.""" + 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 + + 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() + 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, + ) + _token: contextvars.Token[RelayTurnContext | None] | None = field( + default=None, + 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 + + def acquire_conversation( + self, + *, + profile_key: str, + session_id: str, + platform: str, + parent_session_id: 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: + 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) + return turn + + def end_turn( + self, + turn: RelayTurnContext, + *, + outcome: str, + ) -> None: + if turn.closed: + return + turn.closed = True + try: + lease = turn.lease + if ( + turn.handle is not None + and isinstance(lease.host, RelayRuntime) + and lease.session is not None + ): + 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: + logger.warning( + "Hermes Relay logical LLM finalization failed", + exc_info=True, + ) + 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: + if turn._token is not None: + _CURRENT_TURN.reset(turn._token) + 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 handles_hook(hook_name: str) -> bool: + """Return whether the core Relay host consumes this lifecycle hook.""" + return hook_name in HANDLED_HOOKS + + +def observe_lifecycle(hook_name: str, **kwargs: Any) -> None: + """Apply session lifecycle events to the core Relay host.""" + if not handles_hook(hook_name): + return + # Session hooks do not activate Relay by themselves. A direct core + # producer or an enabled built-in consumer creates the host lazily, after + # which these hooks keep its session lifetime correct. + runtime = get_runtime(create=False) + if runtime is None: + return + try: + if hook_name in SESSION_START_HOOKS: + runtime.ensure_session(kwargs) + elif hook_name in SESSION_CLOSE_HOOKS: + runtime.close_session(kwargs) + except Exception: + logger.warning("Hermes Relay lifecycle failed: %s", hook_name, exc_info=True) + + +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() + 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() + 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 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.""" + return str(get_hermes_home().expanduser().resolve()) + + +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.""" + HOST_REGISTRY.shutdown_all() diff --git a/agent/relay_tools.py b/agent/relay_tools.py new file mode 100644 index 00000000000..16867cd464e --- /dev/null +++ b/agent/relay_tools.py @@ -0,0 +1,133 @@ +"""Core NeMo Relay adapter for Hermes tool execution.""" + +from __future__ import annotations + +import asyncio +import inspect +import json +from collections.abc import Callable +from typing import Any + +from agent import relay_runtime + + +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 = _execution_context(session_id) + if runtime is None or session is None: + return callback(args), args + + observed_args = args + raw_result: dict[str, Any] = {} + callback_error: BaseException | None = None + + 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(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_wrapped_callback_error( + exc, callback_error + ): + raise callback_error + raise + + if "value" in raw_result and _json_equal(managed, raw_result["json"]): + return raw_result["value"], observed_args + return managed, observed_args + + +def _execution_context( + session_id: str, +) -> tuple[relay_runtime.RelayRuntime | None, Any, Any]: + turn = relay_runtime.current_turn() + if turn is not None and isinstance(turn.lease.host, relay_runtime.RelayRuntime): + return turn.lease.host, turn.lease.session, turn.handle + runtime = relay_runtime.get_runtime() + if runtime is None: + 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 _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 _relay_wrapped_callback_error( + relay_error: BaseException, callback_error: BaseException +) -> bool: + message = str(relay_error) + callback_type = type(callback_error) + type_names = { + callback_type.__name__, + f"{callback_type.__module__}.{callback_type.__qualname__}", + } + return "internal error" in message.lower() and any( + type_name in message for type_name in type_names + ) + + +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" + ) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index f740fedb79c..a5a1e54ed85 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -20,6 +20,7 @@ import os import random import threading import time +from dataclasses import dataclass from typing import Any, Optional from agent.display import ( @@ -265,31 +266,23 @@ def _tool_search_scoped_names(agent) -> frozenset: return names -def _apply_tool_request_middleware_for_agent( - agent, - *, - function_name: str, - function_args: dict, - effective_task_id: str, - tool_call_id: str, -) -> tuple[dict, list[dict[str, Any]]]: - try: - from hermes_cli.middleware import apply_tool_request_middleware +@dataclass +class _ManagedToolResult: + result: Any + args: dict[str, Any] + middleware_trace: list[dict[str, Any]] + blocked: bool - result = apply_tool_request_middleware( - function_name, - function_args, - task_id=effective_task_id or "", - session_id=getattr(agent, "session_id", "") or "", - tool_call_id=tool_call_id or "", - turn_id=getattr(agent, "_current_turn_id", "") or "", - api_request_id=getattr(agent, "_current_api_request_id", "") or "", - ) - payload = result.payload if isinstance(result.payload, dict) else function_args - return payload, list(result.trace) - except Exception as exc: - logger.debug("tool_request middleware error: %s", exc) - return function_args, [] + +def _managed_values( + outcome: _ManagedToolResult, +) -> tuple[Any, dict[str, Any], list[dict[str, Any]], bool]: + return ( + outcome.result, + outcome.args, + outcome.middleware_trace, + outcome.blocked, + ) def _run_agent_tool_execution_middleware( @@ -300,28 +293,253 @@ def _run_agent_tool_execution_middleware( effective_task_id: str, tool_call_id: str, execute, -) -> tuple[Any, dict]: - observed_args = function_args + scope_block: str | None = None, + display_index: int | None = None, + middleware_trace: list[dict[str, Any]] | None = None, + begin_execution=None, +) -> _ManagedToolResult: + """Run Relay rewrites before Hermes policy and dispatch exactly once.""" + from agent import relay_tools + from hermes_cli.middleware import ( + apply_tool_request_middleware, + run_tool_execution_middleware, + ) - def _execute(next_args: dict) -> Any: - nonlocal observed_args - observed_args = next_args if isinstance(next_args, dict) else function_args - return execute(observed_args) + trace = middleware_trace if middleware_trace is not None else [] + state = { + "args": function_args, + "middleware_trace": trace, + "blocked": False, + } - from hermes_cli.middleware import run_tool_execution_middleware + def _authorized_dispatch(final_args: dict[str, Any]) -> Any: + state["args"] = final_args - result = run_tool_execution_middleware( + def _begin() -> None: + _begin_tool_execution( + agent, + function_name=function_name, + function_args=final_args, + effective_task_id=effective_task_id, + tool_call_id=tool_call_id, + display_index=display_index, + ) + + def _advance_start_order(callback=None) -> None: + if begin_execution is None: + if callback is not None: + callback() + return + begin_execution(callback) + + block_message = scope_block + block_error_type = "tool_scope_block" + if block_message is None: + block_error_type = "plugin_block" + try: + from hermes_cli.plugins import resolve_pre_tool_block + + block_message = resolve_pre_tool_block( + function_name, + final_args, + task_id=effective_task_id or "", + session_id=getattr(agent, "session_id", "") or "", + tool_call_id=tool_call_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") + or "", + middleware_trace=list(state["middleware_trace"]), + ) + except Exception: + block_message = None + + guardrail_decision = None + if block_message is None: + guardrail_decision = agent._tool_guardrails.before_call( + function_name, final_args + ) + if guardrail_decision.allows_execution: + guardrail_decision = None + + if block_message is not None or guardrail_decision is not None: + _advance_start_order() + state["blocked"] = True + if block_message is not None: + result = json.dumps({"error": block_message}, ensure_ascii=False) + error_type = block_error_type + error_message = block_message + else: + result = agent._guardrail_block_result(guardrail_decision) + error_type = "guardrail_block" + error_message = ( + getattr(guardrail_decision, "message", None) + or "Tool blocked by guardrail policy" + ) + _emit_terminal_post_tool_call( + agent, + function_name=function_name, + function_args=final_args, + result=result, + effective_task_id=effective_task_id, + tool_call_id=tool_call_id, + status="blocked", + error_type=error_type, + error_message=error_message, + middleware_trace=list(state["middleware_trace"]), + ) + return result + + if function_name == "memory": + agent._turns_since_memory = 0 + elif function_name == "skill_manage": + agent._iters_since_skill = 0 + + _advance_start_order(_begin) + return execute(final_args) + + def _hermes_pipeline(relay_args: dict[str, Any]) -> Any: + request_result = apply_tool_request_middleware( + function_name, + relay_args, + skip_relay=True, + task_id=effective_task_id or "", + session_id=getattr(agent, "session_id", "") or "", + tool_call_id=tool_call_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") or "", + ) + request_args = ( + request_result.payload + if isinstance(request_result.payload, dict) + else relay_args + ) + trace.clear() + trace.extend(request_result.trace) + return run_tool_execution_middleware( + function_name, + request_args, + lambda next_args: _authorized_dispatch( + next_args if isinstance(next_args, dict) else request_args + ), + original_args=function_args, + task_id=effective_task_id or "", + session_id=getattr(agent, "session_id", "") or "", + tool_call_id=tool_call_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") or "", + ) + + result, _relay_args = relay_tools.execute( function_name, function_args, - _execute, - original_args=function_args, - task_id=effective_task_id or "", - session_id=getattr(agent, "session_id", "") or "", - tool_call_id=tool_call_id or "", - turn_id=getattr(agent, "_current_turn_id", "") or "", - api_request_id=getattr(agent, "_current_api_request_id", "") or "", + _hermes_pipeline, + session_id=str(getattr(agent, "session_id", "") or ""), + metadata={ + "task_id": effective_task_id or "", + "turn_id": getattr(agent, "_current_turn_id", "") or "", + "api_request_id": getattr(agent, "_current_api_request_id", "") or "", + "tool_call_id": tool_call_id or "", + }, ) - return result, observed_args + return _ManagedToolResult( + result=result, + args=state["args"], + middleware_trace=state["middleware_trace"], + blocked=bool(state["blocked"]), + ) + + +def _begin_tool_execution( + agent, + *, + function_name: str, + function_args: dict[str, Any], + effective_task_id: str, + tool_call_id: str, + display_index: int | None, +) -> None: + """Run user-visible and checkpoint preflight on final tool arguments.""" + if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": + display_args = ( + _redact_tool_args_for_display(function_name, function_args) or function_args + ) + args_str = json.dumps(display_args, ensure_ascii=False) + prefix = f"Tool {display_index}" if display_index is not None else "Tool" + if agent.verbose_logging: + print(f" 📞 {prefix}: {function_name}({list(display_args.keys())})") + print( + agent._wrap_verbose( + "Args: ", json.dumps(display_args, indent=2, ensure_ascii=False) + ) + ) + else: + args_preview = ( + args_str[: agent.log_prefix_chars] + "..." + if len(args_str) > agent.log_prefix_chars + else args_str + ) + print( + f" 📞 {prefix}: {function_name}({list(function_args.keys())}) - " + f"{args_preview}" + ) + + agent._current_tool = function_name + agent._touch_activity(f"executing tool: {function_name}") + try: + from tools.environments.base import set_activity_callback + + set_activity_callback(agent._touch_activity) + except Exception: + pass + + if agent.tool_progress_callback: + try: + display_args = ( + _redact_tool_args_for_display(function_name, function_args) + or function_args + ) + preview = _build_tool_preview(function_name, display_args) + agent.tool_progress_callback( + "tool.started", function_name, preview, display_args + ) + except Exception as callback_error: + logging.debug("Tool progress callback error: %s", callback_error) + + if agent.tool_start_callback: + try: + display_args = ( + _redact_tool_args_for_display(function_name, function_args) + or function_args + ) + agent.tool_start_callback( + tool_call_id, function_name, display_args + ) + except Exception as callback_error: + logging.debug("Tool start callback error: %s", callback_error) + + if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled: + try: + file_path = function_args.get("path", "") + if file_path: + work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path) + agent._checkpoint_mgr.ensure_checkpoint( + work_dir, f"before {function_name}" + ) + except Exception: + pass + + if function_name == "terminal" and agent._checkpoint_mgr.enabled: + try: + command = function_args.get("command", "") + if _is_destructive_command(command): + cwd = function_args.get("workdir") or os.getenv( + "TERMINAL_CWD", os.getcwd() + ) + agent._checkpoint_mgr.ensure_checkpoint( + cwd, f"before terminal: {command[:60]}" + ) + except Exception: + pass def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0, *, finalize: bool = True) -> None: @@ -359,7 +577,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe return # ── Parse args + pre-execution bookkeeping ─────────────────────── - parsed_calls = [] # list of (tool_call, function_name, function_args, middleware_trace, block_result, blocked_by_guardrail) + # (tool call, resolved name, parsed args, middleware trace, parse error, + # tool-search scope block) + parsed_calls = [] for tool_call in tool_calls: function_name = tool_call.function.name @@ -375,17 +595,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe function_args, [], malformed_args_result, - False, + None, ) ) continue - # Reset nudge counters only for a structurally valid invocation. - if function_name == "memory": - agent._turns_since_memory = 0 - elif function_name == "skill_manage": - agent._iters_since_skill = 0 - # ── Tool Search unwrap ──────────────────────────────────────── # When the model invokes the tool_call bridge, peel it open so # every downstream check (checkpointing, guardrails, plugin @@ -412,165 +626,57 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe function_name = _underlying function_args = _underlying_args else: - _ts_scope_block = json.dumps({ - "error": ( - f"'{_underlying}' is not available in this session. " - "Use tool_search to find tools you can call." - ), - }, ensure_ascii=False) + _ts_scope_block = ( + f"'{_underlying}' is not available in this session. " + "Use tool_search to find tools you can call." + ) except Exception: pass - function_args, middleware_trace = _apply_tool_request_middleware_for_agent( - agent, - function_name=function_name, - function_args=function_args, - effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", + parsed_calls.append( + (tool_call, function_name, function_args, [], None, _ts_scope_block) ) - # ── Block evaluation (BEFORE checkpoint preflight) ─────────── - # We must know whether the tool will execute before touching - # checkpoint state (dedup slot, real snapshots). - block_result = None - blocked_by_guardrail = False - if _ts_scope_block is not None: - # Out-of-scope tool_call: reject before hooks/guardrails/dispatch. - block_result = _ts_scope_block - _emit_terminal_post_tool_call( - agent, - function_name=function_name, - function_args=function_args, - result=block_result, - effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", - status="blocked", - error_type="tool_scope_block", - error_message=_ts_scope_block, - middleware_trace=list(middleware_trace), - ) - else: - try: - from hermes_cli.plugins import resolve_pre_tool_block - block_message = resolve_pre_tool_block( - function_name, - function_args, - task_id=effective_task_id or "", - session_id=getattr(agent, "session_id", "") or "", - tool_call_id=getattr(tool_call, "id", "") or "", - turn_id=getattr(agent, "_current_turn_id", "") or "", - api_request_id=getattr(agent, "_current_api_request_id", "") or "", - middleware_trace=list(middleware_trace), - ) - except Exception: - block_message = None - - if block_message is not None: - block_result = json.dumps({"error": block_message}, ensure_ascii=False) - _emit_terminal_post_tool_call( - agent, - function_name=function_name, - function_args=function_args, - result=block_result, - effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", - status="blocked", - error_type="plugin_block", - error_message=block_message, - middleware_trace=list(middleware_trace), - ) - else: - guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args) - if not guardrail_decision.allows_execution: - block_result = agent._guardrail_block_result(guardrail_decision) - blocked_by_guardrail = True - _emit_terminal_post_tool_call( - agent, - function_name=function_name, - function_args=function_args, - result=block_result, - effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", - status="blocked", - error_type="guardrail_block", - error_message=getattr(guardrail_decision, "message", None) or "Tool blocked by guardrail policy", - middleware_trace=list(middleware_trace), - ) - - # ── Checkpoint preflight (only for tools that will execute) ── - if block_result is None: - # Checkpoint for file-mutating tools - if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled: - try: - file_path = function_args.get("path", "") - if file_path: - work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path) - agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}") - except Exception: - pass - - # Checkpoint before destructive terminal commands - if function_name == "terminal" and agent._checkpoint_mgr.enabled: - try: - cmd = function_args.get("command", "") - if _is_destructive_command(cmd): - cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd()) - agent._checkpoint_mgr.ensure_checkpoint( - cwd, f"before terminal: {cmd[:60]}" - ) - except Exception: - pass - - parsed_calls.append((tool_call, function_name, function_args, middleware_trace, block_result, blocked_by_guardrail)) - # ── Logging / callbacks ────────────────────────────────────────── tool_names_str = ", ".join(name for _, name, _, _, _, _ in parsed_calls) if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}") - for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1): - display_args = _redact_tool_args_for_display(name, args) or args - args_str = json.dumps(display_args, ensure_ascii=False) - if agent.verbose_logging: - print(f" 📞 Tool {i}: {name}({list(display_args.keys())})") - print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False))) - else: - args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str - print(f" 📞 Tool {i}: {name}({list(args.keys())}) - {args_preview}") - - for tc, name, args, middleware_trace, block_result, blocked_by_guardrail in parsed_calls: - if block_result is not None: - continue - if agent.tool_progress_callback: - try: - display_args = _redact_tool_args_for_display(name, args) or args - preview = _build_tool_preview(name, display_args) - agent.tool_progress_callback("tool.started", name, preview, display_args) - except Exception as cb_err: - logging.debug(f"Tool progress callback error: {cb_err}") - - for tc, name, args, middleware_trace, block_result, blocked_by_guardrail in parsed_calls: - if block_result is not None: - continue - if agent.tool_start_callback: - try: - display_args = _redact_tool_args_for_display(name, args) or args - agent.tool_start_callback(tc.id, name, display_args) - except Exception as cb_err: - logging.debug(f"Tool start callback error: {cb_err}") # ── Concurrent execution ───────────────────────────────────────── # Each slot holds (function_name, function_args, function_result, duration, error_flag, blocked_flag, middleware_trace) results = [None] * num_tools - for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls): + for i, (tc, name, args, middleware_trace, block_result, _scope_block) in enumerate(parsed_calls): if block_result is not None: results[i] = (name, args, block_result, 0.0, True, True, middleware_trace) + start_condition = threading.Condition() + next_start_order = 0 + + def _begin_in_order(order: int, callback=None) -> None: + nonlocal next_start_order + with start_condition: + start_condition.wait_for(lambda: order == next_start_order) + try: + if callback is not None: + callback() + finally: + next_start_order += 1 + start_condition.notify_all() + # Touch activity before launching workers so the gateway knows # we're executing tools (not stuck). agent._current_tool = tool_names_str agent._touch_activity(f"executing {num_tools} tools concurrently: {tool_names_str}") - def _run_tool(index, tool_call, function_name, function_args, middleware_trace): + def _run_tool( + index, + tool_call, + function_name, + function_args, + middleware_trace, + scope_block, + start_order, + ): """Worker function executed in a thread.""" # Register this worker tid so the agent can fan out an interrupt # to it — see AIAgent.interrupt(). Must happen first thing, and @@ -600,18 +706,49 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # ContextVars are propagated by propagate_context_to_thread() at the # submit site below (GHSA-qg5c-hvr5-hjgr, #13617). start = time.time() + blocked = False + start_advanced = False + + def _advance_start(callback=None) -> None: + nonlocal start_advanced + if start_advanced: + return + try: + _begin_in_order(start_order, callback) + finally: + start_advanced = True + try: try: - result = agent._invoke_tool( - function_name, - function_args, - effective_task_id, - tool_call.id, - messages=messages, - pre_tool_block_checked=True, - skip_tool_request_middleware=True, - tool_request_middleware_trace=list(middleware_trace), + def _execute(next_args: dict[str, Any]) -> Any: + return agent._invoke_tool( + function_name, + next_args, + effective_task_id, + tool_call.id, + messages=messages, + pre_tool_block_checked=True, + skip_tool_request_middleware=True, + skip_tool_execution_middleware=True, + tool_request_middleware_trace=list(middleware_trace), + ) + + managed = _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + scope_block=scope_block, + display_index=index + 1, + middleware_trace=middleware_trace, + begin_execution=_advance_start, ) + result = managed.result + function_args = managed.args + middleware_trace = managed.middleware_trace + blocked = managed.blocked except KeyboardInterrupt: try: agent.interrupt("keyboard interrupt") @@ -628,7 +765,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe ) duration = time.time() - start logger.info("tool %s cancelled (%.2fs)", function_name, duration) - results[index] = (function_name, function_args, result, duration, True, False, middleware_trace) + results[index] = ( + function_name, + function_args, + result, + duration, + True, + False, + middleware_trace, + ) return except Exception as tool_error: result = f"Error executing tool '{function_name}': {tool_error}" @@ -639,8 +784,17 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200]) else: logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result)) - results[index] = (function_name, function_args, result, duration, is_error, False, middleware_trace) + results[index] = ( + function_name, + function_args, + result, + duration, + is_error, + blocked, + middleware_trace, + ) finally: + _advance_start() # Tear down worker-tid tracking. Clear any interrupt bit we may # have set so the next task scheduled onto this recycled tid # starts with a clean slate. This MUST be in a finally block @@ -663,9 +817,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe try: runnable_calls = [ - (i, tc, name, args) - for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls) - if block_result is None + (i, tc, name, args, scope_block) + for i, (tc, name, args, _trace, parse_error, scope_block) in enumerate( + parsed_calls + ) + if parse_error is None ] futures = [] future_to_index = {} @@ -683,13 +839,22 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe executor = DaemonThreadPoolExecutor(max_workers=max_workers) abandon_executor = False try: - for submit_index, (i, tc, name, args) in enumerate(runnable_calls): + for submit_index, (i, tc, name, args, scope_block) in enumerate( + runnable_calls + ): # Propagate the agent turn's ContextVars (e.g. # _approval_session_key) AND thread-local approval/sudo # callbacks into the worker thread; clears callbacks on exit. try: f = executor.submit( - propagate_context_to_thread(_run_tool), i, tc, name, args, parsed_calls[i][3] + propagate_context_to_thread(_run_tool), + i, + tc, + name, + args, + parsed_calls[i][3], + scope_block, + submit_index, ) except RuntimeError as submit_error: if not _is_interpreter_shutdown_submit_error(submit_error): @@ -700,7 +865,13 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe "skipping %d unsubmitted tool(s)", len(skipped_calls), ) - for skipped_i, _tc, skipped_name, skipped_args in skipped_calls: + for ( + skipped_i, + _tc, + skipped_name, + skipped_args, + _scope_block, + ) in skipped_calls: if results[skipped_i] is None: middleware_trace = parsed_calls[skipped_i][3] result = ( @@ -827,7 +998,9 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe spinner.stop(f"⚡ {completed}/{num_tools} tools completed in {total_dur:.1f}s total") # ── Post-execution: display per-tool results ───────────────────── - for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls): + for i, (tc, name, args, middleware_trace, _parse_error, _scope_block) in enumerate( + parsed_calls + ): r = results[i] blocked = False # A worker can finish and write results[i] in the window between the @@ -885,6 +1058,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe tool_duration = 0.0 else: function_name, function_args, function_result, tool_duration, is_error, blocked, middleware_trace = r + name = function_name + args = function_args if blocked: effect_disposition = "none" @@ -1098,153 +1273,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe except Exception: pass - function_args, middleware_trace = _apply_tool_request_middleware_for_agent( - agent, - function_name=function_name, - function_args=function_args, - effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", - ) - - # Check plugin hooks for a block directive before executing. - _block_msg: Optional[str] = None - _block_error_type = "plugin_block" - if _ts_scope_block is not None: - _block_msg = _ts_scope_block - _block_error_type = "tool_scope_block" - else: - try: - from hermes_cli.plugins import resolve_pre_tool_block - _block_msg = resolve_pre_tool_block( - function_name, - function_args, - task_id=effective_task_id or "", - session_id=getattr(agent, "session_id", "") or "", - tool_call_id=getattr(tool_call, "id", "") or "", - turn_id=getattr(agent, "_current_turn_id", "") or "", - api_request_id=getattr(agent, "_current_api_request_id", "") or "", - middleware_trace=list(middleware_trace), - ) - except Exception: - pass - - _guardrail_block_decision: ToolGuardrailDecision | None = None - if _block_msg is None: - guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args) - if not guardrail_decision.allows_execution: - _guardrail_block_decision = guardrail_decision - - _execution_blocked = _block_msg is not None or _guardrail_block_decision is not None - - if _execution_blocked: - # Tool blocked by plugin or guardrail policy — skip counters, - # callbacks, checkpointing, activity mutation, and real execution. - pass - # Reset nudge counters when the relevant tool is actually used - elif function_name == "memory": - agent._turns_since_memory = 0 - elif function_name == "skill_manage": - agent._iters_since_skill = 0 - - if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off": - display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - args_str = json.dumps(display_args, ensure_ascii=False) - if agent.verbose_logging: - print(f" 📞 Tool {i}: {function_name}({list(display_args.keys())})") - print(agent._wrap_verbose("Args: ", json.dumps(display_args, indent=2, ensure_ascii=False))) - else: - args_preview = args_str[:agent.log_prefix_chars] + "..." if len(args_str) > agent.log_prefix_chars else args_str - print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())}) - {args_preview}") - - if not _execution_blocked: - agent._current_tool = function_name - agent._touch_activity(f"executing tool: {function_name}") - - # Set activity callback for long-running tool execution (terminal - # commands, etc.) so the gateway's inactivity monitor doesn't kill - # the agent while a command is running. - if not _execution_blocked: - try: - from tools.environments.base import set_activity_callback - set_activity_callback(agent._touch_activity) - except Exception: - pass - - if not _execution_blocked and agent.tool_progress_callback: - try: - display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - preview = _build_tool_preview(function_name, display_args) - agent.tool_progress_callback("tool.started", function_name, preview, display_args) - except Exception as cb_err: - logging.debug(f"Tool progress callback error: {cb_err}") - - if not _execution_blocked and agent.tool_start_callback: - try: - display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - agent.tool_start_callback(tool_call.id, function_name, display_args) - except Exception as cb_err: - logging.debug(f"Tool start callback error: {cb_err}") - - # Checkpoint: snapshot working dir before file-mutating tools - if not _execution_blocked and function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled: - try: - file_path = function_args.get("path", "") - if file_path: - work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path) - agent._checkpoint_mgr.ensure_checkpoint( - work_dir, f"before {function_name}" - ) - except Exception: - pass # never block tool execution - - # Checkpoint before destructive terminal commands - if not _execution_blocked and function_name == "terminal" and agent._checkpoint_mgr.enabled: - try: - cmd = function_args.get("command", "") - if _is_destructive_command(cmd): - cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd()) - agent._checkpoint_mgr.ensure_checkpoint( - cwd, f"before terminal: {cmd[:60]}" - ) - except Exception: - pass # never block tool execution - + middleware_trace: list[dict[str, Any]] = [] + _execution_blocked = False tool_start_time = time.time() - if _block_msg is not None: - # Tool blocked by plugin policy — return error without executing. - function_result = json.dumps({"error": _block_msg}, ensure_ascii=False) - tool_duration = 0.0 - _emit_terminal_post_tool_call( - agent, - function_name=function_name, - function_args=function_args, - result=function_result, - effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", - status="blocked", - error_type=_block_error_type, - error_message=_block_msg, - middleware_trace=list(middleware_trace), - ) - elif _guardrail_block_decision is not None: - # Tool blocked by tool-loop guardrail — synthesize exactly one - # tool result for the original tool_call_id without executing. - function_result = agent._guardrail_block_result(_guardrail_block_decision) - tool_duration = 0.0 - _emit_terminal_post_tool_call( - agent, - function_name=function_name, - function_args=function_args, - result=function_result, - effective_task_id=effective_task_id, - tool_call_id=getattr(tool_call, "id", "") or "", - status="blocked", - error_type="guardrail_block", - error_message=getattr(_guardrail_block_decision, "message", None) or "Tool blocked by guardrail policy", - middleware_trace=list(middleware_trace), - ) - elif function_name == "todo": + if function_name == "todo": def _execute(next_args: dict) -> Any: from tools.todo_tool import todo_tool as _todo_tool return _todo_tool( @@ -1252,14 +1285,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe merge=next_args.get("merge", False), store=agent._todo_store, ) - function_result, function_args = _run_agent_tool_execution_middleware( + function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware( agent, function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", execute=_execute, - ) + scope_block=_ts_scope_block, + display_index=i, + )) tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('todo', function_args, tool_duration, result=function_result)}") @@ -1281,14 +1316,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe db=session_db, current_session_id=agent.session_id, ) - function_result, function_args = _run_agent_tool_execution_middleware( + function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware( agent, function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", execute=_execute, - ) + scope_block=_ts_scope_block, + display_index=i, + )) tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('session_search', function_args, tool_duration, result=function_result)}") @@ -1318,14 +1355,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe ), ) return result - function_result, function_args = _run_agent_tool_execution_middleware( + function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware( agent, function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", execute=_execute, - ) + scope_block=_ts_scope_block, + display_index=i, + )) tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('memory', function_args, tool_duration, result=function_result)}") @@ -1337,14 +1376,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe choices=next_args.get("choices"), callback=agent.clarify_callback, ) - function_result, function_args = _run_agent_tool_execution_middleware( + function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware( agent, function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", execute=_execute, - ) + scope_block=_ts_scope_block, + display_index=i, + )) tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}") @@ -1356,14 +1397,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe count=next_args.get("count"), callback=getattr(agent, "read_terminal_callback", None), ) - function_result, function_args = _run_agent_tool_execution_middleware( + function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware( agent, function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", execute=_execute, - ) + scope_block=_ts_scope_block, + display_index=i, + )) tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('read_terminal', function_args, tool_duration, result=function_result)}") @@ -1388,14 +1431,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe try: def _execute(next_args: dict) -> Any: return agent._dispatch_delegate_task(next_args) - function_result, function_args = _run_agent_tool_execution_middleware( + function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware( agent, function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", execute=_execute, - ) + scope_block=_ts_scope_block, + display_index=i, + )) _delegate_result = function_result finally: agent._delegate_spinner = None @@ -1419,14 +1464,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe try: def _execute(next_args: dict) -> Any: return agent.context_compressor.handle_tool_call(function_name, next_args, messages=messages) - function_result, function_args = _run_agent_tool_execution_middleware( + function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware( agent, function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", execute=_execute, - ) + scope_block=_ts_scope_block, + display_index=i, + )) _ce_result = function_result except Exception as tool_error: function_result = json.dumps({"error": f"Context engine tool '{function_name}' failed: {tool_error}"}) @@ -1453,14 +1500,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe try: def _execute(next_args: dict) -> Any: return agent._memory_manager.handle_tool_call(function_name, next_args) - function_result, function_args = _run_agent_tool_execution_middleware( + function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware( agent, function_name=function_name, function_args=function_args, effective_task_id=effective_task_id, tool_call_id=getattr(tool_call, "id", "") or "", execute=_execute, - ) + scope_block=_ts_scope_block, + display_index=i, + )) _mem_result = function_result except Exception as tool_error: function_result = json.dumps({"error": f"Memory tool '{function_name}' failed: {tool_error}"}) @@ -1483,18 +1532,44 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe spinner.start() _spinner_result = None try: - function_result = _ra().handle_function_call( - function_name, function_args, effective_task_id, - tool_call_id=tool_call.id, - session_id=agent.session_id or "", - turn_id=getattr(agent, "_current_turn_id", "") or "", - api_request_id=getattr(agent, "_current_api_request_id", "") or "", - enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, - skip_pre_tool_call_hook=True, - skip_tool_request_middleware=True, - enabled_toolsets=getattr(agent, "enabled_toolsets", None), - disabled_toolsets=getattr(agent, "disabled_toolsets", None), - tool_request_middleware_trace=list(middleware_trace), + def _execute(next_args: dict) -> Any: + return _ra().handle_function_call( + function_name, + next_args, + effective_task_id, + tool_call_id=tool_call.id, + session_id=agent.session_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") + or "", + enabled_tools=( + list(agent.valid_tool_names) + if agent.valid_tool_names + else None + ), + skip_pre_tool_call_hook=True, + skip_tool_request_middleware=True, + skip_tool_execution_middleware=True, + enabled_toolsets=getattr(agent, "enabled_toolsets", None), + disabled_toolsets=getattr(agent, "disabled_toolsets", None), + ) + + ( + function_result, + function_args, + middleware_trace, + _execution_blocked, + ) = _managed_values( + _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + scope_block=_ts_scope_block, + display_index=i, + ) ) _spinner_result = function_result except KeyboardInterrupt: @@ -1525,18 +1600,44 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe agent._vprint(f" {cute_msg}") else: try: - function_result = _ra().handle_function_call( - function_name, function_args, effective_task_id, - tool_call_id=tool_call.id, - session_id=agent.session_id or "", - turn_id=getattr(agent, "_current_turn_id", "") or "", - api_request_id=getattr(agent, "_current_api_request_id", "") or "", - enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, - skip_pre_tool_call_hook=True, - skip_tool_request_middleware=True, - enabled_toolsets=getattr(agent, "enabled_toolsets", None), - disabled_toolsets=getattr(agent, "disabled_toolsets", None), - tool_request_middleware_trace=list(middleware_trace), + def _execute(next_args: dict) -> Any: + return _ra().handle_function_call( + function_name, + next_args, + effective_task_id, + tool_call_id=tool_call.id, + session_id=agent.session_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") + or "", + enabled_tools=( + list(agent.valid_tool_names) + if agent.valid_tool_names + else None + ), + skip_pre_tool_call_hook=True, + skip_tool_request_middleware=True, + skip_tool_execution_middleware=True, + enabled_toolsets=getattr(agent, "enabled_toolsets", None), + disabled_toolsets=getattr(agent, "disabled_toolsets", None), + ) + + ( + function_result, + function_args, + middleware_trace, + _execution_blocked, + ) = _managed_values( + _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + scope_block=_ts_scope_block, + display_index=i, + ) ) except KeyboardInterrupt: _emit_cancelled_terminal_post_tool_call( diff --git a/agent/turn_context.py b/agent/turn_context.py index 8f703383d61..419c95dab1a 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -367,7 +367,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 diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md index 3943ee5596e..2e997412353 100644 --- a/docs/observability/relay-shared-metrics.md +++ b/docs/observability/relay-shared-metrics.md @@ -4,7 +4,10 @@ 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, but Relay-backed instrumentation is unavailable there. +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. Collection remains off unless Hermes policy enables it: diff --git a/hermes_cli/middleware.py b/hermes_cli/middleware.py index 8045595c35d..f5833aa12e7 100644 --- a/hermes_cli/middleware.py +++ b/hermes_cli/middleware.py @@ -132,8 +132,8 @@ def apply_tool_request_middleware( trace: List[Dict[str, Any]] = [] session_id = str(context.get("session_id") or "") - if session_id: - from hermes_cli.observability import relay_runtime + if session_id and not context.pop("skip_relay", False): + from agent import relay_runtime relay_args = relay_runtime.apply_tool_request_intercepts( session_id=session_id, diff --git a/hermes_cli/observability/__init__.py b/hermes_cli/observability/__init__.py index ce01554d93e..a95e2fe43f7 100644 --- a/hermes_cli/observability/__init__.py +++ b/hermes_cli/observability/__init__.py @@ -10,7 +10,9 @@ logger = logging.getLogger(__name__) def prepare_lifecycle(hook_name: str, **kwargs: Any) -> None: """Prepare subscribers that must observe a session's start event.""" - from . import relay_runtime, relay_shared_metrics + from agent import relay_runtime + + from . import relay_shared_metrics if hook_name in relay_runtime.SESSION_START_HOOKS: try: @@ -21,7 +23,9 @@ def prepare_lifecycle(hook_name: str, **kwargs: Any) -> None: def observe_lifecycle(hook_name: str, **kwargs: Any) -> None: """Dispatch a Hermes lifecycle event to built-in observability features.""" - from . import relay_runtime, relay_shared_metrics + from agent import relay_runtime + + from . import relay_shared_metrics # Session-start plugin callbacks register optional per-session subscribers # before this completion step opens the shared core scope. On teardown, @@ -35,7 +39,9 @@ def observe_lifecycle(hook_name: str, **kwargs: Any) -> None: def handles_hook(hook_name: str) -> bool: """Return whether any built-in observability feature handles a hook.""" - from . import relay_runtime, relay_shared_metrics + from agent import relay_runtime + + from . import relay_shared_metrics return relay_runtime.handles_hook(hook_name) or relay_shared_metrics.handles_hook( hook_name diff --git a/hermes_cli/observability/relay_runtime.py b/hermes_cli/observability/relay_runtime.py index b0d78c976bb..bd6de0b8379 100644 --- a/hermes_cli/observability/relay_runtime.py +++ b/hermes_cli/observability/relay_runtime.py @@ -1,486 +1,14 @@ -"""Profile-scoped NeMo Relay runtimes owned by Hermes core.""" +"""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 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 +import sys -from hermes_constants import get_hermes_home +from agent import relay_runtime as _core_relay_runtime -logger = logging.getLogger(__name__) - -SESSION_SCOPE = "hermes.session" -RUNTIME_SCHEMA_KEY = "hermes.relay.schema_version" -RUNTIME_SCHEMA_VERSION = "hermes.relay.runtime.v1" -RUNTIME_INSTANCE_KEY = "hermes.relay.runtime_instance" - -SESSION_START_HOOKS = frozenset({"on_session_start"}) -SESSION_CLOSE_HOOKS = frozenset({"on_session_finalize", "on_session_reset"}) -SUBAGENT_START_HOOKS = frozenset({"subagent_start"}) -SUBAGENT_STOP_HOOKS = frozenset({"subagent_stop"}) -HANDLED_HOOKS = ( - SESSION_START_HOOKS - | SESSION_CLOSE_HOOKS - | SUBAGENT_START_HOOKS - | SUBAGENT_STOP_HOOKS -) - -_RUNTIME_FAILED = object() -_RUNTIMES: dict[str, RelayRuntime | object] = {} -_RUNTIME_LOCK = threading.RLock() - - -@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._shutdown_registered = True - atexit.register(self.shutdown) - - 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: - 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]) -> None: - """Record the parent used when a delegated Hermes session starts.""" - 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 - self.ensure_session({"session_id": parent_session_id}) - with self._sessions_lock: - self._subagent_parents[child_session_id] = parent_session_id - - 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) - - 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") - - 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 session.context.copy().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") - context = session.context.copy() - - 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.""" - 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: - 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) - 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 - - -def handles_hook(hook_name: str) -> bool: - """Return whether the core Relay host consumes this lifecycle hook.""" - return hook_name in HANDLED_HOOKS - - -def observe_lifecycle(hook_name: str, **kwargs: Any) -> None: - """Apply session lifecycle events to the core Relay host.""" - if not handles_hook(hook_name): - return - # Session hooks do not activate Relay by themselves. A direct core - # producer or an enabled built-in consumer creates the host lazily, after - # which these hooks keep its session lifetime correct. - runtime = get_runtime(create=False) - if runtime is None: - return - try: - if hook_name in SESSION_START_HOOKS: - runtime.ensure_session(kwargs) - elif hook_name in SESSION_CLOSE_HOOKS: - runtime.close_session(kwargs) - elif hook_name in SUBAGENT_START_HOOKS: - runtime.register_subagent(kwargs) - else: - runtime.unregister_subagent(kwargs) - except Exception: - logger.warning("Hermes Relay lifecycle failed: %s", hook_name, exc_info=True) - - -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() - 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() - 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 get_runtime( - *, - create: bool = True, - profile_key: str | None = None, -) -> RelayRuntime | None: - """Return the Relay host for the active Hermes profile.""" - key = profile_key or current_profile_key() - with _RUNTIME_LOCK: - runtime = _RUNTIMES.get(key) - if isinstance(runtime, RelayRuntime): - return runtime - if runtime is _RUNTIME_FAILED or not create: - return None - try: - runtime = RelayRuntime(profile_key=key) - except Exception: - logger.warning("Hermes Relay runtime initialization failed", exc_info=True) - _RUNTIMES[key] = _RUNTIME_FAILED - return None - _RUNTIMES[key] = runtime - return runtime - - -def current_profile_key() -> str: - """Return the canonical profile identity used for runtime isolation.""" - return str(get_hermes_home().expanduser().resolve()) - - -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.""" - with _RUNTIME_LOCK: - runtimes = list(_RUNTIMES.values()) - _RUNTIMES.clear() - for runtime in runtimes: - if isinstance(runtime, RelayRuntime): - runtime.shutdown() +sys.modules[__name__] = _core_relay_runtime diff --git a/hermes_cli/observability/relay_shared_metrics.py b/hermes_cli/observability/relay_shared_metrics.py index ba1b4bb507e..79130f0bd7b 100644 --- a/hermes_cli/observability/relay_shared_metrics.py +++ b/hermes_cli/observability/relay_shared_metrics.py @@ -10,9 +10,9 @@ 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 . import relay_runtime from .shared_metrics import SharedMetricsStore from .shared_metrics_contract import ( MODEL_CALL_SCOPE, @@ -164,13 +164,22 @@ class _Runtime: return None task_context = session.relay_session.context.copy() start_fields = task_start_fields(event) + active_turn = relay_runtime.current_turn() + 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=session.relay_session.handle, + handle=parent_handle, input=start_fields, metadata=self._event_metadata(), ) diff --git a/hermes_cli/observability/shared_metrics_contract.py b/hermes_cli/observability/shared_metrics_contract.py index 41cfc2eaa5c..9eac4182334 100644 --- a/hermes_cli/observability/shared_metrics_contract.py +++ b/hermes_cli/observability/shared_metrics_contract.py @@ -6,7 +6,7 @@ import re from functools import lru_cache from typing import Any -from .relay_runtime import RUNTIME_INSTANCE_KEY +from agent.relay_runtime import RUNTIME_INSTANCE_KEY SCHEMA_KEY = "hermes.metrics.schema_version" SCHEMA_VERSION = "hermes.metrics.event.v1" diff --git a/hermes_cli/observability/shared_metrics_subscriber.py b/hermes_cli/observability/shared_metrics_subscriber.py index bbd0442a520..7acc61dc4cd 100644 --- a/hermes_cli/observability/shared_metrics_subscriber.py +++ b/hermes_cli/observability/shared_metrics_subscriber.py @@ -6,9 +6,10 @@ 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_dimensions, task_counter -from .relay_runtime import RUNTIME_INSTANCE_KEY logger = logging.getLogger(__name__) diff --git a/model_tools.py b/model_tools.py index c59c189e36d..51535f672be 100644 --- a/model_tools.py +++ b/model_tools.py @@ -1034,6 +1034,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, @@ -1138,6 +1139,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, @@ -1276,19 +1278,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: diff --git a/plugins/observability/nemo_relay/__init__.py b/plugins/observability/nemo_relay/__init__.py index f33359d7299..a4f3c0b458e 100644 --- a/plugins/observability/nemo_relay/__init__.py +++ b/plugins/observability/nemo_relay/__init__.py @@ -15,7 +15,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional -from hermes_cli.observability import relay_runtime +from agent import relay_runtime logger = logging.getLogger(__name__) @@ -325,17 +325,23 @@ class _Runtime: filename = self.settings.atif_filename_template.format(session_id=state.session_id) Path(output_dir, filename).write_text(state.atif_exporter.export_json(), encoding="utf-8") - def close_session(self, kwargs: dict[str, Any]) -> None: + def close_session( + self, + kwargs: dict[str, Any], + *, + close_host: bool = True, + ) -> None: session_id = _session_id(kwargs) self.subagent_contexts.pop(session_id, None) state = self.sessions.pop(session_id, None) if state is None: return failures: list[str] = [] - try: - self.host.close_session(kwargs) - except Exception as exc: - failures.append(f"core session close failed: {exc}") + if close_host: + try: + self.host.close_session(kwargs) + except Exception as exc: + failures.append(f"core session close failed: {exc}") try: self.export_atif(state) except Exception as exc: @@ -402,7 +408,6 @@ class _Runtime: ) def mark_subagent_start(self, kwargs: dict[str, Any]) -> None: - self.host.register_subagent(kwargs) parent_state = self.ensure_session(kwargs) metadata = _metadata(kwargs) child_session_id = _child_session_id(kwargs) @@ -421,10 +426,12 @@ class _Runtime: ) def mark_subagent_stop(self, kwargs: dict[str, Any]) -> None: - self.host.unregister_subagent(kwargs) child_session_id = _child_session_id(kwargs) if child_session_id: - self.close_session({"session_id": child_session_id}) + self.close_session( + {"session_id": child_session_id}, + close_host=False, + ) self.subagent_contexts.pop(child_session_id, None) self.mark("hermes.subagent.stop", kwargs) @@ -589,8 +596,6 @@ def register(ctx) -> None: ctx.register_hook("post_approval_response", on_post_approval_response) ctx.register_hook("subagent_start", on_subagent_start) ctx.register_hook("subagent_stop", on_subagent_stop) - ctx.register_middleware("llm_execution", on_llm_execution_middleware) - ctx.register_middleware("tool_execution", on_tool_execution_middleware) def on_session_start(**kwargs: Any) -> None: diff --git a/pyproject.toml b/pyproject.toml index 5031122d16c..b6b0986f4ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -209,6 +209,9 @@ vision = [] # 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.0.1"] # starlette: CVE-2026-48710 +# 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 diff --git a/run_agent.py b/run_agent.py index 8a42d08a6f5..2bd2a9458de 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6242,7 +6242,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( @@ -6255,6 +6256,7 @@ class AIAgent: pre_tool_block_checked, skip_tool_request_middleware, tool_request_middleware_trace, + skip_tool_execution_middleware, ) @staticmethod @@ -6342,6 +6344,7 @@ 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, @@ -6357,6 +6360,26 @@ class AIAgent: "task_id": effective_task_id, "platform": getattr(self, "platform", None) or "", } + relay_turn_id = ( + f"{self.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 = 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, + ) + 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 "", @@ -6381,6 +6404,7 @@ class AIAgent: # Keep the scope local instead of storing ContextVar tokens on the agent, # which may be observed from another thread. with scoped_runtime_main({}): + relay_outcome = "failed" try: result = run_conversation( self, @@ -6394,12 +6418,39 @@ class AIAgent: moa_config=moa_config, ) 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" finish_task_run(**task_context, error=exc) raise else: + 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" finish_task_run(**task_context, result=result) return result finally: + try: + relay_runtime.SESSION_COORDINATOR.end_turn( + relay_turn, + outcome=relay_outcome, + ) + finally: + if relay_lease.parent_session_id: + relay_runtime.SESSION_COORDINATOR.finalize_conversation( + profile_key=relay_lease.profile_key, + session_id=relay_lease.session_id, + ) + relay_runtime.SESSION_COORDINATOR.release_conversation(relay_lease) + if getattr(self, "_relay_pending_turn_id", None) == relay_turn_id: + self._relay_pending_turn_id = None reset_accounting_context(acct_token) reset_conversation_context(token) diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py new file mode 100644 index 00000000000..358f608c2c7 --- /dev/null +++ b/tests/agent/test_relay_llm.py @@ -0,0 +1,225 @@ +"""Tests for the core Relay-managed physical LLM attempt adapter.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from agent import 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_stream_uses_rewritten_request_and_post_intercept_chunks(relay_turn): + relay, turn = relay_turn + captured_requests = [] + + def rewrite_request(name, request, annotated): + del name + content = {**request.content, "temperature": 0.25} + return relay.LLMRequestInterceptOutcome( + relay.LLMRequest(request.headers, content), + annotated, + ) + + def rewrite_stream(request, next_call): + async def generate(): + upstream = await next_call(request) + async for chunk in upstream: + updated = dict(chunk) + choices = [dict(choice) for choice in updated.get("choices", [])] + if choices: + delta = dict(choices[0].get("delta") or {}) + if delta.get("content"): + delta["content"] = delta["content"].upper() + choices[0]["delta"] = delta + updated["choices"] = choices + yield updated + + return generate() + + def raw_stream(request): + captured_requests.append(request) + return iter([ + SimpleNamespace( + model="test-model", + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content="hello", tool_calls=None), + finish_reason=None, + ) + ], + usage=None, + ), + SimpleNamespace( + model="test-model", + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content=None, tool_calls=None), + finish_reason="stop", + ) + ], + usage=None, + ), + ]) + + relay.intercepts.register_llm_request( + "hermes-test-request", + 1, + False, + rewrite_request, + ) + relay.intercepts.register_llm_stream_execution( + "hermes-test-stream", + 1, + rewrite_stream, + ) + try: + stream = relay_llm.stream( + {"model": "test-model", "messages": []}, + raw_stream, + session_id="session-1", + name="test-provider", + model_name="test-model", + finalizer=lambda: { + "model": "test-model", + "choices": [ + { + "message": {"role": "assistant", "content": "HELLO"}, + "finish_reason": "stop", + } + ], + }, + metadata={ + "api_mode": "custom", + "api_request_id": "request-1", + "call_role": "primary", + }, + ) + chunks = list(stream) + finally: + relay.intercepts.deregister_llm_stream_execution("hermes-test-stream") + relay.intercepts.deregister_llm_request("hermes-test-request") + + assert captured_requests[0]["temperature"] == 0.25 + assert chunks[0].choices[0].delta.content == "HELLO" + assert turn.logical_llm_calls == {} + + +def test_stream_preserves_provider_error_and_turn_closes_logical_scope(relay_turn): + _relay, turn = relay_turn + + class ProviderError(Exception): + pass + + provider_error = ProviderError("provider failed") + + def failing_stream(_request): + def generate(): + raise provider_error + yield # pragma: no cover + + return generate() + + stream = relay_llm.stream( + {"model": "test-model", "messages": []}, + failing_stream, + session_id="session-1", + name="test-provider", + model_name="test-model", + finalizer=dict, + metadata={ + "api_mode": "chat_completions", + "api_request_id": "request-2", + }, + ) + + with pytest.raises(ProviderError) as caught: + list(stream) + + assert caught.value is provider_error + assert "request-2" in turn.logical_llm_calls + + +def test_chat_codec_preserves_provider_message_extensions_after_rewrite(relay_turn): + relay, _turn = relay_turn + captured_requests = [] + + def rewrite_request(name, request, annotated): + del name + annotated.params = {**(annotated.params or {}), "temperature": 0.25} + return relay.LLMRequestInterceptOutcome(request, annotated) + + def provider(request): + captured_requests.append(request) + return { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "test-model", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + } + + relay.intercepts.register_llm_request( + "hermes-provider-extension-request", + 1, + False, + rewrite_request, + ) + try: + relay_llm.execute( + { + "model": "test-model", + "messages": [ + { + "role": "assistant", + "content": "", + "reasoning_content": "provider scratchpad", + } + ], + }, + provider, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={ + "api_mode": "chat_completions", + "api_request_id": "request-3", + }, + ) + finally: + relay.intercepts.deregister_llm_request( + "hermes-provider-extension-request" + ) + + assert captured_requests[0]["temperature"] == 0.25 + assert captured_requests[0]["messages"][0]["reasoning_content"] == ( + "provider scratchpad" + ) diff --git a/tests/agent/test_relay_tools.py b/tests/agent/test_relay_tools.py new file mode 100644 index 00000000000..e1d0fffb869 --- /dev/null +++ b/tests/agent/test_relay_tools.py @@ -0,0 +1,85 @@ +"""Tests for the core Relay-managed Hermes tool adapter.""" + +from __future__ import annotations + +import pytest + +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", + ) + try: + yield lease.host.relay + finally: + relay_runtime.SESSION_COORDINATOR.end_turn(turn, outcome="success") + relay_runtime.SESSION_COORDINATOR.release_conversation(lease) + relay_runtime._reset_for_tests() + + +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 result == {"ok": True, "wrapped": True} + + +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 diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index bb87cf7d00d..a261b51ce3b 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -330,6 +330,15 @@ def test_core_runtime_is_fail_open_without_a_published_binding(monkeypatch, capl monkeypatch.setattr(relay_runtime.importlib, "import_module", missing_relay) assert relay_runtime.get_runtime() is None + host = relay_runtime.get_host() + assert isinstance(host, relay_runtime.NoopRelayRuntime) + assert host.profile_key == relay_runtime.current_profile_key() + assert "nemo_relay" in host.reason + assert host.apply_tool_request_intercepts( + session_id="s1", + tool_name="terminal", + args={"command": "true"}, + ) == {"command": "true"} assert not relay_runtime.emit_mark("hermes.probe", session_id="s1") assert "Hermes Relay runtime initialization failed" in caplog.text relay_runtime._reset_for_tests() @@ -353,6 +362,117 @@ def test_core_mark_uses_the_shared_session_handle_without_a_plugin(direct_runtim assert plugins.get_plugin_manager().list_plugins() == [] +def test_core_task_instrumentation_preserves_prompt_history_and_tool_schema( + direct_runtime, + monkeypatch, +): + from run_agent import AIAgent + + agent = object.__new__(AIAgent) + agent.session_id = "cache-stable-session" + agent.platform = "cli" + agent._parent_session_id = None + agent._session_db = None + agent._cached_system_prompt = "byte-stable-system-prompt\nwith exact spacing" + agent.tools = [ + { + "type": "function", + "function": { + "name": "probe", + "parameters": { + "type": "object", + "properties": {"value": {"type": "string"}}, + }, + }, + } + ] + history = [{"role": "user", "content": "sensitive-history-canary"}] + prompt_before = agent._cached_system_prompt.encode("utf-8") + history_before = json.dumps(history, ensure_ascii=False, sort_keys=True) + tools_before = json.dumps(agent.tools, ensure_ascii=False, sort_keys=True) + + def fake_run_conversation( + active_agent, + user_message, + system_message, + conversation_history, + task_id, + stream_callback, + persist_user_message, + **kwargs, + ): + del ( + user_message, + system_message, + task_id, + stream_callback, + persist_user_message, + kwargs, + ) + assert active_agent is agent + assert conversation_history is history + return {"final_response": "ok", "completed": True} + + monkeypatch.setattr( + "agent.conversation_loop.run_conversation", + fake_run_conversation, + ) + + for task_id in ("cache-task-1", "cache-task-2"): + result = AIAgent.run_conversation( + agent, + "hello", + conversation_history=history, + task_id=task_id, + ) + assert result["final_response"] == "ok" + + assert agent._cached_system_prompt.encode("utf-8") == prompt_before + assert json.dumps(history, ensure_ascii=False, sort_keys=True) == history_before + assert json.dumps(agent.tools, ensure_ascii=False, sort_keys=True) == tools_before + + +def test_session_coordinator_separates_turn_release_from_hard_finalize( + direct_runtime, +): + profile_key = relay_runtime.current_profile_key() + coordinator = relay_runtime.SESSION_COORDINATOR + lease = coordinator.acquire_conversation( + profile_key=profile_key, + session_id="coordinated-session", + platform="cli", + ) + turn = coordinator.begin_turn( + lease, + turn_id="turn-1", + task_id="task-1", + ) + + assert relay_runtime.current_turn() is turn + assert turn.handle is not None + session_handle = lease.session.handle + turn_push = next( + event + for event in direct_runtime.events + if event[0] == "scope.push" and event[1] == relay_runtime.TURN_SCOPE + ) + assert turn_push[3]["handle"] == session_handle + + coordinator.end_turn(turn, outcome="success") + coordinator.release_conversation(lease) + + assert relay_runtime.current_turn() is None + runtime = relay_runtime.get_runtime(create=False) + assert runtime is not None + assert runtime.get_session("coordinated-session") is not None + + coordinator.finalize_conversation( + profile_key=profile_key, + session_id="coordinated-session", + ) + assert runtime.get_session("coordinated-session") is None + + def test_core_mark_lazily_starts_relay_without_metrics_or_a_plugin( tmp_path, monkeypatch, @@ -739,49 +859,79 @@ def test_shared_metrics_creates_one_task_under_concurrent_access(direct_runtime) def test_core_runtime_parents_subagent_session_without_exposing_ids( direct_runtime, ): - plugins.invoke_hook("on_session_start", session_id="parent", platform="cli") - parent_handle = relay_runtime.get_session_handle("parent") - - plugins.invoke_hook( - "subagent_start", - parent_session_id="parent", - child_session_id="sensitive-child", - child_subagent_id="sensitive-subagent", - ) - plugins.invoke_hook( - "on_session_start", - session_id="sensitive-child", + coordinator = relay_runtime.SESSION_COORDINATOR + profile_key = relay_runtime.current_profile_key() + parent_lease = coordinator.acquire_conversation( + profile_key=profile_key, + session_id="parent", platform="cli", ) + parent_turn = coordinator.begin_turn( + parent_lease, + turn_id="parent-turn", + task_id="parent-task", + ) + child_lease = coordinator.acquire_conversation( + profile_key=profile_key, + session_id="sensitive-child", + platform="subagent", + parent_session_id="parent", + ) runtime = relay_runtime.get_runtime() assert runtime is not None child = runtime.get_session("sensitive-child") assert child is not None assert child.parent_session_id == "parent" - pushes = [event for event in direct_runtime.events if event[0] == "scope.push"] - assert len(pushes) == 2 - child_kwargs = pushes[1][3] - assert child_kwargs["handle"] == parent_handle + session_pushes = [ + event + for event in direct_runtime.events + if event[0] == "scope.push" and event[1] == relay_runtime.SESSION_SCOPE + ] + assert len(session_pushes) == 2 + child_kwargs = session_pushes[1][3] + assert child_kwargs["handle"] == parent_turn.handle assert child_kwargs["metadata"] == { + "hermes.execution_surface": "subagent", relay_runtime.RUNTIME_SCHEMA_KEY: relay_runtime.RUNTIME_SCHEMA_VERSION, relay_runtime.RUNTIME_INSTANCE_KEY: runtime.runtime_id, "nemo_relay_scope_role": "subagent", } - assert "sensitive-child" not in json.dumps(pushes) - assert "sensitive-subagent" not in json.dumps(pushes) + assert "sensitive-child" not in json.dumps(session_pushes) + + coordinator.finalize_conversation( + profile_key=profile_key, + session_id="sensitive-child", + ) + coordinator.release_conversation(child_lease) + coordinator.end_turn(parent_turn, outcome="success") + coordinator.release_conversation(parent_lease) + coordinator.finalize_conversation( + profile_key=profile_key, + session_id="parent", + ) -def test_core_runtime_closes_child_session_on_subagent_stop(direct_runtime): +def test_subagent_stop_hook_does_not_own_child_session_lifetime(direct_runtime): runtime = relay_runtime.get_runtime() assert runtime is not None - runtime.register_subagent({ - "parent_session_id": "parent", - "child_session_id": "child", - }) - child = runtime.ensure_session({"session_id": "child"}) + child = runtime.register_subagent( + { + "parent_session_id": "parent", + "child_session_id": "child", + } + ) assert child is not None + plugins.invoke_hook( + "subagent_stop", + parent_session_id="parent", + child_session_id="child", + child_status="completed", + ) + + assert runtime.get_session("child") is child + runtime.unregister_subagent({"child_session_id": "child"}) assert runtime.get_session("child") is None @@ -793,6 +943,164 @@ def test_core_runtime_closes_child_session_on_subagent_stop(direct_runtime): assert len(child_closes) == 1 +@pytest.mark.parametrize( + "terminal", + ["return", "exception", "cancelled", "timeout"], +) +def test_subagent_agent_boundary_closes_its_own_scope( + direct_runtime, + monkeypatch, + terminal, +): + from run_agent import AIAgent + + coordinator = relay_runtime.SESSION_COORDINATOR + profile_key = relay_runtime.current_profile_key() + parent_lease = coordinator.acquire_conversation( + profile_key=profile_key, + session_id="parent", + platform="cli", + ) + parent_turn = coordinator.begin_turn( + parent_lease, + turn_id="parent-turn", + task_id="parent-task", + ) + child_agent = SimpleNamespace( + session_id="child", + platform="subagent", + _parent_session_id="parent", + _session_db=None, + _conversation_root_id=lambda: "parent", + ) + + if terminal == "return": + monkeypatch.setattr( + "agent.conversation_loop.run_conversation", + lambda *_args, **_kwargs: { + "final_response": "done", + "completed": True, + "interrupted": False, + }, + ) + AIAgent.run_conversation(child_agent, "private", task_id="child-task") + elif terminal == "exception": + def fail(*_args, **_kwargs): + raise RuntimeError("child failed") + + monkeypatch.setattr("agent.conversation_loop.run_conversation", fail) + with pytest.raises(RuntimeError, match="child failed"): + AIAgent.run_conversation(child_agent, "private", task_id="child-task") + elif terminal == "cancelled": + def cancel(*_args, **_kwargs): + raise KeyboardInterrupt + + monkeypatch.setattr("agent.conversation_loop.run_conversation", cancel) + with pytest.raises(KeyboardInterrupt): + AIAgent.run_conversation(child_agent, "private", task_id="child-task") + else: + def time_out(*_args, **_kwargs): + raise TimeoutError("child timed out") + + monkeypatch.setattr("agent.conversation_loop.run_conversation", time_out) + with pytest.raises(TimeoutError, match="child timed out"): + AIAgent.run_conversation(child_agent, "private", task_id="child-task") + + runtime = relay_runtime.get_runtime(create=False) + assert runtime is not None + assert runtime.get_session("child") is None + child_push = next( + event + for event in direct_runtime.events + if event[0] == "scope.push" + and event[1] == relay_runtime.SESSION_SCOPE + and event[3]["metadata"].get("nemo_relay_scope_role") == "subagent" + ) + assert child_push[3]["handle"] == parent_turn.handle + child_closes = [ + event + for event in direct_runtime.events + if event[0] == "scope.pop" and event[1][1] == relay_runtime.SESSION_SCOPE + ] + assert len(child_closes) == 1 + assert relay_runtime.current_turn() is parent_turn + + coordinator.end_turn(parent_turn, outcome="success") + coordinator.release_conversation(parent_lease) + coordinator.finalize_conversation( + profile_key=profile_key, + session_id="parent", + ) + + +def test_concurrent_subagents_inherit_parent_turn_and_close_independently( + direct_runtime, +): + from concurrent.futures import ThreadPoolExecutor + + coordinator = relay_runtime.SESSION_COORDINATOR + profile_key = relay_runtime.current_profile_key() + parent_lease = coordinator.acquire_conversation( + profile_key=profile_key, + session_id="parent", + platform="cli", + ) + parent_turn = coordinator.begin_turn( + parent_lease, + turn_id="parent-turn", + task_id="parent-task", + ) + + def run_child(child_id): + lease = coordinator.acquire_conversation( + profile_key=profile_key, + session_id=child_id, + platform="subagent", + parent_session_id="parent", + ) + turn = coordinator.begin_turn( + lease, + turn_id=f"{child_id}-turn", + task_id=f"{child_id}-task", + ) + coordinator.end_turn(turn, outcome="success") + coordinator.finalize_conversation( + profile_key=profile_key, + session_id=child_id, + ) + coordinator.release_conversation(lease) + + contexts = [contextvars.copy_context(), contextvars.copy_context()] + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [ + executor.submit(context.run, run_child, f"child-{index}") + for index, context in enumerate(contexts) + ] + for future in futures: + future.result() + + runtime = relay_runtime.get_runtime(create=False) + assert runtime is not None + assert runtime.get_session("child-0") is None + assert runtime.get_session("child-1") is None + child_pushes = [ + event + for event in direct_runtime.events + if event[0] == "scope.push" + and event[1] == relay_runtime.SESSION_SCOPE + and event[3]["metadata"].get("nemo_relay_scope_role") == "subagent" + ] + assert len(child_pushes) == 2 + assert all(event[3]["handle"] == parent_turn.handle for event in child_pushes) + + coordinator.end_turn(parent_turn, outcome="success") + coordinator.release_conversation(parent_lease) + coordinator.finalize_conversation( + profile_key=profile_key, + session_id="parent", + ) + + def test_core_runtime_ignores_self_parenting_subagent_event(direct_runtime): runtime = relay_runtime.get_runtime() assert runtime is not None diff --git a/tests/plugins/test_nemo_relay_plugin.py b/tests/plugins/test_nemo_relay_plugin.py index e8599b54f1a..67dea13b668 100644 --- a/tests/plugins/test_nemo_relay_plugin.py +++ b/tests/plugins/test_nemo_relay_plugin.py @@ -550,7 +550,7 @@ def test_nemo_relay_plugin_metadata_promotes_trajectory_and_subagent_ids(monkeyp assert stop_mark[2]["metadata"]["child_status"] == "completed" -def test_nemo_relay_plugin_reparents_child_session_scope_for_embedded_atif(monkeypatch): +def test_nemo_relay_plugin_reuses_core_parented_child_scope_for_embedded_atif(monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) @@ -563,6 +563,15 @@ def test_nemo_relay_plugin_reparents_child_session_scope_for_embedded_atif(monke child_role="leaf", telemetry_schema_version="hermes.observer.v1", ) + runtime = plugin._get_runtime() + assert runtime is not None + assert runtime.host.get_session("child-session") is None + runtime.host.register_subagent( + { + "parent_session_id": "parent-session", + "child_session_id": "child-session", + } + ) plugin.on_session_start(session_id="child-session") session_pushes = [ @@ -573,16 +582,19 @@ def test_nemo_relay_plugin_reparents_child_session_scope_for_embedded_atif(monke assert len(session_pushes) == 2 child_push = session_pushes[1] child_kwargs = child_push[3] - runtime = plugin._get_runtime() - assert runtime is not None assert child_kwargs["handle"] == runtime.sessions["parent-session"].handle - assert child_kwargs["metadata"]["session_id"] == "child-session" - assert child_kwargs["metadata"]["trajectory_id"] == "child-session" assert child_kwargs["metadata"]["nemo_relay_scope_role"] == "subagent" - assert child_kwargs["metadata"]["subagent_id"] == "child-sa" - assert child_kwargs["metadata"]["parent_session_id"] == "parent-session" + assert "session_id" not in child_kwargs["metadata"] + assert "subagent_id" not in child_kwargs["metadata"] assert runtime.sessions["child-session"].parent_session_id == "parent-session" + runtime.host.unregister_subagent({"child_session_id": "child-session"}) + assert runtime.host.get_session("child-session") is None + child_close_index = max( + index + for index, event in enumerate(fake.events) + if event[0] == "scope.pop" and event[1] == runtime.sessions["child-session"].handle + ) plugin.on_subagent_stop( parent_session_id="parent-session", child_session_id="child-session", @@ -591,6 +603,12 @@ def test_nemo_relay_plugin_reparents_child_session_scope_for_embedded_atif(monke assert "child-session" not in runtime.sessions assert runtime.host.get_session("child-session") is None + stop_mark_index = next( + index + for index, event in enumerate(fake.events) + if event[0] == "scope.event" and event[1] == "hermes.subagent.stop" + ) + assert child_close_index < stop_mark_index def test_nemo_relay_plugin_skips_embedded_child_atif_file_by_default(tmp_path, monkeypatch): @@ -1000,7 +1018,9 @@ def test_managed_tool_refuses_post_authorization_argument_rewrite( assert not dispatched -def test_nemo_relay_plugin_activates_before_registering_managed_middleware(tmp_path, monkeypatch): +def test_nemo_relay_plugin_activates_without_registering_managed_middleware( + tmp_path, monkeypatch +): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) _enable_dynamic_plugin(tmp_path, monkeypatch) @@ -1016,9 +1036,8 @@ def test_nemo_relay_plugin_activates_before_registering_managed_middleware(tmp_p plugin.register(_Context()) event_names = [event[0] for event in fake.events] - assert event_names.index("plugin.activate_dynamic") < event_names.index( - "hermes.register_middleware" - ) + assert "plugin.activate_dynamic" in event_names + assert "hermes.register_middleware" not in event_names runtime = plugin._get_runtime() assert runtime is not None runtime.shutdown() diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 539dbdd20a4..9c3077a6b4f 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3612,25 +3612,11 @@ class TestAgentRuntimePostHookOwnershipSync: AGENT_RUNTIME_POST_HOOK_TOOL_NAMES has to match exactly the static `function_name == "..."` branches in the inline dispatch chain. - 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. + The chain is the if/elif tower anchored on the first agent-runtime tool, + ``function_name == "todo"``. Pre-dispatch tool-name checks live outside + that tower 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.""" @@ -3647,15 +3633,14 @@ class TestAgentRuntimePostHookOwnershipSync: @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.""" + """Return literals from the if/elif chain anchored on ``todo``.""" 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): + if cls._function_name_literal(node.test) != "todo": continue current = node while current is not None: @@ -3691,10 +3676,8 @@ class TestAgentRuntimePostHookOwnershipSync: tool_executor.execute_tool_calls_sequential ) 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." + "Could not find the agent-runtime dispatch chain anchored on " + "`function_name == 'todo'` in execute_tool_calls_sequential." ) assert inline_names == set(AGENT_RUNTIME_POST_HOOK_TOOL_NAMES), ( "Inline dispatch chain in " diff --git a/tests/run_agent/test_tool_call_guardrail_runtime.py b/tests/run_agent/test_tool_call_guardrail_runtime.py index 36ced43c795..3bc8a83ab59 100644 --- a/tests/run_agent/test_tool_call_guardrail_runtime.py +++ b/tests/run_agent/test_tool_call_guardrail_runtime.py @@ -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"} diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index a29bb19d83f..747a2d98635 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -263,7 +263,7 @@ def test_nemo_relay_is_a_pinned_core_dependency(): assert not requirement.marker.evaluate( {"sys_platform": "darwin", "platform_machine": "x86_64"} ) - assert "nemo-relay" not in metadata["optional-dependencies"] + assert metadata["optional-dependencies"]["nemo-relay"] == [] def test_dashboard_plugin_manifests_and_assets_are_packaged(): diff --git a/tests/tools/test_zombie_process_cleanup.py b/tests/tools/test_zombie_process_cleanup.py index 6e0d41148d0..6207c159da6 100644 --- a/tests/tools/test_zombie_process_cleanup.py +++ b/tests/tools/test_zombie_process_cleanup.py @@ -342,6 +342,7 @@ class TestDelegationCleanup: raise RuntimeError("test abort") child.run_conversation.side_effect = run_conversation + child._relay_pending_turn_id = None relay_host = MagicMock() monkeypatch.setattr(relay_runtime, "get_runtime", lambda **kwargs: relay_host) @@ -361,6 +362,36 @@ class TestDelegationCleanup: child.close.assert_called_once() assert observed["hermes_home"] == profile_home - relay_host.close_session.assert_called_once_with({"session_id": "child-session"}) + 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._relay_pending_turn_id = "active-child-turn" + 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) + + result = _run_single_child( + task_index=0, + goal="test active turn cleanup", + child=child, + parent_agent=parent, + ) + + assert result["status"] == "error" + relay_host.unregister_subagent.assert_not_called() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 94eab58fb60..9a8d842a705 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2404,15 +2404,18 @@ def _run_single_child( except Exception: logger.debug("Failed to close child agent after delegation") - # The child owns its Relay scope lifetime. Close it here, on the worker - # that ran the child, before the parent emits its terminal report. + # 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 hermes_cli.observability import relay_runtime + from agent import relay_runtime runtime = relay_runtime.get_runtime(create=False) child_session_id = str(getattr(child, "session_id", "") or "") - if runtime is not None and child_session_id: - runtime.close_session({"session_id": child_session_id}) + pending_turn = getattr(child, "_relay_pending_turn_id", None) + child_turn_is_active = isinstance(pending_turn, str) and bool(pending_turn) + 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") diff --git a/uv.lock b/uv.lock index b993bf9d926..0f026722f0b 100644 --- a/uv.lock +++ b/uv.lock @@ -1851,7 +1851,7 @@ requires-dist = [ { name = "websockets", specifier = "==15.0.1" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] [[package]] name = "hf-xet" From 4dedaa4237371778f024a98468527608cc237ef8 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 08:59:06 -0400 Subject: [PATCH 005/526] refactor(runtime): consolidate Relay lifecycle ownership Signed-off-by: Alex Fournier --- agent/auxiliary_client.py | 324 ++++- agent/chat_completion_helpers.py | 50 +- agent/conversation_loop.py | 9 +- agent/relay_llm.py | 163 ++- agent/relay_runtime.py | 95 +- agent/relay_tools.py | 19 +- agent/turn_context.py | 2 +- agent/turn_finalizer.py | 6 +- cli.py | 31 +- gateway/run.py | 12 +- gateway/slash_commands.py | 7 +- hermes_cli/kanban_db.py | 2 +- hermes_cli/lifecycle.py | 63 + hermes_cli/observability/__init__.py | 28 +- .../observability/relay_shared_metrics.py | 30 +- hermes_cli/plugins.py | 33 +- model_tools.py | 4 +- plugins/observability/nemo_relay/README.md | 64 +- plugins/observability/nemo_relay/__init__.py | 601 ++-------- plugins/observability/nemo_relay/plugin.yaml | 5 - run_agent.py | 12 +- tests/agent/test_auxiliary_relay.py | 186 +++ tests/agent/test_relay_llm.py | 171 ++- tests/agent/test_relay_tools.py | 55 + tests/cli/test_session_boundary_hooks.py | 19 +- tests/hermes_cli/test_lifecycle.py | 60 + .../test_relay_shared_metrics_runtime.py | 199 +++- tests/plugins/test_nemo_relay_plugin.py | 1056 ++--------------- tests/run_agent/test_run_agent.py | 61 +- tools/approval.py | 2 +- tools/delegate_tool.py | 4 +- tools/terminal_tool.py | 2 +- tui_gateway/server.py | 20 +- 33 files changed, 1648 insertions(+), 1747 deletions(-) create mode 100644 hermes_cli/lifecycle.py create mode 100644 tests/agent/test_auxiliary_relay.py create mode 100644 tests/hermes_cli/test_lifecycle.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index da49a695180..fe6536d34c2 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -42,6 +42,7 @@ Payment / credit exhaustion fallback: import contextlib import contextvars +import functools import hashlib import inspect import json @@ -50,6 +51,7 @@ 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 @@ -2335,6 +2337,155 @@ _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) + 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) + 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, +) -> 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.execute_current( + kwargs, + lambda request: client.chat.completions.create(**request), + name=provider_name, + model_name=str(kwargs.get("model") or fallback_model), + metadata=metadata, + ) + + +async def _relay_async_completion( + 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 await client.chat.completions.create(**kwargs) + provider_name, fallback_model, metadata = route + from agent import relay_llm + + return await relay_llm.execute_current_async( + kwargs, + lambda request: client.chat.completions.create(**request), + name=provider_name, + model_name=str(kwargs.get("model") or fallback_model), + metadata=metadata, + ) + + +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() @@ -3627,7 +3778,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, ) @@ -3686,7 +3843,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, ) @@ -3873,7 +4036,7 @@ def _call_fallback_candidate_sync( base_url=fb_base) 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 @@ -3890,7 +4053,13 @@ def _call_fallback_candidate_sync( base_url=str(getattr(retry_client, "base_url", "") or fb_base)) 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 @@ -3939,7 +4108,13 @@ async def _call_fallback_candidate_async( base_url=fb_base) 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 @@ -3957,7 +4132,13 @@ async def _call_fallback_candidate_async( base_url=str(getattr(retry_client, "base_url", "") or fb_base)) 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 @@ -6906,6 +7087,7 @@ def _obj_get(obj: Any, key: str, default: Any = None) -> Any: return value +@_relay_auxiliary_call def call_llm( task: str = None, *, @@ -7042,6 +7224,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 "") @@ -7077,7 +7264,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. @@ -7100,7 +7292,12 @@ def call_llm( # for the transient retry every auxiliary task shares. (PR #16587) 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, provider=resolved_provider, base_url=_base_info) except Exception as transient_err: if not _is_transient_transport_error(transient_err): @@ -7133,7 +7330,12 @@ def call_llm( time.sleep(_backoff) 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_transient: if not _is_transient_transport_error(retry_transient): raise @@ -7150,7 +7352,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 / @@ -7188,7 +7395,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. @@ -7218,7 +7430,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 @@ -7251,7 +7468,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) @@ -7279,7 +7501,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( @@ -7328,7 +7555,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 @@ -7573,6 +7805,7 @@ def extract_content_or_reasoning(response) -> str: return "" +@_relay_auxiliary_call_async async def async_call_llm( task: str = None, *, @@ -7664,6 +7897,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 @@ -7686,7 +7924,12 @@ async def async_call_llm( # for the rationale. (PR #16587) 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, provider=resolved_provider, base_url=_client_base) except Exception as transient_err: if not _is_transient_transport_error(transient_err): @@ -7707,7 +7950,12 @@ async def async_call_llm( task or "call", transient_err, ) 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 first_err: if "temperature" in kwargs and _is_unsupported_temperature_error(first_err): retry_kwargs = dict(kwargs) @@ -7718,7 +7966,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 ( @@ -7752,7 +8005,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. @@ -7781,7 +8039,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 @@ -7813,7 +8076,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) @@ -7840,7 +8108,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( @@ -7884,7 +8157,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 diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 2ec624179a1..da1ae4c70ea 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1908,6 +1908,26 @@ 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()}" + + 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, + }, + ) + 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, " @@ -2087,11 +2107,22 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, is_oauth=agent._is_anthropic_oauth, preserve_dots=agent._anthropic_preserve_dots()) - 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() @@ -2117,7 +2148,11 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: is_oauth=agent._is_anthropic_oauth, max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, preserve_dots=agent._anthropic_preserve_dots()) - 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: @@ -2134,7 +2169,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() diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 039c3ba73a4..c88e63f72b2 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -385,7 +385,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, @@ -1320,7 +1320,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, ) @@ -4486,7 +4486,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, ) @@ -5582,7 +5582,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. diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 98d0f83baea..02bde00d4d1 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -71,8 +71,9 @@ def execute( ) ) except BaseException as exc: - if callback_error is not None and _relay_wrapped_callback_error( - exc, callback_error + if ( + callback_error is not None + and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error) ): raise callback_error raise @@ -84,6 +85,139 @@ def execute( return managed +async def execute_async( + request: dict[str, Any], + callback: Callable[[dict[str, Any]], Any], + *, + session_id: str, + name: str, + model_name: str, + metadata: dict[str, Any] | None = None, +) -> Any: + """Run one asynchronous physical provider attempt through Relay.""" + runtime, session, parent = _execution_context(session_id) + if runtime is None or session is None: + return await callback(request) + logical = _logical_parent(runtime, session, parent, metadata) + parent = logical[1] if logical is not None else parent + + relay_request_body = _relay_request_body(request, metadata) + relay_request = runtime.relay.LLMRequest({}, relay_request_body) + raw_response: dict[str, Any] = {} + callback_error: BaseException | None = None + + async def invoke(next_request: Any) -> Any: + nonlocal callback_error + try: + final_request = _provider_request( + request, + next_request, + relay_request_body=relay_request_body, + metadata=metadata, + ) + raw = await callback(final_request) + except BaseException as exc: + callback_error = exc + raise + raw_response["value"] = raw + raw_response["json"] = _jsonable(raw) + return raw_response["json"] + + try: + managed = await runtime.run_in_session_async( + session, + runtime.relay.llm.execute, + name, + relay_request, + invoke, + handle=parent, + metadata=_jsonable(metadata or {}), + model_name=model_name, + codec=_codec(runtime.relay, metadata), + response_codec=_codec(runtime.relay, metadata), + ) + except BaseException as exc: + if ( + callback_error is not None + and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error) + ): + raise callback_error + raise + + _complete_logical(logical, outcome="success") + if "value" in raw_response and _json_equal(managed, raw_response["json"]): + return raw_response["value"] + return managed + + +def execute_current( + request: dict[str, Any], + callback: Callable[[dict[str, Any]], Any], + *, + name: str, + model_name: str, + metadata: dict[str, Any] | None = None, +) -> Any: + """Run a provider attempt under the inherited Hermes turn when present.""" + turn = relay_runtime.current_turn() + if turn is None: + return callback(request) + return execute( + request, + callback, + session_id=turn.lease.session_id, + name=name, + model_name=model_name, + metadata=metadata, + ) + + +async def execute_current_async( + request: dict[str, Any], + callback: Callable[[dict[str, Any]], Any], + *, + name: str, + model_name: str, + metadata: dict[str, Any] | None = None, +) -> Any: + """Run an async provider attempt under the inherited turn when present.""" + turn = relay_runtime.current_turn() + if turn is None: + return await callback(request) + return await execute_async( + request, + callback, + session_id=turn.lease.session_id, + name=name, + model_name=model_name, + metadata=metadata, + ) + + +def stream_current( + request: dict[str, Any], + stream_factory: Callable[[dict[str, Any]], Any], + *, + name: str, + model_name: str, + finalizer: Callable[[], Any], + metadata: dict[str, Any] | None = None, +) -> Any: + """Run a provider stream under the inherited Hermes turn when present.""" + turn = relay_runtime.current_turn() + if turn is None: + return stream_factory(request) + return stream( + request, + stream_factory, + session_id=turn.lease.session_id, + name=name, + model_name=model_name, + finalizer=finalizer, + metadata=metadata, + ) + + def stream( request: dict[str, Any], stream_factory: Callable[[dict[str, Any]], Any], @@ -262,8 +396,9 @@ class ManagedLlmStream(Iterator[Any]): except BaseException as exc: callback_error = self._callback_error self.close() - if callback_error is not None and _relay_wrapped_callback_error( - exc, callback_error + if ( + callback_error is not None + and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error) ): raise callback_error raise @@ -500,6 +635,12 @@ def _provider_request( for key, value in original.items(): if key not in relay_request_body and value is None: final.setdefault(key, value) + elif ( + key in relay_request_body + and key in final + and _json_equal(final[key], relay_request_body[key]) + ): + final[key] = value _restore_provider_message_extensions(original, final) headers = getattr(request, "headers", None) if isinstance(headers, dict) and headers: @@ -657,20 +798,6 @@ def _json_equal(left: Any, right: Any) -> bool: return False -def _relay_wrapped_callback_error( - exc: BaseException, - callback_error: BaseException, -) -> bool: - if exc is callback_error: - return True - expected_suffix = f"{callback_error.__class__.__name__}: {callback_error}" - return ( - isinstance(exc, RuntimeError) - and str(exc).startswith("internal error: ") - and str(exc).endswith(expected_suffix) - ) - - def _run_awaitable(value: Any) -> Any: if not inspect.isawaitable(value): return value diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index c7e78403403..d9a3d11e738 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -24,10 +24,6 @@ RUNTIME_SCHEMA_KEY = "hermes.relay.schema_version" RUNTIME_SCHEMA_VERSION = "hermes.relay.runtime.v1" RUNTIME_INSTANCE_KEY = "hermes.relay.runtime_instance" -SESSION_START_HOOKS = frozenset({"on_session_start"}) -SESSION_CLOSE_HOOKS = frozenset({"on_session_finalize", "on_session_reset"}) -HANDLED_HOOKS = SESSION_START_HOOKS | SESSION_CLOSE_HOOKS - @dataclass class RelaySession: @@ -453,6 +449,42 @@ class RelaySessionCoordinator: 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], + ] = {} + + 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, @@ -461,6 +493,7 @@ class RelaySessionCoordinator: session_id: str, platform: str, parent_session_id: str = "", + model: str = "", ) -> ConversationLease: host = self.registry.for_profile(profile_key) if host is None: @@ -468,6 +501,14 @@ class RelaySessionCoordinator: 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( @@ -609,30 +650,6 @@ def current_turn() -> RelayTurnContext | None: return _CURRENT_TURN.get() -def handles_hook(hook_name: str) -> bool: - """Return whether the core Relay host consumes this lifecycle hook.""" - return hook_name in HANDLED_HOOKS - - -def observe_lifecycle(hook_name: str, **kwargs: Any) -> None: - """Apply session lifecycle events to the core Relay host.""" - if not handles_hook(hook_name): - return - # Session hooks do not activate Relay by themselves. A direct core - # producer or an enabled built-in consumer creates the host lazily, after - # which these hooks keep its session lifetime correct. - runtime = get_runtime(create=False) - if runtime is None: - return - try: - if hook_name in SESSION_START_HOOKS: - runtime.ensure_session(kwargs) - elif hook_name in SESSION_CLOSE_HOOKS: - runtime.close_session(kwargs) - except Exception: - logger.warning("Hermes Relay lifecycle failed: %s", hook_name, exc_info=True) - - def emit_mark( name: str, *, @@ -729,6 +746,28 @@ def get_session_handle(session_id: str) -> Any: 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, diff --git a/agent/relay_tools.py b/agent/relay_tools.py index 16867cd464e..4ce4eb3c95e 100644 --- a/agent/relay_tools.py +++ b/agent/relay_tools.py @@ -53,8 +53,9 @@ def execute( ) ) except BaseException as exc: - if callback_error is not None and _relay_wrapped_callback_error( - exc, callback_error + if ( + callback_error is not None + and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error) ): raise callback_error raise @@ -107,20 +108,6 @@ def _json_equal(left: Any, right: Any) -> bool: return left == right -def _relay_wrapped_callback_error( - relay_error: BaseException, callback_error: BaseException -) -> bool: - message = str(relay_error) - callback_type = type(callback_error) - type_names = { - callback_type.__name__, - f"{callback_type.__module__}.{callback_type.__qualname__}", - } - return "internal error" in message.lower() and any( - type_name in message for type_name in type_names - ) - - def _run_awaitable(value: Any) -> Any: if not inspect.isawaitable(value): return value diff --git a/agent/turn_context.py b/agent/turn_context.py index 419c95dab1a..9538dcc4955 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -697,7 +697,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, diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index d4293bebd52..de9f404707b 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -411,7 +411,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, @@ -433,7 +433,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, @@ -564,7 +564,7 @@ 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, diff --git a/cli.py b/cli.py index f2c9773aef7..16f34b30cc9 100644 --- a/cli.py +++ b/cli.py @@ -1210,9 +1210,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, @@ -1240,7 +1239,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, @@ -6965,13 +6964,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 @@ -15257,7 +15264,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, diff --git a/gateway/run.py b/gateway/run.py index b071e4854f3..06e20444a55 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6362,9 +6362,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", @@ -8347,11 +8346,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", @@ -9945,7 +9943,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, diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 03c3017c8ba..f4c89afd0cf 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -210,9 +210,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", @@ -289,7 +288,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", diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index de332f36ee4..55ec683f518 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -152,7 +152,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() diff --git a/hermes_cli/lifecycle.py b/hermes_cli/lifecycle.py new file mode 100644 index 00000000000..350e3880aff --- /dev/null +++ b/hermes_cli/lifecycle.py @@ -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) diff --git a/hermes_cli/observability/__init__.py b/hermes_cli/observability/__init__.py index a95e2fe43f7..1c4e70300c6 100644 --- a/hermes_cli/observability/__init__.py +++ b/hermes_cli/observability/__init__.py @@ -8,44 +8,18 @@ from typing import Any logger = logging.getLogger(__name__) -def prepare_lifecycle(hook_name: str, **kwargs: Any) -> None: - """Prepare subscribers that must observe a session's start event.""" - from agent import relay_runtime - - from . import relay_shared_metrics - - if hook_name in relay_runtime.SESSION_START_HOOKS: - try: - relay_shared_metrics.prepare_session_start() - except Exception: - logger.warning("Built-in observability preparation failed", exc_info=True) - - def observe_lifecycle(hook_name: str, **kwargs: Any) -> None: """Dispatch a Hermes lifecycle event to built-in observability features.""" - from agent import relay_runtime - from . import relay_shared_metrics - # Session-start plugin callbacks register optional per-session subscribers - # before this completion step opens the shared core scope. On teardown, - # metrics finish child LLM scopes before the neutral host closes the owner. - if hook_name not in relay_runtime.SESSION_CLOSE_HOOKS: - _safe_observe(relay_runtime.observe_lifecycle, hook_name, kwargs) _safe_observe(relay_shared_metrics.observe_lifecycle, hook_name, kwargs) - if hook_name in relay_runtime.SESSION_CLOSE_HOOKS: - _safe_observe(relay_runtime.observe_lifecycle, hook_name, kwargs) def handles_hook(hook_name: str) -> bool: """Return whether any built-in observability feature handles a hook.""" - from agent import relay_runtime - from . import relay_shared_metrics - return relay_runtime.handles_hook(hook_name) or relay_shared_metrics.handles_hook( - hook_name - ) + return relay_shared_metrics.handles_hook(hook_name) def _safe_observe(callback: Any, hook_name: str, kwargs: dict[str, Any]) -> None: diff --git a/hermes_cli/observability/relay_shared_metrics.py b/hermes_cli/observability/relay_shared_metrics.py index 79130f0bd7b..978fe080759 100644 --- a/hermes_cli/observability/relay_shared_metrics.py +++ b/hermes_cli/observability/relay_shared_metrics.py @@ -650,6 +650,17 @@ def prepare_session_start() -> None: _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, @@ -726,18 +737,25 @@ def finish_task_run( ) -def _get_runtime(*, retry_failed: bool = False) -> _Runtime | None: +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): - return 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() + runtime = _Runtime(host=host) except Exception: logger.warning("Hermes shared metrics initialization failed", exc_info=True) _RUNTIMES[profile_key] = _RUNTIME_FAILED @@ -746,6 +764,12 @@ def _get_runtime(*, retry_failed: bool = False) -> _Runtime | None: 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: diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 9b1d2488081..a41f6c092be 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -2047,32 +2047,10 @@ def discover_plugins(force: bool = False) -> None: def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]: - """Invoke a lifecycle hook on built-in observers and loaded plugins. + """Invoke a lifecycle hook on loaded plugins. Returns a list of non-``None`` return values from plugin callbacks. """ - if hook_name == "on_session_start": - try: - from hermes_cli.observability import prepare_lifecycle - - prepare_lifecycle(hook_name, **kwargs) - except Exception: - logger.warning("Built-in observability preparation failed", exc_info=True) - results = get_plugin_manager().invoke_hook(hook_name, **kwargs) - 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) - return results - - 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) return get_plugin_manager().invoke_hook(hook_name, **kwargs) @@ -2094,14 +2072,7 @@ def has_middleware(kind: str) -> bool: def has_hook(hook_name: str) -> bool: - """Return True when a built-in observer or plugin handles 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 shared-metrics hooks", exc_info=True) + """Return True when a loaded plugin handles a hook.""" return get_plugin_manager().has_hook(hook_name) diff --git a/model_tools.py b/model_tools.py index 51535f672be..eba7e3b5215 100644 --- a/model_tools.py +++ b/model_tools.py @@ -997,7 +997,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: @@ -1324,7 +1324,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( diff --git a/plugins/observability/nemo_relay/README.md b/plugins/observability/nemo_relay/README.md index 52d31bb7890..3f222e0383c 100644 --- a/plugins/observability/nemo_relay/README.md +++ b/plugins/observability/nemo_relay/README.md @@ -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, @@ -167,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]] @@ -179,13 +181,10 @@ 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 @@ -437,8 +436,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. @@ -448,11 +447,11 @@ 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 always hands LLM and tool calls to NeMo Relay managed execution. +The `observability/nemo_relay` plugin can install adaptive components on those +boundaries. Minimal `plugins.toml`: @@ -473,26 +472,21 @@ 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 diff --git a/plugins/observability/nemo_relay/__init__.py b/plugins/observability/nemo_relay/__init__.py index a4f3c0b458e..c173aa1a35a 100644 --- a/plugins/observability/nemo_relay/__init__.py +++ b/plugins/observability/nemo_relay/__init__.py @@ -22,11 +22,7 @@ logger = logging.getLogger(__name__) _INIT_FAILED = object() _LOCK = threading.RLock() _RUNTIMES: dict[str, "_Runtime | object"] = {} -_RELAY_LLM_SURFACE_BY_API_MODE = { - "anthropic_messages": "anthropic.messages", - "chat_completions": "openai.chat_completions", - "codex_responses": "openai.responses", -} +_SESSION_INITIALIZER_NAME = "hermes.nemo_relay.rich_observability" @dataclass @@ -38,8 +34,6 @@ class _SessionState: atif_subscriber_name: str = "" is_embedded_subagent: bool = False parent_session_id: str = "" - llm_spans: dict[str, Any] = field(default_factory=dict) - tool_spans: dict[str, Any] = field(default_factory=dict) @dataclass @@ -53,8 +47,6 @@ class _Settings: plugins_toml_path: str = "" plugins_config: dict[str, Any] | None = None dynamic_plugins: list[dict[str, Any]] = field(default_factory=list) - adaptive_enabled: bool = False - adaptive_mode: str = "observe_only" atof_enabled: bool = False atof_output_directory: str = "" atof_filename: str = "hermes-atof.jsonl" @@ -78,6 +70,7 @@ class _Runtime: self.nemo_relay = nemo_relay self.settings = settings self.host = host + self._sessions_lock = threading.RLock() self.sessions: dict[str, _SessionState] = {} self.subagent_contexts: dict[str, _SubagentContext] = {} self.atof_exporter: Any = None @@ -241,34 +234,47 @@ class _Runtime: logger.debug("NeMo Relay ATOF deregister failed", exc_info=True) self.atof_exporter = None - def ensure_session(self, kwargs: dict[str, Any]) -> _SessionState: - self._maybe_reinitialize_plugins_toml() + def prepare_session(self, kwargs: dict[str, Any]) -> _SessionState: + """Register per-session subscribers without opening the core scope.""" session_id = _session_id(kwargs) - state = self.sessions.get(session_id) - if state is not None: + with self._sessions_lock: + self._maybe_reinitialize_plugins_toml() + state = self.sessions.get(session_id) + if state is not None: + return state + + state = _SessionState(session_id=session_id) + if self.settings.atif_enabled and not self._plugins_toml_owns_exporter("atif"): + state.atif_exporter = self.nemo_relay.AtifExporter( + session_id, + self.settings.atif_agent_name, + self.settings.atif_agent_version, + model_name=str(kwargs.get("model") or self.settings.atif_model_name), + extra={ + "source": "hermes-agent", + "plugin": "observability/nemo_relay", + }, + ) + state.atif_subscriber_name = ( + f"hermes.nemo_relay.atif.{self.host.runtime_id}.{session_id}" + ) + state.atif_exporter.register(state.atif_subscriber_name) + self.sessions[session_id] = state return state - state = _SessionState(session_id=session_id) - if self.settings.atif_enabled and not self._plugins_toml_owns_exporter("atif"): - state.atif_exporter = self.nemo_relay.AtifExporter( - session_id, - self.settings.atif_agent_name, - self.settings.atif_agent_version, - model_name=str(kwargs.get("model") or self.settings.atif_model_name), - extra={"source": "hermes-agent", "plugin": "observability/nemo_relay"}, - ) - state.atif_subscriber_name = ( - f"hermes.nemo_relay.atif.{self.host.runtime_id}.{session_id}" - ) - state.atif_exporter.register(state.atif_subscriber_name) + def ensure_session(self, kwargs: dict[str, Any]) -> _SessionState: + state = self.prepare_session(kwargs) + if state.relay_session is not None: + return state rich_metadata = _metadata(kwargs) - subagent_context = self.subagent_contexts.get(session_id) + with self._sessions_lock: + subagent_context = self.subagent_contexts.get(state.session_id) if subagent_context is not None: rich_metadata = {**rich_metadata, **subagent_context.metadata} relay_session = self.host.ensure_session( kwargs, - data={"session_id": session_id}, + data={"session_id": state.session_id}, metadata=rich_metadata, ) if relay_session is None: @@ -278,7 +284,6 @@ class _Runtime: if subagent_context is not None: state.is_embedded_subagent = True state.parent_session_id = subagent_context.parent_session_id - self.sessions[session_id] = state return state def run_in_session( @@ -297,22 +302,6 @@ class _Runtime: **kwargs, ) - async def run_in_session_async( - self, - state: _SessionState, - callback: Callable[..., Any], - *args: Any, - **kwargs: Any, - ) -> Any: - if state.relay_session is None: - raise RuntimeError("Hermes core Relay session is unavailable") - return await self.host.run_in_session_async( - state.relay_session, - callback, - *args, - **kwargs, - ) - def export_atif(self, state: _SessionState) -> None: if not self.settings.atif_enabled or state.atif_exporter is None: return @@ -332,8 +321,9 @@ class _Runtime: close_host: bool = True, ) -> None: session_id = _session_id(kwargs) - self.subagent_contexts.pop(session_id, None) - state = self.sessions.pop(session_id, None) + with self._sessions_lock: + self.subagent_contexts.pop(session_id, None) + state = self.sessions.pop(session_id, None) if state is None: return failures: list[str] = [] @@ -351,21 +341,22 @@ class _Runtime: state.atif_exporter.deregister(state.atif_subscriber_name) except Exception as exc: failures.append(f"ATIF deregister failed: {exc}") - if ( - self._plugin_config_initialized - and self._plugin_activation is None - and not self.sessions - ): - try: - self._clear_plugins_toml() - except Exception as exc: - failures.append(f"plugin configuration clear failed: {exc}") - elif ( - self.settings.plugins_config - and self._plugin_activation is None - and not self.sessions - ): - self._plugin_config_needs_reinit = True + with self._sessions_lock: + if ( + self._plugin_config_initialized + and self._plugin_activation is None + and not self.sessions + ): + try: + self._clear_plugins_toml() + except Exception as exc: + failures.append(f"plugin configuration clear failed: {exc}") + elif ( + self.settings.plugins_config + and self._plugin_activation is None + and not self.sessions + ): + self._plugin_config_needs_reinit = True if failures: logger.warning( "NeMo Relay session %s teardown completed with errors: %s", @@ -376,7 +367,9 @@ class _Runtime: def shutdown(self) -> None: """Close active sessions and the process-lifetime plugin activation.""" failures: list[str] = [] - for session_id in list(self.sessions): + with self._sessions_lock: + session_ids = list(self.sessions) + for session_id in session_ids: try: self.close_session({"session_id": session_id, "reason": "runtime_shutdown"}) except Exception as exc: @@ -412,10 +405,11 @@ class _Runtime: metadata = _metadata(kwargs) child_session_id = _child_session_id(kwargs) if child_session_id: - self.subagent_contexts[child_session_id] = _SubagentContext( - parent_session_id=parent_state.session_id, - metadata=_subagent_child_metadata(kwargs, metadata), - ) + with self._sessions_lock: + self.subagent_contexts[child_session_id] = _SubagentContext( + parent_session_id=parent_state.session_id, + metadata=_subagent_child_metadata(kwargs, metadata), + ) self.run_in_session( parent_state, self.nemo_relay.scope.event, @@ -432,151 +426,15 @@ class _Runtime: {"session_id": child_session_id}, close_host=False, ) - self.subagent_contexts.pop(child_session_id, None) + with self._sessions_lock: + self.subagent_contexts.pop(child_session_id, None) self.mark("hermes.subagent.stop", kwargs) - def managed_llm_enabled(self) -> bool: - return ( - (self.settings.adaptive_enabled or self._plugin_activation is not None) - and callable(getattr(getattr(self.nemo_relay, "llm", None), "execute", None)) - and callable(getattr(self.nemo_relay, "LLMRequest", None)) - ) - - def managed_tool_enabled(self) -> bool: - return ( - (self.settings.adaptive_enabled or self._plugin_activation is not None) - and callable(getattr(getattr(self.nemo_relay, "tools", None), "execute", None)) - ) - - def _run_managed_with_downstream_preservation( - self, - next_call: Callable[[Any], Any], - normalize_payload: Callable[[Any], Any], - shape_response: Callable[[Any], Any], - make_managed_execute: Callable[[Callable[[Any], Any]], Any], - *, - preserve_raw_response: bool, - ) -> Any: - # NeMo Relay's native managed execution may wrap a failing callback as an - # internal runtime error, hiding the real downstream provider/tool - # exception. Capture the original here and re-raise it after managed - # execution so Hermes retry classification still sees it. The LLM and tool - # paths share this scaffolding; they differ only in payload normalization, - # response shaping, and the Relay call itself. - raw_response: dict[str, Any] = {"set": False, "value": None, "normalized": None} - callback_error: Exception | None = None - downstream_error: BaseException | None = None - - def _impl(next_payload: Any) -> Any: - nonlocal callback_error, downstream_error - try: - raw = next_call(normalize_payload(next_payload)) - except Exception as exc: - callback_error = exc - downstream_error = _original_downstream_error(exc) - raise - raw_response["set"] = True - raw_response["value"] = raw - raw_response["normalized"] = shape_response(raw) - return raw_response["normalized"] - - try: - managed_result = _resolve_awaitable(make_managed_execute(_impl)) - except Exception as exc: - if downstream_error is not None and _is_relay_wrapped_callback_error(exc, callback_error): - raise downstream_error - raise - if ( - preserve_raw_response - and raw_response["set"] - and _json_semantically_equal(managed_result, raw_response["normalized"]) - ): - return raw_response["value"] - return managed_result - - def execute_llm(self, kwargs: dict[str, Any]) -> Any: - state = self.ensure_session(kwargs) - request_body = _jsonable(kwargs.get("request") or {}) - request = self.nemo_relay.LLMRequest({}, request_body) - next_call = kwargs.get("next_call") - if not callable(next_call): - return request_body - - def _normalize(next_request: Any) -> Any: - next_body = getattr(next_request, "content", next_request) - return next_body if isinstance(next_body, dict) else request_body - - def _make_managed(impl: Callable[[Any], Any]) -> Any: - async def _managed_execute() -> Any: - return await self.run_in_session_async( - state, - self.nemo_relay.llm.execute, - _relay_llm_surface(kwargs), - request, - impl, - handle=state.handle, - data=_jsonable( - { - "turn_id": kwargs.get("turn_id"), - "api_request_id": kwargs.get("api_request_id"), - "api_call_count": kwargs.get("api_call_count"), - "mode": self.settings.adaptive_mode, - } - ), - metadata=_metadata(kwargs), - model_name=str(kwargs.get("model") or ""), - ) - - return _managed_execute() - - return self._run_managed_with_downstream_preservation( - next_call, _normalize, _llm_response_payload, _make_managed, preserve_raw_response=True - ) - - def execute_tool(self, kwargs: dict[str, Any]) -> Any: - state = self.ensure_session(kwargs) - tool_name = str(kwargs.get("tool_name") or "tool") - args = _jsonable(kwargs.get("args") or {}) - next_call = kwargs.get("next_call") - if not callable(next_call): - return args - - def _normalize(next_args: Any) -> Any: - normalized = next_args if isinstance(next_args, dict) else args - if not _json_semantically_equal(normalized, args): - raise RuntimeError( - "NeMo Relay changed tool arguments after Hermes authorization" - ) - return args - - def _make_managed(impl: Callable[[Any], Any]) -> Any: - async def _managed_execute() -> Any: - return await self.run_in_session_async( - state, - self.nemo_relay.tools.execute, - tool_name, - args, - impl, - handle=state.handle, - data=_jsonable( - { - "turn_id": kwargs.get("turn_id"), - "api_request_id": kwargs.get("api_request_id"), - "tool_call_id": kwargs.get("tool_call_id"), - "mode": self.settings.adaptive_mode, - } - ), - metadata=_metadata(kwargs), - ) - - return _managed_execute() - - return self._run_managed_with_downstream_preservation( - next_call, _normalize, _jsonable, _make_managed, preserve_raw_response=False - ) - - def register(ctx) -> None: + relay_runtime.SESSION_COORDINATOR.register_session_initializer( + _SESSION_INITIALIZER_NAME, + _prepare_core_session, + ) # Activate dynamic plugins before Hermes installs the managed execution # boundaries that invoke their interceptors. if _load_settings().dynamic_plugins: @@ -587,11 +445,6 @@ def register(ctx) -> None: ctx.register_hook("on_session_reset", on_session_reset) ctx.register_hook("pre_llm_call", on_pre_llm_call) ctx.register_hook("post_llm_call", on_post_llm_call) - ctx.register_hook("pre_api_request", on_pre_api_request) - ctx.register_hook("post_api_request", on_post_api_request) - ctx.register_hook("api_request_error", on_api_request_error) - ctx.register_hook("pre_tool_call", on_pre_tool_call) - ctx.register_hook("post_tool_call", on_post_tool_call) ctx.register_hook("pre_approval_request", on_pre_approval_request) ctx.register_hook("post_approval_response", on_post_approval_response) ctx.register_hook("subagent_start", on_subagent_start) @@ -613,13 +466,13 @@ def on_session_end(**kwargs: Any) -> None: def on_session_finalize(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is not None: - _safe(lambda: runtime.close_session(kwargs)) + _safe(lambda: runtime.close_session(kwargs, close_host=False)) def on_session_reset(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is not None: - _safe(lambda: runtime.close_session(kwargs)) + _safe(lambda: runtime.close_session(kwargs, close_host=False)) def on_pre_llm_call(**kwargs: Any) -> None: @@ -634,132 +487,6 @@ def on_post_llm_call(**kwargs: Any) -> None: _safe(lambda: runtime.mark("hermes.turn.end", kwargs)) -def on_pre_api_request(**kwargs: Any) -> None: - runtime = _get_runtime() - if runtime is None: - return - if runtime.managed_llm_enabled(): - return - - def _record() -> None: - state = runtime.ensure_session(kwargs) - request_payload = kwargs.get("request") - request_body = request_payload.get("body") if isinstance(request_payload, dict) else {} - request = runtime.nemo_relay.LLMRequest({}, _jsonable(request_body)) - span = runtime.run_in_session( - state, - runtime.nemo_relay.llm.call, - str(kwargs.get("provider") or "llm"), - request, - handle=state.handle, - data=_jsonable({"turn_id": kwargs.get("turn_id"), "api_request_id": kwargs.get("api_request_id")}), - metadata=_metadata(kwargs), - model_name=str(kwargs.get("model") or ""), - ) - state.llm_spans[_api_key(kwargs)] = span - - _safe(_record) - - -def on_post_api_request(**kwargs: Any) -> None: - runtime = _get_runtime() - if runtime is None: - return - if runtime.managed_llm_enabled(): - return - - def _record() -> None: - state = runtime.ensure_session(kwargs) - span = state.llm_spans.pop(_api_key(kwargs), None) - if span is None: - runtime.mark("hermes.api.response.unmatched", kwargs) - return - runtime.run_in_session( - state, - runtime.nemo_relay.llm.call_end, - span, - _jsonable(kwargs.get("response") or {}), - data=_jsonable({"usage": kwargs.get("usage"), "finish_reason": kwargs.get("finish_reason")}), - metadata=_metadata(kwargs), - ) - - _safe(_record) - - -def on_api_request_error(**kwargs: Any) -> None: - runtime = _get_runtime() - if runtime is None: - return - if runtime.managed_llm_enabled(): - return - - def _record() -> None: - state = runtime.ensure_session(kwargs) - span = state.llm_spans.pop(_api_key(kwargs), None) - if span is None: - runtime.mark("hermes.api.error", kwargs) - return - runtime.run_in_session( - state, - runtime.nemo_relay.llm.call_end, - span, - {"error": _jsonable(kwargs.get("error") or {})}, - data=_jsonable(kwargs), - metadata=_metadata(kwargs), - ) - - _safe(_record) - - -def on_pre_tool_call(**kwargs: Any) -> None: - runtime = _get_runtime() - if runtime is None: - return - if runtime.managed_tool_enabled(): - return - - def _record() -> None: - state = runtime.ensure_session(kwargs) - span = runtime.run_in_session( - state, - runtime.nemo_relay.tools.call, - str(kwargs.get("tool_name") or "tool"), - _jsonable(kwargs.get("args") or {}), - handle=state.handle, - data=_jsonable({"turn_id": kwargs.get("turn_id"), "api_request_id": kwargs.get("api_request_id")}), - metadata=_metadata(kwargs), - tool_call_id=str(kwargs.get("tool_call_id") or ""), - ) - state.tool_spans[_tool_key(kwargs)] = span - - _safe(_record) - - -def on_post_tool_call(**kwargs: Any) -> None: - runtime = _get_runtime() - if runtime is None: - return - if runtime.managed_tool_enabled(): - return - - def _record() -> None: - state = runtime.ensure_session(kwargs) - span = state.tool_spans.pop(_tool_key(kwargs), None) - if span is None: - runtime.mark("hermes.tool.response.unmatched", kwargs) - return - runtime.run_in_session( - state, - runtime.nemo_relay.tools.call_end, - span, - _jsonable(kwargs.get("result")), - data=_jsonable({"status": kwargs.get("status"), "duration_ms": kwargs.get("duration_ms")}), - metadata=_metadata(kwargs), - ) - - _safe(_record) - - def on_pre_approval_request(**kwargs: Any) -> None: runtime = _get_runtime() if runtime is not None: @@ -784,44 +511,42 @@ def on_subagent_stop(**kwargs: Any) -> None: _safe(lambda: runtime.mark_subagent_stop(kwargs)) -def on_llm_execution_middleware(**kwargs: Any) -> Any: - runtime = _get_runtime() - next_call = kwargs.get("next_call") - request = kwargs.get("request") or {} - if runtime is not None and runtime.managed_llm_enabled(): - return runtime.execute_llm(kwargs) - if callable(next_call): - return next_call(request) - return request +def _prepare_core_session( + host: relay_runtime.RelayRuntime, + context: dict[str, Any], +) -> None: + """Register rich subscribers before core creates the conversation scope.""" + runtime = _get_runtime( + profile_key=str(context.get("profile_key") or host.profile_key), + host=host, + ) + if runtime is not None: + runtime.prepare_session(context) -def on_tool_execution_middleware(**kwargs: Any) -> Any: - runtime = _get_runtime() - next_call = kwargs.get("next_call") - args = kwargs.get("args") or {} - if runtime is not None and runtime.managed_tool_enabled(): - return runtime.execute_tool(kwargs) - if callable(next_call): - return next_call(args) - return args - - -def _get_runtime() -> Optional[_Runtime]: - profile_key = relay_runtime.current_profile_key() +def _get_runtime( + *, + profile_key: str | None = None, + host: relay_runtime.RelayRuntime | None = None, +) -> Optional[_Runtime]: + profile_key = profile_key or relay_runtime.current_profile_key() with _LOCK: runtime = _RUNTIMES.get(profile_key) if runtime is _INIT_FAILED: return None if isinstance(runtime, _Runtime): - return runtime + if host is None or runtime.host is host: + return runtime + runtime.shutdown() + _RUNTIMES.pop(profile_key, None) try: - host = relay_runtime.get_runtime() - if host is None: + resolved_host = host or relay_runtime.get_runtime(profile_key=profile_key) + if resolved_host is None: raise RuntimeError("Hermes core Relay runtime is unavailable") runtime = _Runtime( - nemo_relay=host.relay, + nemo_relay=resolved_host.relay, settings=_load_settings(), - host=host, + host=resolved_host, ) except Exception as exc: logger.debug("NeMo Relay plugin disabled: init failed: %s", exc, exc_info=True) @@ -834,13 +559,10 @@ def _get_runtime() -> Optional[_Runtime]: def _load_settings() -> _Settings: plugins_toml_path = _env("HERMES_NEMO_RELAY_PLUGINS_TOML") plugins_config = _load_plugins_config(plugins_toml_path) - adaptive_config = _enabled_component_config(plugins_config, "adaptive") return _Settings( plugins_toml_path=plugins_toml_path, plugins_config=plugins_config, dynamic_plugins=_dynamic_plugin_specs(plugins_config, plugins_toml_path), - adaptive_enabled=adaptive_config is not None, - adaptive_mode=_adaptive_mode(adaptive_config), atof_enabled=_env_bool("HERMES_NEMO_RELAY_ATOF_ENABLED"), atof_output_directory=_env("HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY"), atof_filename=_env("HERMES_NEMO_RELAY_ATOF_FILENAME") or "hermes-atof.jsonl", @@ -1014,20 +736,6 @@ def _enabled_component_config( return None -def _adaptive_mode(config: dict[str, Any] | None) -> str: - if not isinstance(config, dict): - return "observe_only" - tool_parallelism = config.get("tool_parallelism") - if isinstance(tool_parallelism, dict): - mode = tool_parallelism.get("mode") - if isinstance(mode, str) and mode.strip(): - return mode.strip() - mode = config.get("mode") - if isinstance(mode, str) and mode.strip(): - return mode.strip() - return "observe_only" - - def _observability_exporter_enabled( plugins_config: dict[str, Any] | None, exporter_name: str, @@ -1089,25 +797,6 @@ def _subagent_child_metadata( return metadata -def _api_key(kwargs: dict[str, Any]) -> str: - return str(kwargs.get("api_request_id") or f"{_session_id(kwargs)}:{kwargs.get('api_call_count') or 'api'}") - - -def _tool_key(kwargs: dict[str, Any]) -> str: - return str( - kwargs.get("tool_call_id") - or f"{_session_id(kwargs)}:{kwargs.get('turn_id') or ''}:{kwargs.get('tool_name') or 'tool'}" - ) - - -def _relay_llm_surface(kwargs: dict[str, Any]) -> str: - api_mode = str(kwargs.get("api_mode") or "").strip().lower() - return _RELAY_LLM_SURFACE_BY_API_MODE.get( - api_mode, - str(kwargs.get("provider") or "llm"), - ) - - def _metadata(kwargs: dict[str, Any]) -> dict[str, Any]: keys = ( "telemetry_schema_version", @@ -1167,105 +856,6 @@ def _jsonable(value: Any) -> Any: return str(value) -def _json_semantically_equal(left: Any, right: Any) -> bool: - """Compare JSON-compatible values without conflating booleans and numbers.""" - try: - left_json = json.dumps( - _jsonable(left), ensure_ascii=False, sort_keys=True, separators=(",", ":") - ) - right_json = json.dumps( - _jsonable(right), ensure_ascii=False, sort_keys=True, separators=(",", ":") - ) - return left_json == right_json - except (TypeError, ValueError): - return False - - -def _value(obj: Any, key: str, default: Any = None) -> Any: - if isinstance(obj, dict): - return obj.get(key, default) - return getattr(obj, key, default) - - -def _original_downstream_error(exc: Exception) -> BaseException: - # Hermes wraps downstream execution failures in a local/private exception - # class, so detect the wrapper by shape instead of importing it here. - original = getattr(exc, "original", None) - if exc.__class__.__name__ == "_DownstreamExecutionError" and isinstance(original, BaseException): - return original - return exc - - -def _is_relay_wrapped_callback_error(exc: Exception, callback_error: Exception | None) -> bool: - # NeMo Relay re-wraps a failing callback as ``RuntimeError("internal error: - # : ")``. Match by prefix rather than exact equality so a - # trailing traceback/suffix in a future Relay version doesn't silently defeat - # the unwrap; the class-name + message prefix still discriminates the real - # downstream failure from unrelated Relay-internal errors. If Relay drops the - # leading ``internal error:`` shape entirely, this returns False and Hermes - # falls back to surfacing Relay's error (the pre-fix behavior) rather than - # masking it. - if callback_error is None or not isinstance(exc, RuntimeError): - return False - expected = f"internal error: {callback_error.__class__.__name__}: {callback_error}" - return str(exc).startswith(expected) - - -def _llm_response_payload(response: Any) -> Any: - """Return the LLM response shape NeMo Relay's ATIF conversion expects.""" - payload = _jsonable(response) - if isinstance(payload, dict) and "assistant_message" in payload: - return payload - - choices = _value(response, "choices") - if choices is None and isinstance(payload, dict): - choices = payload.get("choices") - first_choice = choices[0] if isinstance(choices, list) and choices else None - message = _value(first_choice, "message") - finish_reason = _value(first_choice, "finish_reason") - - assistant_message: dict[str, Any] = {"role": "assistant", "content": ""} - if message is not None: - assistant_message["role"] = _value(message, "role", "assistant") or "assistant" - content = _value(message, "content") - if content is not None: - assistant_message["content"] = _jsonable(content) - tool_calls = _tool_calls_payload(_value(message, "tool_calls")) - if tool_calls: - assistant_message["tool_calls"] = tool_calls - reasoning = _value(message, "reasoning_content") - if reasoning is not None: - assistant_message["reasoning_content"] = _jsonable(reasoning) - elif isinstance(payload, dict): - assistant_message["content"] = payload.get("content") or payload.get("output_text") or "" - - return { - "model": _value(response, "model", payload.get("model") if isinstance(payload, dict) else None), - "assistant_message": assistant_message, - "finish_reason": finish_reason, - "usage": _jsonable(_value(response, "usage", payload.get("usage") if isinstance(payload, dict) else None)), - } - - -def _tool_calls_payload(tool_calls: Any) -> list[dict[str, Any]]: - if not isinstance(tool_calls, list): - return [] - normalized: list[dict[str, Any]] = [] - for call in tool_calls: - function = _value(call, "function") - normalized.append( - { - "id": _value(call, "id"), - "type": _value(call, "type", "function") or "function", - "function": { - "name": _value(function, "name"), - "arguments": _value(function, "arguments"), - }, - } - ) - return normalized - - def _safe(fn) -> None: try: fn() @@ -1303,6 +893,9 @@ def _resolve_awaitable(value: Any) -> Any: def reset_for_tests() -> None: + relay_runtime.SESSION_COORDINATOR.unregister_session_initializer( + _SESSION_INITIALIZER_NAME + ) with _LOCK: runtimes = list(_RUNTIMES.values()) _RUNTIMES.clear() diff --git a/plugins/observability/nemo_relay/plugin.yaml b/plugins/observability/nemo_relay/plugin.yaml index b1b00f25d81..046d5d0d851 100644 --- a/plugins/observability/nemo_relay/plugin.yaml +++ b/plugins/observability/nemo_relay/plugin.yaml @@ -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 diff --git a/run_agent.py b/run_agent.py index 2bd2a9458de..82e6065c9fd 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2600,17 +2600,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, @@ -6374,6 +6373,7 @@ class AIAgent: 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, diff --git a/tests/agent/test_auxiliary_relay.py b/tests/agent/test_auxiliary_relay.py new file mode 100644 index 00000000000..8fc7ce168d3 --- /dev/null +++ b/tests/agent/test_auxiliary_relay.py @@ -0,0 +1,186 @@ +from types import SimpleNamespace + +import pytest + +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 = [] + client = SimpleNamespace( + chat=SimpleNamespace( + completions=SimpleNamespace( + create=lambda **kwargs: {"request": kwargs}, + ) + ) + ) + + def execute_current(request, callback, **kwargs): + attempts.append(kwargs) + return callback(request) + + monkeypatch.setattr(relay_llm, "execute_current", execute_current) + + @auxiliary_client._relay_auxiliary_call + def run(task): + auxiliary_client._set_relay_auxiliary_route( + "openrouter", + "test-model", + "chat_completions", + ) + first = auxiliary_client._relay_sync_completion( + client, + {"model": "test-model", "messages": []}, + ) + second = auxiliary_client._relay_sync_completion( + client, + {"model": "test-model", "messages": []}, + ) + return first, second + + first, second = run("compression") + + assert first["request"]["model"] == "test-model" + assert second["request"]["model"] == "test-model" + 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" + + +@pytest.mark.asyncio +async def test_async_auxiliary_attempt_uses_inherited_relay_adapter(monkeypatch): + captured = {} + + async def create(**kwargs): + return {"request": kwargs} + + 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, + ) + + @auxiliary_client._relay_auxiliary_call_async + async def run(task): + auxiliary_client._set_relay_auxiliary_route( + "anthropic", + "claude-test", + "chat_completions", + ) + return await auxiliary_client._relay_async_completion( + client, + {"model": "claude-test", "messages": []}, + ) + + result = await run("title_generation") + + assert result["request"]["model"] == "claude-test" + assert captured["name"] == "anthropic" + assert captured["metadata"]["call_role"] == "auxiliary:title_generation" + + +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_auxiliary_attempt_uses_real_relay_request_intercepts(relay_turn): + relay, turn = relay_turn + captured_requests = [] + client = SimpleNamespace( + chat=SimpleNamespace( + completions=SimpleNamespace( + create=lambda **kwargs: captured_requests.append(kwargs) + or {"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._relay_sync_completion( + client, + {"model": "test-model", "messages": []}, + ) + + result = run("compression") + finally: + relay.intercepts.deregister_llm_request("hermes-auxiliary-request") + + assert result == {"content": "ok"} + assert captured_requests[0]["temperature"] == 0.25 + assert turn.logical_llm_calls == {} diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 358f608c2c7..84d5b8fdb49 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -127,7 +127,7 @@ def test_stream_uses_rewritten_request_and_post_intercept_chunks(relay_turn): assert turn.logical_llm_calls == {} -def test_stream_preserves_provider_error_and_turn_closes_logical_scope(relay_turn): +def test_stream_preserves_provider_error_and_keeps_logical_scope_for_retry(relay_turn): _relay, turn = relay_turn class ProviderError(Exception): @@ -162,6 +162,138 @@ def test_stream_preserves_provider_error_and_turn_closes_logical_scope(relay_tur assert "request-2" in turn.logical_llm_calls +def test_non_stream_preserves_raw_provider_response_identity(relay_turn): + _relay, _turn = relay_turn + raw_response = SimpleNamespace(model="test-model", content="raw") + + result = relay_llm.execute( + {"model": "test-model", "messages": []}, + lambda _request: raw_response, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={"api_mode": "custom", "api_request_id": "request-raw"}, + ) + + assert result is raw_response + + +@pytest.mark.asyncio +async def test_async_non_stream_preserves_raw_provider_response_identity(relay_turn): + _relay, _turn = relay_turn + raw_response = SimpleNamespace(model="test-model", content="raw") + + async def provider(_request): + return raw_response + + result = await relay_llm.execute_current_async( + {"model": "test-model", "messages": []}, + provider, + name="test-provider", + model_name="test-model", + metadata={"api_mode": "custom", "api_request_id": "request-async"}, + ) + + assert result is raw_response + + +def test_current_attempt_bypasses_relay_without_an_active_turn(monkeypatch): + monkeypatch.setattr(relay_runtime, "current_turn", lambda: None) + request = {"model": "test-model", "messages": []} + + result = relay_llm.execute_current( + request, + lambda value: value, + name="test-provider", + model_name="test-model", + ) + + assert result is request + + +def test_non_stream_returns_post_execution_interceptor_result(relay_turn, monkeypatch): + relay, _turn = relay_turn + + async def post_execute(_name, request, callback, **_kwargs): + response = callback(request) + return {**response, "post_interceptor": True} + + monkeypatch.setattr(relay.llm, "execute", post_execute) + + result = relay_llm.execute( + {"model": "test-model", "messages": []}, + lambda _request: {"content": "raw"}, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={"api_mode": "custom", "api_request_id": "request-post"}, + ) + + assert result == {"content": "raw", "post_interceptor": True} + + +def test_non_stream_preserves_provider_error_from_relay_wrapper_suffix( + relay_turn, monkeypatch +): + relay, turn = relay_turn + + class ProviderError(Exception): + pass + + provider_error = ProviderError("provider failed") + + async def wrapping_execute(_name, request, callback, **_kwargs): + try: + return callback(request) + except Exception as exc: + raise RuntimeError( + f"internal error: {type(exc).__name__}: {exc} (retried 3x)" + ) from None + + monkeypatch.setattr(relay.llm, "execute", wrapping_execute) + + with pytest.raises(ProviderError) as caught: + relay_llm.execute( + {"model": "test-model", "messages": []}, + lambda _request: (_ for _ in ()).throw(provider_error), + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={"api_mode": "custom", "api_request_id": "request-error"}, + ) + + assert caught.value is provider_error + assert "request-error" in turn.logical_llm_calls + + +def test_non_stream_does_not_mask_relay_error_after_callback_failure( + relay_turn, monkeypatch +): + relay, _turn = relay_turn + provider_error = RuntimeError("provider failed") + relay_error = RuntimeError("internal error: RelayPolicyError: policy blocked") + + async def translating_execute(_name, request, callback, **_kwargs): + try: + callback(request) + except Exception: + raise relay_error + + monkeypatch.setattr(relay.llm, "execute", translating_execute) + + with pytest.raises(RuntimeError) as caught: + relay_llm.execute( + {"model": "test-model", "messages": []}, + lambda _request: (_ for _ in ()).throw(provider_error), + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={"api_mode": "custom", "api_request_id": "request-policy"}, + ) + + assert caught.value is relay_error + + def test_chat_codec_preserves_provider_message_extensions_after_rewrite(relay_turn): relay, _turn = relay_turn captured_requests = [] @@ -223,3 +355,40 @@ def test_chat_codec_preserves_provider_message_extensions_after_rewrite(relay_tu assert captured_requests[0]["messages"][0]["reasoning_content"] == ( "provider scratchpad" ) + + +def test_request_rewrite_preserves_unmodified_provider_objects(relay_turn): + relay, _turn = relay_turn + timeout = object() + captured_requests = [] + + def rewrite_request(name, request, annotated): + del name + annotated.params = {**(annotated.params or {}), "temperature": 0.25} + return relay.LLMRequestInterceptOutcome(request, annotated) + + relay.intercepts.register_llm_request( + "hermes-provider-object-request", + 1, + False, + rewrite_request, + ) + try: + relay_llm.execute( + {"model": "test-model", "messages": [], "timeout": timeout}, + lambda request: captured_requests.append(request) or {"content": "ok"}, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={ + "api_mode": "chat_completions", + "api_request_id": "request-provider-object", + }, + ) + finally: + relay.intercepts.deregister_llm_request( + "hermes-provider-object-request" + ) + + assert captured_requests[0]["timeout"] is timeout + assert captured_requests[0]["temperature"] == 0.25 diff --git a/tests/agent/test_relay_tools.py b/tests/agent/test_relay_tools.py index e1d0fffb869..f352d0b8497 100644 --- a/tests/agent/test_relay_tools.py +++ b/tests/agent/test_relay_tools.py @@ -83,3 +83,58 @@ def test_provider_error_identity_is_preserved(relay_turn): ) 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 diff --git a/tests/cli/test_session_boundary_hooks.py b/tests/cli/test_session_boundary_hooks.py index 52c64c01c2d..ba4f13d2c10 100644 --- a/tests/cli/test_session_boundary_hooks.py +++ b/tests/cli/test_session_boundary_hooks.py @@ -10,8 +10,9 @@ 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): +@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 +23,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 +37,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 +50,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 diff --git a/tests/hermes_cli/test_lifecycle.py b/tests/hermes_cli/test_lifecycle.py new file mode 100644 index 00000000000..999d6057b3b --- /dev/null +++ b/tests/hermes_cli/test_lifecycle.py @@ -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}] diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index a261b51ce3b..a1953508bd5 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -12,7 +12,7 @@ from typing import Any import pytest -from hermes_cli import plugins +from hermes_cli import lifecycle, plugins from hermes_cli.observability import relay_runtime, relay_shared_metrics from hermes_cli.plugins import PluginManager @@ -168,15 +168,15 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa "base_url": "http://127.0.0.1:11434/v1", } - assert plugins.has_hook("pre_api_request") - plugins.invoke_hook("on_session_start", **base) - plugins.invoke_hook("pre_llm_call", **base) - plugins.invoke_hook( + assert lifecycle.has_hook("pre_api_request") + lifecycle.invoke_hook("on_session_start", **base) + lifecycle.invoke_hook("pre_llm_call", **base) + lifecycle.invoke_hook( "pre_api_request", **base, request={"body": {"messages": ["sensitive-prompt"]}}, ) - plugins.invoke_hook( + lifecycle.invoke_hook( "post_tool_call", **base, tool_call_id="sensitive-tool-call", @@ -185,13 +185,13 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa result={"output": "sensitive-tool-result"}, status="ok", ) - plugins.invoke_hook( + lifecycle.invoke_hook( "api_request_error", **base, retryable=True, error={"message": "sensitive-error"}, ) - plugins.invoke_hook( + lifecycle.invoke_hook( "pre_api_request", **{ **base, @@ -201,7 +201,7 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa }, request={"body": {"messages": ["sensitive-prompt"]}}, ) - plugins.invoke_hook( + lifecycle.invoke_hook( "post_api_request", **{ **base, @@ -211,7 +211,7 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa }, response={"content": "sensitive-response"}, ) - plugins.invoke_hook( + lifecycle.invoke_hook( "on_session_end", **base, completed=True, @@ -219,7 +219,7 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa interrupted=False, turn_exit_reason="text_response(stop)", ) - plugins.invoke_hook("on_session_finalize", session_id=base["session_id"]) + lifecycle.finalize_session(session_id=base["session_id"]) starts = [event for event in direct_runtime.events if event[0] == "llm.call"] ends = [event for event in direct_runtime.events if event[0] == "llm.call_end"] @@ -310,8 +310,8 @@ def test_direct_runtime_is_disabled_by_default(tmp_path, monkeypatch): monkeypatch.setattr(plugins, "_plugin_manager", PluginManager()) assert not plugins.has_hook("pre_api_request") - plugins.invoke_hook("on_session_start", session_id="s1", platform="cli") - plugins.invoke_hook("on_session_finalize", session_id="s1") + lifecycle.invoke_hook("on_session_start", session_id="s1", platform="cli") + lifecycle.finalize_session(session_id="s1") assert fake.events == [] assert not (tmp_path / "hermes-home" / "telemetry").exists() @@ -345,7 +345,7 @@ def test_core_runtime_is_fail_open_without_a_published_binding(monkeypatch, capl def test_core_mark_uses_the_shared_session_handle_without_a_plugin(direct_runtime): - plugins.invoke_hook("on_session_start", session_id="s1", platform="cli") + lifecycle.invoke_hook("on_session_start", session_id="s1", platform="cli") handle = relay_runtime.get_session_handle("s1") assert handle is not None @@ -473,6 +473,117 @@ def test_session_coordinator_separates_turn_release_from_hard_finalize( assert runtime.get_session("coordinated-session") is None +def test_session_coordinator_prepares_subscribers_before_opening_scope( + direct_runtime, +): + coordinator = relay_runtime.SESSION_COORDINATOR + initializer_name = "test.pre_scope_subscriber" + + def prepare(host, context): + assert host.profile_key == relay_runtime.current_profile_key() + direct_runtime.events.append(("session.prepare", dict(context))) + + coordinator.register_session_initializer(initializer_name, prepare) + try: + lease = coordinator.acquire_conversation( + profile_key=relay_runtime.current_profile_key(), + session_id="prepared-session", + platform="cli", + model="demo-model", + ) + finally: + coordinator.unregister_session_initializer(initializer_name) + + prepared = next( + index + for index, event in enumerate(direct_runtime.events) + if event[0] == "session.prepare" + ) + opened = next( + index + for index, event in enumerate(direct_runtime.events) + if event[0] == "scope.push" and event[1] == relay_runtime.SESSION_SCOPE + ) + assert prepared < opened + assert direct_runtime.events[prepared][1] == { + "profile_key": relay_runtime.current_profile_key(), + "session_id": "prepared-session", + "platform": "cli", + "parent_session_id": "", + "model": "demo-model", + } + coordinator.finalize_conversation( + profile_key=relay_runtime.current_profile_key(), + session_id=lease.session_id, + ) + + +def test_session_initializer_failure_does_not_block_conversation( + direct_runtime, + caplog, +): + coordinator = relay_runtime.SESSION_COORDINATOR + initializer_name = "test.failing_subscriber" + + def fail(_host, _context): + raise RuntimeError("subscriber setup failed") + + coordinator.register_session_initializer(initializer_name, fail) + try: + with caplog.at_level("WARNING"): + lease = coordinator.acquire_conversation( + profile_key=relay_runtime.current_profile_key(), + session_id="initializer-failure", + platform="cli", + ) + finally: + coordinator.unregister_session_initializer(initializer_name) + + assert lease.session is not None + assert "Hermes Relay session initializer failed" in caplog.text + coordinator.finalize_conversation( + profile_key=relay_runtime.current_profile_key(), + session_id=lease.session_id, + ) + + +def test_profile_host_recreation_rebinds_shared_metrics_subscriber( + direct_runtime, +): + coordinator = relay_runtime.SESSION_COORDINATOR + profile_key = relay_runtime.current_profile_key() + first_lease = coordinator.acquire_conversation( + profile_key=profile_key, + session_id="before-restart", + platform="cli", + ) + first_metrics = relay_shared_metrics._get_runtime() + assert first_metrics is not None + first_host = first_lease.host + + coordinator.finalize_conversation( + profile_key=profile_key, + session_id=first_lease.session_id, + ) + coordinator.shutdown_profile(profile_key) + + second_lease = coordinator.acquire_conversation( + profile_key=profile_key, + session_id="after-restart", + platform="cli", + ) + second_metrics = relay_shared_metrics._get_runtime() + + assert second_metrics is not None + assert second_lease.host is not first_host + assert second_metrics is not first_metrics + assert second_metrics.host is second_lease.host + coordinator.finalize_conversation( + profile_key=profile_key, + session_id=second_lease.session_id, + ) + + def test_core_mark_lazily_starts_relay_without_metrics_or_a_plugin( tmp_path, monkeypatch, @@ -490,7 +601,7 @@ def test_core_mark_lazily_starts_relay_without_metrics_or_a_plugin( session_id="s1", data={"provenance": "agent_created"}, ) - plugins.invoke_hook("on_session_finalize", session_id="s1") + lifecycle.finalize_session(session_id="s1") assert [event[0] for event in fake.events] == [ "scope.push", @@ -923,7 +1034,7 @@ def test_subagent_stop_hook_does_not_own_child_session_lifetime(direct_runtime): ) assert child is not None - plugins.invoke_hook( + lifecycle.invoke_hook( "subagent_stop", parent_session_id="parent", child_session_id="child", @@ -1121,9 +1232,9 @@ def test_terminal_model_error_is_counted_as_failed(direct_runtime): "model": "claude-sonnet", } - plugins.invoke_hook("pre_api_request", **base) - plugins.invoke_hook("api_request_error", **base, retryable=False) - plugins.invoke_hook("on_session_finalize", session_id="s1") + lifecycle.invoke_hook("pre_api_request", **base) + lifecycle.invoke_hook("api_request_error", **base, retryable=False) + lifecycle.finalize_session(session_id="s1") [end] = [event for event in direct_runtime.events if event[0] == "llm.call_end"] assert end[2]["outcome"] == "failed" @@ -1139,15 +1250,15 @@ def test_task_terminal_counts_logical_calls_retries_and_unique_tools(direct_runt "model": "nvidia/nemotron-3-super-120b-a12b", } - plugins.invoke_hook("pre_llm_call", **base) - plugins.invoke_hook("pre_api_request", **base) - plugins.invoke_hook("api_request_error", **base, retryable=True) - plugins.invoke_hook("pre_api_request", **base) - plugins.invoke_hook("api_request_error", **base, retryable=True) - plugins.invoke_hook("pre_api_request", **base) - plugins.invoke_hook("api_request_error", **base, retryable=False) + lifecycle.invoke_hook("pre_llm_call", **base) + lifecycle.invoke_hook("pre_api_request", **base) + lifecycle.invoke_hook("api_request_error", **base, retryable=True) + lifecycle.invoke_hook("pre_api_request", **base) + lifecycle.invoke_hook("api_request_error", **base, retryable=True) + lifecycle.invoke_hook("pre_api_request", **base) + lifecycle.invoke_hook("api_request_error", **base, retryable=False) for tool_call_id in ("tool-1", "tool-1", "tool-2"): - plugins.invoke_hook( + lifecycle.invoke_hook( "post_tool_call", **base, tool_call_id=tool_call_id, @@ -1155,7 +1266,7 @@ def test_task_terminal_counts_logical_calls_retries_and_unique_tools(direct_runt result={"output": "private"}, status="ok", ) - plugins.invoke_hook( + lifecycle.invoke_hook( "on_session_end", **base, completed=False, @@ -1163,7 +1274,7 @@ def test_task_terminal_counts_logical_calls_retries_and_unique_tools(direct_runt interrupted=False, turn_exit_reason="all_retries_exhausted_no_response", ) - plugins.invoke_hook("on_session_finalize", session_id="s1") + lifecycle.finalize_session(session_id="s1") model_starts = [event for event in direct_runtime.events if event[0] == "llm.call"] model_ends = [ @@ -1249,7 +1360,7 @@ def test_outer_agent_boundary_closes_early_returns_and_exceptions( with pytest.raises(TimeoutError, match="private timeout detail"): AIAgent.run_conversation(agent, "private prompt", task_id="timed-out") - plugins.invoke_hook("on_session_finalize", session_id="s1") + lifecycle.finalize_session(session_id="s1") task_ends = [ event[2]["output"] @@ -1315,14 +1426,14 @@ def test_outer_agent_boundary_preserves_a_returned_timeout_reason( def test_session_finalize_closes_a_pending_task_as_system_aborted(direct_runtime): - plugins.invoke_hook( + lifecycle.invoke_hook( "pre_llm_call", session_id="s1", task_id="t1", platform="cli", ) - plugins.invoke_hook("on_session_finalize", session_id="s1") + lifecycle.finalize_session(session_id="s1") [task_end] = [ event @@ -1344,13 +1455,13 @@ def test_session_finalize_closes_a_pending_task_as_system_aborted(direct_runtime def test_sequential_tasks_in_one_session_aggregate_once_each(direct_runtime, tmp_path): for task_id in ("t1", "t2"): - plugins.invoke_hook( + lifecycle.invoke_hook( "pre_llm_call", session_id="s1", task_id=task_id, platform="cli", ) - plugins.invoke_hook( + lifecycle.invoke_hook( "on_session_end", session_id="s1", task_id=task_id, @@ -1360,7 +1471,7 @@ def test_sequential_tasks_in_one_session_aggregate_once_each(direct_runtime, tmp interrupted=False, turn_exit_reason="text_response(stop)", ) - plugins.invoke_hook("on_session_finalize", session_id="s1") + lifecycle.finalize_session(session_id="s1") outbox = tmp_path / "hermes-home" / "telemetry" / "shared_metrics" / "outbox" [package_path] = list(outbox.glob("*.json")) @@ -1371,13 +1482,13 @@ def test_sequential_tasks_in_one_session_aggregate_once_each(direct_runtime, tmp def test_task_ownership_survives_session_id_rotation(direct_runtime): - plugins.invoke_hook( + lifecycle.invoke_hook( "pre_llm_call", session_id="before-compression", task_id="t1", platform="cli", ) - plugins.invoke_hook( + lifecycle.invoke_hook( "pre_api_request", session_id="after-compression", task_id="t1", @@ -1386,7 +1497,7 @@ def test_task_ownership_survives_session_id_rotation(direct_runtime): provider="nvidia", model="nvidia/nemotron-3-super-120b-a12b", ) - plugins.invoke_hook( + lifecycle.invoke_hook( "post_api_request", session_id="after-compression", task_id="t1", @@ -1395,7 +1506,7 @@ def test_task_ownership_survives_session_id_rotation(direct_runtime): provider="nvidia", model="nvidia/nemotron-3-super-120b-a12b", ) - plugins.invoke_hook( + lifecycle.invoke_hook( "on_session_end", session_id="after-compression", task_id="t1", @@ -1405,7 +1516,7 @@ def test_task_ownership_survives_session_id_rotation(direct_runtime): interrupted=False, turn_exit_reason="text_response(stop)", ) - plugins.invoke_hook("on_session_finalize", session_id="before-compression") + lifecycle.finalize_session(session_id="before-compression") task_starts = [ event @@ -1443,8 +1554,8 @@ def test_gateway_and_delegated_entrypoints_flow_through_relay(direct_runtime): }, ] for task in tasks: - plugins.invoke_hook("pre_llm_call", **task) - plugins.invoke_hook( + lifecycle.invoke_hook("pre_llm_call", **task) + lifecycle.invoke_hook( "on_session_end", **task, completed=True, @@ -1477,7 +1588,7 @@ def test_persistence_failure_does_not_escape_the_hook( raise OSError("store unavailable") monkeypatch.setattr(runtime.subscriber.store, "record_counter", fail_record) - plugins.invoke_hook( + lifecycle.invoke_hook( "pre_api_request", session_id="s1", task_id="t1", @@ -1485,7 +1596,7 @@ def test_persistence_failure_does_not_escape_the_hook( provider="openai", model="gpt-5", ) - plugins.invoke_hook( + lifecycle.invoke_hook( "post_api_request", session_id="s1", task_id="t1", diff --git a/tests/plugins/test_nemo_relay_plugin.py b/tests/plugins/test_nemo_relay_plugin.py index 67dea13b668..fce510b7c2b 100644 --- a/tests/plugins/test_nemo_relay_plugin.py +++ b/tests/plugins/test_nemo_relay_plugin.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import builtins import contextvars import gc import importlib @@ -16,7 +15,7 @@ from types import SimpleNamespace import pytest import yaml -from hermes_cli import plugins as plugin_api +from hermes_cli import lifecycle, plugins as plugin_api from hermes_cli.observability import relay_runtime, relay_shared_metrics from hermes_cli.plugins import PluginManager @@ -113,7 +112,13 @@ class _FakeNemoRelay: def _llm_execute(self, name, request, func, **kwargs): self.events.append(("llm.execute.start", name, request.content, kwargs)) + handle = self._llm_call(name, request, **kwargs) result = func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) + self._llm_call_end( + handle, + result, + **{key: value for key, value in kwargs.items() if key != "handle"}, + ) self.events.append(("llm.execute.end", name, result, kwargs)) return result @@ -127,7 +132,13 @@ class _FakeNemoRelay: def _tool_execute(self, name, args, func, **kwargs): self.events.append(("tool.execute.start", name, args, kwargs)) + handle = self._tool_call(name, args, **kwargs) result = func(args) + self._tool_call_end( + handle, + result, + **{key: value for key, value in kwargs.items() if key != "handle"}, + ) self.events.append(("tool.execute.end", name, result, kwargs)) return result @@ -233,33 +244,6 @@ def _fresh_plugin(monkeypatch, fake): return plugin -def _wrapped_downstream_error(original): - class _DownstreamExecutionError(Exception): - def __init__(self, original): - super().__init__(str(original)) - self.original = original - - return _DownstreamExecutionError(original) - - -def _enable_adaptive_plugin(tmp_path, monkeypatch) -> None: - plugins_toml = tmp_path / "plugins.toml" - plugins_toml.write_text( - """ -version = 1 - -[[components]] -kind = "adaptive" -enabled = true - -[components.config.tool_parallelism] -mode = "observe_only" -""", - encoding="utf-8", - ) - monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) - - def _enable_dynamic_plugin(tmp_path, monkeypatch) -> Path: plugins_toml = tmp_path / "plugins.toml" plugins_toml.write_text( @@ -290,11 +274,6 @@ def test_manifest_fields(): "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", @@ -323,7 +302,12 @@ def test_nemo_relay_plugin_uses_nemo_relay_runtime(monkeypatch): assert any(event[0] == "scope.push" for event in fake_relay.events) -def test_nemo_relay_plugin_emits_llm_tool_and_exports_atif(tmp_path, monkeypatch): +def test_nemo_relay_plugin_exports_core_managed_llm_and_tool_events( + tmp_path, + monkeypatch, +): + from agent import relay_llm, relay_tools + fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_ENABLED", "1") @@ -338,30 +322,47 @@ def test_nemo_relay_plugin_emits_llm_tool_and_exports_atif(tmp_path, monkeypatch "telemetry_schema_version": "hermes.observer.v1", } plugin.on_session_start(**base, model="demo-model", platform="cli") - plugin.on_pre_api_request( - **base, - api_request_id="api-1", - provider="openai", - model="demo-model", - request={"method": "POST", "body": {"messages": [{"role": "user", "content": "hi"}]}}, + coordinator = relay_runtime.SESSION_COORDINATOR + lease = coordinator.acquire_conversation( + profile_key=relay_runtime.current_profile_key(), + session_id="s1", + platform="cli", ) - plugin.on_post_api_request( - **base, - api_request_id="api-1", - response={"assistant_message": {"role": "assistant", "content": "hello"}}, + turn = coordinator.begin_turn( + lease, + turn_id="turn-1", + task_id="t1", ) - plugin.on_pre_tool_call(**base, tool_name="read_file", tool_call_id="tool-1", args={"path": "x"}) - plugin.on_post_tool_call(**base, tool_name="read_file", tool_call_id="tool-1", result='{"ok": true}', status="ok") + relay_llm.execute( + {"messages": [{"role": "user", "content": "hi"}]}, + lambda request: { + "assistant_message": {"role": "assistant", "content": "hello"}, + "request": request, + }, + session_id="s1", + name="openai", + model_name="demo-model", + metadata={"api_request_id": "api-1", "api_mode": "custom"}, + ) + relay_tools.execute( + "read_file", + {"path": "x"}, + lambda _args: {"ok": True}, + session_id="s1", + metadata={"tool_call_id": "tool-1"}, + ) + coordinator.end_turn(turn, outcome="success") + coordinator.release_conversation(lease) plugin.on_session_end(**base, completed=True, interrupted=False) plugin.on_session_finalize(**base, reason="shutdown") event_names = [event[0] for event in fake.events] assert "atof.register" in event_names assert "atif.register" in event_names - assert "llm.call" in event_names - assert "llm.call_end" in event_names - assert "tool.call" in event_names - assert "tool.call_end" in event_names + assert event_names.count("llm.call") == 1 + assert event_names.count("llm.call_end") == 1 + assert event_names.count("tool.call") == 1 + assert event_names.count("tool.call_end") == 1 assert "scope.pop" in event_names assert (tmp_path / "atif" / "hermes-atif-s1.json").exists() @@ -370,6 +371,8 @@ def test_shared_metrics_and_rich_plugin_share_one_core_session( tmp_path, monkeypatch, ): + from agent import relay_llm + fake = _FakeNemoRelay() hermes_home = tmp_path / "hermes-home" monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -383,10 +386,12 @@ def test_shared_metrics_and_rich_plugin_share_one_core_session( ) plugin = _fresh_plugin(monkeypatch, fake) manager = PluginManager() - manager._hooks["on_session_start"] = [plugin.on_session_start] - manager._hooks["pre_api_request"] = [plugin.on_pre_api_request] - manager._hooks["post_api_request"] = [plugin.on_post_api_request] - manager._hooks["on_session_finalize"] = [plugin.on_session_finalize] + + class _Context: + def register_hook(self, name, callback): + manager._hooks.setdefault(name, []).append(callback) + + plugin.register(_Context()) monkeypatch.setattr(plugin_api, "_plugin_manager", manager) event = { @@ -397,18 +402,42 @@ def test_shared_metrics_and_rich_plugin_share_one_core_session( "model": "claude-sonnet", "platform": "cli", } - plugin_api.invoke_hook("on_session_start", **event) - plugin_api.invoke_hook( + coordinator = relay_runtime.SESSION_COORDINATOR + lease = coordinator.acquire_conversation( + profile_key=relay_runtime.current_profile_key(), + session_id="s1", + platform="cli", + model=event["model"], + ) + lifecycle.invoke_hook("on_session_start", **event) + turn = coordinator.begin_turn( + lease, + turn_id="turn-1", + task_id="t1", + ) + lifecycle.invoke_hook( "pre_api_request", **event, request={"body": {"messages": [{"role": "user", "content": "hi"}]}}, ) - plugin_api.invoke_hook( + relay_llm.execute( + {"messages": [{"role": "user", "content": "hi"}]}, + lambda _request: { + "assistant_message": {"role": "assistant", "content": "hello"} + }, + session_id="s1", + name="anthropic", + model_name="claude-sonnet", + metadata={"api_request_id": "api-1", "api_mode": "custom"}, + ) + lifecycle.invoke_hook( "post_api_request", **event, response={"assistant_message": {"role": "assistant", "content": "hello"}}, ) - plugin_api.invoke_hook("on_session_finalize", session_id="s1") + coordinator.end_turn(turn, outcome="success") + coordinator.release_conversation(lease) + lifecycle.finalize_session(session_id="s1") session_pushes = [ item @@ -442,38 +471,6 @@ def test_shared_metrics_and_rich_plugin_share_one_core_session( assert (tmp_path / "atif" / "hermes-atif-s1.json").exists() -def test_nemo_relay_plugin_closes_api_span_on_error(monkeypatch): - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - base = { - "session_id": "s1", - "task_id": "t1", - "turn_id": "turn-1", - "telemetry_schema_version": "hermes.observer.v1", - } - - plugin.on_pre_api_request( - **base, - api_request_id="api-err", - provider="openai", - model="demo-model", - request={"body": {"messages": [{"role": "user", "content": "hi"}]}}, - ) - plugin.on_api_request_error( - **base, - api_request_id="api-err", - error={"type": "RateLimitError", "message": "rate limited"}, - retryable=True, - reason="rate_limit", - ) - - call_end = next(event for event in fake.events if event[0] == "llm.call_end") - assert call_end[1] == ("llm", "openai") - assert call_end[2] == {"error": {"type": "RateLimitError", "message": "rate limited"}} - assert call_end[3]["data"]["reason"] == "rate_limit" - assert not plugin._get_runtime().sessions["s1"].llm_spans - - def test_nemo_relay_plugin_emits_approval_marks(monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) @@ -486,24 +483,6 @@ def test_nemo_relay_plugin_emits_approval_marks(monkeypatch): assert "hermes.approval.response" in mark_names -def test_nemo_relay_plugin_emits_unmatched_fallback_marks(monkeypatch): - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - - plugin.on_post_api_request(session_id="s1", api_request_id="missing-api", response={"ok": True}) - plugin.on_api_request_error( - session_id="s1", - api_request_id="missing-api", - error={"type": "TimeoutError", "message": "timed out"}, - ) - plugin.on_post_tool_call(session_id="s1", tool_call_id="missing-tool", result={"ok": True}) - - mark_names = [event[1] for event in fake.events if event[0] == "scope.event"] - assert "hermes.api.response.unmatched" in mark_names - assert "hermes.api.error" in mark_names - assert "hermes.tool.response.unmatched" in mark_names - - def test_nemo_relay_plugin_metadata_promotes_trajectory_and_subagent_ids(monkeypatch): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) @@ -716,6 +695,8 @@ enabled = true def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypatch): + from agent import relay_llm, relay_tools + fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) _enable_dynamic_plugin(tmp_path, monkeypatch) @@ -726,26 +707,42 @@ def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypa runtime = plugin._get_runtime() assert runtime is not None assert runtime._plugin_activation is not None - llm_result = plugin.on_llm_execution_middleware( + coordinator = relay_runtime.SESSION_COORDINATOR + lease = coordinator.acquire_conversation( + profile_key=relay_runtime.current_profile_key(), session_id="s1", - provider="openai", - model="fixture", - request={"messages": []}, - next_call=lambda request: {"request": request}, + platform="cli", + ) + turn = coordinator.begin_turn(lease, turn_id="turn-1", task_id="task-1") + llm_result = relay_llm.execute( + {"messages": []}, + lambda request: {"request": request}, + session_id="s1", + name="openai", + model_name="fixture", + metadata={"api_mode": "custom", "api_request_id": "api-1"}, ) tool_args = relay_runtime.apply_tool_request_intercepts( session_id="s1", tool_name="fixture-tool", args={"value": 1}, ) - tool_result = plugin.on_tool_execution_middleware( + tool_result, final_args = relay_tools.execute( + "fixture-tool", + tool_args, + lambda args: {"args": args}, session_id="s1", - tool_name="fixture-tool", - args=tool_args, - next_call=lambda args: {"args": args}, + metadata={"tool_call_id": "tool-1"}, ) + coordinator.end_turn(turn, outcome="success") + coordinator.release_conversation(lease) assert llm_result["request"]["intercepted"] is True assert tool_result["args"]["intercepted"] is True + assert final_args["intercepted"] is True + relay_runtime.SESSION_COORDINATOR.finalize_conversation( + profile_key=relay_runtime.current_profile_key(), + session_id="s1", + ) plugin.on_session_finalize(session_id="s1", reason="shutdown") assert runtime._plugin_activation is not None assert not any(event[0] == "plugin.activation.close" for event in fake.events) @@ -840,128 +837,6 @@ environment_ref = "../environments/worker-fixture" runtime.shutdown() -@pytest.mark.parametrize( - ("provider", "api_mode", "expected_surface", "should_rewrite"), - [ - ("custom", "chat_completions", "openai.chat_completions", True), - ("openai-codex", "codex_responses", "openai.responses", True), - ("anthropic", "anthropic_messages", "anthropic.messages", True), - ("custom", "anthropic_messages", "anthropic.messages", True), - ("bedrock", "bedrock_converse", "bedrock", False), - ], -) -def test_nemo_relay_managed_llm_uses_wire_protocol_for_interceptor_dispatch( - tmp_path, - monkeypatch, - provider, - api_mode, - expected_surface, - should_rewrite, -): - fake = _FakeNemoRelay() - supported_surfaces = { - "anthropic.messages", - "openai.chat_completions", - "openai.responses", - } - - def execute(name, request, func, **kwargs): - fake.events.append(("llm.execute.start", name, request.content, kwargs)) - content = dict(request.content) - if name in supported_surfaces: - content["rewritten_for"] = name - result = func(_FakeLLMRequest(request.headers, content)) - fake.events.append(("llm.execute.end", name, result, kwargs)) - return result - - fake.llm.execute = execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_dynamic_plugin(tmp_path, monkeypatch) - - result = plugin.on_llm_execution_middleware( - session_id="s1", - provider=provider, - api_mode=api_mode, - model="fixture", - request={"messages": [{"role": "user", "content": "hi"}]}, - next_call=lambda request: request, - ) - - execute_start = next( - event for event in fake.events if event[0] == "llm.execute.start" - ) - assert execute_start[1] == expected_surface - assert execute_start[3]["metadata"]["provider"] == provider - assert execute_start[3]["metadata"]["api_mode"] == api_mode - if should_rewrite: - assert result["rewritten_for"] == expected_surface - else: - assert "rewritten_for" not in result - - -def test_nemo_relay_managed_llm_returns_post_next_interceptor_result(tmp_path, monkeypatch): - fake = _FakeNemoRelay() - raw_response = SimpleNamespace( - model="fixture", - choices=[ - SimpleNamespace( - message=SimpleNamespace(role="assistant", content="raw", tool_calls=[]), - finish_reason="stop", - ) - ], - usage=None, - ) - - def execute(name, request, func, **kwargs): - del name, kwargs - normalized = func(_FakeLLMRequest(request.headers, request.content)) - return {**normalized, "post_next_interceptor": True} - - fake.llm.execute = execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_dynamic_plugin(tmp_path, monkeypatch) - - result = plugin.on_llm_execution_middleware( - session_id="s1", - provider="openai", - api_mode="chat_completions", - model="fixture", - request={"messages": []}, - next_call=lambda request: raw_response, - ) - - assert result["post_next_interceptor"] is True - assert result["assistant_message"]["content"] == "raw" - assert result is not raw_response - - -def test_nemo_relay_managed_tool_returns_post_interceptor_result(tmp_path, monkeypatch): - fake = _FakeNemoRelay() - - def execute(name, args, func, **kwargs): - fake.events.append(("tool.execute.start", name, args, kwargs)) - raw = func(args) - result = {"compressed": True, "raw": raw} - fake.events.append(("tool.execute.end", name, result, kwargs)) - return result - - fake.tools.execute = execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_dynamic_plugin(tmp_path, monkeypatch) - - result = plugin.on_tool_execution_middleware( - session_id="s1", - tool_name="fixture-tool", - args={"value": 1}, - next_call=lambda args: {"tool_output": args}, - ) - - assert result == { - "compressed": True, - "raw": {"tool_output": {"value": 1}}, - } - - def test_relay_tool_request_rewrite_precedes_hermes_authorization_boundary( tmp_path, monkeypatch, @@ -984,50 +859,18 @@ def test_relay_tool_request_rewrite_precedes_hermes_authorization_boundary( assert result.trace[0] == {"source": "nemo_relay"} -def test_managed_tool_refuses_post_authorization_argument_rewrite( - tmp_path, - monkeypatch, -): - fake = _FakeNemoRelay() - - def execute(name, args, func, **kwargs): - del name, kwargs - return func({**args, "after_approval": True}) - - fake.tools.execute = execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_dynamic_plugin(tmp_path, monkeypatch) - dispatched = False - - def next_call(args): - nonlocal dispatched - dispatched = True - return args - - with pytest.raises( - RuntimeError, - match="changed tool arguments after Hermes authorization", - ): - plugin.on_tool_execution_middleware( - session_id="s1", - tool_name="fixture-tool", - args={"value": 1}, - next_call=next_call, - ) - - assert not dispatched - - -def test_nemo_relay_plugin_activates_without_registering_managed_middleware( +def test_nemo_relay_plugin_activates_without_duplicate_execution_hooks( tmp_path, monkeypatch ): fake = _FakeNemoRelay() plugin = _fresh_plugin(monkeypatch, fake) _enable_dynamic_plugin(tmp_path, monkeypatch) + registered_hooks = [] class _Context: def register_hook(self, name, callback): - del name, callback + del callback + registered_hooks.append(name) def register_middleware(self, name, callback): del callback @@ -1038,6 +881,13 @@ def test_nemo_relay_plugin_activates_without_registering_managed_middleware( event_names = [event[0] for event in fake.events] assert "plugin.activate_dynamic" in event_names assert "hermes.register_middleware" not in event_names + assert not { + "pre_api_request", + "post_api_request", + "api_request_error", + "pre_tool_call", + "post_tool_call", + }.intersection(registered_hooks) runtime = plugin._get_runtime() assert runtime is not None runtime.shutdown() @@ -1422,657 +1272,3 @@ output_directory = "{(tmp_path / "managed-atof").as_posix()}" assert event_names.count("plugin.initialize.attempt") == 2 assert event_names.count("atof.register") == 1 assert event_names.count("atof.deregister") == 1 - - -def test_nemo_relay_adaptive_llm_execution_middleware_preserves_raw_response(tmp_path, monkeypatch): - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - plugins_toml = tmp_path / "plugins.toml" - plugins_toml.write_text( - """ -version = 1 - -[[components]] -kind = "adaptive" -enabled = true - -[components.config.tool_parallelism] -mode = "observe_only" -""", - encoding="utf-8", - ) - monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) - - seen_request = {} - raw_choice = SimpleNamespace( - message=SimpleNamespace( - role="assistant", - content=None, - tool_calls=[ - SimpleNamespace( - id="tool-1", - type="function", - function=SimpleNamespace(name="terminal", arguments='{"command":"pwd"}'), - ) - ], - reasoning_content="need a tool", - ), - finish_reason="tool_calls", - ) - raw_response = SimpleNamespace( - id="resp-1", - model="demo-model", - choices=[raw_choice], - usage=SimpleNamespace(prompt_tokens=3, completion_tokens=5, total_tokens=8), - ) - - def next_call(request): - seen_request.update(request) - return raw_response - - response = plugin.on_llm_execution_middleware( - session_id="s1", - task_id="t1", - turn_id="turn-1", - api_request_id="api-1", - provider="anthropic", - model="demo-model", - api_call_count=1, - request={"messages": [{"role": "user", "content": "hi"}]}, - next_call=next_call, - ) - - assert response is raw_response - assert response.model == "demo-model" - assert response.choices == [raw_choice] - assert seen_request["intercepted"] is True - execute_start = next(event for event in fake.events if event[0] == "llm.execute.start") - assert execute_start[3]["data"]["mode"] == "observe_only" - execute_end = next(event for event in fake.events if event[0] == "llm.execute.end") - assert execute_end[2] == { - "model": "demo-model", - "assistant_message": { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "tool-1", - "type": "function", - "function": {"name": "terminal", "arguments": '{"command":"pwd"}'}, - } - ], - "reasoning_content": "need a tool", - }, - "finish_reason": "tool_calls", - "usage": {"prompt_tokens": 3, "completion_tokens": 5, "total_tokens": 8}, - } - - -def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error(tmp_path, monkeypatch): - fake = _FakeNemoRelay() - - def native_like_execute(name, request, func, **kwargs): - fake.events.append(("llm.execute.start", name, request.content, kwargs)) - try: - return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) - except Exception as exc: - raise RuntimeError(f"internal error: {type(exc).__name__}: {exc}") from None - - fake.llm.execute = native_like_execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_adaptive_plugin(tmp_path, monkeypatch) - - class ProviderAuthError(Exception): - status_code = 403 - - provider_error = ProviderAuthError("provider auth failed") - - def next_call(request): - raise _wrapped_downstream_error(provider_error) - - with pytest.raises(ProviderAuthError) as caught: - plugin.on_llm_execution_middleware( - session_id="s1", - provider="anthropic", - model="demo-model", - request={"messages": [{"role": "user", "content": "hi"}]}, - next_call=next_call, - ) - - assert caught.value is provider_error - assert caught.value.status_code == 403 - - -def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error_with_relay_suffix( - tmp_path, monkeypatch -): - # Guards the startswith (vs exact ==) match in _is_relay_wrapped_callback_error: - # Relay re-wraps the callback failure with its canonical prefix but APPENDS a - # trailing suffix. Exact equality would miss this and surface Relay's wrapper; - # prefix matching must still recover the original downstream error. - fake = _FakeNemoRelay() - - def native_like_execute(name, request, func, **kwargs): - try: - return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) - except Exception as exc: - raise RuntimeError(f"internal error: {type(exc).__name__}: {exc} (retried 3x)") from None - - fake.llm.execute = native_like_execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_adaptive_plugin(tmp_path, monkeypatch) - - class ProviderAuthError(Exception): - status_code = 403 - - provider_error = ProviderAuthError("provider auth failed") - - def next_call(request): - raise _wrapped_downstream_error(provider_error) - - with pytest.raises(ProviderAuthError) as caught: - plugin.on_llm_execution_middleware( - session_id="s1", - provider="anthropic", - model="demo-model", - request={"messages": [{"role": "user", "content": "hi"}]}, - next_call=next_call, - ) - - assert caught.value is provider_error - assert caught.value.status_code == 403 - - -def test_nemo_relay_adaptive_llm_execution_keeps_unrelated_internal_error(tmp_path, monkeypatch): - fake = _FakeNemoRelay() - - relay_error = RuntimeError("internal error: relay setup failed") - - def internal_error_execute(name, request, func, **kwargs): - raise relay_error - - fake.llm.execute = internal_error_execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_adaptive_plugin(tmp_path, monkeypatch) - - with pytest.raises(RuntimeError) as caught: - plugin.on_llm_execution_middleware( - session_id="s1", - provider="anthropic", - model="demo-model", - request={"messages": [{"role": "user", "content": "hi"}]}, - next_call=lambda request: {"raw": request}, - ) - - assert caught.value is relay_error - - -def test_nemo_relay_adaptive_llm_execution_keeps_wrapped_relay_error_after_downstream_failure( - tmp_path, monkeypatch -): - fake = _FakeNemoRelay() - relay_error = RuntimeError("internal error: RuntimeError: relay policy blocked after downstream") - - def translated_execute(name, request, func, **kwargs): - try: - return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) - except Exception: - raise relay_error - - fake.llm.execute = translated_execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_adaptive_plugin(tmp_path, monkeypatch) - - def next_call(request): - raise _wrapped_downstream_error(RuntimeError("provider failed")) - - with pytest.raises(RuntimeError) as caught: - plugin.on_llm_execution_middleware( - session_id="s1", - provider="anthropic", - model="demo-model", - request={"messages": [{"role": "user", "content": "hi"}]}, - next_call=next_call, - ) - - assert caught.value is relay_error - - -def test_nemo_relay_adaptive_llm_execution_keeps_relay_translated_error(tmp_path, monkeypatch): - fake = _FakeNemoRelay() - - class RelayPolicyError(Exception): - pass - - relay_error = RelayPolicyError("relay policy blocked") - - def translated_execute(name, request, func, **kwargs): - try: - return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content})) - except Exception: - raise relay_error - - fake.llm.execute = translated_execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_adaptive_plugin(tmp_path, monkeypatch) - - provider_error = RuntimeError("provider failed") - - def next_call(request): - raise _wrapped_downstream_error(provider_error) - - with pytest.raises(RelayPolicyError) as caught: - plugin.on_llm_execution_middleware( - session_id="s1", - provider="anthropic", - model="demo-model", - request={"messages": [{"role": "user", "content": "hi"}]}, - next_call=next_call, - ) - - assert caught.value is relay_error - - -def test_nemo_relay_downstream_unwrap_matches_real_middleware_wrapper_shape(monkeypatch): - # Regression guard against core/plugin drift. The synthetic tests above model - # the downstream-error wrapper with a local class, so they keep passing even - # if core middleware renames its private ``_DownstreamExecutionError`` or drops - # ``.original`` -- the exact shape the plugin matches by name at - # ``_original_downstream_error``. Capture the wrapper the REAL - # ``hermes_cli.middleware._run_execution_chain`` hands to a middleware - # callback's ``next_call`` and assert the plugin's detector unwraps it to the - # original exception. If core middleware changes the wrapper shape, this fails - # here instead of silently defeating the unwrap in production. - from hermes_cli import middleware - - from plugins.observability.nemo_relay import _original_downstream_error - - class ProviderError(Exception): - status_code = 403 - - provider_error = ProviderError("provider auth failed") - captured: dict[str, Exception] = {} - - def terminal_call(payload): - raise provider_error - - def capturing_callback(**kwargs): - next_call = kwargs["next_call"] - try: - return next_call(kwargs.get("request")) - except Exception as exc: - captured["wrapper"] = exc - # Surface the original so the chain unwinds without re-wrapping noise. - raise _original_downstream_error(exc) from None - - with pytest.raises(ProviderError) as caught: - middleware._run_execution_chain( - "llm", - [capturing_callback], - terminal_call, - request={"messages": []}, - ) - - wrapper = captured["wrapper"] - # The wrapper the plugin sees must match what _original_downstream_error keys on. - assert wrapper.__class__.__name__ == "_DownstreamExecutionError" - assert isinstance(getattr(wrapper, "original", None), BaseException) - assert _original_downstream_error(wrapper) is provider_error - assert caught.value is provider_error - assert caught.value.status_code == 403 - - -def _adaptive_llm_execute_mode(tmp_path, monkeypatch, plugins_toml_text: str) -> str: - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - plugins_toml = tmp_path / "plugins.toml" - plugins_toml.write_text(plugins_toml_text, encoding="utf-8") - monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) - - plugin.on_llm_execution_middleware( - session_id="s1", - provider="anthropic", - model="demo-model", - request={"messages": [{"role": "user", "content": "hi"}]}, - next_call=lambda request: {"raw": request}, - ) - - execute_start = next(event for event in fake.events if event[0] == "llm.execute.start") - return execute_start[3]["data"]["mode"] - - -def test_nemo_relay_adaptive_llm_execution_middleware_defaults_to_observe_only_when_mode_is_unset( - tmp_path, monkeypatch -): - mode = _adaptive_llm_execute_mode( - tmp_path, - monkeypatch, - """ -version = 1 - -[[components]] -kind = "adaptive" -enabled = true - -[components.config] -version = 1 -""", - ) - assert mode == "observe_only" - - -def test_nemo_relay_adaptive_llm_execution_middleware_accepts_legacy_top_level_mode(tmp_path, monkeypatch): - mode = _adaptive_llm_execute_mode( - tmp_path, - monkeypatch, - """ -version = 1 - -[[components]] -kind = "adaptive" -enabled = true - -[components.config] -mode = "route" -""", - ) - assert mode == "route" - - -def test_nemo_relay_adaptive_llm_execution_middleware_prefers_tool_parallelism_mode(tmp_path, monkeypatch): - mode = _adaptive_llm_execute_mode( - tmp_path, - monkeypatch, - """ -version = 1 - -[[components]] -kind = "adaptive" -enabled = true - -[components.config] -mode = "route" - -[components.config.tool_parallelism] -mode = "schedule" -""", - ) - assert mode == "schedule" - - -def test_nemo_relay_llm_execution_middleware_calls_through_without_adaptive(monkeypatch): - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - - response = plugin.on_llm_execution_middleware( - session_id="s1", - provider="anthropic", - model="demo-model", - request={"messages": []}, - next_call=lambda request: {"raw": request}, - ) - - assert response == {"raw": {"messages": []}} - assert not any(event[0] == "llm.execute.start" for event in fake.events) - - -def test_nemo_relay_adaptive_tool_execution_middleware_preserves_raw_response(tmp_path, monkeypatch): - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - plugins_toml = tmp_path / "plugins.toml" - plugins_toml.write_text( - """ -version = 1 - -[[components]] -kind = "adaptive" -enabled = true - -[components.config.tool_parallelism] -mode = "observe_only" -""", - encoding="utf-8", - ) - monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) - - seen_args = {} - - def next_call(args): - seen_args.update(args) - return {"raw": True, "args": args} - - approved_args = relay_runtime.apply_tool_request_intercepts( - session_id="s1", - tool_name="terminal", - args={"command": "pwd"}, - ) - response = plugin.on_tool_execution_middleware( - session_id="s1", - task_id="t1", - turn_id="turn-1", - api_request_id="api-1", - tool_name="terminal", - tool_call_id="tool-1", - args=approved_args, - next_call=next_call, - ) - - assert response == {"raw": True, "args": {"command": "pwd", "intercepted": True}} - assert seen_args["intercepted"] is True - execute_start = next(event for event in fake.events if event[0] == "tool.execute.start") - assert execute_start[3]["data"]["mode"] == "observe_only" - assert execute_start[3]["data"]["tool_call_id"] == "tool-1" - - -def test_nemo_relay_adaptive_tool_execution_preserves_downstream_error(tmp_path, monkeypatch): - fake = _FakeNemoRelay() - - def native_like_execute(name, args, func, **kwargs): - fake.events.append(("tool.execute.start", name, args, kwargs)) - try: - return func(args) - except Exception as exc: - raise RuntimeError(f"internal error: {type(exc).__name__}: {exc}") from None - - fake.tools.execute = native_like_execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_adaptive_plugin(tmp_path, monkeypatch) - - class ToolAuthError(Exception): - status_code = 403 - - tool_error = ToolAuthError("tool auth failed") - - def next_call(args): - raise _wrapped_downstream_error(tool_error) - - with pytest.raises(ToolAuthError) as caught: - plugin.on_tool_execution_middleware( - session_id="s1", - tool_name="terminal", - args={"command": "pwd"}, - next_call=next_call, - ) - - assert caught.value is tool_error - assert caught.value.status_code == 403 - - -def test_nemo_relay_adaptive_tool_execution_keeps_unrelated_internal_error(tmp_path, monkeypatch): - fake = _FakeNemoRelay() - - relay_error = RuntimeError("internal error: relay setup failed") - - def internal_error_execute(name, args, func, **kwargs): - raise relay_error - - fake.tools.execute = internal_error_execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_adaptive_plugin(tmp_path, monkeypatch) - - with pytest.raises(RuntimeError) as caught: - plugin.on_tool_execution_middleware( - session_id="s1", - tool_name="terminal", - args={"command": "pwd"}, - next_call=lambda args: {"raw": args}, - ) - - assert caught.value is relay_error - - -def test_nemo_relay_adaptive_tool_execution_keeps_wrapped_relay_error_after_downstream_failure( - tmp_path, monkeypatch -): - fake = _FakeNemoRelay() - relay_error = RuntimeError("internal error: RuntimeError: relay policy blocked after downstream") - - def translated_execute(name, args, func, **kwargs): - try: - return func(args) - except Exception: - raise relay_error - - fake.tools.execute = translated_execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_adaptive_plugin(tmp_path, monkeypatch) - - def next_call(args): - raise _wrapped_downstream_error(RuntimeError("tool failed")) - - with pytest.raises(RuntimeError) as caught: - plugin.on_tool_execution_middleware( - session_id="s1", - tool_name="terminal", - args={"command": "pwd"}, - next_call=next_call, - ) - - assert caught.value is relay_error - - -def test_nemo_relay_adaptive_tool_execution_keeps_relay_translated_error(tmp_path, monkeypatch): - fake = _FakeNemoRelay() - - class RelayPolicyError(Exception): - pass - - relay_error = RelayPolicyError("relay policy blocked") - - def translated_execute(name, args, func, **kwargs): - try: - return func(args) - except Exception: - raise relay_error - - fake.tools.execute = translated_execute - plugin = _fresh_plugin(monkeypatch, fake) - _enable_adaptive_plugin(tmp_path, monkeypatch) - - tool_error = RuntimeError("tool failed") - - def next_call(args): - raise _wrapped_downstream_error(tool_error) - - with pytest.raises(RelayPolicyError) as caught: - plugin.on_tool_execution_middleware( - session_id="s1", - tool_name="terminal", - args={"command": "pwd"}, - next_call=next_call, - ) - - assert caught.value is relay_error - - -def test_nemo_relay_tool_execution_middleware_calls_through_without_adaptive(monkeypatch): - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - - response = plugin.on_tool_execution_middleware( - session_id="s1", - tool_name="terminal", - args={"command": "pwd"}, - next_call=lambda args: {"raw": args}, - ) - - assert response == {"raw": {"command": "pwd"}} - assert not any(event[0] == "tool.execute.start" for event in fake.events) - - -def test_nemo_relay_adaptive_execution_skips_duplicate_observer_spans(tmp_path, monkeypatch): - fake = _FakeNemoRelay() - plugin = _fresh_plugin(monkeypatch, fake) - plugins_toml = tmp_path / "plugins.toml" - plugins_toml.write_text( - """ -version = 1 - -[[components]] -kind = "adaptive" -enabled = true - -[components.config.tool_parallelism] -mode = "observe_only" -""", - encoding="utf-8", - ) - monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) - - base = { - "session_id": "s1", - "task_id": "t1", - "turn_id": "turn-1", - "api_request_id": "api-1", - } - plugin.on_pre_api_request( - **base, - provider="anthropic", - model="demo-model", - request={"body": {"messages": [{"role": "user", "content": "hi"}]}}, - ) - plugin.on_post_api_request(**base, response={"ok": True}) - plugin.on_pre_tool_call(**base, tool_name="terminal", tool_call_id="tool-1", args={"command": "pwd"}) - plugin.on_post_tool_call(**base, tool_name="terminal", tool_call_id="tool-1", result={"ok": True}) - - plugin.on_llm_execution_middleware( - **base, - provider="anthropic", - model="demo-model", - request={"messages": [{"role": "user", "content": "hi"}]}, - next_call=lambda request: {"raw": request}, - ) - plugin.on_tool_execution_middleware( - **base, - tool_name="terminal", - tool_call_id="tool-1", - args={"command": "pwd"}, - next_call=lambda args: {"raw": args}, - ) - - event_names = [event[0] for event in fake.events] - assert "llm.call" not in event_names - assert "llm.call_end" not in event_names - assert "tool.call" not in event_names - assert "tool.call_end" not in event_names - assert "llm.execute.start" in event_names - assert "tool.execute.start" in event_names - - -def test_nemo_relay_plugin_noops_without_dependency(monkeypatch): - monkeypatch.delitem(sys.modules, "nemo_relay", raising=False) - sys.modules.pop("plugins.observability.nemo_relay", None) - plugin = importlib.import_module("plugins.observability.nemo_relay") - plugin.reset_for_tests() - - real_import = builtins.__import__ - - def blocked_import(name, *args, **kwargs): - if name == "nemo_relay": - raise ModuleNotFoundError(f"No module named {name!r}") - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", blocked_import) - - plugin.on_pre_api_request(session_id="s1", api_request_id="api-1") - plugin.on_post_api_request(session_id="s1", api_request_id="api-1") diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 9c3077a6b4f..c1bc9fb4d7e 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2439,8 +2439,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), @@ -3185,10 +3185,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") @@ -3268,10 +3268,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") @@ -3295,10 +3295,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") @@ -3342,10 +3342,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") @@ -3380,10 +3380,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") @@ -3853,6 +3853,31 @@ 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): + 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" + 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." @@ -4323,10 +4348,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"), @@ -4365,8 +4390,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( @@ -4405,12 +4430,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"), diff --git a/tools/approval.py b/tools/approval.py index ea3bb826906..95c41896b92 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -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). diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 9a8d842a705..49ffb4980c3 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1453,7 +1453,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), @@ -2798,7 +2798,7 @@ def delegate_task( # child was closed. _parent_session_id = getattr(parent_agent, "session_id", None) try: - from hermes_cli.plugins import invoke_hook as _invoke_hook + from hermes_cli.lifecycle import invoke_hook as _invoke_hook except Exception: _invoke_hook = None # Aggregate child spend here so the parent's footer/UI reflect the true diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 57422299d0d..bf8f2b05e30 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -2799,7 +2799,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, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5f04efaabd9..4598fcad5bd 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -429,13 +429,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 @@ -614,7 +620,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", From 55b7903cf7200f958ab64728746365a0183bd0a1 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 08:59:42 -0400 Subject: [PATCH 006/526] fix(observability): harden Relay terminal lifecycle metrics Signed-off-by: Alex Fournier --- MANIFEST.in | 4 +- agent/conversation_loop.py | 1 + agent/relay_llm.py | 115 +++++----- agent/relay_runtime.py | 173 +++++++++++----- agent/relay_tools.py | 17 +- .../observability/relay_shared_metrics.py | 49 ++++- hermes_cli/observability/shared_metrics.py | 58 ++++-- .../observability/shared_metrics_contract.py | 98 +++++---- .../shared_metrics_subscriber.py | 4 +- run_agent.py | 8 + tests/agent/test_relay_llm.py | 58 ++++++ tests/hermes_cli/test_relay_shared_metrics.py | 30 +++ .../test_relay_shared_metrics_runtime.py | 196 ++++++++++++++++++ tests/run_agent/test_run_agent.py | 36 ++++ 14 files changed, 655 insertions(+), 192 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index c5c33210632..cd224167a45 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,8 +7,8 @@ graft locales # built from the sdist (e.g. Homebrew, downstream packagers). package-data # below covers the wheel; this covers the sdist. See #34034 / #28149. recursive-include plugins plugin.yaml plugin.yml -# The built-in shared-metrics package validates exported JSON against this -# closed schema. Include it when downstream packagers build from the sdist. +# Include the closed shared-metrics package schema in downstream sdists so the +# smoke test, documentation, and external validators can inspect the contract. recursive-include hermes_cli/observability/schemas *.json # Gateway assets include images plus YAML catalogs such as status_phrases.yaml. recursive-include gateway/assets * diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index c88e63f72b2..3484c40eec8 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1361,6 +1361,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 [], diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 02bde00d4d1..9d9195fb83c 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -5,12 +5,15 @@ from __future__ import annotations import asyncio import inspect import json +import logging from collections.abc import Callable, Iterator from types import SimpleNamespace from typing import Any from agent import relay_runtime +logger = logging.getLogger(__name__) + _PROVIDER_MESSAGE_EXTENSION_KEYS = frozenset( {"reasoning_content", "reasoning_details"} @@ -27,7 +30,7 @@ def execute( metadata: dict[str, Any] | None = None, ) -> Any: """Run one non-streaming physical provider attempt through Relay.""" - runtime, session, parent = _execution_context(session_id) + runtime, session, parent = relay_runtime.resolve_execution_context(session_id) if runtime is None or session is None: return callback(request) logical = _logical_parent(runtime, session, parent, metadata) @@ -95,7 +98,7 @@ async def execute_async( metadata: dict[str, Any] | None = None, ) -> Any: """Run one asynchronous physical provider attempt through Relay.""" - runtime, session, parent = _execution_context(session_id) + runtime, session, parent = relay_runtime.resolve_execution_context(session_id) if runtime is None or session is None: return await callback(request) logical = _logical_parent(runtime, session, parent, metadata) @@ -159,7 +162,7 @@ def execute_current( metadata: dict[str, Any] | None = None, ) -> Any: """Run a provider attempt under the inherited Hermes turn when present.""" - turn = relay_runtime.current_turn() + turn = relay_runtime.active_turn() if turn is None: return callback(request) return execute( @@ -181,7 +184,7 @@ async def execute_current_async( metadata: dict[str, Any] | None = None, ) -> Any: """Run an async provider attempt under the inherited turn when present.""" - turn = relay_runtime.current_turn() + turn = relay_runtime.active_turn() if turn is None: return await callback(request) return await execute_async( @@ -204,7 +207,7 @@ def stream_current( metadata: dict[str, Any] | None = None, ) -> Any: """Run a provider stream under the inherited Hermes turn when present.""" - turn = relay_runtime.current_turn() + turn = relay_runtime.active_turn() if turn is None: return stream_factory(request) return stream( @@ -281,7 +284,7 @@ class ManagedLlmStream(Iterator[Any]): self._relay_observes_chunks = False self._raw_chunks: list[tuple[Any, Any]] = [] - runtime, session, parent = _execution_context(session_id) + runtime, session, parent = relay_runtime.resolve_execution_context(session_id) if runtime is None or session is None: raw_stream = stream_factory(request) if completed_response_predicate is not None and completed_response_predicate( @@ -540,50 +543,38 @@ class AnthropicStreamAccumulator: return _namespace(merged) -def _execution_context( - session_id: str, -) -> tuple[relay_runtime.RelayRuntime | None, Any, Any]: - turn = relay_runtime.current_turn() - if turn is not None and isinstance(turn.lease.host, relay_runtime.RelayRuntime): - return turn.lease.host, turn.lease.session, turn.handle - runtime = relay_runtime.get_runtime() - if runtime is None: - 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 _logical_parent( runtime: relay_runtime.RelayRuntime, session: Any, parent: Any, metadata: dict[str, Any] | None, ) -> tuple[relay_runtime.RelayTurnContext, Any, str] | None: - turn = relay_runtime.current_turn() + turn = relay_runtime.active_turn(session.session_id) request_id = str((metadata or {}).get("api_request_id") or "") if turn is None or not request_id or turn.lease.host is not runtime: return None - with turn.logical_llm_lock: - handle = turn.logical_llm_calls.get(request_id) - if handle is None: - handle = runtime.run_in_session( - session, - runtime.relay.scope.push, - relay_runtime.LOGICAL_LLM_SCOPE, - runtime.relay.ScopeType.Function, - handle=parent, - input={}, - metadata={ - relay_runtime.RUNTIME_SCHEMA_KEY: relay_runtime.RUNTIME_SCHEMA_VERSION, - relay_runtime.RUNTIME_INSTANCE_KEY: runtime.runtime_id, - "hermes.call_role": str( - (metadata or {}).get("call_role") or "primary" - ), - }, - ) - turn.logical_llm_calls[request_id] = handle + with turn.finalize_lock: + if turn.closed: + return None + with turn.logical_llm_lock: + handle = turn.logical_llm_calls.get(request_id) + if handle is None: + handle = runtime.run_in_session( + session, + runtime.relay.scope.push, + relay_runtime.LOGICAL_LLM_SCOPE, + runtime.relay.ScopeType.Function, + handle=parent, + input={}, + metadata={ + relay_runtime.RUNTIME_SCHEMA_KEY: relay_runtime.RUNTIME_SCHEMA_VERSION, + relay_runtime.RUNTIME_INSTANCE_KEY: runtime.runtime_id, + "hermes.call_role": str( + (metadata or {}).get("call_role") or "primary" + ), + }, + ) + turn.logical_llm_calls[request_id] = handle return turn, handle, request_id @@ -598,22 +589,34 @@ def _complete_logical( lease = turn.lease if not isinstance(lease.host, relay_runtime.RelayRuntime): return - with turn.logical_llm_lock: - if turn.logical_llm_calls.get(request_id) is not handle: + with turn.finalize_lock: + with turn.logical_llm_lock: + if turn.logical_llm_calls.get(request_id) is not handle: + return + if lease.session is None: return - turn.logical_llm_calls.pop(request_id, None) - if lease.session is None: - return - lease.host.run_in_session( - lease.session, - lease.host.relay.scope.pop, - handle, - output={"outcome": outcome}, - metadata={ - relay_runtime.RUNTIME_SCHEMA_KEY: relay_runtime.RUNTIME_SCHEMA_VERSION, - relay_runtime.RUNTIME_INSTANCE_KEY: lease.host.runtime_id, - }, - ) + try: + lease.host.run_in_session( + lease.session, + lease.host.relay.scope.pop, + handle, + output={"outcome": outcome}, + metadata={ + relay_runtime.RUNTIME_SCHEMA_KEY: relay_runtime.RUNTIME_SCHEMA_VERSION, + relay_runtime.RUNTIME_INSTANCE_KEY: lease.host.runtime_id, + }, + ) + except Exception: + # The provider result is authoritative. Retain the handle so turn + # finalization can retry cleanup without changing that result. + logger.warning( + "Hermes Relay logical LLM finalization failed", + exc_info=True, + ) + return + with turn.logical_llm_lock: + if turn.logical_llm_calls.get(request_id) is handle: + turn.logical_llm_calls.pop(request_id, None) def _provider_request( diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index d9a3d11e738..89f53d8a3b2 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -125,7 +125,7 @@ class RelayRuntime: return None parent = self.ensure_session({"session_id": parent_session_id}) parent_handle = None if parent is None else parent.handle - turn = current_turn() + turn = active_turn(parent_session_id) if ( turn is not None and not turn.closed @@ -432,6 +432,10 @@ class RelayTurnContext: 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, @@ -573,55 +577,90 @@ class RelaySessionCoordinator: *, outcome: str, ) -> None: - if turn.closed: + with turn.finalize_lock: + if turn.closed: + self._reset_turn_context(turn) + return + turn.closed = True + try: + lease = turn.lease + 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: + self._reset_turn_context(turn) + + 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 - turn.closed = True - try: - lease = turn.lease - if ( - turn.handle is not None - and isinstance(lease.host, RelayRuntime) - and lease.session is not None - ): + 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: - 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: - logger.warning( - "Hermes Relay logical LLM finalization failed", - exc_info=True, - ) - 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: - if turn._token is not None: - _CURRENT_TURN.reset(turn._token) - turn._token = None + 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: @@ -650,6 +689,44 @@ def current_turn() -> RelayTurnContext | None: 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 + runtime = get_runtime() + if runtime is None: + 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, *, diff --git a/agent/relay_tools.py b/agent/relay_tools.py index 4ce4eb3c95e..a04286f28c0 100644 --- a/agent/relay_tools.py +++ b/agent/relay_tools.py @@ -20,7 +20,7 @@ def execute( 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 = _execution_context(session_id) + runtime, session, parent = relay_runtime.resolve_execution_context(session_id) if runtime is None or session is None: return callback(args), args @@ -65,21 +65,6 @@ def execute( return managed, observed_args -def _execution_context( - session_id: str, -) -> tuple[relay_runtime.RelayRuntime | None, Any, Any]: - turn = relay_runtime.current_turn() - if turn is not None and isinstance(turn.lease.host, relay_runtime.RelayRuntime): - return turn.lease.host, turn.lease.session, turn.handle - runtime = relay_runtime.get_runtime() - if runtime is None: - 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 _jsonable(value: Any) -> Any: if value is None or isinstance(value, (str, int, float, bool)): return value diff --git a/hermes_cli/observability/relay_shared_metrics.py b/hermes_cli/observability/relay_shared_metrics.py index 978fe080759..d834d63292e 100644 --- a/hermes_cli/observability/relay_shared_metrics.py +++ b/hermes_cli/observability/relay_shared_metrics.py @@ -47,11 +47,19 @@ _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 @@ -92,7 +100,7 @@ class _Runtime: self._task_creation_lock = threading.RLock() self._task_sessions_lock = threading.RLock() self._task_sessions: dict[tuple[str, str], _MetricsSession] = {} - self._turn_sessions: dict[str, _MetricsSession] = {} + self._turn_sessions: dict[tuple[str, str], _MetricsSession] = {} self._subscriber_name = f"{SUBSCRIBER_NAME}.{self.host.runtime_id}" self.subscriber = SharedMetricsSubscriber( SharedMetricsStore(), @@ -164,7 +172,7 @@ class _Runtime: return None task_context = session.relay_session.context.copy() start_fields = task_start_fields(event) - active_turn = relay_runtime.current_turn() + active_turn = relay_runtime.active_turn(session.session_id) parent_handle = session.relay_session.handle if ( active_turn is not None @@ -225,6 +233,7 @@ class _Runtime: 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: @@ -235,10 +244,22 @@ class _Runtime: if existing is not None: existing.fields = fields if task is not None: - task.retry_count += 1 + 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, @@ -262,6 +283,7 @@ class _Runtime: 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: @@ -444,10 +466,10 @@ class _Runtime: task_key = self._task_key(event) if task_key is None: return None - turn_id = str(event.get("turn_id") or "") + turn_key = self._turn_key(event) with self._task_sessions_lock: - if turn_id: - owner = self._turn_sessions.get(turn_id) + 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) @@ -462,6 +484,14 @@ class _Runtime: 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, @@ -473,7 +503,7 @@ class _Runtime: return task.turn_ids.add(turn_id) with self._task_sessions_lock: - self._turn_sessions[turn_id] = session + self._turn_sessions[(session.session_id, turn_id)] = session def _finish_model_call( self, @@ -556,8 +586,9 @@ class _Runtime: if self._task_sessions.get(task_key) is session: self._task_sessions.pop(task_key, None) for turn_id in task.turn_ids: - if self._turn_sessions.get(turn_id) is session: - self._turn_sessions.pop(turn_id, None) + 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) diff --git a/hermes_cli/observability/shared_metrics.py b/hermes_cli/observability/shared_metrics.py index 228b2393420..af618f44e09 100644 --- a/hermes_cli/observability/shared_metrics.py +++ b/hermes_cli/observability/shared_metrics.py @@ -15,16 +15,14 @@ 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" -_MODEL_CALL_METRIC = "hermes.model_call.count" -_TASK_STARTED_METRIC = "hermes.task_run.started" -_TASK_FINISHED_METRIC = "hermes.task_run.finished" -_COUNTER_METRICS = frozenset({ - _MODEL_CALL_METRIC, - _TASK_FINISHED_METRIC, - _TASK_STARTED_METRIC, -}) _STORE_SCHEMA_VERSION = "1" _BUSY_TIMEOUT_MS = 250 @@ -50,6 +48,7 @@ class SharedMetricsStore: 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( @@ -58,7 +57,7 @@ class SharedMetricsStore: hermes_version: str, ) -> None: """Increment the terminal model-call counter for the current UTC day.""" - self.record_counter(_MODEL_CALL_METRIC, dimensions, hermes_version) + self.record_counter(MODEL_CALL_METRIC, dimensions, hermes_version) def record_counter( self, @@ -67,8 +66,10 @@ class SharedMetricsStore: hermes_version: str, ) -> None: """Increment one allowlisted counter for the current UTC day.""" - if metric_name not in _COUNTER_METRICS: + 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, @@ -145,10 +146,6 @@ class SharedMetricsStore: timeout=_BUSY_TIMEOUT_MS / 1000, ) try: - try: - self.database_path.chmod(0o600) - except OSError: - pass connection.row_factory = sqlite3.Row connection.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}") with connection: @@ -164,6 +161,14 @@ class SharedMetricsStore: 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() as connection: # Serialize first-run creation and upgrades across Hermes processes. @@ -307,15 +312,7 @@ class SharedMetricsStore: "period_end": _isoformat(period_end), "generated_at": _isoformat(now), "resource": {"hermes_version": period_row["hermes_version"]}, - "metrics": [ - { - "name": row["metric_name"], - "type": "counter", - "dimensions": json.loads(row["dimensions_json"]), - "value": row["value"] - row["packaged_value"], - } - for row in rows - ], + "metrics": [self._package_metric(row) for row in rows], } payload_json = json.dumps( payload, @@ -359,6 +356,21 @@ class SharedMetricsStore: ) 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( diff --git a/hermes_cli/observability/shared_metrics_contract.py b/hermes_cli/observability/shared_metrics_contract.py index 9eac4182334..a8fefea40c5 100644 --- a/hermes_cli/observability/shared_metrics_contract.py +++ b/hermes_cli/observability/shared_metrics_contract.py @@ -14,6 +14,9 @@ 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", @@ -117,6 +120,32 @@ MODEL_FAMILIES: frozenset[str] = frozenset({ "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( @@ -145,6 +174,21 @@ _TELEMETRY_AGGREGATOR_OVERRIDES = frozenset({ _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) @@ -180,21 +224,16 @@ def model_call_dimensions(event: Any) -> dict[str, str] | None: } if not isinstance(data, dict) or set(data) != expected_fields: return None - if ( - data.get("call_role") != PRIMARY_MODEL_CALL_ROLE - or data.get("locality") not in MODEL_LOCALITIES - or data.get("model_family") not in MODEL_FAMILIES - or data.get("outcome") not in MODEL_OUTCOMES - or data.get("provider_family") not in PROVIDER_FAMILIES - ): - return None - return { - "call_role": PRIMARY_MODEL_CALL_ROLE, - "locality": data["locality"], - "model_family": data["model_family"], - "outcome": data["outcome"], - "provider_family": data["provider_family"], + 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: @@ -222,15 +261,13 @@ def task_counter(event: Any) -> tuple[str, dict[str, str]] | None: expected_fields = {"entrypoint", "execution_surface"} if not isinstance(data, dict) or set(data) != expected_fields: return None - if ( - data.get("entrypoint") not in TASK_ENTRYPOINTS - or data.get("execution_surface") not in EXECUTION_SURFACES - ): - return None - return "hermes.task_run.started", { - "entrypoint": data["entrypoint"], - "execution_surface": data["execution_surface"], + 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", @@ -249,21 +286,10 @@ def task_counter(event: Any) -> tuple[str, dict[str, str]] | None: or set(data) != expected_fields ): return None - if ( - data.get("duration_bucket") not in DURATION_BUCKETS - or data.get("end_reason") not in TASK_END_REASONS - or data.get("entrypoint") not in TASK_ENTRYPOINTS - or data.get("execution_surface") not in EXECUTION_SURFACES - or data.get("model_call_count_bucket") not in COUNT_BUCKETS - or data.get("outcome") not in TASK_OUTCOMES - or data.get("retry_count_bucket") not in COUNT_BUCKETS - or data.get("termination") not in TASK_TERMINATIONS - or data.get("tool_call_count_bucket") not in COUNT_BUCKETS - ): + dimensions = {field: data.get(field) for field in sorted(expected_fields)} + if not counter_dimensions_are_valid(TASK_FINISHED_METRIC, dimensions): return None - return "hermes.task_run.finished", { - field: data[field] for field in sorted(expected_fields) - } + return TASK_FINISHED_METRIC, dimensions def execution_surface(kwargs: dict[str, Any]) -> str: diff --git a/hermes_cli/observability/shared_metrics_subscriber.py b/hermes_cli/observability/shared_metrics_subscriber.py index 7acc61dc4cd..677308503af 100644 --- a/hermes_cli/observability/shared_metrics_subscriber.py +++ b/hermes_cli/observability/shared_metrics_subscriber.py @@ -9,7 +9,7 @@ from typing import Any from agent.relay_runtime import RUNTIME_INSTANCE_KEY from .shared_metrics import SharedMetricsStore -from .shared_metrics_contract import model_call_dimensions, task_counter +from .shared_metrics_contract import MODEL_CALL_METRIC, model_call_dimensions, task_counter logger = logging.getLogger(__name__) @@ -44,7 +44,7 @@ class SharedMetricsSubscriber: ): return dimensions = model_call_dimensions(event) - metric_name = "hermes.model_call.count" + metric_name = MODEL_CALL_METRIC if dimensions is None: task_metric = task_counter(event) if task_metric is None: diff --git a/run_agent.py b/run_agent.py index 82e6065c9fd..90251026ebd 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6424,6 +6424,10 @@ class AIAgent: relay_outcome = "cancelled" elif isinstance(exc, TimeoutError): relay_outcome = "timed_out" + relay_runtime.SESSION_COORDINATOR.finish_logical_calls( + relay_turn, + outcome=relay_outcome, + ) finish_task_run(**task_context, error=exc) raise else: @@ -6434,6 +6438,10 @@ class AIAgent: relay_outcome = "failed" else: relay_outcome = "success" + relay_runtime.SESSION_COORDINATOR.finish_logical_calls( + relay_turn, + outcome=relay_outcome, + ) finish_task_run(**task_context, result=result) return result finally: diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 84d5b8fdb49..71eb26c81b9 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextvars from types import SimpleNamespace import pytest @@ -178,6 +179,38 @@ def test_non_stream_preserves_raw_provider_response_identity(relay_turn): assert result is raw_response +def test_non_stream_result_survives_logical_scope_close_failure( + relay_turn, monkeypatch +): + relay, turn = relay_turn + original_pop = relay.scope.pop + pop_calls = 0 + + def fail_first_pop(*args, **kwargs): + nonlocal pop_calls + pop_calls += 1 + if pop_calls == 1: + raise RuntimeError("simulated logical scope close failure") + return original_pop(*args, **kwargs) + + monkeypatch.setattr(relay.scope, "pop", fail_first_pop) + raw_response = SimpleNamespace(model="test-model", content="raw") + + result = relay_llm.execute( + {"model": "test-model", "messages": []}, + lambda _request: raw_response, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={"api_mode": "custom", "api_request_id": "request-close"}, + ) + + assert result is raw_response + assert "request-close" in turn.logical_llm_calls + relay_runtime.SESSION_COORDINATOR.end_turn(turn, outcome="success") + assert turn.logical_llm_calls == {} + + @pytest.mark.asyncio async def test_async_non_stream_preserves_raw_provider_response_identity(relay_turn): _relay, _turn = relay_turn @@ -211,6 +244,31 @@ def test_current_attempt_bypasses_relay_without_an_active_turn(monkeypatch): assert result is request +def test_current_attempt_bypasses_a_closed_turn_from_a_copied_context( + relay_turn, + monkeypatch, +): + _relay, turn = relay_turn + stale_context = contextvars.copy_context() + relay_runtime.SESSION_COORDINATOR.end_turn(turn, outcome="success") + request = {"model": "test-model", "messages": []} + + def fail_execute(*_args, **_kwargs): + raise AssertionError("a closed turn must not manage later provider work") + + monkeypatch.setattr(relay_llm, "execute", fail_execute) + + result = stale_context.run( + relay_llm.execute_current, + request, + lambda value: value, + name="test-provider", + model_name="test-model", + ) + + assert result is request + + def test_non_stream_returns_post_execution_interceptor_result(relay_turn, monkeypatch): relay, _turn = relay_turn diff --git a/tests/hermes_cli/test_relay_shared_metrics.py b/tests/hermes_cli/test_relay_shared_metrics.py index 2a6ba2b2b90..8aa746cf683 100644 --- a/tests/hermes_cli/test_relay_shared_metrics.py +++ b/tests/hermes_cli/test_relay_shared_metrics.py @@ -538,6 +538,36 @@ def test_package_schema_rejects_unknown_fields(tmp_path): _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" diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index a1953508bd5..641105012b0 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -473,6 +473,31 @@ def test_session_coordinator_separates_turn_release_from_hard_finalize( assert runtime.get_session("coordinated-session") is None +def test_turn_cleanup_can_be_retried_from_its_originating_context(direct_runtime): + del direct_runtime + coordinator = relay_runtime.SESSION_COORDINATOR + lease = coordinator.acquire_conversation( + profile_key=relay_runtime.current_profile_key(), + session_id="cross-context-session", + platform="cli", + ) + turn = coordinator.begin_turn( + lease, + turn_id="turn-1", + task_id="task-1", + ) + + contextvars.Context().run(coordinator.end_turn, turn, outcome="success") + + assert turn.closed + assert relay_runtime.active_turn("cross-context-session") is None + assert relay_runtime.current_turn() is turn + + coordinator.end_turn(turn, outcome="success") + assert relay_runtime.current_turn() is None + coordinator.release_conversation(lease) + + def test_session_coordinator_prepares_subscribers_before_opening_scope( direct_runtime, ): @@ -826,6 +851,45 @@ def test_shared_metrics_isolates_same_task_id_across_sessions(direct_runtime): assert len(task_ends) == 2 +def test_shared_metrics_keys_turn_ownership_by_session(direct_runtime): + runtime = relay_shared_metrics._get_runtime() + assert runtime is not None + event_a = { + "session_id": "session-a", + "task_id": "task-a", + "turn_id": "shared-turn", + "platform": "cli", + } + event_b = { + "session_id": "session-b", + "task_id": "task-b", + "turn_id": "shared-turn", + "platform": "gateway", + } + task_a = runtime.start_task(event_a) + task_b = runtime.start_task(event_b) + + runtime.start_model_call({ + **event_a, + "api_request_id": "request-a", + "provider": "anthropic", + "model": "claude-sonnet", + }) + + session_a = runtime._session(event_a) + session_b = runtime._session(event_b) + assert task_a is not None + assert task_b is not None + assert session_a is not None + assert session_b is not None + assert "request-a" in session_a.model_calls + assert "request-a" not in session_b.model_calls + [model_start] = [ + event for event in direct_runtime.events if event[0] == "llm.call" + ] + assert model_start[3]["handle"] == task_a.handle + + def test_disabling_shared_metrics_stops_collection_and_shutdown_export( tmp_path, monkeypatch ): @@ -939,6 +1003,86 @@ def test_async_session_runner_awaits_inside_saved_relay_context(direct_runtime): assert result == session.handle +def test_active_turn_requires_matching_session_and_profile( + direct_runtime, + tmp_path, + monkeypatch, +): + del direct_runtime + coordinator = relay_runtime.SESSION_COORDINATOR + profile_key = relay_runtime.current_profile_key() + lease = coordinator.acquire_conversation( + profile_key=profile_key, + session_id="session-1", + platform="cli", + ) + turn = coordinator.begin_turn( + lease, + turn_id="turn-1", + task_id="task-1", + ) + + assert relay_runtime.active_turn("session-1") is turn + assert relay_runtime.active_turn("session-2") is None + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "other-profile")) + assert relay_runtime.active_turn("session-1") is None + + coordinator.end_turn(turn, outcome="success") + coordinator.release_conversation(lease) + assert relay_runtime.active_turn("session-1") is None + + +def test_turn_cleanup_drains_logical_calls_after_turn_scope_start_failure( + direct_runtime, +): + coordinator = relay_runtime.SESSION_COORDINATOR + profile_key = relay_runtime.current_profile_key() + lease = coordinator.acquire_conversation( + profile_key=profile_key, + session_id="session-1", + platform="cli", + ) + assert lease.session is not None + original_push = direct_runtime.scope.push + + def fail_turn_push(name, *args, **kwargs): + if name == relay_runtime.TURN_SCOPE: + raise RuntimeError("simulated turn scope failure") + return original_push(name, *args, **kwargs) + + direct_runtime.scope.push = fail_turn_push + turn = coordinator.begin_turn( + lease, + turn_id="turn-1", + task_id="task-1", + ) + direct_runtime.scope.push = original_push + assert turn.handle is None + + runtime = lease.host + logical_handle = runtime.run_in_session( + lease.session, + runtime.relay.scope.push, + relay_runtime.LOGICAL_LLM_SCOPE, + runtime.relay.ScopeType.Function, + handle=lease.session.handle, + input={}, + ) + turn.logical_llm_calls["request-1"] = logical_handle + + coordinator.end_turn(turn, outcome="failed") + coordinator.release_conversation(lease) + + logical_closes = [ + event + for event in direct_runtime.events + if event[0] == "scope.pop" and event[1] == logical_handle + ] + assert len(logical_closes) == 1 + assert turn.logical_llm_calls == {} + + def test_shared_metrics_creates_one_task_under_concurrent_access(direct_runtime): runtime = relay_shared_metrics._get_runtime() assert runtime is not None @@ -1301,6 +1445,58 @@ def test_task_terminal_counts_logical_calls_retries_and_unique_tools(direct_runt } +def test_task_terminal_counts_explicit_retry_with_new_request_id(direct_runtime): + base = { + "session_id": "s1", + "task_id": "t1", + "platform": "cli", + "provider": "nvidia", + "model": "nvidia/nemotron-3-super-120b-a12b", + } + + lifecycle.invoke_hook("pre_llm_call", **base) + lifecycle.invoke_hook( + "pre_api_request", + **base, + api_request_id="r1", + retry_count=0, + ) + lifecycle.invoke_hook( + "api_request_error", + **base, + api_request_id="r1", + retryable=True, + ) + lifecycle.invoke_hook( + "pre_api_request", + **base, + api_request_id="r2", + retry_count=1, + ) + lifecycle.invoke_hook( + "post_api_request", + **base, + api_request_id="r2", + ) + lifecycle.invoke_hook( + "on_session_end", + **base, + completed=True, + failed=False, + interrupted=False, + turn_exit_reason="text_response(stop)", + ) + lifecycle.finalize_session(session_id="s1") + + [task_end] = [ + event + for event in direct_runtime.events + if event[0] == "scope.pop" and event[1][1] == "hermes.task_run" + ] + assert task_end[2]["output"]["model_call_count_bucket"] == "2" + assert task_end[2]["output"]["retry_count_bucket"] == "1" + + def test_outer_agent_boundary_closes_early_returns_and_exceptions( direct_runtime, monkeypatch, diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index c1bc9fb4d7e..a34a22c1515 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4364,6 +4364,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) @@ -4376,6 +4377,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 From 2c8fd38f2e169cf07496780c048b1f465cd75333 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 09:00:27 -0400 Subject: [PATCH 007/526] fix(observability): defer logical LLM completion until validation Signed-off-by: Alex Fournier --- agent/chat_completion_helpers.py | 3 ++ agent/codex_runtime.py | 1 + agent/conversation_loop.py | 8 ++++ agent/relay_llm.py | 35 ++++++++++++-- tests/agent/test_relay_llm.py | 79 +++++++++++++++++++++++++++++++ tests/run_agent/test_run_agent.py | 44 +++++++++++++++++ 6 files changed, 165 insertions(+), 5 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index da1ae4c70ea..2dded16d8dd 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2418,6 +2418,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= else "primary" ), }, + defer_logical_completion=True, ) streamed_response = stream_converse_with_callbacks( {"stream": stream}, @@ -2805,6 +2806,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= else "primary" ), }, + defer_logical_completion=True, ) for chunk in stream: last_chunk_time["t"] = time.time() @@ -3220,6 +3222,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= else "primary" ), }, + defer_logical_completion=True, ) try: for event in stream: diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index e0ea0680808..65e80f1d868 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -1270,6 +1270,7 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta ), "retry_count": attempt, }, + defer_logical_completion=True, ) def _interrupt_or_superseded() -> bool: diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 3484c40eec8..aee7b7529f2 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1465,6 +1465,7 @@ def run_conversation( ), "retry_count": retry_count, }, + defer_logical_completion=True, ) from hermes_cli.middleware import run_llm_execution_middleware @@ -1745,6 +1746,13 @@ def run_conversation( ) continue # Retry the API call + from agent import relay_llm + + relay_llm.complete_logical_call( + api_request_id, + outcome="success", + ) + # Check finish_reason before proceeding if agent.api_mode == "codex_responses": status = getattr(response, "status", None) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 9d9195fb83c..65e900c1d9f 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -28,6 +28,7 @@ def execute( name: str, model_name: str, metadata: dict[str, Any] | None = None, + defer_logical_completion: bool = False, ) -> Any: """Run one non-streaming physical provider attempt through Relay.""" runtime, session, parent = relay_runtime.resolve_execution_context(session_id) @@ -81,10 +82,10 @@ def execute( raise callback_error raise - if "value" in raw_response and _json_equal(managed, raw_response["json"]): + if not defer_logical_completion: _complete_logical(logical, outcome="success") + if "value" in raw_response and _json_equal(managed, raw_response["json"]): return raw_response["value"] - _complete_logical(logical, outcome="success") return managed @@ -96,6 +97,7 @@ async def execute_async( name: str, model_name: str, metadata: dict[str, Any] | None = None, + defer_logical_completion: bool = False, ) -> Any: """Run one asynchronous physical provider attempt through Relay.""" runtime, session, parent = relay_runtime.resolve_execution_context(session_id) @@ -147,7 +149,8 @@ async def execute_async( raise callback_error raise - _complete_logical(logical, outcome="success") + if not defer_logical_completion: + _complete_logical(logical, outcome="success") if "value" in raw_response and _json_equal(managed, raw_response["json"]): return raw_response["value"] return managed @@ -160,6 +163,7 @@ def execute_current( name: str, model_name: str, metadata: dict[str, Any] | None = None, + defer_logical_completion: bool = False, ) -> Any: """Run a provider attempt under the inherited Hermes turn when present.""" turn = relay_runtime.active_turn() @@ -172,6 +176,7 @@ def execute_current( name=name, model_name=model_name, metadata=metadata, + defer_logical_completion=defer_logical_completion, ) @@ -182,6 +187,7 @@ async def execute_current_async( name: str, model_name: str, metadata: dict[str, Any] | None = None, + defer_logical_completion: bool = False, ) -> Any: """Run an async provider attempt under the inherited turn when present.""" turn = relay_runtime.active_turn() @@ -194,6 +200,7 @@ async def execute_current_async( name=name, model_name=model_name, metadata=metadata, + defer_logical_completion=defer_logical_completion, ) @@ -205,6 +212,7 @@ def stream_current( model_name: str, finalizer: Callable[[], Any], metadata: dict[str, Any] | None = None, + defer_logical_completion: bool = False, ) -> Any: """Run a provider stream under the inherited Hermes turn when present.""" turn = relay_runtime.active_turn() @@ -218,6 +226,7 @@ def stream_current( model_name=model_name, finalizer=finalizer, metadata=metadata, + defer_logical_completion=defer_logical_completion, ) @@ -235,6 +244,7 @@ def stream( accept_chunk: Callable[[Any], bool] | None = None, completed_response_predicate: Callable[[Any], bool] | None = None, metadata: dict[str, Any] | None = None, + defer_logical_completion: bool = False, ) -> "ManagedLlmStream": """Return a synchronous view of one Relay-managed provider stream.""" return ManagedLlmStream( @@ -250,6 +260,7 @@ def stream( accept_chunk=accept_chunk, completed_response_predicate=completed_response_predicate, metadata=metadata, + defer_logical_completion=defer_logical_completion, ) @@ -271,6 +282,7 @@ class ManagedLlmStream(Iterator[Any]): accept_chunk: Callable[[Any], bool] | None, completed_response_predicate: Callable[[Any], bool] | None, metadata: dict[str, Any] | None, + defer_logical_completion: bool, ) -> None: self.final_response: Any = None self._loop: asyncio.AbstractEventLoop | None = None @@ -278,6 +290,7 @@ class ManagedLlmStream(Iterator[Any]): self._closed = False self._callback_error: BaseException | None = None self._logical: tuple[relay_runtime.RelayTurnContext, Any, str] | None = None + self._defer_logical_completion = defer_logical_completion self._on_chunk = on_chunk self._chunk_adapter = chunk_adapter or _namespace self._accept_chunk = accept_chunk @@ -392,8 +405,9 @@ class ManagedLlmStream(Iterator[Any]): try: chunk = self._loop.run_until_complete(next_chunk()) except StopAsyncIteration: - _complete_logical(self._logical, outcome="success") - self._logical = None + if not self._defer_logical_completion: + _complete_logical(self._logical, outcome="success") + self._logical = None self.close() raise StopIteration from None except BaseException as exc: @@ -619,6 +633,17 @@ def _complete_logical( turn.logical_llm_calls.pop(request_id, None) +def complete_logical_call(api_request_id: str, *, outcome: str) -> None: + """Complete the active turn's logical LLM call after caller validation.""" + turn = relay_runtime.active_turn() + if turn is None or not api_request_id: + return + with turn.logical_llm_lock: + handle = turn.logical_llm_calls.get(api_request_id) + if handle is not None: + _complete_logical((turn, handle, api_request_id), outcome=outcome) + + def _provider_request( original: dict[str, Any], request: Any, diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 71eb26c81b9..a62d192ff39 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -179,6 +179,40 @@ def test_non_stream_preserves_raw_provider_response_identity(relay_turn): assert result is raw_response +def test_non_stream_defers_logical_success_and_reuses_scope_for_retry(relay_turn): + _relay, turn = relay_turn + metadata = {"api_mode": "custom", "api_request_id": "request-retry"} + + first = relay_llm.execute( + {"model": "test-model", "messages": []}, + lambda _request: {"content": "invalid"}, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata=metadata, + defer_logical_completion=True, + ) + first_handle = turn.logical_llm_calls["request-retry"] + + second = relay_llm.execute( + {"model": "test-model", "messages": []}, + lambda _request: {"content": "valid"}, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata=metadata, + defer_logical_completion=True, + ) + + assert first == {"content": "invalid"} + assert second == {"content": "valid"} + assert turn.logical_llm_calls == {"request-retry": first_handle} + + relay_llm.complete_logical_call("request-retry", outcome="success") + + assert turn.logical_llm_calls == {} + + def test_non_stream_result_survives_logical_scope_close_failure( relay_turn, monkeypatch ): @@ -230,6 +264,51 @@ async def test_async_non_stream_preserves_raw_provider_response_identity(relay_t assert result is raw_response +@pytest.mark.asyncio +async def test_async_non_stream_defers_logical_success_for_validation(relay_turn): + _relay, turn = relay_turn + + async def provider(_request): + return {"content": "pending-validation"} + + await relay_llm.execute_current_async( + {"model": "test-model", "messages": []}, + provider, + name="test-provider", + model_name="test-model", + metadata={"api_mode": "custom", "api_request_id": "request-async-defer"}, + defer_logical_completion=True, + ) + + assert "request-async-defer" in turn.logical_llm_calls + + relay_llm.complete_logical_call("request-async-defer", outcome="success") + + assert turn.logical_llm_calls == {} + + +def test_stream_defers_logical_success_for_response_validation(relay_turn): + _relay, turn = relay_turn + + stream = relay_llm.stream( + {"model": "test-model", "messages": []}, + lambda _request: iter([{"delta": "pending-validation"}]), + session_id="session-1", + name="test-provider", + model_name="test-model", + finalizer=lambda: {"content": "pending-validation"}, + metadata={"api_mode": "custom", "api_request_id": "request-stream-defer"}, + defer_logical_completion=True, + ) + + assert list(stream) == [{"delta": "pending-validation"}] + assert "request-stream-defer" in turn.logical_llm_calls + + relay_llm.complete_logical_call("request-stream-defer", outcome="success") + + assert turn.logical_llm_calls == {} + + def test_current_attempt_bypasses_relay_without_an_active_turn(monkeypatch): monkeypatch.setattr(relay_runtime, "current_turn", lambda: None) request = {"model": "test-model", "messages": []} diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index a34a22c1515..9d8762b37c9 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -6080,6 +6080,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 From 9baa8cc96c9c17cdece66ed5acec3f0861e0d607 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 09:00:50 -0400 Subject: [PATCH 008/526] test(observability): exercise shared metrics with native Relay Signed-off-by: Alex Fournier --- .../test_relay_shared_metrics_runtime.py | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index 641105012b0..36f89bccc44 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -157,6 +157,24 @@ def direct_runtime(tmp_path, monkeypatch): relay_runtime._reset_for_tests() +@pytest.fixture +def real_binding_runtime(tmp_path, monkeypatch): + relay = pytest.importorskip("nemo_relay") + if getattr(relay, "_native", None) is None: + pytest.skip("NeMo Relay native binding is unavailable on this platform") + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", + lambda: {"telemetry": {"shared_metrics": {"enabled": True}}}, + ) + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + monkeypatch.setattr(plugins, "_plugin_manager", PluginManager()) + yield relay + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + + def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_path): base = { "session_id": "sensitive-session", @@ -300,6 +318,167 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa } +def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot( + real_binding_runtime, + tmp_path, +): + assert real_binding_runtime._native is not None + prompt_canary = "real-relay-sensitive-prompt" + response_canary = "real-relay-sensitive-response" + model_canary = "gpt-real-relay-sensitive-model" + tool_canary = "real-relay-sensitive-tool-result" + + def base(index: int) -> dict[str, Any]: + return { + "session_id": f"sensitive-session-{index}", + "task_id": f"sensitive-task-{index}", + "turn_id": f"sensitive-turn-{index}", + "api_request_id": f"sensitive-request-{index}", + "platform": "cli", + "provider": "custom", + "model": model_canary, + "base_url": "http://127.0.0.1:11434/v1", + } + + success = base(1) + lifecycle.invoke_hook("on_session_start", **success) + lifecycle.invoke_hook("pre_llm_call", **success, messages=[prompt_canary]) + lifecycle.invoke_hook("pre_api_request", **success, retry_count=0) + lifecycle.invoke_hook( + "api_request_error", + **success, + retry_count=0, + retryable=True, + error={"message": prompt_canary}, + ) + lifecycle.invoke_hook("pre_api_request", **success, retry_count=1) + lifecycle.invoke_hook( + "post_tool_call", + **success, + tool_call_id="sensitive-tool-call", + tool_name="terminal", + args={"command": prompt_canary}, + result={"output": tool_canary}, + status="ok", + ) + lifecycle.invoke_hook( + "post_api_request", + **success, + retry_count=1, + response={"content": response_canary}, + ) + lifecycle.invoke_hook( + "on_session_end", + **success, + completed=True, + failed=False, + interrupted=False, + turn_exit_reason="text_response(stop)", + ) + lifecycle.finalize_session(session_id=success["session_id"]) + + failed = base(2) + lifecycle.invoke_hook("on_session_start", **failed) + lifecycle.invoke_hook("pre_llm_call", **failed, messages=[prompt_canary]) + lifecycle.invoke_hook("pre_api_request", **failed, retry_count=0) + lifecycle.invoke_hook( + "api_request_error", + **failed, + retry_count=0, + retryable=False, + error={"message": response_canary}, + ) + lifecycle.invoke_hook( + "on_session_end", + **failed, + completed=False, + failed=True, + interrupted=False, + turn_exit_reason="system_aborted", + ) + lifecycle.finalize_session(session_id=failed["session_id"]) + + cancelled = base(3) + lifecycle.invoke_hook("on_session_start", **cancelled) + lifecycle.invoke_hook("pre_llm_call", **cancelled, messages=[prompt_canary]) + lifecycle.invoke_hook("pre_api_request", **cancelled, retry_count=0) + lifecycle.invoke_hook( + "on_session_end", + **cancelled, + completed=False, + failed=False, + interrupted=True, + turn_exit_reason="interrupted_by_user", + ) + lifecycle.finalize_session(session_id=cancelled["session_id"]) + + from hermes_cli.observability.shared_metrics import SharedMetricsStore + + root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics" + store = SharedMetricsStore(root / "metrics.sqlite3", root / "outbox") + snapshot = store.counter_snapshot() + by_metric: dict[str, list[dict[str, Any]]] = {} + for counter in snapshot: + by_metric.setdefault(counter["metric_name"], []).append(counter) + + assert len(by_metric["hermes.task_run.started"]) == 1 + assert by_metric["hermes.task_run.started"][0]["value"] == 3 + assert { + counter["dimensions"]["outcome"] + for counter in by_metric["hermes.model_call.count"] + } == {"success", "failed", "cancelled"} + terminal_by_outcome = { + counter["dimensions"]["outcome"]: counter + for counter in by_metric["hermes.task_run.finished"] + } + assert set(terminal_by_outcome) == {"success", "failed", "cancelled"} + assert terminal_by_outcome["success"]["dimensions"]["retry_count_bucket"] == "1" + assert terminal_by_outcome["success"]["dimensions"]["tool_call_count_bucket"] == "1" + assert terminal_by_outcome["failed"]["dimensions"]["end_reason"] == ( + "system_aborted" + ) + assert terminal_by_outcome["cancelled"]["dimensions"]["termination"] == ( + "user_cancelled" + ) + assert all(counter["packaged_value"] == counter["value"] for counter in snapshot) + + snapshot_values = { + ( + counter["metric_name"], + tuple(sorted(counter["dimensions"].items())), + ): counter["value"] + for counter in snapshot + } + package_values: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {} + packages = sorted((root / "outbox").glob("*.json")) + assert len(packages) == 3 + package_payloads = [ + json.loads(package.read_text(encoding="utf-8")) for package in packages + ] + for package in package_payloads: + assert package["schema_version"] == "hermes.shared_metrics.v1" + for metric in package["metrics"]: + key = (metric["name"], tuple(sorted(metric["dimensions"].items()))) + package_values[key] = package_values.get(key, 0) + metric["value"] + assert package_values == snapshot_values + + serialized_analytics = json.dumps({ + "snapshot": snapshot, + "packages": package_payloads, + }) + for canary in ( + prompt_canary, + response_canary, + model_canary, + tool_canary, + "sensitive-session", + "sensitive-task", + "sensitive-request", + "sensitive-tool-call", + ): + assert canary not in serialized_analytics + + def test_direct_runtime_is_disabled_by_default(tmp_path, monkeypatch): fake = _Relay() monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) From 774bcac352cca5b97a78d14bb4044539031a5eea Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 09:23:03 -0400 Subject: [PATCH 009/526] fix(runtime): preserve native streaming responses Signed-off-by: Alex Fournier --- agent/chat_completion_helpers.py | 2 ++ agent/relay_llm.py | 6 ++++++ tests/agent/test_relay_llm.py | 2 ++ 3 files changed, 10 insertions(+) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 2dded16d8dd..427c2ea2407 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -3282,6 +3282,8 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= if agent._interrupt_requested: return None + 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) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 65e900c1d9f..644d23065fa 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -296,6 +296,7 @@ class ManagedLlmStream(Iterator[Any]): self._accept_chunk = accept_chunk self._relay_observes_chunks = False self._raw_chunks: list[tuple[Any, Any]] = [] + self.output_modified = False runtime, session, parent = relay_runtime.resolve_execution_context(session_id) if runtime is None or session is None: @@ -405,6 +406,8 @@ class ManagedLlmStream(Iterator[Any]): try: chunk = self._loop.run_until_complete(next_chunk()) except StopAsyncIteration: + if self._raw_chunks: + self.output_modified = True if not self._defer_logical_completion: _complete_logical(self._logical, outcome="success") self._logical = None @@ -423,8 +426,11 @@ class ManagedLlmStream(Iterator[Any]): self._on_chunk(chunk) for index, (encoded, raw) in enumerate(self._raw_chunks): if _json_equal(chunk, encoded): + if index > 0: + self.output_modified = True del self._raw_chunks[: index + 1] return raw + self.output_modified = True return self._chunk_adapter(chunk) def close(self) -> None: diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index a62d192ff39..41b0b47cb15 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -125,6 +125,7 @@ def test_stream_uses_rewritten_request_and_post_intercept_chunks(relay_turn): assert captured_requests[0]["temperature"] == 0.25 assert chunks[0].choices[0].delta.content == "HELLO" + assert stream.output_modified is True assert turn.logical_llm_calls == {} @@ -302,6 +303,7 @@ def test_stream_defers_logical_success_for_response_validation(relay_turn): ) assert list(stream) == [{"delta": "pending-validation"}] + assert stream.output_modified is False assert "request-stream-defer" in turn.logical_llm_calls relay_llm.complete_logical_call("request-stream-defer", outcome="success") From 044b88eb69aadc56367035cc5a93dde70881fcb5 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 09:24:52 -0400 Subject: [PATCH 010/526] fix(observability): wait for concurrent schema setup Signed-off-by: Alex Fournier --- hermes_cli/observability/shared_metrics.py | 13 +++++++--- tests/hermes_cli/test_relay_shared_metrics.py | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/hermes_cli/observability/shared_metrics.py b/hermes_cli/observability/shared_metrics.py index af618f44e09..0c4912a0a80 100644 --- a/hermes_cli/observability/shared_metrics.py +++ b/hermes_cli/observability/shared_metrics.py @@ -25,6 +25,7 @@ from .shared_metrics_contract import ( _PACKAGE_SCHEMA_VERSION = "hermes.shared_metrics.v1" _STORE_SCHEMA_VERSION = "1" _BUSY_TIMEOUT_MS = 250 +_SCHEMA_BUSY_TIMEOUT_MS = 5_000 def _utc_now() -> datetime: @@ -140,14 +141,18 @@ class SharedMetricsStore: ] @contextmanager - def _connection(self) -> Iterator[sqlite3.Connection]: + def _connection( + self, + *, + busy_timeout_ms: int = _BUSY_TIMEOUT_MS, + ) -> Iterator[sqlite3.Connection]: connection = sqlite3.connect( self.database_path, - timeout=_BUSY_TIMEOUT_MS / 1000, + timeout=busy_timeout_ms / 1000, ) try: connection.row_factory = sqlite3.Row - connection.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}") + connection.execute(f"PRAGMA busy_timeout={busy_timeout_ms}") with connection: yield connection finally: @@ -170,7 +175,7 @@ class SharedMetricsStore: pass def _ensure_schema(self) -> None: - with self._connection() as connection: + 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) diff --git a/tests/hermes_cli/test_relay_shared_metrics.py b/tests/hermes_cli/test_relay_shared_metrics.py index 8aa746cf683..6dfb11eec9c 100644 --- a/tests/hermes_cli/test_relay_shared_metrics.py +++ b/tests/hermes_cli/test_relay_shared_metrics.py @@ -8,6 +8,7 @@ import os import sqlite3 import stat import threading +import time import uuid from concurrent.futures import ThreadPoolExecutor from copy import deepcopy @@ -722,6 +723,30 @@ def test_cross_process_model_call_updates_are_transactional(tmp_path): 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" From 80ef455027b309f963221f9a8cda65ceace4bc31 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 12:13:30 -0400 Subject: [PATCH 011/526] fix(runtime): reject empty native Anthropic streams Signed-off-by: Alex Fournier --- agent/chat_completion_helpers.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 427c2ea2407..065d7d10028 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -3282,6 +3282,15 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= 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) From c73478935f339d31259f0a35dd7c303392e23f76 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 12:15:22 -0400 Subject: [PATCH 012/526] fix(runtime): fence stale Relay stream chunks Signed-off-by: Alex Fournier --- agent/chat_completion_helpers.py | 44 ++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 065d7d10028..a364a448c32 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2570,6 +2570,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. @@ -2593,7 +2594,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= 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: @@ -2754,16 +2757,35 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= _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 None or stream_writer_is_current(agent, token): - return True - logger.warning( - "Streaming attempt superseded by a newer stream; stopping " - "consumption to preserve the single-writer invariant " - "(model=%s).", - api_kwargs.get("model", "unknown"), - ) - return False + 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"), + ) + 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)] @@ -3376,7 +3398,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 From 6f9f5b9b023d59d0e251298664a34af94d6c936e Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 12:15:58 -0400 Subject: [PATCH 013/526] chore(contributors): map Relay integration author Signed-off-by: Alex Fournier --- contributors/emails/afournier@nvidia.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/afournier@nvidia.com diff --git a/contributors/emails/afournier@nvidia.com b/contributors/emails/afournier@nvidia.com new file mode 100644 index 00000000000..d4289ed4406 --- /dev/null +++ b/contributors/emails/afournier@nvidia.com @@ -0,0 +1 @@ +afourniernv From 0ddbb1f2bf2fe19749841b11e138197043f08f88 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Sun, 19 Jul 2026 13:32:48 -0400 Subject: [PATCH 014/526] docs(observability): document shared metrics config Signed-off-by: Alex Fournier --- cli-config.yaml.example | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 2049b4426a5..384b220236e 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1365,6 +1365,17 @@ display: # hooks_auto_accept: false +# ============================================================================= +# Telemetry +# ============================================================================= +# Shared metrics are disabled by default. When enabled, the current Phase 1 +# integration writes only allowlisted aggregate counters and immutable JSON +# packages under $HERMES_HOME/telemetry/shared_metrics; it does not upload them. +telemetry: + shared_metrics: + enabled: false + + # ============================================================================= # Update Behavior # ============================================================================= From a15b98f414f07fe62147117ddefba7dc1cee4e42 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Mon, 20 Jul 2026 11:01:14 -0700 Subject: [PATCH 015/526] fix(runtime): complete logical LLM calls after acceptance Signed-off-by: Alex Fournier --- agent/auxiliary_client.py | 17 ++++++ agent/conversation_loop.py | 13 ++-- tests/agent/test_auxiliary_relay.py | 92 ++++++++++++++++++++++------- tests/run_agent/test_run_agent.py | 10 ++++ 4 files changed, 103 insertions(+), 29 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index fe6536d34c2..68b7f18c9ef 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2440,6 +2440,7 @@ def _relay_sync_completion( name=provider_name, model_name=str(kwargs.get("model") or fallback_model), metadata=metadata, + defer_logical_completion=True, ) @@ -2462,6 +2463,7 @@ async def _relay_async_completion( name=provider_name, model_name=str(kwargs.get("model") or fallback_model), metadata=metadata, + defer_logical_completion=True, ) @@ -7017,6 +7019,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] @@ -7026,9 +7029,23 @@ 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() -> None: + """Close one auxiliary logical call only after Hermes accepts its response.""" + 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="success", + ) + + def _recover_aux_response_message(response: Any) -> Optional[Any]: """Synthesize chat-completions shape from Responses-style text fields. diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index aee7b7529f2..3542fb19cfd 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1746,13 +1746,6 @@ def run_conversation( ) continue # Retry the API call - from agent import relay_llm - - relay_llm.complete_logical_call( - api_request_id, - outcome="success", - ) - # Check finish_reason before proceeding if agent.api_mode == "codex_responses": status = getattr(response, "status", None) @@ -2445,6 +2438,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 diff --git a/tests/agent/test_auxiliary_relay.py b/tests/agent/test_auxiliary_relay.py index 8fc7ce168d3..a69387a3557 100644 --- a/tests/agent/test_auxiliary_relay.py +++ b/tests/agent/test_auxiliary_relay.py @@ -29,10 +29,17 @@ def relay_turn(tmp_path, monkeypatch): 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: {"request": kwargs}, + create=lambda **_kwargs: next(responses), ) ) ) @@ -42,6 +49,13 @@ def test_auxiliary_retries_share_logical_relay_identity(monkeypatch): 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): @@ -50,33 +64,46 @@ def test_auxiliary_retries_share_logical_relay_identity(monkeypatch): "test-model", "chat_completions", ) - first = auxiliary_client._relay_sync_completion( - client, - {"model": "test-model", "messages": []}, + 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, ) - second = auxiliary_client._relay_sync_completion( - client, - {"model": "test-model", "messages": []}, - ) - return first, second - first, second = run("compression") + result = run("compression") - assert first["request"]["model"] == "test-model" - assert second["request"]["model"] == "test-model" + 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 {"request": kwargs} + return SimpleNamespace( + request=kwargs, + choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))], + ) client = SimpleNamespace( chat=SimpleNamespace(completions=SimpleNamespace(create=create)) @@ -91,6 +118,13 @@ async def test_async_auxiliary_attempt_uses_inherited_relay_adapter(monkeypatch) "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): @@ -99,16 +133,23 @@ async def test_async_auxiliary_attempt_uses_inherited_relay_adapter(monkeypatch) "claude-test", "chat_completions", ) - return await auxiliary_client._relay_async_completion( - client, - {"model": "claude-test", "messages": []}, + 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 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_auxiliary_stream_uses_streaming_relay_primitive(monkeypatch): @@ -149,7 +190,11 @@ def test_auxiliary_attempt_uses_real_relay_request_intercepts(relay_turn): chat=SimpleNamespace( completions=SimpleNamespace( create=lambda **kwargs: captured_requests.append(kwargs) - or {"content": "ok"}, + or SimpleNamespace( + choices=[ + SimpleNamespace(message=SimpleNamespace(content="ok")) + ] + ), ) ) ) @@ -172,15 +217,18 @@ def test_auxiliary_attempt_uses_real_relay_request_intercepts(relay_turn): "test-model", "chat_completions", ) - return auxiliary_client._relay_sync_completion( - client, - {"model": "test-model", "messages": []}, + 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") - assert result == {"content": "ok"} + assert result.choices[0].message.content == "ok" assert captured_requests[0]["temperature"] == 0.25 assert turn.logical_llm_calls == {} diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 9d8762b37c9..56cc50c26f1 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4245,6 +4245,7 @@ class TestRunConversation: usage=None, ) hook_events = [] + logical_completions = [] def _fake_activate(reason=None): agent._fallback_index = len(agent._fallback_chain) @@ -4256,6 +4257,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"), @@ -4269,6 +4276,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) From 9ab62e8b21acef3177ccc8ea397e65d90c8a8f35 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Mon, 20 Jul 2026 11:02:28 -0700 Subject: [PATCH 016/526] fix(packaging): bound the NeMo Relay dependency Signed-off-by: Alex Fournier --- pyproject.toml | 8 ++++---- tests/test_project_metadata.py | 6 +++--- uv.lock | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b6b0986f4ff..19709f9528f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,10 +138,10 @@ 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.5 publishes - # native wheels for these targets only; keep unsupported Hermes targets - # installable without moving Relay back behind a user-selected extra. - "nemo-relay==0.5.0; (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')", + # First-party lifecycle and shared-metrics runtime. Follow the contribution + # policy's bounded pre-1.0 range while supporting Relay 0.5 and 0.6. Native + # wheels target these platforms; keep unsupported Hermes targets installable. + "nemo-relay>=0.5,<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] diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 747a2d98635..df59cff2895 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -230,17 +230,17 @@ def test_feishu_extra_includes_qrcode_for_qr_login(): assert any(dep.startswith("qrcode") for dep in feishu_extra) -def test_nemo_relay_is_a_pinned_core_dependency(): +def test_nemo_relay_is_a_bounded_core_dependency(): metadata = _load_project() relay_dependencies = [ dependency for dependency in metadata["dependencies"] - if dependency.startswith("nemo-relay==") + if dependency.startswith("nemo-relay") ] assert len(relay_dependencies) == 1 requirement = Requirement(relay_dependencies[0]) - assert str(requirement.specifier) == "==0.5.0" + assert str(requirement.specifier) == "<0.7,>=0.5" assert requirement.marker is not None assert requirement.marker.evaluate( {"sys_platform": "darwin", "platform_machine": "arm64"} diff --git a/uv.lock b/uv.lock index 0f026722f0b..dd319489247 100644 --- a/uv.lock +++ b/uv.lock @@ -1802,7 +1802,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 = "(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.5.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.5,<0.7" }, { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, { name = "openai", specifier = "==2.24.0" }, { name = "packaging", specifier = "==26.0" }, From 6efc5fe0af0b64ecdc4149276ee2ff31249227d3 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 21 Jul 2026 09:25:22 -0700 Subject: [PATCH 017/526] fix(runtime): preserve provider payloads through Relay Signed-off-by: Alex Fournier --- agent/relay_llm.py | 10 +- agent/relay_runtime.py | 33 +++++ agent/relay_tools.py | 2 +- docs/observability/relay-shared-metrics.md | 4 + .../observability/relay_shared_metrics.py | 3 + plugins/observability/nemo_relay/README.md | 14 +- plugins/observability/nemo_relay/__init__.py | 24 ++++ pyproject.toml | 8 +- tests/agent/test_relay_llm.py | 130 ++++++++++++++++++ tests/agent/test_relay_tools.py | 56 ++++++++ tests/test_project_metadata.py | 2 +- uv.lock | 14 +- 12 files changed, 274 insertions(+), 26 deletions(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 644d23065fa..d227008c361 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -32,7 +32,7 @@ def execute( ) -> Any: """Run one non-streaming physical provider attempt through Relay.""" runtime, session, parent = relay_runtime.resolve_execution_context(session_id) - if runtime is None or session is None: + if runtime is None or session is None or not runtime.managed_execution_enabled(): return callback(request) logical = _logical_parent(runtime, session, parent, metadata) parent = logical[1] if logical is not None else parent @@ -101,7 +101,7 @@ async def execute_async( ) -> Any: """Run one asynchronous physical provider attempt through Relay.""" runtime, session, parent = relay_runtime.resolve_execution_context(session_id) - if runtime is None or session is None: + if runtime is None or session is None or not runtime.managed_execution_enabled(): return await callback(request) logical = _logical_parent(runtime, session, parent, metadata) parent = logical[1] if logical is not None else parent @@ -299,7 +299,11 @@ class ManagedLlmStream(Iterator[Any]): self.output_modified = False runtime, session, parent = relay_runtime.resolve_execution_context(session_id) - if runtime is None or session is None: + if ( + runtime is None + or session is None + or not runtime.managed_execution_enabled() + ): raw_stream = stream_factory(request) if completed_response_predicate is not None and completed_response_predicate( raw_stream diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 89f53d8a3b2..080f5161ddb 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -48,9 +48,28 @@ class RelayRuntime: 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 an active interceptor or subscriber needs the pipeline.""" + with self._execution_consumers_lock: + return bool(self._execution_consumers) + def ensure_session( self, event: dict[str, Any], @@ -247,6 +266,8 @@ class RelayRuntime: 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", @@ -354,6 +375,18 @@ class NoopRelayRuntime: 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.""" diff --git a/agent/relay_tools.py b/agent/relay_tools.py index a04286f28c0..5c93c7b2bdf 100644 --- a/agent/relay_tools.py +++ b/agent/relay_tools.py @@ -21,7 +21,7 @@ def execute( ) -> 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: + if runtime is None or session is None or not runtime.managed_execution_enabled(): return callback(args), args observed_args = args diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md index 2e997412353..60709c8c640 100644 --- a/docs/observability/relay-shared-metrics.md +++ b/docs/observability/relay-shared-metrics.md @@ -9,6 +9,10 @@ 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 RC 3 or later. That release establishes the +lossless provider-codec contract used for Anthropic Messages, OpenAI Chat +Completions, and OpenAI Responses requests. + Collection remains off unless Hermes policy enables it: ```yaml diff --git a/hermes_cli/observability/relay_shared_metrics.py b/hermes_cli/observability/relay_shared_metrics.py index d834d63292e..2108cf454c9 100644 --- a/hermes_cli/observability/relay_shared_metrics.py +++ b/hermes_cli/observability/relay_shared_metrics.py @@ -108,6 +108,7 @@ class _Runtime: 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) @@ -401,6 +402,7 @@ class _Runtime: 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) @@ -414,6 +416,7 @@ class _Runtime: 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()) diff --git a/plugins/observability/nemo_relay/README.md b/plugins/observability/nemo_relay/README.md index 3f222e0383c..8dd838908a6 100644 --- a/plugins/observability/nemo_relay/README.md +++ b/plugins/observability/nemo_relay/README.md @@ -188,8 +188,8 @@ 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: @@ -238,12 +238,6 @@ 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). @@ -492,8 +486,8 @@ produces one Relay lifecycle. 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 diff --git a/plugins/observability/nemo_relay/__init__.py b/plugins/observability/nemo_relay/__init__.py index c173aa1a35a..5e1fbb90586 100644 --- a/plugins/observability/nemo_relay/__init__.py +++ b/plugins/observability/nemo_relay/__init__.py @@ -75,12 +75,30 @@ class _Runtime: self.subagent_contexts: dict[str, _SubagentContext] = {} self.atof_exporter: Any = None self._atof_subscriber_name = f"hermes.nemo_relay.atof.{self.host.runtime_id}" + self._execution_consumer_name = ( + f"hermes.nemo_relay.rich_observability.{self.host.runtime_id}" + ) + self._execution_consumer_retained = False self._plugin_activation: Any = None self._shutdown_registered = False self._plugin_config_initialized = self._configure_plugins_toml() self._plugin_config_needs_reinit = False if not self._plugin_config_initialized: self._activate_direct_fallbacks() + self._sync_managed_execution() + + def _sync_managed_execution(self) -> None: + required = bool( + self._plugin_config_initialized + or self.atof_exporter is not None + or self.settings.atif_enabled + ) + if required and not self._execution_consumer_retained: + self.host.retain_managed_execution(self._execution_consumer_name) + self._execution_consumer_retained = True + elif not required and self._execution_consumer_retained: + self.host.release_managed_execution(self._execution_consumer_name) + self._execution_consumer_retained = False def _configure_plugins_toml(self) -> bool: if not self.settings.plugins_config: @@ -179,9 +197,11 @@ class _Runtime: self._plugin_config_initialized = self._configure_plugins_toml() if not self._plugin_config_initialized: self._activate_direct_fallbacks() + self._sync_managed_execution() return self._clear_atof() self._plugin_config_needs_reinit = False + self._sync_managed_execution() def _plugins_toml_owns_exporter(self, exporter_name: str) -> bool: return self._plugin_config_initialized and _observability_exporter_enabled( @@ -233,6 +253,7 @@ class _Runtime: except Exception: logger.debug("NeMo Relay ATOF deregister failed", exc_info=True) self.atof_exporter = None + self._sync_managed_execution() def prepare_session(self, kwargs: dict[str, Any]) -> _SessionState: """Register per-session subscribers without opening the core scope.""" @@ -380,6 +401,9 @@ class _Runtime: except Exception as exc: failures.append(f"plugin runtime close failed: {exc}") self._clear_atof() + if self._execution_consumer_retained: + self.host.release_managed_execution(self._execution_consumer_name) + self._execution_consumer_retained = False if self._shutdown_registered and self._plugin_activation is None: atexit.unregister(self.shutdown) self._shutdown_registered = False diff --git a/pyproject.toml b/pyproject.toml index 19709f9528f..04ce26417b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,10 +138,10 @@ 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. Follow the contribution - # policy's bounded pre-1.0 range while supporting Relay 0.5 and 0.6. Native - # wheels target these platforms; keep unsupported Hermes targets installable. - "nemo-relay>=0.5,<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')", + # First-party lifecycle and shared-metrics runtime. Relay 0.6 RC 3 is the + # minimum lossless provider-codec contract; final 0.6 releases also satisfy + # this bounded pre-1.0 range. Native wheels target these platforms. + "nemo-relay>=0.6.0rc3,<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] diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 41b0b47cb15..63b60bc0643 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextvars +import json from types import SimpleNamespace import pytest @@ -24,9 +25,11 @@ def relay_turn(tmp_path, monkeypatch): turn_id="turn-1", task_id="task-1", ) + lease.host.retain_managed_execution("test.relay_llm") try: yield lease.host.relay, turn finally: + lease.host.release_managed_execution("test.relay_llm") relay_runtime.SESSION_COORDINATOR.end_turn(turn, outcome="success") relay_runtime.SESSION_COORDINATOR.release_conversation(lease) relay_runtime._reset_for_tests() @@ -325,6 +328,133 @@ def test_current_attempt_bypasses_relay_without_an_active_turn(monkeypatch): assert result is request +def test_non_stream_bypasses_relay_without_an_active_consumer(relay_turn, monkeypatch): + relay, turn = relay_turn + turn.lease.host.release_managed_execution("test.relay_llm") + request = {"model": "test-model", "messages": [{"role": "user", "content": "hi"}]} + + monkeypatch.setattr( + relay.llm, + "execute", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("inactive Relay must not manage the provider call") + ), + ) + + result = relay_llm.execute( + request, + lambda value: value, + session_id="session-1", + name="test-provider", + model_name="test-model", + ) + + assert result is request + + +def test_stream_bypasses_relay_without_an_active_consumer(relay_turn, monkeypatch): + relay, turn = relay_turn + turn.lease.host.release_managed_execution("test.relay_llm") + request = {"model": "test-model", "messages": [{"role": "user", "content": "hi"}]} + observed = [] + + monkeypatch.setattr( + relay.llm, + "stream_execute", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("inactive Relay must not manage the provider stream") + ), + ) + + stream = relay_llm.stream( + request, + lambda value: (observed.append(value), iter([{"delta": "ok"}]))[1], + session_id="session-1", + name="test-provider", + model_name="test-model", + finalizer=dict, + ) + + assert list(stream) == [{"delta": "ok"}] + assert observed == [request] + + +def test_anthropic_codec_preserves_tool_history_and_cached_system_blocks(relay_turn): + _relay, _turn = relay_turn + request = { + "model": "claude-sonnet-4-5", + "max_tokens": 512, + "system": [ + { + "type": "text", + "text": "You are Hermes.", + "cache_control": {"type": "ephemeral"}, + } + ], + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "Run pwd"}]}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01", + "name": "terminal", + "input": {"command": "pwd"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01", + "content": [{"type": "text", "text": "/tmp/worktree"}], + } + ], + }, + ], + } + original_wire = json.dumps(request, ensure_ascii=False, separators=(",", ":")) + observed_body_wire = "" + + def provider(final_request): + nonlocal observed_body_wire + provider_body = { + key: value for key, value in final_request.items() if key != "extra_headers" + } + observed_body_wire = json.dumps( + provider_body, + ensure_ascii=False, + separators=(",", ":"), + ) + return { + "id": "msg_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "Done"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 1}, + } + + relay_llm.execute( + request, + provider, + session_id="session-1", + name="anthropic", + model_name="claude-sonnet-4-5", + metadata={ + "api_mode": "anthropic_messages", + "api_request_id": "request-anthropic", + }, + ) + + assert observed_body_wire == original_wire + + def test_current_attempt_bypasses_a_closed_turn_from_a_copied_context( relay_turn, monkeypatch, diff --git a/tests/agent/test_relay_tools.py b/tests/agent/test_relay_tools.py index f352d0b8497..36ca4322ff0 100644 --- a/tests/agent/test_relay_tools.py +++ b/tests/agent/test_relay_tools.py @@ -21,14 +21,70 @@ def relay_turn(tmp_path, monkeypatch): 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 = [] diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index df59cff2895..6ba5e4809ac 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -240,7 +240,7 @@ def test_nemo_relay_is_a_bounded_core_dependency(): ] assert len(relay_dependencies) == 1 requirement = Requirement(relay_dependencies[0]) - assert str(requirement.specifier) == "<0.7,>=0.5" + assert str(requirement.specifier) == "<0.7,>=0.6.0rc3" assert requirement.marker is not None assert requirement.marker.evaluate( {"sys_platform": "darwin", "platform_machine": "arm64"} diff --git a/uv.lock b/uv.lock index dd319489247..aa902286c3b 100644 --- a/uv.lock +++ b/uv.lock @@ -1802,7 +1802,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 = "(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.5,<0.7" }, + { 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.0rc3,<0.7" }, { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, { name = "openai", specifier = "==2.24.0" }, { name = "packaging", specifier = "==26.0" }, @@ -2606,14 +2606,14 @@ wheels = [ [[package]] name = "nemo-relay" -version = "0.5.0" +version = "0.6.0rc3" 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/d4/6a/abc154857deb7a62f21ed0eef0c989941690512597cb44928c5100d2858c/nemo_relay-0.6.0rc3-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:733a50ac46ae091434a729d9a590874379ee6a6acff4ef931dc7856c5c96aba4", size = 9895763, upload-time = "2026-07-21T06:07:48.838Z" }, + { url = "https://files.pythonhosted.org/packages/41/61/da06f03a0dda13252719f235b4b4ca79f3b0cc27ac62a86c6f3c7297002e/nemo_relay-0.6.0rc3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7742c5348bbe71756441019a1063e4969060202fab4d1cba142a63a18c23d252", size = 8882012, upload-time = "2026-07-21T06:07:51.705Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/7fc4a6bf7f5f27305e73790b7eb351bc321d2dd9f9af3e01d3a5f40eadfe/nemo_relay-0.6.0rc3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98cff44f844f3bd45824b2a6ecc4fd571b521e94ec6b7fae1aca0b02c5384d89", size = 9322992, upload-time = "2026-07-21T06:07:53.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/12/57b0a58f578065226c513c0229ee78c26b3efd0ed6fa46c8d6ee2b949d94/nemo_relay-0.6.0rc3-cp311-abi3-win_amd64.whl", hash = "sha256:c8ad58c6cf05c74b667b1108609c2423bb00244b64a318a153b4aba91abf2fed", size = 9587358, upload-time = "2026-07-21T06:07:56.087Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f9/63e41a74d3f2a493730158de0c2103f5c988f602d1f844f5daeed456078d/nemo_relay-0.6.0rc3-cp311-abi3-win_arm64.whl", hash = "sha256:934078a342c21bd94f3418f1551487e16616de09703229326d86341de0407187", size = 9019017, upload-time = "2026-07-21T06:07:58.072Z" }, ] [[package]] From df892a08f63d36917eda793f567416b1041bb552 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 21 Jul 2026 09:28:47 -0700 Subject: [PATCH 018/526] test(runtime): retain direct Relay interceptor coverage Signed-off-by: Alex Fournier --- agent/relay_runtime.py | 2 +- plugins/observability/nemo_relay/README.md | 8 +++++--- tests/agent/test_auxiliary_relay.py | 3 +++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 080f5161ddb..0b1c341d34c 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -66,7 +66,7 @@ class RelayRuntime: self._execution_consumers.discard(consumer) def managed_execution_enabled(self) -> bool: - """Return whether an active interceptor or subscriber needs the pipeline.""" + """Return whether a Hermes-managed consumer needs the Relay pipeline.""" with self._execution_consumers_lock: return bool(self._execution_consumers) diff --git a/plugins/observability/nemo_relay/README.md b/plugins/observability/nemo_relay/README.md index 8dd838908a6..85f7d039031 100644 --- a/plugins/observability/nemo_relay/README.md +++ b/plugins/observability/nemo_relay/README.md @@ -443,9 +443,11 @@ separate trajectories. ## Adaptive Execution Example -Hermes core always hands LLM and tool calls to NeMo Relay managed execution. -The `observability/nemo_relay` plugin can install adaptive components on those -boundaries. +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`: diff --git a/tests/agent/test_auxiliary_relay.py b/tests/agent/test_auxiliary_relay.py index a69387a3557..0c9e8cc5138 100644 --- a/tests/agent/test_auxiliary_relay.py +++ b/tests/agent/test_auxiliary_relay.py @@ -185,6 +185,8 @@ def test_auxiliary_stream_uses_streaming_relay_primitive(monkeypatch): 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( @@ -228,6 +230,7 @@ def test_auxiliary_attempt_uses_real_relay_request_intercepts(relay_turn): 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 From 82d923ad5ac10745ff97f7e283af8110327cbf8e Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 21 Jul 2026 09:39:24 -0700 Subject: [PATCH 019/526] fix(runtime): close failed auxiliary Relay calls Signed-off-by: Alex Fournier --- agent/auxiliary_client.py | 23 +++++- tests/agent/test_auxiliary_relay.py | 106 ++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 68b7f18c9ef..a15072ce5b9 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2359,6 +2359,9 @@ def _relay_auxiliary_call(callback): }) try: return callback(*args, **kwargs) + except BaseException: + _fail_relay_auxiliary_call() + raise finally: _RELAY_AUX_CALL_CONTEXT.reset(token) @@ -2381,6 +2384,9 @@ def _relay_auxiliary_call_async(callback): }) try: return await callback(*args, **kwargs) + except BaseException: + _fail_relay_auxiliary_call() + raise finally: _RELAY_AUX_CALL_CONTEXT.reset(token) @@ -7033,8 +7039,8 @@ def _validate_llm_response( return response -def _complete_relay_auxiliary_call() -> None: - """Close one auxiliary logical call only after Hermes accepts its 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 @@ -7042,10 +7048,21 @@ def _complete_relay_auxiliary_call() -> None: relay_llm.complete_logical_call( str(context.get("request_id") or ""), - outcome="success", + 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. diff --git a/tests/agent/test_auxiliary_relay.py b/tests/agent/test_auxiliary_relay.py index 0c9e8cc5138..de3d7f247c6 100644 --- a/tests/agent/test_auxiliary_relay.py +++ b/tests/agent/test_auxiliary_relay.py @@ -152,6 +152,112 @@ async def test_async_auxiliary_attempt_uses_inherited_relay_adapter(monkeypatch) ] +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"}]) From 27c7c877c56a153f73be527ab923f7b6a3c7fb71 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 21 Jul 2026 14:40:19 -0700 Subject: [PATCH 020/526] fix(runtime): close failed auxiliary Relay streams Signed-off-by: Alex Fournier --- agent/relay_llm.py | 3 + tests/agent/test_auxiliary_relay.py | 100 ++++++++++++++++++++++++++++ tests/agent/test_relay_llm.py | 5 +- 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index d227008c361..6d018d462fa 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -420,6 +420,9 @@ class ManagedLlmStream(Iterator[Any]): except BaseException as exc: callback_error = self._callback_error self.close() + if not self._defer_logical_completion: + _complete_logical(self._logical, outcome="failed") + self._logical = None if ( callback_error is not None and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error) diff --git a/tests/agent/test_auxiliary_relay.py b/tests/agent/test_auxiliary_relay.py index de3d7f247c6..8866167b42a 100644 --- a/tests/agent/test_auxiliary_relay.py +++ b/tests/agent/test_auxiliary_relay.py @@ -289,6 +289,106 @@ def test_auxiliary_stream_uses_streaming_relay_primitive(monkeypatch): 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" diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 63b60bc0643..40f9cb8372e 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -132,7 +132,9 @@ def test_stream_uses_rewritten_request_and_post_intercept_chunks(relay_turn): assert turn.logical_llm_calls == {} -def test_stream_preserves_provider_error_and_keeps_logical_scope_for_retry(relay_turn): +def test_deferred_stream_preserves_provider_error_and_logical_scope_for_retry( + relay_turn, +): _relay, turn = relay_turn class ProviderError(Exception): @@ -158,6 +160,7 @@ def test_stream_preserves_provider_error_and_keeps_logical_scope_for_retry(relay "api_mode": "chat_completions", "api_request_id": "request-2", }, + defer_logical_completion=True, ) with pytest.raises(ProviderError) as caught: From 70caf3020365fb4829088dddd5de28bb8ddf61e5 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 22 Jul 2026 07:22:21 -0700 Subject: [PATCH 021/526] fix(observability): coordinate Relay plugin lifecycle Signed-off-by: Alex Fournier --- plugins/observability/nemo_relay/__init__.py | 233 +++++++++++++------ tests/plugins/test_nemo_relay_plugin.py | 192 ++++++++++++++- 2 files changed, 352 insertions(+), 73 deletions(-) diff --git a/plugins/observability/nemo_relay/__init__.py b/plugins/observability/nemo_relay/__init__.py index 5e1fbb90586..13218ed5e4a 100644 --- a/plugins/observability/nemo_relay/__init__.py +++ b/plugins/observability/nemo_relay/__init__.py @@ -60,6 +60,140 @@ class _Settings: atif_model_name: str = "unknown" +class _ProcessPluginConfiguration: + """Own Relay's process-global plugin configuration across profile runtimes.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self._key: str | None = None + self._plugin_mod: Any = None + self._activation: Any = None + self._owners: set[int] = set() + + def acquire( + self, + owner: "_Runtime", + plugin_mod: Any, + plugin_config: dict[str, Any], + dynamic_plugins: list[dict[str, Any]], + ) -> tuple[bool, Any]: + owner_id = id(owner) + key = _plugin_configuration_key(plugin_config, dynamic_plugins) + with self._lock: + if owner_id in self._owners: + return True, self._activation + if self._owners: + if self._plugin_mod is plugin_mod and self._key == key: + self._owners.add(owner_id) + return True, self._activation + logger.warning( + "NeMo Relay plugin configuration is already active for another " + "Hermes profile; keeping the existing process-global configuration " + "and using direct observability for this profile." + ) + return False, None + + activation = None + if dynamic_plugins: + initialize_dynamic = getattr( + plugin_mod, + "initialize_with_dynamic_plugins", + None, + ) + if callable(initialize_dynamic): + try: + activation = _resolve_awaitable( + initialize_dynamic(plugin_config, dynamic_plugins) + ) + except Exception as exc: + logger.warning( + "NeMo Relay dynamic plugin activation failed; continuing " + "with static observability only: %s", + exc, + ) + else: + logger.warning( + "NeMo Relay dynamic plugins require a binding that exposes " + "plugin.initialize_with_dynamic_plugins (available in NeMo " + "Relay 0.6+). Continuing with static observability only." + ) + + if activation is None: + initialize = getattr(plugin_mod, "initialize", None) + if not callable(initialize): + return False, None + try: + _resolve_awaitable(initialize(plugin_config)) + except Exception as exc: + logger.debug( + "NeMo Relay plugins.toml init failed: %s", + exc, + exc_info=True, + ) + return False, None + + self._key = key + self._plugin_mod = plugin_mod + self._activation = activation + self._owners.add(owner_id) + return True, activation + + def release(self, owner: "_Runtime", nemo_relay: Any) -> None: + owner_id = id(owner) + with self._lock: + if owner_id not in self._owners: + return + self._owners.remove(owner_id) + if self._owners: + return + + failures: list[str] = [] + activation = self._activation + plugin_mod = self._plugin_mod + try: + if activation is not None: + try: + _flush_relay_subscribers(nemo_relay) + except Exception as exc: + failures.append(f"subscriber flush failed: {exc}") + close = getattr(activation, "close", None) + if callable(close): + try: + _resolve_awaitable(close()) + except Exception as exc: + failures.append( + f"dynamic plugin activation close failed: {exc}" + ) + else: + failures.append("dynamic plugin activation has no close method") + else: + clear = getattr(plugin_mod, "clear", None) + if callable(clear): + try: + _resolve_awaitable(clear()) + except Exception as exc: + failures.append( + f"static plugin configuration clear failed: {exc}" + ) + finally: + self._key = None + self._plugin_mod = None + self._activation = None + + if failures: + raise RuntimeError("; ".join(failures)) + + def reset_for_tests(self) -> None: + with self._lock: + self._key = None + self._plugin_mod = None + self._activation = None + self._owners.clear() + + +_PLUGIN_CONFIGURATION = _ProcessPluginConfiguration() + + class _Runtime: def __init__( self, @@ -107,38 +241,17 @@ class _Runtime: if plugin_mod is None: return False plugin_config = _static_plugin_config(self.settings.plugins_config) - if self.settings.dynamic_plugins: - activate_dynamic = getattr(plugin_mod, "activate_dynamic_plugins", None) - if callable(activate_dynamic): - try: - self._ensure_plugin_config_output_dirs(plugin_config) - self._plugin_activation = _resolve_awaitable( - activate_dynamic(plugin_config, self.settings.dynamic_plugins) - ) - self._ensure_shutdown_registered() - return True - except Exception as exc: - logger.warning( - "NeMo Relay dynamic plugin activation failed; continuing with static " - "observability only: %s", - exc, - ) - else: - logger.warning( - "NeMo Relay dynamic plugins require a binding that exposes " - "plugin.activate_dynamic_plugins (available in NeMo Relay 0.6+). " - "Continuing with static observability only." - ) - initialize = getattr(plugin_mod, "initialize", None) - if not callable(initialize): - return False - try: - self._ensure_plugin_config_output_dirs(plugin_config) - _resolve_awaitable(initialize(plugin_config)) - return True - except Exception as exc: - logger.debug("NeMo Relay plugins.toml init failed: %s", exc, exc_info=True) - return False + self._ensure_plugin_config_output_dirs(plugin_config) + initialized, activation = _PLUGIN_CONFIGURATION.acquire( + self, + plugin_mod, + plugin_config, + self.settings.dynamic_plugins, + ) + self._plugin_activation = activation + if activation is not None: + self._ensure_shutdown_registered() + return initialized def _ensure_shutdown_registered(self) -> None: if self._shutdown_registered: @@ -149,43 +262,12 @@ class _Runtime: def _clear_plugins_toml(self) -> None: if not self._plugin_config_initialized: return - failures: list[str] = [] - if self._plugin_activation is not None: - activation = self._plugin_activation - try: - _flush_relay_subscribers(self.nemo_relay) - except Exception as exc: - failures.append(f"subscriber flush failed: {exc}") - - close = getattr(activation, "close", None) - if callable(close): - try: - _resolve_awaitable(close()) - except Exception as exc: - failures.append(f"dynamic plugin activation close failed: {exc}") - finally: - # Retain the owned activation through the complete close - # attempt. The binding transitions it to a terminal state - # before its awaitable resolves, including error results. - self._plugin_activation = None - self._plugin_config_initialized = False - self._plugin_config_needs_reinit = bool(self.settings.plugins_config) - else: - failures.append("dynamic plugin activation has no close method") - else: - try: - plugin_mod = getattr(self.nemo_relay, "plugin", None) - clear = getattr(plugin_mod, "clear", None) - if callable(clear): - _resolve_awaitable(clear()) - except Exception as exc: - failures.append(f"static plugin configuration clear failed: {exc}") - finally: - self._plugin_config_initialized = False - self._plugin_config_needs_reinit = bool(self.settings.plugins_config) - - if failures: - raise RuntimeError("; ".join(failures)) + try: + _PLUGIN_CONFIGURATION.release(self, self.nemo_relay) + finally: + self._plugin_activation = None + self._plugin_config_initialized = False + self._plugin_config_needs_reinit = bool(self.settings.plugins_config) def _activate_direct_fallbacks(self) -> None: self._plugin_config_needs_reinit = False @@ -610,6 +692,18 @@ def _static_plugin_config(plugins_config: dict[str, Any]) -> dict[str, Any]: } +def _plugin_configuration_key( + plugin_config: dict[str, Any], + dynamic_plugins: list[dict[str, Any]], +) -> str: + return json.dumps( + {"config": plugin_config, "dynamic_plugins": dynamic_plugins}, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + + def _dynamic_plugin_specs( plugins_config: dict[str, Any] | None, plugins_toml_path: str = "", @@ -926,3 +1020,4 @@ def reset_for_tests() -> None: for runtime in runtimes: if isinstance(runtime, _Runtime): runtime.shutdown() + _PLUGIN_CONFIGURATION.reset_for_tests() diff --git a/tests/plugins/test_nemo_relay_plugin.py b/tests/plugins/test_nemo_relay_plugin.py index fce510b7c2b..0dc4683d94b 100644 --- a/tests/plugins/test_nemo_relay_plugin.py +++ b/tests/plugins/test_nemo_relay_plugin.py @@ -53,7 +53,7 @@ class _FakeNemoRelay: self.plugin = SimpleNamespace( initialize=self._plugin_initialize, clear=self._plugin_clear, - activate_dynamic_plugins=self._plugin_activate_dynamic, + initialize_with_dynamic_plugins=self._plugin_initialize_with_dynamic, ) self.subscribers = SimpleNamespace( register=self._register_subscriber, @@ -159,7 +159,7 @@ class _FakeNemoRelay: async def _plugin_clear(self): self.events.append(("plugin.clear",)) - async def _plugin_activate_dynamic(self, config, dynamic_plugins): + async def _plugin_initialize_with_dynamic(self, config, dynamic_plugins): self.events.append(("plugin.activate_dynamic", config, dynamic_plugins)) return _FakePluginActivation(self.events) @@ -770,6 +770,190 @@ def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypa assert event_names.index("atif.deregister") < event_names.index("plugin.activation.close") +def test_nemo_relay_plugin_uses_real_0_6_dynamic_activation_api( + tmp_path, + monkeypatch, +): + relay = pytest.importorskip("nemo_relay") + if getattr(relay, "_native", None) is None: + pytest.skip("NeMo Relay native binding is unavailable on this platform") + plugin = _fresh_plugin(monkeypatch, relay) + _enable_dynamic_plugin(tmp_path, monkeypatch) + calls = [] + + class _NativeActivation: + def __init__(self): + self.report = {"diagnostics": []} + self.is_active = True + + async def close(self): + self.is_active = False + + async def _initialize_with_dynamic_plugins(config, dynamic_plugins): + calls.append((config, dynamic_plugins)) + return _NativeActivation() + + monkeypatch.setattr( + relay.plugin, + "_initialize_with_dynamic_plugins", + _initialize_with_dynamic_plugins, + ) + + plugin.on_session_start(session_id="s1") + runtime = plugin._get_runtime() + + assert runtime is not None + assert isinstance(runtime._plugin_activation, relay.plugin.PluginHostActivation) + assert calls == [ + ( + {"version": 1}, + [ + { + "plugin_id": "fixture", + "kind": "rust_dynamic", + "manifest_ref": str(tmp_path / "fixture" / "relay-plugin.toml"), + "config": {"mode": "test"}, + } + ], + ) + ] + + activation = runtime._plugin_activation + runtime.shutdown() + assert activation.is_active is False + + +def test_real_binding_shares_plugin_configuration_across_two_profiles( + tmp_path, + monkeypatch, +): + relay = pytest.importorskip("nemo_relay") + if getattr(relay, "_native", None) is None: + pytest.skip("NeMo Relay native binding is unavailable on this platform") + plugin = _fresh_plugin(monkeypatch, relay) + original_initialize = relay.plugin.initialize + original_clear = relay.plugin.clear + original_clear() + initialize_calls = [] + clear_calls = 0 + + async def _initialize(config): + initialize_calls.append(config) + return await original_initialize(config) + + def _clear(): + nonlocal clear_calls + clear_calls += 1 + return original_clear() + + monkeypatch.setattr(relay.plugin, "initialize", _initialize) + monkeypatch.setattr(relay.plugin, "clear", _clear) + monkeypatch.setattr( + plugin, + "_load_settings", + lambda: plugin._Settings(plugins_config={"version": 1}), + ) + profile_a = str(tmp_path / "profile-a") + profile_b = str(tmp_path / "profile-b") + host_a = relay_runtime.RelayRuntime(relay=relay, profile_key=profile_a) + host_b = relay_runtime.RelayRuntime(relay=relay, profile_key=profile_b) + + try: + runtime_a = plugin._get_runtime(profile_key=profile_a, host=host_a) + runtime_b = plugin._get_runtime(profile_key=profile_b, host=host_b) + assert runtime_a is not None + assert runtime_b is not None + runtime_a.ensure_session({"session_id": "session-a"}) + runtime_b.ensure_session({"session_id": "session-b"}) + + assert initialize_calls == [{"version": 1}] + assert relay.plugin.report() is not None + + runtime_a.close_session({"session_id": "session-a"}) + + assert clear_calls == 0 + assert relay.plugin.report() is not None + assert runtime_b.host.get_session("session-b") is not None + + runtime_b.close_session({"session_id": "session-b"}) + + assert clear_calls == 1 + assert relay.plugin.report() is None + finally: + plugin.reset_for_tests() + host_a.shutdown() + host_b.shutdown() + original_clear() + + +def test_real_binding_does_not_replace_another_profiles_plugin_configuration( + tmp_path, + monkeypatch, +): + relay = pytest.importorskip("nemo_relay") + if getattr(relay, "_native", None) is None: + pytest.skip("NeMo Relay native binding is unavailable on this platform") + plugin = _fresh_plugin(monkeypatch, relay) + original_initialize = relay.plugin.initialize + original_clear = relay.plugin.clear + original_clear() + initialize_calls = [] + clear_calls = 0 + + async def _initialize(config): + initialize_calls.append(config) + return await original_initialize(config) + + def _clear(): + nonlocal clear_calls + clear_calls += 1 + return original_clear() + + settings = iter(( + plugin._Settings( + plugins_config={"version": 1, "policy": {"unsupported": "warn"}} + ), + plugin._Settings( + plugins_config={"version": 1, "policy": {"unsupported": "ignore"}} + ), + )) + monkeypatch.setattr(relay.plugin, "initialize", _initialize) + monkeypatch.setattr(relay.plugin, "clear", _clear) + monkeypatch.setattr(plugin, "_load_settings", lambda: next(settings)) + profile_a = str(tmp_path / "profile-a") + profile_b = str(tmp_path / "profile-b") + host_a = relay_runtime.RelayRuntime(relay=relay, profile_key=profile_a) + host_b = relay_runtime.RelayRuntime(relay=relay, profile_key=profile_b) + + try: + runtime_a = plugin._get_runtime(profile_key=profile_a, host=host_a) + runtime_b = plugin._get_runtime(profile_key=profile_b, host=host_b) + assert runtime_a is not None + assert runtime_b is not None + + assert initialize_calls == [ + {"version": 1, "policy": {"unsupported": "warn"}} + ] + assert runtime_a._plugin_config_initialized is True + assert runtime_b._plugin_config_initialized is False + assert relay.plugin.report() is not None + + runtime_b.shutdown() + + assert clear_calls == 0 + assert relay.plugin.report() is not None + + runtime_a.shutdown() + + assert clear_calls == 1 + assert relay.plugin.report() is None + finally: + plugin.reset_for_tests() + host_a.shutdown() + host_b.shutdown() + original_clear() + + def test_nemo_relay_rejects_gateway_dynamic_config_with_actionable_diagnostic( tmp_path, monkeypatch, caplog ): @@ -897,7 +1081,7 @@ def test_nemo_relay_plugin_degrades_to_static_config_on_relay_0_5( tmp_path, monkeypatch, caplog ): fake = _FakeNemoRelay() - delattr(fake.plugin, "activate_dynamic_plugins") + delattr(fake.plugin, "initialize_with_dynamic_plugins") plugin = _fresh_plugin(monkeypatch, fake) _enable_dynamic_plugin(tmp_path, monkeypatch) @@ -976,7 +1160,7 @@ def test_nemo_relay_plugin_registers_shutdown_after_dynamic_retry(tmp_path, monk raise RuntimeError("temporary activation failure") return _FakePluginActivation(fake.events) - fake.plugin.activate_dynamic_plugins = _flaky_activate + fake.plugin.initialize_with_dynamic_plugins = _flaky_activate plugin = _fresh_plugin(monkeypatch, fake) _enable_dynamic_plugin(tmp_path, monkeypatch) From 537425ebe4e0cd4a96a0d93dd1338e42d12d0ab6 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 22 Jul 2026 10:32:49 -0700 Subject: [PATCH 022/526] fix(runtime): close abandoned Relay streams Signed-off-by: Alex Fournier --- agent/relay_llm.py | 18 ++++++++++++++---- tests/agent/test_relay_llm.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 6d018d462fa..1dc80b0ba11 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -387,6 +387,9 @@ class ManagedLlmStream(Iterator[Any]): ) ) except BaseException: + if not self._defer_logical_completion: + _complete_logical(self._logical, outcome="failed") + self._logical = None loop.close() self._loop = None raise @@ -419,10 +422,7 @@ class ManagedLlmStream(Iterator[Any]): raise StopIteration from None except BaseException as exc: callback_error = self._callback_error - self.close() - if not self._defer_logical_completion: - _complete_logical(self._logical, outcome="failed") - self._logical = None + self._close(logical_outcome="failed") if ( callback_error is not None and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error) @@ -441,6 +441,10 @@ class ManagedLlmStream(Iterator[Any]): return self._chunk_adapter(chunk) def close(self) -> None: + """Close an explicitly abandoned stream and cancel its logical call.""" + self._close(logical_outcome="cancelled") + + def _close(self, *, logical_outcome: str) -> None: if self._closed: return self._closed = True @@ -450,6 +454,9 @@ class ManagedLlmStream(Iterator[Any]): close = getattr(self._stream, "close", None) if callable(close): close() + if not self._defer_logical_completion: + _complete_logical(self._logical, outcome=logical_outcome) + self._logical = None return close = getattr(self._stream, "aclose", None) if callable(close): @@ -461,6 +468,9 @@ class ManagedLlmStream(Iterator[Any]): loop.run_until_complete(close_stream()) except Exception: pass + if not self._defer_logical_completion: + _complete_logical(self._logical, outcome=logical_outcome) + self._logical = None loop.close() def __del__(self) -> None: diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 40f9cb8372e..b3b62cb7ab8 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -170,6 +170,41 @@ def test_deferred_stream_preserves_provider_error_and_logical_scope_for_retry( assert "request-2" in turn.logical_llm_calls +def test_non_deferred_partial_stream_close_cancels_logical_call( + relay_turn, + monkeypatch, +): + relay, turn = relay_turn + original_pop = relay.scope.pop + terminal_outputs = [] + + def record_pop(handle, *args, **kwargs): + terminal_outputs.append(kwargs.get("output")) + return original_pop(handle, *args, **kwargs) + + monkeypatch.setattr(relay.scope, "pop", record_pop) + stream = relay_llm.stream( + {"model": "test-model", "messages": []}, + lambda _request: iter([{"delta": "partial"}, {"delta": "unused"}]), + session_id="session-1", + name="test-provider", + model_name="test-model", + finalizer=lambda: {"content": "partial"}, + metadata={ + "api_mode": "custom", + "api_request_id": "request-partial-close", + }, + ) + + assert next(stream) == {"delta": "partial"} + assert "request-partial-close" in turn.logical_llm_calls + + stream.close() + + assert "request-partial-close" not in turn.logical_llm_calls + assert {"outcome": "cancelled"} in terminal_outputs + + def test_non_stream_preserves_raw_provider_response_identity(relay_turn): _relay, _turn = relay_turn raw_response = SimpleNamespace(model="test-model", content="raw") From 1d49f0c917c90ef65e3180f571d1a71c8d6bd503 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 22 Jul 2026 10:32:59 -0700 Subject: [PATCH 023/526] fix(runtime): finalize Relay iteration summaries Signed-off-by: Alex Fournier --- agent/chat_completion_helpers.py | 11 +++++++++++ tests/run_agent/test_run_agent.py | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 5ddaf93e564..8836160b6b8 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1912,6 +1912,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: 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 @@ -1929,6 +1930,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: "call_role": "iteration_summary", "retry_count": retry_count, }, + defer_logical_completion=True, ) summary_request = ( @@ -2133,6 +2135,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: if "" in final_response: final_response = re.sub(r'.*?\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." @@ -2187,6 +2190,7 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: if "" in final_response: final_response = re.sub(r'.*?\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." @@ -2196,6 +2200,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 diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 4bd2f6c383f..d710f91cb58 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3865,7 +3865,10 @@ class TestHandleMaxIterations: relay_calls.append(kwargs) return callback(request) - with patch("agent.relay_llm.execute_current", side_effect=execute_current): + 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, @@ -3877,15 +3880,23 @@ class TestHandleMaxIterations: 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" From 91c8f88a2d6a9e29c41e1f3dafbf4adf9b198c51 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 22 Jul 2026 13:18:23 -0700 Subject: [PATCH 024/526] build(deps): require stable NeMo Relay 0.6 Signed-off-by: Alex Fournier --- pyproject.toml | 7 +++---- uv.lock | 14 +++++++------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eebd03e3517..2319e21b75a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,10 +142,9 @@ 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 RC 3 is the - # minimum lossless provider-codec contract; final 0.6 releases also satisfy - # this bounded pre-1.0 range. Native wheels target these platforms. - "nemo-relay>=0.6.0rc3,<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')", + # First-party lifecycle and shared-metrics runtime. Relay 0.6 is the minimum + # lossless provider-codec contract. Native wheels target these platforms. + "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] diff --git a/uv.lock b/uv.lock index fe381cf5e39..078514b8a44 100644 --- a/uv.lock +++ b/uv.lock @@ -1803,7 +1803,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 = "(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.0rc3,<0.7" }, + { 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" }, @@ -2608,14 +2608,14 @@ wheels = [ [[package]] name = "nemo-relay" -version = "0.6.0rc3" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/6a/abc154857deb7a62f21ed0eef0c989941690512597cb44928c5100d2858c/nemo_relay-0.6.0rc3-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:733a50ac46ae091434a729d9a590874379ee6a6acff4ef931dc7856c5c96aba4", size = 9895763, upload-time = "2026-07-21T06:07:48.838Z" }, - { url = "https://files.pythonhosted.org/packages/41/61/da06f03a0dda13252719f235b4b4ca79f3b0cc27ac62a86c6f3c7297002e/nemo_relay-0.6.0rc3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7742c5348bbe71756441019a1063e4969060202fab4d1cba142a63a18c23d252", size = 8882012, upload-time = "2026-07-21T06:07:51.705Z" }, - { url = "https://files.pythonhosted.org/packages/a2/16/7fc4a6bf7f5f27305e73790b7eb351bc321d2dd9f9af3e01d3a5f40eadfe/nemo_relay-0.6.0rc3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98cff44f844f3bd45824b2a6ecc4fd571b521e94ec6b7fae1aca0b02c5384d89", size = 9322992, upload-time = "2026-07-21T06:07:53.767Z" }, - { url = "https://files.pythonhosted.org/packages/5d/12/57b0a58f578065226c513c0229ee78c26b3efd0ed6fa46c8d6ee2b949d94/nemo_relay-0.6.0rc3-cp311-abi3-win_amd64.whl", hash = "sha256:c8ad58c6cf05c74b667b1108609c2423bb00244b64a318a153b4aba91abf2fed", size = 9587358, upload-time = "2026-07-21T06:07:56.087Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f9/63e41a74d3f2a493730158de0c2103f5c988f602d1f844f5daeed456078d/nemo_relay-0.6.0rc3-cp311-abi3-win_arm64.whl", hash = "sha256:934078a342c21bd94f3418f1551487e16616de09703229326d86341de0407187", size = 9019017, upload-time = "2026-07-21T06:07:58.072Z" }, + { 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]] From 0a43bc83b95a34054604e7fd2fd782b472e46fa8 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 22 Jul 2026 13:28:51 -0700 Subject: [PATCH 025/526] docs(config): remove internal telemetry phase wording Signed-off-by: Alex Fournier --- cli-config.yaml.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index df350fd0423..fcd18865bfa 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1395,8 +1395,8 @@ display: # ============================================================================= # Telemetry # ============================================================================= -# Shared metrics are disabled by default. When enabled, the current Phase 1 -# integration writes only allowlisted aggregate counters and immutable JSON +# 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. telemetry: shared_metrics: From 4add7d0c12883d73bbe0bc149ac0e24726cc3150 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 22 Jul 2026 14:35:15 -0700 Subject: [PATCH 026/526] test(packaging): remove Relay dependency change detector Signed-off-by: Alex Fournier --- tests/test_project_metadata.py | 45 ---------------------------------- 1 file changed, 45 deletions(-) diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 6ba5e4809ac..53888274e15 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -3,9 +3,6 @@ from pathlib import Path import tomllib -from packaging.requirements import Requirement - - def _load_optional_dependencies(): pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" with pyproject_path.open("rb") as handle: @@ -13,12 +10,6 @@ def _load_optional_dependencies(): return project["optional-dependencies"] -def _load_project(): - pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" - with pyproject_path.open("rb") as handle: - return tomllib.load(handle)["project"] - - def _load_package_data(): pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" with pyproject_path.open("rb") as handle: @@ -230,42 +221,6 @@ def test_feishu_extra_includes_qrcode_for_qr_login(): assert any(dep.startswith("qrcode") for dep in feishu_extra) -def test_nemo_relay_is_a_bounded_core_dependency(): - metadata = _load_project() - - relay_dependencies = [ - dependency - for dependency in metadata["dependencies"] - if dependency.startswith("nemo-relay") - ] - assert len(relay_dependencies) == 1 - requirement = Requirement(relay_dependencies[0]) - assert str(requirement.specifier) == "<0.7,>=0.6.0rc3" - assert requirement.marker is not None - assert requirement.marker.evaluate( - {"sys_platform": "darwin", "platform_machine": "arm64"} - ) - assert requirement.marker.evaluate( - {"sys_platform": "linux", "platform_machine": "x86_64"} - ) - assert requirement.marker.evaluate( - {"sys_platform": "linux", "platform_machine": "aarch64"} - ) - assert requirement.marker.evaluate( - {"sys_platform": "win32", "platform_machine": "AMD64"} - ) - assert requirement.marker.evaluate( - {"sys_platform": "win32", "platform_machine": "ARM64"} - ) - assert not requirement.marker.evaluate( - {"sys_platform": "android", "platform_machine": "aarch64"} - ) - assert not requirement.marker.evaluate( - {"sys_platform": "darwin", "platform_machine": "x86_64"} - ) - assert metadata["optional-dependencies"]["nemo-relay"] == [] - - def test_dashboard_plugin_manifests_and_assets_are_packaged(): """Bundled dashboard plugins need their manifests and built assets in wheel installs so /api/dashboard/plugins can discover them outside a From afd0b3ecd4e6f66b1cab6da70778bcfebd13e887 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 22 Jul 2026 14:35:55 -0700 Subject: [PATCH 027/526] fix(observability): keep Relay session headers local Signed-off-by: Alex Fournier --- agent/relay_llm.py | 11 ++++++++++- tests/agent/test_relay_llm.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 1dc80b0ba11..142ba070b18 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -18,6 +18,9 @@ logger = logging.getLogger(__name__) _PROVIDER_MESSAGE_EXTENSION_KEYS = frozenset( {"reasoning_content", "reasoning_details"} ) +_RELAY_INTERNAL_PROVIDER_HEADERS = frozenset( + {"x-dynamo-parent-session-id", "x-dynamo-session-id"} +) def execute( @@ -694,7 +697,13 @@ def _provider_request( final[key] = value _restore_provider_message_extensions(original, final) headers = getattr(request, "headers", None) - if isinstance(headers, dict) and headers: + if isinstance(headers, dict): + headers = { + key: value + for key, value in headers.items() + if str(key).lower() not in _RELAY_INTERNAL_PROVIDER_HEADERS + } + if headers: final["extra_headers"] = { **dict(final.get("extra_headers") or {}), **headers, diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index b3b62cb7ab8..4cd9484d2bd 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -101,7 +101,11 @@ def test_stream_uses_rewritten_request_and_post_intercept_chunks(relay_turn): ) try: stream = relay_llm.stream( - {"model": "test-model", "messages": []}, + { + "model": "test-model", + "messages": [], + "extra_headers": {"authorization": "Bearer provider-token"}, + }, raw_stream, session_id="session-1", name="test-provider", @@ -127,6 +131,9 @@ def test_stream_uses_rewritten_request_and_post_intercept_chunks(relay_turn): relay.intercepts.deregister_llm_request("hermes-test-request") assert captured_requests[0]["temperature"] == 0.25 + assert captured_requests[0]["extra_headers"] == { + "authorization": "Bearer provider-token" + } assert chunks[0].choices[0].delta.content == "HELLO" assert stream.output_modified is True assert turn.logical_llm_calls == {} @@ -221,6 +228,28 @@ def test_non_stream_preserves_raw_provider_response_identity(relay_turn): assert result is raw_response +def test_non_stream_does_not_forward_relay_session_headers(relay_turn): + _relay, _turn = relay_turn + captured_requests = [] + + relay_llm.execute( + { + "model": "test-model", + "messages": [], + "extra_headers": {"x-provider-header": "provider-value"}, + }, + lambda request: captured_requests.append(request) or {"content": "ok"}, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={"api_mode": "custom", "api_request_id": "request-headers"}, + ) + + assert captured_requests[0]["extra_headers"] == { + "x-provider-header": "provider-value" + } + + def test_non_stream_defers_logical_success_and_reuses_scope_for_retry(relay_turn): _relay, turn = relay_turn metadata = {"api_mode": "custom", "api_request_id": "request-retry"} From c9747106b683428d2c7cc910ae20a943cfae960c Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 07:22:38 -0700 Subject: [PATCH 028/526] fix(runtime): retry Relay stream construction failures Signed-off-by: Alex Fournier --- agent/codex_runtime.py | 72 ++++++++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 525e58e4e1d..f6862e7a05f 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -1278,34 +1278,52 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta model=api_kwargs.get("model"), ) - 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" + try: + 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__") ), - "retry_count": attempt, - }, - defer_logical_completion=True, - ) + 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, + ) + continue + raise def _interrupt_or_superseded() -> bool: return bool(agent._interrupt_requested) From 1ffeaf226c7c4d0cd716f42cfd50aad12b4b151d Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:04:12 -0700 Subject: [PATCH 029/526] test(relay): skip native suites without binding Signed-off-by: Alex Fournier --- tests/agent/test_auxiliary_relay.py | 2 ++ tests/agent/test_relay_llm.py | 2 ++ tests/agent/test_relay_tools.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tests/agent/test_auxiliary_relay.py b/tests/agent/test_auxiliary_relay.py index 8866167b42a..0902f0fb91c 100644 --- a/tests/agent/test_auxiliary_relay.py +++ b/tests/agent/test_auxiliary_relay.py @@ -2,6 +2,8 @@ from types import SimpleNamespace import pytest +pytest.importorskip("nemo_relay") + from agent import auxiliary_client, relay_llm, relay_runtime diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 4cd9484d2bd..24a63781568 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -8,6 +8,8 @@ from types import SimpleNamespace import pytest +pytest.importorskip("nemo_relay") + from agent import relay_llm, relay_runtime diff --git a/tests/agent/test_relay_tools.py b/tests/agent/test_relay_tools.py index 36ca4322ff0..8aed80eacde 100644 --- a/tests/agent/test_relay_tools.py +++ b/tests/agent/test_relay_tools.py @@ -4,6 +4,8 @@ from __future__ import annotations import pytest +pytest.importorskip("nemo_relay") + from agent import relay_runtime, relay_tools From 1c6a96032c6dc946d07250f7848fdcac1f730016 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:12:50 -0700 Subject: [PATCH 030/526] fix(relay): preserve successful managed results Signed-off-by: Alex Fournier --- agent/relay_llm.py | 70 ++++++++++++++- agent/relay_tools.py | 10 +++ tests/agent/test_relay_llm.py | 151 ++++++++++++++++++++++++++++++++ tests/agent/test_relay_tools.py | 25 ++++++ 4 files changed, 252 insertions(+), 4 deletions(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 142ba070b18..e73695257af 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -83,6 +83,13 @@ def execute( and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error) ): raise callback_error + if _recover_successful_callback( + raw_response, + callback_error=callback_error, + logical=logical, + defer_logical_completion=defer_logical_completion, + ): + return raw_response["value"] raise if not defer_logical_completion: @@ -150,6 +157,13 @@ async def execute_async( and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error) ): raise callback_error + if _recover_successful_callback( + raw_response, + callback_error=callback_error, + logical=logical, + defer_logical_completion=defer_logical_completion, + ): + return raw_response["value"] raise if not defer_logical_completion: @@ -298,6 +312,7 @@ class ManagedLlmStream(Iterator[Any]): self._chunk_adapter = chunk_adapter or _namespace self._accept_chunk = accept_chunk self._relay_observes_chunks = False + self._provider_completed = False self._raw_chunks: list[tuple[Any, Any]] = [] self.output_modified = False @@ -341,6 +356,7 @@ class ManagedLlmStream(Iterator[Any]): and completed_response_predicate(raw_stream) ): self.final_response = raw_stream + self._provider_completed = True return if on_stream_created is not None: on_stream_created(raw_stream) @@ -352,6 +368,7 @@ class ManagedLlmStream(Iterator[Any]): encoded_chunk = _jsonable(chunk) self._raw_chunks.append((encoded_chunk, chunk)) yield encoded_chunk + self._provider_completed = True except BaseException as exc: self._callback_error = exc raise @@ -365,9 +382,13 @@ class ManagedLlmStream(Iterator[Any]): self._on_chunk(_jsonable(chunk)) def relay_finalizer() -> Any: - if self.final_response is not None: - return _jsonable(self.final_response) - return _jsonable(finalizer()) + try: + if self.final_response is not None: + return _jsonable(self.final_response) + return _jsonable(finalizer()) + except BaseException as exc: + self._callback_error = exc + raise loop = asyncio.new_event_loop() self._loop = loop @@ -390,6 +411,19 @@ class ManagedLlmStream(Iterator[Any]): ) ) except BaseException: + if self._provider_completed and self._callback_error is None: + logger.warning( + "NeMo Relay stream post-processing failed after provider success; " + "preserving the provider result", + exc_info=True, + ) + if not self._defer_logical_completion: + _complete_logical(self._logical, outcome="success") + self._logical = None + loop.close() + self._loop = None + self._stream = iter(()) + return if not self._defer_logical_completion: _complete_logical(self._logical, outcome="failed") self._logical = None @@ -425,12 +459,21 @@ class ManagedLlmStream(Iterator[Any]): raise StopIteration from None except BaseException as exc: callback_error = self._callback_error - self._close(logical_outcome="failed") if ( callback_error is not None and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error) ): + self._close(logical_outcome="failed") raise callback_error + if self._provider_completed and callback_error is None: + logger.warning( + "NeMo Relay stream post-processing failed after provider success; " + "preserving the provider result", + exc_info=True, + ) + self._close(logical_outcome="success") + raise StopIteration from None + self._close(logical_outcome="failed") raise if not self._relay_observes_chunks and self._on_chunk is not None: self._on_chunk(chunk) @@ -659,6 +702,25 @@ def _complete_logical( turn.logical_llm_calls.pop(request_id, None) +def _recover_successful_callback( + raw_response: dict[str, Any], + *, + callback_error: BaseException | None, + logical: tuple[relay_runtime.RelayTurnContext, Any, str] | None, + defer_logical_completion: bool, +) -> bool: + if callback_error is not None or "value" not in raw_response: + return False + logger.warning( + "NeMo Relay LLM post-processing failed after provider success; " + "returning the provider response", + exc_info=True, + ) + if not defer_logical_completion: + _complete_logical(logical, outcome="success") + return True + + def complete_logical_call(api_request_id: str, *, outcome: str) -> None: """Complete the active turn's logical LLM call after caller validation.""" turn = relay_runtime.active_turn() diff --git a/agent/relay_tools.py b/agent/relay_tools.py index 5c93c7b2bdf..9f82483edb2 100644 --- a/agent/relay_tools.py +++ b/agent/relay_tools.py @@ -5,11 +5,14 @@ from __future__ import annotations import asyncio 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, @@ -58,6 +61,13 @@ def execute( and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error) ): raise callback_error + if 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"]): diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 24a63781568..9a9dc3598a4 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -318,6 +318,36 @@ def test_non_stream_result_survives_logical_scope_close_failure( assert turn.logical_llm_calls == {} +def test_non_stream_returns_provider_response_after_relay_post_processing_failure( + relay_turn, monkeypatch, caplog +): + relay, turn = relay_turn + raw_response = SimpleNamespace(model="test-model", content="raw") + + async def fail_after_callback(_name, request, callback, **_kwargs): + callback(request) + raise RuntimeError("simulated Relay post-processing failure") + + monkeypatch.setattr(relay.llm, "execute", fail_after_callback) + + with caplog.at_level("WARNING", logger="agent.relay_llm"): + result = relay_llm.execute( + {"model": "test-model", "messages": []}, + lambda _request: raw_response, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={ + "api_mode": "custom", + "api_request_id": "request-post-failure", + }, + ) + + assert result is raw_response + assert turn.logical_llm_calls == {} + assert "returning the provider response" in caplog.text + + @pytest.mark.asyncio async def test_async_non_stream_preserves_raw_provider_response_identity(relay_turn): _relay, _turn = relay_turn @@ -337,6 +367,39 @@ async def test_async_non_stream_preserves_raw_provider_response_identity(relay_t assert result is raw_response +@pytest.mark.asyncio +async def test_async_non_stream_returns_provider_response_after_relay_failure( + relay_turn, monkeypatch, caplog +): + relay, turn = relay_turn + raw_response = SimpleNamespace(model="test-model", content="raw") + + async def provider(_request): + return raw_response + + async def fail_after_callback(_name, request, callback, **_kwargs): + await callback(request) + raise RuntimeError("simulated Relay post-processing failure") + + monkeypatch.setattr(relay.llm, "execute", fail_after_callback) + + with caplog.at_level("WARNING", logger="agent.relay_llm"): + result = await relay_llm.execute_current_async( + {"model": "test-model", "messages": []}, + provider, + name="test-provider", + model_name="test-model", + metadata={ + "api_mode": "custom", + "api_request_id": "request-async-post-failure", + }, + ) + + assert result is raw_response + assert turn.logical_llm_calls == {} + assert "returning the provider response" in caplog.text + + @pytest.mark.asyncio async def test_async_non_stream_defers_logical_success_for_validation(relay_turn): _relay, turn = relay_turn @@ -360,6 +423,94 @@ async def test_async_non_stream_defers_logical_success_for_validation(relay_turn assert turn.logical_llm_calls == {} +def test_stream_finishes_after_relay_post_processing_failure( + relay_turn, monkeypatch, caplog +): + relay, turn = relay_turn + + async def fail_after_stream( + _name, + request, + callback, + observe_chunk, + finalizer, + **_kwargs, + ): + async def generate(): + upstream = callback(request) + async for chunk in upstream: + observe_chunk(chunk) + yield chunk + finalizer() + raise RuntimeError("simulated Relay post-processing failure") + + return generate() + + monkeypatch.setattr(relay.llm, "stream_execute", fail_after_stream) + stream = relay_llm.stream( + {"model": "test-model", "messages": []}, + lambda _request: iter([{"delta": "complete"}]), + session_id="session-1", + name="test-provider", + model_name="test-model", + finalizer=lambda: {"content": "complete"}, + metadata={ + "api_mode": "custom", + "api_request_id": "request-stream-post-failure", + }, + ) + + with caplog.at_level("WARNING", logger="agent.relay_llm"): + chunks = list(stream) + + assert chunks == [{"delta": "complete"}] + assert turn.logical_llm_calls == {} + assert "preserving the provider result" in caplog.text + + +def test_stream_does_not_swallow_hermes_finalizer_failure(relay_turn, monkeypatch): + relay, _turn = relay_turn + finalizer_error = RuntimeError("Hermes finalizer failed") + + def fail_finalizer(): + raise finalizer_error + + async def execute_stream( + _name, + request, + callback, + _observe_chunk, + finalizer, + **_kwargs, + ): + async def generate(): + upstream = callback(request) + async for chunk in upstream: + yield chunk + finalizer() + + return generate() + + monkeypatch.setattr(relay.llm, "stream_execute", execute_stream) + stream = relay_llm.stream( + {"model": "test-model", "messages": []}, + lambda _request: iter([{"delta": "complete"}]), + session_id="session-1", + name="test-provider", + model_name="test-model", + finalizer=fail_finalizer, + metadata={ + "api_mode": "custom", + "api_request_id": "request-finalizer-failure", + }, + ) + + with pytest.raises(Exception) as caught: + list(stream) + + assert caught.value is finalizer_error + + def test_stream_defers_logical_success_for_response_validation(relay_turn): _relay, turn = relay_turn diff --git a/tests/agent/test_relay_tools.py b/tests/agent/test_relay_tools.py index 8aed80eacde..fb0caff6c04 100644 --- a/tests/agent/test_relay_tools.py +++ b/tests/agent/test_relay_tools.py @@ -196,3 +196,28 @@ def test_tool_adapter_does_not_mask_relay_error_after_callback_failure( ) 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 From c16f86dc4c2570b61578fda0f0128b71ed385eee Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:13:58 -0700 Subject: [PATCH 031/526] fix(relay): serialize rewritten tool results Signed-off-by: Alex Fournier --- agent/relay_tools.py | 4 +++- tests/agent/test_relay_tools.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/agent/relay_tools.py b/agent/relay_tools.py index 9f82483edb2..9fb3b487e7e 100644 --- a/agent/relay_tools.py +++ b/agent/relay_tools.py @@ -72,7 +72,9 @@ def execute( if "value" in raw_result and _json_equal(managed, raw_result["json"]): return raw_result["value"], observed_args - return managed, 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: diff --git a/tests/agent/test_relay_tools.py b/tests/agent/test_relay_tools.py index fb0caff6c04..785dddf2c45 100644 --- a/tests/agent/test_relay_tools.py +++ b/tests/agent/test_relay_tools.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json + import pytest pytest.importorskip("nemo_relay") @@ -118,7 +120,8 @@ def test_request_rewrite_reaches_authorized_callback_once(relay_turn): assert callback_args == [{"path": "/approved/path"}] assert observed_args == {"path": "/approved/path"} - assert result == {"ok": True, "wrapped": True} + assert isinstance(result, str) + assert json.loads(result) == {"ok": True, "wrapped": True} def test_provider_error_identity_is_preserved(relay_turn): From 398a8ca580400c3d8b997d345171f9e8a80b570c Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:19:03 -0700 Subject: [PATCH 032/526] fix(tools): serialize concurrent approvals Signed-off-by: Alex Fournier --- agent/tool_executor.py | 92 +++++++++++++++++++++++++------ tests/run_agent/test_run_agent.py | 63 +++++++++++++++++++++ 2 files changed, 138 insertions(+), 17 deletions(-) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 8957d24439f..c63aa7037b4 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -296,6 +296,46 @@ class _ManagedToolResult: blocked: bool +class _ConcurrentToolAuthorizationGate: + """Serialize policy prompts and exclude their queue from batch deadlines.""" + + def __init__(self) -> None: + self._serialization_lock = threading.Lock() + self._state_lock = threading.Lock() + self._pending = 0 + self._window_started: float | None = None + self._excluded_seconds = 0.0 + + def run(self, callback): + now = time.monotonic() + with self._state_lock: + if self._pending == 0: + self._window_started = now + self._pending += 1 + try: + with self._serialization_lock: + return callback() + finally: + now = time.monotonic() + with self._state_lock: + self._pending -= 1 + if self._pending == 0: + if self._window_started is not None: + self._excluded_seconds += max( + 0.0, now - self._window_started + ) + self._window_started = None + + def excluded_seconds(self) -> float: + """Return completed plus currently active authorization wait time.""" + now = time.monotonic() + with self._state_lock: + excluded = self._excluded_seconds + if self._window_started is not None: + excluded += max(0.0, now - self._window_started) + return excluded + + def _managed_values( outcome: _ManagedToolResult, ) -> tuple[Any, dict[str, Any], list[dict[str, Any]], bool]: @@ -319,6 +359,7 @@ def _run_agent_tool_execution_middleware( display_index: int | None = None, middleware_trace: list[dict[str, Any]] | None = None, begin_execution=None, + authorization_gate: _ConcurrentToolAuthorizationGate | None = None, ) -> _ManagedToolResult: """Run Relay rewrites before Hermes policy and dispatch exactly once.""" from agent import relay_tools @@ -358,22 +399,30 @@ def _run_agent_tool_execution_middleware( block_error_type = "tool_scope_block" if block_message is None: block_error_type = "plugin_block" - try: - from hermes_cli.plugins import resolve_pre_tool_block - block_message = resolve_pre_tool_block( - function_name, - final_args, - task_id=effective_task_id or "", - session_id=getattr(agent, "session_id", "") or "", - tool_call_id=tool_call_id or "", - turn_id=getattr(agent, "_current_turn_id", "") or "", - api_request_id=getattr(agent, "_current_api_request_id", "") - or "", - middleware_trace=list(state["middleware_trace"]), - ) - except Exception: - block_message = None + def _resolve_pre_tool_block(): + try: + from hermes_cli.plugins import resolve_pre_tool_block + + return resolve_pre_tool_block( + function_name, + final_args, + task_id=effective_task_id or "", + session_id=getattr(agent, "session_id", "") or "", + tool_call_id=tool_call_id or "", + turn_id=getattr(agent, "_current_turn_id", "") or "", + api_request_id=getattr(agent, "_current_api_request_id", "") + or "", + middleware_trace=list(state["middleware_trace"]), + ) + except Exception: + return None + + block_message = ( + _resolve_pre_tool_block() + if authorization_gate is None + else authorization_gate.run(_resolve_pre_tool_block) + ) guardrail_decision = None if block_message is None: @@ -673,6 +722,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe start_condition = threading.Condition() next_start_order = 0 + authorization_gate = _ConcurrentToolAuthorizationGate() def _begin_in_order(order: int, callback=None) -> None: nonlocal next_start_order @@ -766,6 +816,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe display_index=index + 1, middleware_trace=middleware_trace, begin_execution=_advance_start, + authorization_gate=authorization_gate, ) result = managed.result function_args = managed.args @@ -923,7 +974,10 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe while True: wait_timeout = 5.0 if deadline is not None: - remaining = deadline - time.monotonic() + effective_deadline = ( + deadline + authorization_gate.excluded_seconds() + ) + remaining = effective_deadline - time.monotonic() if remaining <= 0: done, not_done = set(), { f for f in futures if not f.done() @@ -940,7 +994,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe if not not_done: break - if deadline is not None and time.monotonic() >= deadline: + if ( + deadline is not None + and time.monotonic() + >= deadline + authorization_gate.excluded_seconds() + ): abandon_executor = True timed_out_indices = { future_to_index[f] diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 28cf8ce1573..81648dc6628 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3035,6 +3035,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") From a6fbff1d7ecee0e9f8dfeb12f6ef9ae0ef9f469c Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:22:50 -0700 Subject: [PATCH 033/526] fix(relay): retain active child sessions Signed-off-by: Alex Fournier --- agent/relay_runtime.py | 31 ++++++ .../test_relay_shared_metrics_runtime.py | 48 ++++++++++ tests/tools/test_zombie_process_cleanup.py | 94 ++++++++++++++++++- tools/delegate_tool.py | 6 +- 4 files changed, 175 insertions(+), 4 deletions(-) diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 0b1c341d34c..2b49725cbcf 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -473,6 +473,7 @@ class RelayTurnContext: default=None, repr=False, ) + _active_registered: bool = field(default=False, repr=False) closed: bool = False @@ -491,6 +492,8 @@ class RelaySessionCoordinator: 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, @@ -602,6 +605,10 @@ class RelaySessionCoordinator: 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( @@ -636,8 +643,31 @@ class RelaySessionCoordinator: "Hermes Relay turn 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, @@ -913,4 +943,5 @@ def _session_id(event: dict[str, Any]) -> str: 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() diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index 36f89bccc44..7b39d0973c6 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -1535,6 +1535,54 @@ def test_concurrent_subagents_inherit_parent_turn_and_close_independently( ) +def test_coordinator_tracks_active_turns_across_threads(direct_runtime): + del direct_runtime + coordinator = relay_runtime.SESSION_COORDINATOR + profile_key = relay_runtime.current_profile_key() + lease = coordinator.acquire_conversation( + profile_key=profile_key, + session_id="cross-thread-child", + platform="subagent", + ) + + assert not coordinator.has_active_turn( + profile_key=profile_key, + session_id="cross-thread-child", + ) + + turn = coordinator.begin_turn( + lease, + turn_id="child-turn", + task_id="child-task", + ) + + observed = [] + thread = threading.Thread( + target=lambda: observed.append( + coordinator.has_active_turn( + profile_key=profile_key, + session_id="cross-thread-child", + ) + ) + ) + thread.start() + thread.join(timeout=5) + + assert observed == [True] + + coordinator.end_turn(turn, outcome="success") + + assert not coordinator.has_active_turn( + profile_key=profile_key, + session_id="cross-thread-child", + ) + coordinator.release_conversation(lease) + coordinator.finalize_conversation( + profile_key=profile_key, + session_id="cross-thread-child", + ) + + def test_core_runtime_ignores_self_parenting_subagent_event(direct_runtime): runtime = relay_runtime.get_runtime() assert runtime is not None diff --git a/tests/tools/test_zombie_process_cleanup.py b/tests/tools/test_zombie_process_cleanup.py index 6207c159da6..b4679ffbe3a 100644 --- a/tests/tools/test_zombie_process_cleanup.py +++ b/tests/tools/test_zombie_process_cleanup.py @@ -342,7 +342,6 @@ class TestDelegationCleanup: raise RuntimeError("test abort") child.run_conversation.side_effect = run_conversation - child._relay_pending_turn_id = None relay_host = MagicMock() monkeypatch.setattr(relay_runtime, "get_runtime", lambda **kwargs: relay_host) @@ -379,12 +378,16 @@ class TestDelegationCleanup: parent._active_children_lock = threading.Lock() child = MagicMock() child.session_id = "active-child-session" - child._relay_pending_turn_id = "active-child-turn" 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, @@ -395,3 +398,90 @@ class TestDelegationCleanup: 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() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 5326a06f81a..dbe69aed433 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2411,8 +2411,10 @@ def _run_single_child( runtime = relay_runtime.get_runtime(create=False) child_session_id = str(getattr(child, "session_id", "") or "") - pending_turn = getattr(child, "_relay_pending_turn_id", None) - child_turn_is_active = isinstance(pending_turn, str) and bool(pending_turn) + 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: From 9dfe6901256fd78faea769e3121be3998cc4f093 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:24:19 -0700 Subject: [PATCH 034/526] fix(relay): preserve provider request extensions Signed-off-by: Alex Fournier --- agent/relay_llm.py | 19 ++++++--------- tests/agent/test_relay_llm.py | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index e73695257af..51338bdf398 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -745,17 +745,14 @@ def _provider_request( if _json_equal(content, relay_request_body): final = dict(original) else: - final = _provider_request_body(content, metadata) - # Codec-only normalization must not silently change the provider wire - # request when an unrelated interceptor edits another field. - for key, value in original.items(): - if key not in relay_request_body and value is None: - final.setdefault(key, value) - elif ( - key in relay_request_body - and key in final - and _json_equal(final[key], relay_request_body[key]) - ): + baseline = _provider_request_body(relay_request_body, metadata) + intercepted = _provider_request_body(content, metadata) + final = dict(original) + # Typed codecs may not represent provider-specific fields. Overlay only + # values that changed from the codec-facing baseline so unrelated + # intercepts cannot delete or normalize unknown provider arguments. + for key, value in intercepted.items(): + if key not in baseline or not _json_equal(value, baseline[key]): final[key] = value _restore_provider_message_extensions(original, final) headers = getattr(request, "headers", None) diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 9a9dc3598a4..274fc351647 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -881,3 +881,49 @@ def test_request_rewrite_preserves_unmodified_provider_objects(relay_turn): assert captured_requests[0]["timeout"] is timeout assert captured_requests[0]["temperature"] == 0.25 + + +def test_request_rewrite_preserves_fields_dropped_by_codec(relay_turn, monkeypatch): + relay, _turn = relay_turn + captured_requests = [] + vendor_body = { + "routing": {"provider": "nim", "region": "us-west-2"}, + "trace_vendor_request": False, + } + + async def lossy_execute(_name, request, callback, **_kwargs): + content = { + key: value + for key, value in request.content.items() + if key != "extra_body" + } + content["temperature"] = 0.25 + return callback(relay.LLMRequest(request.headers, content)) + + monkeypatch.setattr(relay.llm, "execute", lossy_execute) + + relay_llm.execute( + { + "model": "test-model", + "messages": [], + "temperature": 0.0, + "extra_body": vendor_body, + }, + lambda request: captured_requests.append(request) or {"content": "ok"}, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={ + "api_mode": "chat_completions", + "api_request_id": "request-lossy-codec", + }, + ) + + assert captured_requests == [ + { + "model": "test-model", + "messages": [], + "temperature": 0.25, + "extra_body": vendor_body, + } + ] From f370af68163e0bcbce9c8f36411f21f8ddd1b66a Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:26:49 -0700 Subject: [PATCH 035/526] fix(codex): stop draining interrupted streams Signed-off-by: Alex Fournier --- agent/codex_runtime.py | 5 +- .../test_stream_single_writer_65991.py | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index f6862e7a05f..31871dca8f5 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -1350,8 +1350,9 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta # 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. - for _ignored in event_stream: - pass + 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( diff --git a/tests/run_agent/test_stream_single_writer_65991.py b/tests/run_agent/test_stream_single_writer_65991.py index b1972f969f7..00d324fac93 100644 --- a/tests/run_agent/test_stream_single_writer_65991.py +++ b/tests/run_agent/test_stream_single_writer_65991.py @@ -208,6 +208,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"])) From c789bdd38a35aa332b3da0e2f44f4fb01a9d3583 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:32:03 -0700 Subject: [PATCH 036/526] fix(relay): close managed provider streams Signed-off-by: Alex Fournier --- agent/chat_completion_helpers.py | 15 ++++-- agent/relay_llm.py | 22 +++++++-- .../test_bedrock_interrupt_post_worker.py | 17 ++++++- tests/agent/test_relay_llm.py | 33 +++++++++++++ tests/run_agent/test_streaming.py | 46 +++++++++++++++++++ 5 files changed, 126 insertions(+), 7 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 58fef45a49a..8b2dd47bc6b 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2366,6 +2366,7 @@ 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 ( @@ -2476,6 +2477,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= 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=_context_thread_target(_bedrock_call), daemon=True @@ -3029,6 +3033,8 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= if hasattr(chunk, "usage") and chunk.usage: usage_obj = chunk.usage + stream.close() + if _stream_attempt_was_cancelled(stream_attempt_id): raise _httpx.RemoteProtocolError( f"stream attempt {stream_attempt_id} was superseded" @@ -3351,9 +3357,12 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= ) from None raise finally: - manager = _stream_context["manager"] - if manager is not None: - manager.__exit__(None, None, None) + try: + stream.close() + finally: + manager = _stream_context["manager"] + if manager is not None: + manager.__exit__(None, None, None) if agent._interrupt_requested: return None diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 51338bdf398..8b799b6e775 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -304,6 +304,7 @@ class ManagedLlmStream(Iterator[Any]): self.final_response: Any = None self._loop: asyncio.AbstractEventLoop | None = None self._stream: Any = None + self._raw_stream_resource: Any = None self._closed = False self._callback_error: BaseException | None = None self._logical: tuple[relay_runtime.RelayTurnContext, Any, str] | None = None @@ -329,6 +330,7 @@ class ManagedLlmStream(Iterator[Any]): self.final_response = raw_stream self._stream = iter(()) else: + self._raw_stream_resource = raw_stream if on_stream_created is not None: on_stream_created(raw_stream) self._stream = iter(raw_stream) @@ -497,9 +499,23 @@ class ManagedLlmStream(Iterator[Any]): loop = self._loop self._loop = None if loop is None: - close = getattr(self._stream, "close", None) - if callable(close): - close() + resources = (self._stream, self._raw_stream_resource) + self._stream = None + self._raw_stream_resource = None + closed_ids: set[int] = set() + for resource in resources: + if resource is None or id(resource) in closed_ids: + continue + closed_ids.add(id(resource)) + close = getattr(resource, "close", None) + if callable(close): + try: + close() + except Exception: + logger.debug( + "Provider stream cleanup failed", + exc_info=True, + ) if not self._defer_logical_completion: _complete_logical(self._logical, outcome=logical_outcome) self._logical = None diff --git a/tests/agent/test_bedrock_interrupt_post_worker.py b/tests/agent/test_bedrock_interrupt_post_worker.py index 0ee5e4fce33..52c6877d1f4 100644 --- a/tests/agent/test_bedrock_interrupt_post_worker.py +++ b/tests/agent/test_bedrock_interrupt_post_worker.py @@ -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 diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 274fc351647..75367fb93a1 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -214,6 +214,39 @@ def test_non_deferred_partial_stream_close_cancels_logical_call( assert {"outcome": "cancelled"} in terminal_outputs +def test_direct_stream_close_reaches_original_provider_resource(monkeypatch): + class ProviderStream: + def __init__(self): + self.closed = False + + def __iter__(self): + return iter([{"delta": "partial"}, {"delta": "unused"}]) + + def close(self): + self.closed = True + + provider_stream = ProviderStream() + monkeypatch.setattr( + relay_runtime, + "resolve_execution_context", + lambda _session_id: (None, None, None), + ) + + stream = relay_llm.stream( + {"model": "test-model", "messages": []}, + lambda _request: provider_stream, + session_id="session-1", + name="test-provider", + model_name="test-model", + finalizer=dict, + ) + + assert next(stream) == {"delta": "partial"} + stream.close() + + assert provider_stream.closed is True + + def test_non_stream_preserves_raw_provider_response_identity(relay_turn): _relay, _turn = relay_turn raw_response = SimpleNamespace(model="test-model", content="raw") diff --git a/tests/run_agent/test_streaming.py b/tests/run_agent/test_streaming.py index 55a0b9c008e..ceebff03525 100644 --- a/tests/run_agent/test_streaming.py +++ b/tests/run_agent/test_streaming.py @@ -95,6 +95,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): @@ -1203,6 +1248,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") From e862099dffb26e6b31bcc12a65194ecca035b7b2 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:32:44 -0700 Subject: [PATCH 037/526] fix(relay): merge Anthropic stream usage Signed-off-by: Alex Fournier --- agent/relay_llm.py | 7 ++++++- tests/agent/test_relay_llm.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 8b799b6e775..5aaaac58e7b 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -598,7 +598,12 @@ class AnthropicStreamAccumulator: if key in delta: self._message[key] = delta[key] if "usage" in payload: - self._message["usage"] = payload["usage"] + usage = payload["usage"] + current_usage = self._message.get("usage") + if isinstance(current_usage, dict) and isinstance(usage, dict): + self._message["usage"] = {**current_usage, **usage} + else: + self._message["usage"] = usage def finalize(self) -> dict[str, Any]: blocks = [] diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 75367fb93a1..05c040eb068 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -247,6 +247,39 @@ def test_direct_stream_close_reaches_original_provider_resource(monkeypatch): assert provider_stream.closed is True +def test_anthropic_stream_accumulator_merges_terminal_usage(): + accumulator = relay_llm.AnthropicStreamAccumulator() + accumulator.observe({ + "type": "message_start", + "message": { + "id": "message-1", + "type": "message", + "role": "assistant", + "model": "claude-test", + "usage": { + "input_tokens": 100, + "output_tokens": 1, + "cache_creation_input_tokens": 20, + "cache_read_input_tokens": 30, + }, + }, + }) + accumulator.observe({ + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 12}, + }) + + response = accumulator.finalize() + + assert response["usage"] == { + "input_tokens": 100, + "output_tokens": 12, + "cache_creation_input_tokens": 20, + "cache_read_input_tokens": 30, + } + + def test_non_stream_preserves_raw_provider_response_identity(relay_turn): _relay, _turn = relay_turn raw_response = SimpleNamespace(model="test-model", content="raw") From 591ba267d263d98bda05f9b931e0b0fba458f595 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:33:41 -0700 Subject: [PATCH 038/526] fix(relay): release session lock before callbacks Signed-off-by: Alex Fournier --- agent/relay_runtime.py | 13 +++++---- .../test_relay_shared_metrics_runtime.py | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 2b49725cbcf..895308b322d 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -201,14 +201,15 @@ class RelayRuntime: 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") + context = session.context.copy() - def invoke() -> Any: - self.relay.get_scope_stack() - return callback(*args, **kwargs) + 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 session.context.copy().run(invoke) + # 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, diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index 7b39d0973c6..1ef5a18dd88 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -1182,6 +1182,34 @@ def test_async_session_runner_awaits_inside_saved_relay_context(direct_runtime): assert result == session.handle +def test_sync_session_runner_releases_lock_before_callback(direct_runtime): + runtime = relay_runtime.get_runtime() + assert runtime is not None + session = runtime.ensure_session({"session_id": "sync-session"}) + assert session is not None + acquired = threading.Event() + contender = None + + def probe() -> Any: + nonlocal contender + + def acquire_session_lock() -> None: + with session.lock: + acquired.set() + + contender = threading.Thread(target=acquire_session_lock) + contender.start() + assert acquired.wait(timeout=1) + return direct_runtime._scope.get() + + result = runtime.run_in_session(session, probe) + assert contender is not None + contender.join(timeout=1) + + assert result == session.handle + assert contender.is_alive() is False + + def test_active_turn_requires_matching_session_and_profile( direct_runtime, tmp_path, From 36185bf2e2f6aa6b968fd8ef61f4c873ade1a3eb Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:36:00 -0700 Subject: [PATCH 039/526] feat(telemetry): expose shared metrics setup Signed-off-by: Alex Fournier --- hermes_cli/config.py | 8 +++++ hermes_cli/setup.py | 33 +++++++++++++++++++++ hermes_cli/subcommands/setup.py | 13 +++++++-- tests/hermes_cli/test_setup_telemetry.py | 37 ++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_setup_telemetry.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4645318aa2e..0c02e537b4a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3241,6 +3241,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: diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 0d1a154811b..1148e295364 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2199,6 +2199,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 # ============================================================================= @@ -2607,6 +2638,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), ] @@ -2705,6 +2737,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 diff --git a/hermes_cli/subcommands/setup.py b/hermes_cli/subcommands/setup.py index 406710a6887..fa0c0dc37c7 100644 --- a/hermes_cli/subcommands/setup.py +++ b/hermes_cli/subcommands/setup.py @@ -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", ) diff --git a/tests/hermes_cli/test_setup_telemetry.py b/tests/hermes_cli/test_setup_telemetry.py new file mode 100644 index 00000000000..e6ebcb428c4 --- /dev/null +++ b/tests/hermes_cli/test_setup_telemetry.py @@ -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 From ea171c947a30103f57f68aa98e0777f6e641a831 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:38:49 -0700 Subject: [PATCH 040/526] perf(relay): bypass inactive tool interception Signed-off-by: Alex Fournier --- agent/relay_runtime.py | 17 ++++- .../test_relay_shared_metrics_runtime.py | 69 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 895308b322d..c999749d842 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -23,6 +23,7 @@ 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 @@ -409,6 +410,9 @@ class RelayHostRegistry: 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: @@ -823,7 +827,7 @@ def apply_tool_request_intercepts( """Return Relay-rewritten arguments at Hermes's authorization boundary.""" if not session_id: return args - runtime = get_runtime() + runtime = get_runtime(create=False) if runtime is None: return args return runtime.apply_tool_request_intercepts( @@ -930,7 +934,15 @@ def get_host( def current_profile_key() -> str: """Return the canonical profile identity used for runtime isolation.""" - return str(get_hermes_home().expanduser().resolve()) + 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: @@ -946,3 +958,4 @@ 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() diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index 1ef5a18dd88..b0b7a6dbd26 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -498,6 +498,75 @@ def test_direct_runtime_is_disabled_by_default(tmp_path, monkeypatch): relay_runtime._reset_for_tests() +def test_tool_intercept_bypass_does_not_create_relay_host(monkeypatch): + relay_runtime._reset_for_tests() + imports = [] + + def load_relay(): + imports.append("nemo_relay") + raise AssertionError("disabled helper created Relay host") + + monkeypatch.setattr(relay_runtime, "_load_nemo_relay", load_relay) + args = {"command": "true"} + + assert ( + relay_runtime.apply_tool_request_intercepts( + session_id="s1", + tool_name="terminal", + args=args, + ) + is args + ) + assert relay_runtime.get_host(create=False) is None + assert imports == [] + + +def test_profile_key_caches_absolute_path_resolution(monkeypatch): + relay_runtime._reset_for_tests() + + class Home: + def __init__(self): + self.resolve_calls = 0 + + def expanduser(self): + return self + + def is_absolute(self): + return True + + def resolve(self): + self.resolve_calls += 1 + return self + + def __str__(self): + return "/profiles/cached" + + home = Home() + monkeypatch.setattr(relay_runtime, "get_hermes_home", lambda: home) + + assert relay_runtime.current_profile_key() == "/profiles/cached" + assert relay_runtime.current_profile_key() == "/profiles/cached" + assert home.resolve_calls == 1 + + +def test_host_registry_reads_existing_host_without_lock(): + registry = relay_runtime.RelayHostRegistry() + host = relay_runtime.NoopRelayRuntime("profile", "test") + registry._hosts["profile"] = host + + class UnexpectedLock: + def __enter__(self): + raise AssertionError("registry read acquired the write lock") + + def __exit__(self, *_args): + return False + + registry._lock = UnexpectedLock() + + assert registry.for_profile("profile", create=False) is host + assert registry.for_profile("missing", create=False) is None + + def test_core_runtime_is_fail_open_without_a_published_binding(monkeypatch, caplog): relay_shared_metrics._reset_for_tests() relay_runtime._reset_for_tests() From 2378bd4e4c2697e723ab914102620600423b9b53 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:40:44 -0700 Subject: [PATCH 041/526] fix(tools): prevent duplicate managed dispatch Signed-off-by: Alex Fournier --- agent/tool_executor.py | 11 ++- tests/run_agent/test_run_agent.py | 110 ++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index c63aa7037b4..f5886e39add 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -373,10 +373,19 @@ def _run_agent_tool_execution_middleware( "args": function_args, "middleware_trace": trace, "blocked": False, + "dispatched": False, } + dispatch_lock = threading.Lock() def _authorized_dispatch(final_args: dict[str, Any]) -> Any: - state["args"] = final_args + with dispatch_lock: + if state["dispatched"]: + raise RuntimeError( + "Hermes tool execution callback invoked more than once" + ) + state["dispatched"] = True + state["blocked"] = False + state["args"] = final_args def _begin() -> None: _begin_tool_execution( diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 81648dc6628..6a024a6bd53 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3664,6 +3664,116 @@ 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. From 0be07b0553a7fd59bfd95038b0bcc20b7cf85152 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:41:47 -0700 Subject: [PATCH 042/526] fix(middleware): hide Relay control flag Signed-off-by: Alex Fournier --- hermes_cli/middleware.py | 3 ++- tests/hermes_cli/test_plugins.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/hermes_cli/middleware.py b/hermes_cli/middleware.py index f5833aa12e7..a3b472a4563 100644 --- a/hermes_cli/middleware.py +++ b/hermes_cli/middleware.py @@ -132,7 +132,8 @@ def apply_tool_request_middleware( trace: List[Dict[str, Any]] = [] session_id = str(context.get("session_id") or "") - if session_id and not context.pop("skip_relay", False): + 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( diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 3fdb6d1812d..b540f70bd10 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -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 = [] From a54e52aeb119ebb72543ef44b70fbb5ee26c2dcb Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:43:27 -0700 Subject: [PATCH 043/526] fix(tools): preserve sequential middleware trace Signed-off-by: Alex Fournier --- agent/tool_executor.py | 4 +++ tests/run_agent/test_run_agent.py | 49 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index f5886e39add..16b9f0f4dd5 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -1640,6 +1640,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skip_pre_tool_call_hook=True, skip_tool_request_middleware=True, skip_tool_execution_middleware=True, + tool_request_middleware_trace=list(middleware_trace), enabled_toolsets=getattr(agent, "enabled_toolsets", None), disabled_toolsets=getattr(agent, "disabled_toolsets", None), ) @@ -1659,6 +1660,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe execute=_execute, scope_block=_ts_scope_block, display_index=i, + middleware_trace=middleware_trace, ) ) _spinner_result = function_result @@ -1708,6 +1710,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe skip_pre_tool_call_hook=True, skip_tool_request_middleware=True, skip_tool_execution_middleware=True, + tool_request_middleware_trace=list(middleware_trace), enabled_toolsets=getattr(agent, "enabled_toolsets", None), disabled_toolsets=getattr(agent, "disabled_toolsets", None), ) @@ -1727,6 +1730,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe execute=_execute, scope_block=_ts_scope_block, display_index=i, + middleware_trace=middleware_trace, ) ) except KeyboardInterrupt: diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 6a024a6bd53..6e017a14126 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3165,6 +3165,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( From 1500ce163c42ca0eff01450e0581f860d7d5df59 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:44:27 -0700 Subject: [PATCH 044/526] fix(relay): normalize rewritten LLM responses Signed-off-by: Alex Fournier --- agent/relay_llm.py | 4 ++-- tests/agent/test_relay_llm.py | 37 ++++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 5aaaac58e7b..198d7813a0d 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -96,7 +96,7 @@ def execute( _complete_logical(logical, outcome="success") if "value" in raw_response and _json_equal(managed, raw_response["json"]): return raw_response["value"] - return managed + return _namespace(managed) async def execute_async( @@ -170,7 +170,7 @@ async def execute_async( _complete_logical(logical, outcome="success") if "value" in raw_response and _json_equal(managed, raw_response["json"]): return raw_response["value"] - return managed + return _namespace(managed) def execute_current( diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 05c040eb068..7985dd6d46a 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -784,7 +784,42 @@ def test_non_stream_returns_post_execution_interceptor_result(relay_turn, monkey metadata={"api_mode": "custom", "api_request_id": "request-post"}, ) - assert result == {"content": "raw", "post_interceptor": True} + assert result.content == "raw" + assert result.post_interceptor is True + + +@pytest.mark.asyncio +async def test_async_non_stream_returns_namespaced_interceptor_result( + relay_turn, + monkeypatch, +): + relay, _turn = relay_turn + + async def post_execute(_name, request, callback, **_kwargs): + response = await callback(request) + return { + **response, + "post_interceptor": True, + "usage": {"input_tokens": 10}, + } + + monkeypatch.setattr(relay.llm, "execute", post_execute) + + async def provider(_request): + return {"content": "raw"} + + result = await relay_llm.execute_async( + {"model": "test-model", "messages": []}, + provider, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={"api_mode": "custom", "api_request_id": "request-async-post"}, + ) + + assert result.content == "raw" + assert result.post_interceptor is True + assert result.usage.input_tokens == 10 def test_non_stream_preserves_provider_error_from_relay_wrapper_suffix( From 937fffcfecc5b48c62d7a44e0f3a2ead168e3e89 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:45:40 -0700 Subject: [PATCH 045/526] fix(smoke): resolve Hermes across environments Signed-off-by: Alex Fournier --- scripts/smoke_nemo_relay_shared_metrics.py | 22 ++++++++-- .../test_smoke_nemo_relay_shared_metrics.py | 41 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 tests/scripts/test_smoke_nemo_relay_shared_metrics.py diff --git a/scripts/smoke_nemo_relay_shared_metrics.py b/scripts/smoke_nemo_relay_shared_metrics.py index 93f4562a99d..38a42641d1b 100644 --- a/scripts/smoke_nemo_relay_shared_metrics.py +++ b/scripts/smoke_nemo_relay_shared_metrics.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import json import os +import shutil import sqlite3 import subprocess import sys @@ -21,6 +22,23 @@ 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.""" @@ -321,9 +339,7 @@ def main() -> int: args = _arguments() hermes_repo = args.hermes_repo.resolve() relay_python = args.relay_python.resolve() if args.relay_python else None - hermes = hermes_repo / ".venv" / "bin" / "hermes" - if not hermes.is_file(): - raise SystemExit(f"Hermes executable not found: {hermes}") + hermes = _resolve_hermes_executable(hermes_repo) if relay_python is not None and not any( (relay_python / "nemo_relay").glob("_native.*") ): diff --git a/tests/scripts/test_smoke_nemo_relay_shared_metrics.py b/tests/scripts/test_smoke_nemo_relay_shared_metrics.py new file mode 100644 index 00000000000..d31a90bb439 --- /dev/null +++ b/tests/scripts/test_smoke_nemo_relay_shared_metrics.py @@ -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) From e9b5e4c8f4e1b52f25ee7a2cf062f924b278cc7a Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:48:52 -0700 Subject: [PATCH 046/526] fix(relay): protect turn initialization cleanup Signed-off-by: Alex Fournier --- run_agent.py | 163 +++++++++++++++++------------- tests/run_agent/test_run_agent.py | 44 ++++++++ 2 files changed, 136 insertions(+), 71 deletions(-) diff --git a/run_agent.py b/run_agent.py index 94a2f119509..132d072474a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6601,44 +6601,52 @@ class AIAgent: if task_context["platform"] == "subagent" else "" ) - 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 "", - ) - # 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=`` 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 + 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=`` 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 scoped_runtime_main({}): - relay_outcome = "failed" - try: + # 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 scoped_runtime_main({}): result = run_conversation( self, user_message, @@ -6650,50 +6658,63 @@ class AIAgent: persist_user_timestamp=persist_user_timestamp, moa_config=moa_config, ) - 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" - relay_runtime.SESSION_COORDINATOR.finish_logical_calls( - relay_turn, - outcome=relay_outcome, - ) - finish_task_run(**task_context, error=exc) - raise + 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: - 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_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, ) - finish_task_run(**task_context, result=result) - return result - finally: - try: + 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: + try: + if relay_lease is not None: + try: + if relay_lease.parent_session_id: + relay_runtime.SESSION_COORDINATOR.finalize_conversation( + profile_key=relay_lease.profile_key, + session_id=relay_lease.session_id, + ) + finally: + relay_runtime.SESSION_COORDINATOR.release_conversation( + relay_lease + ) finally: - if relay_lease.parent_session_id: - relay_runtime.SESSION_COORDINATOR.finalize_conversation( - profile_key=relay_lease.profile_key, - session_id=relay_lease.session_id, - ) - relay_runtime.SESSION_COORDINATOR.release_conversation(relay_lease) - if getattr(self, "_relay_pending_turn_id", None) == relay_turn_id: - self._relay_pending_turn_id = None - reset_accounting_context(acct_token) - reset_conversation_context(token) + 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: """ diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 6e017a14126..afc9abbf4f4 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4433,6 +4433,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") From 4788994bd27d5f1f5d5687d9f3e8815389c41395 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:53:59 -0700 Subject: [PATCH 047/526] fix(relay): preserve managed callback context Signed-off-by: Alex Fournier --- agent/relay_llm.py | 43 ++++++--- agent/relay_runtime.py | 12 ++- agent/relay_tools.py | 4 +- tests/agent/test_relay_llm.py | 94 +++++++++++++++++++ tests/agent/test_relay_tools.py | 16 ++++ .../test_relay_shared_metrics_runtime.py | 75 +++++++++++++++ 6 files changed, 230 insertions(+), 14 deletions(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 198d7813a0d..0189f792dfb 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextvars import inspect import json import logging @@ -44,6 +45,7 @@ def execute( relay_request = runtime.relay.LLMRequest({}, relay_request_body) raw_response: dict[str, Any] = {} callback_error: BaseException | None = None + callback_context = contextvars.copy_context() def invoke(next_request: Any) -> Any: nonlocal callback_error @@ -54,7 +56,7 @@ def execute( relay_request_body=relay_request_body, metadata=metadata, ) - raw = callback(final_request) + raw = callback_context.copy().run(callback, final_request) except BaseException as exc: callback_error = exc raise @@ -120,6 +122,7 @@ async def execute_async( relay_request = runtime.relay.LLMRequest({}, relay_request_body) raw_response: dict[str, Any] = {} callback_error: BaseException | None = None + callback_context = contextvars.copy_context() async def invoke(next_request: Any) -> Any: nonlocal callback_error @@ -130,7 +133,14 @@ async def execute_async( relay_request_body=relay_request_body, metadata=metadata, ) - raw = await callback(final_request) + async def call_provider() -> Any: + return await callback(final_request) + + task = callback_context.copy().run( + asyncio.create_task, + call_provider(), + ) + raw = await task except BaseException as exc: callback_error = exc raise @@ -316,6 +326,7 @@ class ManagedLlmStream(Iterator[Any]): self._provider_completed = False self._raw_chunks: list[tuple[Any, Any]] = [] self.output_modified = False + callback_context = contextvars.copy_context() runtime, session, parent = relay_runtime.resolve_execution_context(session_id) if ( @@ -345,7 +356,8 @@ class ManagedLlmStream(Iterator[Any]): async def provider_stream(next_request: Any): raw_stream = None try: - raw_stream = stream_factory( + raw_stream = callback_context.run( + stream_factory, _provider_request( request, next_request, @@ -355,16 +367,25 @@ class ManagedLlmStream(Iterator[Any]): ) if ( completed_response_predicate is not None - and completed_response_predicate(raw_stream) + and callback_context.run( + completed_response_predicate, + raw_stream, + ) ): self.final_response = raw_stream self._provider_completed = True return if on_stream_created is not None: - on_stream_created(raw_stream) - for chunk in raw_stream: - if self._accept_chunk is not None and not self._accept_chunk( - chunk + callback_context.run(on_stream_created, raw_stream) + raw_iterator = callback_context.run(iter, raw_stream) + while True: + try: + chunk = callback_context.run(next, raw_iterator) + except StopIteration: + break + if self._accept_chunk is not None and not callback_context.run( + self._accept_chunk, + chunk, ): break encoded_chunk = _jsonable(chunk) @@ -377,17 +398,17 @@ class ManagedLlmStream(Iterator[Any]): finally: close = getattr(raw_stream, "close", None) if callable(close): - close() + callback_context.run(close) def observe_chunk(chunk: Any) -> None: if self._on_chunk is not None: - self._on_chunk(_jsonable(chunk)) + callback_context.run(self._on_chunk, _jsonable(chunk)) def relay_finalizer() -> Any: try: if self.final_response is not None: return _jsonable(self.final_response) - return _jsonable(finalizer()) + return _jsonable(callback_context.run(finalizer)) except BaseException as exc: self._callback_error = exc raise diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index c999749d842..706ca04608a 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -202,7 +202,11 @@ class RelayRuntime: 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") - context = session.context.copy() + 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() @@ -226,7 +230,11 @@ class RelayRuntime: 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") - context = session.context.copy() + 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() diff --git a/agent/relay_tools.py b/agent/relay_tools.py index 9fb3b487e7e..3972b97d93a 100644 --- a/agent/relay_tools.py +++ b/agent/relay_tools.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextvars import inspect import json import logging @@ -30,12 +31,13 @@ def execute( 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(observed_args) + result = callback_context.copy().run(callback, observed_args) except BaseException as exc: callback_error = exc raise diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 7985dd6d46a..ab3b9a261aa 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import contextvars import json from types import SimpleNamespace @@ -296,6 +297,99 @@ def test_non_stream_preserves_raw_provider_response_identity(relay_turn): assert result is raw_response +def test_non_stream_provider_callback_preserves_caller_context(relay_turn): + del relay_turn + caller_value = contextvars.ContextVar("llm_caller_value", default="default") + caller_value.set("caller") + + result = relay_llm.execute( + {"model": "test-model", "messages": []}, + lambda _request: {"caller_value": caller_value.get()}, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={"api_mode": "custom", "api_request_id": "request-context"}, + ) + + assert result == {"caller_value": "caller"} + + +@pytest.mark.asyncio +async def test_async_provider_callback_preserves_caller_context(relay_turn): + del relay_turn + caller_value = contextvars.ContextVar( + "async_llm_caller_value", + default="default", + ) + caller_value.set("caller") + + async def provider(_request): + await asyncio.sleep(0) + return {"caller_value": caller_value.get()} + + result = await relay_llm.execute_async( + {"model": "test-model", "messages": []}, + provider, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={ + "api_mode": "custom", + "api_request_id": "request-async-context", + }, + ) + + assert result == {"caller_value": "caller"} + + +def test_stream_provider_callbacks_preserve_caller_context(relay_turn): + del relay_turn + caller_value = contextvars.ContextVar( + "stream_llm_caller_value", + default="default", + ) + caller_value.set("caller") + observed = [] + + def stream_factory(_request): + observed.append(("factory", caller_value.get())) + + def generate(): + observed.append(("next", caller_value.get())) + yield {"delta": "hello"} + + return generate() + + def on_chunk(_chunk): + observed.append(("chunk", caller_value.get())) + + def finalizer(): + observed.append(("finalizer", caller_value.get())) + return {"content": "hello"} + + stream = relay_llm.stream( + {"model": "test-model", "messages": []}, + stream_factory, + session_id="session-1", + name="test-provider", + model_name="test-model", + finalizer=finalizer, + on_chunk=on_chunk, + metadata={ + "api_mode": "custom", + "api_request_id": "request-stream-context", + }, + ) + + assert list(stream) == [{"delta": "hello"}] + assert observed == [ + ("factory", "caller"), + ("next", "caller"), + ("chunk", "caller"), + ("finalizer", "caller"), + ] + + def test_non_stream_does_not_forward_relay_session_headers(relay_turn): _relay, _turn = relay_turn captured_requests = [] diff --git a/tests/agent/test_relay_tools.py b/tests/agent/test_relay_tools.py index 785dddf2c45..e7d15581f61 100644 --- a/tests/agent/test_relay_tools.py +++ b/tests/agent/test_relay_tools.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextvars import json import pytest @@ -124,6 +125,21 @@ def test_request_rewrite_reaches_authorized_callback_once(relay_turn): 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 diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index b0b7a6dbd26..737ae6a0136 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -1251,6 +1251,81 @@ def test_async_session_runner_awaits_inside_saved_relay_context(direct_runtime): assert result == session.handle +def test_session_runners_preserve_caller_context_and_profile_override( + direct_runtime, + tmp_path, +): + from hermes_constants import ( + get_hermes_home_override, + reset_hermes_home_override, + set_hermes_home_override, + ) + + runtime = relay_runtime.get_runtime() + assert runtime is not None + session = runtime.ensure_session({"session_id": "caller-context-session"}) + assert session is not None + caller_value = contextvars.ContextVar("caller_value", default="default") + caller_value.set("caller") + profile = tmp_path / "caller-profile" + profile_token = set_hermes_home_override(profile) + + def sync_probe() -> tuple[Any, str, str | None]: + return ( + direct_runtime._scope.get(), + caller_value.get(), + get_hermes_home_override(), + ) + + async def async_probe() -> tuple[Any, str, str | None]: + await asyncio.sleep(0) + return sync_probe() + + try: + sync_result = runtime.run_in_session(session, sync_probe) + async_result = asyncio.run(runtime.run_in_session_async(session, async_probe)) + finally: + reset_hermes_home_override(profile_token) + + expected = (session.handle, "caller", str(profile)) + assert sync_result == expected + assert async_result == expected + + +@pytest.mark.asyncio +async def test_async_session_runner_isolates_concurrent_caller_contexts( + direct_runtime, +): + runtime = relay_runtime.get_runtime() + assert runtime is not None + session = runtime.ensure_session({"session_id": "concurrent-context-session"}) + assert session is not None + caller_value = contextvars.ContextVar("concurrent_caller", default="default") + ready = asyncio.Event() + entered = 0 + + async def run(value: str) -> tuple[Any, str]: + nonlocal entered + caller_value.set(value) + + async def probe() -> tuple[Any, str]: + nonlocal entered + entered += 1 + if entered == 2: + ready.set() + await ready.wait() + return direct_runtime._scope.get(), caller_value.get() + + return await runtime.run_in_session_async(session, probe) + + results = await asyncio.gather(run("first"), run("second")) + + assert results == [ + (session.handle, "first"), + (session.handle, "second"), + ] + + def test_sync_session_runner_releases_lock_before_callback(direct_runtime): runtime = relay_runtime.get_runtime() assert runtime is not None From 31387a4621107ebf0e0928f5bb40c16b38be0850 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:57:30 -0700 Subject: [PATCH 048/526] test(tools): verify hook ownership behaviorally Signed-off-by: Alex Fournier --- tests/run_agent/test_run_agent.py | 185 ++++++++++++++---------------- 1 file changed, 85 insertions(+), 100 deletions(-) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index afc9abbf4f4..ce28322a4cc 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3825,115 +3825,100 @@ class TestConcurrentToolExecution: 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 the first agent-runtime tool, - ``function_name == "todo"``. Pre-dispatch tool-name checks live outside - that tower and are explicitly skipped. - """ - - @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]: - """Return literals from the if/elif chain anchored on ``todo``.""" - 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 cls._function_name_literal(node.test) != "todo": - 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 agent-runtime dispatch chain anchored on " - "`function_name == 'todo'` in execute_tool_calls_sequential." + 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: From e1a6becf862db7050c41b4ecd123b9992785386c Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 13:58:22 -0700 Subject: [PATCH 049/526] docs(telemetry): disclose local profile identity Signed-off-by: Alex Fournier --- cli-config.yaml.example | 2 ++ docs/observability/relay-shared-metrics.md | 13 +++++++++++++ .../schemas/hermes.shared_metrics.v1.schema.json | 1 + 3 files changed, 16 insertions(+) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 67264b1c092..0821bcfcd2b 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1408,6 +1408,8 @@ display: # 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. telemetry: shared_metrics: enabled: false diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md index 60709c8c640..b9dd9bd15a6 100644 --- a/docs/observability/relay-shared-metrics.md +++ b/docs/observability/relay-shared-metrics.md @@ -74,6 +74,19 @@ 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. +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: diff --git a/hermes_cli/observability/schemas/hermes.shared_metrics.v1.schema.json b/hermes_cli/observability/schemas/hermes.shared_metrics.v1.schema.json index 6c067d0eb8e..68496e5ab6f 100644 --- a/hermes_cli/observability/schemas/hermes.shared_metrics.v1.schema.json +++ b/hermes_cli/observability/schemas/hermes.shared_metrics.v1.schema.json @@ -22,6 +22,7 @@ "$ref": "#/$defs/uuid" }, "install_id": { + "description": "Random persistent identifier scoped to one HERMES_HOME; local-only in schema v1.", "$ref": "#/$defs/uuid" }, "period_start": { From bba63d87862fbc8ad668a94f9e704ee72a088262 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 14:01:41 -0700 Subject: [PATCH 050/526] fix(telemetry): prune expired local metrics Signed-off-by: Alex Fournier --- cli-config.yaml.example | 2 + docs/observability/relay-shared-metrics.md | 4 +- hermes_cli/observability/shared_metrics.py | 77 +++++++++++++++++- tests/hermes_cli/test_relay_shared_metrics.py | 78 +++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 0821bcfcd2b..0140c566ed6 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1410,6 +1410,8 @@ display: # 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. telemetry: shared_metrics: enabled: false diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md index b9dd9bd15a6..ed9a77bd9e8 100644 --- a/docs/observability/relay-shared-metrics.md +++ b/docs/observability/relay-shared-metrics.md @@ -72,7 +72,9 @@ $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. +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 diff --git a/hermes_cli/observability/shared_metrics.py b/hermes_cli/observability/shared_metrics.py index 0c4912a0a80..506fb4ab131 100644 --- a/hermes_cli/observability/shared_metrics.py +++ b/hermes_cli/observability/shared_metrics.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import sqlite3 import uuid from collections.abc import Iterator @@ -26,6 +27,9 @@ _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: @@ -110,7 +114,15 @@ class SharedMetricsStore: for _ in range(pending_periods): if self._create_package() is None: break - return self._export_pending_packages() + 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.""" @@ -409,3 +421,66 @@ class SharedMetricsStore: ) 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,), + ) diff --git a/tests/hermes_cli/test_relay_shared_metrics.py b/tests/hermes_cli/test_relay_shared_metrics.py index 6dfb11eec9c..058e07db7bc 100644 --- a/tests/hermes_cli/test_relay_shared_metrics.py +++ b/tests/hermes_cli/test_relay_shared_metrics.py @@ -12,6 +12,7 @@ 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 @@ -586,6 +587,83 @@ def test_pending_package_retry_reuses_the_same_package_and_file(tmp_path): 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 ): From 43d994986ee5f20ad267d9798f687dad937e0345 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 14:04:07 -0700 Subject: [PATCH 051/526] fix(telemetry): keep consent profile-owned Signed-off-by: Alex Fournier --- cli-config.yaml.example | 1 + docs/observability/relay-shared-metrics.md | 4 ++ .../observability/relay_shared_metrics.py | 7 +- .../test_relay_shared_metrics_runtime.py | 67 +++++++++++++++++-- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 0140c566ed6..0c7d708d13c 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1412,6 +1412,7 @@ display: # 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 diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md index ed9a77bd9e8..05a9a3eff42 100644 --- a/docs/observability/relay-shared-metrics.md +++ b/docs/observability/relay-shared-metrics.md @@ -21,6 +21,10 @@ telemetry: 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. diff --git a/hermes_cli/observability/relay_shared_metrics.py b/hermes_cli/observability/relay_shared_metrics.py index 2108cf454c9..fb6f885b2f0 100644 --- a/hermes_cli/observability/relay_shared_metrics.py +++ b/hermes_cli/observability/relay_shared_metrics.py @@ -615,9 +615,12 @@ 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 load_config_readonly + from hermes_cli.config import read_raw_config - config = load_config_readonly() or {} + # 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 diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index 737ae6a0136..fe527f0c184 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -146,7 +146,7 @@ def direct_runtime(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) monkeypatch.setattr( - "hermes_cli.config.load_config_readonly", + "hermes_cli.config.read_raw_config", lambda: {"telemetry": {"shared_metrics": {"enabled": True}}}, ) relay_shared_metrics._reset_for_tests() @@ -164,7 +164,7 @@ def real_binding_runtime(tmp_path, monkeypatch): pytest.skip("NeMo Relay native binding is unavailable on this platform") monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) monkeypatch.setattr( - "hermes_cli.config.load_config_readonly", + "hermes_cli.config.read_raw_config", lambda: {"telemetry": {"shared_metrics": {"enabled": True}}}, ) relay_shared_metrics._reset_for_tests() @@ -483,7 +483,7 @@ def test_direct_runtime_is_disabled_by_default(tmp_path, monkeypatch): fake = _Relay() monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) - monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {}) + monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {}) relay_shared_metrics._reset_for_tests() relay_runtime._reset_for_tests() monkeypatch.setattr(plugins, "_plugin_manager", PluginManager()) @@ -864,7 +864,7 @@ def test_core_mark_lazily_starts_relay_without_metrics_or_a_plugin( fake = _Relay() monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) - monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {}) + monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {}) relay_shared_metrics._reset_for_tests() relay_runtime._reset_for_tests() monkeypatch.setattr(plugins, "_plugin_manager", PluginManager()) @@ -944,6 +944,59 @@ def test_core_runtime_isolates_same_session_id_by_profile(direct_runtime, tmp_pa assert session_a.handle != session_b.handle +@pytest.mark.parametrize( + ("profile_enabled", "managed_enabled"), + ((None, True), (False, True), (True, False)), +) +def test_managed_config_cannot_override_shared_metrics_consent( + tmp_path, + monkeypatch, + profile_enabled, + managed_enabled, +): + from hermes_cli import config, managed_scope + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + profile = tmp_path / "profile" + managed = tmp_path / "managed" + profile.mkdir() + managed.mkdir() + profile_config = "{}\n" + if profile_enabled is not None: + profile_config = ( + "telemetry:\n" + " shared_metrics:\n" + f" enabled: {str(profile_enabled).lower()}\n" + ) + (profile / "config.yaml").write_text(profile_config, encoding="utf-8") + (managed / "config.yaml").write_text( + "telemetry:\n" + " shared_metrics:\n" + f" enabled: {str(managed_enabled).lower()}\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed)) + config._LOAD_CONFIG_CACHE.clear() + config._RAW_CONFIG_CACHE.clear() + managed_scope.invalidate_managed_cache() + + token = set_hermes_home_override(profile) + try: + assert ( + config.load_config_readonly()["telemetry"]["shared_metrics"]["enabled"] + is managed_enabled + ) + assert relay_shared_metrics.enabled() is (profile_enabled is True) + finally: + reset_hermes_home_override(token) + relay_shared_metrics._reset_for_tests() + relay_runtime._reset_for_tests() + managed_scope.invalidate_managed_cache() + + def test_shared_metrics_policy_and_store_are_profile_scoped(tmp_path, monkeypatch): from hermes_constants import ( get_hermes_home, @@ -956,7 +1009,7 @@ def test_shared_metrics_policy_and_store_are_profile_scoped(tmp_path, monkeypatc profile_b = tmp_path / "profile-b" monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) monkeypatch.setattr( - "hermes_cli.config.load_config_readonly", + "hermes_cli.config.read_raw_config", lambda: { "telemetry": { "shared_metrics": {"enabled": get_hermes_home() == profile_a} @@ -1012,7 +1065,7 @@ def test_shared_metrics_subscribers_isolate_two_enabled_profiles(tmp_path, monke profile_b = tmp_path / "profile-b" monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) monkeypatch.setattr( - "hermes_cli.config.load_config_readonly", + "hermes_cli.config.read_raw_config", lambda: {"telemetry": {"shared_metrics": {"enabled": True}}}, ) relay_shared_metrics._reset_for_tests() @@ -1149,7 +1202,7 @@ def test_disabling_shared_metrics_stops_collection_and_shutdown_export( monkeypatch.setenv("HERMES_HOME", str(profile)) monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) monkeypatch.setattr( - "hermes_cli.config.load_config_readonly", + "hermes_cli.config.read_raw_config", lambda: {"telemetry": {"shared_metrics": dict(policy)}}, ) relay_shared_metrics._reset_for_tests() From 6fa96916a8eb981365668339c1fb1e419a357c03 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 14:05:11 -0700 Subject: [PATCH 052/526] docs(relay): disclose managed data boundary Signed-off-by: Alex Fournier --- docs/observability/relay-shared-metrics.md | 33 ++++++++++++++++------ pyproject.toml | 4 ++- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md index 05a9a3eff42..8c87da044ff 100644 --- a/docs/observability/relay-shared-metrics.md +++ b/docs/observability/relay-shared-metrics.md @@ -9,9 +9,25 @@ 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 RC 3 or later. That release establishes the -lossless provider-codec contract used for Anthropic Messages, OpenAI Chat -Completions, and OpenAI Responses requests. +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: @@ -49,11 +65,12 @@ Hermes turn, API, and tool hooks -> immutable JSON delta package ``` -Hermes sends an empty `LLMRequest` into this metrics lifecycle. The terminal -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. +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 diff --git a/pyproject.toml b/pyproject.toml index aac9389df35..03da3b98b18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,7 +138,9 @@ dependencies = [ # / 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. Native wheels target these platforms. + # 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')", ] From d2e179c54f5495457a7fe7f6409fb6a5dd712172 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 14:05:36 -0700 Subject: [PATCH 053/526] test(lifecycle): document finalize coverage owner Signed-off-by: Alex Fournier --- tests/cli/test_session_boundary_hooks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/cli/test_session_boundary_hooks.py b/tests/cli/test_session_boundary_hooks.py index ba4f13d2c10..0a78ad1f88c 100644 --- a/tests/cli/test_session_boundary_hooks.py +++ b/tests/cli/test_session_boundary_hooks.py @@ -10,6 +10,9 @@ def test_session_hooks_in_valid_hooks(): assert "on_session_reset" in VALID_HOOKS +# 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): From 5e1b68ce163cbff0b859a04efed1c7706dbba0f8 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 14:06:33 -0700 Subject: [PATCH 054/526] test(telemetry): mock profile consent source Signed-off-by: Alex Fournier --- tests/plugins/test_nemo_relay_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/plugins/test_nemo_relay_plugin.py b/tests/plugins/test_nemo_relay_plugin.py index 0dc4683d94b..6081f0660f7 100644 --- a/tests/plugins/test_nemo_relay_plugin.py +++ b/tests/plugins/test_nemo_relay_plugin.py @@ -381,7 +381,7 @@ def test_shared_metrics_and_rich_plugin_share_one_core_session( "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif") ) monkeypatch.setattr( - "hermes_cli.config.load_config_readonly", + "hermes_cli.config.read_raw_config", lambda: {"telemetry": {"shared_metrics": {"enabled": True}}}, ) plugin = _fresh_plugin(monkeypatch, fake) From ad6fb26681b7ae134741366392e9c6ebf10b74ee Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 14:09:33 -0700 Subject: [PATCH 055/526] fix(relay): fence bypassed stream chunks Signed-off-by: Alex Fournier --- agent/relay_llm.py | 6 +++++- tests/agent/test_relay_llm.py | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 0189f792dfb..7480af3da5f 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -462,10 +462,14 @@ class ManagedLlmStream(Iterator[Any]): raise StopIteration if self._loop is None: try: - return next(self._stream) + chunk = next(self._stream) except StopIteration: self.close() raise + if self._accept_chunk is not None and not self._accept_chunk(chunk): + self.close() + raise StopIteration + return chunk async def next_chunk() -> Any: return await anext(self._stream) diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index ab3b9a261aa..c65ba2292c8 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -759,6 +759,33 @@ def test_stream_bypasses_relay_without_an_active_consumer(relay_turn, monkeypatc assert observed == [request] +def test_bypassed_stream_still_honors_chunk_acceptance(relay_turn): + _relay, turn = relay_turn + turn.lease.host.release_managed_execution("test.relay_llm") + provider_closed = [] + + def provider_stream(_request): + try: + yield {"delta": "accepted"} + yield {"delta": "rejected"} + yield {"delta": "unreachable"} + finally: + provider_closed.append(True) + + stream = relay_llm.stream( + {"model": "test-model", "messages": []}, + provider_stream, + session_id="session-1", + name="test-provider", + model_name="test-model", + finalizer=dict, + accept_chunk=lambda chunk: chunk["delta"] != "rejected", + ) + + assert list(stream) == [{"delta": "accepted"}] + assert provider_closed == [True] + + def test_anthropic_codec_preserves_tool_history_and_cached_system_blocks(relay_turn): _relay, _turn = relay_turn request = { From 06ed41aa1f4fdd4e319e58e55897a8cf7c6c8a00 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 14:21:37 -0700 Subject: [PATCH 056/526] fix(relay): preserve managed cancellation signals Signed-off-by: Alex Fournier --- agent/relay_llm.py | 39 ++++++++++-- agent/relay_tools.py | 6 +- tests/agent/test_relay_llm.py | 105 ++++++++++++++++++++++++++++++++ tests/agent/test_relay_tools.py | 20 ++++++ 4 files changed, 163 insertions(+), 7 deletions(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 7480af3da5f..b4f18e935e8 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -87,6 +87,7 @@ def execute( raise callback_error if _recover_successful_callback( raw_response, + relay_error=exc, callback_error=callback_error, logical=logical, defer_logical_completion=defer_logical_completion, @@ -169,6 +170,7 @@ async def execute_async( raise callback_error if _recover_successful_callback( raw_response, + relay_error=exc, callback_error=callback_error, logical=logical, defer_logical_completion=defer_logical_completion, @@ -433,8 +435,12 @@ class ManagedLlmStream(Iterator[Any]): response_codec=_codec(runtime.relay, metadata), ) ) - except BaseException: - if self._provider_completed and self._callback_error is None: + except BaseException as exc: + if ( + isinstance(exc, Exception) + and self._provider_completed + and self._callback_error is None + ): logger.warning( "NeMo Relay stream post-processing failed after provider success; " "preserving the provider result", @@ -448,7 +454,10 @@ class ManagedLlmStream(Iterator[Any]): self._stream = iter(()) return if not self._defer_logical_completion: - _complete_logical(self._logical, outcome="failed") + _complete_logical( + self._logical, + outcome="cancelled" if _is_cancellation(exc) else "failed", + ) self._logical = None loop.close() self._loop = None @@ -492,7 +501,11 @@ class ManagedLlmStream(Iterator[Any]): ): self._close(logical_outcome="failed") raise callback_error - if self._provider_completed and callback_error is None: + if ( + isinstance(exc, Exception) + and self._provider_completed + and callback_error is None + ): logger.warning( "NeMo Relay stream post-processing failed after provider success; " "preserving the provider result", @@ -500,7 +513,9 @@ class ManagedLlmStream(Iterator[Any]): ) self._close(logical_outcome="success") raise StopIteration from None - self._close(logical_outcome="failed") + self._close( + logical_outcome="cancelled" if _is_cancellation(exc) else "failed" + ) raise if not self._relay_observes_chunks and self._on_chunk is not None: self._on_chunk(chunk) @@ -751,11 +766,16 @@ def _complete_logical( def _recover_successful_callback( raw_response: dict[str, Any], *, + relay_error: BaseException, callback_error: BaseException | None, logical: tuple[relay_runtime.RelayTurnContext, Any, str] | None, defer_logical_completion: bool, ) -> bool: - if callback_error is not None or "value" not in raw_response: + if ( + not isinstance(relay_error, Exception) + or callback_error is not None + or "value" not in raw_response + ): return False logger.warning( "NeMo Relay LLM post-processing failed after provider success; " @@ -767,6 +787,13 @@ def _recover_successful_callback( return True +def _is_cancellation(error: BaseException) -> bool: + return isinstance( + error, + (asyncio.CancelledError, InterruptedError, KeyboardInterrupt), + ) + + def complete_logical_call(api_request_id: str, *, outcome: str) -> None: """Complete the active turn's logical LLM call after caller validation.""" turn = relay_runtime.active_turn() diff --git a/agent/relay_tools.py b/agent/relay_tools.py index 3972b97d93a..5023df1bcf9 100644 --- a/agent/relay_tools.py +++ b/agent/relay_tools.py @@ -63,7 +63,11 @@ def execute( and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error) ): raise callback_error - if callback_error is None and "value" in raw_result: + 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", diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index c65ba2292c8..524a91fb090 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -508,6 +508,34 @@ def test_non_stream_returns_provider_response_after_relay_post_processing_failur assert "returning the provider response" in caplog.text +def test_non_stream_does_not_swallow_interrupt_after_provider_success( + relay_turn, monkeypatch +): + relay, turn = relay_turn + + async def interrupt_after_callback(_name, request, callback, **_kwargs): + callback(request) + raise KeyboardInterrupt + + monkeypatch.setattr(relay.llm, "execute", interrupt_after_callback) + + with pytest.raises(KeyboardInterrupt): + relay_llm.execute( + {"model": "test-model", "messages": []}, + lambda _request: {"content": "already returned"}, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={ + "api_mode": "custom", + "api_request_id": "request-post-interrupt", + }, + ) + + assert "request-post-interrupt" in turn.logical_llm_calls + relay_llm.complete_logical_call("request-post-interrupt", outcome="cancelled") + + @pytest.mark.asyncio async def test_async_non_stream_preserves_raw_provider_response_identity(relay_turn): _relay, _turn = relay_turn @@ -560,6 +588,40 @@ async def test_async_non_stream_returns_provider_response_after_relay_failure( assert "returning the provider response" in caplog.text +@pytest.mark.asyncio +async def test_async_non_stream_does_not_swallow_cancellation_after_provider_success( + relay_turn, monkeypatch +): + relay, turn = relay_turn + + async def provider(_request): + return {"content": "already returned"} + + async def cancel_after_callback(_name, request, callback, **_kwargs): + await callback(request) + raise asyncio.CancelledError + + monkeypatch.setattr(relay.llm, "execute", cancel_after_callback) + + with pytest.raises(asyncio.CancelledError): + await relay_llm.execute_current_async( + {"model": "test-model", "messages": []}, + provider, + name="test-provider", + model_name="test-model", + metadata={ + "api_mode": "custom", + "api_request_id": "request-async-post-cancel", + }, + ) + + assert "request-async-post-cancel" in turn.logical_llm_calls + relay_llm.complete_logical_call( + "request-async-post-cancel", + outcome="cancelled", + ) + + @pytest.mark.asyncio async def test_async_non_stream_defers_logical_success_for_validation(relay_turn): _relay, turn = relay_turn @@ -628,6 +690,49 @@ def test_stream_finishes_after_relay_post_processing_failure( assert "preserving the provider result" in caplog.text +def test_stream_does_not_swallow_interrupt_after_provider_success( + relay_turn, monkeypatch +): + relay, turn = relay_turn + + async def interrupt_after_stream( + _name, + request, + callback, + observe_chunk, + finalizer, + **_kwargs, + ): + async def generate(): + upstream = callback(request) + async for chunk in upstream: + observe_chunk(chunk) + yield chunk + finalizer() + raise KeyboardInterrupt + + return generate() + + monkeypatch.setattr(relay.llm, "stream_execute", interrupt_after_stream) + stream = relay_llm.stream( + {"model": "test-model", "messages": []}, + lambda _request: iter([{"delta": "complete"}]), + session_id="session-1", + name="test-provider", + model_name="test-model", + finalizer=lambda: {"content": "complete"}, + metadata={ + "api_mode": "custom", + "api_request_id": "request-stream-post-interrupt", + }, + ) + + assert next(stream) == {"delta": "complete"} + with pytest.raises(KeyboardInterrupt): + next(stream) + assert turn.logical_llm_calls == {} + + def test_stream_does_not_swallow_hermes_finalizer_failure(relay_turn, monkeypatch): relay, _turn = relay_turn finalizer_error = RuntimeError("Hermes finalizer failed") diff --git a/tests/agent/test_relay_tools.py b/tests/agent/test_relay_tools.py index e7d15581f61..8f605dfd4d9 100644 --- a/tests/agent/test_relay_tools.py +++ b/tests/agent/test_relay_tools.py @@ -240,3 +240,23 @@ def test_tool_adapter_returns_dispatch_result_after_relay_post_processing_failur 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", + ) From a3ef27ab70b4d742be95ae4aae0826a156dc0339 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 14:22:49 -0700 Subject: [PATCH 057/526] fix(relay): preserve intentional request removals Signed-off-by: Alex Fournier --- agent/relay_llm.py | 118 ++++++++++++++++++++++++++++++---- tests/agent/test_relay_llm.py | 46 +++++++++++++ 2 files changed, 150 insertions(+), 14 deletions(-) diff --git a/agent/relay_llm.py b/agent/relay_llm.py index b4f18e935e8..ba9cb6ad248 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -43,6 +43,12 @@ def execute( relay_request_body = _relay_request_body(request, metadata) relay_request = runtime.relay.LLMRequest({}, relay_request_body) + codec_baseline_body = _codec_round_trip_request_body( + runtime.relay, + relay_request, + relay_request_body=relay_request_body, + metadata=metadata, + ) raw_response: dict[str, Any] = {} callback_error: BaseException | None = None callback_context = contextvars.copy_context() @@ -54,6 +60,7 @@ def execute( request, next_request, relay_request_body=relay_request_body, + codec_baseline_body=codec_baseline_body, metadata=metadata, ) raw = callback_context.copy().run(callback, final_request) @@ -121,6 +128,12 @@ async def execute_async( relay_request_body = _relay_request_body(request, metadata) relay_request = runtime.relay.LLMRequest({}, relay_request_body) + codec_baseline_body = _codec_round_trip_request_body( + runtime.relay, + relay_request, + relay_request_body=relay_request_body, + metadata=metadata, + ) raw_response: dict[str, Any] = {} callback_error: BaseException | None = None callback_context = contextvars.copy_context() @@ -132,6 +145,7 @@ async def execute_async( request, next_request, relay_request_body=relay_request_body, + codec_baseline_body=codec_baseline_body, metadata=metadata, ) async def call_provider() -> Any: @@ -354,6 +368,12 @@ class ManagedLlmStream(Iterator[Any]): parent = self._logical[1] relay_request_body = _relay_request_body(request, metadata) relay_request = runtime.relay.LLMRequest({}, relay_request_body) + codec_baseline_body = _codec_round_trip_request_body( + runtime.relay, + relay_request, + relay_request_body=relay_request_body, + metadata=metadata, + ) async def provider_stream(next_request: Any): raw_stream = None @@ -364,6 +384,7 @@ class ManagedLlmStream(Iterator[Any]): request, next_request, relay_request_body=relay_request_body, + codec_baseline_body=codec_baseline_body, metadata=metadata, ) ) @@ -810,6 +831,7 @@ def _provider_request( request: Any, *, relay_request_body: dict[str, Any], + codec_baseline_body: dict[str, Any], metadata: dict[str, Any] | None, ) -> dict[str, Any]: content = getattr(request, "content", request) @@ -818,16 +840,26 @@ def _provider_request( if _json_equal(content, relay_request_body): final = dict(original) else: - baseline = _provider_request_body(relay_request_body, metadata) + baseline = codec_baseline_body intercepted = _provider_request_body(content, metadata) final = dict(original) # Typed codecs may not represent provider-specific fields. Overlay only # values that changed from the codec-facing baseline so unrelated # intercepts cannot delete or normalize unknown provider arguments. - for key, value in intercepted.items(): - if key not in baseline or not _json_equal(value, baseline[key]): - final[key] = value - _restore_provider_message_extensions(original, final) + for key in baseline.keys() | intercepted.keys(): + if key not in intercepted: + final.pop(key, None) + elif key not in baseline or not _json_equal( + intercepted[key], + baseline[key], + ): + final[key] = intercepted[key] + _restore_provider_message_extensions( + original, + final, + baseline=baseline, + intercepted=intercepted, + ) headers = getattr(request, "headers", None) if isinstance(headers, dict): headers = { @@ -889,25 +921,83 @@ def _relay_request_body( def _restore_provider_message_extensions( - original: dict[str, Any], final: dict[str, Any] + original: dict[str, Any], + final: dict[str, Any], + *, + baseline: dict[str, Any], + intercepted: dict[str, Any], ) -> None: """Restore provider wire fields that Relay's typed codec cannot represent.""" original_messages = original.get("messages") final_messages = final.get("messages") - if not isinstance(original_messages, list) or not isinstance(final_messages, list): - return - if len(original_messages) != len(final_messages): - return - for original_message, final_message in zip( - original_messages, final_messages, strict=True + baseline_messages = baseline.get("messages") + intercepted_messages = intercepted.get("messages") + if not all( + isinstance(messages, list) + for messages in ( + original_messages, + final_messages, + baseline_messages, + intercepted_messages, + ) ): - if not isinstance(original_message, dict) or not isinstance(final_message, dict): + return + if not ( + len(original_messages) + == len(final_messages) + == len(baseline_messages) + == len(intercepted_messages) + ): + return + for original_message, final_message, baseline_message, intercepted_message in zip( + original_messages, + final_messages, + baseline_messages, + intercepted_messages, + strict=True, + ): + if not all( + isinstance(message, dict) + for message in ( + original_message, + final_message, + baseline_message, + intercepted_message, + ) + ): continue for key in _PROVIDER_MESSAGE_EXTENSION_KEYS: - if key in original_message and key not in final_message: + if ( + key in original_message + and key not in baseline_message + and key not in intercepted_message + and key not in final_message + ): final_message[key] = original_message[key] +def _codec_round_trip_request_body( + relay: Any, + relay_request: Any, + *, + relay_request_body: dict[str, Any], + metadata: dict[str, Any] | None, +) -> dict[str, Any]: + """Return the codec-only request shape used to identify real rewrites.""" + codec = _codec(relay, metadata) + if codec is None: + return _provider_request_body(relay_request_body, metadata) + try: + annotated = codec.decode(relay_request) + encoded = codec.encode(annotated, relay_request) + content = getattr(encoded, "content", encoded) + if isinstance(content, dict): + return _provider_request_body(content, metadata) + except Exception: + logger.debug("NeMo Relay request codec baseline failed", exc_info=True) + return _provider_request_body(relay_request_body, metadata) + + def _provider_request_body( content: dict[str, Any], metadata: dict[str, Any] | None ) -> dict[str, Any]: diff --git a/tests/agent/test_relay_llm.py b/tests/agent/test_relay_llm.py index 524a91fb090..411a1220f4f 100644 --- a/tests/agent/test_relay_llm.py +++ b/tests/agent/test_relay_llm.py @@ -1228,6 +1228,15 @@ def test_request_rewrite_preserves_fields_dropped_by_codec(relay_turn, monkeypat return callback(relay.LLMRequest(request.headers, content)) monkeypatch.setattr(relay.llm, "execute", lossy_execute) + monkeypatch.setattr( + relay_llm, + "_codec_round_trip_request_body", + lambda *_args, relay_request_body, **_kwargs: { + key: value + for key, value in relay_request_body.items() + if key != "extra_body" + }, + ) relay_llm.execute( { @@ -1254,3 +1263,40 @@ def test_request_rewrite_preserves_fields_dropped_by_codec(relay_turn, monkeypat "extra_body": vendor_body, } ] + + +def test_request_rewrite_can_remove_codec_represented_field(relay_turn, monkeypatch): + relay, _turn = relay_turn + captured_requests = [] + + async def remove_temperature(_name, request, callback, **_kwargs): + content = dict(request.content) + content.pop("temperature") + return callback(relay.LLMRequest(request.headers, content)) + + monkeypatch.setattr(relay.llm, "execute", remove_temperature) + + relay_llm.execute( + { + "model": "test-model", + "messages": [], + "temperature": 0.25, + "extra_body": {"routing": {"provider": "nim"}}, + }, + lambda request: captured_requests.append(request) or {"content": "ok"}, + session_id="session-1", + name="test-provider", + model_name="test-model", + metadata={ + "api_mode": "chat_completions", + "api_request_id": "request-remove-field", + }, + ) + + assert captured_requests == [ + { + "model": "test-model", + "messages": [], + "extra_body": {"routing": {"provider": "nim"}}, + } + ] From 5a5743188b8a6bae0550d068dad131709cb68f16 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 14:25:14 -0700 Subject: [PATCH 058/526] fix(relay): close failed chat streams eagerly Signed-off-by: Alex Fournier --- agent/chat_completion_helpers.py | 114 +++++++++++------- .../test_stream_single_writer_65991.py | 15 +++ 2 files changed, 83 insertions(+), 46 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 8b2dd47bc6b..409df6604a6 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2639,6 +2639,22 @@ 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: @@ -2856,28 +2872,30 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= from agent import relay_llm - 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, + 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, + ) ) for chunk in stream: last_chunk_time["t"] = time.time() @@ -3033,7 +3051,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= if hasattr(chunk, "usage") and chunk.usage: usage_obj = chunk.usage - stream.close() + _close_managed_stream() if _stream_attempt_was_cancelled(stream_attempt_id): raise _httpx.RemoteProtocolError( @@ -3282,28 +3300,30 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= ) return False - 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, + 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: @@ -3358,7 +3378,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= raise finally: try: - stream.close() + _close_managed_stream() finally: manager = _stream_context["manager"] if manager is not None: @@ -3421,6 +3441,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 @@ -3717,6 +3738,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. diff --git a/tests/run_agent/test_stream_single_writer_65991.py b/tests/run_agent/test_stream_single_writer_65991.py index 00d324fac93..4fbf74ae251 100644 --- a/tests/run_agent/test_stream_single_writer_65991.py +++ b/tests/run_agent/test_stream_single_writer_65991.py @@ -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, From 502e4d63a22dc3659b3d1adfb83e6e2b4710c89a Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 23 Jul 2026 14:31:12 -0700 Subject: [PATCH 059/526] perf(relay): avoid disabled mark initialization Signed-off-by: Alex Fournier --- agent/relay_runtime.py | 2 +- .../test_relay_shared_metrics_runtime.py | 25 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 706ca04608a..f7df8eeabe8 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -811,7 +811,7 @@ def emit_mark( metadata: Any = None, ) -> bool: """Emit a fail-open Relay mark under a Hermes session.""" - runtime = get_runtime() + runtime = get_runtime(create=False) if runtime is None: return False try: diff --git a/tests/hermes_cli/test_relay_shared_metrics_runtime.py b/tests/hermes_cli/test_relay_shared_metrics_runtime.py index fe527f0c184..9444ae411ba 100644 --- a/tests/hermes_cli/test_relay_shared_metrics_runtime.py +++ b/tests/hermes_cli/test_relay_shared_metrics_runtime.py @@ -857,34 +857,31 @@ def test_profile_host_recreation_rebinds_shared_metrics_subscriber( ) -def test_core_mark_lazily_starts_relay_without_metrics_or_a_plugin( +def test_core_mark_does_not_start_relay_without_metrics_or_a_plugin( tmp_path, monkeypatch, ): - fake = _Relay() monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) - monkeypatch.setattr(relay_runtime, "_load_nemo_relay", lambda: fake) + imports = [] + + def load_relay(): + imports.append("nemo_relay") + return _Relay() + + monkeypatch.setattr(relay_runtime, "_load_nemo_relay", load_relay) monkeypatch.setattr("hermes_cli.config.read_raw_config", lambda: {}) relay_shared_metrics._reset_for_tests() relay_runtime._reset_for_tests() monkeypatch.setattr(plugins, "_plugin_manager", PluginManager()) - assert relay_runtime.emit_mark( + assert not relay_runtime.emit_mark( "hermes.skill.created", session_id="s1", data={"provenance": "agent_created"}, ) - lifecycle.finalize_session(session_id="s1") - assert [event[0] for event in fake.events] == [ - "scope.push", - "scope.sync", - "scope.event", - "scope.sync", - "scope.pop", - "subscribers.flush", - ] - assert not any(event[0] == "subscribers.register" for event in fake.events) + assert imports == [] + assert relay_runtime.get_host(create=False) is None assert not (tmp_path / "hermes-home" / "telemetry").exists() relay_runtime._reset_for_tests() From 44211f36082a5005cbf909f9312b5c9c5372775b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 06:10:54 -0500 Subject: [PATCH 060/526] feat(desktop): dev-only render + store churn counters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two dev-only counters that attribute re-renders and store notifications during an interaction, so a perf claim can be answered with a number instead of a hunch: window.__RENDER_COUNTS__ what re-rendered, and why (props/state/parent) window.__ATOM_CHURN__ which store published it, and whether it mattered The `wasted` column in each is the fix list — components that re-rendered with no changed input, and stores that published a value equal to the last one. Both are inert until start(), so idle cost is one branch per commit and per notify. React 19.2 removed injectProfilingHooks from react-dom, so the mark* profiling family is unavailable and onCommitFiberRoot is the only channel left. can't answer the question either: React invokes onRender for every Profiler in a committed tree including subtrees that bailed out, and a bailed-out subtree still reports nonzero actualDuration. This uses bippy's didFiberRender instead. bippy over react-scan because react-scan/lite is a thin wrapper over it while the package pulls ~217 transitive deps and floats two on latest. main.tsx imports the entry statically above react-dom because react-dom captures the devtools hook at module init — a late install reports renderers=0 and observes zero commits. Production exclusion is handled by a build-time alias to a no-op module rather than tree-shaking, since a static side-effect import can't be eliminated. --- apps/desktop/package.json | 1 + apps/desktop/src/debug/README.md | 111 ++++++++++++++ apps/desktop/src/debug/atom-churn.ts | 132 +++++++++++++++++ apps/desktop/src/debug/dev-only.noop.ts | 8 + apps/desktop/src/debug/dev-only.ts | 19 +++ apps/desktop/src/debug/index.ts | 31 ++++ apps/desktop/src/debug/render-counter.ts | 179 +++++++++++++++++++++++ apps/desktop/src/debug/watched-atoms.ts | 81 ++++++++++ apps/desktop/src/main.tsx | 7 + apps/desktop/vite.config.ts | 16 +- package-lock.json | 13 +- 11 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/debug/README.md create mode 100644 apps/desktop/src/debug/atom-churn.ts create mode 100644 apps/desktop/src/debug/dev-only.noop.ts create mode 100644 apps/desktop/src/debug/dev-only.ts create mode 100644 apps/desktop/src/debug/index.ts create mode 100644 apps/desktop/src/debug/render-counter.ts create mode 100644 apps/desktop/src/debug/watched-atoms.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0d2c07756af..1e3a6e10cee 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -147,6 +147,7 @@ "@typescript-eslint/eslint-plugin": "^8.59.1", "@typescript-eslint/parser": "^8.59.1", "@vitejs/plugin-react": "^6.0.1", + "bippy": "0.5.43", "concurrently": "^10.0.3", "cross-env": "^10.1.0", "electron": "40.10.2", diff --git a/apps/desktop/src/debug/README.md b/apps/desktop/src/debug/README.md new file mode 100644 index 00000000000..5cae200efd1 --- /dev/null +++ b/apps/desktop/src/debug/README.md @@ -0,0 +1,111 @@ +# Dev-only state diagnostics + +Two counters that answer, for any interaction: **what re-rendered, why, and +which store pushed it?** + +``` +window.__RENDER_COUNTS__ what re-rendered, attributed to props / state / parent +window.__ATOM_CHURN__ which store published it, and whether it mattered +``` + +Both are inert until `start()`, so the idle cost is one no-op branch per commit +and per notify. Neither ships: `vite.config.ts` aliases `@/debug/dev-only` to a +no-op module for any build that isn't the dev server (or `VITE_PERF_PROBE=1`). + +## Using it + +From the devtools console, while an agent streams: + +```js +__RENDER_COUNTS__.start(); __ATOM_CHURN__.start() +// ...let it run for a few seconds... +__RENDER_COUNTS__.stop(); __ATOM_CHURN__.stop() +console.table(__RENDER_COUNTS__.report()) +console.table(__ATOM_CHURN__.report()) +``` + +The `wasted` column in each is the fix list: + +- **render `wasted`** — re-rendered with neither changed props nor changed hook + state, i.e. purely because a parent did. A `memo()` or a narrower subscription + removes it. +- **atom `wasted`** — published a value deep-equal to the previous one. + `@nanostores/react` bails out on *reference* equality only, so this + re-renders every subscriber for nothing. This is the + "preserve reference identity on no-ops" rule in `apps/desktop/AGENTS.md`. + +Or as a gated measurement, driving 5 concurrent streaming tabs synthetically +(no backend, no credits): + +```bash +node scripts/perf/run.mjs render-churn --spawn --tiles 5 --tokens 240 +``` + +## Why bippy, and not the obvious choices + +**React 19.2 removed `injectProfilingHooks` from react-dom.** Verified: +`grep -c injectProfilingHooks node_modules/react-dom/cjs/react-dom-client.development.js` +→ `0`. Only `onCommitFiberRoot` / `onPostCommitFiberRoot` remain. The entire +`mark*` profiling family (`component-render-start`, `state-update`, +`render-scheduled`) is dead on this stack — anything built on it is out. + +**`` cannot answer "did the sidebar re-render?"** React invokes +`onRender` for *every* Profiler in a committed tree, including subtrees that +bailed out. Counting those callbacks "proves" a re-render that never happened. +`actualDuration` is not a discriminator either: a bailed-out subtree still +reports a small nonzero duration, so there's no safe threshold. `didFiberRender` +is the honest signal. (Note `app/chat/perf-probe.tsx` exports a `PerfProbe` +Profiler wrapper that is used nowhere — that's why.) + +**react-scan** ships the right idea in its undocumented `react-scan/lite` +subpath, but `lite` is a thin wrapper whose only imports are `bippy` and +`bippy/source`. The package pulls ~217 transitive deps (babel, preact) and +floats `react-grab` / `react-doctor` on `latest`, so installs aren't +reproducible, and its main entry currently breaks Vite with a JSON +import-attribute error (upstream issues #448, #467, both open). We take bippy +directly: MIT, zero dependencies. + +## The import-order constraint + +`main.tsx` imports `@/debug/dev-only` **statically, above `react-dom`**. This is +load-bearing, not stylistic. + +react-dom captures the devtools hook at **module init**, not at `createRoot`. +Installing afterwards leaves a hook object in place but `bippy._renderers` +empty, and every commit goes unseen. Verified both directions: + +| order | `_renderers` | commits | +|---|---|---| +| bippy installs first | 1 | 1 | +| react-dom evaluates first | 0 | 0 | + +A dynamic `import()` behind an `import.meta.env.DEV` guard would resolve a +microtask *after* main.tsx's static graph (react-dom included) had evaluated — +too late. Hence a static import, with production exclusion handled by the +build-time alias instead of tree-shaking. + +If you add a test that imports these counters, note the `ui` vitest project's +`setupFiles` pulls in `@testing-library/react` (and thus react-dom) before any +test body runs, so the hook can never install there. Use a config without +`setupFiles`. + +## Baseline (5 tabs × 240 tokens, dev renderer, darwin-arm64) + +``` +sidebar_renders 6 +sidebar_wasted 0 +wasted_renders 10,432 +total_renders 78,385 +commits 1,566 +wasted_notifies 0 +``` + +The sidebar hypothesis is **refuted**: 6 renders across the whole run, all +attributable to hook state on genuine busy/needsInput edges, none wasted. The +`stableArray` guards on `$workingSessionIds` / `$attentionSessionIds` +(`store/session-states.ts:236-259`) are doing their job. + +The real cost is elsewhere — see `topRenders` in the scenario's `detail`. +`$sessionStates` notified 1,200 times with **10 listeners** (fan-out 12,000) +and zero wasted notifies, so the publishing side is honest; the waste is in +components that re-render on a parent commit without their own inputs changing. diff --git a/apps/desktop/src/debug/atom-churn.ts b/apps/desktop/src/debug/atom-churn.ts new file mode 100644 index 00000000000..4c78a012e28 --- /dev/null +++ b/apps/desktop/src/debug/atom-churn.ts @@ -0,0 +1,132 @@ +// Dev-only nanostores churn counter — the state-side companion to +// `render-counter.ts`. Render counts tell you WHAT re-rendered; this tells you +// WHICH ATOM pushed the update, and whether the push was worth making. +// +// The headline metric is `wasted`: notifications whose new value is deep-equal +// to the old one. `@nanostores/react`'s `useStore` bails out on REFERENCE +// equality only (`snapshotRef.current === value`), so publishing a fresh array +// or object with identical contents re-renders every subscriber for nothing. +// That is the exact failure `apps/desktop/AGENTS.md` names: "Preserve reference +// identity on no-ops." +// +// `listeners` is read from the store's own `lc` (listener count) at notify +// time, because `onNotify` fires even when a store has zero subscribers — a +// raw notify count over-reports. `notifies x listeners` is the real fan-out. + +import { onNotify, type Store } from 'nanostores' + +export interface AtomChurn { + /** Times the store notified its listeners. */ + notifies: number + /** + * ...of those, how many pushed a value deep-equal to the previous one. + * Pure waste: every subscriber re-rendered and nothing actually changed. + */ + wasted: number + /** Peak listener count seen at notify time (`store.lc`). */ + peakListeners: number + /** Sum of listeners across notifications — the true re-render fan-out. */ + fanout: number +} + +const churn = new Map() +const unsubscribes: Array<() => void> = [] +let recording = false + +const blank = (): AtomChurn => ({ fanout: 0, notifies: 0, peakListeners: 0, wasted: 0 }) + +/** Structural equality, depth-capped so a long transcript array doesn't make + * the instrumentation itself the bottleneck. Beyond the cap we compare by + * reference, which under-reports waste rather than inventing it. */ +function equal(a: unknown, b: unknown, depth = 0): boolean { + if (Object.is(a, b)) { + return true + } + + if (depth > 3 || typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false + } + + if (Array.isArray(a) !== Array.isArray(b)) { + return false + } + + const ka = Object.keys(a as object) + const kb = Object.keys(b as object) + + if (ka.length !== kb.length) { + return false + } + + return ka.every(k => equal((a as Record)[k], (b as Record)[k], depth + 1)) +} + +/** Watch one store. Call at module scope for each atom you want attributed. */ +export function watchAtom(name: string, store: Store) { + const off = onNotify(store, ({ oldValue }) => { + if (!recording) { + return + } + + const entry = churn.get(name) ?? blank() + // `lc` is nanostores' listener count — see node_modules/nanostores/atom/index.js. + const listeners = (store as unknown as { lc?: number }).lc ?? 0 + const next = (store as unknown as { value?: unknown }).value + + entry.notifies += 1 + entry.fanout += listeners + entry.peakListeners = Math.max(entry.peakListeners, listeners) + + if (equal(oldValue, next)) { + entry.wasted += 1 + } + + churn.set(name, entry) + }) + + unsubscribes.push(off) + + return off +} + +/** Rows sorted by wasted notifications, then fan-out — the fix list, in order. */ +function report(limit = 40) { + return [...churn.entries()] + .map(([name, c]) => ({ name, ...c })) + .sort((a, b) => b.wasted - a.wasted || b.fanout - a.fanout) + .slice(0, limit) +} + +declare global { + interface Window { + __ATOM_CHURN__?: { + churn: Map + clear: () => void + start: () => void + stop: () => void + recording: () => boolean + report: (limit?: number) => Array + get: (name: string) => AtomChurn | undefined + /** Names of every watched store, whether or not it has notified. */ + watched: () => string[] + } + } +} + +if (typeof window !== 'undefined' && !window.__ATOM_CHURN__) { + window.__ATOM_CHURN__ = { + churn, + clear: () => churn.clear(), + get: name => churn.get(name), + recording: () => recording, + report, + start: () => { + churn.clear() + recording = true + }, + stop: () => { + recording = false + }, + watched: () => [...churn.keys()] + } +} diff --git a/apps/desktop/src/debug/dev-only.noop.ts b/apps/desktop/src/debug/dev-only.noop.ts new file mode 100644 index 00000000000..9c4595ee149 --- /dev/null +++ b/apps/desktop/src/debug/dev-only.noop.ts @@ -0,0 +1,8 @@ +// Production stand-in for `dev-only.ts`. `vite.config.ts` aliases the dev +// entry to this module for any build that isn't the dev server, so the +// diagnostics graph — and bippy with it — never reaches a shipped renderer. +// +// Keep this file free of imports. It exists precisely so the production +// bundle contains nothing from `debug/`. + +export {} diff --git a/apps/desktop/src/debug/dev-only.ts b/apps/desktop/src/debug/dev-only.ts new file mode 100644 index 00000000000..e052c2f14ed --- /dev/null +++ b/apps/desktop/src/debug/dev-only.ts @@ -0,0 +1,19 @@ +// Dev-only diagnostics entry. Statically imported from `main.tsx` ABOVE the +// `react-dom` import — that ordering is load-bearing and non-negotiable. +// +// react-dom captures the devtools hook at MODULE INIT, not at `createRoot`. +// Verified: installing bippy after react-dom has evaluated yields +// `renderers=0` and `commits=0` — the hook object exists but react-dom never +// registered with it. A dynamic `import()` here would resolve a microtask +// after main.tsx's static graph (react-dom included) had already evaluated, +// so it must be a plain static import chain, whose evaluation order ESM +// guarantees. +// +// Production builds don't tree-shake this away (a static side-effect import +// can't be eliminated) — instead `vite.config.ts` aliases this module to +// `dev-only.noop.ts` whenever the build isn't serving dev, so neither bippy +// nor the counters reach a shipped renderer. + +import './index' + +export {} diff --git a/apps/desktop/src/debug/index.ts b/apps/desktop/src/debug/index.ts new file mode 100644 index 00000000000..365cc23fdbf --- /dev/null +++ b/apps/desktop/src/debug/index.ts @@ -0,0 +1,31 @@ +// Dev-only state diagnostics: one import, two counters. +// +// window.__RENDER_COUNTS__ — what re-rendered, and why (props/state/parent) +// window.__ATOM_CHURN__ — which store published it, and whether it mattered +// +// Imported FIRST in `main.tsx`, before `react-dom`. That ordering is +// load-bearing: react-dom decides at module-init whether a devtools hook +// exists, so bippy must install during THIS module's evaluation. A dynamic +// `import()` from main.tsx would resolve a microtask too late and every commit +// would go unseen — hence a plain static import chain, whose evaluation order +// ESM guarantees. +// +// Both counters are inert until `start()` is called, so the idle cost in dev is +// a single no-op branch per commit / per notify. +// +// Typical session, from the devtools console: +// +// __RENDER_COUNTS__.start(); __ATOM_CHURN__.start() +// // ...let an agent stream for a few seconds... +// __RENDER_COUNTS__.stop(); __ATOM_CHURN__.stop() +// console.table(__RENDER_COUNTS__.report()) +// console.table(__ATOM_CHURN__.report()) +// +// The `wasted` column in each is the fix list: components that re-rendered +// with no changed input, and stores that published a value equal to the last. + +import './render-counter' + +import { watchSessionAtoms } from './watched-atoms' + +watchSessionAtoms() diff --git a/apps/desktop/src/debug/render-counter.ts b/apps/desktop/src/debug/render-counter.ts new file mode 100644 index 00000000000..30dcae9146d --- /dev/null +++ b/apps/desktop/src/debug/render-counter.ts @@ -0,0 +1,179 @@ +// Dev-only render counter — answers "what actually re-rendered, and why?". +// +// Loaded from `main.tsx` BEFORE `react-dom` (see the import-order note there). +// That ordering is load-bearing: react-dom decides at module-init whether a +// devtools hook exists, so installing after it has already initialised leaves +// `bippy._renderers` empty and every commit goes unseen. +// +// Why not ``: React invokes `onRender` for EVERY Profiler in a +// committed tree, including subtrees that bailed out. Counting those callbacks +// "proves" the sidebar re-rendered when it did not. `actualDuration` is not a +// discriminator either — a bailed-out subtree still reports a small nonzero +// duration. `didFiberRender` is the honest signal. +// +// Why not react-scan: its `lite` subpath is a thin wrapper over bippy, while +// the package pulls ~217 transitive deps (babel, preact) and floats +// `react-grab`/`react-doctor` on `latest`, which makes installs +// non-reproducible and breaks Vite with a JSON import-attribute error. + +import { didFiberRender, type Fiber, getDisplayName, instrument, isCompositeFiber, traverseRenderedFibers } from 'bippy' + +/** Why a component re-rendered, attributed per commit. */ +export interface RenderRecord { + /** Commits in which this component actually re-rendered (mount excluded). */ + renders: number + /** ...of those, how many had at least one changed prop reference. */ + propsChanged: number + /** ...of those, how many had changed hook state (useState/useMemo/store). */ + stateChanged: number + /** + * ...of those, how many had NEITHER changed props nor changed state. These + * re-rendered purely because a parent did — the wasted work a `memo()` or a + * narrower store subscription would eliminate. + */ + wasted: number + /** Sum of `actualDuration` across counted renders, in ms. */ + totalMs: number +} + +const counts = new Map() +let commits = 0 +let recording = false + +const blank = (): RenderRecord => ({ + propsChanged: 0, + renders: 0, + stateChanged: 0, + totalMs: 0, + wasted: 0 +}) + +/** Did any prop's reference identity change between the two fiber versions? */ +function propsChanged(fiber: Fiber): boolean { + const prev = fiber.alternate?.memoizedProps as Record | null | undefined + const next = fiber.memoizedProps as Record | null | undefined + + if (!prev || !next) { + return false + } + + for (const key of Object.keys(next)) { + if (!Object.is(prev[key], next[key])) { + return true + } + } + + return Object.keys(prev).length !== Object.keys(next).length +} + +/** Did any hook's memoizedState change? Covers useState, useSyncExternalStore + * (so nanostores `useStore`), useMemo, and useReducer alike. */ +function stateChanged(fiber: Fiber): boolean { + let next: Fiber['memoizedState'] | null | undefined = fiber.memoizedState + let prev: Fiber['memoizedState'] | null | undefined = fiber.alternate?.memoizedState + + while (next && prev) { + if (!Object.is(next.memoizedState, prev.memoizedState)) { + return true + } + + next = next.next + prev = prev.next + } + + return false +} + +function record(fiber: Fiber) { + const name = getDisplayName(fiber) + + if (!name) { + return + } + + const entry = counts.get(name) ?? blank() + const props = propsChanged(fiber) + const state = stateChanged(fiber) + + entry.renders += 1 + entry.totalMs += fiber.actualDuration ?? 0 + + if (props) { + entry.propsChanged += 1 + } + + if (state) { + entry.stateChanged += 1 + } + + if (!props && !state) { + entry.wasted += 1 + } + + counts.set(name, entry) +} + +/** Rows sorted by wasted renders, then total renders — the fix list, in order. */ +function report(limit = 40) { + return [...counts.entries()] + .map(([name, r]) => ({ name, ...r, totalMs: Math.round(r.totalMs * 100) / 100 })) + .sort((a, b) => b.wasted - a.wasted || b.renders - a.renders) + .slice(0, limit) +} + +declare global { + interface Window { + __RENDER_COUNTS__?: { + /** Per-component render attribution since the last `clear()`. */ + counts: Map + /** Commits observed since the last `clear()`. */ + commits: () => number + clear: () => void + /** Start counting. Cheap no-op until called — zero cost while idle. */ + start: () => void + stop: () => void + recording: () => boolean + /** Sorted worst-offenders table; `console.table`-friendly. */ + report: (limit?: number) => Array + /** Attribution for one component by display name. */ + get: (name: string) => RenderRecord | undefined + } + } +} + +if (typeof window !== 'undefined' && !window.__RENDER_COUNTS__) { + instrument({ + onCommitFiberRoot(_id, root) { + if (!recording) { + return + } + + commits += 1 + traverseRenderedFibers(root, fiber => { + if (isCompositeFiber(fiber) && didFiberRender(fiber)) { + record(fiber) + } + }) + } + }) + + window.__RENDER_COUNTS__ = { + clear: () => { + counts.clear() + commits = 0 + }, + commits: () => commits, + counts, + get: name => counts.get(name), + recording: () => recording, + report, + start: () => { + counts.clear() + commits = 0 + recording = true + }, + stop: () => { + recording = false + } + } +} diff --git a/apps/desktop/src/debug/watched-atoms.ts b/apps/desktop/src/debug/watched-atoms.ts new file mode 100644 index 00000000000..ebcb2724b78 --- /dev/null +++ b/apps/desktop/src/debug/watched-atoms.ts @@ -0,0 +1,81 @@ +// Dev-only: registers the stores worth attributing during a streaming turn. +// +// Deliberately NOT every atom in the app — a churn counter that reports 200 +// rows is as useless as none. These are the stores on or adjacent to the +// streaming hot path, plus the sidebar's inputs, so a recording answers one +// question directly: while an agent is typing, what is being published, and +// who re-renders because of it? + +import { $projects, $projectTree } from '@/store/projects' +import { + $activeSessionId, + $awaitingResponse, + $busy, + $cronSessions, + $currentCwd, + $currentUsage, + $gatewayState, + $messages, + $messagingSessions, + $selectedStoredSessionId, + $sessions, + $sessionsLoading +} from '@/store/session' +import { + $attentionSessionIds, + $focusedRuntimeId, + $focusedSessionState, + $focusedStoredSessionId, + $sessionStates, + $sessionTiles, + $stalledSessionIds, + $workingSessionIds +} from '@/store/session-states' + +import { watchAtom } from './atom-churn' + +/** Streaming hot path — written per token / per delta flush. */ +const HOT = { + $awaitingResponse, + $busy, + // The global mirror the workspace pane paints from. + $messages, + // Republished on EVERY delta: the per-session source of truth. + $sessionStates +} + +/** Derived from the hot path. These SHOULD stay quiet during a turn — any + * notification here is a candidate for the "wasted" column. */ +const DERIVED = { + $attentionSessionIds, + $currentUsage, + $focusedRuntimeId, + $focusedSessionState, + $focusedStoredSessionId, + $stalledSessionIds, + $workingSessionIds +} + +/** Sidebar inputs. Expected to be cold during a turn — if any of these notify + * while an agent is typing, that is the bug. */ +const SIDEBAR = { + $activeSessionId, + $cronSessions, + $currentCwd, + $gatewayState, + $messagingSessions, + $projects, + $projectTree, + $selectedStoredSessionId, + $sessions, + $sessionsLoading, + $sessionTiles +} + +export function watchSessionAtoms() { + for (const group of [HOT, DERIVED, SIDEBAR]) { + for (const [name, store] of Object.entries(group)) { + watchAtom(name, store) + } + } +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index b1dd657655b..02cd6dfca36 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -1,6 +1,13 @@ import './styles.css' // Side-effect: applies the persisted window translucency on load. import './store/translucency' +// Dev-only render/state churn counters. MUST precede the `react-dom` import +// below: react-dom captures the devtools hook at module init, so bippy has to +// install during THIS import's evaluation or every commit goes unseen +// (verified — a late install reports renderers=0, commits=0). `vite.config.ts` +// aliases this specifier to a no-op module for non-dev builds, so neither the +// counters nor bippy reach a shipped renderer. +import '@/debug/dev-only' import { QueryClientProvider } from '@tanstack/react-query' import { StrictMode } from 'react' diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 2b0685c9a0b..4a58133b8bf 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,18 @@ const fsAllow = [ ) ] -export default defineConfig({ +// The dev-only render/state churn counters (src/debug) must be imported +// STATICALLY above react-dom — react-dom captures the devtools hook at module +// init, so a dynamic import lands too late and observes zero commits. A static +// side-effect import can't be tree-shaken, so instead the whole graph is +// aliased out of any non-dev build. `command === 'serve'` covers `vite dev`; +// the perf harness opts a production build back in with VITE_PERF_PROBE=1. +const debugEntry = (command: string, env: Record) => + command === 'serve' || env.VITE_PERF_PROBE === '1' + ? path.resolve(__dirname, './src/debug/dev-only.ts') + : path.resolve(__dirname, './src/debug/dev-only.noop.ts') + +export default defineConfig(({ command }) => ({ base: './', plugins: [react(), tailwindcss()], css: { @@ -57,6 +68,7 @@ export default defineConfig({ }, resolve: { alias: { + '@/debug/dev-only': debugEntry(command, process.env as Record), '@': path.resolve(__dirname, './src'), '@hermes/plugin-sdk': path.resolve(__dirname, './src/sdk/index.ts'), '@hermes/shared/billing': path.resolve(__dirname, '../shared/src/billing-types.ts'), @@ -80,4 +92,4 @@ export default defineConfig({ host: '127.0.0.1', port: 4174 } -}) +})) diff --git a/package-lock.json b/package-lock.json index 0ee09bbcb29..2848a8c89af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -184,7 +184,8 @@ "typescript": "^6.0.3", "vite": "^8.0.10", "vitest": "^4.1.5", - "wait-on": "^9.0.5" + "wait-on": "^9.0.5", + "bippy": "0.5.43" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -19748,6 +19749,16 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/bippy": { + "version": "0.5.43", + "resolved": "https://registry.npmjs.org/bippy/-/bippy-0.5.43.tgz", + "integrity": "sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": ">=17.0.0" + } } } } From 37ac8ae76a165a4e5a27582b917a9b91a6b91ea7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 06:11:00 -0500 Subject: [PATCH 061/526] feat(desktop): render-churn perf scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the same synthetic multi-tab streaming workload as `multitab` (publishSessionState per session per flush, no backend, no credits) but reports render attribution instead of frame pacing — sidebar_renders, wasted_renders, and wasted_notifies. Answers 'does the sidebar re-render while an agent is typing' directly. --- apps/desktop/scripts/perf/README.md | 1 + apps/desktop/scripts/perf/scenarios/index.mjs | 2 + .../scripts/perf/scenarios/render-churn.mjs | 217 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 apps/desktop/scripts/perf/scenarios/render-churn.mjs diff --git a/apps/desktop/scripts/perf/README.md b/apps/desktop/scripts/perf/README.md index e8cfc575231..4c74de4983d 100644 --- a/apps/desktop/scripts/perf/README.md +++ b/apps/desktop/scripts/perf/README.md @@ -51,6 +51,7 @@ directly via `window.__PERF_DRIVE__`, so no LLM credits are spent. | `stream --real` | backend | same, from a real LLM stream | measure-real-stream, profile-real-stream | | `keystroke` | ci | composer keystroke → paint latency | measure-latency, profile-typing, leak-typing | | `transcript` | ci | large-transcript mount + paint cost | (new) | +| `render-churn` | ci | per-component render attribution + store churn while N tabs stream | (new) | | `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) | | `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) | | `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump | diff --git a/apps/desktop/scripts/perf/scenarios/index.mjs b/apps/desktop/scripts/perf/scenarios/index.mjs index d69ecce2fbc..d92526b0fcf 100644 --- a/apps/desktop/scripts/perf/scenarios/index.mjs +++ b/apps/desktop/scripts/perf/scenarios/index.mjs @@ -6,6 +6,7 @@ import firstToken from './first-token.mjs' import keystroke from './keystroke.mjs' import multitab from './multitab.mjs' import profileSwitch from './profile-switch.mjs' +import renderChurn from './render-churn.mjs' import sessionSwitch from './session-switch.mjs' import stream from './stream.mjs' import streamHistory from './stream-history.mjs' @@ -18,6 +19,7 @@ export const SCENARIOS = { [keystroke.name]: keystroke, [transcript.name]: transcript, [multitab.name]: multitab, + [renderChurn.name]: renderChurn, [coldStart.name]: coldStart, [firstToken.name]: firstToken, [submit.name]: submit, diff --git a/apps/desktop/scripts/perf/scenarios/render-churn.mjs b/apps/desktop/scripts/perf/scenarios/render-churn.mjs new file mode 100644 index 00000000000..d1e940cbed7 --- /dev/null +++ b/apps/desktop/scripts/perf/scenarios/render-churn.mjs @@ -0,0 +1,217 @@ +// Render churn during multi-tab streaming: WHAT re-rendered and WHY, and which +// store published the update. Frame pacing (see `multitab`) tells you the cost; +// this tells you the cause. +// +// Drives the same synthetic pipeline as `multitab` — publishSessionState per +// session per flush via `__HERMES_SESSION_TILES__`, no backend, no credits — +// then reads the dev-only counters installed by `src/debug/`: +// +// window.__RENDER_COUNTS__ — per-component renders, attributed to +// props / hook state / parent-only ("wasted") +// window.__ATOM_CHURN__ — per-store notifications, listener fan-out, and +// notifications whose value was deep-equal to the +// previous one ("wasted") +// +// The headline metric is `sidebar_renders`: how many times the sidebar tree +// re-rendered while agents were typing in other tabs. It should be 0. +// +// node scripts/perf/run.mjs render-churn --spawn [--tiles 5] [--tokens 240] + +import { sleep } from '../lib/cdp.mjs' + +/** Components that make up the sidebar tree. A render of any of these while + * a background tab streams is work the user cannot see. */ +const SIDEBAR_COMPONENTS = [ + 'ChatSidebar', + 'SidebarSurface', + 'SessionRow', + 'SessionsSection', + 'CronJobsSection', + 'ProfileSwitcher', + 'VirtualSessionList', + 'WorkspaceGroup', + 'OverviewRow', + 'SessionStatusDot' +] + +/** Page-side setup: open `tiles` session tiles, seed each with a transcript. + * Mirrors `multitab.mjs` so the two scenarios measure the same workload. */ +const setup = (tiles, seedTurns) => ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + if (!hook) return 'no-hook' + if (!window.__RENDER_COUNTS__) return 'no-render-counter' + if (!window.__ATOM_CHURN__) return 'no-atom-churn' + + const turn = (sid, i) => ([ + { id: sid + '-u' + i, role: 'user', timestamp: Date.now(), + parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff handle the error path?' }] }, + { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false, + parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- The catch block drops the error.\\n- Retries are unbounded.\\n' }] } + ]) + + const state = (sid) => { + const messages = [] + for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i)) + messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true, + parts: [{ type: 'text', text: '' }] }) + return { + storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '', + reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '', + busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true, + pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false, + needsInput: false, turnStartedAt: Date.now(), usage: null + } + } + + window.__RC__ = { ids: [], timer: null } + for (let n = 1; n <= ${tiles}; n++) { + const sid = 'churn-tile-' + n + const rid = 'churn-rt-' + n + window.__RC__.ids.push({ sid, rid }) + hook.open(sid, 'center') + hook.patch(sid, { runtimeId: rid }) + hook.publish(rid, state(sid)) + } + return 'ok' + })() +` + +const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})` + +/** Grow every tile's streaming tail by `chunk` each `intervalMs`, through the + * same publish path the gateway's delta flush uses. */ +const drive = (chunk, intervalMs, totalTokens) => ` + (() => { + const hook = window.__HERMES_SESSION_TILES__ + let pushed = 0 + const tick = () => { + const states = hook.states() + for (const { rid } of window.__RC__.ids) { + const prev = states[rid] + if (!prev) continue + const messages = prev.messages.map(m => { + if (m.id !== prev.streamId) return m + const head = m.parts.slice(0, -1) + const last = m.parts[m.parts.length - 1] + return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] } + }) + hook.publish(rid, { ...prev, messages }) + } + pushed += 1 + if (pushed < ${totalTokens}) window.__RC__.timer = setTimeout(tick, ${intervalMs}) + else window.__RC__.done = true + } + window.__RC__.timer = setTimeout(tick, ${intervalMs}) + return 'driving' + })() +` + +const START = ` + (() => { + window.__RENDER_COUNTS__.start() + window.__ATOM_CHURN__.start() + return 'recording' + })() +` + +const COLLECT = ` + (() => { + window.__RENDER_COUNTS__.stop() + window.__ATOM_CHURN__.stop() + return JSON.stringify({ + commits: window.__RENDER_COUNTS__.commits(), + renders: window.__RENDER_COUNTS__.report(200), + atoms: window.__ATOM_CHURN__.report(200) + }) + })() +` + +const CLEANUP = ` + (() => { + if (window.__RC__) { + clearTimeout(window.__RC__.timer) + for (const { sid, rid } of window.__RC__.ids) { + const states = window.__HERMES_SESSION_TILES__.states() + window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null }) + window.__HERMES_SESSION_TILES__.close(sid) + } + window.__RC__ = null + } + window.__RENDER_COUNTS__.clear() + window.__ATOM_CHURN__.clear() + return 'cleaned' + })() +` + +export default { + name: 'render-churn', + tier: 'ci', + description: 'N streaming tabs: per-component render attribution + store churn.', + async run(cdp, opts = {}) { + const tiles = Number(opts.tiles ?? 5) + const seedTurns = Number(opts.turns ?? 20) + const tokens = Number(opts.tokens ?? 240) + // Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush. + const intervalMs = Number(opts.intervalMs ?? 33) + const chunk = opts.chunk ?? 'A streamed review sentence with **bold** and `code`.\n\n' + + await cdp.send('Runtime.enable') + + const ok = await cdp.eval(setup(tiles, seedTurns)) + + if (ok !== 'ok') { + throw new Error( + `render-churn setup failed (${ok}) — needs a dev renderer with src/debug installed ` + + '(the counters are aliased out of production builds unless VITE_PERF_PROBE=1).' + ) + } + + // Mount every tab (keep-alive mounts on first activation), then settle. + for (let n = 1; n <= tiles; n++) { + await cdp.eval(reveal(`churn-tile-${n}`)) + await sleep(350) + } + + await sleep(1000) + // Start recording AFTER mount so the numbers are steady-state streaming + // cost, not one-off mount cost. + await cdp.eval(START) + await cdp.eval(drive(chunk, intervalMs, tokens)) + await sleep(tokens * intervalMs + 1500) + + const data = JSON.parse(await cdp.eval(COLLECT)) + await cdp.eval(CLEANUP) + + const byName = new Map(data.renders.map(r => [r.name, r])) + const sidebarRows = SIDEBAR_COMPONENTS.map(n => byName.get(n)).filter(Boolean) + const sidebarRenders = sidebarRows.reduce((a, r) => a + r.renders, 0) + const sidebarWasted = sidebarRows.reduce((a, r) => a + r.wasted, 0) + const totalRenders = data.renders.reduce((a, r) => a + r.renders, 0) + const totalWasted = data.renders.reduce((a, r) => a + r.wasted, 0) + const atomWasted = data.atoms.reduce((a, r) => a + r.wasted, 0) + + return { + metrics: { + // The hypothesis, as a number: sidebar renders while background tabs + // stream. Should be 0. + sidebar_renders: sidebarRenders, + sidebar_wasted: sidebarWasted, + // Renders with no changed props and no changed hook state — pure + // parent-driven work, across the whole tree. + wasted_renders: totalWasted, + total_renders: totalRenders, + commits: data.commits, + // Store notifications that published a value equal to the last one. + wasted_notifies: atomWasted + }, + detail: { + tiles, + tokens, + sidebar: sidebarRows, + topRenders: data.renders.slice(0, 15), + topAtoms: data.atoms.slice(0, 15) + } + } + } +} From 08130a26f659fab6a76a8109891f26b5874f3dea Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:12:36 -0700 Subject: [PATCH 062/526] fix(telegram): follow @username renames and support non-"bot" handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming a Telegram bot's @username in BotFather silently stopped the gateway from answering in groups. PTB caches getMe() in Bot._bot_user and only rewrites it inside get_me(), so after a rename the adapter kept comparing mentions against the OLD handle. The exclusive-mention gate then saw the new @handle, failed to match itself, and concluded the message was addressed to a different bot — dropping it before the reply and wake-word fallbacks could run. Native replies to the bot were discarded too. Polling mode recovered on the next 90s heartbeat; webhook mode never calls get_me() again, so it stayed dead until restart. Separately, the bot-handle pattern assumed every bot username ends in "bot". Collectible (Fragment) usernames can be assigned to bots and drop that suffix (@jarvis, @pic), so such a bot could not recognise its own handle in the entity-less fallback and was suppressed by any message that also named another bot. - Route every mention comparison through _current_bot_username(), which prefers the last observed handle over PTB's cache. - Learn the live handle from inbound updates: Telegram stamps the current username on our own messages and on reply_to_message. Guarded by user id, so another account's handle is never adopted. - Re-check identity out of band (TTL-bounded, one getMe per 5 min) when the exclusive gate is about to drop a message — the exact stale-handle symptom — so the mistake self-corrects instead of persisting. - Refresh identity in webhook mode via a dedicated low-frequency loop, cancelled on the same teardown fence as the heartbeat. - Match our own handle by identity rather than shape. Foreign handles keep the deliberate "...bot" narrowing so human @handles still never act as routing hints (the intent behind ce4d857021). Validation: 11 regression tests; sabotage runs confirm each behavioral test fails with the fix reverted. 157 tests green across the Telegram gating, reconnect, and topic-mode suites. --- plugins/platforms/telegram/adapter.py | 252 ++++++++++++++++-- tests/gateway/test_telegram_group_gating.py | 185 +++++++++++++ website/docs/user-guide/messaging/telegram.md | 1 + 3 files changed, 419 insertions(+), 19 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 55ae362bfe0..b7e270a9f29 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -753,6 +753,14 @@ class TelegramAdapter(BasePlatformAdapter): self._polling_teardown_started: bool = False self._polling_error_callback_ref = None self._polling_heartbeat_task: Optional[asyncio.Task] = None + # Live @username, refreshed whenever Telegram tells us what it is. + # PTB caches getMe() in Bot._bot_user at initialize() and only rewrites + # it inside get_me(), so a BotFather rename leaves self._bot.username + # pointing at the old handle until something calls getMe again. Every + # mention/routing comparison reads _current_bot_username() instead. + self._bot_username_observed: Optional[str] = None + self._bot_identity_checked_at: float = 0.0 + self._bot_identity_refresh_task: Optional[asyncio.Task] = None # Consecutive heartbeat probes that saw queued updates the running # poller is not consuming. get_me() can't see this — the send path is # healthy while the getUpdates consumer is wedged — so the heartbeat @@ -2674,6 +2682,11 @@ class TelegramAdapter(BasePlatformAdapter): if not callable(getattr(bot, "get_me", None)): return await asyncio.wait_for(bot.get_me(), PROBE_TIMEOUT) + # get_me() refreshes PTB's cached bot user in place, so this is + # also where a BotFather rename gets picked up: adopt whatever + # handle Telegram just reported before anything routes on it. + self._bot_identity_checked_at = time.monotonic() + self._note_bot_username(getattr(bot, "username", None)) # get_me() succeeded — the general/send request path is healthy. # That does NOT prove the getUpdates consumer is alive: PTB can # report updater.running=True while the long-poll task is wedged, @@ -3441,6 +3454,30 @@ class TelegramAdapter(BasePlatformAdapter): self.name, topic_name, seed_err, ) + async def _bot_identity_refresh_loop(self) -> None: + """Keep the cached @username fresh when no heartbeat is running. + + Polling mode re-reads identity via the heartbeat's ``get_me()`` probe. + Webhook mode has no such probe — nothing calls ``get_me()`` again after + ``initialize()`` — so without this loop a BotFather rename breaks + mention routing until the gateway restarts. + """ + while True: + try: + await asyncio.sleep(self._BOT_IDENTITY_TTL_SECONDS) + if getattr(self, "_polling_teardown_started", False): + return + if self.has_fatal_error: + return + await self._refresh_bot_identity(force=True) + except asyncio.CancelledError: + return + except Exception: + logger.debug( + "[%s] Telegram identity refresh loop iteration failed", + self.name, exc_info=True, + ) + def _start_post_connect_housekeeping(self) -> None: """Kick off deferred post-connect housekeeping in the background. @@ -4009,6 +4046,21 @@ class TelegramAdapter(BasePlatformAdapter): self._polling_heartbeat_loop() ) + # Seed the live identity from whatever PTB cached during + # initialize(), then keep it fresh. Polling mode rides the + # heartbeat's get_me() probe; webhook mode has no probe at all, so + # it gets a dedicated low-frequency refresh loop — otherwise a + # BotFather rename breaks mention routing until restart. + self._note_bot_username(getattr(self._bot, "username", None)) + self._bot_identity_checked_at = time.monotonic() + if self._webhook_mode: + identity_task = getattr(self, "_bot_identity_refresh_task", None) + if identity_task and not identity_task.done(): + identity_task.cancel() + self._bot_identity_refresh_task = asyncio.ensure_future( + self._bot_identity_refresh_loop() + ) + # Command-menu registration, DM-topic setup, and the status # indicator each make Bot API calls that can stall for certain # tokens. Running them here — inside the connect() coroutine that @@ -4172,6 +4224,17 @@ class TelegramAdapter(BasePlatformAdapter): pass self._polling_heartbeat_task = None + # Cancel the webhook-mode identity refresh loop on the same fence as + # the heartbeat so it cannot fire get_me() into a torn-down client. + identity_task = getattr(self, "_bot_identity_refresh_task", None) + if identity_task and not identity_task.done(): + identity_task.cancel() + try: + await identity_task + except asyncio.CancelledError: + pass + self._bot_identity_refresh_task = None + # Mark the bot "Offline" in its short description while the bot's HTTP # client is still alive (before app shutdown closes it). Opt-in via # extra.status_indicator. Non-fatal. This is the clean-shutdown path; @@ -7746,22 +7809,128 @@ class TelegramAdapter(BasePlatformAdapter): return cls._GENERAL_TOPIC_THREAD_ID return None + # Telegram bot handles historically had to end in "bot", but collectible + # (Fragment) usernames can be assigned to bots and drop that suffix + # entirely (@jarvis, @pic, ...). This pattern is used ONLY to decide + # whether some FOREIGN @handle in a message is bot-shaped; our own handle + # is matched by identity, never by shape. + _FOREIGN_BOT_HANDLE_RE = re.compile(r"[a-z0-9_]{2,29}bot", re.IGNORECASE) + # How long an observed identity is trusted before the heartbeat re-checks. + _BOT_IDENTITY_TTL_SECONDS = 300.0 + + def _current_bot_username(self) -> str: + """Return this bot's live @username (lowercased, no leading ``@``). + + Prefers the most recently observed handle over PTB's ``get_me()`` + cache. ``Bot.username`` reads ``Bot._bot_user``, which is written only + by ``get_me()`` — after a BotFather rename it keeps returning the old + handle, so every mention comparison silently stops matching and the + exclusive-mention gate concludes the message is addressed to a + different bot. Observing the handle from inbound updates closes that + window without an extra Bot API round-trip. + """ + observed = getattr(self, "_bot_username_observed", None) + if observed: + return observed + return (getattr(self._bot, "username", None) or "").lstrip("@").lower() + + def _note_bot_username(self, username: Optional[str]) -> None: + """Record the bot's current @username, logging real renames.""" + handle = (username or "").lstrip("@").lower() + if not handle: + return + previous = getattr(self, "_bot_username_observed", None) + if previous == handle: + return + self._bot_username_observed = handle + self._bot_identity_checked_at = time.monotonic() + if previous: + logger.info( + "[%s] Telegram bot username changed: @%s -> @%s " + "(mention routing now follows the new handle)", + self.name, previous, handle, + ) + + def _observe_bot_identity_from_message(self, message: Message) -> None: + """Learn our own handle from a message Telegram says we authored. + + Telegram stamps the *current* username on the bot's own outgoing + messages and on ``reply_to_message`` when a user replies to us, so a + rename is observable from the update stream itself — no getMe needed. + Only trusted when the user id matches this bot, so another account's + handle can never be adopted as our own. + """ + bot_id = getattr(self._bot, "id", None) + if bot_id is None: + return + for candidate in ( + getattr(message, "from_user", None), + getattr(getattr(message, "reply_to_message", None), "from_user", None), + ): + if candidate is None: + continue + if getattr(candidate, "id", None) != bot_id: + continue + self._note_bot_username(getattr(candidate, "username", None)) + + async def _refresh_bot_identity(self, *, force: bool = False) -> None: + """Re-read the bot's identity from Telegram when the cache may be stale. + + ``get_me()`` rewrites PTB's ``Bot._bot_user`` in place, so this also + repairs every other consumer of ``self._bot.username``. Best-effort: + a failed probe leaves the last known handle in place. + """ + bot = self._bot + if bot is None or not callable(getattr(bot, "get_me", None)): + return + now = time.monotonic() + if not force and (now - getattr(self, "_bot_identity_checked_at", 0.0)) < self._BOT_IDENTITY_TTL_SECONDS: + return + try: + me = await asyncio.wait_for(bot.get_me(), self._BOT_IDENTITY_PROBE_TIMEOUT) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.debug( + "[%s] Telegram identity refresh failed (keeping @%s): %s", + self.name, self._current_bot_username() or "unknown", exc, + ) + return + self._bot_identity_checked_at = time.monotonic() + self._note_bot_username(getattr(me, "username", None)) + + _BOT_IDENTITY_PROBE_TIMEOUT = 15.0 + def _is_reply_to_bot(self, message: Message) -> bool: if not self._bot or not getattr(message, "reply_to_message", None): return False reply_user = getattr(message.reply_to_message, "from_user", None) return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None)) - @staticmethod - def _extract_bot_mention_usernames(message: Message) -> set[str]: + @classmethod + def _extract_bot_mention_usernames(cls, message: Message, self_username: str = "") -> set[str]: """Extract explicit Telegram bot usernames mentioned in text/captions. - Telegram bot usernames are 5-32 characters and must end in "bot". + Foreign handles are only treated as bot mentions when they look + bot-shaped (``...bot``), which keeps human ``@handles`` from acting as + routing hints. ``self_username`` opts our OWN handle into the same set + regardless of shape: collectible (Fragment) usernames can be assigned + to bots and need not end in "bot" (@jarvis, @pic), and a bot addressed + by such a handle must still recognise itself. + Entity mentions are authoritative. The raw-text fallback is intentionally narrow so entity-less mobile/client variants still work without treating email addresses or arbitrary substrings as bot mentions. """ mentioned_bot_usernames: set[str] = set() + own = (self_username or "").lstrip("@").lower() + + def _is_bot_handle(handle: str) -> bool: + if not handle: + return False + if own and handle == own: + return True + return bool(cls._FOREIGN_BOT_HANDLE_RE.fullmatch(handle)) def _iter_sources(): yield getattr(message, "text", None) or "", getattr(message, "entities", None) or [] @@ -7780,7 +7949,7 @@ class TelegramAdapter(BasePlatformAdapter): entity_text = source_text[offset:offset + length].strip() if entity_type == "mention": handle = entity_text.lstrip("@").lower() - if re.fullmatch(r"[a-z0-9_]{2,29}bot", handle, re.IGNORECASE): + if _is_bot_handle(handle): mentioned_bot_usernames.add(handle) continue @@ -7792,7 +7961,7 @@ class TelegramAdapter(BasePlatformAdapter): if at_index < 0: continue command_target = entity_text[at_index + 1:].strip().lower() - if re.fullmatch(r"[a-z0-9_]{2,29}bot", command_target, re.IGNORECASE): + if _is_bot_handle(command_target): mentioned_bot_usernames.add(command_target) # Entity-less fallback for older/client-specific updates. If Telegram @@ -7801,8 +7970,10 @@ class TelegramAdapter(BasePlatformAdapter): for raw_text, entities in _iter_sources(): if not raw_text or entities: continue - for match in re.finditer(r"(?i)(? None: + """Fire a TTL-guarded identity refresh in the background. + + Called when routing is about to discard a message because the bot + handles it names don't include ours — the exact symptom of a stale + username after a BotFather rename. The TTL in + ``_refresh_bot_identity`` bounds this to one getMe per + ``_BOT_IDENTITY_TTL_SECONDS``, so a busy group that legitimately + addresses other bots cannot turn this into per-message API traffic. + Fire-and-forget: the current message still routes on what we know now. + """ + existing = getattr(self, "_bot_identity_refresh_task", None) + if existing is not None and not existing.done(): + return + if (time.monotonic() - getattr(self, "_bot_identity_checked_at", 0.0)) < self._BOT_IDENTITY_TTL_SECONDS: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + task = loop.create_task(self._refresh_bot_identity()) + self._bot_identity_refresh_task = task + tracked = getattr(self, "_background_tasks", None) + if isinstance(tracked, set): + tracked.add(task) + task.add_done_callback(tracked.discard) + def _explicit_bot_mentions_exclude_self(self, message: Message) -> bool: """Return True when explicit bot handles target other bots, not this one. @@ -7872,19 +8070,27 @@ class TelegramAdapter(BasePlatformAdapter): adapter's own bot username, this adapter should ignore the message. MessageEntity values are preferred, but some Telegram clients expose - selected bot handles as plain text in group messages. The raw-text - fallback is intentionally limited to usernames ending in "bot", which - Telegram requires for bot accounts. + selected bot handles as plain text in group messages. Foreign handles + are limited to the ``...bot`` shape so human @handles never suppress + this bot; our own handle is matched by identity, so a collectible + username without that suffix still counts as addressing us. """ if not self._bot: return False - bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower() + bot_username = self._current_bot_username() if not bot_username: return False - mentioned_bot_usernames = self._extract_bot_mention_usernames(message) - return bool(mentioned_bot_usernames) and bot_username not in mentioned_bot_usernames + mentioned_bot_usernames = self._extract_bot_mention_usernames(message, bot_username) + excludes_self = bool(mentioned_bot_usernames) and bot_username not in mentioned_bot_usernames + if excludes_self: + # Either the message really is for another bot, or our cached + # handle is stale after a rename and we are about to ignore a + # message addressed to us. Re-check identity out of band (TTL + # bounded) so the mistake self-corrects instead of persisting. + self._schedule_bot_identity_recheck() + return excludes_self def _message_matches_mention_patterns(self, message: Message) -> bool: if not self._mention_patterns: @@ -7906,9 +8112,10 @@ class TelegramAdapter(BasePlatformAdapter): return self._telegram_guest_mode() and self._message_mentions_bot(message) def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]: - if not text or not self._bot or not getattr(self._bot, "username", None): + bot_username = self._current_bot_username() + if not text or not bot_username: return text - username = re.escape(self._bot.username) + username = re.escape(bot_username) cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip() return cleaned or text @@ -7975,7 +8182,7 @@ class TelegramAdapter(BasePlatformAdapter): return f"[{sender}|{user_id}]\n{event.text or ''}" def _telegram_group_observe_channel_prompt(self) -> str: - username = getattr(getattr(self, "_bot", None), "username", None) or "unknown" + username = self._current_bot_username() or "unknown" bot_id = getattr(getattr(self, "_bot", None), "id", None) or "unknown" return ( "You are handling a Telegram group chat message.\n" @@ -8277,6 +8484,13 @@ class TelegramAdapter(BasePlatformAdapter): # environments like groups/supergroups where the bot can see its own # messages). Without this, outbound messages are counted as incoming # unread in the Hermes inbox (#52363). + # + # Telegram stamps our CURRENT @username on those own-messages and on + # reply_to_message, so learn the live handle here — before any mention + # gate routes on it. Otherwise a BotFather rename leaves the stale + # handle in place and the exclusive-mention gate reads a message + # addressed to us as one addressed to some other bot. + self._observe_bot_identity_from_message(message) if self._is_own_message(message): return False diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 1dc9a13a6f2..018be18778a 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -1412,3 +1412,188 @@ def test_unmentioned_unsupported_document_observed_and_cached(monkeypatch): assert "program.exe" in message["content"] asyncio.run(_run()) + + +# ── Bot identity: renames and non-"bot"-suffixed handles ──────────────────── +# Two failure modes fixed together (both break the mention gate): +# 1. PTB caches getMe() in Bot._bot_user and only rewrites it inside +# get_me(). After a BotFather rename the adapter compares against the OLD +# handle, so the exclusive-mention gate reads a message addressed to us as +# one addressed to a different bot and silently drops it. +# 2. The bot-handle pattern assumed every bot username ends in "bot". +# Collectible (Fragment) usernames can be assigned to bots and don't +# (@jarvis, @pic), making such a bot unable to recognise its own handle. + + +class _IdentityBot: + """Stand-in for PTB's Bot: ``.username`` only changes when get_me() runs.""" + + def __init__(self, bot_id=999, cached="hermes_bot", server=None): + self.id = bot_id + self._cached = cached + self._server = server if server is not None else cached + self.get_me_calls = 0 + + @property + def username(self): + return self._cached + + async def get_me(self): + self.get_me_calls += 1 + self._cached = self._server + return SimpleNamespace(id=self.id, username=self._server) + + +def _reply_to_bot_message(text, *, entities=None, bot_username, bot_id=999): + """Group message replying to one of our messages. + + Telegram stamps the bot's CURRENT username on ``reply_to_message.from_user``, + which is how a rename becomes observable without an extra API call. + """ + message = _group_message(text, entities=entities) + message.reply_to_message = SimpleNamespace( + from_user=SimpleNamespace(id=bot_id, username=bot_username), + message_id=10, text="previous bot reply", caption=None, + ) + return message + + +def test_renamed_bot_still_routes_when_reply_reveals_new_handle(): + """A rename observed from an inbound update takes effect immediately.""" + adapter = _make_adapter(require_mention=True) + adapter._bot = _IdentityBot(cached="old_helper_bot", server="new_helper_bot") + text = "@new_helper_bot thanks!" + message = _reply_to_bot_message( + text, entities=_mention_entities(text, ["@new_helper_bot"]), + bot_username="new_helper_bot", + ) + + assert adapter._should_process_message(message) is True + assert adapter._current_bot_username() == "new_helper_bot" + # Learned from the update stream — no Bot API round-trip needed. + assert adapter._bot.get_me_calls == 0 + + +def test_stale_username_does_not_route_message_to_another_bot(): + """The exclusive-mention gate must not fire on our own (renamed) handle.""" + adapter = _make_adapter(require_mention=True, exclusive_bot_mentions=True) + adapter._bot = _IdentityBot(cached="old_helper_bot", server="new_helper_bot") + adapter._note_bot_username("new_helper_bot") + text = "@new_helper_bot what's the weather" + message = _group_message(text, entities=_mention_entities(text, ["@new_helper_bot"])) + + assert adapter._explicit_bot_mentions_exclude_self(message) is False + assert adapter._should_process_message(message) is True + + +def test_stale_username_schedules_background_identity_recheck(): + """A drop caused by a stale handle self-corrects via a TTL-guarded getMe.""" + async def _run(): + adapter = _make_adapter(require_mention=True, exclusive_bot_mentions=True) + adapter._bot = _IdentityBot(cached="old_helper_bot", server="new_helper_bot") + adapter._background_tasks = set() + text = "@new_helper_bot what's the weather" + message = _group_message(text, entities=_mention_entities(text, ["@new_helper_bot"])) + + # First message is lost — nothing has revealed the new handle yet. + assert adapter._should_process_message(message) is False + await asyncio.gather(*list(adapter._background_tasks)) + + assert adapter._bot.get_me_calls == 1 + assert adapter._current_bot_username() == "new_helper_bot" + # Recovered without a gateway restart. + assert adapter._should_process_message(message) is True + + asyncio.run(_run()) + + +def test_identity_recheck_is_rate_limited_in_multi_bot_groups(): + """Traffic legitimately aimed at other bots must not trigger a getMe storm.""" + async def _run(): + adapter = _make_adapter(require_mention=True, exclusive_bot_mentions=True) + adapter._bot = _IdentityBot(cached="hermes_bot") + adapter._background_tasks = set() + text = "@other_helper_bot please run it" + + for _ in range(25): + adapter._should_process_message( + _group_message(text, entities=_mention_entities(text, ["@other_helper_bot"])) + ) + await asyncio.gather(*list(adapter._background_tasks)) + + assert adapter._bot.get_me_calls <= 1 + + asyncio.run(_run()) + + +def test_bot_never_adopts_another_accounts_username(): + """Only a user id matching this bot may update our own handle.""" + adapter = _make_adapter(require_mention=True) + adapter._bot = _IdentityBot(cached="hermes_bot") + message = _group_message("hello") + message.from_user = SimpleNamespace(id=555, username="impostor_bot", full_name="Impostor", first_name="Impostor") + + adapter._observe_bot_identity_from_message(message) + + assert adapter._current_bot_username() == "hermes_bot" + + +def test_collectible_username_without_bot_suffix_is_recognised(): + """A Fragment handle (@jarvis) must still count as addressing this bot.""" + adapter = _make_adapter(require_mention=True, bot_username="jarvis") + text = "@jarvis hey" + message = _group_message(text, entities=_mention_entities(text, ["@jarvis"])) + + assert adapter._message_mentions_bot(message) is True + assert adapter._should_process_message(message) is True + + +def test_collectible_username_recognised_without_entities(): + """Entity-less client updates must also match a non-'bot' handle.""" + adapter = _make_adapter(require_mention=True, bot_username="jarvis") + message = _group_message("@jarvis hey", entities=[]) + + assert adapter._message_mentions_bot(message) is True + assert adapter._should_process_message(message) is True + + +def test_collectible_username_not_suppressed_by_other_bot_mention(): + """@jarvis + @other_bot in one message must still reach @jarvis.""" + adapter = _make_adapter( + require_mention=True, exclusive_bot_mentions=True, bot_username="jarvis", + ) + text = "@jarvis ask @other_helper_bot for the log" + message = _group_message( + text, entities=_mention_entities(text, ["@jarvis", "@other_helper_bot"]), + ) + + assert adapter._explicit_bot_mentions_exclude_self(message) is False + assert adapter._should_process_message(message) is True + + +def test_human_handles_still_do_not_act_as_routing_hints(): + """Widening self-matching must not make human @handles suppress this bot.""" + adapter = _make_adapter(require_mention=True, exclusive_bot_mentions=True) + text = "@alice can you check this" + message = _group_message(text, entities=_mention_entities(text, ["@alice"])) + + assert adapter._explicit_bot_mentions_exclude_self(message) is False + + +def test_messages_addressed_to_a_different_bot_are_still_suppressed(): + """The multi-bot exclusivity contract is preserved.""" + adapter = _make_adapter(require_mention=True, exclusive_bot_mentions=True) + text = "@other_helper_bot do it" + message = _group_message(text, entities=_mention_entities(text, ["@other_helper_bot"])) + + assert adapter._explicit_bot_mentions_exclude_self(message) is True + assert adapter._should_process_message(message) is False + + +def test_clean_bot_trigger_text_strips_the_current_handle(): + """Prefix stripping must follow a rename, not the stale cached handle.""" + adapter = _make_adapter(require_mention=True) + adapter._bot = _IdentityBot(cached="old_helper_bot", server="new_helper_bot") + adapter._note_bot_username("new_helper_bot") + + assert adapter._clean_bot_trigger_text("@new_helper_bot ship it") == "ship it" diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 1ea240fabf9..346a00456c3 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -538,6 +538,7 @@ Hermes Agent works in Telegram group chats with a few considerations: - `/command@botusername` (Telegram's bot-menu command form that includes the bot name) - matches for one of your configured regex wake words in `telegram.mention_patterns` - In groups with multiple Hermes bots, `telegram.exclusive_bot_mentions` keeps routing deterministic. When a message explicitly mentions one or more Telegram bot usernames, only the mentioned bot profiles process it; other Hermes bots ignore it before reply and wake-word fallbacks run. This is enabled by default. +- Renaming the bot's `@username` in BotFather is picked up automatically — Hermes follows the new handle for mention routing without a gateway restart. Collectible (Fragment) usernames that don't end in `bot` are supported too. - Use `telegram.ignored_threads` to keep Hermes silent in specific Telegram forum topics, even when the group would otherwise allow free responses or mention-triggered replies - If `telegram.require_mention` is left unset or false, Hermes keeps the previous open-group behavior and responds to normal group messages it can see From d7488a55796b7edde3102958a2cfad84ea6db30b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:19:20 -0700 Subject: [PATCH 063/526] fix(telegram): treat "never checked" identity as stale on a fresh-boot clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity-refresh TTL used 0.0 as the "never checked" sentinel and compared it against time.monotonic(). That epoch is arbitrary and starts near zero on a freshly-booted host, so on CI runners and containers `monotonic() - 0.0` was itself below the TTL — "never checked" read as "checked just now" and the first identity refresh was suppressed for the first 5 minutes of uptime. The stale-handle recovery therefore did nothing on exactly the machines most likely to be freshly booted. Invisible on a long-lived dev box (uptime >> TTL); caught by CI. - Sentinel is now None, meaning never checked and always stale. - Both TTL comparison sites route through _bot_identity_is_fresh(). - Regression test pins the invariant under a faked 12s-uptime clock. Verified by re-running the recheck tests against a simulated 3s-uptime host: green with the fix, and restoring the 0.0 sentinel reproduces the exact CI failure locally. --- plugins/platforms/telegram/adapter.py | 25 +++++++++++++++++---- tests/gateway/test_telegram_group_gating.py | 24 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index b7e270a9f29..f9f9e21d0b6 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -759,7 +759,12 @@ class TelegramAdapter(BasePlatformAdapter): # pointing at the old handle until something calls getMe again. Every # mention/routing comparison reads _current_bot_username() instead. self._bot_username_observed: Optional[str] = None - self._bot_identity_checked_at: float = 0.0 + # None = never checked. Must NOT be 0.0: these are compared against + # time.monotonic(), whose epoch is arbitrary and on a freshly-booted + # host starts near zero — so a 0.0 sentinel reads as "checked just + # now" and suppresses the first refresh for the first TTL seconds of + # uptime. + self._bot_identity_checked_at: Optional[float] = None self._bot_identity_refresh_task: Optional[asyncio.Task] = None # Consecutive heartbeat probes that saw queued updates the running # poller is not consuming. get_me() can't see this — the send path is @@ -7873,6 +7878,19 @@ class TelegramAdapter(BasePlatformAdapter): continue self._note_bot_username(getattr(candidate, "username", None)) + def _bot_identity_is_fresh(self) -> bool: + """True when identity was re-read within the TTL. + + ``None`` means never checked, which is always stale. Do not fold the + sentinel into ``0.0``: monotonic clocks have an arbitrary epoch that + can legitimately be smaller than the TTL on a freshly-booted host, + which would make "never" look like "just now". + """ + checked_at = getattr(self, "_bot_identity_checked_at", None) + if checked_at is None: + return False + return (time.monotonic() - checked_at) < self._BOT_IDENTITY_TTL_SECONDS + async def _refresh_bot_identity(self, *, force: bool = False) -> None: """Re-read the bot's identity from Telegram when the cache may be stale. @@ -7883,8 +7901,7 @@ class TelegramAdapter(BasePlatformAdapter): bot = self._bot if bot is None or not callable(getattr(bot, "get_me", None)): return - now = time.monotonic() - if not force and (now - getattr(self, "_bot_identity_checked_at", 0.0)) < self._BOT_IDENTITY_TTL_SECONDS: + if not force and self._bot_identity_is_fresh(): return try: me = await asyncio.wait_for(bot.get_me(), self._BOT_IDENTITY_PROBE_TIMEOUT) @@ -8047,7 +8064,7 @@ class TelegramAdapter(BasePlatformAdapter): existing = getattr(self, "_bot_identity_refresh_task", None) if existing is not None and not existing.done(): return - if (time.monotonic() - getattr(self, "_bot_identity_checked_at", 0.0)) < self._BOT_IDENTITY_TTL_SECONDS: + if self._bot_identity_is_fresh(): return try: loop = asyncio.get_running_loop() diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 018be18778a..fa05f605af9 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -1597,3 +1597,27 @@ def test_clean_bot_trigger_text_strips_the_current_handle(): adapter._note_bot_username("new_helper_bot") assert adapter._clean_bot_trigger_text("@new_helper_bot ship it") == "ship it" + + +def test_identity_freshness_does_not_depend_on_host_uptime(monkeypatch): + """A never-checked identity is stale even when monotonic() is near zero. + + time.monotonic() has an arbitrary epoch: on a freshly-booted host (CI + runners, containers) it starts near 0. A 0.0 "never checked" sentinel + therefore reads as "checked just now" for the first TTL seconds of + uptime, suppressing the very first identity refresh — so the stale-handle + recovery silently did nothing on exactly the machines most likely to be + freshly booted. Caught by CI, invisible on a long-lived dev box. + """ + adapter = _make_adapter(require_mention=True) + adapter._bot = _IdentityBot(cached="old_helper_bot", server="new_helper_bot") + + # Simulate a host that booted 12 seconds ago. + monkeypatch.setattr( + "plugins.platforms.telegram.adapter.time.monotonic", lambda: 12.0 + ) + + assert adapter._bot_identity_is_fresh() is False + + adapter._note_bot_username("new_helper_bot") + assert adapter._bot_identity_is_fresh() is True From 085c7da1113964855d56ac361e38e9a0de6ec11e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:33:46 -0700 Subject: [PATCH 064/526] chore: untrack committed PR infographics and enforce the rule in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR infographics belong in the PR description, referenced from the image-provider URL. The binary never enters git history. This rule has been established twice and leaked twice. #48261 removed the first batch. #54564 removed a second batch and added `infographic/` to .gitignore — but .gitignore only stops an accidental `git add`. It does nothing against `git add -f`, and nothing for a directory that does not literally match the pattern. In the four weeks after that rule landed, nine more PNGs were force-added, and an `infograficos/` directory (#70552's loophole, never actually closed) slipped a tenth past the pattern entirely. Removes 11 tracked images (~14MB) with `git rm --cached`, so local copies survive. Adds an infographic-check CI job that matches on the IMAGE rather than on one directory spelling, so a localized or typo'd path cannot sidestep it, and extends the .gitignore pattern list as the first line of defence. Verified the guard both ways against synthetic repos: it fires on `git add -f` into `infographic/`, on the `infograficos/` spelling, and on nested `docs/pr/infographics/*.jpg`; it does not fire on legitimate product imagery under `docs/assets/` or `website/`, nor on non-image files. --- .github/workflows/ci.yml | 5 ++ .github/workflows/infographic-check.yml | 78 ++++++++++++++++++ .gitignore | 8 ++ .../bedrock_converse_cache_demoniaco_flow.png | Bin 813078 -> 0 bytes .../approval-mode-validation/infographic.png | Bin 1364201 -> 0 bytes .../infographic.png | Bin 577615 -> 0 bytes .../dead-delivery-targets/infographic.png | Bin 1284475 -> 0 bytes .../feishu-group-events/infographic.png | Bin 1225522 -> 0 bytes .../fireworks-provider/infographic.png | Bin 2243922 -> 0 bytes .../friendly-tool-labels/infographic.png | Bin 921590 -> 0 bytes .../infographic.png | Bin 1632053 -> 0 bytes .../list-profiles-perf-54751/infographic.png | Bin 1596005 -> 0 bytes .../reasoning-max-ultra/infographic.png | Bin 1693937 -> 0 bytes .../win-clh-lock-traceback/infographic.png | Bin 1898362 -> 0 bytes 14 files changed, 91 insertions(+) create mode 100644 .github/workflows/infographic-check.yml delete mode 100644 infograficos/bedrock_converse_cache_demoniaco_flow.png delete mode 100644 infographic/approval-mode-validation/infographic.png delete mode 100644 infographic/checkpoint-prune-startup-safety/infographic.png delete mode 100644 infographic/dead-delivery-targets/infographic.png delete mode 100644 infographic/feishu-group-events/infographic.png delete mode 100644 infographic/fireworks-provider/infographic.png delete mode 100644 infographic/friendly-tool-labels/infographic.png delete mode 100644 infographic/gateway-reconnect-contract/infographic.png delete mode 100644 infographic/list-profiles-perf-54751/infographic.png delete mode 100644 infographic/reasoning-max-ultra/infographic.png delete mode 100644 infographic/win-clh-lock-traceback/infographic.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1ded0ff541..3441d7bb6db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,11 @@ jobs: needs: detect uses: ./.github/workflows/uv-lockfile-check.yml + infographic-check: + name: Check no committed infographics + needs: detect + uses: ./.github/workflows/infographic-check.yml + lockfile-diff: name: package-lock.json diff needs: detect diff --git a/.github/workflows/infographic-check.yml b/.github/workflows/infographic-check.yml new file mode 100644 index 00000000000..288f6f493a0 --- /dev/null +++ b/.github/workflows/infographic-check.yml @@ -0,0 +1,78 @@ +name: Infographic Check + +# Rejects PRs that commit PR-infographic images into the repo. +# +# PR infographics are rendered to an image-provider URL (fal.media) and +# embedded in the PR *description*. The PR body is the archive; the binary +# never belongs in git history. +# +# This has now leaked twice. PR #48261 removed the first batch, PR #54564 +# removed a second batch and added `infographic/` to `.gitignore` — but +# `.gitignore` only stops *accidental* `git add`. It does nothing against +# `git add -f`, and it does nothing for a path that does not literally match +# the ignore pattern. Nine more PNGs (~14MB) were committed in the four +# weeks AFTER that rule landed, plus PR #70552 caught an `infograficos/` +# spelling that sidestepped the pattern entirely. +# +# A passive ignore rule cannot enforce a policy. This check can. + +on: + workflow_call: + outputs: + review_status: + description: "JSON array of review_status objects for the synthesizer." + value: ${{ jobs.check-no-committed-infographics.outputs.review_status }} + +permissions: + contents: read + +jobs: + check-no-committed-infographics: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + review_status: ${{ steps.infographic-check.outputs.review_status }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - id: infographic-check + name: Reject committed PR-infographic images + run: | + # Match on the IMAGE, not on a directory name. Keying this to + # `infographic/` is what let `infograficos/` through in #70552 — + # any localized or typo'd directory would sidestep it again. + # Instead: find tracked raster images whose path contains an + # infographic-ish segment, in any spelling, at any depth. + # + # `docs/assets` and `website/` legitimately hold product imagery + # and are excluded; those are referenced from shipped docs pages. + OFFENDERS=$(git ls-files -z \ + | tr '\0' '\n' \ + | grep -iE '(^|/)(infograph|infograf)[^/]*/' \ + | grep -iE '\.(png|jpe?g|webp|gif)$' \ + || true) + + if [ -n "$OFFENDERS" ]; then + COUNT=$(printf '%s\n' "$OFFENDERS" | wc -l | tr -d ' ') + STATUS='[{"source":"committed infographics","results":[{"kind":"action_required","title":"PR infographic committed to the repo","summary":"Infographic images belong in the PR description, never in git.","detail":"","how_to_fix":"Untrack the image and reference the provider URL from the PR body instead:\n```\ngit rm --cached \n```\nThen put it in the PR description:\n```\n## Infographic\n\n![slug](https://)\n```\n"}]}]' + echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT" + echo "" + echo "::error::${COUNT} PR-infographic image(s) are tracked in git." + echo "" + printf '%s\n' "$OFFENDERS" | sed 's/^/ /' + echo "" + echo "PR infographics are rendered to an image-provider URL and" + echo "embedded in the PR DESCRIPTION. The PR body is the archive —" + echo "the binary never enters git history." + echo "" + echo "This rule has been re-established twice already (#48261," + echo "#54564) and leaked both times, because .gitignore cannot stop" + echo "'git add -f' or a differently-spelled directory (#70552)." + echo "" + echo "To fix:" + echo " git rm --cached # keeps your local copy" + echo " # then embed the provider URL in the PR description" + exit 1 + fi + echo "::notice::No committed PR-infographic images." + echo "review_status=[]" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 8c0ce9c23d5..a72536f0077 100644 --- a/.gitignore +++ b/.gitignore @@ -175,5 +175,13 @@ apps/desktop/demo/ # image-provider (fal.media) URL — they are NEVER committed to the repo. The # PR body is the archive. See the hermes-agent-dev skill's # pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1). +# +# Spelling variants are listed because a single `infographic/` pattern was +# sidestepped by an `infograficos/` directory (#70552). .gitignore is only +# the first line of defence and cannot stop `git add -f` at all — the +# infographic-check CI job is what actually enforces this. infographic/ +infographics/ +infograficos/ +infografico/ native/fts5_cjk/*.so diff --git a/infograficos/bedrock_converse_cache_demoniaco_flow.png b/infograficos/bedrock_converse_cache_demoniaco_flow.png deleted file mode 100644 index 37eb271441269f19b94af585a541a7a18524660e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 813078 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%0A`(bQ(0Tg6oFHc*ndYXd`|^-I=;;{MGz+ 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¬(TCX$)rqnhuhTHsY0BLV#FZl*zemAmLqy2WMMF@vCnhPt>7 zJ(jdgwb7mbK#f}a8dyU*gfGq_rbC)o2f>Z)KgN!T^z-rhT{=q#8yLo_J{%XGtdgz- zQgsjg${L^#j?biXq{KY@l=dHXb3y6!UNy-6EMys{7t)YX&=q^2R%t)vw^{Mlx>Zkq zzo-I_l)9G{jpwoPT?8a&<7a`(;3o?W5OdFa11r2BIrL@B{Zy~@y>J=)mBY+FA_e#pecTz808 zU+m3Z?r8`_rDsWmQDS(;SYFpV8)H6R{*duF`&G5ctq7flA1(0-71LiYtO~B?Lu)@K zyJ}SLITb|RinUg_l7*k;XF#vt(q6AkT=t>8wsrTmpaex7Bk7 zPzawpE56nW+R~U%6IFj5hzcrYaBMga=)x;_rKi56c!>S%@*<`v` zbRJCVwQ-9Yi6#<=_!6- zt`o7!RF9GyzVp1UUT@v}{?BB7Z-H_T^(_w29u50(oHT1-4DC4QZs&4c3`*oB(J#6vmd< zqp8RQK?=>vs{aO<(zOTp_xlyrIl2T?npMIEWC$zyxGao89#7#B{Gu1j)vKvP6?UBQ zvZzQ$5WbE_B(bHxyyNsqFjf5Y$xRv=fEv;5kBsueZ^awzMkC>4;q_=i=naahwg$Z6 z7mrb0O6mgNu$0cNIjhV2eK@l>3l;(n*e`6m`&-r)Z*=`G`l)4?88gPH@7bOf?+BPnE2YgB8zaf z*})x`VYP4HmP+JZMgcjS*kZp>ZZ?j*Z~(~h#hugxI*f>?RG(-?>5Irj!zxq{0mh!N zP4i3h+mD@|1T5J-aQb2XGb@hB@xvpbQ$hLigPEzYK>Gkw&M5~QGq-uYYk_*Q4gWeT z56XTCrI%M{SN+YZ-M>_TD;?$MytFbOPxx^anG(R#4)1!bHkA4SnQ(N)@^6~&g|KM1 zTZ$f-40w(GqQxsh{6CzL+_4*vpY@Bdaj6WwmnwNEl||*uIX6VtKwgQ@1O`4D(Vrdt z4QfuuonTw|E-J&E^&VT(J%G&nj}YwZ=rWz9@m8@Mto_(`ai{yR${5(oLaxm7UoV^YY*rJqZ!9^y;Qf1I1sR`9Q+ z>oC1wUUZM5EX}%@X3_VC^&Qdx?j4W8H%kxf`cL)cdf`(qk&g!5B8OU}E|Ht-h$0Wg zmYA40h6W#g4eHPm1Q>k3_;ZTXaw}XwdJGf;@RCOG$t!&Sv3W};N?l}Du}(0gJ~DAN z3``iPoBQQ&TaFp5}+=>L&vE9z_i}24XNd;e@g3wJJ zXH`|wHvVIa2d&mg*NwkIKiN-hmNKSEu40ZcurV#B-u=}iB#<};4p9XG{1|CPn^Rr!Pms~lXyt|4XLvjA&=hWJcOnbbjPYVSm>S(Q!ZPjc4UFWnX{C7WFT zt3JgHz)Cne5P1yzeTd2g=E%PM$tzq8E&2^4KkZ!rc?=@Hl}a($HfQFA0{?i6)Ca%R z3jY8lJJYiJNOz~sBW~H%ebd@5kOj{H-TAHoT$^Rk4hwjX(nWPk2{Jz#o&Yvskv!D5 zlJXxLyYWe+$1La}_|yvG7NMqrz-zuT0Fvqem(jxRQG{A6-L{O+O%_ju(BZ~|m(r{$ zK1u4KMvvF1me-+e+NE%;U+^Nr2)k=azre7KVR3`V0fV4MAq=^y57RU>00$Any79@v zU7VEoL?nnf3hM2;Fpa#aSrw&VIT6?CQL!EDxbaN;nKQHO<)}~f(sG}Q?Pie6_!3f{ z*$m7BRi&w=z;=KbcW1g(q4uaV%y$4SczZlfT0+Z#yq~%!K=_ZXUlb+ow{Q*-Y}?X6 zZv0o+piZ4R_+C~-a0B%_>)KM|rj5t$y#Uh#?W+=~pZ4LWo31`sC3j30?Ot=Xlf9Xn zrE}^gB1@l78IT*H}c3anS3alL&a1ao4XZi*DD zV}1-BkB%YDzM)IGen-3oGE@Owdu=7!Ym`)UhTK`3;(W5KNnd$B@6Pn2Ptptc@3V9I zTj6uNg20&(pwu!1;tAO9aW2M<`Z<)UFQ~$EQb0Ib4PIOGK`bTbO`pg?=;onC(45%u zu-O5hN-ZTt`y-0u_lcKR-asQor(W@8wnB&7v`C6y>`Gyus;rcD|B`!t@;xo4#}2>j z{(>`mynt_xLA2F%-Vt_APSH~|`8Vc}g2VQMxB$2>^DM)R=0sO!+^6{LMv-BJhX*V54M?w zU#Pj|z|1keiqvHS95mQCKg=223( za5c0U2qEsYA=$^S^y#3^>-?j&x8)LlThbqBlUWHHxoJByK6FuZ&-t#aK>De)I*QAQ zae;y!kWN{UEXDWjpGPc$X<4B_V`lHn!mW-(uAJNGIR~y+bN9%ew@T^0OiEAR`xdnh#LY z+t4Dz11h%KIt$;05hR7ywibJB3M|aKC>+iOO(UZLCsHRxN>NB-b!`ARZ1S7lB%PPb z7Mq4zWqWUYx>@}&bF$CGHa{abCJ8zGW$=3npXEy|AM{(1Jwgppi&M0#Iz1299}879 zFDX#e|84ye&SjfRNlgzJV*#OHJ@OrPWH3Q^kYC6T--zkKoIygFg-Bikf5Lxkl7JKE zr4`2}3fBvUGRgEs2oS#WADf7u@L6crJ8&>+!f{0-7r`e_e4oBLUGT;x| zq!}sVA8=?OsZZq2leCdE9;32m=N`bV=B%d0`WL{9r`8bpIpVusiP@LTbASG8=(CId zReYHHz*yAItgQ}ftvqr^)Us5kPe1&but86xF5ywE>yIWO`NvSp-wzxkzNdPfwrnyF z#wu!^|C<~A%)N@jO&wuof?((vH1ANq`urlnrbbe&n&B{HP^MM7Wzs1gr-ZLIQ-I(!z@lsUwV;F^`iNZq-lzf z-FL;<0qN(Udf^^?%z|*Qr8jlmbxO4{QrIn+Fcmus4tXAEc+dmwyW>QHpE@UC(tfP3&6$Ii zN8auOadvSdApKGjT@=92*n=2J)2uxM(;v-#6Valbt#98NDbco8EN8O``>;8TJ{lO> zt)hz|@Kj(#AN>|-O2+J-A|I`;_Xnhi3HP`J%qvj)DcD{R5V&)j9GVl06nyWCKd%Y6 zu5IQNa!+?y6Cmfd&wWo;Ho8X(wrK05bA$=y=qNrCEGrr-hHzh=9r^e-<;p~9a!OQ_ z{&?2RfLC1*)*PpwC40kk(LCSFJXBSzAyrS!aimmVx7_>{G~vs7Vzw-oV&tjGcuhq@ z-KcGWb1>H2&b2e;(&kL8j-pLU<(;M4xD3`Qb&4Z0iZa(djJk%@^)=L@E13a-Ot!=m zW%0zSRN)~!uVt^(!gm*62m61|ItiFFu5R)_!i4^ngxP0XJV~SGr8UWb^~qcefn;J! z|4Qsiop3*d12NSr*0jFcM-d)C^C3kE%E_u8;C8a$N{j-9i@MfHTyjfao3oyH(zwF2 zmD^9c-I&W%J$oT(f%x-f?OfG=Y_!V?I{{U!h2`UvH7wm+Aqgdg{6F*u1j5y0UUjKs zj?>yEk9t7oe^MmcDkxn2{HM?l%ZkddiP@1=^A6XOUFxSoMIXm)Ayn9x)iDTl;C#M9 zd#>;UZ_{O#IKqOohM`|~WDP^ayRL2FMLS@tkmHCslPS&p(~R4bhCW50#73OLK(19z z`BrWy0Q8?(EBrD9&i#+gX{K?X+kIuo19qth>ui~+agW)Xn=MsetADmaq*@{qa*1Db z?3bFwO|YN^UuS;4yjn<+lvxAxm!U3J&s5-sHP+^7kmDzFca}Q)PVq^){jZaR%EvAh zfP+F{!T&WktNO)wQG{1ZAl>N+JhXIM+tk0DkTFbks^V(0p zFg@2lXU%zpqxtAUsN`ioX)xm^^F8Qsz>ELb0N&zm7CB=zVAr~s5l+jY7*IdbqUhEL zr7`t*lpz^aP`TJcf%KrRBzHH9m35YhXu(~2olWD^Jt?SGq@h-sD>w=cEpm46u*F3l z?E77QnU0i?lWya`t8bq~m#U*29U#2flh(y?12*aL`;?9Y zJNZPc=uGOzU9A4(uiT_kHBS=bGve7ly$%OLTyo$mrzTE&QTePtuvreCbzfN{hv(s+ zFSd^__|I-z=d6a*pIK)K7W}w+Hd|4`Sb2u@AZj2X^l!^?1efwR2Mo=8ScVez@)0Cm zSH2^K&5==2v|R4`u+HWa*VD1{KZ62~G9etAE6G5KEI*^Qh~ang?c(fwbqiWrhLsxz zC5L8*UohLNPM21Ff(G_3+x}Xk~Dre2wH|PdB zA=%gwFPhS)AABHQi{Y&gf!v|=e1L8K6-L*qOWf7ovNL2v!LFjd6oQ?WeMpdVO3NYu6LcA$o%j{ zq$4jrfx0LD^kc?)kkgw7=brANy&-XDO21Cfwg(PQmLM|C2vK4e%@yV6#og7eJ$QeU z`|1S~Zsl3!q^F^hB>iT%`s&P_9r|ON+lt-xoI{^ulHb+b5?slLb+fLVr4=`=CLA36 z)rU;-WJ@~#<~N^?;^)EeU(JNTlzcTESHo zXl@^4S1C>Dzf+A#%6!9Zb-|!kai2)&dgHiTRY;{zy zb}w(N7=hv~%Jx4@HRaCyMZxY6{$`R(gHCcB8@d-$cvmiX(CYlN^`fc9ipO4GFqtHo zhd)Dqy^sf6VYE4~izw{B1yqM|7cxdE*p%0nm+f97OwGN{WIvqL)*@TTxP2`$e{HtB zXc=0^`ikMXeVKwd8n8LO-C9B}%ukYebnt^ytnU%u=~bR%*&NSH)*TYBKo_hk*OCH` zv}(~|jnXjxmc^o6LR*%xyoG&pffl0;6b?PuJ}?%Z!hULez_v4ZCmnJ*`%7;cBS(wOhs9r;rX>>%<>kd1SF5x% zv7$Goj#R6XGb1#!+P0<~G}4X{C^mkLiaiVDiV-VpXQ=07d&7*I_Mcrmskt>2L1{=Q zvFi9cn-nu{rE3yROZ%UBF$Oh?`V%z;gXM?nx|f07lk+cbw)j3sa!tGa&wX~zQl(`o z0Q-MBb}XJBbt)r# zDDJxLrwIFJP4aTQ8;S;#zS~raTQ9_EvawZGzK^}nx%1?_jE<`DNQrmf!$xwCiCzE_*EP*sD{2L=4$nL^YjFJOIb)r@;U< z&F3}P>h1XTAj{j59eH=uxxb~+E(Eb zBsQO1x>}f&-QHh(xSN)^vS01w)^=xF-%5_d!g2x1Ey~eVXYb>6xsaWw5$iAV3%`%E z@pjDZ^E1R*PdXqZ%;N7=$X$C%=Pth}UtF}*P|B`1 z)|))E=x)6N1$h3&S6p1UD?At%4q6^QO1|3tz&`j@U~lx@&F=e81AkwZdqR^PT-KmD zkplRE?#73tewJZKi(Os1p@rt54fRHJuEaAe-a`^f;&<)EQuFIvdm9$Vjd0VdZDZ6y zB5~|MaraWLs2=4?s;{nM`LMHd`#e?@zP!0qvLCtqLH|mVyHu0@K}`O`U9}{$fkAV+ zg7=@r^_E2lH-0q-(_~ynS7ptB8o{ZlKr?no;GfU4YEBzqouaju0RE8O&MO0wCIjxO z;9KMp0pGfff3btf6_(fyfv~@8LFps$Co$v=^}&gMR!$)B z_hP#L@!@%lpDasLRQFO8TEZl7o5dFuJ^l=cE`=!faID( zI3C)Y5PVFb}t}}!& z#G#>4pkzFThSl<-zN{QWS|C@BX>o^6GC(yOkkc$BKNJteJ)?TFP>GUmN)l!0aHf^7 z0C_kUve(8RM2o(U`{ELEim}SzWCCKZCjs+q0g`SEUgihX1%@6vabXE4@j4=XN`63f z7IYGk*5!>jG!X_&Wf$CW?-vnC8$Zm`VY$!Vc~!jS5bE7q7Ye|fdNstj zy{%qd(kqci&kKdH%HTJH_6^YA%|GO(EPd0Hr(DAM3iXw&)9pS?%byT2S2~+hA5yHl z^SAR*p+&9MjejeKFvG#*Hi^fT*CglH%OeK-?Gm8VZL%D0>Cup3vI( zbeMD2wncEc-&~g}xi+TeQEPiT01CNJRjXkmlNT$boG~p5yB7$6vgIUD{Z}&NDLO!L zy;_DBm!k{}jMm^P88Dg)fs^?TrU1dIww3`hza!`^i)L9@&I(SI>e3!x^B7o{Yb7H6$%ns8DE0k)Z z8Pv>c-c;<+Ilce|o=<#eJ6D`gbhy93{kq(fg+g(T#h!mK$$*o^_+k|`krvM1%MZNU zO+eY50OIMN6>tZzs!Uul>%rY{`qU zmc}V|Xu$-!pa;o@rlh58JRIcJ?=HGpJ&%Z8K zliMG%-u>n_jD1kYR>u*OQgeN~doWz)*k8}JOWrDAj_9W*;j0giKON0~__S5-*w96F z%_|b=e0&ik-PPYiMWhN5_I4oCVIipV)L4+l3g*#^&zptUVAF8Z50>pZo{Xb<*l)Z^ znz-E}B%8XtusG5@ka+0=V6ykRgT0XR8rzVbE(hCF;li*`{OQ5~nqzYXkFkd(*0q=N zlIyE|%e!~Y4UgUlI%2)v6nh(-O;|0<^bk+zW(!p?ddjwS-G)D~C6-HTo}t`ES5Mom zNdx&z1xT`mpJ{bbe188kxAI513Re<)@QD$gtLjHKFbBB$>@dhOtNBk;nyFffy#}wS zf#MuC(rR>-g3%S$m3v19IFtWZ(NP;FGn$C!xk-vrWf@lRLn{ z#TM_G9W=1{lsya9=c#o|`+aE31aE0UF7ut1V!-Y>+10T+z9g39^kL_9U+w$IyG@w; zbB2jneRvMFwEWYw^JI_vUTB?3`sVJAoByv+Y43}qW6bz&X~|+V`v!zd9psIV11?si zj;8Gfv>GZUHHSkfxYFX?TZM(YE`KxhDKiTUEMwfn+)G+9ZE0<7=$wkfc=?F?R?C2% z{8eK;Yb0k^Ca(>?@tga7aYQjagcltkqhXe9ChrC6NY}{r7JVwR{ySg(p8ZvCnuM3J zla-x^RbU8YPpQ@RCH&MEKkKAqCNVj~e{?J7uuwl7#QT^goOsQ-uGWG}Lh53ppY70q z#nm2DE|;^~bq@$;Qw0v59}hC&;*GUJz#UW@I{w^Pd7@}?Xt$1lJFPwQGu+8gn$pJK z*nJQ0`ROrINA6knY=H9nI9<>7>}4|>;^dmCS&Sy+Bx|g><%v3awMa@Z(s3g9d|EF= z^3wCKQ&q zgPX6R?rgMtGEc+X8XG6~nUu4<$r5kFd0Z@<_g$sOS2z*xI~HOzqVHV^GQ9(JeSJD5 zCgowxv$VGl507e30E`*Fl0s#?7|b~p_dyT(XV>5k-Rmp42pAlFa1;OZ$fbgl;_q|w zHC*FbgPetvX^ir(&6#4CGy5N6l}AEimPbGerZ$ZHu0dessF` zOR?3w3v|uNT`J$!Iw+H@%$A#i`dVrXe_%5qeH)kqHavTSGYwWtrNi7+w6i}BhY4a| zbX~2!oALR%Q0}~ZobH7zk`CCQ_xj_DPCqJh_3=BYlCM6i;o+E5mOMXyBlOm*~&$`gvH_q@z+uk7%i{J*^WsR}tGxso@T&yk;JDfEnp<&a&zMrahHUyy z3Wj?+?+{u{Ro?q6vETi=eb&&#v-jtTLBwgRyTHK?(G9*5or!9dC*vb|;~HjXCxtC0 zaJ!Kh@kC0%i>p}`4+_n8Wp(zd70#?DHOb}MP~YI(CNo zTs49OEu^j-r#xpH-qB3{>+YNQnEeDhhcakM^z%q?y1{Y%0Cg64w;nmKWFiK zWNW%XNmk9A88WLTiqB!tRXP8$FM&+s@lw*rf8fQeNUCto&3G9aDnYoEwQ!_1o2mm0 z;?Q-Nfwx_tAFT0ZuIV!72c%0WBC?mI&`trptZv$9q{9Kn9?8iLGl_pQ->#)N&X+~GIQs2#IQ}eZ3UjXG29O1! zt*%+ihd|rF3@~RmrOr*NF!cCSnv4vUF7XI2nH_CneAls=t=y{3~Yw;!vj zEesf&|3&vZm(1n8BEA@sNw*?p^cGqJ4DG*9**H?>jFj|Vd6>N77uYE$JS2UsxT9x2 znYMsFUSI#Kp*bhAi!pwxS3)#pkfL@RQ9Wf?x!C)qvC6uSuu9C*vB?X}ipUO2&;4a% zHXy%OXh+eopFG4%A&wj-|BSftTTcIJullYTrRR@s__I;gmL5{qKsU?XRlx}kbXNYb z)LavJ-B2hP)EEULnlmI@CyU8HYRO}yfo$6!v^3V)$f{%r?QZF}wR+Z<7K+C8?B?{Y z9IesesY%l3$_xPE+kAIN*=D3bt*_qeyx$fHoBAZ947-gB%(^1D( zYtx#g&NA~1?Z{sihvIL~F-!w0+?}D>Vvv3~Dw5lAcQ?dkoD0msl1nE1DlDLI8vF&S zMhCbMb&b0dWCf{DUW?zs2c+ba|Dv4C3r~y8@Ysvxd)S{V-!xH_cc#ReDc&6ZJ?y0F zx$p5;fQx_kl`@G3r2wkvOB2lYrnRVOnLCCi=dYhKO+5MzcwuhOi|(#e|mD^ns)QC?u&Q_{6jZos(%;FsfgB2Fx{m9w1*x; z(CT{@2Q4iyuokgG%t>yNR9_7n07W0TH4PNzmCi3fCpL8Ea$V2eUWk9ma4O%T<}MeN zx-UQws3?X0XKiD#wBMw5>?exJYpVl1ISINLmi`qLS?#Zq&QNTqlU?0$&Z!2G2dj8(gG2q>qnX@=)Zla*DZCQe6g zr4L$hrz{VuId!nSei#p@2eGoWjfqWQQA3=U|KmDLA2q@|cUfjSw(wo8pF1x?X4Zf5=-!Mv!-I8-3B$7-Af0It9ODixjRIQ5#xkPHq07fxnQzwT z427#9w8$HjoLH3(^$I6M&h=*U%u&2&*DUf5(e37l?S#$p{$EV?@TFtV6hE`QI{Al7 zS5z|LzL_jL7fyKcgPp>N1f54vj_24bD=UqZ{4-bX2z38eR!Voiqvc6;t)Xvv)0aP6 zkNPS0TEz;jZt1p+rBl;imijv>8w{Znv8rxBpH+-s)Sy-Y5@ZiLAlvV82Ilytq?Nt^ zVZ;atu&Ide!;2UT`#SUC!UH2o+9S{XfZDZDUonK;2p*X(*&fma|ZMh#0X<)_DZ_cY#RUwVYIs?)? zmXwQM;OqkqufLmyW7oz9CSV%iK;GtpK&sXxc6wJ$>#}_9x&rkIGrbYA5HNriI)o%h zX-;I(PgXUrRxY(uyni(0a*O?P&~S&$zb3EB2BioP(ORz`)xDH( z=9`MX>6w{g&RE=sg6e|ZwXsS>3j8kfv2H|Ayh_<*fb;!=J9;j;Tc?N$+@&G2sRzTD z8V7e{;DEaNPM~{$lj?&{MmMJQ?l0J`*6vK`?CKF9DgQdl-Vy1L6KUrWxnrsU-Ol^T zf5Z!nS6tHwcjo%9QJ)EiaR)!bZOrDn<7yXf5Ecu}XQl0FrF2Ku*IE2&{Nn%)KYUZp z4vd&~hH)PFv0CH>S|>3|feHmilD+$}XntcZ=$!uF`pyYMP$>=6+w43Rzlz~o^4$me zSZCfA@(4fM`D|vba)!4&YP66|nk^e^Q-S(t(&2-$QX>N41yt83FXm=pI8Y zbQ%ERu;f`&__JMl40UD>+8Z2OczFtWq8gZqm3k1}!b4Z2{Q{v=cL?Bgrv;qKCD6f8IT7=nkL*Qc9e(|rMUFqMUcG33@e&6 zC4k^Iz^YosB)fCWc^#-Uq4`U9aWz$(MS|lwC#Y#g)sy{mLbmhEe)c)dM?qRv(KW3w zW4~usicY~d6+z*p!gBeIowIp<5fdpRAE=r`eq3zorob7;=d|mGB zVxN$PyTbvxVxWBE$OigvwxE1t;iXc#rAO6wH)t3|?!6{lwXBG@^NZYj|I|Wx2Z`wJ zq(8t>0*G*jE2$7^78iqKx<*Sn-eE-?B}Wq`urNePbf13aNU;lTr9T24PoU^;yO69tl%bI%^pk|%%t=Q?)ZN>wCS7ZSlenJ9UVSAN{=|KpEI3Vbg9a0khm9>C1`bx zYL$fL5_K}?Ee)Xtr~L4Wyi`5g?DGe^y1z&#%f6iLXm`|#DmE72 zjVL*?RXdhuOmdih6g}lY`Aja%!EUqUErlYP!dBB-bofO{XO@d{ay%;x?+2P6cVNEl zGDiB+6ap(Dh!Hc|4T|jmgj<%DWOy}_up}@Lfr-V^wSLUb{&w@5%?+i9m*$y@EtiIR zCODgwTGqdP0`jd*)7ROlCGO9gn$O@KG7Ai$6q_MpZ9-!l**-BiIGiGEJ{6LxKeI}E zp`CH5(}Tx;fiqp6%zTC8Etzx)&v5!YX@(b$x3taDPpu8kop3H6Hd7D1)Y7z*^Q=)5 z@poVr>%x~=eEMVAbg80Efq5X-`$t};`pIRRt{jIY^(|Qv|Iq$-?End2^X)-Ues0zh z`UKKTXLp4z_yb_~p?f2_A?F=wi+5%_Kx81%K{Mc-S^4G}C>3>?!r2NfRftDkmAz4O zep*X-y821cMC!ni^(uZ5;ExPQ$3yDDLtvn~Xy)3Hq^r`7R^z(^IFZJ*IxK2Ry1@_x zAiPlNV~9S&QM{VwEi~EIHhOuyAFys5{MaIv9|4Z<(5ktCU7X(vTDha_x3Jexw1C!x zhyg>ZP?|Me6TonrFwYqgD*|0-Q4?)e?S$QD|44lngaQ`=!17~XvJ4=l#flxKM3mJD z9=?rRhT8&~DuH>O#6Ru4=DUqi`UbGzu!HE~U$0MBoJQzM@bEl*lMx-kBUP+73bo)q z)w@+}pJDk$v2nFnD7StN7W~ZZF!<|s&%sRJ-SOnZrwbj&AJR^sOJDn0(B+>sK5L z_+9U@%`ZF|GaYIAsK<5UX>qyrn}0XLD5E51PBnvFiD8(uMKZ&ay$jwC5cW@`T5(?n z28%Cq>?nXi_E^z-H$>drknoX?JxhW-lpK;8M1&P6%`}dhC>uN~s|IQH>;8LdXvL@b zapbJn;ihdSgwbj>TU8?ep~1N%E4vNTfUxb;Mahgb%sY%>(?^cwkd*u0+=&XjsX5iB zxn&D3Xc89w`m-=`3jyi_oki-Cnd7ld>PgQE2%pXPwVTYX7mv6b^W4~vW>iJWSLoq%Hi*40SYqa=Yba;Q z&*9s>cgD9c90%2TqP>mfq+lzqQg@NuI!CLE-5x$E<$2f58 zz*xwF=u{~E5+iPCRNrRKy}7Eg8vO1ujNlZ_a3{ntU&ywq<1vl+?U@UH!q%S|#Y|#NCo1O|;>YjjeSY z1+ueA*G1k8uQ5JLi|@y9AuV;Xcwfbks^|eC2+r^tj%s^|khA0F!hZatxLdz&1(y4Y zZC%`<-}_eF(Wl-kEnzH=+-eH z(bBOu!Kch*Cs8wKLL`lJb?8LaFYLmF)scps5xzyNdyVoN1w%gVd|#5D{XwzrLY8-W zi)#C_^E}g36anee-MZ>jX#3(bDZwvWJn0im=(oRURz!Yffyy}lC5n?@ey;nh&3((3 zoga@ED7>a%gzM|;r>}i6mHC-3@#@BDzF@$o6VVi`u_wwpS91A}OyKjA!xDeGH&fro zU}}uj8P_KMC|fEFcCpQ@0Zuk(QTds$ey>6}ue zyER>0lWS13D?Q*YPxE>Ev<#ZiP4d;XI%S8oDJV5Pd-H=$aYEmyn_a21P`c4m%wR$<|_|F%E#Neq{V7>7!ej^Q=FDX~}$@%MetlaAJ zld@Qj6S9GqibXG&eg5372hx6eZ0r>K`xBjP68V_Mm(#>tdvN=hP%K=R*VB*JPwcR`e%N&=n&yqfe50?9F_BT(O?~JnyfWmrCe- z71I`XvU(85bM2|gDU#gJym%QOJy~{L(I=#u>z}!b*juLGio8BG7V&0!6-by2oPN%) zD_@uLCimIYgQ*Ez=mJx!T?hA1Yvo;ti*Z{?3&cPh*VLw5g8MxJrQY`wrbHFEySP-W znue+nY-|!=TSy2F$yMknNbm$pDMc@OUOQf87^K9TRC(_n|D&b^4)*j2#`|-ydWv{5 zL~#3Tomzv{tM{p&H2OI55nVTqw0_PenVYHpoMA;S!F^y@^u#lhAC+pGD;6-S{nnh>#+*axPLSt0zKHc-GHj;e zF+M40P!CN`${Ia2y~9_orjlB^CWd$uZ9b%Ic+23H4@S$ZN6Gljb zXpQu64)$r@k}C?YuiRzJI3QO4IPo`4^6K;XpJ|=ZQATDzQ!DTOw3=&gh|q$I**6_U zxXe=h2JSR2!ty+)CJvSY@Rlxrm;BL|5yA|?Fw(P_K>6j2)$!-fuD@@8GgFQ48R!0P z_N!IOPuc0#ldby82P+|^_u#)irW?w5R{&MEBaKw$H0}{aKt~JPADuIkEnVs5KkX(1 zef!Cm*w&K0q9`l$JlruhxlyI087=8x*Yj)miT~k!Q<^mlGl3Xa*@1yQ_K02CDUyU|1w{qZ@o2b<_K!T74-3D}bS!S}NuF(khp|iN} z;oq6`=E|&{tV#3jXDuN6J&JRCHnJGqAnMQhY>Y8LSl~f{q;Euz90vc zt3&MRKuk2HTW($NC&}T((+t0FIIyLw-O+S5y@9T_cG1{9V;XH}EPpx@obFZw=}> ztz{O{9@gI48txMr*V5e3686S?q4=__;D2mzON9q^$@a`VK%10mqFd$?8y7JDyT^k2 z^Nr?8=k&#cTHM+2POK!%yfhayVWaNWOo~<{G5*HG-a6UEO~M zz-hmwcy{S{#!384zgl?hX%5HgWTT=b*vh`r>dUB^8^qE%!ezula@zP!Yph@X%=RN6 z(NS}AM_1*gvcr(PFnhBV&%Dm@$+R>G4~vHpB+bbPCvJ4{qsF?m^haJkGHnuV!mCzc`r*w)r^N^aths_}O$knUX$74!NyQ^WbL1QA;un>8l6va3PB z#2WJg1_rE=Lhcrt^6EAns?u?bXuxaYW=ovwSGMPgVF~f~Cq8bOQQs|D1Jg_x1_6X| zE|<@H)P>V`zaIXju`ab1Xg5xtDK2{4uf8P0^{YqjMT+N79`-S<~F>bdp7tb4xkS|8CNEiCe_+>idd0LW-3a4ea&wQ%4}-L%rESGw@%@D74E7`)R0J1 z>GhhAQ_9kPd=*#uHvaM?_D-dS-<6_MmlcC-Q{0yR)+Dy zZlwad_8-jhWEz4MzKTd+X{L5tr{xD=&(W#|{HsO99W$%)8GR!uPeqdjV!Lgp*)%f7 zeS&f0u<)8pgVfpBK%dqeMCzs1=H=C290)tPE`+G=LlFnShesjN&_XoJo4d-K=M5$+ zEsO8U)7CNm+uc2EY%~9~A#risyymgG@*-v>S&dLNyYp?^^R-6Kpftn?sH$Ht7TI@J z?*T3Wr=ZuWz35uu*8Fp?ISi^b<@0FmGG2{;nfE9nWc}%9qu*(S9$6gv@k+f(7CjQt z!a4~|S~$qxKo9v-oVj|{6DN_6X!WeCGac5%w38dn&S}rz17?`+doU4Aybv^Zn^rkx zKD{6rW=|f93xVzW!IcK7n-8_$SYAYnhlxM$zOTK$D5<~dKW3u;{A%=dj=3!@07{sq zDNye)pLd;PD3O7xMGL@mhX;f z&4^3*fbu>Goro}Mqq0gxsMv*6tW7nv5~>%x^jj|d$iFosV%sYew=?+gw6Fy~mttjRY}{lQc~2*S{Bp5F1VRtM9XJy9nyp#xG1!p_Ae^v?Cz>*^f3(*Js!<-!ssw{K<;NPJq(HHkAC3 z=Dz|!+kBy^?n7o7X3s|=Nol7oFI6vaV#7Qh7hbTiNHr%KD3T7i)oCi=?okcuM`jsI zWmCFAp@UU{SFcC zYHEXP|HpQpB1pv&U(SZm|JrjM>~jI@w??rsPBW{G#Q}&)k}OP$>m<9}rs>`iDKE_zR$BpBYt?cRjuKY=t``7WRlo!BKyjzHw~v5vRL zxKA<61QRAS4HiWhw?Zp7?AzAkkvGD^D!Gk>-Qulgkf7jl$KgPQc-j!_)DjtdkL<$` zoN^;magu1u zF%3{IwBTb<9ISD)bcY}by;$o&R!Czw5LcuT>pc-`8O2BofVGxln%HDI6#~DcyVr<0 z-P1;0zk4^}5NAbqp4pe$42ilpVllP)migFXiJGc-;BNCO@j(o7c$@q;q~#sXIUM=G z&MxjH^QE7bxkieg{#$4e{d|u3*tN!7`=*&c#tRv&lN#i%kv^6TO*Wv6H4CQS0%%$h zowPdPU7g^fjVr97wxa<{kfr|16^`c7aGsu#_L_8m_U1~tW}c+r6D;q8_49k=LaNZyaYg8I{vU}h0RAc{1e z3}VMaKLPqZ?*G_gAY`1D2AKrFx~6^O^9`<)*s>RD$sZWb(}ouaslj*WG90J&awWYX zJe^s4$>kyfrn8>2@cuALA6*^Y zEI6P*w^S}`rgoMJ$326*$j4^sE#wu6L>l`J|Ndfr5R*#S`B3nnR;V=t+FTk1X+lk@ z(zQ&1i}?aG_RvC_lXfsZWGqA3Pk|$OiQ)((dq7|yMd!vWVAf5!Mj{&5c@_6mZDV-X zUxCH2eQru$HW|0DE@{Wp&O%(fFqK9_zo++HJ)ZJ_UC(Y9A8}wTi@C5IfW>VM>IZ}! zbrId8-X(?_K!IpbiZ=f%d;{=U)P-n$t3zo>^@@!%47JWQB3Fv{tk&NSc$u43+d>mI zP798{Q9l{7WV|L@Vfq1fW{Wxj4E8tIe+NYB$LUH0MFd^%W>JPj2Q|j6ET28{(6aJ# zU6{#h2=fOlrLvo?{y{tc%t4ezZ3Ml{ALNVu>l9SGd(fnHvcklAa@PpH2axHt`q%TW+UhN@y|Wc z`B$D4KIShpm3BfZeH(V8>tyCLAAAggzu&p6P+7~EPV!$T7X7X12XZFrYX zYcHt}!PVL7T>}^nK=w9iPW*>4ALbtXL^D{2hiw;S9Q!(=fZF=}y zcG4lgY51+@9B*}HUX=+7Nnhl3sp=|?8)34!PjqTu`ka4FPquis?PZzBXv=_ftC59l1pGwsdF&R&XD%(I zoeT*V{_sgsKupsvdi$T|s+d9(Gi&~z0AWy4z zLWO-7=)>vPI?F!DDu`Iz1uq>ev?=AJxLT5Ay6w(#)ZOC|az1kbdTZx)l^xbVw`nch zxOyDY=zRIg756ZjjuI{$uC*&vL-*0GD9|p4PW&#P&iA1k^j>WIyeltVrz?2zn`U`3 zH~Ro8vG;6zhK~5cjq9!_V4(J^=eZW2fLOSH#nlnqENfPT;y=pW$RV;Jx?i1N_{bwP zZ_2S(;$cxg+2C0A5djZnEAD~BQugJ1)n=rl2&n($9(_Clfl7rsu3ZH$9sjb+e{UJ@ zXu8@}xvJ$-eQv4HzCZ6bcg2oR`rN^dHpIF5m>p)#B`}lLIB=`usTSrgbgqs*AVTS9 zq4$bujqZHR6(7+5c*)wClM9Lw3b}O7dM3fKz&s%=2!9J>% z_d~Tr{D-e(c9DreGGi0Z1%pz_`|$;`+J$}&`Gc~}Z0(3*f2-yhZ7gyPSWV>!D zh4;OgT1@uC^@o!yR?Jb!DazyI*MBuLo9Jw0$O0eyolvsuO+AnYFoC;E&rjKQ7L^|s z-%{URTGVq)K|Q^tSHsC7L~j*(p`~(rdqpwnmA@;MqStl89oR|$(7!5vl^E80{&PE$ z_E$G;nc@edX-q|@q={e2%)dZ|lR^jB#oE$atBe{7_3^`C!@^wBwmpC2g|LTdOTDpG z*~2#M$-(G(d?zMFa)oU58|{23T#xiM<7IdZLaULhF_ojipY* zjG{T-b=YEsVVDoqmAwI!!mfFAxP>_MrSo}h1uoFQ3|4Jzr}GB0{HNxuO~L>v#pWL=0)74 zM*M==e9Ib7=!Z)W7U0d;Nc=GRZ@fFi3Cc|pZNBDc(!&vJ; z^cbK`VH`>!LX3%Om2j1X27L-*5<0pPZK)7qXWWSsje>)&63|7ZB>9CC)&Z6aw{@vc z;}XV4`*XzyXLFzIO}ustY=&=Rd4KJrS6qDF47@RTbMRzBB(~%IHmG=L+gvBzBF*(h zW__uMg+ZF#<832`0vV9==f6zvUI<@mu1OekBb>%IY&k(g-ILFLg;3H7A)wMLd`UmZa!?rfEhH ze1X7vy8L>+`-3Pdc5>(kNvfUr6oXY5mVoJYc=@11EP#lbIZ4o6nn02mPxB_bpRU8i za8Jgf@}mS_ohNYD0QBM5l{RJwHHt$2p?c_Zk+~_#c|8uIApxD^T_WdjcfDGE)b>>? z#BTiygsu^CgQS5k9SDodzu!eoq#Mj<%rqmm;?D-JpDak3r4IUEIDT%(wHwHzk9mbl ztVIj!SmBMdHm)H3KtQwM3Vd=yKX#p_!`cgg0`KCa#)pqiFzc)&=oNw_g%9Wn$ZuGW zQ`AS>M%mJn_|c+VaF1(LbL4OkYkUWlqdg`Wil^$&6UUv99`o=wOiSfHS~O)D@vB~P zB8<(eO8zRnI|qZE&Q@|bkI~6C-_^O-S<;weSXeRV13%SYQZ`rdks3Ayj}^%K=7F<( zN|v{4bj#X2D@7d;M>PTfU&5xPq9qu&n(d{()6MF%-W)TS^sz0VAIbsK7|v?$vHk61 zPK-i#AC2qxt+C>czu{B|yo!T%lO#o%4(rXG)*iXL^L8?5AYgGu^P(QxUw;$5QJ8Iu zInVW*fUlM$Ucc`o(UwhFkhqSah*Ffm0q&?9gmeO3j13T86W2H%dWl(U#vnOZQy@&h zanTlQiq3RVCouYhUv03znwnf=@{gVzkr__yw1Cb+?b>u=CP6^y%!4iIK-B@;AYWUW@t zf0XBZDMbof05mz1phi;OEn-GKoido%bV6`y#_{>rEhPZ~jT{1rBzp)T4*2o5qp>~q z&9rasi-ld|7R`lVz}7zR@vju@u|Dgcr@`{RdvkeU%5-^p-;~5hO&n(BqaIS0JL%fh z#>^s4gJ)8{*r9#2ayPR{6`rZ!(}0{lz3f~-!Hd%Q{h_Bb&Vz;swpqVGOYNtpzc+zZ z0uOd`!!-aT<3s*U_*-K41~+XOt+U-g=o4H`6bV$F8)jRZj8#+!bS!}l+v0lhEVX67 zU7x{J``R?(r1F_zS^c^x*OO}Ecaa5u!j}FLEO7aQm7+`Q*NnpY7?#K?xcOD{ALx?n zki5tjmM?e8zr!50aRT9REw8Xb=d}m5U$BaPj#V&Vj7+zqak2Wt44TEiY38@(s)i45 zkN_}DcC*ix{ydD4^q#ncX9cuYUCOGLns5btW-Kzyx%>0=W1MNYZ+>(Z=+hC_kvOu^ z9ivRSKw`DSn_>pjuXEtsUZs{z&DBmeT+P#AwIsTV>c+AIY1`*S$Yww<6%++Q?|$~^ z5{9ve(gHmn&O$UPpTpx%pwyxNtZ*ouYa1kdGNwylb@eL){HWgW7k9%XGA zrDbIAjq!s0?ohku;^OOhmL{mqkd|wP-oj4gR-C=@N(0p*d>i6)xnZw(Y-M6$=ClQKDrZf%f&4yqqqejV#GBT}^jVEz8OV>!K@dIs9+3uLie`<3+u`rcVoW2h zg^4jkKbBXB(6L#mu;?(RjsUduv?5ipmR~6>W{BgH@*UhK9`QvWGYkbQ9bjSk%POCf z!6N^l$!8OQxBlyrCXS)k3h=Id-BfU$YLeW{QHO5%I3`YNRxu7K(CeuYuxV|lI&%!& zSR@$~!UR#qE%?M{VB!w2Q3Z483w(w)Y&Errg+{?C7rWTkECBRIDpb%09!fOWaAzdQ z;aQVN?)dh8<;VD}y>)s?P8}#JU@)Ie@@&KQrjEx&^8mLGMj-0JOovj@5{%lVdB05m z9q3Epj4umo`d(PQKz!)UeH5fm|1&BnUipr>r8Pw8=-x(hNtv%ht78!XOzP?(Y$)M znRI!)C!wy?q!bJbinZ!mWF1kr?bK>DDpFH7&?=jYBN#KrWqvr#si&70Ptcq;D$*LA zM?1_5u}WzUrx?Z>3(oDvBdY{XDy2Z9m-j{6%=vi8E1 z&kk^y5?D%6z<4M7H4WU8$c@gAzF>_d-9^(eUr(&x0eEyM=YDgLNXrZW93{^=Ugcf0 zkR*uT(32dRBf|A0AL8w>(tf)Q&L<~Gx1@*%#DpULoFI~`)Jb7&%kGzc#BF(_bZtL- zy|j%owYefn`Ply>>u{D~ej+<0QdBNm;pvVGbKGDClrdfLf_+RCuRARaVbKMKx<++Z zE7pxsn#}_GDdIj^I+!pNCweCdH;j2BMtpQ)*hw_U^Y-S^5(jZXmu9}1<`C*@erIsC zlZASx51Xb)hwertx)Kh-Td!8VUS5M`1B16K_f@FzJ$X8e8B{THyE&2ZXLAvnM`VXz zWrx7~VW^+gxzNi;Fj}|a8j|!?tTL_h+S6vwmlys>JnH}tgt%VfY^uF3DPz(_Pb@%Z zW}#m2HOVOJ2#Pa-#9llbYv0}qI)(k6q)3cRo?V>nYn|(#-E|-F84?c~_5AeD&R|*N zY41}7kL_Rq-;Q`=Sye`p`&Q*rFbzE|ZLu`8V?{i_08_J;`N6kEnE5ENzpsj(J7J-? zz`HURCGx$L|MNY~4F>-VKKcmNt4Ji=cOjJSHwn}{Rdo&?p6|FD90K>l%1J$>#=*vH zl8$iionA_BQ=Yy1%Hsbp|DD<@zB+0*tj`!-?_{D7)iTHPlM8gF(Miw0WV~45K<@;-zYN*8XkRcoBsH>PdGWV_6ebK@p#ofj1n$M z%Xg+D@X7eg@V5r(!^O3tU$W?EGPbv$P0#Aq2N){8(?0JRq2PLZks`OdLzYN~v;lN7 zu`hgfHfly`y^?zmhcw8(vQp%U<$w;l7^E9{-ZV7sHqM#^tWO`OFMKD)(evdTls4_l zv`q^KocrrGBPOSUZBamoyO*K?%oLq}l4s7Nj`QVlGH;6c^sJ4&Syu4TW6e~bqw8l& zr`Pk!d|##E0=QpgEoX%8$O2SP*Pl-xLq3u}c#z=LKvP>6@3@g#)9pB+aWnn~Y-Eo% zm^_C*4^|r1_Z%BPk|*1{a(CsKK3Q^3-Z$QOS~6B@^!r%?&6rNZa(&&wTCVNO<@V0i z(yO-vNM>XElBt=6&f^-yRoI;nLVjaS+@nB$m_9nFDx+|v0Ow|Jr<$X6KX~WShkdT^ z|B_3`I04%+h#MVy`WM!ITWa3ZSNtZTw=Ky(i^tZz-z(u(5wD2gclg)8EA{X4st!AG zYW8C*@A;(MgLs;iiw~sM-oZ-Oee+?Xc=*y`iuBfQiN0IjJx?zK5$>LY?H8i1)*g$3 z0q)CZXG^*Nxh->B7{t3Mj3A4c9ZN1C^$UdQKb_qw{8v>Zzu;|_scY`C#CKkxbo7(w zG~vr(o7kRsY5fdHK+N=vL+5bgR|2nl5_uT?8n zdv6Su3@dQ+P*|#;)%bzi-{c4X_b5+kL}2^zH!r?y)0B1JgcZ;?Ejhkjr5_Rz9m95#**x%y=*vgzsS_>vZ`KWqE@m3Q#Mr@8zrbU7dmQK zs-@K@Se(P0(%sgYo9${@bO?#g#>$?`44kFa}@zu&N|NU zz{`Dsm-a$EcJZn4n;;XH&kK>bB(%1yzu z7P?H3L9#vURhvN~N7}<va}yZaupvVC4@=3mZ2p3I@-nz)gFj%`PNy_R{q z+GV-0b;?QT?kb2vS#d9)N|DhHedYRcp+*>a4N=8hz1W%6#_g1UA1Mjz;}0lVvCM@G z%IhJVWj?R@HpgK7X9q(Z3n$L@aavyNj3jQn{_7b(W_(5gdQbF761>jjoM~Z_UumOJ zc3|Q!vF-}wpV`_Bb9q@?t3dfY$0c9LQuVzw82HGm-_h;3j#B8)r@{iGpTzuLufq$6 zd^}S5Aqtk`YB7jB`2@%`qpRWc$Tf3k>BnFtmxIv-YlvBM<6H)2?VnI}!#CtFnduJD zeUfjCZjw1Mqm3G+wi6By-BshQ5Vb3%^YgEdzbde_$GHM%U{h|Mae$k z@y{WnR8rY+IL0Q|=&K}U%kh*psgqCWv-OfK?g5{!wecs1MfDwS7l#h3=emLAw*UN( zPsacGEpFFT{!@Y!(Gg{6gMTYSu9t$Mzm;0Ja;&9aKCn3m1KH%}q_xuRo=nDnvJ#KxNDSt|tH>#L)V(0N`eJ`JUteaIyb|j`Q3jvg0*2 z97@qNIs^yeS0F{KCiE`s#B#yUT3@eWzC!|vzH-jAYMJ_@Q284U5?I=-pMq<){aL{~ zFLF$VcQ;}~Zvj8W9y$|t7vcaIE;SCvDhrX!aWTE;i-5plHCPQN(unEw5WerqBUg`C z&Mx1#kcrvJiRr|XomyOvj{cTfNphhUYroO>PXND8XuPTja}J|(_6IcxU~l6iH<=UT z>s;j3V>bVfjLQ{uoq#xGWl2#HDRM7dUFDYY^G|f%a5vO(oFkR?=F6P$nY0{Bh|pfM3MplLoEoPBd#Iwm+Z-y*8hf4 z!h-ub7Lx2RoI1;e&8*78i&9S&Uwqk7BfKc5HvZRnsRC5#B8oRRx|c%N3E!?#0jf-x zlh89m62Ql(5~=VpF99a5|4P4C3tjU?`9XK>RKuPkzYp?&!f>(S8(hDV7_8aUG6%?r zxgGWK%5fMaMx*X}jwnRwar4n8ei1E-{hzkq{VGN@l!l}Y^nFxxw}JnZRMbGzvFdDG zy*i)*CVrUr6T$+@h_*%n(fm#GSaep;mt7P%?l9;K#g2V>gpNw8SZsLm-BK5qer=*ygEi)LA&kFsgeFLO6->Xl1 z_vGwN?121?w;q#|HvOj%L}^f}*HUqAw)H!}CO(1*W-$)e#qnxfH7SBHbi^kzk)br8 z1fT{zYPH85zKQH-*nczx9>5)^;-#%Wd~1AKlD9BAO3=N(mOkf;3^2fDq@$59l&qE3 zIBQs4!f^UEM45ao95hk%nv>XMy9|CaO%mQLI|ncXBN*#|BW5yIa#@tYl4xWJI}h;) zVYna&tNAKvo4HBd`BGJlh<$1uvuMi&*+67;Gn_Q)1ODWGz;#w$k9XIH^>*5!-7Ff{ zd&4-cCxP7+b7=(A$9UwT>j_CW_q#af&NWHqn08zG!zR3{Na^Foz>+;vQ_-u0J4keU zu=|VkGXa&WGG(%`e*UmtTDVjh97^;9#Y(Cx54 zBvHzeuV}s4)Zfy6sWqYp&TT(cZZeSUDk<$y90Ddkfz+BI1F?N$i$|C zYi-uV^E@Rg6`K>o22r+OuDO~zF~Y$EU97~A@;kWB?np&Bw8Kn;HDeD7^7#a==H+Nl zpD6s|!aRFxJuwWYaoBki6L2K)9e@V8s{q0c5(8@PTOY<{4_^!55*GXaHSiW#?4j1W ztBIK!(I>`$5$-3C$=-_*yv}E=TZh(rU)%dc|NkRXvDWLO%ATAK;|vAF!*vVDim=|k zS@Y{D$i0*ASH*A&-0|{;#307iKM8YOR00h0sArY|TlI%NQPbl{sy*A9VZ%IPq-^CS zlcWdIiZ&A1Ka^KFUAN$1;7QUFQJ~LjocONNOR-+Oz6u~Bq1iY@ERei#V=Ba$jX2OP z8<(ubm;$l2!C-`iOXkIRbv9?E+4{kDrW0r3s>^8ftW~T?W|+ zj=Iz$E)$+Dp4-x0&v(=4_XpS9wjzq_ni_}PIKAv^Ydb`3XEe02RMHmRDR4|6QM$#62cUZDyAkzJ*iBe{~voP9b%r71pZ>ajU#?++f9u0q5D|^}YN{ zNkEKZiyvhpL4ebUV2^_w{n^+h)>ptd2W=iTWgFr$V@WuX1up~}B5=zkSB9R`)3w|MSrRv>b6+{s~bLjB~P zlw*uvHHXA0ZrwYC-!kv8kWQ)2U}$SgZee~ts_e!?`FcG-;2!0KhOQq>OS)=)8_Lgw zB_1J9ReQ>XOxTAO6I`76*Y+FMR?p8?i;;i-2Q{LChd_6bq8%Wirx<7y$3&++MxO!3 zvSZ*diE*V-FbCYE*#L+1`xay6M8K4tzpLiSk9o@ZJTz4sCUVHC+;zxT%wj3$!J;ohkMoAQcS> zI&7QcKT$a=#wR~_vho#dE?j1pcrQTlVGAn|O57r0xFOMS-|p~3GI9j_#+nP%kehj8 zOu?-FW+lHqAgz2qnE1=I+9-l-gbR1)IYlc(+AF;1f!%{YIqRCOC_O4JW_AJPnD0Y! z{{nekuBOeUFA0uc_2`Z&2JPTS1ec^>n`^(|{Tc_fVIAI&#BDm7{`} zPqo(_>z7Ttp>$hyrkkb7Bl83|=`4aWnZt}%De*DIQ*-Fv6wd?%zR=iyVo!%#3 zHeNRHxY3taF&>m4k;R{Ez57!UcYprV;G!)~ZFY8=dB!RG6Gg%K@wMD8U;cmL>R#az zi6XvbeNXko+O&{ab=w}&^VZgYAC%<6t{xR@(tvS-l!>>8mdvjc|Bd#wb)2ZZgN|Fh zW`(AJ=Z2l|kbFwuq!NX`r>;LoNaeFz5jwPCDkGC+J(~}bn*|V^j9RP=FAMGvdbS+P zZ&I!Nd9A?n$sb}xblgx+{tjc=+yo7U(f8vP(I&ZG{8bhS<87P9$-T?Y!v4rK5 zUBtQD^;g_x|H|1He!&K;{@s`eWc=`ZhabdO!u(9DuhbtE5qvS3($%NwRfWfJbkUxC zbb;hEXF?{cDdT&|XtuRL?yU!c!}sXK)qNW2jkO&5%J{y9R^dvITJaUZ!NL}oJr|`h z4P%*~mR)QF%@hUfJ#EAUKa0Y({EDAjPd8V6d15Q-6X72vw?;|q2mIW%Tjt=9OKSLo zCZg~Lx1d^#IdKsKUS-hd>~((h)OC^r4#mF!Onh+qsCSS5NQzQ~^*y^JkEQ(cE!i=H zTZ8Zxr&k*CxcpN68kO}oNl{ncYSl$LpBgSx1J^l~G6ENW)d6hQ*kO^qZMxGdh&*>` zr57_Vh2C~1tK)I(8P2hl?1PI1pW>!x;sxRc$}YRPoJXIP{IhIJ*K9eMJXpJjR?HU3 z;H1y|@Nbym4MN^SE`ygo20Z~Pmm1yXQi2cBaVExpXf=MN%sgA^`cx7#sYe{aOKgON zUyH24`2ajjmxYc_ITM{=sY0y9Ngry=OF?P+YhX~#w$zt+y(qgHYa$4v$Z`8T(?2L- zEwch$nC?jJ3=lwyGrq+Kpt_}(dn8jelc1S^#9$Y z!Fc_o23@*71EREUnA(pysZ4z0cd7PnYM5g`>TUbSf`yttu<1f!ck-6b4_eoGL|p`2 z;3^UN9?$xT6pRs#1F;|<EOu~d`;-$V&t|?28zttvnTI$>`>twEh-Usx(bGRTO`q*nESyKMwXa;tE)4Is zMhB=Sz`{hv>NsSpUOxMWd}Za?63?{B)*RJ8`TMEEoBh!#t*Q7T4^soDq$v1{7|nS_ z)!QQp>>Ubx9?#e#Gf<<-K0kk}f;!k7*@lk$dm${>AQ*_?0WfY#Mm@@R@zBI;z5~c_ z3y(@n*u5277kT9`?)C^59Spb3zS8r^@UgD`CKY%5ur#-{PXrs$LVpe`*lw>Va5MbH8OU=zia9GlAFG zXvc>ngBK);PKOFyW!zPitAJG+;kHxRG49U~IMh?$Rk#E=myUjYh2ncX>aJHwwSuBi zSBPWh0gpLGcdLGS^8N?AuMbBJm7!U@;#EH6Q7&xwn}IvxS57*XDSO+*`S91Ny35ZpyPbwa9>Hq9Y_DQ;>2Bu? zZ(yYv?v*(>p*Fq1QDkKlO+lVoas?eM8@GRsU`Ljxc#3j9__GapgEa=d*x|Yeto`Ti z7-0r!pZFG5>LO1;^&M2U*6#p!S-PxUY6RC8wu~3Ob>2W?F9l!DsZ8YA+pUa|n(p3~ z3IlDdoMdjQyHibQhE84EdENEfUvr3r>c1XZ?MqOmh0&{E3)FdSW0afNkf&EXwZ}#T z9p4SFuX1d|dhc3b1h2U*YEM0Rp1|`FGC$C2)lQ3vRl(ceWDF`Et+JQDcX1gv?@VA)sT>-&}YU+N^kwp0J8&m-un%NhUoaw|P#^clpw z>n_{`rFYj3_`YuqfJZghks@lPvLcgP{+J0XTyW^=?YVhvf&fzR;Mbm~lNC#{qY=rP_Ox z>Y)V!*;NGl;v+p)_UM$);9lxB5b%jKFMo_Q$`cP-!^PT|>Nq5$|IxPq+YvT{uXFRwA0+!Y<1M!Rf`*0ed;+MoLU4JXw=aIWA>~^RjKJsg-y_7Rs z-A{J$UMJb%JeoH_6xFS0B!{QXrbUFAz^oe<+uIQ_Hh_wMCTYkhWSaoO8Lr=lYZ3wR z0Yjqn3}fEq>e{UNUF^dxiQJWxs-;-OzUq#V(Z&pdKOErb;@@kFg1&yo!--ZNS; z+aD=`y*Shk^v#xKP;ze!mVJ)b9UR}?Tx-3wzh21WC$?{_qi)(>6?KXHFQ1-g|Mi{? zC9xh7FNy|^%mzS;cFd?T+jwXM4Y>O6>@aI=^|w_6AuHCU8Sg@Gt>z~Ljl-W13a<(w zE>MPjn`c)=p%O&kaeelsQMLxFbmB{@MTO64ln*|%bTm$#XUn=ZSaU;)33 z_8%ruhYo}>K%7BjKsIOp)SnX+mBm%=HM#9ocE5ae6gvN7C~tOuC7LdF(;QC?`mM}9 z4aEY3LdMYJlD4+48IOh{;y6x+fVe6Sv+wjV!;2mPYI6*0FVblE9pi zLV;MrW+F0hlSao7|K@@Z&t{KX`O6V!h0;eG(Z2&l**8GxUW_KuW+ZXFD*o{&tpxqE zNKV_-<@!9$135D;gIaBT%+?_RbDl$3PsBep3Y~DdZ-4-|KCappKomJ0WE-|?{23`S zBR#$N;5|F0*EBOr;) z&gCRAqAIX^0&%v{iVrWz_bb7BL zr-cz9CYY^Zy^gi&^RD`6$vE9O?Y4&6nd#SN78YrSfJrT+c^%t{Pa4Rl>afP^5ORsN zn{@YqU}u_`xKQQ;j>LLbkFI<8=xjc5Gg!Ay?dv}`d$>O|U zsJ27nu0cyl;c?b{CugRPpUW!C!k(+qGlX1Qa)J}Tpm(Ndz0G2V!N6hT^0@ez(-X9rPUz>+>I1 zqVc89{xYmVK;`pcAN6OgzD%pkl@*nG@YiU)n{fPHM7ff9iJ4p)-WX2YxXOTHyPZ$C zLq~#6jue2X4jsZPBJU;d9uPbHzd?;&MxyuS8^D|A>K$HH9mtG0T9d)8Hi#Wu^Dger z05o&0*^+AjN70(CMpz)p^Z?RCfz_z|P4b`h6HNl$uW<`=ULF(HAVk#X8#Xy9i>&QW zE3(MgJp1CticPf`IAPL-5}sE~wurs&_n#%4Ts8RCe3XIIOJ`o{!|fV~^vVgMF`Q^b zcbrG2T2jAZ|JFF#)wQ4VFhiLs>vv;u7kmBuyQ!?+tsm}EN2}Ozjhhx?I^QhzTUIOg znU=M}0^G6fT_}BtQkkcE%#2>dY@^hxqpk0=DSzc$o^)DRohXL<9db7@`3?tiUr;v)l13Npw8J=kX@57|G=UEqTvY^_x@5 z{XIZjoHsyyAAAf&fe8gTXgqM%FDVdIi2?{%whmDPN1?iK{SZ*f!7c6Vix2aeAkOQt zuI>SN&>)5Ote^FJ)MW44?YhKu^m@NTOSik>UzOT9DXiXFpSy>!s2|5J&tMFDPkWCHo2-U?f^IWs4A%{%|JesYE065~(w42sXYqfY*`F(=$p zB3%DHl^ro!Ueqg|%a`n%@eR;_U>Q$iGrNd4AI@cmC}jBqF>M|VuDT3580S@p^2zza zz8#ykxAwR&Q*L#7<6@YwHFmKaMSQ%`k74^F_+~GN$jg~Xo2Nr2%TATrPSjdifAG#W zOcfWo(EuVBQVgqh4P!9H-04Q=Gr3$?=f?;Ef+UzZ|FjgCMn=z;eqY|BKp>VRfp#6b zwC^m9ZEUK$o}I@o(6DM`jIP*>{F_Gy+KAFyh`XE0JG8ZsrT06Z*7lW;DF4UnuW;|; z&^j9<6LZZ)43tu`Ri9Io!1MO)9O}+u8)*cL@Jyk%aV2HbmQKLT-S6=-qMfEKy}8w4 zn@wCu*n6mlyL;nZBnx<4;TJLSag@t{@t_}rLW-T8%3;Cj^Rc?8YNw-E+q{MP?fUw9 z+o?60_jMhQsXimVZ8MI)(-5hV_h9O*>zmK4HK;MUhfq)lTGQZGBk!GhXRe{-3MwZ> zarnIW<0J$~)R{AY=7ZQ+hUaKvc)47EEm@-L+!0Cb^PCu8?ssXiG|iR2IHDir1XHf$3hG*Tb{ z=wx)veO#Li?c#)0_(*N7QO?yk)HThC2o*FpyG9l+TP_2bvM)%=O{((|_;5_j9%C)4 z+tNw8s%uNOWd}EYl-o~~^Vsqll8^VA>D2GL7#~a@@BF2ErXplmwgpZTTsAj#<|V5v zm;LZQhx+Dej!ysA%ILJcwdhB=6;qWI&V9u~^Qij029!Yu5Sn;F)dynrGNYc!U=`o~ zB@Q19(U9&jy~0wfGv43St4MP=s(5idldg%7_esE99)kU;1ZW-l0TL#_2A7z zJfP00Mle?7`mV63kcfy-HF7thFoP;CFiRlXY?+QL`6s(Iw%A2gl@T!gkCam9s zQ(hJOsuf;32Oa+VQiO?^cskzD&0gg~Pj3LXL%0?g%T!c8{^0N) zyd?`2%K(Y>k4sV7wU%KXAf4=f%ovnQHW=(CEC;npW*toa?s$WCa^Monew%h(Z~5u) zT`jw^VCWwgl2~@HNxxy*dZ+A0=?L4X{;IK+$roM>=YLJ7DwA&BW;}FO^heJrYUL`P z`$UXB)8nibix=(EJdn@xicGDAfAYQq*sJpX;G+&oe~^O{kFIuLRO{frAU9eiVAY8Y@>Z|Z!|5YU*{ ze%K;*+3&d*pbTRJH{={s$b!R*BXg2dTpEX73Kj1reoxdAe5N(*arY<)YHJA`VQi?vU-H- z#JW1c>-EM$D-KXLD2vgVjE{k+wg`Z?=! zGQJ(9{_P&f6TTaFaSBu1_kZ;BlJ~HKe|+$>OHLhRy$?Rrm}?e=YHG-B?xla0leS@V!N|sh+;ao?7~pPL1rz zTS{KV@>}t#*6JyJyV}i(&;4)1tS8w2dug5SHTmoNj}7kcZd^O94)a<^sjriCIkmy3 z$Lq;@Cfy_c^Gnie3#UtgzJEXOc(>T4_Dud&>C5p@EFaQa>!GD2)7g0@`;y-~$R{;i ze)FsBE;xmfjLcOW<;fJT@_;(Ed1s4flN|uaebak(`Ib#77W4hds6VCz(-m+fw61`s z;th3HEf66L`s3DM=f&@W^X~YuH%R|klmb%PqO@pr4V9Yd&C0E*Si5yIv2sd8o4YDn+ z9J1%s`f`e%^6;YMQL0Sc(iE|9@xawN(r-SWNjQ}EBo3p=Xa>%D4it`o zAd3=QenCpddRgN2!}D5_BDOBat}_i10~)6f!Ob`CT&l ztj`)rZJu|;H4MVV@&eEIN4I@@XSj*aCLk+i;(6p&5EVai{NKM(cgX(-h3pKEbNm zvHJ-{d4}eOC!c*UGm=Gwi(!Z_8Jb(KBI<5u=+R zknt?gRSSlSpp@v4vB=~zyPca*)to(&TKTf7_o-7>$eKpps~Rt^viJXDGWj%%H=uW0 z&6C%$SB`q(B&q@98rxt{>Samny_UPEp2aTF4n==qD=Q|@S{r}OikGyJc2lM zg3YZ-`pA>e%fEnw=%;`I0f1DbTq{4#dok!HosO>m z_K^*E>2CJ!7FqTO;U9o#4o1Dg6|=*#!#&Brwf3&G8v1X>dwOo+x0hB}`~B}%713?4 zPFQ*z;a<5mpZEzb1=njT5ck@%_#$7ffGaCHi#ct7GLB zdlCn%;(O9TpLz%W653v7#SrW}&*OyRq~@fjSL8IZ*d}**eqaJi4I!IPbz)n7O9iT6zzae$+LuE2;IQ8{B;2wz`(2 zYT#=fs18B%%+;0BouWi$J~8I3Fs4R$QclR-bRH-iwuoY0SD7nFh^?s4e%CEW&U*)% zt)O{ZHDuwnS)#b*&}>~2@#~8gst)1v@6RB?o|m#e-)aicYTo3_d8;g)Wn%C+hU)ff zdJBRPN7?Nc6`#q-)6(q8sOi$cKKgkq#2WCS?@_9b&BVB+HP1#aHzW`&(oqedf7a`h z0rp|ZC}PmUG4~5=i;JI@mP^j^gKcVMOU; zO7OU;X*acDB(f6I)%q+Yik(4o`o7i;`RGe!Tzn6!Xsu^f2zGAP%GPGozNJ0ABt86h z`?rq|ncf*n_L>!`elosS@ZvU)|GTK7#QqM&{h=zkGz5FK)98eV$V^*X2q`0G#`8GD z_Q9_wcHT!e0#u(%e`WB}8Tz_B8Ry%ayi=T(v9BOyZ@-AGgrbZvZ7iajtKRNM#aAUT zk;u0>cPuLd)8etW@1^m^i{n=9ldyEs6+@ytM58`siN>$_%Gic7MF@t^Wa!MbdXL`h zC)We_gaBwD?XII3>#fCp=Q0Mp#h=@u?~Qg2=Wtr&FwkK7PX(XSJ0(kGt$iEzKB*HF zhCazT8=H#0>@_I^o?TQnRzgp;L@|aoqroLed3b7E*FvW2at+oT8V}dKtIq6`qbHs| zlfems)Vy=497L6XSSqG-EN0gt7n{ni9!(#+Ev%gk+uoLHzswHi1aw4q9V4VZV{4?w zC3Mn1c3dz1plmaZBe6dS6h3y8ZjF#J7d?9a;ZhSn`FZXB6kM%fjr1JH9pUW!eo7{R z#7v#!#TvcS>6f@{GsWk9BJ)Xf*DrD;cc*1a6cIh4RV{RURLaA@0pUEfVOW&B3VYt+ zs-&-q72{6l4Nucnpht2~6Ohf0gWsRo^WT;Ba!2rl{;G$$sfE<&Si%vJNp z1=%v%{AeeGlVP!4dZT#HxGp8lB6Y@Q_D|Iv1;I^tNx;x|R)}iM#*2DIm`t*d>DF+niNy zzljgO$@p=WnB6Tfg-^^E1L?qBaRQ_VUE+(9oB_cJVjtpOPn~dE`u&Y*bcOi|GaN;e zWn}BOMo#qHOBMP|hVnT}KN4X}7w|YL+YR^DSR2w9RpFf@&ycN|K8?3lwBbo`(#Ge? zzDZ12(h@lJpDuN1G?^Jb?dJTzmXj(u{8o(VANVbv+3AhNzMzjmThQZtVGJQeGC*AS zS3$I@Kh0MCS53nGxrH0T#B+DBaB|ciOC&HAXGy zAA9^CnJ_jm;a9dES9}gLI8Sy`pjzv&p@wE9(tOcd6Hl6;y`v|0b6f8G0oSPppQq9h zV}xGdFooQRlI#R*Ph4V{eL~lKs^vX}jVC$VhVRG&Ke^#q{(9XP+zg9qrN-_T@IOJK z>J}IKu25lNM^L-o@d%>Xwj`8i1v%Mg4bhdMw-jQvk#31G3J}vNS#J=Dc(7ykT#Gkl z0TYOE=4)<9-0{Eh@bM(5HpZmR_Qz!5;hlr|R}va`zWIpLQz?diFnXCHkg;tR+3tE< zl#}_bOLIi%gCxq{kne{g9+j)XqjIB;-N?VKc1S9-`#Nn~$O@{u`m@n=iK&RyZ{ z0q->P_a>o3px7|lckcv9kJiM}6UGz#N2;epj%W3h4?mBGe^I@0JTVJsBbx zxEQ2ZC}8b9ez(ja3~3{4>KL27_nMDx()Teuggm1F)GeNxyHfM8Fe5x6p^(_!Z=n|R z1>2#txh%{vIhZB6#@gCmJD>S{-pCwxwI6%-s`c^?ve4y4!1SMSS(FRG738t{zPXk z7tY>1S#!HzFU& zDY^<4qko&qk%GC3g79^eB(LwL?@kR%+K2q(Kf01x`fIwS!8yh9>a7j8aO+=QL@;jS zj;qMXXxXmfx4!n1oy~$j{u(JHM(#S1WH9el@BdO%sFG&$RAsK`J@Me>yWh|DnykA1 zHSr3E+DBR}_u7d6xc6hwzt%O&Jn>j|F7v>R-Tl+ER#$ha zzSp+5MxM(6q`%=u4`!^}_LgZir|{c%-&MBnLy4b6@g@#@FXC_qBmOD9XCok!pY$Q} z<0N1Xhoxt~hI~TGOZZpB;5Igg@Yb<|z+-@H0md6E&PW}=IrSC&pgs&1XOrR2#Cchy z18DJ%lo6rl3;zHh{$%9*+{Qr)VCdrHU$hUvu*5&ZFNyMGFmL#*^k5JYK3LD#Mgppk zrItogatU0mFRbI1*=E@8rBB{@JIR%2+u;Go(Dd#H;1+MBOV#?Fl8No;T_fqA2(1w z!@vZddFKNh3iibPp*{nh#$Sd1028EalvV!#gwIK3Scw2KgJc1l0Fc@8w~~B;z^0Gd z2jD`6{{V|&@iTdImTwqnjusMf0=L;e*8*6nA>t(b*5l;beQzIV-zvp)y}gG;8#KMT z=33j;U0JjX2bbRe0NVIVTU6s)SGKLuT3bf7`>uG%@aDUpo5NlrFX`~L3{@R%6v5Vp6@D; z^}a69wnHItyu_=gwopVi#w8Bwz%$6;l~`r?{{ZkIl~q0)d`}O&VnO2V1T#h#Za?H2 z${oLmN3tekffBY@pIXOVrt<`k4{!i+ede{3;2-b}?D%+(S=;+@2 zeAb#+^Kqr?r*nV8H{wIHf=7pSUNXT*{{W9X^(Vgo0tW_|blr5YE8))(o-$nce^bvV z{=GUA#&9_3bAeukFUQ}4WFd>;&&0G0Ldmzq+8ErV0>rQOYi2^koSdN;U5aqRFZf@- z1NRU3w?7iWBWkGeW`(hna5%x!yZp=dnE)xaGDkA2f4OFnfIRyQYv4-b&Tz249 zNc4Sn)_86s@aDW_g5nFfMaPA2?%?wvnmdSm`%A4lYg@QWs)?;`p`Oy>HE3prSc^v9 zoWCDF3xNB+9{fi8>=i%ZCeR89iwqD=fj)AKg>$(%AG!f*2|hl29%XHw0r-d*zA}7C z;qn$P2mywffa{OEGZ;MiT!1wC#xrm1%tA}Qu(hRYf6MFo>2EC&pVjaeQjTYZx=PYY zs@F&V06tSzeo5-R4tK;_-;X?T;(ri$*T&I!&&3`u@m{ZaulSq9I=+*s+-jQEsedV& z=JxJWYZ|WOh~a`5;k2~3jtfgDE$r>>?b9s7smIUu4~Tgnu5?{M0y*qp&lv;}o<>JX z>7V0=!X_ji0(?Rkmv;5>Cx?re81C}OI#2}XJ5E9sIS4olQcsQF3NmE2@IQ`$XDU){ zZ$oXcWPl?pV5CYiaO?ZF$OaMsV3kZtQBljW2`eOkF>PX!Ed$9DJx`c0O zN`CJo<0mP(Mk!yFB^29lN3oWY&WZ{aRp7QjKZ!U5ZX>=k$GuLsGY^<#f=R|kdFVrW z9((RTcaDXi02B{F`Qx8+&wi)goA(JIjFG_3?ztGpKn@N*ryOho%0*g^S(|~KtU<}iBh(B6eR&{dy}7Ci z3pU@pau3Wv9eC%U>PC9?J&1W|VM(HJGWwouQn*6-} zS2{4X9cr{U714hI7wpEPgg~!)4T}(xR$TrR{Ne%uLi> zJSGDUoLziOuXlMyofy7qlS#!tAksbqc(YB8*5k%^{uTId4DA-w@#lywbbVAsASGo! z8@C#Vi3EILmeOM26ybn1^bd)CA!yzh@L$7^1Zkcj@OQ-O-svr4Zw;(g=W)1syw_Cl-@@Mp-%lsRZx(C6 z6Z~Q)X*FF7z&F;gXx4YE>Ta#IkBIujTAS*(k7Z7*GJx!{k7 z+Kz+p_r$4fr2G}o^-U96@dmplp|9)SEWFfocr~-7_2Fj$|d(5H=tJ2d*p=_DkrMt$ZC} zbf0@q@bF8vK*@Ef+_5?N3q#dzH4hQ$H#btvb#XQBieBbdY2`CErt+7TSr$f>-we!M zi6?Z2BbECe7mi}8PIbAXgtB;e;vYUKJuK%W)uC;pIaI5EifOJ>`Rm4!TTF{l50;6c%I|MzZ1V`Zv*@;@OH1LYg*)A3;a)}cq_pc@2F_)4g44W zD%GxKFXJB-$7^q+=spqf_JgbI`pjzgtu~iqWiFv_@TbLVy9?WI8{X+q>X2zSo-VMs z(-T^~I*ze#VRLb++*(`fI)R2Wd#XhQi52zPQYX4eBwVQ#)s$m#wYZBPg{#W2n0j$g zRN&fDlY>jzH6MSLQgesAl5uKDJIm*|-y+GdJ{fSoRaB!!yroWUsUOsNaIp*cW%J5mT5J1!ImQB02Z$#K~NhGlf8OH}B0eA#tGnU2) z$vr^h0|ag@)2;>-0tV1C^H=DrZ7pXQ-&dxIyMJ0+&9nKHrFXTPzTNhFX=UaA00m@F zUmyd88OUA^?r>OogNy-_j1H9pz!+hfPCz*7a!4ImW9xy2zyQw22z`ZHy~z za4&+yttfb94pe;acXcMCf2nFyMQshntEpdVw=vp5_IpUD)NiG|o-0^4J40(6v%Gfj z?sFU=A>Kl6HO9gL12{O(ZZLM1U^qK@$5Fr@uiT+Y3IT3|jsfVUiNHA?Ren z=NS9bN-gcoqh_x3>3x!Ncj}TU)SHB9IbIQNN;h^+Nhc(p+}2-rYbM8wcg#*3ZWQs5 z3G7G=c+LpUPak`&PqQE;fa*cy1L#KLK+h!Ofsud)NEI<~dhR&{@(Ah%Mt>iou- zfE*A2AdpBI>5LKxBZ6`Ueo_WHn(b~IaU0H0-WWO0V$Fk}m#`%C1+oa+>Ba|qb?1@v095kc+H0ow>1}j+HL}_F*^>49*It@m^(x0~ z1NoR>%sOBxJ9dnd-zONr1o55#&A84!QVw#t$>>H0ameUDgmmBnDH+Km;GEzeLx2W3 zIqU!c5&X<7=XM7Lxh=@R>Gd5BM&5V?bTw8DO-FuWhx`K7V&jPiN-e`9k&iXUc)%!8%9P+uCu~e{{V!`Q7XleY_6DW!6OkwRDc6yU;~l7k3grUEw6Ru+efv}S1pG=Ck>{r6HQxtH)Vg8o=4PQ3_dJ;Qqy#uD@)Qe z9b3kJ5BPlg*NAR(J3F5kExw(kYqqi2c*YB>%^K|@(=oQ1@T1y@+ujZN? znXGU87W!E3JRhnkLj)sN)KU}n;@A8m6( zxY8TLTDGO7-_0a;(FlG4CKvFwqPBByv0o8tFB2X0U~le4Bi z6DWlKznM;Xd~l1}!9uiT_^0UY6RlEWOHcp1;i z2*q0YdEoFzwn4`&`e2TDAa$yCE&G&Wa=TOz0m;IE2|WnTexr^s3AjQ1NgQQ{ILjZD zVB;JBN7Iw{(0=S)wX|NlbpEe&tbZH|+5-W|0OJ6Rw;90hp>lY^CnLMkZI#0IUH}7eau*}94aPY< zW46#uayC)#aH>m_a=1K`@<}6)?d@jX+D%`_o;SF(l1N(O@5K7tQtCR!n`3j#{9OLtyYSb+Z`mKf z@lXE%2{*+JPs5%#@x8Qh`BysEi2PSC*uE9`Vsud(-07MRgzp!{7Pj+TGDWKR=JshK zcD1%C>7TSmiF`lcxV$~^7vc5PGJHPxF{jVr+bg?ejbrgQjP4|gUmkeoStE}ZPvSoi zK{xhfva?MWhO9Iz85k({GHf9?YukSy$4MAdGOQ2*6C~EKZf2HmT3Gdpu-rMeE8BuTa8~|o+nuKpB8IU zUTd1%@riElUO4UHhvz$Q7R_xk&Kw6)2qzyV-K9tjxFfhzoZxYQxZpKiS*D%Ikdu>! z+)fJ+2ql5ea(Ms~fDYVb^KJChxA=TQVFs3ePYW?x3*1lS(pTAbxma6 z-f*$QGD8$lug_YKiKbZDyvIK-N`f04a0UYKc)-EIZoC6r1iFLrOOi33IV0HTuTBWY z>;ZsB8F$yuEG!GKAy^VdPH;~LsV4-1#{;f&yV=Y5YYBsdVM`APM|PtZ&9q(Q?W=ZL zJ#PMI=3H6hM-uTZdbo_oG_Q%RINCh*Wy=+%lw%)uDeq{dz22>}KUUS|pHtK>_A}!| zzlfY}i3(fpa>bk}C(Z?OdkzLkUuM$&KWJ9p0WUrjYCaUO)%DFwQ}m9$5$Y=P!Qu@sRJ(ilOGG1+=c^_c(B42N5e2xpj_tx9 zG7C%HIiiW=V(2`tv_KJnG+0ajSqBxX>` zp|V1sx>*&8qaBst@y-;n5ouv@RO?ewoS|xzl9e{xB_3t(UzS&kPB%@rf1Am;zdXn# zh{t8spyi)sIIO=JI<#Y6H8A+8a#w;=Qc7vU%68_9T07Z`tm)SJqiMGmnv_s#5L+#S z#w@Nb;J6-fFc_q>l3Bd$$dNp5izeobs*LVzpZG^Fz*04v2J=cJZZxe~%kT2A724kp z#LA(t&UZLdx!VWOJQ6h@4tSEoSk!b4Y4qDmh^D)={>!(&H}=s>6qkB&w$yE|YyiKt zNo*SG=Gx{vRJnOR&2Xw%(LjeZW&{yli>;#>SH)lb6DAKqVE-F!7VEYa&4ABFYH zxOFSLKM44W&i2swGsJpj?wx0Eq3a$MwbN}ZG{3Z2*=V*_6Hlh9#b%h?EZrO~4dS61 z4y-2`#wt}gp%p>f1CTbF)a*N*(4kKo8l#CE7^Ddp8#@pyMi8 zW6*9k?mE?iWJ$qG%#-a51>PHZK#)#uKY!$>DD${oz{h+ol8%$(>zUgXW}^)^HI|v zhC3cL_@D6i#2TN%?Q-AX?d;R|N5Iz}6Y<`OEw#>_;lCN&Xps1iPWW@JX?8kQ>=NjD zrkkQ_8cEkCytvjTzScB_zLr~`KK?ttC&U@Iw{V_QUB>fJsTz@LT(qgwgVjYPQS(tv z$vDfFPTbdC?7TDJ=Yge(rN^`SoQO{@$ZGT9dAzYAAvQW4A{jlg(TLsNVRL5jXLkm z(Jn4-B2Noy7q)hn8f~YGptRI4G)SkCL8e}|W0#BYF0rflGsIVZBbUPWn&6j6@ivnV zjjecl`)^3pf41~%-3~U?ZKt=5B)76l#nfY--WyF)Jwnzyn`vQ@Q+CfY#;Ta8M+)Wa zX;YmUN_LJSQ-YJH7US+XxVX0NmD5RF^WnjHFR;n0&pf7s*XfNU;oZS?&<+g9*>#q{&|irOuCEN0a8Z39@j z)RRcmC6XO8S-Y`ocr3YTXL*9Dg@s%c={o+=R&ktaLk%cKEk>lHtLKi=isibrq^^%n zw*h1@p`6viXPA6f_H?LyQWK+y!_~~GRI;m5f|T4~=*r2{l$4~M;+>x_$<5(}=sZJW z>##FAXm|erAtgapGBN?TBRL}+5;@IQe-5-@5wBW=e=J5$i+rJso>L;L845EnmLf*t zHnQNcc=Pmk!W~vG0(kpF@s6{ic$!;J4_sbqXtw$WpQ=gyq?&!ydQ^u^@d>%oFDt$@h&feS9X3G@o&T3SHZ)>aYGKXspx(s z*SsHVpx%5z(e6A#mpT&onq7Y0>%^ojgmYMbYu;Pv_j+ch4VJTS7>x5VrHQLTjsfDK zPY>;*Ry89Ydfdq@I$XLoz3yb%z0*qfeHl-O{4g&R;DsnjT#z=en;uR`1&2j+E%)u;$G?Wtc*n;+B0sbJ>C${r;m?M8 zHI<2nLpuJIuY5SvJTa_lzB+AAZx38(%cywn??TjVwaq(9@ZObWb!V%6n^%2Ve%~gc zhv3hSUKj9{=Zup3;GM>|;m?S^Hk-xTmYCKWMwzL2F4s)B*Y%$SX#W5V^~*~sXVBM1 z*7fM+@dll!T56sh@l}n4Hg}pFcC9+kFwe7RPIGxhNXBHMnyqx1oRVk~zB~8k4 z_pKR9D$OY?b=2_FgDK)^L2=FwR4P`*(WzBn@u|^NYE*_AtfHIdr-_VdNpd+lYNO_j zp?C3YS_{mq>#RiTtCHW@H=A~lhn7}l2+F~UB#noDN;;CS;fe6%>al{bM{*goi~YV# zg#>eMQ;#Y4ua`F{`;?~O%U?nK68M8Z!>@`y9r%ahKZon%iya?Sn^3**{;Q=z_L_s= zL3e8RcGhku(rxv9Dtn=-cz*W#N!2YaHOmb;^7~5FW3-;m@92v^+84uinw*-Xe*mB8%FYWQcmr=7-SwEaE@(;Rq?!2 zPc5nS+2Q3%t`8GCVdCj1;@qP3r#QLt!aUMaj;hY<^VE29P=Dksjj}l9a=Jy~*sbM& z&zT0qb1Yyxl?2G3WU_{?`V%Px>alGskt-cGQVYrDh}ESxO8{ch#y0t+lIjNLkOTL> zh(0s;UA2#gw;mn%O!nRz@CU?uuZTLPm*P7s4OUMVX;;#CGD}Z~x{c+QnWtP_cpE^n z@aCtY>2?~o_N|-8so&pTM>Wo&djyuF`1hb`9~JcvidO#s4z)dYE58SS!YyOrZx+~S zuLa$gh;=KQuM{=*so~8xNz`=9%{#-Fx4s$hUb*4nYvK(e*4Ij$YVnH&v6C8nLz>Fh z%PK{>btI$hA%1wyDx4*wN>w56T{jr3r?a=KU&8(nqf)FX@un7~IyjsqCsko_DiXuj zpsH8IK}w`$C}F4ju3l8r!?J0FdAeQJbK33%Q>;OKQ?_OAJji=a6%S9#B5?g7|&W!tK zOKW#~&m-j!%M^3Ws3eT8^$EEH5KpA(?U<1689RN4LB~#oN%aI|abHW6IJ`Wpz1LPx zo6Wf0u8nP?(?*io-x0)$tTPH;-CC#BF#OT$8FI$)owQF@_0j0pL!q{Puh#>i^v4}e zMn_OO3}sGvuD?m0qHyCq7lWK~z;HMOgVPx~8OB9&HmR1&4w%6_dSeB;{YOrmk^$;$ zIVx8pVF0-#^1Km{2_O@Uj>Di~lZJB+YBJGn^LMgp-+Q~?qSEi|xFWpPE|ZIDT=0^8 zl9W}uS>0PjqV+!B@jO;OCjS6}bo@{^^GtOghu;={7x<&Y#qCk!x4XQ*)%+)^Llw#a zaEW!Nc%H)IR8?5ymrRW$l!Uj1y!s7JO?&oB_#Jm~BS|NRH7|)?4Yy{2f~BRWiWgsk zB$?ho$|Ug}$q=WMu`YK;2`whQ<-tF&U+oPalG7|cH26d!lw1}28{K%kH0)te+m>{( zf;J-r5etBDF~)dC;_3eYV*db!^0MuTzApS-jadYOH+Zc)2P3ph;y7bqB?4xQlPL1{ z0Q-Ub5`-rlYm{74qr`KJdRi4QyzVKhb&}boc-gHF^nV+Or-{pBPOA2j$9O}6t5rKS zDws^lohouqbk#aMx3hY?-DrH_tVmJh%7Qm_3&;d;ayk%HoSc)Kt$zdnqN(|-&KtUHz*kcA1Uj>Y~*~r061WAftt#>)njl|Bmt3;k%P2x zoCEEVj)$qqJlxkx&cUCOfDNaiI3pv}k&KUb zxmiE8b!&t+YRt;8*)ILS#z-vkA_*CzE%M0`;085EM7EMG9d2J3d1jIfW92{{O99jZ zRN;eT(1LJzlqDIYm79uhU8C+jep)rH{I^>xwPkDHTcx$*Z^x(LdfX(D!xN3CW&yzI z$v7Urbo|6^By_8bCRZn%;D8P>*ktjaLyitOIV6nO$N*uEa58utoN>=1(>)F`fHRw+ zkTT?fkTb{%3C2ERKqO%MoM8Rc$8=r2ER~zJ_WW*vzR zcUH`WQ#^nNT;nHcAYhT{fq{e5jtz5jYqQ5Ag^Vx{$OW^7C2$WJ>Gy`-Pau#@OR8!j z2nwP7VT#=AQ;7{=58SlqVySU=z_${pQ)F|z?mi~vgz2^l9Pc_W+XOac!Iqy{8{N#_KDNH|g(mgDr9LqyAtSHA8x=WAqO;A9-}j4963iq|gv z^SGQJ;W*%6o}`jGj=9r~YFfry zB`rFu>2GyKDD}$)Q&O6a(F$BS&B?IQb-_o&U$z120D@Rb?eh;+1b99zPddX?{2zp zt-faych<|xrMA;Yv$p&F)=|r3Cr}wO03>GzAdmKd<-JGVAPnxotf{;|1cbSa4a9S| zk(_c*8Rz8~7&+p)Xx&(Fa6#%iWwFLj_m5q;&OI|z?2W?!c^sA}IAOtHM+Sc3J-;SHE7Wc!j$7OWDC@X7nakOWMr1`pn zMg~}oq&5a?$iK4?fE7ZV4oKWV!QdPM0meP~1+&QdA5mOb-l)&~d0n2of5*IojP*Pm za6YO@YVg)LP;J zm~;wBI2k11V3I+!^OJ%=!sMKmEBABPp12qQdhu4#i2yD!^6ulV2PB2+03Uk<{qDIH zlC;(IYhN_>^4%`>+1Qm0ZqshdR<+lcO`i6>?`wRDiDaXa2T)G~00WL#dY{jyQ&atl zG8=Bw&lm&Tbiv5T(-_F@!TblW z%bby$jBjiCUF&PvZKnN|_qP38gkMWq-F%k)uKxfu?ygl9Aaj5M@q^bNP&w(w)lf$O z1Cmj+^#B|KM_l0YbA!qLSUDVbtq?)r6VRUH3!L`kXQ4fEJDy9#f9)~I8*|7R;1iS2 zO!q8D1wq|ZqW96Iy{~PSzkTky>C2|2az@JbS81+i+m&egr+ZuVvD|3i3$-0f;n#@% zBS$PFO7PCFqv;n`%#xX|CDoQg=G(}sVcTp{1(gd4b8?JVVZ(5x0%dWXryzlWoNxv+ z*F6E}8+x49+&bp7Z>3M6!>HXZk8?6y+-a#JrKQEhLu@n62qk5bF_9C#YBZ|dd({<4)t&Xy zNp!L7=5;ce(#vC(Ve8@QQ^3=wn98*Im3(TeQd6x-JMvTHbl{+^@VPbKAm^RgCve&n zkUcZO$Oq8(>F6{docx3_2P?-M_W2jhz4W)O`n%h6&!bjQsTT=2x69St z>XOr|*IhTiP5bR4?SrWVIODE<%J^4yHU9vGpA-BoaVe4fKL(5O z4@8Wom}9upZ==z5)k1Q}C(^Y=mTj0<=W6fx|;&64LPEBmxI-Heb zW$wxmcTFa${Avy24~PE%3w&WRxxKfw@dto)8DtH%Q{h|7Tiqp$j!Vevqw8>6=0aSh zvV{v8Ag_$|NUYCiQIOW*9iZ z8NluiMh-jY*MWnKV?0$>9XgUg;Bm$Y7{)Wx88|$F&N6LWu3X>}*uV{gfEZvBagaeE z;|CcS?kbdggOiL53}+p%IQstp53xMvzV<8KTG`oMTUWEXwzundt9@;${NGwBRNmU^ zwdmt!)4i{ByWeE?7!NrgcoDP`PILZ9>~g1)dZfar}A8$J7+&n@u#O(v%0!_Xw|Q_m-X||n|700=X zX?p8&87)=8Cuupu4!m$U`AGI7*Mb4&qyEoaoG2gH?)`cWGsaGRGH-?CbtfYrcERJP z(DdqfJd7C|AY^tUj^KLZ(}VeAj-q_6^V>}<{{Z0ixeh7n(zcGt=&aXE*?iYs48y0O zQ`0Mq1Jrd5jOV`qai7DOe!w%yQb5i=_ApL3I0vT(Z&93aldxC;&M|^X57PvgWH49k5C3U=eHv}xb>yW6t>@8lUl7+_g1^Q^w4QVD{p;W(z{;no?CC%OZjA* z6mA;_KPrrakHvxDb?=;fpm#xHIU83hKqv1L>OdofJn#S=h6p*)X4-M@l^k)NoOQ?} z_;6buqnP(#oHj=8xyA=vch7#KI0R=SCX{W`I@@)l)p>cZlkTiF4z^n>H?F#AuU### z>vFQ#f-(eP9y^@!Iq8gb1Chqj)PQDPIZ5F{;`k00q6-J9>;;4 z`tlM6gG>Yxssj!II0TM_46aUb*B`~e^>Ncd1Qbx%$T%3!2a-U*A2$Q56M_aa zqaN(=PDmYdjDiCm+q_zYVqd z3z5aAYv{IHZKKgAe@!;h=;zLy6mB6%J5D$!oZ#mG{v78gxiuNE!hp&>$Xt`q1C}Sy zu-p7xaa)oBpK=N4I3px4B(87>0i-GTRx20%N8slof*{cxmkJCX@gj9?6&nFo+I6lWv?amP65Kqu5>dv?eq z;iy4bTVAQBeHzgv)hq0^x>tLTk~Zmkt6EQW%DUNfX*Rm8^u9UM(>O7J6nEz*Xfpx~(<$2bFyc^gMe^vLW113KhoxM9i180pv_VcQ>vJs8SC2c9rngN{#3 z=OiA38Rvn6nx0t6_uA=3NiDSLt-R9LOI;#)Q*Jj)MD$Nq?R%~DeO>Q=k)D<)4+ClA zJZB%njGny-!NxL10phNtepm%|^dOzQjDhSrfPFggj1f`=+w*ag*QZ`bPT9dfS_TGq z`VPb2{PEw{H_I5SE9#PIt8Hx`O|RwpiU~En(ejq`77S+ zw*u~E+CRK;$9!ZA9N?Y^Bj&&!)z#@Xp_((dl0nb6$_NA;^Yb1~dhkK#owW+b1aL8v zjOXe&2a%7Iag22p-sqP5nFk{t`RoY6C$mEJVAk0_Z9PpO46nB~*fVAllm$SIlbon6F{98OMlUzk}r|AWSy}ioIYX1O3msas# z?CtQL>11sx$HV^s4^8!>z{=}$@dEt#+h4f|u)`#RTMb^;7MYpeI4yQDbSg*9zZHC0 zbK;*Hc(cUUF8+44;tfvsV(uvow3iY`WdZ^Y*sW~R7$Q(YtWcsTDx$wHqebSJ*XbvM>$!8l=rMpY!x-Rja7IAMB!Sks%|peP_hJa{ zudeQv(%mgBR^sAm*3#7&v{tsFS78OZyEe!n+JT+QrzW{;>%vQrNjM~&;0%&D8OKsd z!NJA{-4)GEbk0b}PI$oRI5{P9N#I}&z~M4hgx`^_a>B^X|Mlt0$JOVk@Y~<&~VOsbSSHSbz$t0fl+!m*v9}3Bb=*ImhuF zo=L|XaycC5)jktm;yqhVHulA5@i&I9joU>TyGFKyQ;luXc2^?R{Qm$tco3^gGQ{Cr z$BOl#hg2gTSl;XIR@K?tWS-XVXS4W^?Mj)RI)rQ0y4A;-Uz$lREq=DQ_jGG*eKSbX z{w2rYrMuHK-w^7b4)uQ=N1}^+3t4qt4(s9_o}U%ny}Y&-jVZP98|ZhW>9RBsU0>O? z(%8#(ws%Wu<-Rk$aiMsICs(&~toVG(Z6Y*s#WH_tq>;@NI~bl9-5EzvQ;sk%8Do=BlkEm;sUGI;Y3GsVxVyWWX(yBCa@zQ}oMNcd!A6}Fok(J!q~li7 zrOKsv+0DjHHl*88X~ie6>l|N#$|-n%lH;&^L01`$sJ(_GC&Wse<$;VT)s*K)mTIJE z^QCCYoM7EsMpAKY_|y_Hv_~WqP_Y4kNGiLC1CTk+4oS(tz#t0mxCI~qyOkpVVC~5# zIXwX5sTl4_ipgaD&4{sVG?GZYN6Mfk2g(5i=Wji8#^Tic9P&pXWOO;m_27ZXkop-TEZFm6u1dEu8Moda)!Sp2d-%uIVG5}2aExO-=W8+U`99??I!X;9OD4w z@<2GpCm9`120l+k7{I{BK+QoU>c1%?JAgRBBObXbM?8Drjt&h)J;?c^gCQ~lN%vY#nG1q8p~f+s zp5$bKPh&eM48R=Zo`am^6P)sUa5`fE(H&Y>(WUQnZqa+|Woun&eR`uryKAz1+J7#Z zwQV))qTfRCy1@Apc;J$E1@-nIfyX|lw=_i3FfusK@_4}*1CTnBPFvJu068O=1WC$* z&5{5=c=L>dpImZ52OoFk;;gCQ1jte8z~ehcFb;E^9!D7Cy0g`^`e?uH-5%DLWUZ>~ z?5$lsi^;96Zksi|^ztsK3JWnCdY-+pk-NI%9XZZ(*dDLJvX${Q?^XXOWu8;_U}2m_(#(z{Oyqf6qAPHu8^3%6nv<8r|Y z;}|RfzyR{WPC>~8b7G_Wxo>`1U#rzGzgwsMU)9Slu^4GaO-eYLR!vQ|}1^t?}&x3Z}A@P5Od_QF@H+~_}el+|=_@%1&(@Poz7f>#};k%!K=fm>Fpx^k7 zG@TuE)Y0`z9e-1OYVq|ALf#DqYf+~DWn5ZY!=dU{_R>f#m&A7~Y8j<>YiH7ATVpo# zSMz+LjU%uHiFRngVPA3n&R?@tr^g=-PvU7t>{vT*J_dXNSJU8K+Z5PB}5Ox0m z6i26Oo+pDy@Na`JwcS4INR@QhlU&!D+E}e8)U@Gk2BR}DwD_@N38G#Lc9ZP!_>L82 zc!^`1A1Dl|Xo8)j7LHFW7A0Cp$~pYwB5IyzoJHen;$<1rZj}9`sZr*sT1qjUM&l}q z?lSgjF^ppfxao8Hhs3vA6T+MaT0A{~%CPdl;;B={(R3q=%&J2Rf`wYO=uwJvsc5P} z)s;!rsZy<5#YshYwTZ>NVC~Mp*#vDT1J@WJgO$P0PUKa=k}yc%o-#eXeU3*s&PeZ6 zY;olV&Pg3GIL1an=e9fNu4?f4i2!GueOuEx13uoMWD|p5r&Cs^2dBXlrG1m-($7!$ zYj3Iihb3uAU8bbdZ%v)PPW$#czX|g-_Bs939A|@xB^|&75OIQgoN#NoQM4Y}D}p-Y z<2>*%NATeBxP#ufe+nGQsAH*u4?~Q!qa8c(oDNiA5P7cVH2FbdPD+A$XD5u4-yKJO zMgZo!AlD>y-MuZU(L4OtPcJZnyq3LHx6|Re{55uKe#c9uL|R2qowkBN=bS0da!xWj z;pX zjSEUf53Zb)m+y2QLbnnKmD?QX`hc!#}qmT!7U%gddI{2 zQ31SxFBQlC;=pCAulQ%+f^S7!=-$izsyVvjD*LvQ>?%TQ@y>bsy+;BJkFaZ8GI$jFK2J0IP*6llZS%og{&D4-UZ_Fe&6(YF>09 zV)C}6(`vH5+^KS1rNL&}%3CM3cv54vx(+}%ILG&k4w)y8r+^M=7f-h(TWXVx9jY=j zkWW2EbI#m#9Ok_G`E?rA9Z4-!pSvl>?&(JSttW3i7e{uR^>CTCKL1b4GM7kk7yu?oAo9#un)H9{n_Lh`=LLAiag6mJcoE+j z+E<+L?OiKQRm@<3ep_LXayJeGk-3R&u01g{%Ul2u9^8y% z@tk%!$m9-rz|Bs&UAE(x!nQh)2*Jlgf^tbD0rKTY$j=oVvV?58Anj{&Cfe0?cYk}) zeA&%YILs=!Ibv$3K3k~1NWN5(vW23yva(A}cWCknVb-Q^HJa62FUnHF$p8Z%bSxWG z@HqiU2M2>$^69$FjDOMyX57pLyb3aQwm>m$Uq_eVgSN|(>v-<G&Uy47sjf5ekl(OucuZoTI8y`8l@At3P-orgPH@88FxQK6J7B`Q`0t-U3-_FJ=J zQ(MiVR%~LIwi+vnmWZ}i?bhDAK^3)Y#0pLA*wpwvdH#T0$#rs{`#$IM9xsL)-wJ*; z^MR$^j}d3afZimZR$^|W6Sl#4QS5cpGW5+`Em7VEXp-vBv|$8jrkYAE+=p7~tZ7!| z+u_?sd4w+xRDVH;kGB~$Navrb+oQ)SvtD%U|Ge+g2RPY`4Owdh8!GxDZWHH8RL+E7=y03bP&LItEUmsQ zrk$A1eT5{;FX2wPZreWUE#R7dHW%vRT3rKm&Y))TQ1XvvFF6_thuCp8HtUs?m+y!> zRo5$ICHYWeDhj^@yd>DvI*puYor?%UWuy>W6SIOG&Ap5N*1Rr-tP@#l~hL znz?3d`4Vn|XD;r~&w{*)nz=pxO(#COmSx1I&NNcE?(=Iky}4J)FdXBTj$L9KYHHmR zn;sII4?UocbImEz1Tz2f`fB667afEpX3DL5x!e+X>GaFVeYL^nwyL*p6t8rX)MX-M z=^jS?{5KF900+b(Xn7fXu7^~x*(ccF6t)POd^js^S5`(4sW=R;NwmbN6mvNFAry4* zW*Z)pmAPx5>+04|JH(k*?)dRGzuapGcc1kSfbr9eP%k-sda=ro9v##AP|v&7MxJrh z#jz9y*uFRYTuoea^pH0tYa*$z#yQk8u78-8^kvogm@5W9I!uQX5wYZS`Y)e4)|ZZQ zM3NvhBLId&xQfkJQ)933_%l;$cZE^;N?b=!$0J%xqk2iEfsM{TSJp)x(fTOls-Kat<>H4xGSG|f5A zv>mkhg%X)W%HCE)$J>bRy$>@jGw;R+;O->}R~qG;SAZLgvBJ8!j}b@;lN5^aDzTpS zTVW7nOLkItKh`;2qA2U23uQbzwQyNB(O{{;{ z4nM0s#}|~AlI8QM6ZB_Cs|1c$rzxU5mz&ZzU}O3p{qm*l6B1g3a{$n>*8}<Mx2{H&tSCh zd3`B!ar3Qj=+pq_#4+eZ9RvsfHVS^eYYMsAVqkKCVi1xV!ytmN4$)@uX?$rwmwB`$AW6ZT@L0<)Lvx)|5Yp%N@oik#>%d_`U--diI(B_JAKpAdUt{MO0P#nM0#ZXZ7aKV$xqD;PMUs(B zU~krFWGDwGf=cM)DmrM^>I*#%dxrzY)yXl3iHP0+`FaiyvG3q2)~C%6>&vVI3D(uB z=dq>wj$dXj8^xQe0%r%q|Dh@)1WN4sqnzwh>IGb%_q}buW!I6Z<_|VtvHj0V{kM9P zQ(aJ8^edskK+Y>VeM;Slfk8t0Kqt#1X z-Ax0T2B*#vv-eAW$d{4h3sT$zeNHRXZQ|;iR=9+ku29nkS7mt5+O83T>K6Xnj&C^6&^Cn6~X)gTv z?DUU+^Zx^Ae#MQMB0mg0?z`d>7nE)p)r2Pq^trTWD->@3J{w&A)A2dPekCOV^?2o` zJeQN^uet|vdfU<~`~q$JGa7-iQcphRo3Pkuc{>kp#h=A7h@glSc5lRYT+a-L{6yQI z4$ulj$v7sJMK^UPHFdp+Mu1!@=LhKfM^lqGerO8`gI7z_8-N@DmJ$A)xIEcrhJ6}` z^F)f;!1l{mI>J{b4QKF#d1RAA&>rk0^xoezP~@59X{Zn!cHb~V=lhoh9R=Z$4>NFR z`tK{|Jsw3;=jgyPg8Au-K~8jrDW*grF_|r+P%aeq*bpJ&OoC$mG#R~dXOs>O=4EgK za(HV-e3iaNLFw_Kl4J?T>jxNG?(~{M1AcS4OCVc=1%S2o}J_K@rMfFhw;P-@h zk#ulg=Y#YXWk@m;f(V8X{r**T57}3FR_WIh4kMzN(wkCpd@ZPt zsNGas5DK>#nQlm`y<{L|N1+|X$OQ37XKNMT>-^FG#Y}8>3ITpRf}jC_T_dSZ$RP&^ z-JlVN%U8|yqcr<&qaK?ST}XWWCY<2evZ=Ess@-G5;r;L{WXDXq$5{qI`{R>bEqjaQ z!1I#&+S9tXiGQ1|b~b*TZ)0)a%-aaH+r^RD@(peOJdl+4X+FIOo^D?}t+rR@9if-T zqGCbOw1(QEI#e3Kq6LwxUvk>k8ZFfsuRc6um@`eFWM?zA21tBw82su?58*>WQS7fS zbd{wou5%kTQq_3B;9FurLnvzI&QchTzU%n7=6m^f>5@!sep69Zm)BYIgALTrUWNkh z0Lap8)#cf}GfKtaLc-OK+N*FCFBg9+bDgNr+RCZQb&*4zqy3=u;Pt5I%#<6*GI7b5 z4h94Z@jL)`?gTSXVOMN6YBDLP7ahK*$cI>}SdEdv5&lLXB)chKnRx(yruG){4Zv^> zw6L@vx|%#WEHbYH4V2ynA6P@54BQ%ixtNa^Dk8mUxT?qZs`>NSE$dxNe@K=9bKPn5_4 zrmkXk%6xxRNvTYXqLFf1*=(i1bClP%z{#YcQN3}~{IQ`#UW`b&RQa10>TBviA?RLO zOKwjb9_b6)g57z+>XiP2$iYCPspZg9vi-}!&laDHtkEph9+qzFhoFj~u}||JJRCNp#H=ZB(-w(`TVsb)%Y-|9F*ZX(o%s!y0kQq{&KEOXdboo72@ zOe#(vZgYp4n@T8^t`p?fiC1*OAYr7tk%qKC^8r+yUCODTg;j=s&c5u=@gDhFX}`Rd z4j~vxB!V#vy%Y=xl&jx#t=n&_T@NOa#zxA0JjLhiuDnm&Z97iAzZR71J-{!)l(F1# z7*<^SO#k|U>oY$=-;X39X<$bNjYR|Tl2WzN@cCcW4hgd`BFB~_je!l8$fZReE$Q*} z2bRW|31PsX=-;Wll$IQ6$jHQNRn=uX7ml*W5rS)Eb*J2g3a{4gRL;te@66v%MEgX4%+hUsrF*(q+G5wfR-FVZ*>I0e3UJ~P%B2yGv*uUZlShA#taD|dpUknOJ21T8yU$x^?%FDG{kC~YEoAJ>D~kz?}oPWFv|rc`VX#hdm+kDNmCKnxw0%z6Jqq` zi>l^kWU$^!GorWH2M$?!hS2V!_QR{%5vRFo)S=gUa!I^Y8)}9grgn~<*JI!$u0V60fr~};hWJ$s(UD7`eoeIM%t}bhV?f8ggdC9?K;R9jw9YwnGCtXPENFI-Eo;uA#;2}w>$rxR zP*tT_H%TZiy|NalifFdd8laAgE>?DkVnR7P#o#5woT7o6q5RS603tt^21SjirsX(` z?qc0-Wp(fy5E24r@CJ-fY@R*SnD-8T=|`I}xW1T_ffQcX3 zLn3Y2#&sFfGbdI_n8|>a$rt#;)mu7!4j`4#J_6K>0t*KBZyMvZ-qQi6fiR$NCkVju zn<$X1DcM*g0gBb(b=p8uaR9k5g8J;UR% zS7+Pj&cde3BMIjUHusuk{&s-3T`;Vg_JU&JT{Yzhin^{ZkLh{VdHaFkD!S_Xzljjn zvrza5SSi6m;LTDR79+{TKh~iks;m%r{RH0;6MRpoePaCFzMGb{#lvbbPTXy(*2sgF zYSEtDHdV-#gU_kt4i7Lc-RxLy^B%~^xv#OhRQ0G|Sj&vpY0WzXQ02EmZohY<=pJP1 zPZ@(gbgm{o#3W0)dP@8=mdE;g@%*I-o+0lRIaFTUFg*iu2bIrSzOBWPv9f8L%Mlg2 zT>7lEtYx;`?)84zOD7x+Qc_!amCP8v)4#+>DSI@NmxS?E8x(0ka0V)ko4d)R&rQ*< zc<}(xALR#~!#YBhz9Gtc==8*rW5=ai1pylfZED|8dlarczb!IFw-wG8>OM(1wN&m} z%}#9w&skjLzv-MZb3OA}97m*#R}v-<@y^s&YN5U`2pWyM^oyIg4ZmIiHY~VpRt@r> zo*?1Qc%4tWx@Otz=5YlWzwP0WF5crM2Di$HRZRgU&C#_~gftzz%Vc0n>q$zH-)F4t zww#wlanX8YR)kj*Y4E0(3XoKEX)v2QREI%bzi-L|STYvUgP}*ClDO*>i8>=cF zEiZ3X@u9}?@2NKUxNvQMZ+my*XEWzd2;9c>kVk=anwHeBfL=I$n%VEJs@8+D!R z$X@dwd89W9BdRZAKpbLrqrdNFasGh3isW-KtYG;pEB+rwM043AC8nNH4Y}LAE=ljxQ7|TbeOQ(rFvuMt4&CNl! z)VPcAA5yr>&TH@>qgOp^A<76LmY7d(zdK6FW>{qiPZuLDTB@+^HyyOJujfJWzzko{{#c{6iqqacX{F4vsMPHN=mCzZDqlH$@-7JGbR znfuE?@$EtctC~k2C<36;XnyV^UKu`gh^xAl;Em5VQ1$A}1NP#Pyx&v&zySwI*g>VQ z0cXeBClK{sGTpE|?xs$8?dc=7*TQj_5kJLvJY21fPaP0EvcO-2xa#(_c-{NFKCsxc zMRq0E_U>j|IbmFvBAUq6;&fSQ(oNGtbGhllnQi-8+PCklvqem`X`!+`sFPXEopuauwXd;LtC@0~CWp2@!5olOA2i{k$Chr;Ucb)j5;0E8P*UYOv$*zsM=m{N1{C1a=la(lc1 zQQu$|o^tEi%JhZP45!{>R0qwhXIAD^LFT5ds_j)(+;Y~|^fji25Nje!4FC0WwO%Gh zyph61Vb>@R$#gk!>!B~6m+so%Q?GO3k48eTR2BM%kwGCR-TwoK2Ln3xuT#s@8iS=e zB|7=E@Y_Ydqej^9S{JSJWK961UlZ2Z$4?#D`DI$~D@^dV;;rjLcy_MP1J)hYi%Y4W zm--Dqzk&P91Tnp4dUob>mutI)*+FgX=P9?BiN*h#dPYpgLtU9E-;_N@2(2VAA6H$7 zRLuzUmQy0%JHt!Vbi@=U-H+H+ZIed;Hnem63o>P3$FN5}IJOG#XOC-#XIgRu=@65s z%*MeCOvf~O54sk<7y=VsWAssB*)DPN)CdFMwG9&F8g1VPoHeERrJo->v|L~*9_6bU z7F94$1abY$uWEoEatsN@<&G!wv*s+G99fNLK(o~J>^k9$ohjXw8*Zh{|p%4>#b^ToSU)27+$(@123~& z=U>}z9>2w2llk@GY0CSmX`IHFIqe!?@XS9lU1Q@WP8v1*(d|CM?HN zL~&BC1@UiUyNpoP1Uxqk!lzRd*?@#qRJTI^RA< zerNK4IWI$b(ta89pI3HF2?oWMz5fI3ZO0;M4@gj#X6J!E6|IPZpga28hrKRZ@n)-z z%=*2b=D^EZSRE&9YH0`u)ckADKw0VOt6Wd_H35AqGH90euQ z(@#xLF5`C8{>;w(9W(%-7^7+n{_!OBR_pVPxfpP2iG8YP9$f1y$sm{eYxzS*A2)lt zoGV~@3#e8fLaqR(a_6n5_I-fI3R!Ad#K6@4IOI;f1-a&CVRWE7fe>QJ?_IVm`SfXO z`BzEhl%oS81gD*@jN38*LVXLY?nYz=ok{e;&i`BGdB=d&mJVk``X|l%wpW=cm22Lc zUEdb^Y+kK5D|oIDIrXBE1Y^_|>Nzuzo*dk043sD2=KignO7XO&2tMbg_Ka6IB{RkJ zdem;!{uXRY?!M`|h&K;5@E(^p?Qv6!TdkfH{}gZa$g!b53Fg2$_ny!<(!q?nqeiie zQEm?x`lG4CB+XH|sGFOfZsNGvqBObh-#>;|$cGB#=uf=Z+4g=>N6(H4$!}_K_o}Si z7HDY7ss8mpz-XYS2&X}Pj;-OsobA&`!T0)<`kYk0x6ML#25u4xewC*Y?v;=B{V725 z1^vw9BwD(jdivITIZt!nY{5K7;nVCiRydE`mkF$H{b}#sTf0j$<-T#;8evkLkTP}G zt<&kQ!MyX(HP)N=M=0+g#9*))`SO$MafYd6+iYj>MW8Yb59sZn$Fns=h?QUA5Te3E z)(uhc;2~@2e7rH0ylLURTfBI}{=!M=-Rl0}1Y}tk>xNxjW!iOvINUhAq`7th@#E-R zVrhY*dXo)!oj8b}DtYwlL`S1P`Z6xZjiWIkP)R6+o0hxjWH-M3PTO8^;)U3(wrM9aRWIUoL}u^ zVE5PF-#)%%z+CP;_sgRZ<-;y8e*@HFLzcIRCCJ>SJ~HTNCX(sr74)a@!L(<#zToab zbzAP$cHS^?@Qw-g7OO+7UUb-_i%f z=RS3~j&r9mO^Gn1)k zmSCO3#2pfU81*Vk=4XS6dX|EPl-2#Z^x;$g6^q!w-D^V0D6W;a9I{onPOZP2D+)Fg z58l&1*0<1UnRpRMYaZ^tBf7(z9D;om=&AC(mV7;PYGq||CELLH)HLrFO0D>`W2Gvt zl0|nslBdhfrE+81{+YaiM2h%st61^6eCG6VgL!sQg};yLx4`S~lXkZ$gimYp+WmB0 zoOWwC{vSTS{O=Y2#F4Eo(P*Mh-&4DF!bif*bC969v!XZS?IXTZ@N;}bwA@O^RZz>- zpFi_#ZdE!llEYbyWb2&d5IUMsQV*cTPYuMyC4WS`p7;&Wr{8G!Q4Kn4 zt@k;8zXJkycJalzVr%{bm%rpM>0&Uei4MBx#F*al`w^@7^_e}g0Ha?QfFW2uuUhxo zn%u$-@C@(2ZSm2gAe}^~C5V5v-A+oN83nwW@if}2YweDWUF#TCqb^Zt|NZQjc%Gnx z6l4gV)tM9w=TZl+Zz2{j0JGy&(ii*!W295RGQd}3Eet#UNK-@Ut5hupDJ@OopYkqP zW;H4^R-=~#RhS$)fSRLw=J}7fEV6N|pn36}@zUo$9b2%-*YXA~_rU4)(eT;^M z^{R6WjKEg|H_?>{U%lH@%E`og?lbijF2!!eq>9Spok{`FqSk0xM~(Bszcx9-#nuDE zj{^f;-GZveR0B{6YQ~STlD>3zsIQ#oOqX~?qMN9~R@1S0#yUO1(E#XVg7Q+(SqwFn zYCx!6x-JmVI zM)%$kR^XGxPy5u3;!z>Nm#_|~d|=tsFe&J9Yw_u8dYl3SQX7Z~+3Yt;wAnM)>HBf4 zKRerIKD_a7cEr|NwpbHk&yXFo?`8R-`N;NYy@ZBQ@g~SA3KGe>9fQ$&xckiG8=f^}7IkwH-U} zgP>pTHSM_`hosF!PMAnlJ}L)#qh>4-qv9IOz0#)jD~}vZaqNxmas*rR$2XK!%HsH- z- z8rv$_`{*0gU2vYuqn_-g#K|tciDaMjD$l>m^j+<>$n}e?yN>Isw@xh>)7T1E^pZjy zBINJ4KC9eNSfC@@=mi2dBKgMxRTT9G;h$sgo>eXyX0HfZp6H3rZX)j9cYIoy@xkKf zhewhEXMS}HuoYKBNSTe^kwmxso5kA6%f2|1<;7QZYHaRdOmP=xx*QSDp0qyHj5y`&KpAc@&kjyxw5l#}Seh zRGCjovR$O3bMH;Nt;1VJV^o`iD>;%U)CK_%BL0 z`21f56AAM8vd#YAxk}VFX?D;6n%vU;@{L4{u&UmRV3M@2|Hxm80^0{_jwfjFlCa#; zFkxRx9lR4i020fz=e=|xsXKsT1nq3U>GW0&l^Pj}OCV13=@s+ioR!(Rc-dh0JWWM-*>f_)wWRf*)%0#ejy_<}fu{Ch8w}~-16TZLH{r1CeV8#KWPUvAq*+XL zbK{WnV5Ti-yxDC!WCQ_=WL$b)V&^;H=SL{o#X8M-%y}&QtF4+p$m!$5k~}CvZ9@MX zt{8Tf;t;Z-IE5UD>snp5rfO=^6cpMDP+M3<6Ak&;1A11&wCZ7E2#i z1i%M&v^c=vGrys4oDTAmNSNyHqt4yjM(RUSLS;(d=lzSr?MwdQsf~5-x|zTE5eub* z+j1K@-vB~rO7Qzi-3_|x1m?$t9RA#66I$cX029}M#h@Y>5#T=!x$FD-)w0ITWM5Oa z=ul{~uR0M_3<1jeF;qdffmnxIv?PBjjP~}#GrwVXwHBYvKkV&g`n8CifA&A7#)oD% zj&=HX(MTjE%CzL)KT?0~+^x(cj=2j4INFHxYC}dyC2OfkG`+L_ph)E43Jctz>w+#( z(4G!XaJEc>1vM~Q>t~2c4aPw26&&1G;vQhLfNP#5Gz+z7g%JjW7cL{Mt#_v=u7H_4 zEWtMNS}-V12TV%QZ<>|qKOZ#DptLsdvfx2nAk_<}cG|8IbD2dTZ~}=?F=$e4bvPK1 zN7C_s6#`=Y7M0WXSHqua$!_h`MuvGc=;zE`98KC?na?#_jQWgSR_)KMqp^pxw6;CT zlG5S6N`df`djW>>yU@d7CDaTc)?hrjajui9lvny=DH#C3gsMr{ph>u5FgRSk>?Y33l6pmY4O}9)D?hMXVnf>`E(c?1HvZ(*iFO~*y z#Vdu;p=O8HLTO#k8TnBL!-FuSbz*Md9m9EGAkwIvh3;~=Mf=P*yd)2!ncr_0xe+m>`3 z(ux|%Y#k6^+tXLexMs_St}oAxZ8eV{zz(dsS}|ioF3MG8fu9w1-}b)($YJPcc>l29 zZwa*rLf(iM&3|eHu>lH`fxyp~b%D^ayT`%${J~)f)Zcc4JhW%u`XLE{rVw#h$SPR7pV%01L zLk=WR%GJ&dwhzKD46WqMEwldE8boyZrkpbX;kl=<&B{y^9rG(3P7xq^1@ zgGD)_2mO*-SC2RV-m3sQ+1I9DsfK-hd#JC&V9+C#uP|P!fg2tLmQ>pQad?o5I;ep_ z$>D;~kX2B6Bazx6Emi9q@9LpgT0BsUf?>U+VK=gx`Ab+S&uZCetD6y%pTDR#X`TDK z?P=Zo_XIIl__=wB>{6kH-0N)t28JSQb4p0mqs5~2PC_WGCO7}_B?B`Z@zczzPQlb zo+MZP;&^kTp{`c@^1)@y{{Sx*L6P5&qqtD6sGEK(K4_fmsbAnyRJP3cLm=v7ou@=x=?z&K2X7{EFIFedCcER~SQ}lwRu*7ohJ678joM`)|lnZmYcZ znR0QQv`yaY)3=G_T2EzCqFN2{!-~gLiJ79M3)8c>o?pdq*spwjM4{FVKb?sZ_`pWm zvE|5JSIcRpRfvLJ^4(Hl+Z)^AcpzN7#ula?dks_Pv^*QlLKDR=`VDf5z(AYq2m#F`P5ro9>2RQeVkmNhCmd$<0Rv1k9EEdo8y|h^sAT zwSj>al!hgORXkJP{0MNA)Pj>ZsL}h{FkUnX5M39hgcmTQ`f(zVVlr7X0J$ZpUKNqTSNoC$f^A{IDS9LY^Yxp(y-*Iy)7=tY_Q^Gbq?O%Nw6(E@m_`!VjI5Vn9-p+Z~Qo zH`IU8KNq*hkK2#;46K#6_%+&#tH#DWV+WzN9eZ1pcmKVNPOV_&mGXu z#^vT~s@^)@?35&`6A05f4fLh$kplk>PrZ>`_(n8rpFQob&PY_bw6vOP0ye^Yc4zCCr$^M{>F zLDco0aa~hUMsB^kJITumaWWn3B>NEtLLUOY_V4KMgYl!uuTn$c_Z=BRyE<^+&~=o<*m@=YPw+n9>@TU%QkyLTpL#BPxjR&2C3a_JOs{BB-w_- ziq9JtN0DK+PcNnr3bx{twFY{E+ijUC11)ETRIlR=sjh1%y#YbD*HaB%_yUwu!g5yr z8!8DA(td+nlj8(i0&gzYD+9LIqLs2Zhc$rS@RDN&aUw1n9jobwPM^lnfK;?0u9=k! zf$y06&yqzhRhRBx&$>0wFRw}Wm(iCdR7j}uo81;M_`LtSQt{{J;2QJYQajH9rP_+9 zVAJEV>6V5o-J^~i?W#R=ZJ0sA3Vu~@oh209l{~->xz52s5|)_=o6ZbDlC3trl4z9b zJS{a>Xr?)g5$c@QV6jH@ZPul@twolAxg^hr$>E0MdWG|sbZ%o%O+ z+goh(4X36|+%K+HH%c*#AdKdC-r|N7Iz7E}SUPmQhau(J_opweF-ks_9+*yelXy#BI+RBwTXWhq+J99u;Nm7MmW9R&3`t{Ko zQ}yF@R0|kzVNC50e{DQ`>yMo>pu(-B%STBw+=GtiB- zDIB~L>dxiGHP>M9VcoF)bTXOFK~fS6)54*?bQ?6UxmKNafxrAti2~vGjS~Kb z8ha#UNnytBg}T_lS5qwjBxUR^EnWzP3aKc$=_#W8tKab5gcrqy?4(|ZC; z0>5%QNz8NH@cY9O%hCuEwf5oih$vts>bL0QO^m$kGpzQ*+H&fS2#3L8S~o36Gx=u* ze|LLeOJ|UpW-ORjo^@=K&9*Hf#rTcKeB-;TsV_~xH4S(({d+^DlwyoD3xSe2byuf{ zrcH9*P)FY}{CF=e70@T7){D^L=XnzCIjyW$u5zd#*<0vk%z=&V4NY0=r`;gEBU>l}|cJl~TDM5BlzWxBHxvXz*Wshs9v{$<*Lva1++Y`J&>_p;bT- zLGaA1Wk-)K_?7{60OaT|Bc*+U5@go?Q+$JVvk%PCHS&>{$fbnTl#2GK4x}dXCd*(~ zA^)Ey4BU{~d=c=%w=?-t5fXH!4dl~gg7du@k%?SwDWkafZ%--BX;Q?z#c!SqK`jLP zUH#p)_U#nrcGgm^XeTJou_nmJmj(m|@@LFtP-4)rRQNF(TCoHbzgUpEe6r3iN~L(9 z0FR(jzT4zYH2}o)vNU|$&2#*?deX=pw7PVdMA;&5)ce5#817K>Oa54ovTr0VqJD7red~(X*<$s zuMjV@ztcjP9fB2yAFdI+$2Y6Y8m#T?{AvUH;GHAIn{mmCvG75;ETJOCDDJp%u6i`u zDJE^K_TKAuoq06`5%SM(SY|!>u!@e01{t-$4`rZ)Tx~(G3$?Ba-c*5rIBt(V8C!@-`1pi~GXSQYa)%xL4T0TIT9wLJMg3q>(E#y=aZ5`z zg%Q!N?=>L)XlKgHZ%nMglS2^qC#|w6gXI_K`d@H37(gn1;*SrVm6@@EX@w5;G|5ui;qw#!BL7R74`g#ST49FK{Ck5wmWK_1RaSh*fl_ z!7B={ybBE|?Z$LV;}y!<^?&uZcjFI3`Ry4(nyI+&t{py&{rhhwZ6>Ka$Yhv->R}KMjcHMo#wXji5t`pS>F?21cvln{X0a(u4KoC1vZuLnmRV9IYrkd6 zpb#xI<7o0$E{*5#dDKU4WVc>g`|aR%z}>v#z`2F5=D4V9_CNNlJ+i*V3lM1hH0 zyYa^R^Q9JRAwRiLc`Vx{3U_r4X3X51jf&x##yyU3TcTdhLZMOdR+SfjK}XGL`0MJ9 zTheY>L?;MBwf6oa*};6pfW7zGr(s5guZH(6&DP>k{-5*pBsh?G{?CWOslq(t6h zH^-oO;@1m!7l)9$GGh;f?3AJrdIR;*js7GQdhtoY4G!<54pQrtvG?_CZio-`oY_{D zx)7O=WnwL(2(aw2i6LsHO?lzath>eWft+v@#hLcRhkxjqPk!5Ard!b3CiSV_Qe0!W z0Z+)HfnmRyoB`mstq%AFTiV(<%JiGAlY^%6my_E(a^8jJi2-jOHsjRXdTjsAUy zFJ>&gTIk6QS2U2#$cB%f0gGK!2xsXA@lEB$V( zV_af&b74GlcXivp%c|p*gzI=dn3Q_?D-s|6diy%z>W$>{l^h+H$5q^WJYAeTES8I#YBvHcl?~io zocu~QB3UOUMNys~XRL*Cf@P0evJlfRww)ZuHts@qdC{R+iV@l0fK{R^VQ+u8;@^Eu zu63j|c9?okM9jbigpKttgwZoz^5`AlYN!U_#XIEc9Yqql~0hcVtvoopKkc) zibir9A#{t_{Ie7NbHR^o&-z||rT~mp^8ed?W&3D_&eW?#IUOQ?ZopXXY&MtoKGn=% z_-V-T`|suDPcbr{-UbuuA4$R9Oor0ZA@lm-xIFJ?rv}S&zPi9N)Fa{ba~;d(Ze123OOtn<}6(g2a~?SJvj-a0x!`H)$*~;rYM$G)5u35xj8CI84qe_5l)XZsk zNk6+cWVYGa{{fC)UFN+Aq!$s>>^>n@b#p3RDjCaI@@Y->}XuBmooryX4`iRERV1mJc`+cAC!LM*2&hsN#;Y~4_Jq2}>IH&jD zgpV?a7xh(YgN@3uSXL$S};DfEXZg#x}4z_MhRNQrLz^B!V`aH8-r zYFN=@yJp;4EYOyTQ9cPJTQpXD_pnfDHvSn;s1U{52= ztotIevMel&n)Uslua*|07s+}p|G(AU=x|ee8>xI*smhGHIjZI%arbn5nR4p;FodLc zwM*@R5+x#epGQhhvT25lPSjE{H|OPNW%rdT?u$pKYQgz`JF0CuXi+9br-M4DabHdqdQ{o6 zd#v`h_JrbCwCR?uox|T(KCWnOPP)qXsqo003JQyu?wwWJb|H&J0;9V4Daf^uh5IP8 zbGfTYJIT{ed!#4!jHjQ{J0ovTwoAL?MC&H+t_R3CRcWclbKyU6WyO1=G`{q$3e*j~ zV}E?lC%$KLsI4y{K|JwaCH+nFK*@dNajj%y)1@&e7Ow-V*%oUyIid^$$ z=}}cm2lvGtvf8rSR@$D`;L>7pJJkJ(dawAqW#BH@quNqFC;aoNR*zosMt1N^3;ikC zn zPpGHRhyB!(2zu3i-)h0`)KAtcSby^NT(@FfTV1*N>_X0$@0CMEp6s`uDrt*djY=$p zF>N=UE6zqz+`$c??|BJP4HCU6GgZB6cOq4vw@Z_S66@3w6nA?UXYyBeyPpT>i{G?= zyf@vo*+DzB^1jv0t)#NX=kuoGYx4J@`VanIJ}&&w=`SI_)m|{23gx9libZwlU9rrB zV9t--PpQrwFBDG=KAtM9v?r1CtXs*wk!ob>2CG*|%4U>x*V1En(^Gw{)vao=zM^EQ~DEeW$YHVqV~(mqn6D zuqW$ua9mJo7Pl6cHtkdFm`24r+<9gc>@Ld!!Z&w88)LH}CWuk3S9g@}R5;nasr=Iz z#VlfT`k7F%Eh=_6H<0i)`2A?f>EIQe&oL*@Ow{)%+9=PFmlVJad*tZ<06YOaGxfw3 zwOD1T6(tq5<&)kd-+6z(f(^x(mOa5V*XGDC&OuG&sbXoZA_F#Tz2Z>D%2i?Hk45e% zSIQO}RhP9@v1Aaf#;mMQ>;PT>DN@06Q~>2FCI$C$Gq;no5}$cp^Aa3WDytAOPE?WH zXl{>x=C#7+kqFUEh)cPC1yR{hEDJ2U)Me zdYPv?YL;b%C-#2;4~dtHkPFN62d9}mI;WI)W^q>}&z@%53J=w~Rr{Chpq$9rB8sEY zVKVSNF~di0i1CNS))3!T>!ZTC(_*uU3$Gy>@?w$eL$g)v!l)FxW7O{JgoI(){jnhOkoe&XXV$crs&*oPb{G_Yfe7kCO3V4Ix zC&wq_;UyRWcRJSRN1%MTkcAB1@%-z!uQ<>GiKd3>1Z zG;#y%@&<2GV)u>^KJgu&qzqkqWt>hbksx@Cow`Py3ZBGYD4cVlV3(Cw!dJiT>DyV> z?b%g%Hta-BqsRyLH#So# zzUuW11KKHd%&^u&Ud2u~oU%J9XqI5FAwC>wMR8x*i7M=Dn2ksCTB1LGN0Bb<_O8{j zAlIU^i%9O?xNZo><#J%<(d14t&^}H62;I7=3qkS`+l?a15jUX1cPp340~u`0YB6ju z{cgVO(jng}n`62^MB8oR^(QB|9oTAQrr zwPvZ5`fY_Tb*pH{nO;fHxQKIWRDmm|Qi_L{b6Wk+Jo{NNoYc+KMQ89n>pF#F?7MPR zjs2Xuh(B^=**O==kNr1JhM@wIqJLbpn(gOqLlYdPIf{BFm(11r%c{{eg!!UwHMuqjo~dB=w*@`gFEe@jBw{TlTz;a> zvC`~No}sB}E>AyDbI*f9R!c{58s!^Oawy|bSGM00mp$9RM%9V>QAGN{jOo1!LTjZS zuWwo}alJVUJa%4Z0h{giy5nB?oW=JDlJ2oQ^&|8jQEs0@bfa|eT*EmqRo~|ii)<$O zzD+OnE`nXX=IF!o|14_U<@9$#ihgoMcaZ)E=r-^>{lro~TZQ&Sb~d`N#dWY^1bg|< z;*lNHzs}kxFkj0-*s*>$eNKXSyEtnH8ZZ8ieermE=Dxop{>PHDgp4H&aZ*ujwW378 zUbS;MOI_e#z0jsY|6{9nJhwPb0$fUep7lgOx>#F4z1#qNT$Pm@mU(tVLfs(IK)23j ze(IdBJa+u;=u1 zY%FeVbOSySwVpl_HIrV#g-YE+b}@x{8{5faoy3wq!wLz$8iIcr$+OS=Vxajb|Kame zmyL+I)1nHf{Y4K3-qQ!kP3dL@$Bh*Pf+5;dX>GXCQ4p`T`!wu(72Uu>C8j`%iPuGE z6@99{lm1a4v%~Gj+ivqa-TELmvr!1qzVgL5m6NpgBsZu=vgFl+#rII)5|gT6Aq$n2 zMdl`F9@s%5|GU1h#V>u2>)Kk^Kt$@K6jz)tvp|m{oxF((6_0AhYa7hX1_XPj4RNvhBBdB96(`i(Dg)UI+8n_EBwV{# zdcT{qW5F6LGs1=8Kz27{H(Ht!GR}(o=9lGf@Ghxj0eHUYN8QtG&!zK01Xup`^VdZ* zZPw*w13_4_8Wqq$*h(bBm6@|H(dg{``&ycGgHw~Nhx&ksrP2iiy?8E*0_s8&Hj*Af)qXd^aN;MzJVGC+CPS+8kBWc z6fUOu`fX;>P#O-EWS1EiL+{YWWU$9pcx?9_Z_XL`U`KDj77eQAWBRK$00XA7xAF76 z{bjU21-ct1l8o-PeId@XjG$5<213O*TQkmX*By%DYO>t#EYTubl6|W$b#i543f*S! zgMi#n>+&Ef0rA7;Ei~gF4`T_mGe4g*&v$szr8!(g<$BKxpTyKV4f)u{9|&m=@npm- znWD3st3dGj~~jtF!}ywka=D^q%wFXw3}qG0*{a z149eeR_SgM_Ok}}qvj5Lh-*O_+|%l_FfQX_^&u5~U5%&Kab#d;q>=ygYXxGoL5h=O zb82n3G+!%@HZ73Yk!zGkD9;zBKiY59H(Ow#tYvnU#Z;4(6!`89^BqKn_zIgBC`rm6eQFs}6f>QXzqJ<8Y{~H-;8{xtT=?b-=On0qbxmpsOsn1fWyybouhuKnjSIxIBxQbBSrgp~ z_G@&ks%t;0Ifk9u%?=5jQl{j_W4%clY3>Ulwb7OoGtMmy@$*G>I#}}?*2UIFfM)Av zmOewwcG{vvu8>-2hw&?`g6bifUd)~JhPgxpfl}q*&piclF;B{a7S9B~l%UPrW#k8U zsIpp?`NnN?^$F{A{YZT4E!9QCfb1t7kpphnSF4E?4u{whr(Bh<6mNJ>mdICYgR**7 zoEs(S{kL0}EEj*8CvYjNv*CX9ZrYycoGtktVB0n$$UrTSEoCK5r=}c)vur5)Xi5Er zfvl0qj+>7}m1?YlV9KZAJsY%Xn?I4!RTJ__mL0bwffNVr5U*2p^=b=y`;szCSOGfD zuE_o?4XGWsZE^hiKU1&-b@i(zlPo@EwK4CSljdSNUk(0O2=38S-Y?!5>$ji9QeZAS z;$Z5f()R+hMvA|?FJH}MX_kL9vTMp{v^CIe{8ay%|-o_H2_LJz%Yh4}giB8H;0P$JLXF9asQorn5X7T7MjBhWT+ar#~%LAgIyU9LEhT_vUR}x;bxrA7C<>n3b}o9Kc$UZ0yG0v%0Mtp_D7L1I z`3@APA2Ur*rO|n2G-f#CsBi@`_9!tl16OG0myYF|48`<3(DsPBGP5%LP&ci~DHG~_ z%uta1A7G2YO2Tq?*BB@8QO#n5)zh=hIMNzdKu`*OL|^ASt(q!^C^m-4qyb42oG%nO zHs+57=Q?TR`7s*T8>dlNEj;g^`Y;xQiE1$gN*6QtvE;H9r_BwV+0)BoYmFSObmYg* z^?S1AB}{}$)9lW)fa8SNAp^hJsS(JrrS+dLl34^3rqCsrs0p7Bl|gtW>+V}AV3gO3 z@IVryLr2{+>U)473IoZ{I7#Sjc{v-k{gp zu;}o7@!g!PQYEtko>Z*!r0P#D3}|S`r129JP)whmVnSjh&gD50fq4z&-2vpn69TH= zu$nq6os&tRssf0Nt-sPKiUi()uKBE#Y)2Qq3ZD zWjo1}SV;Z`Lxe`6a-zc-F)>|5&*f?MA7nGkvC&}okq20qV>+8UocpY>s$#U_@cP7G zioo4>Rux z=V_jX1BJiuchI-p@{jiyF|A;Hd$F^MhAg|%h7{OdIBC-#epqqt%J)>YBCWb9LBvxz zdD*PhR0=!jSH5R%4W&=?E3k8H{@X0JC-QTJ+y;m|3z&w+xY6$=y>Y5_$){86jzzXo z83F?zDkh4JJKk$No9!L05h$datO$KweoQw|V&SZT*7v;%gpQ->&T`_P+!Pz2WnZo5 z+{&!UPTMYR7db6}sRnJ+^3k6>i7-Awt(Vpm50_5`#FpL~p1cvu+547K{CE(T7)4oc zQm%}jR-rbb`gqZ(FSUv9`^m+0Vgt9sUuJHY;>Rul`$U~*F9EdYX5fY^(L8gqGN)XL zkP50&$a`{dh-w;p^3{if5+Cvj^AC-qsbO}G;=H~Usz(Tq+1Xhab>0PpyF`1Z6s*V8 zO{~JOLWY!7-#e)ai>1sj{=FjkYDk=VrPRT{7%>q``l(GpDA9JNO`TW0hw!L=fmyNj zao+0#;yzXzlIO>)Cxe6TQotAM)oh%w4H^oS>dw7j{jFA33(C;`F?Pi*ilpCA?-lEc z4vPElosL?nnwVlZrm9z-REO?PnVAfeDK;NU_ne*}-k&u7x}}=`ETp%^eKu_)MWiQ! zVa=)V37Tr4bG^!%c+X5bIizAwCcgzym@k$HuH^PuHY*Uw~*syB%-A$H-^KiKJ=5z0HOD^}vC~J}jnTV0-r;4AHzs{I zFw@Oa;!1>gR8Tw}jY+tgpNe@mr92ol8OOUlQz3iM5n zp@%=oS7{l>Q4$|idiQOVpbx~JXI7{iR18o0Q>I@9=Jd05|6?BA_z%$c8|r7SKcXGr ztiBkSa1z%Q&-(Q_m0N|68dc!~-9)w$#X&GS!#<8Sdrq6n%}r9;sI7;}NAGjlj@N76 zY~Ap#`%;f^dRH3k%~0q~iRjrFzV4n4mZ(4}kUE)_B?qGNPKnIT_GB+toIgl(h&7>= zf76(?!;?PPR=Ip`>3}iie+TY$NMp0`aIiep9Yg(8s5B~sg$ninjo6sKlPg`=r_au_ zTUU4r!d;(>ce|v!$diIowY?4Hg=1>}P5w2R{gIp%d2|UCv~L7ho^?Z zGVc*%)jR_h)7x=bQtFG|Sz_BXJiZ#?N9OANL)wxzM{B*z%pbd8S{aN> zriE*-XzxaqUU4X}VxyE+I~PVYOf;sa?Gc)vF8?~GXYrkOh4@ZeY??^z{QTRI4yjVh ziS|EXWzuYE8t20YO1;Eu6GvhzRQ^QOvkzA%4~v%E>}`!vHVuvW1E)N&<}~mmZB`)P z7xGrJ>SFPK-WxiLf>*)Vu~4~dI2~rqwQ4_G(wZ7O?F9v5pH95(Po0EZ$zCRN&FHJ~ zp4@2@v$qnf+-H~Z2^TOywTa?sMi*wrDQJ63)NZ3>+4Y8CVj<;38)f-EwTwcdn3ki) z%$-6BCJqY>I5*E5m0#0KcG>ITXbgP=#pEukaky&?kL?=#vfSs${vPNyp~=}C5Mj+= za$tJYNg3U*4LlG@E});-){^nhWX;bs=_GokEvQNj;~MGfpmS(uCb*4=|9e*@KfG_4 zB7-QONg)oWZr%P=G21nUQ1_8otAlI%b5BR_Xo-d4oY7pRV_UrApB^MI5rOOcAtp2O zFGwl6H#{j#K}GtL+R0_gs8ljcME3+nW^8Ps7;=2~WIIjQT!cn_?C`5@7i%BL8ihH7m&|bCp>WcTeD8dw_-`7_WArHXbx9)Lf#ZD z|6hqqT@uwN{Xa|YpmLn?B|&Cdu+5Px*St|nf$C!p0d{uP+elo`2&o}Z50!Tq)i%i2H>tI{Ynb4D?*@7grTUn!8o&NKx2`u# zC*IkxI68%1ujGcOT8#5R%T?d!N;o6XXUE4iq)^#SXzq#V`Lad1G^$8$8Yh}?9|<4> z5J`L5WelZIrm@o&9^7RS0B6y&ik%{ zHI_-A=7(f&#XtxLU#N{U-aF9TwuD);3pN_Dbk?-X6r=N1(Ut!J=7tX8f#I<5RwmWB z=LDVXl((&J0)T__t!VFbnV5Y4{V2<6^2lKwY1UuBn2bO9QF{__D||ff4l1zd+Ajhx z$h-S*|w*16rJJ%8Og&-m&+5wRK-S|ItUry@-mV2qIG zxm|WQn}WcJ`p_ni_Z3Y~)$UnpZ&50V_#-abnIrV4alzZReC7ZS;; z#dXIaNt~A}A+JKi@uYDV=qPT0V+J70{(Ch_=T(|N^`yl6jpU-dbZqWA253Gux9Zm8WjxiCX^cv{!dJF*LR6%0)5%uM z;NS=`Hj)$rcFg%yq2QTXj!1cDpT1_I@qDlKaFyiB>|#H{VBojiL*F_hleW`r@7ev6 zF+r7LNS_JPz{k=Yn(!&>Jy6V@e)m#j1lw;u2B+0C zI{pW!!S$2#v|tl06j4=*UJ>de!xD4TMdX|8av`+uzc7+Lid13|2I8^tkcd3V{|o~7 zj)ucEB%oB=`%4j)Q**!WOj2^>Uuq#71RAlnA@`8Q(+Dj}&L}EMjE{F@)gTvG7}cr9 zN%p}=k?wN-xe)&9a~mI|5uF@%4Gk%@oLz-7WaT&ap`}`*X6Aw!%n=TiP%rNt$OKRe z_b6U={LYRiecNnV95qpA8H%p-kX1rOtN4!VYHNGR;7;KMlL&oG(e$v=4ishjcFFh% zBt7mHjK`HB&OIP=9lTZ*V6xzXn7LWper*!5qRuh1I59ig3YQF@o5`dyVLTHeWxtf7 z9b_Wa_42}be1_xv0bl6`qV)I-*M_lB=AsK*LVPdSnak=X+$IrOdF`vYvaX}Fyuca5KDtZx# zt8aLz0hqOe_A!OUbi1)qH^EV+l3}Myd9B7%bM{AVjrjlEV2Hb^kekc8<6rtl%N?5p zY+OJ9zS7y!C5;_C-N{_m^kqcTQaw@l?U7P24?tyP58wcZw}e?Y*;ur9#T;^j z^rg(ynZ?GlQ`kh5+k2&yeQQOIM}k1Bs=)lYQOEaIc3(c6zf*ZC4$Gb#`AZ7h+?y(; zs~#b`G@G`1QkP*hCO?bdIdN8pDl5Az=PY>5hNN%5_oTG>`z{IMoj4|vVB=G5q)|8) zBq1WzCLRcZo(zpy4QH7FD|+)5d58Kn1ci+(pd^}n{wM|;O{k5NrySU(Jv(NZP+U7iJt1OeK zf3Yuo6pIy8etrs&9=8Z4c7)<`R`WNrGB*$4Fn8tea@AlV+K~Bv$sxAsi%s18sDa?L zJ-}|-iP*NY<>BPLzxPUD*rdz9_hLO)8WXLxGnQB#hjZsDcJ1|(!N zCf^Pl2UC<{g=iD)M(aG@vWZq=B-M)y*m*6nnclYBK%le|k_HX~?B~97Xxc+&^Layr zgj6GRh3BJ&z6&Huw4Fu-%(c3Px!a!}wz}$M%vi#=Cswb`7VocCBvuksnSzdwqozZ< zJ`?ubEd!*Xe2PLhk`h#=g~It>qTP(dJ_NRLLsd^{0I!Uhdov1AC`TqyY+G!3X3u>d z{#%iD4pNP7JH4KY*I?jrGbS$q(BrJOj*{j34N``r1K6U zqQq6WA_X8OkCnLhK>`C=FR^A+W)uoV>}v#Clssm#Y%!a=1unuBwhuI_o+4_pEyV-P z{OhyQ=ZySXs{in!_{t^6cjs*t8Z&4oCP6IZJSbx(?tkyU48eb zeMoS)YJ4lSM)iEHCI$|QM0UN5wH8_tmDdRM2Dm#(|EInYVsu*XY&NxMinNL0&R%5- zH;+eI+V134`%wWrz+s76L~z)L)&@eu!izVw%Y^H9nl4qdtEFrI&JNFnK#p`|jxClw z;PIvuS0mdaT-HU}&q31C(h4Vk^CcC12(+6|#6Ujb`pBn+N}FIC^8BrQ2Q}%zfq=P~ zJk9hB_v;6b{08&OhWos=Hy4HYCD4@K^0Fnr2d!ZSnB?EnUHHCrcWVUU>O;Rmy+=l+ z20u_aLLn-5^5`wSd9owABj!H2tnI^=ra0n$nf`*rc-BO7u)3|7u}soK=q5+uOs5E- zBmPtUsMA>?X_nYmN+LB7wH6;ZCjs9@Ig~|yI)|WGzo?0%@SaoW;40o1$jg?tjtNk+ z#LS|rblAxk4$J~m*_}_=4Yg|UrbSgITk*jXXHRVe;r-;(ov2)9vgy&^lAe;sQRtwP zM~W0Xi|X=Bq4ENCn)Pw&ntWi<>@RIe8pDH~F(DFv-_Km7c?I>;@h)Cx+VWeQoDR$` z3PXx06{bUtC9;^wMnvXRbo(Rv~N%IZ(0*L z<8NqR0Nvx%)6f;yPyT$`CM=>hyFl;H+UmQE067A7aQJ<;_>uELcFKy=g?cN0yAd{u zP_Y^AS!2@bT$wR>UxGNe4_W)J%WX`+2oMo^lrgv~ee&q~Lh5O*epjP*jP!1PRG|-v z0#0EJFjr-z)kAtu9gBy=K!k_7ZWITRbevU#eZJ?u$43m9^Dfo9tVWIMT>)A1^fmMiTF+X@p_Z0#BQL zbTuKdFgUVl(bPw2%Z5Y^xnh<)X5;&Zj8L|jcImSSewZTd$1bDp?Oe#4s=tuCOV&u4 zeDt*U&>mojYJB*5a`><{w8d)a;yh$UHanvwu(Krt3yI85Ja#}_?+3c3JO8^}ErdTV z9#0q~nYG(NM&qJ>@)+VoSGNZ3wUd6u%qb@II3dDhB(-D>oEdw;VM)gwALDBYcODNB zj?+2NcGXP!EqTA#R`UXQppH3$23CE?M7}X@$B`;kE8|y`Akui)S`oT=?DEp|$U^2k2 zY&?ND+oM5Kl6!nr7D2ajQY8qK_0jX(9rgM~MDfZ&z9&?3ziP>r{bIji*8Lc|)4F!T zvsL~$Ez*!el}#E~4rhx6j{7Ie_$F?udrX^%b(aZ=+sLHe#d@aux#`(BHWa!Q$OT!7 zf6NFWAtCYz|L~-dNk=dXwU7FnTa9ZkA+Fu;o&Q-hz|O%q z-GHBaVMK%7NY>9@YPfV;51%5sB|A7n3C<*s$xHVX>eu2fiYT!4&5Cb_3d9|(5>+1Q z5j~<%Q00pIQJXDv`OE6&yUw$*Urcc&;=FvTi)L6+M={aYH{n4Z`$M>8!RT%jBkrD% zDi;9GXHiQ!JOf%FXH)Pg$e=$rwkQ^8^23FCv8-T&g?L%S z%85STq(NWZl`ls|tG{@qn&h2i^ak}%N@kGaSGr_wTo!-z#}zA4U8tCA6{!;-^b%T6~k@UuaeyKJET#9>0#ovMXV`i?laVzkI?YB@+P2ks^_u)hi z^X~!#YFBHC)%IVyC0_cI0Xtn)0@-=o&y~D% zK&>#ml$F@JAaCo(=Gk3;>BSh|w1Kf~z+iA3iy%SnwnIR`@_Mb0C1yZLM zf=zRx#eY$8FlduOHF&PUY}KbfH1sp-%t9*UPLxCaC9`bK4}{YF4yOA*>>Rb12EFPb zPf}lfUeUkQ2IYU!C@o}hJcz$&7q!1lp}qSi?O82F3oMOwG#i*TBqG ziY#?Fmg=wztd84@W4L*=kMCX>h-GDeu7=b`#Ve>PBw+xD0CxxFrO3`=VS`-&7Kr9< zve!|@5&%(KHsniKw0T!rq=+QZS-%pU+ZHMYvWeSB*9DALQLUY_YFW6ftqne`ayeLa zKF|+vnd>O5(0+gbM@1# zF60(qh;g?tOmQH%kViUzIZmKMB;SJfLs%~Sba=|I*r{1CRxDSmG$6a>9C2VW>Md1G zO^RctVd$j|otd(_Fna&mAUWN{HCI4S=k1a+Z$>Lc*Q+raDFB7eWB*IHiXp~Ge0unN zG!F9h0|#uHb0n6c z+br)7tZK-<($ub5y=twxF%Pq91U8FZPPhlIE_pVZ2Q(u*yPv4wuu@(JoJ(w74g~wt zVRw!pE;2uPquZ!xrI6Bemuy@fVffztHbN?Yl&nx&b^sEc(R`M7#CCBbG{rw(7&3bi z9!4wD(jBQE5GXGSDVr>~SG}sgboVnp45Nb8&NbW&2G=8zIt?-(ZGCW%$7EeFXU)v9e?!7Pgp%^e(Q=}EZ!(k37|PzvJyv>SSX!xV0nd^Q zbaX8(V_>yFZSCSl#ayz3qvd`LkRk#UMO6QQiNpD{NgHc)Y|U8X#=L?_M*xf&gIjjY z1*5MA8Fn{MZMH6vO4+z+%Md7dle59(AbrNVabY5~eyP!b$-rcWqw(Wp%Ub(!SmD() zL^3pHKpn`q1t8E5Mk71q4f-wAg`JJxhi$F*K1CL{k;7H1d-54ZJdgEZjLb;H~cbl`Al@f17Xaiyzoc=iijB9R@tS zn*2`A7aRz_7$K!HFY&7BkE8Pk33#k0R92g_HmAH!`9|h#Nq^@#B^W4v-4hXO;xoi1 z4Y~-k(=iEPWK)H@Hh3qPa|sVrMmD^@ixeEtTN7eq|4cfJWVNx2)|MWVvD+i%(vUNL z%N}P7L81R*`3~cHOK_zr@pGv{%5I%p!!vX#w``+kZeC`+d*{cEQ+|0t{_&-AYX(s6 zCnvrBk2AkAxhuW41_mIK+2q?AWS|!RpqCrX#>=hFt+%%u63HlqXQ1?uamo})r3rV~ zMc*$>*t&`{P`T2YGs~9qd%19XQyX@|sPnF)9pW^5Xp$SI?dqWJE;jOoRy#ZhJwDK` zf2-Vd)*0`16iT&D@bTFXr>Mz8y#(7A@-OEcRw@rCKRqe2(&p=pp1snIBkZ7)XaxQ~ zWv>N-)q|K83g$QYjyd0dP1@!20RaGj8WW?Az!ZpD7dtb66$02F^BqKGEfq@j3am%i z?}z#5P{{x*fp@E8jBO3xP|j2K*}A+;c(r}jxOpUuAzZxJA>V{O({`KbjQK#{ed6g6vjv0$g0_{UJNBkLo0YMqd?Y8e`M zP8P7sZVw<(8kFLKtaM3L@VlRSp1|twcbrj)!Qe8$oL z``7CPg0YiI9QG$A_PZJ9V7oHmSh@pRa12tmi9ovRsDlBNqk|=akH;|!&ZyDr#L9DE zaQhbFBTWK1-!P4;Gvd_#(#sW&&Z6YewA|RO#>I4Atn?VqU$D8J_G#`=>GhJw{(kf3 zLo{7CbAQ0+!HDbm<(zWBX>s7(o5R%PqB@ga1)}ALx!i~tpz4ORXQ$n$AgVF8 z?Tvq+FVtnxvrjN_@igV)KS10X%_*!TX-+$*ca>)H&RV{TCi>#g^H{T4Y=wfzKjfxH zoJfBBZ(;n|BGL+jgXZ11kX#(W#CKcM%0CMgn*_?*J*JTfhr6QRC}J&}E6cwtJUPq$ z^_T8OUbHeXFHU+h&0Q62j1$+DmcwfxpOZIIF+76Z5zp^lV|-Db3Cut={8Q;!e&=(b zdkKb3867mC;4}hfKA@rZOz_`#zi7@q?J>Yggmi+qlskTQAadtM%@am~1f!1Hh)|@l zEMy(e?({I-sC%&k?4ex5k@p6ZU7rGBn|}#t<6&z3xG)8suE2;H@0J;lXV61TUy{cE z3vVu4b`jZk1^LMZ+-lY~y$RUCS)b@Lxe`gP;K`EfAkod?5-Xsa>IwPSzG8>{!C%v? z&#m45NBohJh)>_Uck=sdQ;SK;@QK&Y(V5;-mvJ$n;j+S{Ze*TGM%O1NGpGw9IvIb}Jw^FGYd=O_siv^e$7T$z^9NmY zlE0z)EusZgWyprz>dbpy^>nuX;d760EK1Pv-bla(CVw@W@6BG#(FBOjFIqSSHeS=$ zn}w%tAX=s`mfoIyL$k%f0(syKtmH6cYdVUMTW7_ zTYW?DsgsDyzc8@E6Oib@DsA0;kvt>F>=YU7&;j%O4}iyJ1HFK*dhB zi#29Oj_cRC$Nal)56XwjsJ z4{YHh;SAW4(i*lByP@+bc&EAe&3e0_dGm}QKif3W=&VtB^3HkN5|lUaV?--y*XYdg zQ~iqdz^pO4=iE79Uq-AW;_Rg$IY`zREs6AehN&~fgjI)+(R*Z|d3(coCoWb*uy+%I zfpR5_!ARF3iSC}&o>Qpi=}?0p`Koe;D)R=!*?3%=Ub(Vez+zBzr?EWsozi_FOkPT% zpAt=pqDj!kCN1%JF;!@q{zW7bo*XVRC8iLkKvagwS?Blc?tHG`7!kLMjqu0(z>HX7 z67f1Zy*I4sx=TQ(`t?OU;dl-06BjI`NaN%WGmolB-OOJ@>>&rBKRsq2NOV2omDDkJek zgHJq~2WQ3lDN-s$wfR*V@^je+lld?))N~l`74kk`9-Fi4Rk`CzU+XhN_Uk-UEzfPkZ+ale%6&HOZCeZyoa>KOyFsu(KA*vamS* z$-k<~=9+7a83!GY#HRG_-Z@xQc*^3$mbazr^Ju~K8 zOZAEb_d9<{9t%n)bx?yetuYSOAOW4^4(UJG(0PafnrSxH)42?kVIH@po2-U@%VtJ_ z(dp9zg$K1MyRR!yGvW5N)Z{q&bABze7^1`jYTdFv{IMcIM+}e>o8L*JX_78Zqvc=D z*6T2Vkgpql74@2lH{_JM$g|hYT*5TuG!Brxv;W3F)sLi-&zl}J?)x#1Z5omcU1v)jEZsWaG#*p^<94sOeALOv; zSXS|8#^dYO{g%wTw{mWZsXb-`je(m*Obc453gcmZeU_q5r_qp}MVcO##kKX5M6_gA zhd>*8?sy5cmb^+A-&M#L(?{=_+=f#LV%V%Hc7&US75un0YKWCgypy%7Ub5`QJB<&0 zQ`KGSB4l=;tHUVw9Nn~7BLU_YF!ccf7h5Z|MRRlN-3NrT5E@#C9Mt(V1=c(xLcc0t zjze@>T6V97okNH;3Z%PrL+c9EI5?YsWYjax@vX(WM%0ZDL=a-Qu(WH+JNL8Rek(?d zH*5`LOo~y1Qw*#e9|P??{^i7BmCzHu@{Q}U=3`g=5}t*hh=(K6cc> z#CzTfE{6)Cc|+f$t@{)fbv6dFu+gHuKr8Oq4&nX{x#07)p5WcMT%DJ@-MGE-WNm+7 z$2IQt*t$c{Kj|3{sZJKq-0Qgx?uc%)4tA%Ou{=2FX+OOkNVIqL5+?UAe<>N^d`#K;S!zed!Yfm-?{2Kbx`P_7myk%!;AcMa`pe-;M8* zSIU2sz8aDa_siyOlC%Spwl|Rp1(QchcT0Ca#vN5OC&2CncIXbf4t%G5%;kau#V#X+ z7k(8$1AosGpiwxVMN|lMg?yZ=NiB%I3kgvfBP7k2-N&mPar_Iirz49iV&=}B$~V@1 zV2f`>V*2iZ4^FU#+3~koD~P$3&L7gLeGfEg-p%4q1I24R%;#fjpS?DzE$r2uu4apQ=Ps!C%jxLkMgH%j z?d`u`bjV}71ka5NhjXvujto^F=luQ2M|t8rRl7Z6E8_JunqHnsd;6y5POr)Qy*#r@ zq0M;gX>uYHQ@YBWpk!Yu$&4C=?P=uTdJX_gN+aB@`Y1ER%m7ocYfBSF2+MZ!6~D(WUw#v8LJ-2Js?+I^phu z@af6W@UyVnwz)N&hv!=CklH#W@68Z>LgKy`F?}QFtwFK#6h8gFpYh&eulkO|qxy;k zSDvDq`{M$~258FkZAuiU0-G+{e5p1LC(;` zn_nDP$P+$(*3mrv^QP5t%DD?!!5DGSK_Zx?N>*&Y9A1~8F+4jlU6)cHU?$MGwr8Ev zrZfa|>o8)jiCB;}L1-TySJS7vubHn{J#?D7M$|W^me#dQ0c9m(P|tV{M5#vOdpk*- z3(0QZ36v6>$Q0%g&U$=!MMWGalbK``(x86RsXx2ZlI?7tt!|~_%$~O87{qk``&>5t zTx~S{eWK$B5}j~;OXWQFL3L>qg{pitXb1=8Pzwm!-@ukkUOT}cQ&vmqE%cP z>22mIGI}lr;$=zs6vl{FrxnYugi8hgm#reXu)&9b&7Y`EMsL%}k@UWzHWhqnR)5BG z;Yd-)JSx|2*gPhGH{TxY2yg^$f>Ee6FvJBnW5oVG%zF-vJ)g63-Q(6XoXM&;@1L7x9`an;u?1GJbj)EI~hg=baN5>>fYpA2@X3j!rj55 z&lxjXvHLOFaj7F!Xt*8S1KatoTrf`5^d>9n_HX@%LeUXB;4$-8Sre(vC`ja^V4HmU zy@iPGj+&u^thQW_;p^ssnHu<#$HCG~z~8@3R~9wT?^Z%=;~CSYSfU6bA4TOJ02wwy zo^iv0a&WajPPrv@44_qu46P0A&K)!Hs@sYY^#QeDhJJtkw8?q%p2UiN___EWJllAqrF17!; zJ9vuL3I49!ZW;#V@=$uWacTc(m#K&1(Pvij_MZ<)WaWAXcsrNOwObvUu}eg+ggUOn zG?ob&}O}PagR)1Dn=37V0R{rd8jUWSTM=RGv=VY#_!u2Zna#4z_b7g5R zfAeunT49@T&&H2TV@~(g@#Rlhi^;P*BPS~oAz7e!0<+5Ua~EMtTVgA&je2)SZtWpI zbcjsx1Fdk}1~^RdBx|aMO_PX{jIFQC`uP*`5*0SMz+^ zwSSz-tc2~yt^Dy(_BA5iv6h7e#LV}2%I0gy>sqmJmO^r3@cLb!V7YRWN^4wCXUL9a zx2`G?CTjg_39E9k*+B8ge*n$1s}hT{hxcI0<&-T(dl^xj2}AoE)hp1LB5l{WqHi<- zqvP4QTL^H!I|1dT&>|d_F-}mZYT4Yyl>{%}uilODUpbe6xl?Y3wRl3c&Y*o?<`n`- z5}Bwh2Xba2!Iu5zwmjZYtWAuDuLXLSc1*ijNFiv)Sh>Ym4_{neyOxFLJGOE zqlefib~g+;{d*FI-Ew@?VfkWP{-OL2guZ%zs&apzINz50jdgHIcJff^j7JzhEK~*^ z`wipM7*2iGUVCRcb$rrBK9H_b>KO=AU&oY8(;?XIlUZlnYgj=D{FEQD6hYZi>=5Jg z)P!LC@7T3WA?ukcM2v~27aQN4_3$h37(?nHX59HNpArG9%K0yuL>@FXzfixc_BCt! zGl;*pHX@t4v_|IPaY-7zRy&vC{u~(oJP&F0Bzs57kReP*1+5&PbmX=rj06ukY?#)# zPwY55kO9+Fs_6NBA{xeH`A;5%T;)X@uv1cbE~f_;OxlkX@sfZEOEXNda&1ee69x6T z6|a`NxH*B7riuVRvxKKGBWcu#R34oHlO|Epl^%HZW!_fu&eb%}fUwz6Nxr0Ut8;t3 zLJj7yn=b~hmo6?MtL^miri;FQUWv9Yaw9GZ}^O`OEV{NU58(dJ>8NtjjT;(2S?k9y58Sh4z11nW*y{u}|wui z3S)!R-YS>&lUA4PD|4ovJGHaW^Pf3t^^HcVhR#4aAETlG@`M=Ubj6a;c?b7|?~0Cu zdb8MGLhSZjPNQP|5Sc3fPFjQq7@tmnZq?Z?Md@)?+Eg5_&3P~6F(yc)`nxF>X z2Vd1~tDuPqry^pVWseG{YnRo2S5BMoamIi&?LLIJMr@zsWG3Gut0BNM$I`Cmx=z!v zs-fet_f1s=jX7RyWuC=hdWpr4=D_swt0_&(_sm0hbFnX$*kzEP)IT4mOH*pu>&^?P z9QK6P7Taus2AAl!-v+X`{qg6GD?IFbATd!)U_Y-1bqhs){t14I8hH66g>_9>x63>u zJyZ%4`mHNLA~+^$EG&|sCYYq{m`!Z_h?+l*Zp(MBNSl_V4JWDUe}{mOhOd(Gcn=<- zwkY#gc3u?d>gt+Ar!B2d<~5&b*`C37LXke%=Yy$EgJ`a#`cZ*ZZ-<%%*MI$8PHx&? zWyhH^U!m3U`j=>5HZ&x$FWb~M_}=zgORFUkhiixl5DY@u-~@ZBw3{)1(nbGz5jJ~% z|FTR4W+YQXGJ*rgKx3<<2L`lgKb%kgD|K{58;x;2 zp@2pYaobjPc)ZhT?3?y$u5O>#2p=8hzHKxyKbTbKGen7oo^1W%Vlq8pPsdl6?iwHw zU4Ak>-$fz(*?Xbo?^wjA^l=|W48JR0Cft{ z>B={}fo5i(rmIXzcav6(% z>W4UTS<3@GEJ9*@}e(jewhfSEOS^Dg+JErEQrgh_Ew%cCD9ys8iP6Sy2aOqbs%Q4GSLZkj|c!kEa`_ z{ik%{bKz1`6eHaV#LT^_7`^pox*DlcYTF;K|yZyYtw8IS}>Vx8MKU{`kBw>u990|Yd!Xpc21~UqkFJj zO@HTHfY6Of>azaTb$xQksmJxy)0iNsvGm`-dEU`BVY1`~4s3FuC(Qg))--L+^>tVE zc)&IenQK(yWSW1q$bjWLmLA+Z-W1RD5~=%$b^i_-eB}mLSHqq=VJJVJBEpw_n;)rx zaP50>k?_`g(%-4+PL8?cSqX7%JqX8JNicqArB}Zi3PJ#!BMl(%jt7)^Js5WK8Yny2 z=oB2fs?S)K@Kc9QYm?ROH#!Dh3E=Ichb6G}te6ZUKC&qA`5IYyr#G(N>`O_$af!Q} zUrWhmzX$&xnf|6(sbX)>E+ebt;UB(Ri<7s;ymK(Ljg)8X6{6RfNac%bis(x zqqOA9K5e3k8Dp3vYDjP`Q_+CEh)-Q?2I3adc3tr1BiAuq^@yLZ2YLgY5z1D0Z}Q=6mpq!LGm< zM|+I;45*$g^$I;2lZ5dj`D09NO2$WMhO~ZzTIXkkj~bTO8=IHEd}M_*=6d~CHK`Y- zQeANWjHVMaittHZS4m%(3-@W2mkEq9c|w7%qZ9|2q6UZyjrR6T08ZB-b;pJGIp0f3 zF{C0UnwvY2s-VvXxO{XJDprAT)Gp)kLNu-#28L!~n^`n$`g1X~%^muDNK5#w5xy;1pr6WH1OTWdq za&C={)woA?NTZ0(@Z-|Nnv@W^pkS%q#r~FM@)e(h8lcu)K@P-;L>deR6kX*;$|~t0 zWXAY55((U35rk$WJJDD7j;pOeb4`MtGy$=M*#f~JMC$1?WQ4qqDR`Mza#gVxX`m5E zx=u1i4>VdnXA{{iS4!DXFzM3RZKFFyJ0&AD+ULVs*`OdM7Z$OZ*Hviv4zB7J-@sn;$wENSR(VJN|r_&BnS zevmCZr#((#VPn2RB%f84EIojsND83mVmy)ws$wtf!D=&X<`06{fdIiq2Yf*wwl+Rm z#_!D(T@oiExpj~uznm}qo0*^e;DyHa#E#7>3;3p(9%a;rH0`A@y79S*M8%M z?SZ#TsUkYZ!Mqe;{@oP7*7lZg2X~=?7%!K7O#EHC*QO-+P#`i$wb5j4l|y46r2&W4 zgwUbD#QWQ|o;fH3#3nU}2qSPnL_2PbnHtnguw0T_>wQ;f#$~Ox)SFgSf0y0WTm!;x z_>V?3%4J{uO=1(3BmEijK1pPNlMY;zqZ=V{4Ig0*WDc z`{0yx!M{*pJUyS7HIh(3y?SCT;d@M%5W9ay*A{f29US)FAI~eTUg&oUOVxE`Or)Ck zEjV{C*X8GX^wZq=pRYwFfI>IN)UcoEpB6CA(1aL#!Z`#G8yU6pXjc0vw^|Kgfs!V) zqY_CB=JX*lwmU!==SU8Q2WE$f)j=>KDpxXDk?16g+{3amlZ{$>et{$|?!76t%Qy%h zepFp)>vp%=Aq@RQJwPR?>YU(LoXa;0*EBocC>gI|uJF*yF| zO|F@_KELDuB)B5}VquqyMJSHLcNhW`C*s&pvp~ zoU|jIKde2we=S;FuFSo{+VaPr&i%!a(0#oZOR0*TiQVWKV{7!`JD9oxiRf^ zv>)c_A-9sxdlz0Bsg-Z=nE$vLU>;GiLK;nzn{c=Bu~@xxxc)ioo&Kwx8UOr?I_kZr z-pY1+@V<3^JA7Nb^15Ww@#RoO!Ay3h(re(2ynv&kzcOl9Kez|zOliF?Yye4_4cnw~ zp7ZaObbBb1)Z4zzM%3;~M=U9#wt3H%WV^L;-Z)Szs7(rS4qKD`9^n0MM!mza!CE)K zLx|Js?nm3M0WdsA=x@seqF?4sgoU zHOVQ_-z4NjI|3nAJd?I|Bl4B7G-Qt*TkA8gC&5w2$&MobDIaOUU?>u&9D%i7(ri}P zN@C1F{M_BbeBE?+KljJ;B_gta!{ovO^!AL&Xw*-|n-)im*3H~7l@PW4mOs8x=|C6A z36i$g^Z^;sMgnmua5-cZDRB$a0jGE*#P9m;P!-f1T6EI^=l;x%`AK8gp&WT2&wPwf zlG(7X{t_095SNKb8W6%W%bvOqqx+2(Q$}l@{FBGGSWeE2vb|!1#gEn1H4WaECPwXQ zx`prNrsw&P5wNc$11{&9UJM7;g#S1%h#d=Q;)(950gzO-e^>_ADjrwtV8R*G6(-V9 zj(DV6%!78Xs12|V;IgpTo^%m`r(+z;4?CmcIa1sxIpRMq{W)Zd#d0nh&J9tPKWHot zb}brQh`4=z$!bXpjhr=o5z{tx%BNj^Mw`7fOHgCsZiiI(QDo1+^w8KFN}NSXuy z83>DETPQy9VOxmcJ21hVS`eyDEHi3~1Dg%loH|eN<5I*Ry6*~^=y4@!7SPv{mT=Y%WgCoLU@4= z3Y^l>wrm23F}a_BfU!?ipQPQ^M%9xD__nt5f)6~~!6M(?BqNyYJ{mf$8(1uGN>}rK zR5Vgo^Bw>QC$=HT>T%zv1m##?QpN@Sn>;i92bY*Fh2`!Gvq#}-e|74LrYn(J>C}i7n?Rc6>Yn z0iWU^X*Kst^51To@;q*AW0h`X^{ghYD%g@GpIV)HuQoTGW?Qnt7KA(PJq~)i34*tQ zP~=WfLl&_OjOcNq!*XLhuVz@##VG>XePU!4wtfUr$d9Bn|TK&RqD zB=2)H4$iJv(}cHo;G8c~VmtTQiPW|u`<@x;n}RmAqfVo%{Uy~liK6W((F}Xwc90&V z?Yjq7w|CK-V^PEX1?U83t;$)LwLr)g`~43fHWMz#wAY%Qc(M{N^%X(#M*k{BBH*PE zM+p!tY5@4#@gINy*Jxngx4iGhQp+D)#)WA3v%(AQo()cpw|tyy*ba7O=6yuKAzQ)} z1eJr{YN|+qfq=1Tp`F}{Z4WxyB-Wxx-eDR55L}s;Vw24lHW?s9H=+_T3?W)TCtEo& zkZf*xJ^B;4B~5|@@1&g@BBn+kaz93mV%d|s^0~oAuBCBdaMDJv*gaWJ#dB!1H6VOnwpG3s zYd{^O@)9-Eda@+3L2wCshDCFDj~LG{b@bYCveBA)Z(G)=%U_uxKVi|Ib`8IpZWhfa zTtJCjGU{?S8%}_#{4!F?4ZM9#Vo6+`zwFZ_Ddu(nte%_qT3cgBJ62L)P$(ErV1~>GH2bCCiT0 zMvYch9n6&OcU%qFE2%2Ww`R@F_Dm30aOYlkNy{h>%h^mP9mmN5+{*~-% z(xf9T_Bv0Sp$G+_!hG-(At-&=fKQL-Fjd=A?;aT4hLHKX(-BOdRi@W$jedn&kOD?0T*o*y zIMfy_^=GLc6Yjo&FY%@0nw)%{&bw6g1No* zO4&4?Q|+%PML%Ccu9@nUm2xX_^GI%!rt!WvGBgk$d&?TO13dBvN7M62UrXTC&xVS2 zT9)#bTTJvK?i8BeBS$rk3NIv}ASfN#XaENmB%Nn7_v~dY@LMFm^e45~5(IQ~V8@8EpZBbX)NFt#%_#qKWlKt{iOy==H?1g-MXs9YCr&fpYZ~g`e;=|)nzT~- z!RvbQ<-{W*f6X1=*!iGx*<*%Na?K6hyedLHoT4dlT2>zEfO^!u-ON9B=PpP_DBUK$R#s&6wW5aIK+;)|hrrFa}g^^Ej6-JF3U z!+^MqfpmY%+S!sC^ZM-`;h3LagF*>fCtel5`gZ@@K&~xP$(DMWmA_goIdJ-Of3UL1 zB^*pQqYZ`}5&`~!=?^^C9Rt?CEVoOTwu84j{C?>3!{_QVSk64PBFj`nci_WgeVI}1 zYKdr$g@kbXfG2V8dUW=F2xfTBXzlsE`p2B45x4m3$u7}hpJYa%(Y-0oPIoZ+b(XZ4 znbgQ3`1g<3FrmGW*gFPQLvbB4e4Rbsl8+y8Uk-fa|2b_;-u=K4i})(xL&s$H(5Rt-s$>mkFAwcZo%hX zZt`=xj^r|5%b$A?aWc^Khi}=*;dzUc5yx}q*(#WXlm_Lz{9=Qk!hZlq*8a0}xq+Gn zkB_&z$k}$UI_F0EQHpV(hw3POc=a6eIzDMMQno?sbSRI|^WbZ7f;!%hy0$Z<;)i*q zJ9ryi?QbLKozD>K-#j?9rrmar@xlz(8eS(b&z*l-@y(kj?)&gbidTB5{pz`w7jAjK zc=+UH$>~zu{b(${v@3;jDV=p#Q`6-X)#XN^)`UeCj5g(}R$C?c+isvdjh6!c90fh> zt5$gU5Ab9mOI5T*e6Vp1|4xy16;5GXgA1n~TzhDLYwqJ3MQZ~vXaJ$W{;Cr-+W~7h zAIx^dYHhekXq0cyn>XA)lAM-tEl)4+)rRjyg^>%S6x;4bf1BEEUag}4m1-3vN`BxR zclmKHc_gvOn&X#()LYoDZl8JfC_BaaleJ;Qlnuh;b3hnw@*&&T4sE>T-+F5HzJ;-m zSy4%jSVt|Dej9sTj=#jg9$~opV}ItHQqYfEH#Cr!p1WACS_qFG z#S}AvFBrpdjoMTE?*aN#$G5n4+g86YM5SkJy-#1M?>ob}ks@i!KjD}F361sU9c;b+ZAPeJ(Da?BO6t|zGdYS~MF$EU-_xiNU|eF^}JxipYYZBej=)t;+WHki=g?yyV+wBD*%yOT=B?h<0Y* z{Lhoik$)?wLjZ%Zh%m3>`3zSYneK9tAa|H;`bl#FkNA%TmQQJrz(kN1!?;Ozmj zE@k}h3w;?QM-&1U4ev=uHru0f7EZKD`~y67vr&sm*VYFi_5((f=1SJwr=N@ond~NQ zXfAthMj8g#dh$3V7@g9Wbasn<6Kb_<3q~fd@oIM_ZfC$aK)Pf+co`ySa2I;|hroB^ z$6v%`My(RvrMm+BCmI4;mV~ZWUamBGvz|U|yCv)8Si22A$bGh7wOG5M(zl`j=&kml z0?v!&;&fn=>Bc4ajYw}DKwOhw;Y_?pJlEsUo9opDO+pSc>8;R{$Gh73-BMd*8~ZQx zCRq)wcvRI@i~2L~_Q6t-zl1EBn`%~a)VF%gykq}- zo4Ja2EJMm&ap@1P_AD$dU0TRj>Z<6pK+QnQYu;PM-Tm)j_GffNgK>WD*5IS>cN*EN z6OVwrV7}2D)B?1H|9oSrz^O}(x0ygGx!n^8jzu+ zP?KHMtRbsK1;fqu6IUo+w090Xc+_=0Y=%E&>Phz0K;j{C*G+VIG1N!qhDY^r7SGvR z-@m+l*T+2|<t+hN1DLk~r3M1tWQ5v7y%>e@RA? z&8S;^;BSGO=q|0Pwh~Z_73hx_p@9#G0?SU2r+xCjNO(RDXzo zc;YXd{2ILy*DcYy%XXMe_>1wse=9ebeE!$$p7BT1MscxM0Qz})3l$#Y{FES}Y$H+v=I;peej zuzs8Fj@M+Phh#F&W2{Ss>{NI4ooG~c+C9?D+Sb>3g>M^_TNyF~^VAZ9PQ_T`zL#(v zL({>UbBdpX4%ba5E~1YU%0)nV!PESmwIQe`k*PEx+3?WH6UXCsI~ajwv$JWv*|r3;~K0YxNA0gyYwtAW*cv%ilETYZGaR(-He>SzP3 zMgTDp@?&Ckc~!wy7CTFMf2~;ayj_hUL5-}ziGyo(fpgv<9XzKNl!@r6TjU|1PNQ%! zS$n|&(EW9nj)j<&HcF2;y9eGi^$vn^s%=4;pQhG!k1pG+cxgnK+^Y?{R#V&+UQ;^< zyGRE~U_ro`{u6}nKI>RC*)0ymVe1ox3n2t)gv8(hyl|rA!yKkGLM@UJEdG;TW*f7R z5`}{kh1pm}pWgr_P>XJe-;Jf*Mw{wdcQ4P@RrL`=>hiwTGCJ69-wsvML1cV|I^&QC zh|TX2Zghf4796Y#5oTcc?|E~Fq2REwRbGH#-zP^LirreV`U`t>Sj4TIJ#9t`g^w5J z?-R4EBg#W0g>mE_`L>!E?MJjd^Aq&14YM<Rxh0(+aTN}9S%@0*=3&+OO#Y!~ z34b~}9+xf5;K9=7jeldz*;8 z>y@wV&t_|-y>?OWyo_XX!^THL8%J{|ja7rGxfY;V$&Jo^dcVeX%9Nv6vr&|WKClkL zN+Qrp^;l#>3>?nI$4B75i^g#)_VnX9dTy*$VVLfoT2XWS8V!e^#OTNGSeq`DEX}3V zR<{^8*EA;@xmVGOCiiLk189-AM6#U&!Q{EaoGKjZ+}B z@=6p+I2vNre}1PBnK0fb1wV&_WHald;$#J4xR zA7N)6Dc6)-Gb{4)pgox1bywWSD&fa)=)$w5+m-Qp)u|SrYdemRN9lg8;85e$bFu#b z&nC~!n}2+;Te@PhcJrID+g3#Hrq?@FN7Kiwp@1EEkwwdE@2a|r z*?b9JXQ|fsO~^lhb3`moZ%T{(Lwwbu5A;^KOJ@92sHbW_#*ZkQyw;TZ=mYz072%>z zYY`i8eB$67g!bcm^JE|ey|m>=yN>+(Si;gFa-`0JIqY28RW2HpGqb{RrkAVb4JS0?94+}zD!+kCuQiq-H%*2%lv#-@=cLR zlTC~EmDB5`7p9w3Zl^u`7U6ck?P|qC^p+WvJK#O_{(A*ww8#3?TAd}~FYt`Yr;m)n z%Q@feH<&v2fx~THS3p(@Jne@wTccxi0)}}jiPp{l* z^z~gZ#D7}edgnQ`&`<7G7oYw@KBM}T^ar8zWiLfYiLw@0*Z$Gq?}A85yO7=)W3>a- zUoRf^|G97BOzfJbz@KS*Bgl7S7A9LE!sI*M@Lj(;iIX{4%{h?9b*#zfs8=%Zh0GTI zA6m!rnDN^(tDDAG7f_9UJ@39jrfq(dq+CZ?lWt89=LRL$#7MwlPe%|D$48 zxuZ}jJn%$&+2b`yv9}{B1{vLrl1r}ktl%8TB5 zL$7(*A8O?$b_Vp^pDD0B0y(!AS9u*7Rc^(^Yq6SYw;b88YINAHlt5{KF7A+fxBZmAntrWF=u_c`qyU~5 zIe15A%R(&L-|&&!{p94m@;Z-A+RR?F*ipPv__5XXqzoussmx?AyFDm_@XnDnLi4cQ zA8y<}+_5`JMRD=R{Nm|YVDqx+rEoxEKQKx?ToixGe8hZK_dgSA;NELg;GE`@WFk>{ z=WdJUZokJtW9x5|Hi0BWlIyOvH?o1!uOo>?)Vy+dVFhlGr4Vx0@?3G|##G z9dyo`@2nLUI5ZZN9yvStZ32j$(eP%i;BI1jECykHP0A-pcebl}xRhV4l^vc2-LI^V zX`c}4*K!WyoR@qRW^!jWGb)_p+p7mRR)w4NN^ZVBG>|l3Q=fR4G7|T5_TCS{#*0}S zvf?l7PWa09&tzgJ1-2%!;c^*{b@i*QFTA$iX$a1*B9f=pyngTte6gI!1>VgWx7;<+ zF|Uh@tX#7xxG#ma=&s~*dq}s$!=zmA6Yb8tB}}Yn$>i!TjeZO;3Mp-MOksg7h)t^N0>T;%!6n=dt!_eje9oIW?WwKhqxB75&uu z8R4ZvgSF+zhG(y*OgLTRrdM8v^HzLp|2A^luq;4r%;1lknAF>TBv;s4Rqb86(Vi|Vt?oL!@~k9^*@r(1!6mV=v}$^W%)^%wrb3)eyP;zzCZ%-03Bl*)hd^8NTfDJqo+1; zb@sWjrSjUt49Ejq2K46j^anWW!Q!63wiX|a5p$o)AhIfx-_}-Q)6}z%eXv*h67rJ` z1HijH3u0F87ZJ`U#aDDze7R~?ahMlV`4ZCV8jE+ZZ2p*!Ojnlezg}DTAOWd^Xd~D1 zaR2~;qACRYl&e2a^POejmnCN3ERnUf-W@;f_jBF&PrZag5{A$WRw6puzwF$)pp7UP z)Tc-gLU1T;kx2EVI{GIH5i)iD$cOjU!zZV+KO5(INp&+vne%0bcQT!Yekz`EoDD)E zO_L8OHyZ}sS5!7DS(Tqed8{31DRAOKf{7%6UH05caCZg&sz=NRO0rd`ob3=Mq=OEh zdRFlOtH)(;PvBhlsgFlUZZ7nA8s{c3IVw2z+0Tq?-P$%HJ2J-1h04eIvz2zTntDlvmOB6B#jv25p&Uw6N`rF`+$aH_K zw>J_1!0~A4LB+;4;tC(TZ5hlZ^%hH3Hv}~A&+Qj4yvH?|H^h4-EjgCB-}v&;-D<1> znwiEy2+p+CD}!)xa)WtHg9p029{>Of`CQRp_!ck^tiZddYIc$A;;E-?Q9$mZ^t6OH z)!U^*gxALuo}SA?@sdKEla~2mk{Gb2gU!jhoYJz!V2!?oN-wjBAH_EXELYr{51Tz( zPqo2L=R0k>R9?tq*>I*6&R@jzvjMtwlwX z;cY$ll)N>H-gQn#mjRLy2RU@%y|i@^X;IKE-N1rgBf;*`QZ!rdk!d|;@3<}ME-lOI zHx6-W@U>hj?Xi7TZepM|zSYt&{J3hwy>YMg@;7d&H0J~ZxujnJUiHL&2ovb3*lpJE zyY4tZ(p!HjK?xy>Qqa|t(x##t1}Ysc1mpSdS9x$bGT9&qU$R@#ZI5~3NWLITC@^)f({k^4TA4Gf`>2M#6$VhQ55xf z>mvn~RnYB7{!6Z^FX$=>`w-P2*J+6fT^ifpJ>3JO2h3P^RoXA zONurOa(lxa8#NzGE@zaMCYM$R7yqcLHnG~~<4BB(iTQQ%`;>QB;7Wob7H#sN3II1} zpFBYUaMb|5gf)S1LHqSVC!%5^Imn*)L~M2~!1rR_!{^7~05%}Hooxlu_Cacl1C~ z=tyHLGYP(EMGW6mv^F_Q9ox`3RhY{~fmm-`B6*VLcf&pk)+^UckrDOn~;U)2$<2!x@w z+Mu3{XnuH4o^$9Dg#A84>B18BFWa6(VZz4ZvPf|o{U!?NzBL;W`94J}9DE0;c28e1`Y7x!h=Ts;OSYP=*o;J^Z6 z4^#|!y+@;Un5eAow3dL5^uePewH}bbs)vpBlo%Wu!&r}yP%`)j*xH;2T-X3e1;Yts}h!~RK2)3{-vt^bEZl!EdUAc0C)$j zU?Nr7A#V2*Hxx`c?(qsA`x-11Q|B11K^y^VbJe17y41Y?z!+9@e{ghTwiMXR#yd9( z!uL=hcE6Ucx1=5}dkAL(q?-Aswc0aZ*$SU;UIC{iainpgY$+7;ALpoYiy;z}ZTJJzw8VeGLEyJ4?O{$v( z!lzdU3cP;|BnQ>(9d#Jwd%ZseOfd3<97l1Z6Wd^TOsVemC>o;p@=Lw$Lu+R1^OJQ` z!5bKGd!k_07=Yx{j?zbvyNFXh{h%_W+ujAvD>h_6<*DG%?9yM?=egk!in_R`HwN}| z+vXo2-FV-t)@<)^SqxKbj=5M?W+UgW>0Fzfft#Gowtx2?GN+?aCn=hbo;R|O=>UQ7 z=MSO}+RJxMwyk#q3S2ANZ~Wh=>ogN*WtV}Hu71~MYU!#e-anzkseq};FPy&xMBng| z8hcRj-`b~$Sl=FXaDe%zKX7<0I3Yqr|7 zDKTpB@cWZNH^tbhT-4E)#LV3{UQE*ws85 ztG{0{vV411!M-gQqg0{xakZ-7i|rg91`^FJ%58}Kp-$|Q`i_^nF_)H8<7(|UR*9#_ z_e7FY?DAQgFR-@%HZH0?I(wis9q?MX)qZmKO2o^vk8L$TZFZF}l151vKgx#LY46gU z916}j+LQ?pLXsZ~#5@md8dUpDd%wGXLR{c)m-tC9Imc?P;qXgqVGU9_S5r*m)TRTz z)#VXjFs0mfYb$lWMAgEK+dQKZndE%Cq?_%Ra~J0wY%iJ)QFjabqZ>^8(~Vh)&82Ekd9I8QE9`efWCFZ~u<&I&b}x7`$_VB&W`CIF!#mKzUj0(wnM^Y3--q6=W%l zsbxt=0=f#AEWBcC7Vyjn57v=t6vBaZ*=fF_+$QicmU`C23pu}KvW;)+wdRP7l`y~Y zOorB*he1A2%h8lCGo^k04+gcQN|tGM*Wa(BH&EUR*ReU6RU%YORZL&PKK9Kwg~`hx zgw`8TpF`Lu;I6JB$vyYrd=f^KxCv|R3WwXPhO3V>z|2A_uxSQKy|=Dfm=HqRg9mEL5?z1;&?StzPj^9pbFIYF-*S|A9R> z^yW6W-62w;Etaa1tWx?(Mh0rx5Fc{W<=WLLlKy23Gf7M%=7TE*s4fC5pLCad*n2Yb#Owp2RIt8mQVpN0!``Lfk`! zdom>hxcI&~-?C$vK8QV{?S-LK4QxwcDRvmpQFUX^b5f&|hm z*Z8Bu&!5TW=L9sYWF7yp}=dP~`Fjy5l5!Rw4Z^p^#a?qM9R6p}l0pQ02r=jf3^B;Y9n zz`<%8s%gVa$>*hL@ESpXYfdvA=@Wy^dltG$((BY0o;wH3*ZD;kMQFGQiUdjEg$4-W zt@p8YE4#G*(!$c>K9*dgTuDh!JodnUoQ>b8HJ+f^h_#E>)aa|PDpJG)Kv`@Xr!`+B z+j@W$so7R9)6njbV8jqi@>D~cyj6^@b%MDA1kr@+WLq_kXgy)Zv;q;QpcLNNaEvLc z0S7_FzMCHe7&Xjg+U;JM=xQ{mDs$IRTOQgDyIM2o;Q^s^od{aj4SlQY&(vyQ%dY$D zf%(=x))&`7-YBgVe3$`!^{-fS7?9%?NvEoGh|1f}cPa=jh19go2LJ~))?I{_pVi{d zzBL%J`QSLZarxwWa>vNI***O>Vvn#H=A*gEo@sT`pnHR1_ce=NIfL2Yx{}3@{{Tcg zTbfr$Gj6Q?SemmJ^hgz~cPnU26~o{+Hye+^_|hf$PH9w_nCkLs`C{;lCHa?O(E+zR zDBQObyg7)!5o+kkBqG%q=3>F1D}X6eR!ZwqdHC$3ekFLD00=Jv=v-R} zD4t>V?v$xZhc#3*hoeng1*sPv^qeUJ))5}gY!6N_X?`9^HGir@4J2xFh<}jIS}?!S z!{?S{tqrs+-5$C5lgOz9OL(2sxh*Wpaj}SvvgIkRD#;HSetsaUw(aj>Epv-)`0U42 z`G<|zHpq|1y)+wvJgLZr3Q>+j#1QQ19@(ToDN@t+wfZW6D9t7IS`*LKpn{VJ+o!^u zlJ!-@WulV2zdH&dG?9oo#afAdpoP{nkf!&*q37*|hsnIKS%$CMiY5Q>RP>OdUv1EmXd0oXJEHW+_O zF{KCUTq~{sEP&ODanXQ)lW#_pbn2fcn0P+{YD93VCfv$e)vcwe0PLgQr7*%m2^{Rp zRDqj6*z^5zs_e*om$B(TK-7lqf1L)8$JJQfen%7TlSY%vzou=aP}&lQV>MvKHuy>c zN^6R(wRpeI54Q)QLN*WAVg|6kxn>$Gr#zlB(>Y=~Zh}>rqV{5uYqfCFVvOFQM8nXn zy%cE#?0P+eFEKfCKY*z9`uM|VJ256Q)3O(G{mqf{b6Ng zr!F4n0ZPybfpC%zOd8Hmgv}Bq)*bKk=tOeg6oUf`HOPewJem#c5Rcrdfe1e0%&~u$ zqGcV<6y>1f;KE!$tD1iR{wf?r0^)~uCk87_`B^Bjij%#QkhV9+?jpV$FARr^9Y3SXTF#fYQ|uaK9^F&Mru#kLMscd6}TH7l=b=d&vw znRAj}Hn)Xcz_Fe%y9!Q8(g^wMrmsNI|0#YXAjT^-mo+qSF=eiEn>z&qescrEzS3 zl<%L<4>R?fZ!8q`tx#IovC^ZpcWMf^{9SJj%iRpt5ck29bi~I9cw2=I9 zS-)EPxPR;C{^otVZtOb&atC*KJs?0GfX2q8%mx1`GWuME_pn|ypcAG5?GgmVs5v-7 zdZ=~!_|T)slBasmqpWhO!3f`Nwby2IsGO(8-BaC9luUl70}C>+Y}^G5un8xtPi(%<65NPn{Yy@}c|#RKUy;Cw^_<^t8uKpw7Uk z9-Wwt%e@Ic)!LGRND;0z{05CZkQ79;Q8Z(uUsgLk)#?r2$w(Ey`#rak6#kTl2)g*$ zi*G(neIy3&-2EI6#fCObZIa<2eCS|$u6((BWmxmLUFLsPjs7)RYf}D0g^j~<;UXKB ztdg58RNBDCMot#jbGo!N93d>>+XI5L`neoGnk>F~Gg{>cZuZHvJ^-ll*<-6G+1&H% zg^>VvTmHlC=XmEn%>cH!0tm4G7}umM~>f8lMK4^?Y66F^Y7UIpfrjP368-?a+% zEG->8TnekTEHeiivNlZ{YGOw`OPpzX!x}eg2`wr0(#H{G9c*({2YbupjOgJCcv3OEoKHs?*Kl)({*l}QvYqT2>)zK1Zg>K8fJ3dNJHZ=r zcj5yzw8;-Lk01hPC8hO>V%+va8jjKzobo32Lf_n|O=l)D+)g(+;y0z!&XS zJf|AheyiiXPc`G=etL12+iy< zW=YjdTpCE`9N;B2h#sGozY3dY&LrZ|GB1UZJQ66(plhI12UN{#{+2|%wyCLH2wOhj zPkbynK3WkbS~93MyyVwwIRZ;jxt;e5dDHWLRNE#TN{?DkIrx-9efTKy#3?0oBBr3{ zfXEHq7{em|G-NKyPI1V2A{e|T2nb!4{4@;R6KuCAyV|zlMOE!2=ih#Hn+(EZ|Gt;8 z9O-LL?zcPlZN$=bXmrv9U$ye3#N*?NU%2B4%|>nBXKh4g%Knrp9U@6Ecpgf`JMwKV zP`axym$@8xNgDfN=q*uQ1;ClA8?x(q-Mo^1N=yyy`6h8s12TMh>({m43FjvxRlA~k z!ta4mdIJJv|H9!3nH{TQx8$T@xvJK%iIGb+jb3KMOPz%H)PU*~YEO}p?qazF!;ypL zLTO{?z|zzLM+cyVRPZkywcrgFc2Auz^Hxwv%=GwwUro1(d}2}ykth+yArFZMR^}Y> zO|5X-{W-=#3GYkZQ6!Srg_-9layR^wOJ_70p6Ci$M!LrGeR-|a-{K$g?UrNQf`r|^9-1|iskrP8;-kL^N&CM~>^Cfzqf zG)EV!T8{h_0}OBmTwC^tDW)f2C5hxiifHb7HN*XgyMstlb^OZkg$c7W7MR>M5nufz zxoU?i(CcsCKu)qfMd*H53%rwY!$tM&KR~7@l2>Vys!tTyk!BZ*JzRY62fmD2Sy|bA zuCVBptq$=DkvrbtZ`gCkL)qVOQaXY_qutGFY~kiuh!4+kU{4-1E$zCr(&(dF%*itq zz9;qL-;Y8g`X5kwbl%%$=bI`u3E?mNQof{i+p6sL->P+Zj~aZl4M5MocMF{~s7uM& z?#~^WG^(zRFtut}EZu+XUK^pg;#;NP{$#@$&zXY?qGQ|f^qBag_jSvlevLO8XaZ7; zZxy8wBGeG7Rj@xQZZ|2W*uTp&&#k(K1vzSp4JZNJ!|Ora5?{BalvM54h@0y7yQJNJ z3_q5Y$BqQq>HPz6xYRdaV~q@1t~AUY*kwhZ@FZKK>!LZNxxm_*b@Ud+7*l%;!=3?d zqT{%OsMn)@1%tQh)YS@bNIA3ee*g`W8YO>c{3(q<$TC!2t+2dJWf%h4z#`hz)5vwA z4StM4RpaOBCVdBTLk-KBd8ySOCyj=$_0cLe?9?86KEcceN1GH7>7zigVK%iK#&0u> zK#)+{OVxUim1sp0Kni7WsWz2eMzfFTY1kh!0>U6idTZH^^}l|#br&qBEqUr#8zX?( z2`gm-f!RDV(@*__H*%e_Td)+iutM%^YRFD6JU0v`m0Q(6&C1@gO>KGe{eiT$E(a?H z<2dC>?5>49&) z4|IP(is-F_{{z_ijDC$*XJR!JG_K(+23`HiWSZG6_sM4EmS)>Oc$(?Kc-4+?K!0my zKuB6KyHJA?nyp}n9#bSpv|&H_vBG!3@Kr=lo?=oANki3do|pYsY<|k z`dsNMYc=K}9@fNG@Z-YMZy+@U5Co~|9n&`2;h?BO|*d~cWshmpdEJw|cTz%h}7wZyVRW4R@$ zu}_Guq+bLObb>LKeRur zS9l{}SJnS9`37kvc20=N1_)31de+4M~08Nd#krtSELbWh5sF8)nDeLYR! zsId6vOa1{|j_x*2TFe~u$mp*Y+`KV#V6cGiO#do}D@gbg!2A9^p4<>VzG|=31>_eo>f00o!8LjBZo~Yd3AHK^sZvdpttAG?wtMYMkBVd!Zrc12vHBKY zU03JoI+Z-pRmh0i2?^=-yDWkkR2pZ$wDr*m8 zqRUl*?F3Q^yynsx14{n*Bq=DM!B znk1XuKF=yUW)QRi%j@aJ$H!H%Ee5f`p1yZa_;dQ+$6AEmADeN_DDe-ZyoK?-;5Sv` zfBv*)9r*Jg?5D%4yP4s|U7BJ+q0*30asTEIq7SWh@8x}$$r_7Po+KP#$~SM}1VDY^ zKy$$5%Qv&q8AZXLR0WdcRVkP^gc1g^c;|ek_RuTY6Fjj&8|MvR2e7|o%a^)@yc$@_ zIb*7)ZRUzcUTHGuJ2l2t-;oY1$D;%6OM9U6AmT5WRrfQq=^yA> zB+Ef+UNGokp{+ZE1kNJ(d2eBigcL8u1$i&+)gNPQa(lmAQn;<%|L zr2uwe+M9=$CgweptS%m^7nH-|xe-BM2Y7vFsRMA`y{MdogA>J|@FuDbT{`Q*`&g}p zW=(Iq=cuejs`%8`cLl3vPlKrI*7{r9!1!=1_vPn0wog=%72YTIdy#(Ty-{~D;?RA7 zu(_+#t1Pf~-zSSiOrs*8vk~Ht|0tE-#;SoGwoxw^7iiAP{)!L*p7aO4F zwdFX;ZQlVfC6A(AN9u8FF!bYiend#j>k$Tbv{mg|&~v}>=dK@6fo|@^!K%u5$Lpnm zHdea{(`!$j(bBHuP4u(nv=L}qIi2|mQTAoBLf?gN@DB`10#G+9QxXgh!yKOCLoq=D zQl&TS5u)d{L^-@7Aez=e9gH|XP=9nXZl%D&f!t|42)0OcheXy(WYoSUpGuK1`E=4X zFdJJEAIq(*^0Sfv&Wk2dz0#*I{WgVnrwq9_+vqTod)K>eIXji`R7dNW;`kvIa63F| z*sC7M_!$K}viGQG+IhmM96=O18Uyk}^7@=SQwWxh3DcVALn6S^>I%Ett=|s*RXxE_qWLS*?k4BaY8h^b9_IG0jVv%0X-gZQ&nv*5@HF?THOtMc<)gx@Fo z678e?n>pQ-XdPb)6@u=h@PsM#!;a7Ki?*ZYc<8WBkO1J9@@J&pVK3{`TgqAJP`?Xz zU!VZ)MKD32iS-TDUW+646ed*}Enp@rxQ-n}pV`;uX7hd6QD$kusK{+uEY@}MCla2} z1c!N}W1{ufpgUg@)U}`m5uHm~cd>^QNUYSVFr_x7_CpYmk2G`^j612#8yunz-?>k@!zq$xA(iA{qwfaU8|D>bnHHY)_;e-+JBwLv2)LfzcJHIj?&*UM~ zt(l7@t&5rNp00$Q#7*yk`Le-2qF8BGBcA#K#mf{d6NPdl018M&rW$H$Sfr{P<{>>P zx(<{0a*R&YN6>DM>F7xpn%+;TUxSd`rxe2UY2I=6OMMjx*)|@Cx_N|}BXmQaoxqSC z^7E|OSDl`W4QkuyIM8ev^!%2@#ER7vNMqG-YGPt4~T;OJE9hGG*VO zcfBTm)XLl3Z>8e1nOR@kJXihd)`L5I#(E$&lYNXNw8_f>L&%M=sK9=j1#*m|^L9>@%uRF! zo9kvoS&bx@EZF#l)N#tzd{&g~B^5w8C;^3L#g*G~q#P;UAc>puhXr@*!&kr!;<^ZO zng>pJG3=l;YL`IpN6}PI(X_;M0M+Vbyb7Qv)wI?Fk=O?ycfy-_gGXFWu9xm$4Lu6B z2TJ^E6Kl9N>FIMR$wu2V`5mtA7PU27*3I)lm>m;MQ2cdD$K-0*)`5zmErEJ9y#@q`6i$Y~ zh=k$g%%ttI^?*)IM_GeL%@evbV5FQG5nno)`Qk#=ruVj+>=kp1?u(sQM4p<90Quv4 zym2jWP1A#idO%xbxzD=P9spn%tv#)lCn;3=f8t5>C+GxY534y$cF`c|G1qBZp521rVf zxhOAlDZ_n6py?hB@;gmxotf3trbOBCTCwD5KvIu=iZU%vedSL^QP0{*?-$vbsb(Ce z%2q<1b4(_T;_7OQEIW)1;&n~-+K>6q^86~NT$3MGiDfIcOy?PA>$|6sT5W zz-eBG3Wx|Eb7E`>x~pUN%T#fiWhQfUX6$SC@*l;tk}^w{i6OL_ipBG#W=4Nj?CM) zA#S^06xl#In8ML4G$zP5!n@NXRa(e66+sr96i1s7e@Pb)OftFA-^M5J)kH_$6##iP zHHprAq<-!M;0K}&;0$>@%^%@+LrpO9^^SM4vv*zS;y+Ma$@XZ7t0do$Wx?-l>_5E4W-Y3`M2m_{O;!sX9HQ7|0l;R69a+(E;$=DL81?B&JzS8W z3-RQ|;mIW~P;603wL>MFJB_msWru3Pxc*qFlQLdR; ztWCy#qWQ?6%!c%}`Mk7rYLuss^$2m$>QrU^9s8%>4i{6kk-T2Z8`5ywIJ%_2txxXr zennn0f%__yPr`Hz?AU}^9t z-~a$3u2AMBcoAuQAz#kliR=1MhEd#G=Nro?>3A^r!_3U`(B1&Y>%WHJN~ut=+xI6i;2X>HH7z3T!K$12(ZYw4#-t&#~jNUF>|8J2&Cu!OXZ(hD0 z?QylB1hJP|eSX~e<11^wszku@aN_LJ$)BCyMD)nEU7Qv14|iN^b9ZLI>;ClNJ|V+Sa``Nb|np;|cNCVcjiJ#NMEZ z#bdL_UK`yI!+w7DG$!@eQpu%e^ex$`M_Fd>SA7Uiu5lNvXH#d7Tu8{F#9HVV&bJ*) zf7P+&WWLzA2h3sLen0U@5j_e`GV z$of4qX+Li4^ncmb)coRj(1Kj{$usZW=%-&~ zCA>+BcfMhff5z9hvIn`lmBl@cgY$8&@UtMEXi`j=Iq7&AjBx9_gJ4#DcsEFgj}M1x zN3)m8=3%hcrQeu>+$J~(g6N8cWLn0AM_|mNV?-i;+6sZhlfkum#yWfof62(g0)4d$ zbBrXSx;jIpcJQjKu_ic&vEiJraeK(7>U&o^ma<2SeLn}beJ%~72-=6oP$M#=%~dfkY{Co$Qx8o`w=l~1v`lt{dirGRxFO;CJ>Mj^5BHqLj!{P@x&GKkQm(FM?wjQ)DIu8nlhAXL?~W7< zv`In`I0TxM(B=m_pvC&TYY;4x-^2LH#RvOc>B8V;MCB0Wl*EJpF{x~E96fEq)a0TK zY67^Cej=#&lcFH;CA=LTEg#6_u{M({EY9|EIBcO{asH&MQz@ZjXf~s?yYt-{lBe%n z&!~_6wu=2KciO()=xu-24^9LG5l0~i5pD`Piob-tz7y{iS&p;>1#ZwKJ_0IIVHck& zrL%Vl^lxY~L0FU}?OCUtDE+lWDGew=ayDscjMQ~wi2MH->x)Xgff~%VUE1^FxEF#zeffNZi}CT3X&C3XbLm6 z3zYovSozyJd0tx&ZUo?uCr3)hbVZs7@VB;U!+A*2`dhk)XnxRA7Z}C&dHs83NA5%A zy34`JHD$!y^^x>C>)K@5Yh8?z>Qb(+rMyDaI{-AU7EbbkYP=UW(r^R00#KZt0UAHs zeo==2no(VRu&A9cU>9psE(RU`dBE4Z866F3H-=aq?^Bjg0@mr~(T(b=6nhGDxts|s z8{Q4RORAhKEd{_DYO5vOJ_D6LQx}nYB#M2S%yMZGuo}`Z|89_LUbK>NEw^&vb-$y>kSC_ZuGj zvucH<>NfbAy#&ZdZF-TT9_RSs!O-*YktTg~}X;OqKho(^B$}mP;{kdy3a| z8?dnjf$2)whjPjCs*lLp^^hf)y)E_q7UAecHyD>DuMeH=suh^-%Y?R{(;}IZ#scHo z2m-L^kQxHA9&xRpup=|A&yW}d9H(roIh0lb6Z_x>xuvAj+G6w*-1Y?9k>d52%Cjme z`BV3|ke0ZBsNO9U8fI(1c!Mc43@&Eu~v6d}^|MCMSQ64(4I4!1Y0+KVK z2L*tb{!==7NM4rIaYOZM!EpnHMRUnL>%bYbpn0o%UvSx6Wkz4tYyNwri*Wl5X(wKS zLNjkHwlLjOr=mnhJQ*aU_ha`X`xKLOd;^D*$4=Dyk@sSLrcv?o1cgKJ_w>0GeF(&g& znY-7m^zVI!LuJ!5d4*r^#Qrz42SMOr7kPXm@O|g?!E318QzBdL67r`|EH*o4gP<5d z(^VH}7j1`aEnWCfwr>r&lK=8K7BPts5sX!h>x+xPzfb(BN?5jFgBlVyZa#xWu|%bj zVmbjx`pLZW{MULaRf1EJtmIJ}sG*g)}75Un%#`?W%n&e08UwA2 z@1@-7zs=*VDi{OTs`x~m&Jy2Lqpkv~Idxekw8S7>-n28IREP?AL$U9ckU6HgzkUdr zOy6&;yltG1-1*A!d346b5LK0)<2(N;c06|iIy|}hDYC(=aV(STl4Vk8BQiN=Abi+5 zm*o80$GG$8_qZza$^Nqk_26Hj2=Nzp17NE_lNdGm{x||=FTtKHOVV#f_>+7!Yl@>(dxcdNn>2*Jv+X zsdq>Z{EgeDD~%RTea(T8S4Fzoo*ouFk z|L#8%{1XJ9>sWlr#e!@mb6mT&z`3UD5ZQuyv=>tMI>H(TRPAKe4=?Ftdl>egY>rjZZ z`7c27GzDo1;ml007`+jdzvS!5i#1}WX8sV58rk?62ehMPX!00WeT(Hcforo4ejcTL z1vwi%C+(?aBfopi^wSyXBil|XWp1-_+R@-HIKcHZQmU%ZlxHpymqFk&^OuhDISoSq zuAJiX$H*`->G0+@tu~PIg|{plf#z(BBPKJ=nOooXvh(vg^?x2}3{8kNdMO?eEr0_H zDb=e-{S18>dZgr3G3M~E!bS&Ir>{Q~*RM~Xi$|LMQEDy<7*E6fGBCJHHq4Ls4|EzF6RZ0AWrVI2lUf6D?{A_TsS7lLk$DMAPhJ!F zYdKP(pZA6(gcX;)s5(UILCMAVsbI~n=cjqaJaAqq^gm$AQH&90>Ciw=x^MUA`1$#5 z=MIl`=dF#ZkpGBQRs&88Rj#4u)8mTFh(kB;WU~;OjOj38%Tj($sX&XPXZCg5r9@cp zu&pD717#K5*zM;~BHsm>f3iJ#wT$@YF2*R+o3aueFsD^N8MSHPt&Di{*DGpsO>|W% z_1bQ^RZSSg1IZVcZCJ8!Ro23A+OecIq-sXc-syGLCItRb(MPKr}8Th|{cgJrZQ|@ng&3-Q!(z742>bA}|SdEFTY( z{n&rk?xUdnNCZA=)!~XRau$(k^d2JdjN)SKSKK?l4cxXJ;d+!39}=A!T-Iwx31!x= z2dzfeuK)T5gh1On+DG0U3yQw_E#Qv zwH|IC9PK+bI*a+(jL`TZPRrD|_7~K4b!EYo!Ukzh03G?CG0_;$#FH-Rh|pk_uJq2* zk*c8i2J04sQSVAkpHb{Qb!R3oucY(&d7d%^5MY+P!;G5=Kmus{d{w88cUPrE%%%TE zU{M?rGlsIvqpl<4KcwY0;ps&>wr;)Wm8v4OO^NVFs9%2L-}0YW>0WUH+;MJTD?pSn z38sUNDmAmhjZ7bfSUcow7LE)utYpQA+dhhx9mVe)#5Vf2tn$-))^`~n(lnCteqN4m zN0a8KY53-?8b0E&X5|>X-8q8Bqb^k2xKXlHf?1d16xMB#ZTPCz zHV^~_5O8O~n_V{_n}{*xbqtG^C_%W1RnEyb@uJG7uT7+N41Esf`ea18Zzukl zFJAozYW8#WDcH8?EqxyJP0^0P^Z|u8%%c_nfbmD#<#Phl&F6!4c}Y|q{x#{o$QVrO zFI6%7@W4Lk^~UlwMHoTVhu8Lit0G3k3D8jV^Qve*G1g_UC6)bYCXOg5^E&@;B?conipak6gfvE6$h}NcM?6-i8ZZxBhytfL2?Xr zhK+&G@{qAP7z)I*#CUzw6^EDo*V}{yF61&x<)6yW>FBcx9&Dg5)i*%VXGpVy59un9 z)U9z96%cIfdlPa>Kql(WkL{5py-DWDf1nIF4g$uxZAc9JoP3vgFwkNzFJ-Q0s194> z>AStgJ2JAoTz2bs^&X?7&egdj;-g)uj>ZR?KBM?J>8LJR1a$2qRZ*L@;0Vxu5E1+| z{3;#&B*hCOpalT&k{!nHea64btH>CW$_FPvOef#e83X}(qJHW2RM-z|LPj`2xS$8#o z_oO3ue%_Q;|7vNw-VO?ClxJt# zwUbuLGfUN$&T7N=FH!JxV`22`kcKi|iA)$tyirsg1wn)<`b&!m zmwI%%zxgz3VJA8yQ;jYT*frKNLp`U%?B5gwpM_+#!%++LU0xEn{+aRV%{1T6A*`Ia zpIm;vUuA7nE_J`sZ`yZZV2xX5Ff)hTdO1W^pjLacCG@!aQ=AJCPm$xlE-X<@ZFNX>ibX@q)W}p{sLgUw_X*|MPnHsMSI#OVp=}@=EpW7~|}_ zC*0X9R6@0oq86gOsol5T5}?-o(9Q_^ayVK5yr(t2@Ilp9NcbN8MBQSvbK1uW2ollE z8{ZBF2sa8Orcm0s7f7GjU7}{kD;cbAkwIO;(0Kd=yOXN`Cq_L zC|oI~AWwrRJ6f-yy>uRj({DSo`NBY4qgxU66Qtr1vX3X(;}omRNt5+Ur{crE6+8!6 zxCyYhFsMq~gYBb6!dluHGxy7W7J90Ab9<~9nwV=Nsp_(hJ|!s&llxdVkISc!(xNnN zrem0{npbl@^8gMh8@+CJm{hdasjk-qTp3YWl5!BJ@@uqyx**5{pz49ze_U$roT3Z>}b}z`N z;=8NGh6VMbU_t^-a0!8W4!P#P+j)Gd)Ln@RnyL?ithz#7iw7*W*JE`sgXJ6vy(aq2 zD0$dA8(1Y$*fvL}!$@MWLa1dP^h#VJKoGw9mZ8uf`&*>={+sxO?Mv4`7v_uxx{W%N z`xye)MTQshs`E$97RvmBZ67F78L!mB+cXx{wQL7KsW-e@^~89;wp(lSW&sF!b#3+T zxHga|4Y3PXwN^@w1~vRV`)#X*szC9gjgePh{7lk2>qStAi-U~thG{>EUP-N?vL=u_ z%|=ItKG=+TTDir~7x%SWDSLiWyzj}t05rRQ?}IZ(h?Ag(Z)3@|;C^`s~a5URPX4M`J_L#ns=TY^bo zBO8%vbeLS=(V&d!u7y+WWe^H6!ewiEFB@xG(ly;$>DSdB?EVB<8tbnAFkiWEEHpl8 zrr1LK>U-6L=gaHqcbEr$G9`lqQ>}(SM8*=dnM>pfH65iHjP*}@dxC4C1ibUTNJv*7 z`i7C}0l(9{$4U61iS|e5Hd77m9@@)#Td%Opr!U?-f-$OT@6JEueKOX0kXsX4)sw$k z)Vo0(*${Hp8LX0#Q5%|@pHDLLv}w%lx%5~eGV(25>MLE#z!xFKg1}(Mliq{Lr$wa; zgIH7|m_h;Rwvyxj=#HmIPeQA}&~c%1aP96hji)OSirN?QqfR(K6g?djPSN6Lktli^ zZayeTl!u(+C;y#hmwdlK<(7^rE7y#alB&n+u>?a#)%hM^l&iHkU-iAiDx=cdOmvu_ zCO+(enx!)KtslVb;63TQwj7c#9Yw=aa{{2o`rKB+(q^t6&72!x3%Ne_a!yC^=~5F- zhsaXC>|b_^2DY|67HiA9@tbW>ZchpI4qt7HNjX@kh;>@1i7%Pi9@0r!wRo4)jlG^Q zIMOoKQQMN~k!z)TLo6&uzX?PX;%QLVgD;VBI)Pojuvoj_aetYtkG!yN(s&R>aE0s& z2i|0`QHA31_*U5;Rgga8_ij0Tr%`G$1^6Razak%*5)1eT(%~&bvrj+%$pT&CmO-Vj zCPj|uKg41gL2Ax@g(kfT-Qu_3x+Djx6_6Q&9=(PZdm?kCtSH&vvDZ03nV*iDon9tU zK>oSTu9MzYH9QDXaHmoiiV1*r`S|%NrmAf|OF<1=w2j2+t=yF&tgr_#RXc=VpT#0&j(^FZ@+tbyp^Z?AINCub)G$T{L zH0>VNC_lHr${4@<4SHQhfL>u@bm z>5r`z*F~b`1)%k|LI7IVF|J-BGMscMJji)$s_*@R2~PCZN6~saD!g5a6&VYGi8yD{ zwgnLTn$HE!hW!3`P_%NRks0PtCdH4|8c6mxRw;viyTlOf`Lut=^xl++i5;&8(tZ)#&om8gdi8vfI+G27E`!1 zrBU=clVS_AH(2KI3fix7K4vGJOVrnOaPVF2nC377O62l_^3MkoorAGO6Yd_xoxKd# z&uX-CaQKD}|6~6q=)Ks0=GkYQXboGvG})o!q+@i1l%7bYiw~j8h@*Apfu2?0EnGSIq518)%R30)s$$bU51X^Ru&Nne>o#g%n6DW;vZjF8HDj*X5d$U==@NRn0?#koCR^PQ}22>Ak+Jt>+3fg;*k+ zbO4ykRPi?g{(E{+bUIAOi-IFYDB`=+71jDK?kjdIf_6L<6_0#Hu>}3ym~6r0VLVV! zP@d;$XUXd!z2$Hb_ZAUVyB8WCnB=E|cHuo4y>S+`TpQC3M!;HMlR+nA!|L>K@~Ed( zD&`x$9SRs)bU}iYm2N>Hif@KzgS0aUW`B&tfi4V7=nx1X7UF}()ef4x=W~VB)lf@Y z4zM1s2YjWlpCfXeO7y|k9E3NFI~`fEg|xkmp$FzdmQ!JVAxRX=OjCGq@1 zxonH=D#n*p{gDlBxKJBMt+L<>!!i z7RpcuDjb$PV(Owwj%v4Wt4L=CZ?Ry$FrgHi#bdV@{E_i6#QfUcOoCOZ}u z*7r(NlKdvV`qVsk?SDm(YIWeX zwoXMQqg#&$uBcIUZcxZiDYmRlB1=k81Q$AXp|fJk6ICxY6w?6v2ZBlwxo~_uo2gfO zvEv8#u)m_|s~zP2G!z+qJ-8h|N}_gB6W^gmudzkG<;m!ZB|??stHa4M{B`)Pw$?$Kwi`xQVOSF zFG*ch|1XlND11LcVE=NJ@GI|#0U5u9=K3;G5R39kZKdyP{gmN_ zK+(bjAYK^TR!)-QdI#m^IBi7xBGC)b?kM*3W~odP_jJ-xXF$ujWHD(qC>XWu8gN5Rhy z$70|ykzH9pi^&@p-l>(}E*^reS_*QgCSzU=ZZQ!^xI@6KWvqWoOLZ(R(jF|`Q+}LzZm5}h-F-#o{>r5CR8;HHnK$*&%)p`3+{3fVbT1Wff zyWMXtADfFM-$&mRIj{yhiYdT|f2ZlV4xssFJh^9MN@dzlTw#Ts(!Fbh#lb=KAby!J zeWv8=apIxU!^Z@I6raYGt~*#k;o2c)=te?z!qb>ZFgDhahp{_vrXE+^6w#wiWwr{m+wFu@pP|p~EZ!V9dyzvgbWW5kPNa)Jku?Nt+LoJ{ zcQ!-ET_wN2xqr<0$j~)D)3WR*K={uUMwajPO8UTf>{5Zl-dW!W6F+2rgCKo&iJ zr9Q#>ZTjulZw@sK#`lzamqlST#3BX<=Jowkld)L;#8!Ko_`5oOa-*DpzxR9=aqn{O zQ%#NXCVCY!lXLI8yS5(k_#8Wc%2Y@F%Ym!$k0-LVaB%+m4Oo7qcrdgxK_VDVxDn-ilJqxu;l z6;XHJ7?l)tXIzP)9k#Z?S~6l))X;gY@DvQq&n7s6oax8QL-v~SqSv{R#%t)~IsesO z?(Oa0DxD6-l`eyOPTepGd$9XOUZ&&&(T(+NNvuth!lqUXz1>4L(Z&z4B{0$%kX01{ z+O_5C6LMUb`n8IYstfaT1q=oggYtzoG`zlKX#qtt93|7>~NgPuI?kv=hv2UToWyV-+uYXJ)(wgmokw(p^zi2 zVR{3Wqx47*h4is0f5y;o)i8;3cS;yN@8CC3gPBa~z3*IBleaTnG?O=9;%n#+C8my_RcZ# zx!&}Oa@U=AUg|6Q-%P7ZL{4gD9{c(8)RP8jp64floaRXxquX)<*M`T3jVt>CZ33MJ z2Yd7c?v-mG zrxJK(@Wr-7V0nsKll0$GJ`q>klho%itx=;qvdwV*)^XUTL$>!V!+N{mt)}{!(iiDJ z3M&R`ElFW0(Jg#rn|#PUlGk#qkoH4@@6u?5ys(>XDxM_hy%o>^x562~yy7Tq9+D7{ z>!&GgPQD6|vIFKJR_Sfh8d9B4S6aQz?{)QkcNvRTXZLfA&frzcX7@s~8;pQyzcQn2 zr^=dObl&H0q?NXl;eajUFKFwz5zfD9P@YfYkbjq@aXlw~ zy5#%qeUE0(nlb~UL<`vv(jFD9yV%(HX6f3leivV+ME2s)PgTtGqgnCXYJ&i`?934< zw{boDvyD0Pmi9guYM-P!KfV`cYs81Uz7DOQ5mHpBiq@OY8`6HX3qMXd-mXJa3U7kp zfy?w)@4lLW;%DTwAPC98@J4VKM2dxMSo-?#7W$NP(i8m8aTmoL*&3_bw|$JU)|i(8c>{SSn{;t>Xgkd)|dT0 z!GSwYi1TbkS?lnC0YOSk@=+<-hCzriYEB*n23n+^4x&wk*6vxQHALsn7DMAJH9gfE zTQisCaFrV#h4!T#b%{x{BfrLY;u^k{Q17)&0;B-z0ut6gAPP`L?BBVEAXs_0<*#9n7R;&0rCHoMsa z+S)(b?6qbYYs~;)7cy-0*Yq|!%43TIBH3Nxm;R)@hVpQH@#oTwl1o4E6q8RE%bWFY zw<^14*7Q`mx)ujzgf_na^XB;dBcwFrx3|4;6vtCZ&bc;*1%>(CjIyGK1L*j+c}3iu z7C^CHiqNxUdaI-U{1i{CyCoCh)s^~*rp8`W6~TrnR!B?Pi2_SE;Fnjm74diAw)P*F zBMs?6*{jkM({xSm=lG6yAVt@Kw_B!M*9)uDX@-cLvO>dT#}=fH*Hdr(;^g3~ExF$r zWg|0lO+9X3QrsRH=Sa4FXi>PEduq5;QO7(Glo^YY=W+A$8+}3!!ty=nIH05?Vts4N zsylMS;QzIbFoj<})`<9cH(#YJ`C+~xYI2;EkXJcbM~hp|6H~9I;1(USgqohyQ9Y_O zrRYz)6%g$+GoEftC_!FK7kWFQHnVLdlkV{%`z`tSk!NIy6t%wW%Yo4G-S?XYP7kn~!Pv{HJo{)WaoPE`pUfqMudXygRLZWLg8)bK%k(p~6KvI+?V6i* za+@F;Pos=3I`Ew4Ef7{CV0i@rLJS>G(`(ugA8uC8shO*?F-oW%97^wrogJ^jhBmIv z7Nkm#>_ayr8~4RKd>DrLa#wdPRy|oWrx;xK^UBcE&RO4?7SFURkLMuaAeZ$Jv13C72w7mkVS&cxK647tZ+SMw!|HG};IwK9;;?|Fj5QR6Z7x9BhG30*e z%vTheAcT^J4ibGiQar0*W$N*Rx1YJ`C|qi*z0`F-MZfI)8E2gn(-%@@FYa{S#XS6- z7SwiUjyBn!XDtj){C#cOcJWy1w{)FD5}o-EFZKR34{WPWyb{0|%;eX$xCErupHB@; zQ^LDol;hL$$SsWS{C!e6%zjNA|92^-3I+5+{Kme-kOY^NR(Jg~SP~x)ydtP3uqfS{ zXJa?F)7;vecs@TNakaGe_MSI7Uv**Kck3U>Cgf?v4M0At4|MB3u%mUY{SPM~d5=d& z7_sA0YyR49FG9Y8Ktjld7Gt|Y>p(|o>X-T1zm8Nj;&WT}?nl-qX$lNXgS52U+&CSr z=(=Xp18}^FC-RDiJQ%Dk59*HZg(ES)W7@$|)LlAv}%}899_i zhxjP4pFmODaZQ3sJr%1mo9-JUJ;kqzGm4KFq5Tq~ZEl#nkTChk6+At!`n6F<|52EK zo9hAHJ3`k<9CTR70;2945UiGtvACCisN;U-d1O^qvrj2`u&TH>H=W864^nr#!cgtN zQH(bnGGZs3>*BhKistW2IYtvV;8DpCBzGR)?&*6xW+TLM4Y7c4SZDy|QxwfbM_O{8 zBIwI21h@gk_g|2AOsSZPi?hGq6Pw`GaUAeO9 zuC5I7-l>$(){Fq!2oQ>sCc(nH*_ z1WFxZ95CZ&OIZ#O|0Ttvqe>36lj3wsW};}CXUs?*ogTfn<%Y_{lyi$q%qLyl<&1AP ztK^n7s{{`&*REsNdb=M;r8a`Uw(~NV063K}gKmA<_#-KSJ~E>e&W*)9MuKBVjxolH zr|aBSni0VBOYs#0WZps<4RPUZY6M*vXNh--PSBW!_i#UReTI^Cx`+cZ`R;>5aehVE zfrmrQD&I$aN>*zs%^c2_a@F-1bTo&T?ahIJ5mC6&i!mu_kgO?;`v8+Iq6+R3ow?oA z&p~7;^hZu%(2;Gbc3@I>h)2l1cBur~yt*o9!B+B}5cO#cpVFLiFan?Z%}X#$vB{MzEKmdQNC4~ za@uo;4X&q)qC*ARjR^dY=*IYDmEU?ZD&U2tdPFSdj!+ET;V{U35v{eUG~{nv0?MyvCTTi-B}SzX|ta zp--XRn*lCu!*snybu`i~cx~qd5RJbU>Vg@facsIZj!bEY0i}?}RK8*5sJ9vlyGFh2 z#j-|>kWKo_?u;&>%!AMK^IW%UGLr7!>jt2X!cK!x>#|Qvw_m)x(BTHS2h-WV#-|@i zam?;L^@t_lEp|#fTyrcQs%=@&|2jOpj<}Y4=E3cgGN!J2_YJFLP$-ozj}tbWWmVm` zrrayJFdyD{W|*QvT&xO=#Ec&lzJKS`(EFu^ro%LTyKeh!HuVE6(0o>t>kRCE-)y$; zT-rTWxA!@Y6*Wg++8I0bX1q{%^Xg9^WK|#avX6SP>M#1%cwpnBFN0PC!&AP!Dm?S+ zw{Qun3|g8~5gqT9e#>>z@N`^AL+Ya8KTs6Fin!Z;dUNB4&4B{+;q=zUu12EnE3|20 z(0{$k?!t`;-(0uTrY$t$g+6_rl7N>dKCFu!HLi;IHTUUerqgyw<8a}?9U#|+Y^?P7 zo_&(HbJBU*QOtUOtv4k<_-fU zS=INu$Eg*a&=ZW!kR6MzFKHYVYV`{K1Vjz_Z<1JHn0Y(DRGs|)Z)iA8~Ii3z;xqV=2%f{U~h z5MZIQL0Jq8K5+Qnmwi}Bj}1?qeF~N~dceo?%zkI3P{p`sWUcbz z##^5~o3YfF#qP(HCs#a5hHN0sc@GS7C51-mUvm6ImVb|B76Fy}M%+r{QWRHoM{cuy z-DNM$Cv_l@zJF;mz;6lMm<05#tChyBTpKNFD}tF(!mierBlRmIhD>^uC67#PCuO{K z&4183E;_fW)+Bo`6N~7EKZ~vmNI&%bHU*GxtUD7jPyU(DwO(2)?+bwoX=D&!w z?z!hNGt#jo5 z(XXy)u&)OEHD&9Zq6UW6qc$Nsa=Qjgm>T4ZV5OUvE`sPT;edQ?1G50ywCPEmBqQoRuV&oV(=N4ja zA+{Mgmo3-Iwtk}qY=}X-E+3#N+Yl|!3AB5r+vVnY)sGik4AnHLVRWEt57wH%lW>qS?&ep8-iY?W6nxNu?22hq08k@jwkga08_A;=+R4rGIc?Fq(Cu|1}ldj^8WjXXL^d*s0=II$4Lr_jo*@K zpa?uuiW6AHT%=k$`dpo%iLHXUsqVJ1SH01>R^)M1bg6r1srdyy{ic>Y>vs+Xf{%TQ z*e3?AZcZN^_rl3R$E4&{Oe%)MK*lA`n*LHPh&ApZ8I#iCO8&Js02ex}<8Y;Bn^D}- zDtG>>Y1qdU_ibJ+wuuMZG(w6rzPbKb8k5aT;%HEx8S}A1h&lakRU<}tO`C3&ALVWB zPNoPXhrDUE&AhJnqslBV|*Y=-o2eXRKFH4}|yXq$j_fc5FOd_iu^4$L~y?hqj*RVpsY%ubD zOyYaKiT$~*Z}c+2yfA1{;OI7WwuHt`e;=n`4i#~un6JF)x zOtHvZHNQIYYkS{lT50_^`s8w%OnSJ0U4|VQ5b&D&Y`KRM!Qfwkn`CwgC;!ErqQ7Na zhwk8wGAUd~S#L=&iEaKBMN0B-N$RjcRrXQq!c!?SX=Sc_|K&^@&3b71 z=Ro*;=KNdK%fkHiN`+z_O)c~$8tp^u0~T<05leyDchd2+;gOohiPZvz!R7WUrdoFc zLS@4gHjZ4;ozvdy6OG<1DdQ?-g{PsvMwha04^^K1uOX23LR*Shp*^mm8!n{PJ#_J} z$VmXa2iV0vbyS)844Nz+U>LLACU4&%W}e>bH{hXUCq}qnxS{g>tmUo>d7Y)s5P>$g z)G(Ik9bmONOO((2VnF#As-n6KXB2oHeNItiNgc4Omz*alRQLf3W@j@X#O$ZS=pj-o zA6IsoFYi8RFw}&3%?lWbfikVHt({?Jhf)54`t{wLt2d)o zWKvhLyS^cWB9*P0LrdPW5_GBmK;&;p;s5==B4>vKIT7!@{N! zfpvc>Ph1&8I6lmyy7V^hUZwQKp}v10ZfdFn`DzkHz_Z?y`UM|)$?gvX9%ofO`R@1oD7@OJ)Oq9vdb;7~&uOCq(udAp zMFR-@59}#Z`vH$?tFk|yLSxmjb1iaNb?>kNZ+|!iI~~hNgQhZA2PpZy;Cjtuc+fk*rzuXlgUYo^w=|w;@(C~I zg%)fKWv_C$_v=(9uT6N94>ywh!@oyxh&W6femu-$;B?}GeOpw0kK0NGCG%&yG~hi~ zfJ_{e2yeiq!2PE~$e7jQZZMw@>;lDs2C#ZqX}4Duf@~&el0-9zZxdJmnskezlth!R za->F)1R{0ptD~8tayDaYoZ@sL0f;!O-9X5Ln8TngLdpzq0GmlSrVPf7H%wn@sxK>E%W|6rR%IQ3}bQ32e zD5&-Ix^~YG6Pen0Bjv&kiIOg6dZNKqnjt;C>l$piO*50hzd*Z8y|Y?Lw#+!-aIvQN zOK_*Ax$X68=tLHkZ`s4RyC#%4>y)ze^yVD6R-pmJEC`mwdflrj90>JCII2wQVDZw4 zGc}U{RLx-5s5T`Kz?jCBr5`3$Pkpr8Q)?W$R*)sOYoy~296F!ITYdD5o^8P6TP)Ib3CMF2 z*?MeMaW^Nsgx>)iX`Z@{kobqI0kL-|oR~^!Y7o1xj=x5uBh=HDcXw7AlVaEW50n<2 zLL{-uYxlMx$Lx}-!dnCD_M~=6pC)mQG45VIR2hv-t{SY;r_aCiR%YYmlp7);4&IOD z6*F}=VV>pHnx}3ubC&8pUrRXmBj)dEqqCOkUd}`zCdn49p|C=6)=R-5*tka+Pp}t` zdS#yEePT(It*7oqRMDj9#RhDCM_aK3%yW2?L`H3gg!d{+#c2)QGIyXLb8jjFWeDbU z`*BA~$MSicNt|{$WKiQ>UyJkDC;i@I10e7d1z@v_EJI5FM}L<6bqlH1U_OQr?IyJz zk4e7~XFeCe5OjxH5^Bo{`ttoB_8q>EgJ)~%R!#+lP z!wH>qAL4%ppZmoEQy!L@^(CCw$kEAu#aY_ACJxdIC)RzF|I0fA$1h`-%*-YT6#b2X zy6?Tyy~kk+sLHfoFrDqZ71541Z|EGL%GXL}*0CEKHD#X!xKDLzIXxllR(dbSglV&Q zcdDce=@!M97)82BX6i$N=|}~=*b@~;DxW`-2s=@5WS=0&r@W;QCdwS>wXO#uoZ0x-RlO1L#l zKO2@{#0l%5zk)x4FR~qdr@oCMw;x52cgaVmw(k9o@Us_s8anZ_ z1pWcr@!*4s68joSS^2W#&Z)Wib!SZ4;9tKs&)bQO5A1~I`qmFWO=rMpcCD|jua$lZ zLl^z9A^n62C{#UWfdE>gS*hB;Vi;nMJTtaGf!Vg{36BV7p2V|1MW4R75v@JE0L~#$h%950c8tS^S9!_w!Tt%>1LVhD(V zhvuEj>rw+cTp$mls(dNtpEiY!pDUJ4#hH6Uf+7f+f!KR()9M-e?#44$kop=l*NP0y zz7Y%2gK0$cVq061*-GTBjMrY&r+Ta+&rqm!)uA(Tyop|}Y*_kn7!!JzhdCqs9Rc?;H^96I+@3#fKErg#%I^-I>X6B0J=ylfr zcCJMw{`Cnkr`{uh@#R`9#*4o_xf`cP%{|eeLI@E@jVLH^X}?b`p=dggrEw|fWfnWe zP{&kfFwZesx))BJ3ydmqkkrqdg zSe#kAFS@9Wa64J~d%^6D@BZq3vs-R9&mO%?&q#k4H$F19u&^?;_78Li#4V7g&9p8w zsQOFiDgD>`ne_KB!oD&U5@ntOd|g1`bL~_C-I$sQ_SNB-rWGTi^yDmezl*rr^fh0-tiC;`qnx17jRC{Wgq(aR%MK|W&YNCi*xsK_@ zV2%u3-b$=VEY=-8r0nfRP=@;58v&R^3hF7qWzpozsSvxlbuxd|C%?ZO&nZtb16DcY zEn-RDx5@OSZP`+s?{R*}RpcB+`hfM~kG-GIKV|u*prnPv5apxaKD2~b$hP~CVo~V5 zQhudmRZ(^shl@$5yRZBV3oE5B3eLQeRA;N0SNrhiRa9eJ>;7+$sYsVUGB#diD63OB zt5ka@`jQk=f(l3Bw^T|K*kwKyM`w&JBt2?#8ihp$J%3^%|Ha zHJ_+&PSYjt^h~=ijYfHUR_$0VZ0lr|*FIQqam5V~_BghL=NiR}f)r8lj~{;+QY^y+ z(Hw#FF36bw2jXP#LvR8xacCtTPSOPMF{_iGW|w>_o&|Bp#S2X`X_^F7O`Qv{rZY_e z8o2%jpCgF$&(bn#l--P;6+37IDH?%mmh*OWw$snNdqC7|Xlskge1)9sBvo4;ZuJGb z`67DhL0pOb7Rk9s>9SW#1EcR~6s{tFXHLz!{TihNyCkv>8e7do95Uw?BB7#%p4#sR zZJ1@ho5>KVswmQ3{rSoObvQz`Xo<&eblf1vt&1URsmh1y6sTTk{TF!EQ;jR#`_ zFljV1rG87U2UP37BSTITXfCz^{KEBl=R};%RK)jEVZ!uP^3S@;9jk5l(~pnjfL`YU2~k%_k&zM^S%asJXK#tJ@o9L|Be$)}L*t49FXp z^uaE`ng4-2_A=mN;3wED92%Y)=Rj`nlQ<9db}Db7riBqP+|(q~xy-3ed*|h%%G8>0 z3>zd*XJGDggqW`3Q~(%#{nxL!TUEYCytmjLUb;%8|CcJ?>J(a%{+Z>>EAGl173~Lz z+Fz-;ZYcsn79H!tp*F9aZSEc+YKmIWCz_w;C;se?UhT)rPFA_S+Uo5P-O@ZNc`P6; z0udoapgLYgzmv*2Yw3@yEYa7AJb91odhPm^IVrRI=`h&(l)gzH2sHE{@^akVLLui( ztmimD-`B(QOR_MPjQf~rT7B)o=ct%&w=&Ix#NQGT`wLiTQ~vdQg-+7-n#IQBI#fZt z&R5E#f@!-Cn+J{47M<`i9j64ruc8RALSALWi(6Q!kl~ZAlt!d=|3>TD)A?E>5Pm~T zG=}@su#;0!g5dcZwum({>#*IQ&rzr26aIlBd5YiO+-A|*EaQ3lJrSyR#3c4jQ$fTR zC1OEWsI3dn->8mhn^j7diCwNl&m%`i18iLH;B#y}4z{(J)(OTAeNt^UI=j(V3gma z``64nYLo+nAq4cS$Pf2o6=JUD2Z*=Dk*K~88WV3aKgCHX-Z0e?(i#uTWnf_*GJ9u5 zk*PsKzbqddf`V0XOup3|#e3ZEL%-brA#Q0AYGnF#3os2EO4Om9WX+Ci;1M1qSX+CR~XOk~3slbVEz* zB#T2{?eD>S?a0zr?B+@NPUO_))NBOKuCv*E-<8zWX(D8}->w&Ppv5$urCRLv7Qd>2 z_sr5c7^)P*3~3QjT899oL=udxoJ)}?CJyf$?(`7rvE}ZN%D^u7^Nko+9IiYsaXE&8 z3GTf>s{w7%WB~R{H_#4*V)dpI21CX}urigE6{fo&nk+z25EPA#5Q`55WA(+WbDt)v z1oyqLu)KxS0&gT)j4V3OZKI~JX6U*f=$0pY%^8mabQW+H1qu;@J)dt5THKDwwwX8y zqsGo^t7rfX!M<>8;2Y+o+~3H~K`6@jve8YZU$wXVNn)BF+^}uBDJTpO4iFN$9dnWC z^H-Lqj9)iwc_yRRzPbFImG_OU*xeIf=_El`2xBd|3&%}1)9YQB7zy#8+CAOfKe!%< z+lubDnhG+#+$!iQ`Oef>De@UQsKUk`aj1XuYtNX<%9Z=#F`%=oJBgY+gQi*_508j( zzm>kkn;@oX!0GpJ_)@}+b9m49mzt+v&&0;gN9NT}>WjoHp!(jzBNqcuIqQJX4+K*F z`h@4S>OND_#I;L!&ssc-Hq%Le0!V1g9PqL`)kb6iWqvsi)%MI@{~=J{^JMGynzz_z zR+;RMTidEVpySFjQk=$m8x^cFAEh3@N-8d4VoJ9dtCkj#cRrq9UT~7yE1#=_jWr+n z={P9_1?c#Zf?OcA2woMz(AG<*u*@k$Z5Fv;eb=%Os&Y;1W?a_o`Sqo;Xo&!=fxFhW`Cac{Z$R8MTMvI+*2~KaO07eVeCW1v zn=A9ZbvHtk_mywia(+|Jdf;D~ShWHJ6umNlAxu-6Qp*N7{hVvfvuLsNHn9|zfsi-S z`Sx=&7gKuHQ%vz^XEQn`?nsvBpgZ5go9;wqry3S@;}3*nCh+1Rg%M!c%W2~(VfM9( zwTf%ePoCr$U;o|y)$3*1Bf+mz2Ci$5%=oW6y?Kf(8xmUMs6_k@)%A+_6KW|q40w=_ zg#-t2&JcclK=ScrRKFp9vaq9k-=2ca^Q6_xe41HzSW65#4hC9455=74>$1KSWb^#= zYvz3x{DoazYohld@k6Dd1iRNO>(E^D=God`sL$6QKfi1$xLcY4+IX3ZNP5|g5BBRd zbmW&a!Cn&0CLA$`Apw|Vtm_B0%`%Z;Nx0SB%>vLsw3(30oE(WaS&(4}Ps9uD?ruR) z)P^9*-IKYgZ9zSJJ~zH460+R5)k@Ak_=9p=o_F8xDwh;d8cXD|%6_nMi763!prz-j z&a`pnx&Nm%kAz#><))0SN4LbdEMjoiojzMAPfL-FtH9HO2jgMRfmcFroO`gb2T#-E z{622bwAe*j*{hvzXp5({{W)-rI*j?F4|kvWdwxmD*H^OHm84J>I>g-G`>dWQRWG%X z(~q|~+F+3%WYrQgAqtTuyh0|t!pqPsyBZ**d1kE%kd*UyGF#VPK()GuziNHGKt9WO z%k0IDJT>ITa%@Um-(c#}H@{%_2lW_L7x^aw7O>RN784#Heh4vcSTZ9;EaNu1NXz-C zo_&Dp>WB9fB!uj9z5$7!wctEAtiB~KP(1whoU}>A=+jc z{UgbK?oYOq2WOPGRMJw?Yo^ajvs?M>4HZ)``yuTuq}KiJLgd0%K{4o`nr+dU%8O)l zU7)L)#ox`n7Hl;=wy(yf?~{a(8tNB+>He6gB(rNA=SLp4AjX7Ky>{V` zEm@u8JpW%3V9ZSoMRAaDDKG=-$?B7WDzBchPVyp@t6#HMH>8u~6@Q*+qxvpXGDmA8 zMAA7rnZXMDT4&$?T`-l#6PFrC075I>xA$#@lIHUbK$zST1-l zf^vR`N$%Z}f|m<53#5fSU`bwO=g>rCJsuR5=y$xUR@M4QCWEkBdgkfaVzE@~*39T< zNBK}g8|!kH*HxC5>+OTrv0vQ|ZN34;s-Nt!kJf#A zsZuP5uDx6!IDUUozgTis>xy9af9^^_WW)=D=ahzL*g&jCefFJDh62*pKL=DUsnwnDJKR37BRkN zcG0e=X)~kad5YdBt1+t?%NcP$YIR0(_R6yYD%I5w_cJqU8FD&*= zC@rrq1g@_l0x$U^f-_PsdB`CGwM04R&5frN2nDS7qRuh3r+m+HJnfKBL%Wh#xqiu0 z{tJI5Z@S&mmI1Bbwm&mh5-T;)(Kvhw@iFt1yl~z1 zKG1tf=KaDoRc|4tA_FH`*H$-;m`X9v7Vm&I)NDcPIzZuDYIB(Ubs`fHxPsX3Fsm`I zl5YF5_>x7GKVa&sL8R{Qo~(0PL*E>PNZqD=pV`Ju6~=Xm(|Sk5x)$BztcL$In%5n6 z`=shO^a^WDwx+d+2{+axBP}l_J>5S$s5P#e`?Y5eA^PM)S?l_@r6_w$cuvDsGj?qx z5#}(vu*n!{4ERHuj%@b+Xat9rvM3aK)@l)@_KVxQ*Tm0S@{WiiUg%^gkDELQl++f& z#}fuwr1*H{PnXr+Fk0>3041D~X%1Dn3GNM>`CfQ>;0sGqAw1eB)RLJ!I5t7~Y2mMT z>1OuDu4!vM?i;F9*B=Kg>fsn^d7G29rhnp;c%G2*X zzcpSCOX#$exs+@jgXelDw0VJP8iYSzjWz|;FDB|408p@*mr&Fpf`(*_nK|StN`3^k!41TWzM&f$iPby zZ|YbiU&IINCYTVl25vrF_#*0oWVuxQNbt87OIGoU`i=xwoMtICe~`gN{?++$w9KdN zI8Du5z;-s^5#LzF;&uz!$uwS+-Ad*}1}Wq7cEWVMAtiV%_Y%uVrRAJ*f&Q6PH}W+^bRV8jRR z3m%BG(|SVvaWQU)HTj(%wD78Mxv*pUE0-qkT{(wRlB57s?nUl{2Jp?EV9tO{LZR`+ zhg-e1PR|B!pGtcrcWb`Xxm?Tc*~T4HTU)R4k!r2qvWtp$Eo5nL`p%0ccAjG9?hm8? zie=}SZvhEA4xW4P!c3Osb+=T6_8_aCcz?)O_0XRhncr`mdD<M;vF7%tWaWF-xV}38l%q@qzhfoC5{gUm(TEop3 zPf%)Y=%cAcc{Q}|9tSQS2`y|sw`iN2hffp~>(rE(V80<$w|T-~1+E)sU=+;mMpP+H ziTd^vmdKT;Q&bqk--##1Yy}>Sx?UR6(WO2{GZ!owwuWiDpwtH2uA+o|YV(%M_XYmE zX=?qw#_`2F*y9FBbnUe1(0isAF&=mlsYd-L0yKWs&VmIb-lrvJ?Aa}yNE9)x8}6_s z*QL@dhyB(TNG#XNwF%0v;=j3_?I;};{_dC8sa<`tLn!EYQsBKSE&@eREh5O@3;9Jf zhQrR>Mf$#ew7M)zd6rvgUu3e_EFhGc8`et<{+%4BZ;WNF1ZHk3FjU#0T@iAa6V;)b zu~^(7@LcU3F1anI<#|Ue`KK%)h1iW*63*;waD~FY*O&)Az$Bh*yhnzdn( zw`ZE88bE7@yj3BvY_1??ep?Xq_khVhNt-4T&CawWC%hP@m910!Lt5DjRPoozK z@}K3}wFU5#-h27vxftFH;P&|t!Yyug!SH@VI$31rWys~oQ;xN!8)63c-yaTv4la2z zy>wGb)MFZ8NUXj7%_*}g9A|d>OlF~qsDG6OQUhrtXl;9g`o|n?->HecD7Dra?fLDX z1w~{LBG2>NjZ(D5^!)vLpOt1-zcEVhAF;S=TQys4^x#X|tA{78L}`oUdv_6jw&kS4 zZA-mNmy(5u+LCqW&*|S1J9VCQEhiFT`_zOJWReRHvG{|{Q z>+7b@0anWMSkypv4p7A3v@au1S^cbHGi-Qe`)bQvQwTNPZjEU24FrTshaMN(_TrT% z2Olcm7_up(7oG2z{oMHM&to$l^MSjCi+6)#dad%?6{Jc>pvcwR(gg^@qrz}Ouo^J4 zTt5!d?vWAFzS_!*%AGmx5k=7BIR)~tVoP;qtB=pR+z1=Fch*!$I-jyv2^lp*I3`$z$rb=|c3#jQWLr$HfFYFe@m3aWEu3tiUOJ#lEK^BTrC8O$xWfPQN82(!7Svz0?CGWiejmJj|R&rK)MyTEn z3eCBDTP$tn6P{NP!Iy+UP>SA1XbKy?2L-d-1NDo(oR=8A1Tubbs;w|6;MZRn*t@OD zAB>}yayq*Q4pGyVk&LuIQO*$(>p8|+JsU5;#o9X);TuT^%m{nP$T4$q#|`C#uq@(x zeIr)TEL;n(^budUMj6*;-beN^dxFAT=J=7YqWJjhBgKz@PidKf_J`#?UOdaOx(Iv1 zEZTh9Go)t2`NE7#Ugn@dt%a;_SN_uD>R8EYj+E+*kAypSzVt>&E#1F+VP5TB6neM# zdQ7{#A7U2o6(Bf%ib+N)7?5N~cx*(`&bN0Ouku$_QFZT-lXvf%1-y43i>h?x2}e{ut3FKU%gcIg;lSzBbdZ02F>1XPMa8+c_5NnCP+Ul!e&Bdymc*Y3ZCHsaEuS>UQW2p zfAYf{kq{H9K7ZctMtb5p;&FVohOogW%vhOAlF$sBMajqaA!F7GyP zc@?20pXkN)i8QF3(%Q&@YA+X_!XZ3s{BLN>Fz5E{6e5m>zL{)HS7@sihJObxAwD7V zzW&F{xS*Axo6%)*=RikH%)v1KFl?Qm)(PgC;Eq+|y7?x3Hr8k_Ft4jUEbiCbGe~SZ zy8K|l3C6$h>sazWw7WO!Y)p(O-V2ea!+vlo+Zg|}DbdIXBzg_&22Ry)7Z<2J@1TeL zzV)LpLQ5MT!^s&c8J7#+QYdl z_?{g+G@fr|Ff(^3E1tN{f(ZWwzaBZnd9Cv4MA$Z` z>4v_g{>fg(J&rVj7_@O8XbtSJ|UcIa|s?oGI-@&x9N`IDAS^P&G ze&6>LG5DAX`DY1v>Ud|!Em%%v=u(~_i$0Zze51!#Z1JP8z*A>KaND?Q2tHrz*-b>9 zC!|buY$W|5))`ughC;27O|?rgp#z)51*z9Wh*P!iadjY9rS0w>+;W6IOA0(HY+?2a z=-#LnPyWorlW4m!fs7HZy7WXQix3;E62%NJZ0&{H=>fn#J1ei$cIa(Jc-ep5nF} zl1#{GEoP1A4WIi^NsI;P>!+TKgj0(qf<1%q)Zz{Y2}P|T(SY-3DTvQQAUL0=RCv!& z$cs?Nu*9=m>UU$~c)Jr-ml8?^#XC5cvrDd&S1(jH&jb1wCx(QR(x|MQ(YE zOA#)7&V*1)j=>-M$g|2+e8~JSMB)5lz==-=HrbsV#ImbQz$S$OJuZROIW+kvJX7;8 z+wdvMnT_-ViRbRz9d|0IIja*TZH==GnXSm9fskO)U?cx z4-qReSK+)=@4l`ZNB;hd)n zNrUoIQNi_>WO_v6&-JT}FJKQY7RHo&y$p>{5^p@Q_R5MZggE#eBm204Cj8 z>8YMf$f}r)F+DF%8m`Y@xnEt_xWuRxDHm>XKX z%8~MPH8XVDB>pV6KT4-L&D#_5arptnIVgQCeZhkO#(w~l?SlAV(_NZWCz;)rXv)x$ zBUPW=PSD6D@7$vXl+Y|NVu#iP$J#Ujpvs zhjhGAmvoF{Atd=4VOeXqb!=S2ybLww9UnD`yHk$+GP?aZ`ak(cke_PxcD}tuI^2C& zq#4q8Y|xI+EKf7i)V0-luJw3`c5&|rOwIy2uxI?~ue+{5Vq%tP7wG6N**K9V%FY8r ze-xacGNBiO$1%}NrqO2}Bb>}uvsvA!n*G8E)n|ET%R&8$#IeLkcQ&AnoZS&kBv~Gj z$k;v%-vjpn@_aXjDUS6cO_faimEA|ir0;dyQp)JKRlZf?SudP9*I1U#zPwV#q2t@d zH`Ua+I&YDpOoTT%&@S2#@<|FnQCPi{7%mPLe}Lvhc63bJFVpkwfI?LpV-Vi# z&>IATDlyJyoJd1=RviYEQVOC!Pj+d>vCY@0S#s~B$=%HV`|Pu3njTjbG;pKeJm}(H zT9X9H{Q_0fYW}~rpKnWjY`!*=$Aq=of;e=jPIj4`F$#)Krqy?h%52PaQ3svJW}cM- z*~7|QH~Kspk{B%2HxEU`J4J=&dFdga{V5~Gp*>K_Q71LmeV)WLi1 zz&(Ok{8prABiuP?2Gn<}9|4>7lhNV88a>&dRU8|%*k*qBK+Eg;9KQ~-(!cH5(T!J) zE_1DI>4N2LZ8?_HaOyRg9(SsgQaUCmJF9JI2)D#{^oANj!P!!aOIM)89&s+Jn%-?Z ziLHOH3>x5nJ=~pJCMlxtqm$`F@!ashs~z$e@>Pv~{Rh|F$jSf$gO8pK16s)v4N3!C z3p+>PdBz187SrHX4ve{TyKM3^q^BZ+fCv)Qb0Kz$Ls<200WS#p?Qh>uZ_Zb5e%5B6 z3$KOd#LJEA2a191TT-oCK2I~O==J|VzVK8ynuSzN#4N)5R`>uB0zCh1SNQ4pRb*`ihwG-%3<4*j-F;iKtvD}7>c-o5?2H7Lx^NcB zIvKzA12iXAQPDn&u7~mVJl}qAI|_$?BHqw`wneOJZEedE{gip_Q++1kYN^zW*It94 z#tfYfL`MI2bD#--1{j7)bZxw}e>o_@uZ(t5ptP7|yKPGfG#R#E$}%2BDAD*!2|+Zz zFe^IB;6~gn>duK3&5JBRGOncYlekL?j^^mVO8OP4;x`7e&-)ZN!m0>P zdfo(c%YFl{jyXN6GN|tAqd4Qq0GdLqm-t}c{VzEXx{`ISBbU$Qu&=_0o5R=O5PB&F z{}G^Cm|zwwSBr3ib=k#ai@I!}qMI9EzA3EmvmKzmjkt64#FkSZE%=AK2sH(=|s9|MkD>7EA5PLm;-rG_v z(V|P%-lFVNXt^+VCG?An?SVspf>LeeetvY-j|ZL0hqT|zZaBT;sC?CL$9#Y(nXYA6 zNJxmX?-OKfnpBQ&h3EAtRg>Ls1KCHuCy!xcICik#9`z~|KhAqInHRs}Rgh+-HTEvH0v$?|M`RQy;DuqfS z?s3wrs6!ObRhg$Wj&hnJIVejnb`{G9)g?5i=e^iuoF;eGS1-F#V|)2!E0>(Uqq?4! znR-{GG&UzLAeYM%1t*?Kl)+mxX%}1wnk1_eF<5_bqf(-Q7|ocxf;o}hbmmM&Q7Z=p z#QEbT2CRHB>}p`9Z9ZTA#jBO^@qU5mcwv3RDfvwv8_S0m8t9)?MCJwv@(zx6g6NvU zMXNy8K{oGFKl8<90rPCch$^C;_4-Q16|``URZ`#7Mj`(dzlx4pxt-+>bWELE_NJsc zy#oGNViC(Yp9^HiF?unKmwL2BEKdFh^28)d2&q@Y(~lG5Z(C4O2NXmIM!DXEYie|N zD|9oQXPY6^Obvk$#~fUy3Ga-)Q0~oTmaY6SNK>n#;ea#GspBybEC}y0g+tyIKhJkg zUa2CuXS89H;*>Zmum2Wf>PB8L)7R}ThMJZjMhxsF40|}?|r3dg?>wwRyi1-R> z?xI@B-U}RtFJ_4iF(Dd_YJznYH7MDGN=uE5^QKj>B{*N#IYR@{TV$6b4|>s_n!H3Z zoQWnM<@w3g2Pt0PG2tU`?8$Vo}~s zGo2fZ^FTZ7%zyM^V3D(z4#Cr`*F9Yg4X9EAry(6G5diCnNXJV|<($400++Uh2 z-YL%s`m{H16oU>cYzVHMQ_1j)j?lCYka4;3IJQTLRIV2}F?L<1xI{jk22{aGzXtz&VUB2Z{{F0+yJg~ysIeS=D zd%&h!Ee*t4-m&`xQRDAWszRL%?IEzZhftQmf1uOgCwo+>jh?y-+j*XY#jFf2K#@gU z3PY}_id%f}_c$r;GTEm0Z{|vBRkVh{v~P=wqs5WU7{oVBA#=nygxUW|7DfgWgk5sK zOE1R+#zT&Vsi!eKUHpDgX5=9k@{biM#^5yVAs!ICi_;=AvP+z{+gx#}=%UC$2wb=C zNEluA`@tQJlWLT~_Am-i)V+JGM3t?iUBLbayh#L=?FbCPo!RN)p|+BP`V7F+3>CfR z#RqO@<~qz*Uy;>(t7&=#^LA}B`NlJfdP*Pta(tbebIXPal#ffBM=iqr{24Ke6Qd!v z)r3kx?T3r6stxZl#ve4Bx#ifmxx$RaRiCST`mVR~EaXB-MyD1*e!4B|N(Rcp{A&Kt znaJF`f2FIA!MpRmGPK*~CjnJi>M2Cx^8VLPY4wX>QP{VUtZk5~Sln~+Zikv_3-fsf z#Hv;(fSBjImOjqp`KA%YWWzZd{wq7~-!8eBI+_eufRvTe_EPf;~|p=QGY< zB-;RES-dX9C>kHpi7Ydqe;YtxdjdO1O*G}#TL^w1Z_anWxF5$Aff6xC%404pQB$@D z&4N-t12TS-TWle|AvG5MHFee_GOe^w525x`dEIO4^)Is@ewFen?(MvX;j&la-!RKL zth}avp)fBk+`-w&3G#U65A5={kM>!X!j1(I@mcP7Gas{7!G81pY&vXg3~4xYn8f|{ zq4q)f7#w-@qW2<(n4vM*w+Z+v>L}$~V*oQ(hondnuZS3{HeHGAKsv^|eeHaZ9d`+8`@rHrLSxhH6N;#_sv{H#Z_BA#;0VoM-1QGsy5v~)<<=l$3DY9bW4Q=E zGNY$pyo*!2c&VroX!emQIULD7cZd~rrW#0aQM>eS_agZx(fY>s%-Q;Nnkyk{!b8ab z_Yg9w3oV-ps?;{j2Ldzr1&Jf4pHtFxoyv zmBXq}u6z0@(BUVF)p*IK(bYkvVLsj_3Jk^g{{wwrx=Vldf3#anw#rUf;h7Ckmq@~K zV|;MQaQ-gV2NkvIu{&I+sdfFB(+BJ1W)w7z7#%@y_3m42&U{!?KK7TI-}iD;{7UR# z>rAajDxsLF@fWW&fL#hC2&+_|-0(dodQmP*&oz8ID)ID-3!j<8wbvYmZUD*k0Wj_d ztm(HIum=|5c6z?+GPI@7K!NJ@55(R9*FP!_Apoc-8N7{OfDMJE}Ah|NhgYNGSs7DvSrYMWcKF z*CZ{9s1F7pz{QOunoKlVbBG(7(f`8dh8o|_ytDYru;{9ulD^Bu*0JFY&xI6kf`P-B z2gb(ehY3;3PgLWS=x@M&ToN=mHI|||#1KML>DAEc?lw#hiS4F~`Kq24Z($9>zHF{#g8?xx(IYnfxRZ*L5$faBi)IMrHW0e+-Pe+$&`vJ=3f3~rha zwJ7Ms*v~;H2yvNw=J;6?>Z%gx2b7oCBOn}5TmK1~ZA!UV+gjxW$h^9`I^SluH>1DP zC=PE?wwV2iX{sYB1JAl+4(z=rMAM*WgCDu4Vc1~TsY6a=n>&=C)%mRH!I;!%disR1 zNc|w!q~1mx6g%)YCG!&$$re7TBr`!eP&OzE32!-e@OjZICm46P-(kJnPIg>Q!)iUQ z9$^z2uhF~`xV=8}O{MK^B&!)u($A7x%Q~ppZ2ia9_Yu}Do%}P?0e`~baCpx*0}V=Z z@riL93%&SZ)kKuPFj%@851b<1NY0r!a}?C3XFlfHv=3k{ z-tk0DnqvMlyYj2EE>j&QMNJlcEua^W_wv2r`UW=V4J>rL4deXbi2!U~;*^0{oFtIO zDj8$$+>C3`6M}lq7K5L83U`RI8&2I z1LnryKLER|1Gqvm=Tpu9pS`EMMC0rlsILJ9D24>35V$PYt&^k4;-$3<)Z;;<>|B6k zbCPpzzp`)WR+lEGVd`hRE-B^r;JDcr$(B4DF&tIen07$|kK5(OG(Dd0$ECVofL&gu zMUV|r$v@BOor%Jqh$!&&&84jx&!Xk4i+TMtT2vvbqjh(JDqVU4kCbl(T%YF?@^YM) z`=V3L`Wz9wZw-A$IaR3k~Vv^}H6Qw|PI$ zUcQEY@~3sZ^e*=8mt*i<_@ku&2Z9n!B>TjM=0*ie52jL7hmb0izv+B|f-5DcsalEu`-2BYW98ez zyl&m(*I!hYfG6Y)@393FQo@zeaHqSj(&Vy_66i0xl*g(h1ZX#AWTsGPl1jXTsU^GL z-`d=uPq(89olm9*kQZA%Pw7GMBzrS`u&oZPD7G9@pg_-cU7^PV2#zYJN#or(Qfs9c z4@gjDaJ$kJL#0J|_epesNT1scdI`-(U5SOJf}SHNyY~=IP7yKBb$|aVeDTsDuuPz|u4(fO-rjm2X)NxY#7^D$d1Q_y3TKUd-5xvHMj{>=~pQN869`3G@l1u4=8a^5YAy-ra>I=KqDB- zNZ{U=2!l<=$PCnRIzz;zsUaV4GI*%<S&+lR?sT= z{5BtdGkW$>=TyZTpT+Tyjox~d7iFALT3_ZX@4k{9YZ9gLeYf@^mQVQ2-Qh*XnteS2H5z{0a8OK@k6-1Y$`+GqKrHaV z<-4~NL05ygsD2MbiC|R?O^=s}ESDTesk>%)cp`=ujK^hXz{Re4*hCwx zOMzNdNma61V2AYU7n<#Wyuema7QL+8-a|{DOfB}2i z3<3H_I0T-JX_84iu;iwC_u>Tff>#tK!?`YUXvf{IxUHC0>O1YSol)k#9pn2pDEuTp ztp?3jcB1wo=~8Y?cDsQHfPU_D0W~QGt1Lx{!Ma=>XF#orrHBl5DbMPK1yI;5Tgfp{ z5%W2bRhegck4wM*aBJRhvv?M>%(5ac+>fIqZG@@U@?wQKRXw``Mz0qU8%qsVy z%6L(0>BoBd&zR<>7)?}DWJBbijz+D~%?<74DCg|Y)wjhgxxV2PpyM^t777Cuf8M{_ zLrwqKg?nRDzzu*w!~gf${4OiL?)yK`5rQH?QY9C`Bx$Pihj0;SMsG~<)6(fKDTw$L zihlOvr_2dp=&Ub{^g+1q$ZO8)pY}%pOY-5UUkQ5LzE2R=tIWS3Q9HYiX z&RYI`ff)i%EatRo;q2#Lz6t1E<+}fmqI2nNFcviQi_u%JF` z?>$B7kNn?|%F)c}wiQYnaC@^jzTmirKB%3E#;Eg0PiNRy2qgd~$f13``c3buDM6`< zv&8hiiUg_VA^kDGjkCz!(AjJfA0mo(G-8R?O#fdnOD|JM~< zF<*GM!Yr}4cAbQ>bd(Oo7}&Ed|6M@NJ!<(S_n{baFOJYzk>b93>GJ~qwfm;Yk@W%4 z;$oXP?dgkvX3#Z%9PDpOwx}sBkDcrA{O(aHD7~59B3^o7v8H7;uPfHQz{8Gd10HkN zUcrlGCHNkYS9gt08hXMLqMNF+E*v}m<)JMJy=s8eH6)U=cu#o`PVEH_f<{N&u(k(q z!^%zg%LrqXLyT2Pe$LVlfIeWgWM_eYUT6H>9gotC>dne(bd)*}TW?;Ei{^+DKW&Sy zSYJ6oZPNEd4iaa8BSX~X4eqRhrYqP!fOKM)~$;{nfwWAVn5V&Ilj0vv6PwC=`5Ts_FA8)L4% zjt#K(e{KAu->VKthQ63O4?sTe-kfqye+7Kjx5XEQ{|jp1dS&*{>ph`2>qlbjF%JNr z?h!fz02%BBs;FWi9<$G{?bv>zd8e6xZ)9mmBH8=(kIE zFu<7q32^@QU>)ly^zik(a`nR%rMGU^o{)&uRkuv5juiL)e==TZ69KeJ<1&Euaf8bB z>Gew?!N0P=l4_S1C;1QjN|p|vp9-fpMe%v=AH;vr`|e&WnZk-!^0^c;ck7*8lb274 zwP@c5TDyn1Go;b8>)a9i%%hH*S){xV4r#SWNzc2bk(fWV?`16=7H2%S+O9xte&Bn8 zJeMFD74udRwcGbHK0LO92fIW{96ei5+Ft?QW&pPa7wwoCHfeiqW_?w0b9u(NH5xr; z^KNgS{%h(Sumidr?;3ff3qInrwv+WrvC>o8Ik>mvPXxPSw5rt2vpu2WNo*`mG0Gs> z>gh^$2t0wXlX%MU`6zVp|6xV;W8PBlC;2yk0`9eq{pM&M6&c{mNS?g38Q7DKDsk_9 zLdo~Uiw^jt+^??d|5`nCY+Sq~TVx)L`axZ-Xu)gAKS(}xuVp3&zb%*T)a?n`Q5WwKa15#gLFQKpo(@2Wl=7Jyg{U%-TbZEflqLi$M#-;f zIwO>61C?1X%kzc8ibm%zumPzJYGrD!&+M<3BnAq3!KUOoSIUoXlVt*0}VUx-g;# zv^)d=W+DG%HAIle_2To)rWGv)npj=N9EUc`a9}UQ6sS1`CF?~US8Ql zJstbC9;ZLDn#2DGqR}GLT}XP8V-1-x=XJ-lFKzjTZ;#+#Ob6@;oKdM{(^!&LI+>J9 z8F!ss(Xo4d_hx_863!9oMUUauR7p(d?@>j}#X?lPrel{Bh0@^zF(a*^A}S6p7+VW# zt3_p+r(~z0ZIG8LUrC#fV&7h6x^XeSmiBGD#AtlAS=@U`$$NkLB(w>{(&}-CtBOS= zSaXDz3WtN25M&!VUq#xDhfEJa3`GD`#y^cWV!9(+4avn0njRO_OGj>n%&RMwgfkMV zn~E=8Dwecx&81oB_sxdgbs3!4bzk;8Q@L$tJ~S4*T3%1GT1=UZA&BY0^VmU92h?zK zt4ur0-#u3bFVhMVf#I22<+47DEFoGw##{I*zi>2ZK$@UDHUaHz`3oR&fwyIh#t0*M z3gkpjjDFfKj6s?7k4_QlXx9D{G}9ugt@W@)Re#u2L3yJ!Vo_qEvXau%Vh&AW)Ak2{ zr94wTXcruKao#uN@PDA)aueC}QI1rMv@Q^##RaRLps?=$J=bmJmPS(imG(qZ$RaUe z7v4!0k1coX5ld+T+(e7az@IUIfWn%0CZ=s|2!QM%rNx zu~<|2BE~DGst3nkxCwAo-!RnTNM>F_4l{y^k}=avj%#D_)@8*S9|jy3oeD=AZ$?d!>S3J_ONO<6HewlF{B20u+TC z@~Adp)1^a%nYlH9UQnX_XMwPxcWQsP<}qqdcb9u~$v z#v`GR6`}L6>G?al;z=Qt?+BsaJi@4qAB8w(S&)zn1INv@@Wkmns)=I9Wl~;$B(5&5 zy>5N;@Th*GG}1a;T*n}SW>9jXRI99hFQiY?M1}<9)mGb~D69T-8XI1J&vaaK0aR6pDL*`0YNmhS7n@q9z0^%n9|E_IUX7yT1kt$mu6S z?Kf_7olFM#qbl6%X_Lc-H`BT;M=fhTvuTxEjLfzFfl_S&w_t7dT9$khzWDJgz%1;*XH3C0lTJYzVOPHuhY1&QtG0_WB3R8UGgh$Z?5Q+vEhLpH!y(n;T{Ty*_FMP58=&eUZ*w{q4?SwVOD8qKjd7Pb= zq!`eufjABop_Lltef=<}z>-|f^tz<4sU74;searB`Cd1vx27S26S6xw+aw^Z*(!$2 zIiX5IE*4X|;w&{mSvH59g0FD{YsJ+2p30+Elt{tTFz-?qZN*0C$Y|i$A=Pnm#RKI? zC^5cl{sM}-uorS_qRS7$^Jq~X@M={`GYR;!sjcbS(Ls0(mT^ZFQw5W`Urk!hhM9y? z`|H<+5vs~g8w6Ny4?8R#w~wGq>~^S2gO2=~_!bnh&nbrI>{$^z_lG8p#_S_8ZFw z{)mW+oe{xiiy)bdfXLQ1@V_~x126UZTgdOdd(JIQ_P<2P^yPoSAj@h5`O&t($Q|(Y zgop+|yYtyd!|$WYimrWXn8il>w3^fMzo+LeV7jF%-0pY!=NrNyq^?k%fYRKG`eMee zTHirnfdF_K+>-B@cDHKau5o;mb+~9t4J|^O>V4e)u4F*$iewK|TfYVu10IHI zpy(H^fBZH-W!`#NBK*jQo^a`33%woP+8HG0tULJ;7Ss4R;?8h4VI_7W+WJ3tkqw{Y zOCpwol55XD%9qs-zpmha*<9`|)0k2tpCOC=;biuh_z{TD&h4C(1SS?VTpheUHMT!n?s~-4WHzD3H62mk(X|9oA}(o;j^TceVg!k%4L5VoYD=Yh6fwr$ zyS@WHiB_Fq-T`&5>R_HOAsATGr}BvIxvHg)7fnOt5#z+YZq?a2mP3ZZ`72pnHNHHD ziM>2vn+4Xe-gIOuD!F4RhnX3u+kpJ+EuwmZR()nN?{%lPw0>A23c2H}% z^*&w9Tswh&C+0jfRmAURgK^z>SyHf|F@%g1RtQhQXX)aLi9$%`{v4ntnG$= zeww)}7lL{mCsRFFhKICHkkj6)t7+@P0Q@UELsIvjqF$Z!+!%uoKQv9NY3IK3R3z2q zj9V%rR$F=C7;ep0?T~-GEnjy^FH*#2v7whbnV3{0WYkRu;@xnwz0dH2*wTXT6w{%@>~g&8D*cGbvn%@~I~EIk&~zKD(DT za$)XbW|xTJ6TgbQf@gc@cC~H~`Uq@2>6rz!LtlVO41gcStqMpQ+p?0!&uhT>*YsMA z?Y?XdB-?sd`&8;wYv^}D{V#&Z;uy5-7rvgplw{n_-Ol!=pH1|*u%O4oC1;nH6LK7H zmSrg_N*4N1gDRqesJ2Ri%!8goQ|d354nyl{k^TRD{rI~n7RE$EBHB3+`yzs$AQXr^ zV@7wt;RhJ`4->|0Tm@k`?XW=pEJfLV>UgG%!h<@RfIiQ)kS5^)-rMdmLcgk4aSkad z7{beEXN50TflGX%xBA@oeNHS>o$&eMwMdDy zyM`)Zkhc3)+(V!ncdn^N2g-|!PoXAhtCs7(8y#oU(cot12eIG@sc_X$ane`b&c<5s zY&(6pbQlYUIiNQXJ>umPW#N|M9u@rxHFLopi@BvUpF6pS>R0+}%6!Z#f;|n~ov`a8 ze;)NZ-J-ye6eGD99e#LxoA$J;`HdD4M*K^N3gE7BN+PW7BH&;Sj~Ekz3UBN%TId1E zM1ziop7$dci~F`%Aja9WjWk%!=%{^!=Nb7Tkunl#q)}Q4u2HJ<#E1n8uD+lEdNb(e!?KIK8n4T4rvDI+|$|l|3RDZCSWe1 zsh_eh11ZAMF@>*gSv3#q2V>|uNHAs|jQ8c{W>?u9 z2073ns%sSGKbk7O;~))}ab;ZAr?)CwAuMa#hQnyAh67)EiL6`?liIjLM3p#(XZfR* z2|-)TdU2Q}nOQ-aCE0iS?PgnCxUE_26P2M*L>v_lj8>&Rb>@t6#A?u~F$6dkEWoDQ z4!V$$WK)AR#h(SGVALL}9!(3Ty zBUUAnNj`r2KbtQz7&xbd?w64ES*&v79S!94c_zD$fLMAmp6N#zZ^kzQD-O@C#S&i| z-vjD7y9Z-x)$X5v)}(puwQwrzG6U5LMzEe|xhM_p%J4XEv2Wrft~^yRY>3Y&R2WKM zBbcLt{ToPwiv+RCf+1q@lwp{3`NbeXdZ`wr8;y26@I}C9SItFS{n|J zqhQcQX}=gl8>}Af|Fh(BJw_n70Fn%f>@znz%bpxt0hq{Gz2I>4W-?8sME{-O;po_u z#Pf+=tNLQk`U{d4bv}Itwk4~%rOtta>RR|s$%ti1nXL!hVbGe}^9Uq#GX~jXq&a8I zt=nOCNdEzi4oJpiL&znB-&#`7kW41N+&b_8TO;n3i>C7_@wg=M)aTy97iG1>_C@e@ zz=Lc>R{ZuNJX>#nTkm^)gH^<|~PoTSu-A4=3F=(u_bWejgxt)FT)yuD?pKMvCCfb#atKBYEjAe#{LdF#@&?tmS;gyDQ2Z8%Yt|0#W;h)dHw-tadnO>^P66%S00BO!fQ-=9v3S zkYMw>Auf1nq#aNVCro574x0|p1Mqv{L8G0E0gVQh@}*TpMk`KUh<}Oqn>v=8oi^KG zT-h*2*L*kn&ZmdIVSkM^vD$J2JEu1^SfRsBEUXUam152=_a?WAu*8y)6nV+eRScP> zqg4}la*lIuxBby0h>Di#xffFp%xFt%6{GOtk|Nj3RDH=Y*ccGV;V7@KpoX93Cf`QF z)>yfX^X=Co>W^a4$Myd1D;mh>bj*|UOn}lc(HBgQ@io*KK`bb3gz@6e>r%XRqGGmg zPd+P!CDjGM(HDCBs9S4iS?AJlQ+$&N3l)v!dY0nw(VQcRM^_h}u#vV|o>P&$BtmhA zE~00MpOqeq(MEPq?3$|>fw>%CBB#OC>%$v@r}bje)A$#OcJY8SYgqLXT~#JV^UUcp zkRBr$fA$v+=j4D7TU8DVyixi1T_wrA(C%<~J5s)(e!tp7>}0Qgb+*4}Og*hmN4b-*bxgczO&zH&X0zNjg9r3^Vc)CN^6%4f>_J6)V;7COH6MC=>7 z{QMX18x7S5fk%Q78k+%!5e>-UI`xfk!+Nu-%co+M1G~}C;@G#Yit}V8n2EjHhWwo} zO?X0}5Lk4;M90qyHdH*%-Y8#TGHjr1N$^fDuc@*2Ax?Cnp+!ZrK}DpL1FKQxiNl>T zPoLzl9FgZBKe&u2_-`K5JTjM`X>weTG@3f32AtWMGhUeEVOPYc%1dm}h?K?0)! zWgyV}ckfJPCh~;AYyLC3HoHAMU=~%mT|qKqsBKjG%MW2T7Lmb3Uv0l6w#X_lnA6kS zh}%~Wj6f`aAB1hb>|~(gUretMYpG=xHYLMLlcmnNl~;|O_Lee>D!ih@j+XOiZs=r5 z?z7K1G2{NKGK(klHX%uO{tpcLx)U8Qm%Q!B&)=#OCAO2+Jqd#>^&Xo4^rK*=KRp!VPJ zJNVV|gCT8f-y)K<1i59OxB@fLX-42Y0V2$4?oZ$!TlP{aTrHTe>M~BxthxQn!z4IS zdpJ9y(SI>F?Y&FK)u5(-A;J!Ulp~lFl&LjbkOx_is4RnQ7su$wchFfv1&l(Wj>)wu z=#Lb*2oe~@3f3J8#<+q4kr(7$1$t!2fJ;#soxH=UD2ze2Jat#7AE=qI^`=Iy_8sr7 zPMi?llqJe%xQlyN%-Y)bjM;lB4Spgq8_i97^>AzMvk>e~LRx*VX8&Y?sINmeSMg+Y z5NmRLN8#MvJHF?)cm&6Hg{2;HGo$GQXlw69NDSNA=hH`y`HSQQ;2b327Gh-)rm7a? zRk%A?uRQgYN64;Ya?$eNg#X0Y*xvGR`?th$KYq}bHG~0@>yb9)r9lG)?k|*BgTfpt zeHG^82xmfibdktAO#IyzZa?Zc4;^sffo)L*jRVvNuCQ5OH3xsqYl+Yw?I3PYyGn`n z8IFXzKEZ|odeK3{B>P75dM3wZ@rr(9^NN}tx^4Aj{&3d1{`qH%iIVibsqRv0 zQSy0deRM23mb?H`tp){&j9+LunA;kTPpqY-0>50Y?4)pPWK+SdkF!ZBIPRD*SIpT% z)IW&_SMt;r5v)ma05_)+9I+#CD@_mwh}_gx0}TvR_rk30A6r?z3=6xtnjKLxTx+p7 z*w|<^OCid@7&y8JWVzK}V@>0_<^($OuUQ`IdXK-+J*=!L)R7;8q(D&%byq_Y6F;B% z!G=(-MgWo~epW};>9EIjI6C9pu_aPf8Y%3MT!Eppzx9*e@O!_s^-#0eASkM5tzIqIV?^KJZYSO`g6PWK}r;OCX4OcX=xgg66)D5W1%mB{K|$3T+6zY5&SNt0<^UkMozK zz~KYvt+1fH{C>~Bl{Id~UJ?sand{DFy&ZHhWjn*Q28qwa;qcYUiM<=4eps`gscjF# z16UnJl9-k|=j)w2)K*?0OhW7*BFGd{r^XREIkDdAFeDg1 z1asW{u~8Icr?Q!t{QB9eVaI;FaDXg z%nz1+=v){GY&`2RtanlSPmSyQv8Bbl#FY%k&^NE=ZM6*RsFG^|tJm47%RfLsu6L+SG@)PcjY)6pqnhjirgSc_7a1~B$C`DMvL0Xw`@3YnYKm&`w z@^FIZKc6X2*E{|7*tsC{@`i4jL7>$_|Dv;ti>|>>2~xf)sG}X4S>5AX%~Hz0XqJU@ z#LM)uO;7+pw+hoQ1|uw^?yn6UgMjWJ7L4SYrXBX$Y%<*9)XuSFX~pB&3@#cOo#lsg z@ULfldX!uC4)fm2BrLdba&K|u<;S7ZfExMI;If8#U}s!d`R>*gzh8-FjMyYLxg;=h zo+Gr0o?_)U?q%bEgEEIfG1Vftfc73Hv$?OHrd?j0sbgnB-vQjBP6PPfNGs|QU$ZxS zEr!KgC3eksJkf@!1C+!i_0-s)aR0rY?NN=V*M7tD7y8E`Og}crzy;qGTwh$)XKieF zrrMihHiQB~t?+1#I70VU(^UUpm>ltHk6$9yZ#T`;^4ky~UMiw}6w(A~gW*u??NA{? z#Pnmpw>r;?*K74foqz?5q(k5rc0Iyt{DwNlD8!C?#fr_|rKa(h;YlSjnBxxr$W zx)ZOuqr^WT;jq}%UXp;Zh}ft|9+$a0Q@a@i(E*1;DDyoCs4KK==5qzpez+^V0W`l5 z`Py;G|Hgc9C#c8ZLdwD6X~eYSP=)DjQD>f7168TWwbeeweK+SjQP*=)y!XOM*#-?` zIcbS`&F7q4eVAU02=6g|Ley}mUeMWYL+GS6$k$gJf&|5m#8+%5ojunvKYacNzi9$LV}!jyf%NYJ9Zf3pU)#RU zziV%jz<&;3W5me;5-y>8erQKkD4^!?kKA7GJ%vW{3<+?U`0d*rx=&6~D<>ZL{`q-9 zU(CO(ygZn=+ECg@D(=D>*Wqr1OIf~%g+Vz}$Q1jiZ(q4c7)?5sjm>B+jH#VW!)K@9 zARo?ivkNnawz3%uZ)mft;dWRMxnhlh?C`B|rq1Kh1!E9b7cS1&%HBL@@<-V7;^VqX z^OfVHrOBA>tB2v;i&NTb0A^uwH}aA6XLx*!tOm^S=QV#O5y0{`9msg{&Yawi5ZFI6 zPk_52XicCPO;C)+U1Ohn_twZF2pH6X7q?|({5*Mpk@1O%zmr=5$IQOighZ&zR?4#Z z%24lmycB=FN32{FvJIq~HU4>-Sc26D?9qkpC8c)nZI3G#?6$YJnTun%O?N2}#}sS` zjKN@*)se6L^pApA9FC8VlflaMy58Xi^>CT7#_57Cv>}jW)%L~S^V~cL@3^8b%A_;@ zKC`$nxYg?Dz)15zz^}q2&Zb~!nUD%JsqWGWr|^Nf7W9-wNsGcte3nE}NO+wHo$4O_}eY!hcHvsD+;jp3p@GSLD3i?caRI& z`AtUKzz|{RcAOra`f~*YnK0UKC|HF1yjnM!3N{O`H*!}uT^S3vHzOFh2k(nt|0S(S zXTiwo5L9+4gv|grpP07Hu(|?|Fd)QCo>Ga?XktJ!QbjDpcMuQ?auFlTty9h5e(~P@ zvfj>uue5(;sLG+=Fu%ZJ%gx=ONMhNbwK>Vh%+q#k|HdA1o!WUQK1J}7D6j0eej`4p zmx;p|!4exAn@USgZUaGm>-BmuU2`2A$YU`7lD}^=g8c8521^j@k9%NbkCH%@3cVyO z#$OwuTCjw6iayKw3IJ*(p;nWV$zTCl@h2_t%Y#J~=CtWSfDu~SkP%jq624apWX8EF zhB_z0mgmg2291=x{=Gr4Kp?_&kcsFnwrr;-|Jz^Sy7HzxvJa5#?y$r>`D<+dd<(JU)j}xVGgOu6qnYn+mlKQY*G=Nz zVTSr>I&e?h-KokwfB-t9i(zEh{!VEF?nO#TGW-6%(sC(Gi8xn&#x(8%nMI&uLCJw> zM5g$4tkz|a;8X}R5AgUxjWe}MBmaGWqRO$|s+5uj=@0>{U_K$q3~$6OQh2RsU+YkP zYGy@cL)fn8LyMXzYg^0F2=#M|x%>T#6CTcQ#$!hW2>|$u6}=TQ&FKnOxtgT$dxr(a zi}qIvTVcl)V1#ncg1fKGZ3Cq?%CejGkD^NN8>*xCgoY zDcG+`ohU9#(lW}ipha^$ zkK9F1hO-T6I>i;oPj1PNdFv5^azpB$uML)xB3JfY&-fOfHMhwJo!4RI1I4$uKW$=# zI>eHT^!OP`)z5m?taQe8ML)_=CRnk)dTY{h1G`}igLwg5Nv{%j6+%SCYlX*wr`{j4 zBn?^!+NL~kjp8;6oyXxY_0Jxa8;y+?N3TxiPtbz<>i6C9^&cgc&&}!O14)Zdh{CbG zMnj+Snj6nhF}_H0n;O1K1P_>~b?!;gbi4Waf8_%hkw{brSY>AcJgiC+_z`7Ww?okg zq1efW<0m{qu5ra7gyVbBYuCbe;dw}J@Pj0{OIGfVo&HIn?fNoGJS@Q0mS}FMP+{&t z+x0kdQ-0Y*7aV6KfgnrT%6kji8aG#1{s;%cIJv(9{2pUM^asW7OR@qz$2%gobU5QuA>x0f3XpPP@9 zt6)O=vMt3DIz&_$Yij?`5O^%JZlHvbH=u0cN ztUeY@0QrVM1iywb1{JHjT){%pa2O|Vo1K5C*vJC=>8{3h1OWt1rceps?=VN!iyELX z94@<{cJXAEaOe40SQx2z-P497SiF$BN-(gscAjP$4a>XSvL{5`(GAUkT0I z3+TxQ7eiFfw#uP1X?hdxkyZ=ohK)Rui?up=du4qM7G3j|HR}ya^Sx0YhiB&5U+k^3>N*gRYU)k>Cu1mC>>5)OCL#m0gLY$JXZvtTHac! zf|tl8K4jBTaCF3F)$u$Uj>Qv@CaA1?e&biyQ;h>H1E|CzQl#QqX@SM~Q0BycMb$Q` zpTi`c6^$(yTCXo8+HCN=Bu)v0yw=(vy{agz_pu!&p0gF#>tAsQf8TxYbj|H=Wo6tM z(Mdg+^8>Cg4z*8%%T9An2eQh+aX_hA>U9=J#I=Iup$6fT>^fT?znR82m1(HP(I_D| zSuG#I^uNm?WEqO+z^Zt(8#Xa)@3D>6dFojA9MGrqIQaMdw8u`Z{2=r1#5tYe2(ujb z{f!V`&)#dr>Ux7x#rQFarxLFyN^^a7#^nip$8AUuZZ3pALrDOtVQp5MQ;EbAt>2UI zO}^zRkD9z}Wn=j=-RU+?%`IUfG4c83v`xNOlaq*q8publZCH z&VS`^lW4ZXUB5L@DEMR`1C^0_bIBe{ec^~0*pu-uJMGS@;2M}Tb)ji*C~E~GZ#=$( zSL+jTD~=by>fNZo>K0Q{1n;GwPp)q$|8BVQdQW+SEjika)VP5??X^F@t1Efg60Kl0 zkr-dc6`DP6^E|1joTl*2+yH^q67Bl+H|;F6YsT+iNJUbhu+D)0S6_0TvualPggHX|7CWeswR3Yd*(h`uD#_eMm&)@LW<*@6RR%t z6F=GI70Yef5m90bxq7y-)a)_VQ!YVV9O)jP3!CZpmQ*l0DwUwDm2x65c~@lrJ{Q&Z zD9dk>;=gip+~=`+YrltIG!*J#tV2)~G0^d99Gm%jWk#(*5KQ{C8yU5x>%=wBRySDU zt=QS~0NsD(qIgH;BVPE&;+xy{=R6d@Iy+WfKBGC0o5vXW`eB2)fjbx@atc_?p1}<3 zpM0s)@gj8avR4>$cvyH|qTFbTEI5cZZm1##CAm^wQ#5^@T$08sXm7Qy66PgV|9o?? z3G`+{(uFs1g3QwwhCwlBktd&HBgfO3!k6#KR+I)!e4euywB6lRk5~I}&>7OmTZMo0 zNFM-C-&DH7A9c$VOe#e;^_}NGf)b-ooo){R^o1E4PtHxJRs!V@VGL2 ziQUV`83v*Ssj(x4Wtvk3KxE3syWOtvp?6FD&vY+zpCd)5S_a=nuzYa{bH%@AuBN=* z%Zqe1H~tV=><~a-zcbX{6&oX0Z83LlzXNf{Fw)sos^q?Ht_3%cX}zV-eX{eI7FC$7 zj&sR3|BD>38Y0ijr&jc1q_~v#gC2iL8>%%_mQV?iuA+Y7tdrMA8~>qw0Qs}4l#Hi| zz{T$2DnqK-MBO$_#go_>@tHYHNj%WZ-w~bXnfhxYFKW$FSYM>Q`8hWJl9kUfVM_8E z9>g+|@q57WobQna2Zeeq1cqTGeirEH3T$?jSneEJ{%?2iK5P;nraDnGYV*&jXeh(e zWn%KERNOl6y>|co%QH`6+Zh+e9nP~wbK?|T&dzN9rE3D=jx439#VN24XeQ%oY~I z8U!mBqu$yZh+9UUhHn!HuI2TIqr{ThQTv_A*tfBeYuaE3jV36!E*vlSN(2q^caFav z_3WH${&JiK62?T?uT0cja(4c!*{YD4b6#^kPshZMgj@kl$Lqd3d)-)gwI>`6QVj){ z#kMo~;YfYTB}1c@1eaZl#%Oty)XGS+ohYM;!JXy9#82#6IDZ1#A(_O2Wm|gyB)^Cd5vp={Hnua)w~+EBpr^9lSPg5 z4wp!4LQOFW-(8;b0>R>yY_O^k6VwsJH1IBbhi2hYS5z_j&y%1*{RDRocN;$NiInnM z>HoLx;mqC7;meT{r->8}yDXAtX@O{`HGS`^5=mvb7CWCpG5RO$H8Dx*Ec!lo=0YqXm15eG_eAz^X$^66b+Dw+^ex{v3uxSswkWyqE+pgW?N^-Vt=Zm z?-$a9uw!h;KV5#rGPj~tc&j7j}EF}ffeo65A{3Ad&^t#WVMxSUw){%>dd zLrVYY66qjP!r8~ZyS7gKJlE7yBxDrP0Xxs0h(4sRI|MfEwy8PV@D8I`Vz(G*M?7kH zz`2S(R}ybeR(Qe^tFRU_o4wk~#^PFnWMx>OHlnBJF#!51{n18N^QwZXz1@$Ewfq^) zyifhN*PhF#zAQ-_(IwY4IdPk@4JHU$a{6Uom~#om@QOEEw9P@@h1dvQ6&$J$>t|#e zvA1sF7^{#xs@JH*HCGqwG{^FYAR&n^Uup|o=5c}&BrGUZznu8*Sw-y<$Au!7G~FG_>jHCCj;+3Sk>cT-)07i zJ>}Q`q!xSUTjO(YKg>5tebUM2ZuAf*x+lthizqI>K-F8xAmZmKTyd!0Lguz%YLFjp zn%meWzo`YG8-MLJKP&zdVsF1Sm^*%$Z(s3PPN>QHH_=vcMyK`7TIt&JA|M)Cp>{b0 zwel;xdh*KAgS05QG~NEuM;Z1vl+)m1yuKWcbtmbk%r<(Fj(^t^qtqJ)DvmxC!|?Vm zuNtnUNc>SYb4u6&*cL7R&u=C>Z%N*(>&b(hcHT^NR5j+ZRSYS%vuyvKbqA>rJ_c{cGISbBmY**dJF@x07zVN2RMUOTe z##tam1VYsDIz-Y+Xta-Vhx4YjRvnRqE1*cl9=rS8-ufRXL}kPw?iA5Mw$bsEUB7jq z-NCc3Vtv%$QBAC9^xLbf`c6km>l^6_@t4Q78Cp-P^8Bi}`r#Qb+A7vvIqq7yH`~>e z!uGsYCn|S64cM3Y!f8gfcfIZsvi@7GOf&EOr{zHx`eigO*zCU62HW3Vk4N`^B(ref zU&m53n*dfo*zVn(G{TWeOJ0q36wHk`tD?j8CJ=uJ>7VM%U4iDEA($jid2D-uCx&wfFko5V_))gCbq`#kOZ}UD| zZctv<8M-r(|YsTS&p4e}dgiOWprKKki%&@UzZ!{f2#yMORlp z-!ZR~TK7nw@Ge*xLwF?I_SY-ET-Q;b_06KbxFl0S%~~#C`na%{nH`}ng`A#FOjmId zJljgz(=N#16ojY!3OQB}EVNxPKP>;}?xj}z)YT$)~ zba`J&RlBjEWJmZ``a0sA>&fel!)8@}y#cvfv^wt!6v?Y@`bDzUKg z{o|JhMiJ@&A7~)zmS#NhRkU2yW=5hW~TF|5WKAx^Kg{I)g4b{ZU zLwGp#@4)q;J8MK#yGK&S-@20^Yon@HnVZ-Q{MFp%6T)|GZAEVQ2FIE zA4}wphe1C>x-^on2c;Z&H(f=1OmlPj^tJi{6SmlU`!?sbed{hZGiMbW#ZNN@)VM-; zutLU%%#4~uMtW}MdZgOxR<#RVvYq>6YJ!w2ho9fO(JE~#_v~Sd^5C#1Bau9&S63Tf zKQCG%O6c$HofEh@)-&d#Zspm0(S2+2hxB&InZs^&k||f3A9rxh#~zD7S3MSy->a8? zDt*A-epIOk{eBQKo6vkGxkg+h4$Z_vTC@eXJhDDk>woWF6S-FV*ULYAihGH4&hKWU zSMXojegpB9a_y7V^}l^{KL-MN<%|8q&VBaueEB51qNQc^o_g%5dj03$VJ)xUM~KXD z>HFTDkZVp)yF8RwWDMI{Qi|yhbK>R(dn4aZJD6{a` zeD6PWJOn&*_w8zgdU$^g=&#x;dR@8SRTv&n`B|A*qw~jantn90jyqXFGn_U7O&pAO z(WK;3GCEaA268fThtJ@mZ|ui61IZoN{rO|mwU*VG55(PJ(g(qo2X6~{IBdq70t-U! zXbNUv*6$9^5uWDODO_G<{=KT4eUb)GTcv-v=9W8q7x)JoIVh8mB4_dJa4G*%JKOJq z**2>w^U`_Lu$lg5M$j~4uCaJZJ5a!bYHV5ZxH-ekQ$pELkz|sp(6|DYmJYjdA6RG91@--?`ekeOFuMu>=0qS*JLLi_on z+-IHd$PB~Ku1Mh%Wyhc3`fyu9Q=vgu5MD~iZQ+ajSJz%*-}NV-vQd?+A46l~{<*2S zZl90)QeriAw&Db{H>pM~q3gk3;-$`oWcr!~^IKX8%n5U!6Am?X2xaAyDo(L3T-fm= zW?_R3rwYp|fiY!=rTpMzF}$#h>UHh-;@Zw8I|mDnW!p6#D;thOz$H zrK5x|7-Y}d=-<#Lay#YRj%p(L%%Gs|N(1b`Z$iki@W6To;_LtJ?76@Bj=r{i;Pl&f zw!|_CDTP;71_OExr4L@_){wV!WC?L~0Y|JJPpU)Z+l@c81g+8L#HD$do!=1j4R2zD zsd}O`N?;V;_Ro4(&bJ+XN-D8)z-N2dI=9AZG5XW*@YhTlt%`v?GH6VlFAr#MJ}u9{ zn|T${)X@%RL2nPt&puQi8{-~3tZbW>iVaw{sNJ;xU3zJ}=AP)Q=B8*aQM{#w*j0Y1 zV*tb^(w^nfv(a|)DL_&LUB~pR?1gcBGzalA#*4QNe|NBGH0PaXsJs;C;#f=OZ$A+g z#UQv@-PWj!6eqJn=Gw39Z-kG9{42R#-EPY+^$U|z8$zEekG#Yly*EVOoy(1^Fj};< z8=A7Nx7=LWi=PuG!xLKG3eF>8Co6swm3i2l!E?Gd({nn%3IotWH7J4nb?5KyDf|Q& zSQ9B{ht zZ0fA1kIY*3zvmiIr8?tFeYHCByxNN@yw^uzyv@Q~mwxJP>0SGtY^yJAu4UPinwgn8 zNHupa4XigCOHHluiPV3ATgix&`cpa9vG0>xQ8T#ieV-mx`-F1?%rEQ;hU0ZQ)gHAy zz+88rtBX5A=tzEggG?N^udD*eh&WuI!Hd^Ma4qp@z5=WEgt;cB-z-o~>hkN@XJt%t zhw;=%%V1@&%FZj3i|bxH?rtm8hMM}l#YjV5i$3uQ%bX&t{$}Le;rj(s_rG?F#UWWy z6Q5OZ$#}mMG!BYKB5ppu!r#%#Bz#`3R)7X)P41bp+3+-5c{L*)!a9!+=Z=RbCsTXT z1K$RdwORO1MS6^9$h3!neDU|yoA%|F zZG&BAjrbajS~bfd20pWZrnG82)CKkmy3^YOq%ORS)_a41%nuo>1r}kF@132i#jIgt z#JZ9!3BY>YaA-wyFJU6JYJ($^^x9YkF1I%=Xo{d?JD$4}F=vrW##Qt95E$AQMddHn zn~}~|M}S^E)8jz4cl@G*vOa88@v{)9LcqMnmBO_Y1?fN5+q7r}n($`B@74g)%nPk% zLFmS0O5w6%#%0*VOr$fhKKj3S!=H_5a)R-$tig&_A8uH8xKz9?`c{AYVWGLc{NR|y zU<<6mqBh7-amw~0RRc$7#bu_Y_jT+9SFY~X)2x=gPMvg{SYDDL69ChEaooXICIu58 zsQ>KO@rZDMu)s*Gk=}fR^yIkHT*Kd#J@*40u8Ls$4Sa+(@yS|ovG>q6S@1$iySq0G zW2yPR^%iAT3z96dH8?S@F$ZUdmD@&jH?rQf6eVrgx2|972;^$EZa=+!VChil^O9V% z{eKLdWkZu~7=}SnL`pz9m68tWMx~{@RX}2l?iT5k8ahfOMk6r>jP4MSF+fHPMmHlT zAn&_hun&8-=f1A{JdZcRS-vy%>4{@3fZr0^5ap#hEVn zLKf3+k5Ra#{c?nE8DuM`{V0q_g;8eso$O=~5q0o3XQ5b{o0NjrBYcT4V-erNAcW6+ zIy3*%csB+*Ek#(|zB}3MQ{5u|c%qDd%&}q*MIV$vzV=kAG|If&gZEAbU;P7lQqdV= z1vE3IL7J^6_9^OppAub3Qk^t$57nvQKV*ccc)wjkfz*)l^mQu$^Rg{4vVY^ktP#CU zcML5=?VZCTPIsL>zj6)}g(F0@rY7dQf`|1ACgqqApQPV?VU8l;NK=(r(l^~9uyJM_ zQAiJDZ(6Q-*M4h%lD+fQnQnz1`)fF$@0iN|AnJ#S+W3$yy&&N`Ysoq5h)J`^_c~vd zcyd(1OX*mBJTB4{BS$Of^Ctrb;@cpxTPv zBS^quwNA;x%CYf=bJn;F`G_GrN~u#*(YI%@hpoKcO)j7bqEV7JA$&c5M=hkps;@$0 zofGb$-_{Nqn>*G|i8u|a^H|Lk_)z_AL_I;l=gn!j5IeqI;5@CgZ_>?NEf|R3e%_%vvd9^&hiAXOe8qtr%pKysO?{u{YWz+XY3DPEk}c zs8qy?yi5*tc#0-*Lu?Nk4k`>CN-PVgHA9uG9s-;ip>U8U@<+6Xh_Go=lA4 zmZ;S~Gn_Pbd!2G}7IxID{K)@^?L0ORLTVb1QH; z@DKl8yLkSQH$FLE5e}C_t76@Kt#OeMb}Ks}*>as}xxs)cXu$+@ z1I(Ln{)onK#u1Pz4zyZXIrbbj@8ZF8YFve!Yk-Yd+k*K&5y&SA=c8anu+;*5?4v#D zKK-|*HpEM>E*4~I+Pan@#8$(J_?!@b6=8>+%s83948hUgJeE$;7!YB;<%gZ%N3>83 za|73@W&c__`Chwy+$-j@&|`w~ESQ1r8kW#C z5x6n9$_sOe|yvpKgUYyth2 zJ^n$z9!jLXat)JyA6OL>NAw9~ORqO`wAzFek$hzDKKFZJX-R{2+W#Se<>s3-Sv$EG z6XF}bpS}byju5K=Q*it-141Rdz-bYz8**%k8?D&&9)4J~ntmUrqj5KUl9ujNs!x&k z06xKT)n3q&FK;80x{<{c6hnl{@at> zPHfBD+sdQ!DE&vyxxJB;e}-mk!*n!@L9f1u2l1M;x;_!0&j7KSd>xYravCz@qc*UR6PW&d z83{#xpuwY3sLx?3N9t#{oS1|S`N+-hY0tdEKo1^$c`0^g@Yh;z-4McqXtozWrgcP` zo&&-WO^9V7m!!c{(){MlJAdCerS#BXPh785 z`g>PoShQeosj4jjH}DAdpeoJRj&bDAmjp{(2;+|)z1TwwubEWp<9)rs8bCR;EqfifnjT+u?iTT{ z-meNkU4?X^EZI3f4@qsLyvre?Y_HKb99cbIPm`PZS;EQS?y=k>JMy=UB~51WQ<@@R z|6A9+Th3|c=zdFFE>`^XR1ws2q3YiK%FTkF-5afsoIbCa;@P0y3n;Fn=zjG;TQ4Ja9U5 z-poHc%9I^YmUy(t*t6Knvat?Rq|o4mxck#)?@ng_ z<;pp4(v=iAEuZNcuU_mzCArq3Yzx7=oj10CQ`=bscq2U9IW2g|d!7?nWcN_^=>v~^ zY8iqMFG`l{!f%X3&Yf0IRzba7SDeg2J9sk<2U$wm0wkEr(ecv^SBh^|#{L;g#&9_? zEeU!Qjwd*JEc>-w-kPobs;Pm^9gTL!f1GRS8bz14L-k!1udi8BD4u2w+o>zc?0M_I zQ`w_D`TRwOW&QI^3fXPRAOEkzS)cw~@au)JbvQ(+>k4tyXfhDD?#N6i;S0Mrk>AW(<{J%d(2}#IwV?}FheYZ zPxHN3nt#NGILFCuILi_M39h3;DD%GGlvQaSI)N;TqZyBQ3)Z7agQ!YdTfvq@-l{~} zWSJ8JUote9GLL#qgX=3+aU^TD0vf+DSm$)fyhvvIiq_d%v9)?~i@nQ2W?1$w;Q9DJ zqhssFOxy4jJKYU$E1m)ezbPQ=+;{9t$(a(f>aZ`s7ihmsgw$+V=b`gV#FVVIn7PLy z_iROT=7N1VXvO7k0hKC)oeZ%TgUo&6g5@C@0}Jx+Y~(NOEK-X#j7m1~R=LregaQycV>O~z#ckXN52_{AmMk(u%1x68Po z9}0LBr7Vu!GHqqMSDC&pwqsyW2$I%8;txS{jW|(1@O->?#|`s$R%J14_8zmc=pAs+ z9FH`srWf%Ms$TF_I~)&yhE%(jCtFKQKlTpc71;gt*LJq{=y2KP26erH=3XazwELxv zh$1R*HEX}1jbCMd0>|0^L=}Y=-FQbqY#$LmDM|@Jnsql8%x*lq9LDQjj}C9o+!w>> zv4oG8_&0ywFUj^dLq!cB#txqYW9okYrd(*>n_IDN3kxq5>TGM%D#MR*Iz#*o&^Fa) z&vh7<-UQzJ#VO87s~!V?AD?}Tr1)}CR+jYl#)r;XCGy>o*dMsOHF(89 zpOokho3Qw^6%$ZmbAPlbiIGm&7{LZ+0uPDMu@095?0d3UxzT8Fz5$ta(ajr-idV`z zwt_8I2nO|^ub4eHU7)~JA+&5;T^oJOGux*v(bk@kIJsBUp#037%o}lNbHHM?_CzqG zcIw{a+NcP#UK!&ve@@PYqar7Phq@d zXi6oXppYr!lN@BD?%MGao4jh$<410RxuT=~6+w}9jjscp-&YO0d_bQ6)0?+M6sofQ zxHf2wTADkI|8&RZW*#jVrmgw8b6n$nWsop#qD*|`GV9N%cPWZ_@XL$AUv4K;y`R*% z#I~w>yG*&jvz2yyiqdX-ivY+4 z)(CV`OOtp|u)L6CNE;|mYeLk*bdF-H24YAQ_=25%%K^Xb#@|<1*!4`?x{9N!6>rcz zr4vLUek4MJSPsc%(Q(cwPMK@aj0O&{n(RDxOT5<27z7`t>-J#MD_(bSl&F1(kylWw zv~Zo1x^@?+b)8o&dfS-;4Sv_IStoJc(oLn~PD$fBjT6y0DeFfq0ou2d-2LmR_PhV+ zQYPGUgCT}%TpRab073ME`qQ6?)zl5Ojx*lV&(_Ii?O-V9BVRR$i?7{vC(W!B)C#RO zV|FeHiK~?RlB~^dWC-8?H9tPK2%Em>Ys0lE_X~-mdzHyOp3fW|Hg5s-ExKlEH5ST| z)UxjW&z$_3dn4EP|8QyLSt0Kbg*46JDeaRbI%$x!Rn@=R6Ztz5(i`uHxgxR-PPi!Rj9yXH(d&jai4Q;30~Jq$ zQJSpXycdi|{>s$$Q-T&9?O63n#Yv<^zUIuAhOd(tE;V!mmjV4dLX-ROi?chDX)OIJ zI=)K`r(^oBp^G0^HM~aI$0`hTUi*)rf4o+Hh)wdOrE{G`a^P+l4-34^@dAZJer~Qc zTf4Q#EJ`FTE4=aZE_ta9&OsVSI1gBsODcu<=W_0(I5cQbr|T zzWG%2JT+wszj?MmQ_viB>`W-uopYZ9sKS0`x8&-D#uoupx4=~8b~XxKc))=rt#BC^lW^}z<}5P8}-^wZYP-o^^W!Bm)4VdEKy^e2BL zVJ_94-;kVmt_8$&f;}75N3Zc$P7i``>{#pGZblB={JMT(yT&l(<2wYC-+u%&w}jYu zm7jT9zZHysycPTvVpQIN-{Hje@yvb}EHo7`j7est(V?GIj&A^Hs$*Zb_4bh7}MUJFCC@WeP9)$FPJ zdSBISJ_S#=MjUES*co2g9<^oZ-Ug%`pM(fHSTD@f z^O?t(s_}aiI5YhOXY;Gm=uxfXBgS^eZ@REtOtuHt4~r@-cG}|_o|0YCg%m;nzM+sm zwo86Vq2oHtqrf?X>KCvKQ&@%eTA4zoy6i{~duSU0%MI1%!k|0;n#FiNSeuEE8B_Szd-g(%uwY861&fl0iUT}W_#I_+Rf6FbTs_OEcoRbk!) z=Xt_SO&W*n5^!aA)iEZI>N<&N^0}Gq`aMRYP5;Ss81$+5%fLo>@uW#no8C*;v$7u% zce$uJ#8kNXM9~zYMDec;2n4{VZbJcZvk;*!dqI}_aCZJfGA;hD@28rRkJdR@J`DxV z4klv6zC8nph`-p5pGaMIeV?}#w?-O!_CA|zn4d2dc`t1luxHt9A00s+&Hls^XFZ_w zTvzVW3~JB5-;&#cnk*r)Z*uAHS7L5Jd~a3chTr|}hR@7N1vy8XFhoR95(X7vA|Rpb z%CtdA^IEO5OwyX=h@*O|8C_^|ti+AHfDTs^tO?N!m-#+(=)Ov&=HvnT{hE5X!Cby~ zRGlVes~zHkeseKxUp{&BmvryKfz4UZaGDn$&}_T_^q)gP0Bi)WGlrGEZnLCUumlW6 zJ}c09c9ueLFIM4fv}c_KA2=dbl*?x?xnN(oQlBZ-+Q1dKefTm&)xAbKmzPVlrO$u& zAx}l|65WM*m#9m)Sh3l%SCskE)5Q8OKldwea6FC^BUVP8m+5$J=tHMzUaX(U!#5W zZ)~zsUhuZatb?PqqD6jk#H7TBdV6G>!y^3mC%WEuY# zli+SUd7RCUGwtOzXb(fniEE11Jy2Bs_}a_HKFn}hD7}y)>)`GYc6M&)8xKnO!09 z%*8WcGSWzQE0e(NBMkb?a4}8>1Wu}N1)Psm196mnXXe6+zjXcS5V56?*#~IwIPS)dK-`IBW60z7*mvx7MFSm3!MX$_9UC+YQ0bc`a z=3s0zCqrO#yU9|6LKg=-b;aysn#4yfRg@KVDN>FWeHvUQ<9=|hSR3ko3wEkhgO<)_ zE=&5gSsCi`LgC74u<>$J8A0#g!d~IBODc-QA3|2i&fdO^8?pQRdC7s3c#AWInwVGt zyN@sL(nQc(2I*@xV4Y+9!>tF{=s@^Xaf1Khb5>rwabVVZ++yCSrMYmc?^vTVQ=e0R zww7DPLu0|?P^c_(`KZIyk%DkJ8~*>s;2d z0Erlcgv#44bAk7dcFxCwLN2!IGazpm^5m7>X3ue7lVc=2h@U z-zP4_*<3qBpY((}N)fY*-JbfW4#|H;Wb}$PkWLQal^N`M1ncZk7lS%~|077+JGwOQ zy56}xJoV)rr=Q(9t^@OcYCCJ1&u8kP*6Rl?HeuaPuqg)F)2wI`e3<23fT9;+<@CwY zr>iJ3lA8{PJ(W=#hx~Wv?^)Y1h#4fx+L66ojGEeaG39bnb)5t8KpxvF6-3~`(9_F; zore%b+=FkPSXn`Ue&%+^KzZ1VdFb;jKn~oad0G(x1&x~v=lLz{zZP3F-(+BT)+jS1 z+e75IPN=RzsPY_6&T?9y$w9`3ug3Uz5pGc6#khnHHbmkC`=3)B*ro4#JoJV0;-WZH z?h$hq&ZuVz-rMG6B!=LBrkQ#woCMK?jJ@@R6Kgjuh?(l+A66IEdl%+svv&XCG*MTYeYM z6Xy7iy$_$dG(>QA{mY4XV7G^`LgB)dfu5{9mHz(H3$<%Mq4>YteIulBS%BGM`_QB> zn@+zxm$!m$YjrU9a@X-6*!t`USo1e7Z}l`GDTC;!@I|Y+xxdkUje;?SiAB2iDO82y zYu=jpuI1N4#2b*s3*E$<0GE%g&URwc`~x@`Y41q6teB1s1kaeV8$ zXRZp@1(HpGB2w*w3v^ECgn*EwGD0Dl@n1&zn!C;Sjh!HSd)kqswmEjGVN40pL1s69 z>V=kc_3smVln6!@SBAA7UQrTFb_vtLC~6osKs8hN0FdBla^Y!*4~q}FipXo!O#gW3 zf2sHU=E=i#@}1>$m`rA5iI5{dpCaUQY%eWpA075Z&jMdXVpLe&RN0-J&QIC6s#k0i zW{sL;Y{4h;-mB{?T=jC0IY;XWTpmQni2jj#%2%zP`ocKLs?K6aYRJ?2^vX+THf2xll@+8MhY+CyZB-;*rfg z-696QF6gvmqe`a5UB91HA=9tB-LE3-5>9C(f+}MvWM+QxFRIk_Lf-**ULrkuxsHZE z^bkPjmKI5xU2FfuTck+4#W)4>|Mek$S4!CYF!GpnVusO<(`D&@V1$p(ZZ=hUr+Y^Dmg}-3{I|dn^ zK5u^VF+6Zq^ByKR^cwNb=*1T^EUx>CGNNoeId0oHxf{I~Ib#(`$oIsMxMDWzeSsmu z#|*V;6bwX;{J8h6(N?PYn=OSwHHQ()W~2eC(R-ztqI#--6J+I^qErp&y%;yljI5?~ zYcM&@`Z1xD1z{~2Mq)00?HMbn>kCikNTJ!;<;(esIq=@ORjst0zBOvOTSVo4 zp}0>+6#j%3cvKWIK(-DBM30DzhTLqdKMrx+j`C6@9&b*J(`VS8;rxxgU{w6q@ODTP zLd-~%b-{QzsX1KC;kV&=iZjieUJh+vux?2%yOh0jTXcnYwwN{TT%qT#dDTj|vf|#! zpAGBb_A~yZtP@Z#{E&Qr{Gz&%_BzBn^ZRx^G12;2!*Ezyxc8s*NIUrxvC$q{4Gs!D zl_`eZc|~IP*lh;ZM7DM*P}S5lH^8-N;dWkK5sDsHTZkYDyWX#uli67Bk{O+1$@h_! znX3Q3p|X2gXW=f37xLz(S(l4_`s2qSHNu52Rp~{MQM3_d4tp;5_ON|(P}kQhi4Hj# zZqaYJFT+SCrk?k=)^8c=cL7}i0U`>7GhvRoZ*D<1z)rNjRIS<~!>3VNnjvov z+YvpyG8{=EGs;3iTs$%gs*jYHS}ADWsqZ|3>g)bFx(>0sL@h!CArRfi-h8g!zu6$= zn8zo+!!o45?>(gSHM2sBQq(Md+8MZhSFeEogkhrdjf^J=d=w-Ms}3m}cvh`q!hxPx zy0!K%bLRAw`!WZbLRq6qoezU1#lTwJF1LDDW^f$~A;eA7;Mdv0bRdnW_2Ldu5s)c5P&wIVuPQ7?G2J0Xm{ zZm*De9_Y{x`)od4&-1e@j&wg*@R-9oIY8sdvbC-R&5(Fqn&BrFZj}*wV3cu~AbvRGD!$qvl~JTF_e7IhevSk@HxL>y+etoAwG4E6L2KaQ_6R--a~r4^fU+Vp_&^g+jns zy7lqaS;vk z+WZnfew2(Km99I2l13E|YCS*=`T13*9!4I%V7TYEHm&8+iM>TQu+w8^XJ%cJk~*l~ zG^3seOIKe)VjwepD;?nntaE z#)q9A7W-*MqpXLM{9Dp2jdU_RUqp!}sY{>RKjx_{*=OT`&z(=uyq!L3-e>;iIT!p? zzbd$z=EDl(0NwlA71_Fv@2a_e4>NV-K5iHgli2q)hM4Mk2Y^%RRuA%w%(dj*va}C* z+Sv-;Lg-^GLU6wnt)2=Dnv@vT%BoAco~ij71G!~d%9sZiMrHPnLg#x$K3zMf`Q4D~ z4@4+q>A%&v$qnU}1OfDMpUPCL(Mv8XjBWF!UERoA-D)-05?z3=r?aPPDcVYCJMqx7 zF7l@&5mJcfQ}7hotq8s`y)N0^Cy!>wHRBgYqxvY4BmatV`tu1L=7#9FLKZ)2{S{u3rXOUZo78Rn^hrh(ozET zE`9GI`v8Z~VqJjZ5{djUS?0r-ff2KZ_s7$jHF5Fb#kdvrgpkN|zX}B>hkE728vk9t z(8V_60nf1ZsM%liOgh$h8F?_ur5S53l+L^V6 zzI}V((wq{2z>Rcbhrrqt(S9M`-cgScVOYSW>pS7~=UIFAPRIN0o@M?W9R4gp8p?b@ zgU0ddB(BY001jWdIAZ~O?f#twg$2bOR|WQil<|cGI7_Vg+Y^e0^+-myJG!{xp7yB} z=s(%>n{H}X6mO~m=UNf<(he%T)Q+?BWOn&Kg80(ey7wUKHV0@oAgGWN-%?%-gSukK zWzZ{ozCpv<(&qFqdYUPzqS{f(!A)eLBPu0x-1%9iD-WAr7OkbTAhw-0^X)~U5DtIN zeG;Y?iYa0Ydo_I#`DA(6ll-VsD!tP6beU8>NMiiq^C_()hIQyn%>j_ZB#J(1;e*p? zMiu^ejEkKw4(qF__-EGvxVz>Xr!_Mv(<_r{K4gmQNHG^Tv;j*s&&AURS)5qx3dR$X z`A+;a?&%9MTO+HAabjxT(dpV7P;eY{lYKcQ%W29R5Wn5*Y zTHT)XJT;fnD=pt}@hR^u4W?mw5F+>$QQC{&!)(lX{Nq6o^3<4yKVudtTwW)s`N%4T z*F`TgwMi-#T5l$nU)(eVI$`lHdev_mXOK)MPeO2bNSU@zalFBgpKf9)d~jrzkFL6_ z3|tHPYj#W=V1(=BPK0#+oVO#~bP)rDWb1NIi;H!hI30*qkmr2aDyUn+^ zz;ryuKSq$J46M=y%h6;(ei%1@*k%v7orc?tO<0>c1A#u5-F|piz+&?Vn7*^ULy3R- z=}<9uD;Omo?yL>Xa1 zoZc%-BJx@JC{J=xA#;)01WJ@zGLFy^2y6mYz!$=KA7h0SvY54N_LQAS)NMLhj8n7z zmQ}ghU3x+e(YLhwmo+%58w=dxYIjNmQPAXn1Vd6Qq&UxN+{-}&G){k@iw54)3oF z^U`;b=2Ywij|(;|zYw@=&DKZ+Zcr)B8=tJiJnw)bKVT&n+egb?ocwZU-s_9gu(U&e zNy}=e+{=sPcQN4mH#=m+HP#A8D(FN~R94}d-ZX)i1m(hOBEmo@-NxJBuiaA`1>Xm% zC-WR|y^p39B-j`iAC79&l^I?!?_9jHsVhAXblpSP3Zq~M@8bjsnI9hY&mnSEN(hOA z@A)PUlWldg>i{8%K)Cx65c>3YZLRwo?grzd9|2mB|1+p$P`@qu@Gba^az>P$m-)+vd1)1CF9Nu#Hu^9+opgrnvr~+sS zrHw3F*{{VbPlRJB0PGvD*h^lkjy(VaXFb=Cty zlV4CKU3-+Rx(FY0K~J_yLIeGs0kqfZXMZd@=8nC~cgX{Z@ix-1nMKe8=4okQbO@1*1wO4Wo3SvPWFt!1e&drCDa@6jFv#6z{YVDJu{bDoGeZ z4md$woC^CMdl#o>#S~kW2s|cC~$ti$}JLr6gm(-ccC%+9i|LP4cajeX{qrSk`j{g6>QhYFe3Rh7q`0?JA_5N-byXZ%oQv*^Ys@FQ87Q!p>Im24k+PkUOPj53?x9(G&^ z!)G?EbE9euvCD1hE8~?`FG-=Um~YR4{8|d%6j&Z#R!a4jj>-PnA**|G&|s1BkYBPX zBuhJf-i1H^v|nB>%{$vl+jF6n3zG#0>`kSzH+f{>4FUm7+i?ovzLm`6<|fk$YR>hz zBK0Puu;)59Frm3XScKHd%9$d$iw&)(q0!*cd03`-KSJg;D51|MwQ|%O!}lmRjp-dcdW#N!Y29K z>ECAXA=c#dLYcY;gh+hSi}@GA7o}$Ka?7T}`vAqLk=yd}bYnGXIL{clDX5>QV3)aM zJM7b>fsy6O;Fd-Gniqw4_KXT}!l@Z&qVV*Ma1oV40T@C7SPq~EfnfB2^4=f&0Yz?U z({#Zd)euYI_uPa32qZonAM}~^Rp;c8dQZrGomaeIUs3snwAPl?Leb{VD+d?p zBmlXUO3xarmAnFblapI2x63Y0*9WzWE~i?O`Jj-DmXAq*L#fT;wr04CNq2!D%+Af~ zwL}l9yIo5frM~iRAK6;R@aCDh%G_AZu=$+~R@PKX9>1 zi?76W^&0^Ipa*<%?eW_FO3t z`^o^nr|-~vqeVsjgTsV~av2w@m2xr$5gePusg8?27Hjr1s;M@F7>WDhi?m=@J1HxG zKM=Zr)=N2i$wKSiO=C@xP{vPNfz0Ngu4r>25jI)px-d$1y&0838yBiDuzVru**~Mo z8s(znmAO#nyALw5Lhf=$jtKLZH4|)(O=bA4N%P-=OB<(`bN;m%QC|wZQ(|oDQ%a?a@EL z|EPz7H)B}u`^FWkI>C(gi)h(PG-Mm@&&3j@Cn}a!O1lqMT$JZ3vX%A(9o@7*VS4`(yXyA!PJ-~Ig&qWn*vir)8AyU{`Qb^^+fErwC93RVhPS(eq){`-vh0=}{oQFFfge+2UOrd2Q#v<{%Iz{IAmpr$r7 zyw?4&eya2CW_?kWWWGc8>Ce9{X8;sepcqv)n9*PU4Tz4oB2&symcND}O&qW`a$Artm)Br*def!;?ZWQ84-MV?zn?;&)?#{#L{>bT{gVi~auyXoIX@L6L99 zffUeSpT-{0m9mqo@S|7+9mv#yMT-me@Oj;rjSPAPF;XhV8>Tcfy+iF?iS^KyxrP9J zd}g(HPe*v*9l*!}Wn4CKu#saS1JlzJZ#Odc&J0B@wI8IHcQBqwrdir|>5@$fI;3X?s_IZil$76@;R*zJSkvVL%h zX$io6*(vT=gFFY&l(&JHV=7z|Mj?Xp#IEgR>)F~rI?~|U3rcd_{-9WpJ)cx{&0w{g z-E)L$o5#-dyN*0Nujb@*0r z_1C(xnL_yjCC9kM!zWzR`Yxb(Mvy_>57*)hiEcW)8*-=X*~w4=mihIJgz}TFB>zLX zN2Q_RAMiR}@4YV8w(OS7qvY~E7283ficiVa^r@#D^)a=%-tvX~64)8}fCT;i59+w2t@0N5&?uoHyPpdw=b;2FNm3)R1Fg_KQ2-pwBd> zz+{sWz%c6(Nt(Y#%k?AOvIEDwn*DiwE~g}DaR`Qw_bb=C8M{o>^plst5^w2C_b;y{ zCpxF%*ShW&^M{2V3^JH6=>=rS2eTUlGak4ZjC$0=r>(g%t zMTYSLN_Ie2U5Rw{7#(uGJ?yQ%cd2p$)I2jB&~GLDP4X^h?M9LR*Z@nu-di##2^~23 z)@DzB`*Hr3@&}+@B(96!B}534cr3g*Z>1ws?@u}^Da34aa#s=x*$Z4@!-E8Zp+f9t z-81z^e*4MJ9X~tUTUssOf13`ge>+#xtZOu7IDN;l!(>+c!`>QTMSED;9j7aA-0;m_ zcUFiyoOeP89+;rut9F_?W<2hNt@HC);qwz~4f*7)owSGmplRQ6uEw1UV7X}MBR<)mUH7tY@S|3n{Z0?(7f zjb!}>>=`WgtTvBIIMJ`Vm=IIqdyvvOK_(^wu76p6t?L73OsL z>bTm9^64n;06FF}WY0Ustdz z@#Tr|$^xt1y0W(1U*N6(2#n~21h0(7bo8zsx|WH*guitJwTz3(-Z8V=1=Z-($7iPh zjM?+me%WCiV3?${;%!gUn5=7YfLcx*Y%lDM#>mdToSNF5-sS|j(9@S&1Xqnq_(p^2|C%&8NC z@43af{X>p2ek@Hmg}=vA_MM@w4w5r)VG4dvuR6388TJw5m3~uPpwB-qM z)si>_M$CfGn-)CrGVuQhOjA%P7^E$7$v8Q=sj1z}V}iF9o)<8OMu}SNJz;M1w|$N; z3gEWG8`T~bzWTGb2WMJc&~mGG4LE%jF~Q{~{q>jWzX$kuBEPaL`VaHvn-j_uCC)Gl zTpp&H9Thq(_YuCvO1<5$OwysX{^*V(NL(5;q4zB^yj*{+(|+JivOS61+M+eee!kByUf-yMy~7L0L$UY!ueU0( z&g+9Yw{j_FuK?r!;njGH6CbZ1ru&9t0O^fAEiffAq{wqh&fXe}1#2}oWeyR)#ag-1 zgSz`_^+DyD$(BW88%+pQq$D{){r-@qbD^IsT5%#%)~vBreZL%A6n{Q*X;0QmJ9vBq zUbMtMxGWNDSSkJ#3&g;q#z~C7)*jCLW}CO4I)uvNE?eYsHYbis)`f(4x|7EWb-b%| zy@l66(cvnK5*a-eDv?cJt<3Q`DYKrPwcDIciF_wn6ZrJRiuQC;W4jjjc$L061O@!t z#pQj+oP;$fU>M4Nfw&Yix8hTDJh)gHyDoDYtEr#Pt8XmAAH*q#%e1Q1%3rhesh`zI zyrwdhw)zIw)=P4M53heJW@dei4I4bkiN zp^3>Oz@zNffu3cWd%NB>OR+FVG!TNa8*@yRfm*IxlO%EmRUIUGSs;q?H8d>@;hc`D;44*0roUyZb7v#syWuI z96KTv@HkHpL+0g+!}Dazn9B&J8SbvnI-jN=3(bxy0VgX%n~TKnPS>u){Nu-CYWNO| z?sc)@^fB~_7uI+bN*8?1{*g-;IhH=5UUEX(qA9e1?RsodK>u;SFI!u<13n}wjvy}1!L`i4DeBdcMbtVhnR@Mz}b z=!aJCTdVKA4JZD5YqUawoUC5!P5{YqU%R3TvvV)OJSAM27W+~!T|;Wb6A#KQg+yvR z%3xr=5BxXD49SNJTj4s2778|Wl2iCMA0^LUPOTTJ+`mfrc%|~PGsDvK7J2UW|GhhD zhfz6xjm_wb(}|AN{OFziUhWSnhuGS&_w76+7=S`q#_oyuk>Yz+`x(%hoF`NI3hh2MAjFcM_*|+oJ6LSWwPq`ffYNEOt-mb zyGnv54&;HF$O}?t65-?K3Ag#TX_qL3z&9CS=->oqalF=e(4HE2t6HH@b~jsP<(9P~ z7;xD#XZEw_AkMJ?25W}J%}t7ee!gZJ63Td*y{lk~mAAsy#r{X|=s$u)d>B*u+7=(j z;>9{`hmysl>>p#zefRTKwA2 zN6!0479=HBF6_^@G08&>l-cLJXw&xx+b_y~WUMPMqDmuN;!UvJ%^#i-Wh@3w4R{@; z`{c?y!87#qG`R!fZD@%If=BBlkr~6YM`L#fx_DwCfqtJDjVO3^r3C;V7(GzmWn12}Chwrs0>u^1SCUIKimr8Ldq!H6s<<@+Ge z_d)gY&@d$ZFxB|@UTr#T>2^3x?{TK+%QU(957}jUS-`QYd9!SD;bff^)W7X`v;0aE zzfD>^^es6h%cxz;zY`iB5D<OY+K zyEMF|bXK(Bi=m}=>^#uUEbB$}>#foj1WEvfx`73H>StX6p&2p1X>Pgh{o&;(>>epB z3Jio8S#b~)EP8*TkmZD^NYkB9z4DTI4!wl76A* zSg#zNa^GM{$35aLRus**?yLi)&+dBM*z4AJLT4+5+%G~s;ci!pI_?LBqjqyI%_O$@ zDep5*$~&aVT;As;@|Ue8^+P?>7zQ_e|9oCuTc(>q-Vn9S5`n6nleURGO_;HNW?YNY$R_Y3hm z1{Ui*RFZm93?w^6p<}4#KZ1@S(&F@A6{cQg+{dr4631Lmp==hYWk0R*BXE02u3mDr zk*HP+VpDhJ&{{__+_OCS==X;1Rbp5L(VZw(K2N;ymF}%U>C37|D9!b>Sz4J?dbbyU zRfeysi>E#PNkk-W3Va8;pOj5M2hqWH*rm_8(X&q~;L^}7Hqov3obxli} z(eS%vz&g5TC!@(TQce@~0$G(K9;_B9d@0mD$!6{D_~nRygKWMU=P|V3Be#-V`9A;+ zLGr#u@e9M6CcURir1*zVd1urN)>0~&^!v7oO)~3Gnh0c7lHAE}JaW#0L{QO*7ncz? zTMt>`YI9GKsJB~{IW(<#tMdEXyEeNwYyCb|fQAP(!T$hk(w-X(t5B&~PJ*c?N1B}F zB}Q&Bsd!!Txg{&4L6-98?(Ccjozo?ZC_JqF0M|8 zs%aWr76!*tzk}tSXT^)JLw#$m{7dluu>)$FhO;l)9$Tx+y;l6d_jmH%N1;P`cO>m= zZRPKglHDPOI3t5#WY_R_#m#Hs{=K63qr*QHbZZ@ZK=A&x;e7^c=wDQ}(mX%n7&S5zGnUoK9uAHgl`p75HPu+vp(?nluvIx6 z@gDEQx(27?JyyfSng@(L9AbjjX@$M_!&cWd9TVbj zi7kJ%bxRwGb^ibcY0>F^CxRA#?3d8(_4T@Imx?K)Z^7?@o*4L_;ctr`C%^F@j6?iA z(cfCqtvoU`i;YG-J?!;s%{#eg z{tx(peem-8SJb>A@ejiqCHAl3&l31L_T*SYdSvkTi?sg$?2UHP54GqrMQuIpl(R-P z8#Ipc*h=kdHN39wc}+WCveUGkb4%0wYvJuXPS>q$w7qiDD@*YfsbQq*HoC^01;aLw z^Q9R}u~BaEs2Vm}U%lw$3xca*Qo zZ9J?m7I;AkWiJuK_st8TE#HWlG;bSthd`3it#4=1u5_OdUi>%FF3$J4YuL4EH4lhdea+0bfi(MFKEmSK zBQ@RSvs!$sqw0Fsg{GR$MW0ph$hnxwq>G|;Z6xd{_g)*+4&!TZirbB&ytZ?YqFBS` zQS|1Q@!!XO9?-0OHR11znr@Hb==7f(=^xn^T5Z^~xwQC^C6xX!{?kX&WIC&AQt5h8 zg6mX{^6O4Z3pLXxwDR)aEcO~*!lAhF?}t>$Dv$KtKU|SwkK|qD#i$M(e8yshMyQRXg z2re~EJ}WJ5+WG;Tdt0lkV-UB!w~r{c_uFH*V$<7Q-Di9?qSx14{?V4_Op{OXE}y1D z70#ONBHY7yYdT2=tEb#beEZ0S(ua~JS?=wGthw@58-~uCNwL%JrM}Rw1ou2ONS7WNYHrJO>c(U5vQpWE2X4S3kBtC79y%ZSKZSFGV;D+vK z&CZ>4#bZ0(Hk6FWKX!f)-2VW2T8^$S<eRYJZ>+*uWR?rqq)Bb=BYn99#|^^EAtFyHm1FGmuMlXmt4VQh{gpl2th3qaI-Zq1 ziG|e!&unknS#1b@BZSU;l?tJkYPk69=Z9-!Wg3*HI&xm>j;%PkLBE1Yrnyp#qa^Kg z-z=Jc(0&>CwU=<#8m?cD_OPW^RO)+rGle?b)Tz#zkMC*KrBa-gSILf{R<)=Z!6uXVUy9{hQ(FWJZef#5cN}S6cPmw5X{) ztKVB&7$AVSZ!+IcwuDGNd1jG3h^x#WNz<-jFy45_NlRHJj#(^hlJ#M@I~7=7-N>=r z&W;$8cWvfFw8&PyT$3nybqLc@<+S;wEf+MUZEUQpozzm=*66SBz9jM84l2yt@X}Gk z(~q*GuPMe9(^G!*o1Eu4xzwvUNx4*%rK7T5m2u+DL?qfrg|$68d*-)_JMR!Omx>pZ zlQpiK(nBlXYD~mLZ(&WAv4ys=1O16*mrY-LJD-|it6^1W!x2_cp(S}Z!}gtWihOUaX=3%-?(XL5-$~MRNG{^hwEGmel@ixa(?#!-ZKuWN?SS6h&l@z1 zWxAONd0Xk9iQfZsF9G~Y@ZZB94*08C)O8!p7r{fsI>xq^*4h=Xi@Z-^r(NB_;jK$a z)^9ERL*Xk`)a=HctVc1r)`Cejye}JDq`nhnK80x6Zy5MuRZy}#Z3V=iYAVX^mpkEg zm@_GNIg`zi_dAx}E4g5wbL1TC#lBKLwrD#Zjw-#731U(XSei=T?VfC&jCfr;;2P?NGQsxls)IpnujWkTjo9E zU;UhXGi~ti#~wWJmbv3=v+&=TCSQjBFLyzA##7Rzm%CTYGC^A+(2Ej@C;EwcATO-7b4ueN}AclG$}jSYo~qe38u=3!XX| zXB4cywJNyiyj1JLbgFx6C{wjQYHn(qa&nTKlTb}3ZfnNp+ScO#00w2B;Bj>F7{@K6 zfvHCqMd0B$*Tu?G_H~vTSe{ro(v)EDUP;az{J!&vp{M*Vu#4kNUkzsQr-}8gA$(@O z6-{fxwz`sPy4(i#D@d<2O*6!LmYZ#9aVw)6iS=8hv#?QdaABS+BNYC3<2@ct3&c9+ zm!$Z+#+TYvzN@NP=-Qp_?y=$b)ik?%#<#M+lFEDAiIeS-MHR$Oj$)Z`@k#(vx}FyJ zkE!@KSJk{j;h%}RUWu;imKv^^b*$OO$5CrdS-0v!+V)*F?kw)FgUKBFm5bSGO$@U@ ztrSwotj`m8!Tf)#Ccoofi+0{5)}^$w)1=ll?MqeFwFkDeMTXMOd%NiECXUwD6%oWd z_lgjtV{QrdaC9@eSjtkZRy8ot!&0YJDyy1x(~mtxO;c7<*)>tEJ_MS#=X^z(czKP< zF?6!L6!4k7Oy3cS!%0v|oh-hkl^ipy@eWetUiFqAR_)H!)8M|{sjvF45_rdNmMte% z@lK@R!Zwq%y~~{KMP>}DK{)^@1k;08@eY*jK9k~IZJlnu(e`a>`E{Kx?a=NJUs&od zI!cnVq;6G|H~>c^MT1vwsd!#1YfC%3JxV=J8@v0PCNNt|ZUxPh7P7R6?KF{HSx@$q z5M4(h5{F_$WW=)b?WDY_@&z&?1Lj7OWCE-KY_1Gs7ulIG7bQU_Ap7-kl&CcdwK*ix zNjX{T%JlDh>$B16t(WH5ejdi->CUzb4_6UN3hs2_DJZC`w<{-WB>dH#?3SLJ4dlA7 zgEg&6H=2DTUig3FZ9?+e`rZL0rS+6j!+oY&%84^Iu9dA=O*Wr3!^Hko#1cfpTZx2! zBv{H`IM@!V2<^Dw1D1E20M8k(s`R}oEkEJS>c$;ppW*k!E4iV06h_T1qpq&O zvHpK7MvNjc23Zo|yKPvNK2OsJ?&1e439pQc3cf5Df z*4strdvy8gl;uvW=}AdSGL&SS+E8nvS4}0>+V;7u6pSzf9(^j`@`E6vh>Ytn3_c#GtJ5HpVOLLQ!0QpFI6Vr@gcw^8U#*64V|_~?faDDgYusK;GU$4iJNFRJQ6|4$>ffi z>JJ?cJdT+)-z###Zuw)~H*QrYv)Aw6PPc2NuhA>JtyDBxy=`qY+3xfvLxxtt$Y41f zl6dM*Os{SR0M6wmx}7#tyeQxd42&o%z&xK)amE1yZ(8KyQc8o*IUJLYd0d{SIpB8~ zUWU58J`Lec2LO5!Mig}FIRFd}bFkp>JUq|%a=)g#YNKV@-QM1JTOX%zM5P>EDMe`) z8*8gg#?Ab%uKHVMea9Wd(|^G{e`y6;q{gj+&5{o6FXEh*tBY`H{{Y58NxhYyEhLv)wbAIUqtfYr zp!D%Bc#a>VJE#8u5U&`c%-=1!@sfP6TW;+9uHDs-hc%JQ7vaGpILRP|2O|tQInDqi zj1>*jgu9HZwg!4toZxg*gTZWI^V7K1v03jt>BER~aWiv}DWoFiC9{()}0Vu9v?3c0Yl9LUCBkLcQ9Yt$j32 zYW**Lb$42{MYkJB1eL(sfOC>Ca(3jBc_4dW^*uDR81(D1Z~?Vo!M;_-#lS2`#tw1Q zCvxW`*ECMzycODbIL`wh_36tHcw>N99cs^sF51V#w-Sb2$gn^N`@qV{ou`5r4cnjG zob5TUqiQmvG}ipLveBhx?QJ=pug_Mjep`pC=wd0Q1-avlW8YpX$EO(%;SZLDm=l~X(~@~0f)772V8;iX@i*^IF55*X z*QMQ-kL9)RytQ3ZTe~}Ldt1$4N9n!qWo~C!gxlQgzyZ`8kK!a_aUFRidt`tPYrD39 z+iCXjhEwIhxxm3Ck&6w-kVz+;j2>H@6Gq!jk5ts-xt9`KSi>BJMn+_2-4egv1PpI% zf*6g%3P~6BDNzyEaO=PXjA0n^PeI!tXQ3Pl&KF$rdZ%@CtnX#>zOQAapHchTyZUJK z+ShAMHt%hj+3e#df_h_&WR5vJfI;MaF`QIv0pS7K26M^JJp*KQ#yH`+WOlMT6+edH z23fcn!0d7lVsHWPQEkg(>4A;_&m4CL{{UqmbgrLgD_u73?`N`2M@F>1mv*(&>hEKl zRo9!UYi^tAce6^$-*>O3&f7942lzATmOP9AFnWW?#xahAik>MWRc|&uz@mg8;gc)| z;fw~(IRh+kF~}6w3VZd(Aoj>0V<+4XxkfDQ&oBjw-@Kw6ov za@TjV)jn4n^0j8Kw*8%#N2b(jdnu`?##X(QqNJK>bhFdV?W^o1z=xi89P;DP8!9Py64s+^EG1DrDfy9b`C0Y037K3k4B-Fj;D)voVUx6^)m-s`K< z&*gh;+Fy^{GzA|{z4<(zr<`X4k<*SqIcbQ*<@$nf03UaNazgewI5``+>yB*%3^Fmf z&n?gbdFLQ089Z<>K>2|H(u5$ZHe3UeGoF4!k%Dr>l0XZLVSwqFl$0!=yC-f`~Dz_-6>7>1+`EM8|(z}vPEnipNE8L>{ut{8HvzEcyIPQ2k1P-`7 z06`qpkOEE!;BM=jdICTrIP?P`c@x~j0ouQuGPW6U!j@vc z#6q4&2Mk6?2OS9*tgzIm~n19xsP8T$c@$;~|B@WAXCB`yA#xvU0@Wp+db{ ziiB*fD$u7XDMrhi+D0E|#C$83a6sPSdRBD^0Ds?Bw_<(^WpbALYH$5_QA0YPU95xT#{wn5K4nX9Omp!qMf0P`9 z#y_AH)cA==lF$reZ09_Dx$^iOI0WY%dCBiw@f%ca;E%nZl#Y1D?s7Zeb~(wfqm{Xx zt#@npwWIfGZ`VeAZLMor-)8zd$5pMgx6bKwDqSuhY-PB}9OsTs7@YmloL~|MavHwo z@YT`}hdOw*cqfWag1SU4tj-t#IH zfwntJRh%uC>D{ds z$LR;`!||)YT5o~1--Q1F6KySY-vh}fi9RFU-&twNHOGy${{Rp8THttF;qQxu{g$_9 zf2CaC++80I>QAOYdEo7G?CP3Up$s=VrLFhEo+c1n{5|-H@Y_m}8@~)(+pDpEv&J=N z5l1fMh%-kO!y&|Rs*@hr!Xl#mi~BzQ$po8Uq8|bmUjX%VbT1R^lA22L8aat|JN*JcuOYUTF=i&Hzd0Wr3WrM_2oodnN z#88(qrAlB-Ggr%A?i`J)$ll5M$O-!18y1-7daDUHnK)gq1riOb|J)kJKwGcm%I z4fj|DUzE2;Kf~<@NjA@~Ur8Or!K7_3PmUY=`(y>zZlI;1x3HMWAC}O(&v6>Mypb;o zHT)-W4v(W;YS1ILqZ7kxVJ{$B++3`T_kgLwTtNgsxDLl@VCNfFvgBaJr1{!OA9G0O zdowcv>{+)Kb}UI^cM@AT=D$Xzho$W4w+PB|NwnRxyme~*uI=~gkLEpZvbD9-U$dic zTUzeU&G}gsf3tieANq|SRGAbO-z?T}lZ*kzZd5TD1Ob3S3aUwJhD%KqjBf^nS8FI9 zexl4s7~S&2X&4_jQ}=iykZ>)?rvL$*WN@c1(;Wfek`#;rPaJ|pNG*>IBkcu70LI{X z>c{E9CvX@&b4^f&wzZqm(OkNMYNhu+g)_tE2Fb*E40(ycDktgZl2(6 z)9p|ypSyE5&_e(L`9g*RARVN0&Q1uS2Zo_{W}Kl?$I6Z+U6=)Yyt61~Ckh9z1S+v0 zZT{jW`JI`u@}dyLu2-nxSFZ#q=bj18NgK^63WLjt9I3}rcIPJEMrydKc{#@jK$4t??*)H~pG^9OxcC@gIXe3u&4zr}1CMkK&IMN8yVb zeGY#Ri>)8S{vFU6XVv^=;)~5ISY0DdND9~wIi-sXOfTt#FuH`0PY5^cwb(Qj_OuF^nn2&T^ErR*Yig(pS->uGRiGZ97h71IUeWoD3(S z03F99Fvm_G)Dcci7SYs&00MFV3f%FGV>tv6I2btN2b%pU_fd@22=z9H!le#$=? z_1$~n#;^N7_{-w%y{7o&_z3(|_&?*X4*vkcL#60n2|goQ+v~m<(KQp|&k$&qZ)4*f z2GT1nGs613ULnBf~vVP}Y?UUezatA}3Rok>zu-A4&i=Fus|-d6Xy zqh%!0`D6PA(Nq>u$OD5JP)8Xf0EYJ;H((E1qb8T9smGF~l;F7u&rk_FM_i6Z2><|8 zs4eyP!*AHz_NMsDqFZ=_;h({ccf{H~*NZ$$;8AvUPYh@x%f>zsT{!Fh9@Z`VORnhG zJ|EQWC)Kp;8+(0jEj{&JPT*;JIn*@>=C_aK9dAt4yg%ZdL&AP1v%2u6yQ27p=fhqm zu-EM`Eblxqs_FWH()E}%yAcGsjl8-w%r^_DTgPoB(hGT`hTdsPD{l(;TCP#0Ds^QT zsYyY_wPuyvZ*}Xwjc&E!uYk-bQ>8pk7QGsEnpCP*r6@w9N-ZePNk!T%OMMovM2z8O zV8E}D9@QCA6+l1i@(VE>5=I6=z&z5OKEcTX2-G%n(2N%OdE=)z00KZ@4n|McU$mF( z!B6 zn7-6Ch_3a264>dIYNp!K>rT;h%VI?T03^pN0fWwZ4oJbre4gNA9e(c}OT*Qrh=)Bm zd$)1-qTufr%Oup2llPX5rGB=1n9L?816|?k@+zvG8o#@oYDbc_C{?E8I7(2J;WY0S z(rv5XLbK_%dw8}5fZ(GzAmM-@mH?gI4gtn^1Y}o}ON-?@rBJxSnF&xohiPw=3}6kv zcmgsD5H#9MWP^eL7#JK7ysh)XR_tVdxo?1J-UF`OM3bxxd>D$Rr7H}_BZUDh&bJL;w!0zd}h6(2e zltBXYPETLoOX}V7p@$ALBfzNLFz&(EU zPXhz79co-@UdkyhYiZr6+1V$v=+&=hv#}R^+qAUXrq|bNEf%|Zwlt-&UB71lr$}Pzi|0-#FSf z06h-lo<}_Jz~G(%01sw|!Q6P-Lkyk0FagM1oE-Y+7$jiSN-@5W(?{0&@}#|Wy|s7p zNscp>x9e?Pwn?v_OZ3w3q1t>g)0B9R#QP%oWAKiwN+KxQ3PA0<5sVdJ!sdB29j2|;r)0u0s0NDI4h8GkT6ImoHC!+JY%gmmd0|qEzp8a zG5F`9!Su-53Bgocl6IWqC!BhfBn)JnXB`GkbJm};rgAyw9Q&UC0ERK`)0*}ZZC*;+ zF50^-Rhz!Iw|zRF34J{F(P`7q=KU{mzGDDDBn~s3-+4LtyMd0sbP|7v~uVSr=~dP)2~xO1bcoX{QZ8p?@BxD?vlHDZkL<8(^mD- z+oA$^}at7xroRx5F>=-+6XMhhHJE~RVc%cEG}}dHye;z9~klG&YMA zQ%v6xZ<^`ScenyGZF;ICkpPXPD&cT5l(%#l@YrmhKTWg*p@JEVT z+3oGMdp{277uQ;RdaQR^g#Q2pd`^VP;2!~>CA_uQ^+@v}-?fBNGe;60Jhe#>06y%9 z+kQ~OzeD^{`zBpzo(}QuqkH3CCC!GA33eN*lhCVnNvZ)_x>eRnFQI%OjUgTwOcG9}FmhJaHXz{NH z#1t_+KUY7()5FzyWeg@A({!aev~Y@a`Kr2C{qHw4W~SV(uF|?koA_(Nf7)MVh6vQO z`$=ZPtRaATC7w|e#|64>8|7jE8;>9YI_tjE z%cCpKq^M+-VFMVz+yOG)YS$VTiKJ-uGPEaA(^l^OWZsh8L2{^(TJH|nw+1G0g$l|M zRN%UvVGGC?7*H|6F0LREnUUoLNROMnV)L{9_v*xu>f#dJ3DCCdw&rObAy}$3ggKJ zm|U2=Kc*)0!!^#MFI6p3P zI@qEB(z=pBQ5uyy0m&IB1E(J@73t%0}c{f{0k~R68&MKo}s7aCsR~99|o>+N&e^ta=qx z9PJnbuT5&8pIwu>R`^#(*RM9-LC-Bc=I-p>+tpca{{Z0L-8xZ=L(?K$8(7$# z*)FKc00*7hSLO^i5;pK?P6_SCNXX9}K&dVnl}SR!*Et!- z4U&1rcq*U+(Rva-y7)WrL*U$>vqz6VYTu6+pBj8c;Li*EVbOjJS$GTJAn=}_;teYD z{{X`gc&78mem?PEjC9Ws>DmW|jlA$^-Wst@4W!q#{{S-9T~kkp#t%LwtsFEXDZ))T zK}A9-$;wU%F3S78byvH(XI2XZh{x7+D9$Oyoa;_?Wy=^-rx?z2l(qO{d&OQ|EvBCt zNvG+jg@QEgkQGYfw^P?6gulKQ^OkP!mk$Gp9c7P_u@B-{7a{JM_trD8F;Qg5%|Bu zR{jds{0pS`p6biQo-CFb{4J>XlSmpylq0*mdwWlqyfOPFe$yT;_*pN;j|}`J*7a|P zG3j0**R3^l)Vw)wtZSYg@Qjkf;=dC3Q^A*d)xV8=Vd1Y0&24g;#=YTh4{G{dskXa$ zE`(OoNgpE2GWqjftvE_Df|MrQ9A`L2YA>0k&&y|SS7x2{Ozp3bXIOPj%LiVpE5fx} zQ^TiTooZNmu8mr>=S!VBYNT3Ix>8)SS505%h%{?N+I9k=N{kg7Se69j1z^LeIo-P? zbgO^aRtj0Z&aPMtJ01Av1daTjNyz7ta6!PYuY3dh4F1<&5que-c>e&wp8~u~;!g^A zx583*-@=-novZjor13w8AvcR8-W<~A@f2Flx2ndPebvMGV@$BR@jNz(73A79R=1W@ zTzOZ(p9J_9;&;W*1$YY5YT}Y0+Ldh{%tmaX2}`tm#TMeU05n(`s@}J#M1huAE#|*6FJp z(#ta_RKmj%hOLHJ$wo9PVxcM&>T`QGnyFDJI3+kq%IR{-_p{T)q|+?aF)&Bd11dY5 zWERdx-O0y1;{*`da%r|F{$0?6l^Fm5$ROi9^%)>w_5|dgzP>bk75E7^?8)G-+OPI` zzWBYPcvn&Q-LCut@kfC^Hs5$lTi4{*bl(WW<6SSs{ubB#9isSh#(gUDM|6i@(5`g5 ziS#{E;wU23ZefX@Uxg(KTcA<60m#OFQUNEN?c@+}Kpb_d!&RY*hdgOYJhGHzrny{a z2Dy`0=5I8+XPjP|?+s<*4<KAL7|9%9uQ@o+L!4x1fsO|Qy>$CJch)vmzmjRIUi#hXyS;LgoF2k zvvJ74>wvl2j96wZ%VgsNBO@5b-cE7T0~qOy=M@PA3hyd0#7VnvYbAE>?|V0*?dSKVPO({le|I2LjD(jVa@~W1jzWwD zAeK1kfJAfYuo$G8-bA^DPGlPqE^Wa9ftnMPShq5w%@Y{IU}ZjCkro9(-~a|V>5<%@ z=szwENp8xsBQR}^nAip@zBxfwMk=Rzuq5DsrIdF8PF%2WMW&psa@{w}-M77!m73Fc zp!KqM>X!QJW#0O9Yfo3H=-vUg-QfQK7^Wl$JVW8xBOz6yQkFW6z$6s_WWWLG@@K1H zAJAV2csot;&xd{#>b?WhG?>03{4emnrKot*;g*@FX){{*x8e1ko#LC=zB`%qcfYsQ z_0JAM>!SQx@WeJk?ki0n!(KVk?W3}n#24B(!@mrn&x3qr$=;g(0ER-SWhJq1sG};e zLYtU}QMdp>bzo7XQTK0x{{UqhPZFO2>Ja|ZzYDw${x|qL{{RaT&gWk6@5M_?ogUqF zeQ&}ZCh+7k=pGH$n)g-kqUgRSyYYsvqZiUN?+EGorl)^$VUjqT;W)0XDpJF_V%8R1 z({PldIYMiZDv+FFpSt4pmX0kc#oac4Pk0%J#m^6#Ri%@>;)|t+jaWjZIMu_r)m11} zr71L=xuBDSyd;~9mD;z+9}VT1FKyB{5zPKEv@wmcylcA7AY?6-ZM#4r_FUNmZf0w z2tl^crMLTSgtJ2(*p1=+<(}^*;yC2Dk)wd4?eiTDFZ6iSU?@ccf!F272aNP24y0zj z?*k_+HW_nsMl@pXt!H?}T{rJG)w)@0=8xxYAcI-9VC4=;EM;`zqU36;jH24pb-KE; z?Y^@|z~F7*dSmYJay$Lroc+*q$*$4vYz~>^upMx?9D+9Fo_mmS&Is3r27K~z0N{W@ z&ekB34;{E2c)&b{HLe)sFb9**KR{18#{-TqIIhJ7D66M!EVXyq`Xv1?rnl2uo;{ki z-n|oU%V( zF~}pJ!yF7_ka61`X`u%gP&;HEPXj$T_TUh4jCC~}wt7Y1)mlw$%6+23~D0R~5}LO?m~-1Nyj zr*01e?{(xU&pZqcy)l7@Nwdqw1{e&H*OA{n0AtDN(;Wt1O(L3mz4dl_yI$JqeRtB= z3qIX-vUgf5eipX9*O9C6&fzWoC-`}w%d6eBMR+9!fvmABm4_=iEivv;?+x7KvIAe9CRxx0-Dji3^;EBSH();C~5 z1%dky!Cnn-h@TO@6!<|k?IXiq@RHn}8ZBpDxLrF&(Gp#H;wGO|bTT+(iYXR2qw|FK z&utNnJmy%ueBP{R`{i-~F`#5|=@K?iA@bg6Rj+x_M z40uAv!ja21hoSgqN7B3xpz1osu7%-08po!k)z^+Zd!yLLqFkSemJg)9xh3war|TLr zHl3wUYx$+|{{Tx3@h3-CSy^ZBUxm*1aPkO+vmgbC!klGF1shJ#st;e$Z;l_b{olgx zg?u~#JV(kUxsdfw>7H^ z<0hG@TwB=7r^oh)=C6W&H>^|qP|)IlWlw~BEg5a>Q+%k*O0fX#21USJu>dFqfCuOJ zG2thaR9H+!d{r;4I7(I2l;Jzc$~JL>O81hw*6HhZm+4+Utl}I|N;%eBfXeZaz|iES zmqHPvDmH?pE5dW;lCy-HX~&U9%_Mnshl1h1S=}BxYgD@co-(W<}|5ur3k0Wz2_vgdT7(q+F9K;p6B2ya&FE~R_|-w?Y^3N-s@*} znQmKO3%qIeZx2d#k%zg|$t|=T?-q8@BQZP^l?3Etjw>5IIr3LSwHt|1zV}mnD-J@B zJY@0I0mxqbi?;Dd>{d8H2aJ%s=O5n2NH_#zX(wXzYPxNr0eshtzTA}pNsERcs)Z+c1#o(rk#r3@HAayHj-W2-JiM_7A2AAYgq(l~W1JFB4Q@># zP9qA$uF!)x83d9^a7QC6#z4u&0mgjkBL(vu=Z4L!L z(!PNBN&6doO!(E|D1IR5viv9TAA$8>7fs+V3iz+%KgDkbd??bqd*jP!?I*hMZ-7sV z^&5M?6l)d&{{T;2O5)1dG)*ewP_eYs^@}T;h~XbT-C;lnfOCeThesRN`C}+zQ;t@WV^Mx)VwdK+}m6#!Nr(WmL8N{7}J!Sk292c znvI*6BpjfnFLgB@X#1}5O{9-sCcU%Xw$#TwA>HAB;MOLX8RdnF)(w`?U zGmkTjZzJT*fA)U(q2Q^$Eyv=&hkBlX-vRt#qIe79;M5yUg}e{r4-)DcmY3n(4^dyW zmcF^xuXJ5abjh@2)GV&;Y})xQMT5a{Hx6vGPtDR#| z)b*bkzl-!8dsx2LuCxo!3|lp?iFFNT@8R~dX>|{T?)0%An%=MR7gPO`z7$$F?APK= zQ&H7E8(e;ctgiC z_^0FV>@WK)TzonBUjG0Bzi4mxC&$A5Pvb_Ep9uaQ!K-+d`t#stk8ZU;iF%ihBk;ob z%G+1)E%$_U9cxI}Y&=6@28VNJbA2++f2&guYruJ)y6`$B2+6;g(pU{l_*fH zFJ}3wrxyzGQJP7nrS532t?`HUYw`a8z@_kI{{W2P@GpowC-EOn)iqCt-x0nTc;myL z622UG$_)bkJu}2M+9!*=GkJ4l`nQK~?k%mg-4^~k9a~ImyD4rgV}kP1dHjw&I?(4f z(QO0oV{cwZAdEI~(5imxu!3%+YWX8AG+tV0(!JigTXmy*U&>OKb#Ax4^|xDD zC$9Q-vK6eX)B;do?E@?uCqF6qhV1$k=jG}MrY)VId>D751`W1Xi*8OckO=pC51hr0N$G*t1CR$&NXY0HgMvEb)XOGuxa9MU4EpvMC#Dwzj&Let7;Wt1eYE*K z?(JsuzV^C$Yo*$IwesxZ{{S}iy^`~|hRaW4QKOhd{5Hjj{IbUxWh%a6AS^~5U9s}F z%s`=grCY@l%M&EAs_pY58U7Z1vd17YMJ6`jkCB|=4&EzKn4FW`=NzpX)_QQs4TcDPIw_$WN=7552+}td@)a!@4ebzdt2X2S*3BN_jhkaeve4L zmbIF`uKxgipS0*Vf=>nbLJ1aka^51{H<+eEt8?T;A$APN`Hm!v?Z{}8AeCL;t3MGu zdlCJeyiwyxJPF}VWjsgVuYrC6xYINnUj%9254* z>h&QwR8;vTFRVk_&~k!slC=4iJ4@bab2k?Y-}?u^I=RjdhB%JCUy;W)ql%?GJF4s9 zsJyA22!3K7clJ zj%)N<5BC^oZlg*Mnb{|J#{Rl3`rg;Jhw^rDK77|OnpRT9R(4w})OS`}wX%OFqA!*_ z3=TcNJmZ27r~d%1tH~kS$7vW`44iNj@^jy|?c9;K1&ZcE>QAqubk1>{^O6Y2#s=ZQ z!*f;TAggUW0gUC3Y-6G0wm{@#qOK09U!|?1^SWO>?Ee5A&o!p>vUXm(xasD$+kKsy zJ;u3NL&UnEYuR*kuYF#6u(b=fkJLtOU z=h^G6I#QdoT1_bFE46QRZ>#Bh`L)*0)wmKc$s=wsNf{$5F_I2Q&tMA-gUQWydTC%+ zu^3+Y#uto}%JH`#VDP*yYmT=HTn>Z)bAy%`J$G_)LW6h=pDQ;~#++Qp|TLjhDv$L~f+8M$D4U8=bktu@0Nb#Q0Hgv;P3WG<+qe z$1II3f3>&8d!uYIwS3Qn{{RhGN~bwy0a;3-hFowC0j+qgw?Ldv9Y)%b;4vr8XRkH00K#lknTl$97sMO#hN`KxQG{)Xka zdNnf+9Hod{oZb{-qf$yYbm2Pqh0N2|{FZ5dnevZ_5@;uqIg#R-7w1VMD(YAa7FPkg zj1l*SG06;k`eTU-Oa^FJ-f|f=hd7ZJJAXk|R1> zqQWJYXa-4~4=~8?%yA@rKalr$)tREBJ%x*OMSpit2cqfzQBXQcm9#~~?Hv`G) zS8)!f>UorDN;X>QtJU7x+V*WbZLYVsf5{^UB`GzeVv}yxyRur_OWmcWi=)uQxYL5z zO3VtWQbyuIA0m^H=mvPl9lGMY`&K!%fZ*-(00eQ5mmnWj!t?#njoEAw>e{W$YXZWo z1I+6Q1aLtI3|M4jjj9F-&qZOAUQ?@lfuk+73yDA+jldiZpa20Q04OA64oL@!qH$J# z%WL_rU6V;W^KE5o+U3W6wOa3eEp6SN+p8{%dnd8seMT!*xwW|)P26eZzILiIE2u(F zHij*by$de@U=v$bke78_6)fA1(!)3^4(@nhGDZg=jP|Z_Hz+{C7$`Rbffru9CWKSzqU?vrR9a)c04~ z-;LG0`g(2H_7Vpf1RRVWdJ;cOXRlsB86eUT#xubg$l;DUaz;oyNIY|l4_amA^`(R_ zb9X955(AygM2_4L6?48vCmT^lNc(-3+Ca?uLO%OLwDyIFPOZ0|X?L1_32S zViE)C?h+7CIwVKuCN*K~`<&nN|6Z^YJNJFA&vm^o--EfV#u|6eHc!X)MWo5R{&JA} znZ1PqJ7Kcix?>`IVZyIjIZWZNBCX|7apbS)Smu!l@d8=8Uhmq|$e;w=3FPgNt~p#>?b4oT*rx;f;Ao%Bi>*vh7ZelPNTtv)%e?rh)x?z>lY z9<;1$B`l2-?&^G_A%8LHg>N~;N02}LmFSloR^K~Ggqc?+riPnj^VGa3>=5&tE|cS8 zuhu?XCNdczP`w$rq4L!v+U*jbkWfI8$je)gd;C2>tyBsso8J)s2SP*EBc2a^4pdBL zQzm8S)S%}6GDNUELdZVXl_8eeph*Pu!U&X@1A>qZUM}7C?FW{Q%%Cr28U)NELnNaL z;7=LT%BmB0!$NN~;BT;^8263Iqr+mH3Rc_+-M`7yZxENXzV;ympXd~=2_Oqz2+`~- z?A57Gk5o*gt=b=4XP!^3+O9YxxTIQJS$~BmhC3E%aow&NB?jz?1@kAeds9v8TVCB| zUiHhvq$N60sF4V#iyx@=f(l6_eu&9uLDP3W<$U_K=_`Q!fR%nqXP)%9L!Z;qt#;4^ z!K+K7o37&%W4XlefJIQcX4<(!`_ndyMritrZ!g?mz+$s#lZEHMRG1ZCSYwbnWvreP zNxG|&;peu_YoZE?S^gEh*@W_30KAsjsfd}stw3Uh#oN#P8Bem9DuE`6fOwwIpL028 zW#V9@)kB_YwP6yH z%w>g0-Gl^%I%qU#7f1^cKdetQljlJOAhLDe%ys%V#TCHbHh+FaI>hL!sUJg3O#*TS z(f!Q;>af^3?-3*u3{Caq5saY9e6_aL*&V;1gK41p$FF~s+^=_TBH;PhGqT+r$U?M! zM~-1MU%_9vQ(OLDE=kdDwZ7c`{GI{-2UvxG2S4L%gFz@zAT;lgnO`$szJ-mqYx4_n z_n*6*n+uy>`)X&Eq)-juaBt6k^eXwxvs01vN}oe|0d=u$OS_e!VJ-fVm0516N=n(! zmrL9M{$y0ULv!jB!zWu2Y*N#?eIDj(BiX56gyozxHVOBLjf)U^;7Kxw$1j|o_#d=0r5;VR06jZhCw_{gi8kt;f$}75z=~(OM)*I@4z6YO-|Nk_#n!SDLqzj?j>D|7MS@ z7(yuIRm{fdq#2nYj6I9ZbKSGk)E6bMA%jY%%0HEq)|X&7ulc%z)N&zenLE{tmI1MS z>)sCL2&<9bH5z%3c*jZ0<e9PXSBO z_*x@VOoQ+}L+t&4|K-KK0FG;a_xsDKgLF*{T~C(~W-@`tfy&x}Vo%El&?34$VEeZ2+An8eb>rpw)U@>F)2}iF>`7J zL63cj1>ZxkWy!|GW~o=msE+kuBd_4#U<1n4wM|E186i4Y#VL}-JMCw>46i8JAF)?z zPSR5BGsG70hU$oW$d#q+-RU2IfVgh!RM?ge!sTuYlteK=L;H~(=4PY&SQsT5)AbNg z!EO{G$4n?7V~(xYK)<^WYt~Gsj$86Tl{3pGOytP#!zN!={n8_M7Y@G;w_L+lcNbR&nsuyw@y4tY%6Mpo-O zuUgfuJ@&gPr4YcDKIwAPFkRT0IAC)Ta1k`+X>JjW+ZaO$ zKqJ~PmT%wRUsJBTX~Urag=`3?^k2O?pj5G$_VI+sc6%7ahf`xmZeHMln}=SB0G`tv zr4)dP9?V&bxrkrSA`KW9t~Q=h4rk_nmM`3)CS9^L+g-@2|I1;JXN0|aJ-DnKBRUk) z-D^M^K6zEU7q_t%rNV=4-mFg8-cRT2zzbrk-+zULTCm2J%zIJJead2`%w7tRGCn8DmCO{?0ix36kO-kJJFy^P0Wt$DFLod6YY$y8$UQ23J>gwY*SH

Zhj|ld%~|RIGHn+Gmi&oQj9Kmh}4#SY*7?@X>1sij-YbO*zxIxv@L1fSfHvq zHx{{8`8D^sRI^SwE8;QZVI`Zb)$`6toxw5 zB6M)yCh5s3q1|C zp1lE@_{b$EHX60qo!4=z0~CzxwNfKCqde^EX>X}EiG5}1^88VGv~omwW+p1$5+IcF zt=tGe;U8V6dY95^-OTZ)ZABds5t=FOxotwB|e=Oz&!FUW{;o;0^5zmCgv4%P<{xRx0Y`XThRus1Q z+Ew5z_oMengGxhxV>&I0*k@$eQ|=+3E8$z>-!bbx`)976q)YTk#Y$A#3@?hwCA5`% zqW_>ZU~;Ged{5F)NnDhK1QXBoTY?i`7;2k&NxWC+_21R;&JakD`}Nw`*FTL6J6YNF zo=Y|T2KEI7&1n1GQH(`GPp09>`nU%;kY0efVai+0m^t%bxFhw?5J&L$E;gKP6Pmi; z>pnL0SzTKr#vrldX_4rACF8#XfG~{pDSgUqu(3wk>sN9>qA{@=3{l>o@h5Q8N1jy(L?uI^Wh35ck@rF2)P8 z=t2;&V`r?vuLK#Q2aHPJ=d;WO>eJ>qG5h}QGUFFWcN0sI%KdG#`yYrER_zrJVN`j5 z-Lg_EOJQ_x4(E*>x*PfRH!`4*akzXLn#Sh;o_ujo$*}yron@hz*s3M&GlHWz=&zzn zuHg(BJYCy;Wa21n)4;wr1w^YCR=!zZWRKIr;P6yH=^6#rch^`KN!OnD001F9imq4#}r9pk}#^rs0Uupyf6&};+VvxvU-oX>IROAKPg}ZL`In`0jmQw-Q z$WgFY=z~jlp`M{U4stiZDrIbcEBCGMm&c{K{*Be@sn-}>GnTKL+4XF|B3Y77X`1

$m?^-R9)k7`IN@!s3ir^YU(pxw3{|h7#K=LFpz7Jx=7@|o77%E zpU6&eosmb?m|mJ0%ZxTH5EIYymgjPX1l1U9?DUZYZoltc`7tCxrFNjKPtXhTf7O}E zlixa<-T5^``=)93q@*p#^LhabshIJ4cIxhye{E-@e`w=|47F^I%yUx7yCc_wBRft! zzkWd;r9np6N(cffYJILEl}-*Mh@qn;Z)Gwj`6)D%wJzlUwdJ95@*fH<5Pa9-=Zf4T zz9)7-%DmB{bRlz~8M{^wq*MD?&F-7q7>gQ+P8@h^Z2v`#BV+Lz}X z`Zi1=46sc_LD>Y82QK!HXD->PIto5b>Ta%TY=PKVc)R04#p!>V*&RXNVw!|v4I=Gz zk{Lt3pdkhC0cSMH6YZf;gT|Pul7h-t4Rv~lVX|KOZPkhU_|XAjJ9%D20j}bzvAJz| z+`n7CB3(G&Ca!^)3r{%w|m&jTu}t#cJyE-ilvf&DR`da#S8=Ls+`@Cnx?n8U36~Y^fMQg-30p) z^yguBIOqmd7yvKmm-i1bka%G!LFbuyn{1=mC;KX*^U{yb!L%y|p_3LnwXAervZ z5LWW;$PTjDn}UZJ3SKLNPkLRsCFO7Kk~0*_ae+_jX`d9vu7|*2Pi0NTPN}-S7mHY< zku3Uo(CLuDW_>*k5yQ-~?O>VHpj(pjxbmb*D!Kw$ajO_@m+BH8bf$MklE8GPr&Xv! zU9*eAZ>4M^HX={9g#$RA9t--@P6@IJ$~_sf*T`9&W8Tu@Qp`8}`BN?MWmV7# zd9Qk_gMu|C>m|$*^9`RiPCc!*^9-m)-)m#wWLmbXE zF@Pd?a+K4eICYZ>jEq zhq<<{**7WiPyh|PLtKWo#IFZP=g08vUyP5&m@aP>o2Ad6H$v;R4m3hs$^ zsd)>yA0a7)WH`wiDmUx69_mG1U98Wh0Si17yM`g@Eo1^b-n+e=EEtW*O|LtaaDMdU zz_39@eFb12 zEAa;P?52vT5iz^OK2}wv$$Mnx`}RJSU!1_5C?MGx%yUu08LY&UkqMRh$8%EH>1K$K zmSy4ReN^h+R9rKVmr{%vVkqW+w7xez)$rnIO`z<+$u&8;EMNYcoLQyR(g)uZ!4#fi zzpJ<&R-rENY{YsBq(L?w{}?WU4M2BahTrBDV}=K~QPl78tmxqc54f@yhGdg>{Je_+ z%~@W)jI9|O9t_MX-?&HGK~s8|{>QB;rR3oEa!KH7#AaM1T+$sgjfzcK-^O^wgfs6$ z=4ke|&_GDi?QCY={nt5)1_FzXGsO67#V~={!NDUg zO$=nSCOQ1^>uypdMbzVl&?NA>MSpxa16(QY`EQT&)}szENxXClosjkTPu?}FSsRP_ z02b$&lRJN;B-A~>r44=gC#iXFn@3$h^{$mKm6(RwU41c9?kEN4^?Yv_>!$#-Q6J+% zr59ry+$83uT4&0^+aY;NC7H-qClhbh6iYwpl90pP3eEc`VywTHo1Ix8DMap38j09c znEvyw%+xsf>wA!Xt9085SLBa_OQNrO*C_B0;RGGGS$L8I3l%b~_IgCze<02dLJaq2 z$_F5*KrcO4ym^`&LE9m{!J&aS+4KVcfeHpyt?;BnRY<3opP@5Ls&_Uw56{B+TvgQ^ z#n+f2{;vFG!MmV`A{~4*9hK6~(0DR9A{hCDFkR=bFX!BD!&FAp;_Uv>_1pSCl*B$PxX0LV)`ut?`mrZC6g|mVib1-=3m0@zQ={z3R8cjxwWE)``&S}f$kmcpsoTBkFtCSvZ*e3LXO>WSJ_(;C_ewpL-l1d zm#spZ{Vgr6IuBt+qt>LcO1`h$`k9_!YDa2u;V0@(?3})2a!M4AfrlgS-}Apl-CL`D zpvtctd;~HU1A(@5po>FSIEK8?!-I_9ol=gQ4cdFifUDQ%^LmZP5u#thndCUhFkN!* zJM=cTUD?|PFGFvCs)Y3rD6mt1V!7|S^W#Pzby+%d6wdJ)Pl&$E^`QWInC@1oP%aM3HJeyF;ip2oBdW+&j8u)~H8wOBHAO~3s;w5bBS z{R2!;kDw@r0}$32UMfw@%Hrghg6mZRp$!_@l#3fZi7V^ihOOF0^!M7EunOoe`=ac# z6Vak4e!cSpFLvW&6!yErjo!ID)*zE+o-kwo*J?`7)1AB0`5&kfes}1V>G$9+YT@Bu zz^B)PKf1lh47qrqFZpqW-`I5D=+fEFBPYuze7QU1#-lo-l%b?3idvBQZ)dJbm zg1pI#)V3jcuWBNz!C9Vp{}> zPKl4?cw7sJ2{^n#D8e=f$VthQ`A9s`?^mh`GzKXg3|9Yoc(SP6(b#R5 z)0+P2WV5|@vt;w+*SC=#FBLwbbXo77f`Plt zy$ly3ovuW9F0@j?i*%q8R@z;;u6@)o25xyde)p%*Q*nVxzPV=gU<%b?Rwmf*4)~%G zGTW(0YFEcJr<{33ma%gyFFAfRMjN%m1~sFBMI z`_b%@TqT;>J&HEUvlHK^Ll2``n2pG3DCOvCx44*&7CKZaJp z^sn@P!_8d%JlyI84w~nzW4^&Q>cd{{ydDVnYcb5WCb=0D^VzHxsPJ%zA>gSMS~a`5 z*i|-Q|4x|5aHqqQvTsAI)l4(m!+79jCiLl2s5)y#NM*}lNyC+Mb=%t0)2>uz+%m_Y zQL-PK)Avtq%(5m2nEsm(f5&3hyxMYei5(~&qgQU#{Ax-3LaSxv#Fl1CUtj#OwT~ z+)bdBtv(sgb}w{~@j%d}RpyGxyz)`(j!Q&qx6|5!JCN6jide5*Yl0-g?KZ$9K;>Y) zSHCc%A41bbwr716=N}f)(}p}fzS@LPEWt^CA1NtUEUxTX$mG~(qYOA+Ti@+7PwbA*&bX0}N}q-Icpi>5n14R5AwI%VBMg+;MlZ2Sd$v5XA@gPJt< zrZzqv#zs2&1{MJ>{J%q++97Eho0O0@>WzxbQ;rl8(pv)QTyjE5JJjN8|E$vDfdUOq zH(b!A?=a6uu!oS*c7fIjciR2?)DwG0mHouasiy2vzXZMQ%) z=+zpq38u%(!sC55IvU32_PhUubqnZim=8WjI4rhYP0;ldf-~_I7=sAtY)7<8E7>3`0 zb2p*EZ&koJ8cg<}1Kpt>5+~4(tt`Wu41|lLQ$9#@Jds3&dQmMO%4Unolz+vB>P0_o znH@^<2)jSwYBbZx3~^x|8PxM@+MmvDXR{y6=NHbHwX(i8;Mw-48<0Af&E)0Pt}xmv z7tkB^G~a>ejXw6AK7-^~HYQ1I_(4P6eVHC4%F?t@;?zD{wX)oeGlY>-QMyvP4MBm% zn?tBbkIQQ%?9XDk7x7owgp}#zk&`T2)N7~M$F9j89!~4PYY`YYuv7q2iKDxb!-b|? zMgjROAo^E-X@=&12d6}54}$9(k%OJx1E=JcZs9B)a~EJ1?1!}{o!#BP8-g3l8-*nV z!4wDCUG(VHT1s3_+&;;%*Eg8WIsonEgWwcHFGFsN0OwPZiXfIe3qqhG75>AX6dPKB z)5H30woj7p3l_t5Fl&QQuU8nEs5eR`d^Ivi6ye=s8=Dl01B-Bpb_@t({yw0}PbbNw zk*BrfByT#d4TWyX^E_=z#+<#0`&g=gx%}J7+}p5cYND^*uAg6Xm$5O|*#6w3*dv0y znQwKKH~nDqp36&9<$*EQT2C!v4Kvri-Kc@}L{RVr5GE^Z2s$P~^J~xe*SvFJfkW3t zsSgyQ+=G@lKYz2ahxs2G_NA$Bve{AF*3bGUNe+nhrImy~n0|BA*n$E8N~%KQNB{fD zM%~4N*CYB*$#EXH)!lAjBADP1o)E({aJo2s$&Gb)0fs?94enr@@aceP;;P&fFDAUw z1ppFpJk@K?R;`^;qVcOZ2uxE8_1MTwp&e=#=l@ikaT^VszQRJ%4r<^QH@Iz5VZu*^ zlh$IB(36)SYy@T)m6d=-4P5RAY@EiMbF$8!>jIC{X;|U&Ho!ZAWKg2q?1T(zYR+)A zPKjQ$G{iUR>OIxf%_WHd=xsC>+@XkaCb}Tfe-`fI(8gOFvcA4s;#l$99)AiZT&`5c<~jP zdXjiPY?^x|AA$pg?Y-Cwa5z?eqCVFc6K9snhjfz`{Oua{hTk!us{2j%lH_x&8l*=L zg4#_G4yPagVuv-r)0obje??=TV79W(dIMLv-T5(Lh0&hP9TxWVP4h))+}ts5<^Is@ zPs$td8+H6|PU+ja?)4lnV;3)eDgyny)2lC>r)jofjRT-8H8C9x-bg_mD7wh8Bt3?( zxaa80?v0dq@O{SXHKSP^H^+H6587Bt!tGk6^4ltuNn*j;{hG4#DB^ozQ_8ytPwE(Z z3M@Bp@fMu$WSARLoEG+OQTR{(oa#m9RKX%ueR|_|pM<1>$7~j6>8# zr}@NuFR(qu@O-y{xjO(4C!_9}E&NnxXR89-R$}0{XP!&U*Sre3_K89vJGp6SvulvM zy1KeYS(c`{bh1v>{tokrj=za*TFv;Qmjc=|(Qys-N4re&2PnQj1yy)^{3GDxt9OaR zvU?n;ROoPg*ceoI?0+Eg2Jj-_`0nxIz%?%9kW4>?)TKhTrOfE9j|&0Tkm=Cuj~ zX7(eiSyQo7$)Pn*&TMI62GVMz<-tGAUQP(cpbz+ny&^UOMd{iTE{r}O_xS=t^ec3< z$&@2>h1-|ME_Ih*C4Le?^5iU?%o$&1XBkhr2M}G>Y17>TlPe-q^lqFAAMo|eAx<}7 z4wyq{ zle_{#y|0=Pdeg#%hOM7HPwh7&MAZLAG+MFP^RzWXQ^j7-!3qpvJuVg^Vp=~0SVDXz zB8n#O!(7)n;&3!i@nmD0;12~f=;W9ZcOaNTO$&pdP6xt(-W2J=0r&2AtX?U2$(n?Q z_DPPtYd7qqVck@RFvs)~a7o^*JoO;XETvRgiKsvN9q|l!$6aGwgfpQFkBJH-fyY-! z_)iFP_j-nkGFGq>>wam7UAbY!iDAtuuo)ZtNzxG%`+*KrxG+@2`#^;}g~_-olY#l5 z=C;BFA@16GrmR(l1Z|88GAyWa|EikhLkh2g-0N?xrb!<^TC_fT{Alz~`KaDl=F;Vw ze_5H8&Hdf@fz49m;!>TeU0!3PChNz4#g7lRM_Wy8OG?>V4xJ+ygA|%R(`AM|8E03# zQ)k=5#tkYbHtdOx;Rao@C%bi(zo9|cRQlcD8s$qI0`Z8sQkX=M7lfm^<&E91yMP07 z#xHPkVH0ld;n$XSQy{L8g~~zJ;Unss2RI0Zx>xzt)ulPs3Fs(xzcx=mQSwTQeppJH z%MS>~5bs!nEngdd(9$N2sY5*tTXOy;;U*qX@ zzIK1iHKNQ8`}F|7rj4pVn3)!NxeEAC85`u7v9p`}wsH?VnW|q4c;~z1n?$Bbhja6* zDx%i`d3y&k5^%7?74^(S%n0QO@QQ7n@&W~k8TMbM^KTwvMh)7F_UTKx^)h&j49Mh(Vmj_(0z)LDMjgWt&&flQ(E*J!(J{OT$zU2 zu^H9Qd@s*w(~&J{@H2aOprJ5in2;EpzT1|GVgu;UA^G&anpWfeG@SOdR zAB&EKPSrKDbUYBur-2!+XcuCHt3-2lu=@jJqFlcBO|`>R3?nA($)EO9!kwurAx(CIAqJ$AD%Aj#H3L z0Yk_B{cJizRo2Mtt4BwmXd%t;VrzP?KZLIUEL< z6q5erTnwjN&Df9JrBN2)c3diW)wC^bJI*& zrn{oAPtjJTYKf_auat8FBd_RbT2EB;ZJ+! z`{2lMPFwwBb~66>PX=$m$D~nzS2uQ&P%h4`$$Z*et`DA1dj5=nXi3|qe~6h+mVQxUebCm|*cj85 z>*a%3f|l5-`!J1JJJ(Xj6;kIF$KGhhQ&d}xirFkY;bX8$^u&DCB4!(jA9$z0rxJ+~ z?Om}k!v>QKknv#&*OU15(PjCY_dH0nu$d(*u(d&%pdiMO=VFBvPiwFUI|n-D@zU|% zzrC{6a;9dt(l?ol-ZS7O-@gXnHcOU3Yee&@Nz7yUCGl@7Eb5QS^0ptV3-Zk7?)+|w z(8Fqf@{y*ZnG}O7o9CH+Yr`(-#5Gi!Q0IyqewH-E@WNc=22A@D&#lG0Tk9Y*)8MK&tOD}E4bGx~@Ir;Cl@Vg8Dqu^GSothyEA*td<`dP&M^2+CxSkLim=Ju0ptvCYRbT-(dZ2#GstiXk8e}0248k zq%*f{f=SLSp78YOcuYB4 z1itjT^0bf_5%A^`68eyEdy;ELR66`|IAD^sxSCJ@d~zzvm%e$h8RRi8boO(Sr+R?y ztlpV0r9JZE>SzQFw!)$?t{Yc<`7%pD9)6;Rp}Jh9@Sd$bw1B=zysHuZfyapc?1_4H zq134G>gDT)X{cC@5Zsz@-ws@|->@IU7-19d=YME6v=aa|=@*#A(CzwvZ*j9oVwdK0 z(WTZN^-34a@y&gMkKE|E%PF&YAUPr-AkSx_{j2!tT{U?DOKbM>xq-RH19P8_!`ZBa zRbSikf(uNsUL7_J*vfrY$h+K}G{bJ(=)iQJznz%2v2$Yu_pec6iw9-1n=Ypx+2oRX zNw0`ZPFOLAeSPHY$G8`XnWS7C+~{6#y@XBu^kUe3k8`+r0@uqgS#3WOl@=w2%S}~3 zw4#Hao#5z@>g+&0(_3|LY9xDJ7c8$`~J#m=dMqok! zB|Zfm)XT|$RYcf7yE(3rHXvUf{{~l|6IxIw`3yzEb!R*D0z3k~vmjL_dA_s?J1FW> z8vBktLb@gn3-$TvooVDCm|JxLOKd6QQis=9DERkm{c}z*(~wnzfOO2pDnM{D`+kv^ z6Z2e8C)p4|KYg|FB&EKRw9CwB)cQ;ir&pC~g~^z?PtC#|rMB>WL-2K zOW90Vm>DvE(rU+~pU4*S_W+ndb2Kc-?{K#R2Xh0SL;-o7R=e7N_@L;Ans@{u%L9@ zen z-CdXdJ6}+2z_I@#|GVP(a=u7k)u8Q*<9e;LqQMgjdA4|PXco_hZ-2~@H*gL89$==M2srkij97)A_X2Wr1XVfa0$cbY^ZUO1} zFI9ianyVCe%(pB)`f_#P3%iOXL2I%%scjUQ(UJk_Qy$~-Zy$*UnX(BbVzm~kchWvN zxikFpX%`841mcvbsYYepYcw}u`xp5qN=@h~r|%*tS$l-T^!O#y-Rc~{Wz%M_ETDa$ z@xr{R^C-X=VXz=;@!2%vN;K4p_f_GmD|hwq_B@y_G6Xjk?DIG3UiSlTv+d$o2?{sn z`ydwHY{keL-CsgZHbITaGTi>D1NSp&A1C;#&?k+5dQSqDnRB_R5{&uf0Z0EG;pP#~ zTK%R43XQ)c(#DU2Z+i&c> z>Db!PSF3ogkgWN{TW#dL?<_L9An(3n&fZ6lV&kck@$*0fy&S#sc`wbMS?^1oo0iQ^ zI?bC8nnSy?9}_m@yGzHzeGm&vR`a@ z@^j{!p39wxFUKtP&{~Km-*cu_<>qF&X5Xz*^~uSFE-uZl;DK0UO?bs><+L7TOh11u zP*X{ZUYuEeV(nwv4D}~eO|5eMB)^1~O-2ghrUZnbP^3ut7yv{WZQi8UqQyWD^w;&t zw)~>JL)X)iTHg?w(JGyd-SI^<%h~#pJWrNf2-zvKEk~}|9bMTe1cb>z9W^|v_ zC0PpJmi3zCQf;YopTPR`<&~xyM3`4+8`viky~8aYBOl75Jv740c+xQ2zJizzn{AN8 zR_Q(>V9tvfhUX=2p;CPnsPadmJOGZu&P{h{9SF{ zPzN)n_TSZ=DKp<4G&@G~wZ>>ngi5NvqD-X698XV<3V2nnx?jQHZ-_3Q4M#Ap88@P- z;@j}ZjpIjdSJn)Tvw7Cr$B9cZSve+8rVs^!ogo*VHNpcq_F2seksqG&1ZD`di+*rl z1%_U?Fz!cM{OeU*n(HHCc^Dtf!Ia82T*%>>1| z2W?j8u&+_{EuY$OryG+?o|6N-INlo_toa%$Fd7=b@Xmq8|FHQtUcw66_A=p~>y(na zj~fQot|!J7>LEGYq>w+hJk0J5lsEKQ^dJUWlirvGZ=nAoW8OhW)cgD^TiW@5l%?nA zWcUO^rCs?ZTgKtw^*kzzk1zb#r>WnLW4vgdl<`x4=-siT_R#lV;eBtZlPR#_Bw)tP zJhNTSqC?x^h)&dRU*gFPbHz zkL%O2|67mD`_-gF#Pqc6&3l?D-7b_&LrYYpsEEblK_8qgI;OF;trPvL(N1)8K87Ka zCtZ{dOIhtH`l_*UZ9B@>|e4gi*fzt#&qpWUEUAfq2arN7h1 z;(wS$it^{2U+*N=9XXxH9bMdLVi6nFg*;50l9-s}{0#>}Y&9h2S~HGoil^y2X0Evc zx>TLd*X;8RNDPHDCD<))x*rO<1a!?d}Q)F59e!XnNecHfDK#Hs`=a6(OXxt zcYk+HD^Jw!j0?RJ_{^#8$1SGH<2rH}IiD`nyK5EKY>JA#jA-%JUDd}md5Zgbmt{jF z(|2-Bk21PAAE>a}bD}@`R*y`(XLs5V7X8GCzl0wH!X%v z6V|@h0NzxYHI{G+oLGY0TsPo)i+^0pR0K5F}2$MagSS9<)_*Oi45?cfB%=`6w>Q_ z#mitG{uJRb^zs)NtrHO!K?Z*cC_;L3|AE{b-aF8Bu%T`6WEXy4_v}F!{^&P2UXl-8 z(y;bT)oe?wauHhuK#MKN@bcSSBX?7|+cxucn!B0R9)VJP1)b^x#ArYKJ^mXh9AMr$ zD!t*sFIhO$l@=Z`0Rp%`W;a1e7-Mtkk3MUPDf?sdy3Mn>ygM17bE+|9*2G*ei=1~; zy4!nZl)@qmW5+gSa?4<&^Trs?&t8?2$AxGsucg9G*MV*7umuU}GBRRkSu@>#tLTpVM3?0v3t&9i_KiBuxA5{QHs!_=|BI=xVP$v7ASwI~I;$8h*=tWU193fNhB0lt zVVyv3{09OQiGwceC)2e5z;=1_4~K73LR-~?N&d( zzorR#6AWBa7TX^!L|O~UBi0KFfoI4P_2pkI^oABreOm-^zx6W*Z08N#FuALJv{mQ%;7W6 z2^j5=@oz>j1Baq{BZ3ROZRs$;xDItU1oer&yI!do>;}w=_i&0E%91_yBAD%(Xk1wJ zep^=D-NCJb$5RiwnsWOv4t-K%x$l_|tl{5cX)X~sTAQa4*m&I`L;Cdl;X?RTqZM$D zJaT3kXKb|<_~xGn$cnu_CEc`_AZ{FdECN|C{SQQ=B90%+DLfVhWQt=n_-G96w*fgG zM#q^$TiL+^@y3~x&3UQu7l^1(#el}f5C4H|v*t~5 zTr-Q*ip#C@VH%bnvaU-7$`xv(^R>@Lan($@ZPtQTqLO#xicKgEhVSWV?=b9X_r2RG z(x8e>hF^XK>K2t|T2E$=f-P|LS9mft51{5{#-ZMzC*o}ZA7*mklnOs1bL|xiC#!;8 zNIeeth8IkUN5cqh(KA<^tWyobkB@|b8i>2I>t5eAgD9+54s5jrmfqh5V}-78bdU`Q zj!tkmqJB3D&jdFH2AMW^#^ER>zfCV%w=Shd^xy|ed#u5LwQSt+L-a*=tUppt(