diff --git a/api/runtime_adapter.py b/api/runtime_adapter.py index 28a63d80..85e4c4e4 100644 --- a/api/runtime_adapter.py +++ b/api/runtime_adapter.py @@ -1,11 +1,13 @@ """RuntimeAdapter seam for WebUI-owned run execution. -This is the #1925 Slice 2 seam only. The default WebUI chat path remains the +This is the #1925 RuntimeAdapter seam. The default WebUI chat path remains the legacy direct route; enabling ``HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal`` routes through this protocol-translator facade over the same legacy execution -path plus the Slice 1 run journal. This module intentionally does not own -AIAgent instances, cancellation flags, approval callbacks, clarify callbacks, or -new long-lived queues. +path plus the Slice 1 run journal. Slice 4 adds a default-off runner-local +selection point for tests and future runner backends, but live chat routes still +stay on the legacy path until a separate route-wiring slice is reviewed. This +module intentionally does not own AIAgent instances, cancellation flags, +approval callbacks, clarify callbacks, or new long-lived queues. """ from __future__ import annotations @@ -17,7 +19,12 @@ from typing import Any, Callable, Iterable, Literal, Protocol _RUNTIME_ADAPTER_ENV = "HERMES_WEBUI_RUNTIME_ADAPTER" _RUNTIME_ADAPTER_DIRECT = "legacy-direct" _RUNTIME_ADAPTER_JOURNAL = "legacy-journal" -_VALID_RUNTIME_ADAPTER_MODES = {_RUNTIME_ADAPTER_DIRECT, _RUNTIME_ADAPTER_JOURNAL} +_RUNTIME_ADAPTER_RUNNER_LOCAL = "runner-local" +_VALID_RUNTIME_ADAPTER_MODES = { + _RUNTIME_ADAPTER_DIRECT, + _RUNTIME_ADAPTER_JOURNAL, + _RUNTIME_ADAPTER_RUNNER_LOCAL, +} @dataclass(frozen=True) @@ -106,6 +113,36 @@ def runtime_adapter_enabled(environ: dict[str, str] | None = None) -> bool: return runtime_adapter_mode(environ) == _RUNTIME_ADAPTER_JOURNAL +def runtime_adapter_runner_enabled(environ: dict[str, str] | None = None) -> bool: + return runtime_adapter_mode(environ) == _RUNTIME_ADAPTER_RUNNER_LOCAL + + +def build_runtime_adapter( + *, + environ: dict[str, str] | None = None, + legacy_adapter_factory: Callable[[], RuntimeAdapter] | None = None, + runner_client_factory: Callable[[], Any] | None = None, +) -> RuntimeAdapter | None: + """Build the configured RuntimeAdapter without changing route behavior. + + ``None`` means the safe default ``legacy-direct`` path should keep using the + existing direct route. ``legacy-journal`` is opt-in and delegates to the + supplied legacy factory. ``runner-local`` is also opt-in and only constructs + a ``RunnerRuntimeAdapter`` around an injected client; this function does not + create process-global runner state or wire live chat to the runner backend. + """ + mode = runtime_adapter_mode(environ) + if mode == _RUNTIME_ADAPTER_DIRECT: + return None + if mode == _RUNTIME_ADAPTER_JOURNAL: + if legacy_adapter_factory is None: + raise NotImplementedError("legacy-journal mode requires a legacy adapter factory") + return legacy_adapter_factory() + if runner_client_factory is None: + raise NotImplementedError("runner-local mode requires a runner client factory") + return RunnerRuntimeAdapter(client=runner_client_factory()) + + def _cursor_to_after_seq(cursor: str | None) -> int | None: if cursor in (None, ""): return None diff --git a/docs/rfcs/hermes-run-adapter-contract.md b/docs/rfcs/hermes-run-adapter-contract.md index 1796ba7e..4c0092f6 100644 --- a/docs/rfcs/hermes-run-adapter-contract.md +++ b/docs/rfcs/hermes-run-adapter-contract.md @@ -780,6 +780,12 @@ client/backend and explicit route selection. #### Slice 4c: Feature-flagged runner backend and restart/reattach harness +Status as of PR #TBD: implementation proposed. The code adds a default-off +`runner-local` adapter selection point plus factory wiring for an injected runner +client, while keeping live browser chat routes on the legacy backend. The +restart/reattach harness remains synthetic/fake-runner based until a later slice +introduces a supervised runner process. + After the facade exists, the next narrow implementation slice should add a real runner-client/backend selection point and a synthetic restart/reattach harness, without routing normal browser chat to that backend yet. diff --git a/tests/test_runtime_adapter_seam.py b/tests/test_runtime_adapter_seam.py index 26bd36de..641b4f31 100644 --- a/tests/test_runtime_adapter_seam.py +++ b/tests/test_runtime_adapter_seam.py @@ -26,9 +26,65 @@ def test_runtime_adapter_interface_and_legacy_journal_methods_exist(): assert runtime.runtime_adapter_enabled({}) is False assert runtime.runtime_adapter_mode({"HERMES_WEBUI_RUNTIME_ADAPTER": "legacy-journal"}) == "legacy-journal" assert runtime.runtime_adapter_enabled({"HERMES_WEBUI_RUNTIME_ADAPTER": "legacy-journal"}) is True + assert runtime.runtime_adapter_mode({"HERMES_WEBUI_RUNTIME_ADAPTER": "runner-local"}) == "runner-local" + assert runtime.runtime_adapter_runner_enabled({"HERMES_WEBUI_RUNTIME_ADAPTER": "runner-local"}) is True assert runtime.runtime_adapter_mode({"HERMES_WEBUI_RUNTIME_ADAPTER": "sidecar"}) == "legacy-direct" +def test_runtime_adapter_factory_selects_only_explicit_default_off_modes(): + runtime = importlib.import_module("api.runtime_adapter") + calls = [] + + class FakeRunnerClient: + pass + + def legacy_factory(): + calls.append("legacy") + return runtime.LegacyJournalRuntimeAdapter(start_run_delegate=lambda request: {"stream_id": "s"}) + + def runner_factory(): + calls.append("runner") + return FakeRunnerClient() + + assert runtime.build_runtime_adapter(environ={}) is None + + legacy = runtime.build_runtime_adapter( + environ={"HERMES_WEBUI_RUNTIME_ADAPTER": "legacy-journal"}, + legacy_adapter_factory=legacy_factory, + runner_client_factory=runner_factory, + ) + assert isinstance(legacy, runtime.LegacyJournalRuntimeAdapter) + + runner = runtime.build_runtime_adapter( + environ={"HERMES_WEBUI_RUNTIME_ADAPTER": "runner-local"}, + legacy_adapter_factory=legacy_factory, + runner_client_factory=runner_factory, + ) + assert isinstance(runner, runtime.RunnerRuntimeAdapter) + assert calls == ["legacy", "runner"] + + +def test_runner_local_factory_requires_injected_client_and_does_not_fallback_to_legacy(): + runtime = importlib.import_module("api.runtime_adapter") + calls = [] + + def legacy_factory(): + calls.append("legacy") + return runtime.LegacyJournalRuntimeAdapter(start_run_delegate=lambda request: {"stream_id": "s"}) + + try: + runtime.build_runtime_adapter( + environ={"HERMES_WEBUI_RUNTIME_ADAPTER": "runner-local"}, + legacy_adapter_factory=legacy_factory, + ) + except NotImplementedError as exc: + assert "runner client factory" in str(exc) + else: + raise AssertionError("runner-local must require an injected runner client factory") + + assert calls == [] + + def test_legacy_journal_adapter_start_run_delegates_without_owning_runtime_state(): runtime = importlib.import_module("api.runtime_adapter") calls = []