diff --git a/agent/relay_llm.py b/agent/relay_llm.py index 58a5c1bd5e..7758a2bdcf 100644 --- a/agent/relay_llm.py +++ b/agent/relay_llm.py @@ -897,7 +897,8 @@ def _complete_logical( output["response_model"] = response_model_name lease.host.run_in_session( lease.session, - lease.host.relay.scope.pop, + relay_runtime.pop_relay_scope, + lease.host.relay, handle, output=output, metadata={ diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index f4f15b552d..003213d87d 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -94,6 +94,40 @@ def _run_bounded_on_exit_thread(fn: Callable[[], Any], timeout: float) -> Any: return result[0] if result else None +def pop_relay_scope( + relay: Any, + handle: Any, + *, + output: Any = None, + metadata: Any = None, + timestamp: Any = None, +) -> Any: + """Pop a Relay scope without passing kwargs the binding rejects. + + NeMo Relay ``scope.pop`` gained ``metadata`` in 0.4+. Older wheels (e.g. + 0.3.x) raise ``TypeError: pop() got an unexpected keyword argument + 'metadata'`` when Hermes finalization forwards runtime metadata. Filter to + parameters the live binding accepts so turn/session close can complete. + """ + pop = relay.scope.pop + kwargs: dict[str, Any] = {} + if output is not None: + kwargs["output"] = output + if metadata is not None: + kwargs["metadata"] = metadata + if timestamp is not None: + kwargs["timestamp"] = timestamp + try: + params = inspect.signature(pop).parameters + except (TypeError, ValueError): + params = {} + if params and not any( + param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values() + ): + kwargs = {key: value for key, value in kwargs.items() if key in params} + return pop(handle, **kwargs) + + @dataclass class RelaySession: """One isolated Relay scope stack owned by a Hermes session.""" @@ -609,7 +643,8 @@ class RelayRuntime: return a_uuid is not None and a_uuid == b_uuid try: - self.relay.scope.pop( + pop_relay_scope( + self.relay, handle, output=close_output, metadata=metadata, @@ -630,7 +665,8 @@ class RelayRuntime: ): break try: - self.relay.scope.pop( + pop_relay_scope( + self.relay, top, output={ "outcome": "cancelled", @@ -654,7 +690,8 @@ class RelayRuntime: handle, ) try: - self.relay.scope.pop( + pop_relay_scope( + self.relay, handle, output=close_output, metadata=metadata, diff --git a/hermes_cli/observability/relay_shared_metrics.py b/hermes_cli/observability/relay_shared_metrics.py index 756c6caa88..2ab88f51c3 100644 --- a/hermes_cli/observability/relay_shared_metrics.py +++ b/hermes_cli/observability/relay_shared_metrics.py @@ -1026,7 +1026,8 @@ class _Runtime: try: self._run_in_task( task, - self.relay.scope.pop, + relay_runtime.pop_relay_scope, + self.relay, task.handle, output=fields, metadata=self._event_metadata(), diff --git a/tests/agent/test_relay_scope_pop_metadata.py b/tests/agent/test_relay_scope_pop_metadata.py new file mode 100644 index 0000000000..b307d7eee7 --- /dev/null +++ b/tests/agent/test_relay_scope_pop_metadata.py @@ -0,0 +1,121 @@ +"""Regression for #78993: scope.pop metadata kwarg on older NeMo Relay.""" + +from __future__ import annotations + +import inspect +import logging +import tempfile +from types import SimpleNamespace + +import pytest + +from agent import relay_runtime + + +def test_pop_relay_scope_omits_unsupported_metadata_kwarg(): + calls: list[tuple[object, dict]] = [] + + def pop_without_metadata(handle, *, output=None): + calls.append((handle, {"output": output})) + + relay = SimpleNamespace(scope=SimpleNamespace(pop=pop_without_metadata)) + handle = ("scope", "hermes.turn", 1) + + relay_runtime.pop_relay_scope( + relay, + handle, + output={"outcome": "success"}, + metadata={"hermes.relay.schema_version": "hermes.relay.runtime.v1"}, + ) + + assert calls == [(handle, {"output": {"outcome": "success"}})] + + +def test_pop_relay_scope_forwards_metadata_when_supported(): + calls: list[tuple[object, dict]] = [] + + def pop_with_metadata(handle, *, output=None, metadata=None, timestamp=None): + calls.append( + ( + handle, + { + "output": output, + "metadata": metadata, + "timestamp": timestamp, + }, + ) + ) + + relay = SimpleNamespace(scope=SimpleNamespace(pop=pop_with_metadata)) + handle = ("scope", "hermes.turn", 2) + metadata = {"hermes.relay.runtime_instance": "abc"} + + relay_runtime.pop_relay_scope( + relay, + handle, + output={"outcome": "error"}, + metadata=metadata, + ) + + assert calls == [ + ( + handle, + { + "output": {"outcome": "error"}, + "metadata": metadata, + "timestamp": None, + }, + ) + ] + + +def test_end_turn_finalization_survives_pop_without_metadata(monkeypatch, caplog): + """Mirror #78993: nemo-relay 0.3.x rejects metadata= on scope.pop.""" + pytest.importorskip("nemo_relay") + + monkeypatch.setenv("HERMES_HOME", tempfile.mkdtemp()) + relay_runtime._reset_for_tests() + lease = relay_runtime.SESSION_COORDINATOR.acquire_conversation( + profile_key=relay_runtime.current_profile_key(), + session_id="session-78993", + platform="cli", + ) + turn = relay_runtime.SESSION_COORDINATOR.begin_turn( + lease, + turn_id="turn-1", + task_id="task-1", + ) + lease.host.retain_managed_execution("test.relay_scope_pop") + + original_pop = lease.host.relay.scope.pop + assert "metadata" in inspect.signature(original_pop).parameters + + def pop_without_metadata(handle, *, output=None, timestamp=None): + return original_pop(handle, output=output, timestamp=timestamp) + + monkeypatch.setattr(lease.host.relay.scope, "pop", pop_without_metadata) + + logical = lease.host.run_in_session( + lease.session, + lease.host.relay.scope.push, + "logical-llm", + lease.host.relay.ScopeType.Custom, + handle=turn.handle, + input={}, + metadata={"hermes.test": True}, + ) + turn.logical_llm_calls["api-1"] = logical + + with caplog.at_level(logging.WARNING, logger="agent.relay_runtime"): + relay_runtime.SESSION_COORDINATOR.end_turn(turn, outcome="success") + + joined = "\n".join(record.getMessage() for record in caplog.records) + assert "unexpected keyword argument 'metadata'" not in joined + assert "turn finalization failed" not in joined + assert "logical LLM finalization failed" not in joined + assert turn.logical_llm_calls == {} + assert turn.closed is True + + lease.host.release_managed_execution("test.relay_scope_pop") + relay_runtime.SESSION_COORDINATOR.release_conversation(lease) + relay_runtime._reset_for_tests()