Stage 398: PR #2696 — feat(runtime): add runner-local adapter selection (RuntimeAdapter slice 4c, feature-flagged)

Co-authored-by: Michaelyklam <Michaelyklam@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-05-21 17:43:54 +00:00
parent 6864739e55
commit f563d37244
3 changed files with 104 additions and 5 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 = []