Stage 384: PR #2544

This commit is contained in:
nesquena-hermes
2026-05-18 22:44:02 +00:00
6 changed files with 320 additions and 28 deletions
+1
View File
@@ -28,6 +28,7 @@
### Documentation
- **PR #2544** by @Michaelyklam (refs #1925) — Implement the first Slice 3c RuntimeAdapter control routing. `RuntimeAdapter` / `LegacyJournalRuntimeAdapter` now expose `queue_message(...)` and `update_goal(...)` as protocol-translator delegates, and the `/api/goal` route uses `update_goal(...)` only when `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled while preserving the legacy-direct response shape. The change keeps `/queue`'s existing browser-side drain semantics and goal post-turn evaluation in the current agent loop; no runner/sidecar, WebUI-owned queue, goal scheduler, cached-agent table, or execution-survives-restart claim is introduced.
- **PR #2511** by @franksong2702 (refs #2502 / #2503) — Update the `docs/ui-ux/` demo appearance controls to initialize as `class="dark" data-skin="slate"` instead of the deprecated `data-theme`-only buttons and legacy theme names. Brings the demo pages in line with the live Theme + Skin contract referenced from the new `docs/CONTRACTS.md` so contributors following the contract-index path don't land on stale demos.
- **PR #2509** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 3b approval/clarify seam shipped in v0.51.89. The RFC now marks Slice 3b as shipped and defines the next Slice 3c queue/continue + goal control gate: route those controls through `RuntimeAdapter.queue_message(...)` / `update_goal(...)` only after pinning stable response contracts, bounded unavailable-control behavior, replayable lifecycle/status evidence, ordering/idempotency expectations, and explicit non-goals for runner/sidecar ownership or a WebUI-owned queue/goal scheduler. Docs + adapter-seam regression test only — no runtime/control routing changes in this PR.
## [v0.51.89] — 2026-05-18 — Release BM (stage-382 — 6-PR full sweep batch — runtime adapter approval/clarify seam + SOUL.md memory panel + #1855 resolve_model_provider fast-path + PWA sidebar spinner fix + /model active-provider preference + contributor contract docs index)
+36 -6
View File
@@ -7798,6 +7798,18 @@ def _start_chat_stream_for_session(
return response
def _runtime_adapter_goal_action(goal_args: str) -> str:
"""Return the bounded RuntimeAdapter goal action for WebUI /goal args."""
action = str(goal_args or "").strip().lower()
if not action or action == "status":
return "status"
if action in ("pause", "resume"):
return action
if action in ("clear", "stop", "done"):
return "clear"
return "set"
def _handle_goal_command(handler, body):
"""Handle WebUI /goal command controls and optional kickoff stream."""
try:
@@ -7870,12 +7882,30 @@ def _handle_goal_command(handler, body):
)
previous_goal_state = goal_state_snapshot(s.session_id, profile_home=profile_home)
payload = goal_command_payload(
s.session_id,
goal_args,
stream_running=stream_running,
profile_home=profile_home,
)
from api.runtime_adapter import LegacyJournalRuntimeAdapter, runtime_adapter_enabled
def _legacy_goal_update(session_id: str, _action: str, text: str) -> dict:
return goal_command_payload(
session_id,
text,
stream_running=stream_running,
profile_home=profile_home,
)
goal_adapter_action = _runtime_adapter_goal_action(goal_args)
if runtime_adapter_enabled():
adapter = LegacyJournalRuntimeAdapter(goal_delegate=_legacy_goal_update)
control_result = adapter.update_goal(
s.session_id,
goal_adapter_action,
goal_args,
)
# Slice 3c keeps the adapter as a structural seam only. Preserve the
# public /api/goal response by passing through the legacy payload rather
# than deriving HTTP behavior from ControlResult.accepted/status.
payload = dict(control_result.payload)
else:
payload = _legacy_goal_update(s.session_id, goal_adapter_action, goal_args)
if not payload.get("ok", True):
status = 409 if payload.get("error") == "agent_running" else 400
return j(handler, payload, status=status)
+45 -1
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
import os
from pathlib import Path
from typing import Any, Callable, Iterable, Protocol
from typing import Any, Callable, Iterable, Literal, Protocol
_RUNTIME_ADAPTER_ENV = "HERMES_WEBUI_RUNTIME_ADAPTER"
_RUNTIME_ADAPTER_DIRECT = "legacy-direct"
@@ -72,6 +72,7 @@ class ControlResult:
status: str = "accepted"
event_id: str | None = None
safe_message: str | None = None
payload: dict[str, Any] = field(default_factory=dict)
class RuntimeAdapter(Protocol):
@@ -81,6 +82,13 @@ class RuntimeAdapter(Protocol):
def cancel_run(self, run_id: str) -> ControlResult: ...
def respond_approval(self, run_id: str, approval_id: str, choice: str) -> ControlResult: ...
def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> ControlResult: ...
def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult: ...
def update_goal(
self,
session_id: str,
action: Literal["set", "pause", "resume", "clear", "status", "edit"],
text: str = "",
) -> ControlResult: ...
def runtime_adapter_mode(environ: dict[str, str] | None = None) -> str:
@@ -107,6 +115,23 @@ def _cursor_to_after_seq(cursor: str | None) -> int | None:
def _active_control_result(value: Any) -> ControlResult:
"""Normalize legacy delegate responses without changing their payloads.
``status`` is an adapter-level summary used by current control tests and
future runtime backends. For legacy goal payloads it may mirror the goal
action (``set`` / ``pause`` / ``status``), while public route behavior keeps
using the payload itself to preserve existing HTTP response shapes.
"""
if isinstance(value, ControlResult):
return value
if isinstance(value, dict):
accepted = bool(value.get("ok", True))
return ControlResult(
accepted=accepted,
status=str(value.get("status") or value.get("action") or ("accepted" if accepted else "not-active")),
safe_message=value.get("message") if not accepted else None,
payload=dict(value),
)
accepted = bool(value)
return ControlResult(
accepted=accepted,
@@ -129,6 +154,8 @@ class LegacyJournalRuntimeAdapter:
cancel_delegate: Callable[[str], Any] | None = None,
approval_delegate: Callable[[str, str, str], Any] | None = None,
clarify_delegate: Callable[[str, str, str], Any] | None = None,
queue_delegate: Callable[[str, str, str], Any] | None = None,
goal_delegate: Callable[[str, str, str], Any] | None = None,
live_stream_lookup: Callable[[str], bool] | None = None,
session_dir: Path | None = None,
):
@@ -136,6 +163,8 @@ class LegacyJournalRuntimeAdapter:
self._cancel_delegate = cancel_delegate
self._approval_delegate = approval_delegate
self._clarify_delegate = clarify_delegate
self._queue_delegate = queue_delegate
self._goal_delegate = goal_delegate
self._live_stream_lookup = live_stream_lookup or (lambda _run_id: False)
self._session_dir = Path(session_dir) if session_dir is not None else None
@@ -221,3 +250,18 @@ class LegacyJournalRuntimeAdapter:
if self._clarify_delegate is None:
return ControlResult(False, status="unsupported", safe_message="Clarify is delegated to the legacy path.")
return _active_control_result(self._clarify_delegate(run_id, clarify_id, response))
def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult:
if self._queue_delegate is None:
return ControlResult(False, status="unsupported", safe_message="Queue is delegated to the legacy path.")
return _active_control_result(self._queue_delegate(run_id, message, mode))
def update_goal(
self,
session_id: str,
action: Literal["set", "pause", "resume", "clear", "status", "edit"],
text: str = "",
) -> ControlResult:
if self._goal_delegate is None:
return ControlResult(False, status="unsupported", safe_message="Goal is delegated to the legacy path.")
return _active_control_result(self._goal_delegate(session_id, action, text))
+22 -10
View File
@@ -82,11 +82,14 @@ adapter-seam work:
- #2479 shipped the first Slice 3a implementation in v0.51.86, routing Stop
Generation through `RuntimeAdapter.cancel_run(...)` only when
`HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled.
- #2487 shipped the Slice 3b approval/clarify gate, and #2496 shipped approval /
clarify response routing through the adapter seam in v0.51.89.
- #2509 shipped the Slice 3c queue/continue + goal gate in v0.51.90.
The next gate is not the runner/sidecar yet. It is the next control migration
gate: approval and clarify must be specified together before implementation,
because both are callback-backed, user-mediated pauses in the active agent loop
and have the same state-lifetime/replay hazards.
The next gate is not the runner/sidecar yet. It is the Slice 3c implementation:
add the queue/goal adapter methods and route accepted legacy control paths
through them without moving goal evaluation, continuation scheduling, or
execution ownership out of the existing agent loop.
## Goals
@@ -354,7 +357,15 @@ class RuntimeAdapter:
`queue_message` is named for the legacy `/api/session/queue` payload: it
accepts follow-up chat text rather than arbitrary runtime input. The method name
does not require the HTTP route to change; it documents the adapter-level control
semantics that a later Slice 3c implementation should preserve.
semantics that a later Slice 3c implementation should preserve. The method enters
the protocol before route wiring so queue/continue can land as a separate, small
control-routing follow-up instead of being coupled to goal routing.
For `update_goal`, the `action` argument is the bounded adapter capability label.
During the legacy-journal slice, the legacy goal parser still receives the full
`text` payload and remains authoritative for details such as the body of
`set <goal text>`. Future slices must not route goal semantics from `action`
alone; doing so would drop the goal body and change `/api/goal` behavior.
Required data classes / payload fields:
@@ -364,7 +375,7 @@ Required data classes / payload fields:
| `RunStartResult` | `run_id`, `session_id`, `stream_id`, `status`, `started_at`, `cursor`, `active_controls` | `stream_id` may remain the legacy stream id during Slice 2. |
| `RunStatus` | `run_id`, `session_id`, `status`, `last_event_id`, `terminal_state`, `active_controls`, `pending_approval_id`, `pending_clarify_id` | Backed by live legacy state plus journal/session metadata. |
| `RunEventStream` | ordered events matching Artifact 1, resumable from cursor | Can be implemented by existing SSE + journal replay at first. |
| `ControlResult` | `accepted`, `status`, `event_id`, `safe_message` | Controls may still call existing handlers in Slice 2. |
| `ControlResult` | `accepted`, `status`, `event_id`, `safe_message`, optional internal `payload` | Controls may still call existing handlers in Slice 2. Public HTTP responses must not leak adapter-only fields unless a later RFC expands them. |
The interface is intentionally narrower than a runner. It does not own `AIAgent`,
tool execution, callback queues, cancellation flags, approval callbacks, or
@@ -438,13 +449,14 @@ execution-survives-WebUI-restart gate remains deferred to Slice 4.
### Slice 3: Control migration
Status as of 2026-05-18: Slice 3a cancel routing shipped in v0.51.86 via #2479,
and Slice 3b approval/clarify routing shipped in v0.51.89 via #2496 / #2507.
Slice 3b approval/clarify routing shipped in v0.51.89 via #2496 / #2507, and
the Slice 3c queue/continue + goal gate shipped in v0.51.90 via #2509.
Cancel was the smallest control-plane migration because it already had one clear
browser affordance, one active-run target, and an existing legacy handler to
delegate to. Approval and clarify then proved the same protocol-translator shape
for user-mediated callback controls. Queue/continue and goal remain intentionally
held behind the next gate because they can change run lifecycle semantics rather
than just resolve an already-pending control.
for user-mediated callback controls. Queue/continue and goal are the final
pre-runner control migration because they can change run lifecycle semantics
rather than just resolve an already-pending control.
Scope:
+138
View File
@@ -230,6 +230,144 @@ def test_goal_endpoint_sets_goal_and_starts_kickoff_stream(monkeypatch, tmp_path
assert started[0]["model_provider"] == "openai-codex"
def test_goal_endpoint_preserves_response_shape_under_runtime_adapter_flag(monkeypatch, tmp_path):
"""The Slice 3c adapter path delegates /goal without adding adapter-only fields."""
from api import goals as webui_goals
from api import routes
class FakeState:
goal = "ship the feature"
status = "active"
turns_used = 1
max_turns = 20
last_verdict = None
last_reason = None
paused_reason = None
class FakeGoalManager:
def __init__(self, session_id, default_max_turns=20):
self.state = FakeState()
class FakeSession:
session_id = "sid-goal-route"
profile = "default"
workspace = str(tmp_path)
model = "gpt-5.5"
model_provider = "openai-codex"
messages = []
context_messages = []
pending_user_message = None
active_stream_id = None
monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "legacy-journal")
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
monkeypatch.setattr(routes, "j", lambda handler, payload, status=200, **kwargs: {"status": status, "payload": payload})
result = routes._handle_goal_command(object(), {"session_id": "sid-goal-route", "args": "status"})
assert result["status"] == 200
assert result["payload"]["action"] == "status"
assert result["payload"]["message_key"] == "goal_status_active"
assert "run_id" not in result["payload"]
assert "active_controls" not in result["payload"]
def test_goal_endpoint_adapter_keeps_full_set_text_and_legacy_payload_status(monkeypatch, tmp_path):
"""The adapter action label must not replace legacy parsing of full goal text."""
from api import goals as webui_goals
from api import routes
set_calls = []
class FakeState:
goal = ""
status = "active"
turns_used = 0
max_turns = 20
last_verdict = None
last_reason = None
paused_reason = None
class FakeGoalManager:
def __init__(self, session_id, default_max_turns=20):
self.state = FakeState()
def set(self, text):
set_calls.append(text)
self.state.goal = text
return self.state
class FakeSession:
session_id = "sid-goal-route"
profile = "default"
workspace = str(tmp_path)
model = "gpt-5.5"
model_provider = "openai-codex"
messages = []
context_messages = []
pending_user_message = None
active_stream_id = None
monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "legacy-journal")
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path)
monkeypatch.setattr(
routes,
"_resolve_compatible_session_model_state",
lambda model, provider: (model, provider, False),
)
monkeypatch.setattr(
routes,
"_start_chat_stream_for_session",
lambda session, **kwargs: {"stream_id": "goal-stream", "session_id": session.session_id},
)
monkeypatch.setattr(routes, "j", lambda handler, payload, status=200, **kwargs: {"status": status, "payload": payload})
result = routes._handle_goal_command(object(), {"session_id": "sid-goal-route", "args": "set foo"})
assert result["status"] == 200
assert result["payload"]["action"] == "set"
assert result["payload"]["kickoff_prompt"] == "set foo"
assert set_calls == ["set foo"]
def test_goal_endpoint_adapter_error_payload_still_controls_http_status(monkeypatch, tmp_path):
"""The /goal route preserves legacy error/status handling under the adapter flag."""
from api import goals as webui_goals
from api import routes
class FakeGoalManager:
state = None
def __init__(self, session_id, default_max_turns=20):
pass
class FakeSession:
session_id = "sid-goal-route"
profile = "default"
workspace = str(tmp_path)
model = "gpt-5.5"
model_provider = "openai-codex"
messages = []
context_messages = []
pending_user_message = None
active_stream_id = "running-stream"
monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "legacy-journal")
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
monkeypatch.setitem(routes.STREAMS, "running-stream", {"queue": object()})
monkeypatch.setattr(routes, "j", lambda handler, payload, status=200, **kwargs: {"status": status, "payload": payload})
result = routes._handle_goal_command(object(), {"session_id": "sid-goal-route", "args": "ship it"})
assert result["status"] == 409
assert result["payload"]["ok"] is False
assert result["payload"]["error"] == "agent_running"
def test_routes_register_goal_endpoint_and_kickoff_stream():
assert 'if parsed.path == "/api/goal"' in ROUTES_PY
assert "return _handle_goal_command(handler, body)" in ROUTES_PY
+78 -11
View File
@@ -12,6 +12,8 @@ def test_runtime_adapter_interface_and_legacy_journal_methods_exist():
"cancel_run",
"respond_approval",
"respond_clarify",
"queue_message",
"update_goal",
)
for name in required:
assert hasattr(runtime.RuntimeAdapter, name)
@@ -24,17 +26,6 @@ def test_runtime_adapter_interface_and_legacy_journal_methods_exist():
assert runtime.runtime_adapter_mode({"HERMES_WEBUI_RUNTIME_ADAPTER": "sidecar"}) == "legacy-direct"
def test_queue_goal_adapter_methods_remain_docs_only_until_slice3c_implementation():
# REMOVAL REQUIRED: delete this test as part of Slice 3c implementation (refs #1925),
# at the point queue_message / update_goal land on RuntimeAdapter / LegacyJournalRuntimeAdapter.
# See docs/rfcs/hermes-run-adapter-contract.md Slice 3c acceptance + non-goals.
runtime = importlib.import_module("api.runtime_adapter")
for name in ("queue_message", "queue_input", "update_goal"):
assert not hasattr(runtime.RuntimeAdapter, name)
assert not hasattr(runtime.LegacyJournalRuntimeAdapter, name)
def test_legacy_journal_adapter_start_run_delegates_without_owning_runtime_state():
runtime = importlib.import_module("api.runtime_adapter")
calls = []
@@ -120,6 +111,30 @@ def test_legacy_journal_adapter_controls_delegate_to_existing_handlers():
]
def test_legacy_journal_adapter_queue_and_goal_delegate_without_owning_runtime_state():
runtime = importlib.import_module("api.runtime_adapter")
calls = []
adapter = runtime.LegacyJournalRuntimeAdapter(
queue_delegate=lambda run_id, message, mode: calls.append(("queue", run_id, message, mode)) or True,
goal_delegate=lambda session_id, action, text: calls.append(("goal", session_id, action, text)) or {
"ok": True,
"action": action,
"message": "Goal updated.",
},
)
queued = adapter.queue_message("r1", "follow up", mode="queue")
goal = adapter.update_goal("s1", "set", "finish the task")
assert queued.accepted is True
assert goal.accepted is True
assert goal.payload["action"] == "set"
assert calls == [
("queue", "r1", "follow up", "queue"),
("goal", "s1", "set", "finish the task"),
]
def test_legacy_journal_adapter_cancel_returns_bounded_not_active_status():
runtime = importlib.import_module("api.runtime_adapter")
calls = []
@@ -156,6 +171,29 @@ def test_legacy_journal_adapter_approval_and_clarify_return_bounded_not_active_s
assert clarify.status == "not-active"
def test_legacy_journal_adapter_queue_and_goal_return_bounded_statuses():
runtime = importlib.import_module("api.runtime_adapter")
adapter = runtime.LegacyJournalRuntimeAdapter(
queue_delegate=lambda run_id, message, mode: False,
goal_delegate=lambda session_id, action, text: {
"ok": False,
"action": action,
"error": "agent_running",
"message": "Agent is running.",
},
)
queued = adapter.queue_message("already-finished-run", "follow up")
goal = adapter.update_goal("s1", "set", "new goal")
assert queued.accepted is False
assert queued.status == "not-active"
assert goal.accepted is False
assert goal.status == "set"
assert goal.safe_message == "Agent is running."
assert goal.payload["error"] == "agent_running"
def test_chat_cancel_route_uses_adapter_only_when_flag_enabled():
routes = importlib.import_module("api.routes")
src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8")
@@ -191,6 +229,35 @@ def test_approval_and_clarify_routes_use_adapter_only_when_flag_enabled():
assert "HERMES_WEBUI_RUNTIME_ADAPTER" not in clarify_body
def test_goal_route_uses_adapter_only_when_flag_enabled():
routes = importlib.import_module("api.routes")
src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8")
goal_idx = src.index("def _handle_goal_command")
goal_body = src[goal_idx:src.index("def _handle_chat_start", goal_idx)]
assert "runtime_adapter_enabled()" in goal_body
assert "LegacyJournalRuntimeAdapter(goal_delegate=_legacy_goal_update)" in goal_body
assert "goal_adapter_action = _runtime_adapter_goal_action(goal_args)" in goal_body
assert "adapter.update_goal(" in goal_body
assert "goal_adapter_action," in goal_body
assert "payload = dict(control_result.payload)" in goal_body
assert "else:\n payload = _legacy_goal_update" in goal_body
assert "HERMES_WEBUI_RUNTIME_ADAPTER" not in goal_body
def test_goal_adapter_action_is_bounded_to_slice3c_actions():
routes = importlib.import_module("api.routes")
assert routes._runtime_adapter_goal_action("") == "status"
assert routes._runtime_adapter_goal_action("status") == "status"
assert routes._runtime_adapter_goal_action("pause") == "pause"
assert routes._runtime_adapter_goal_action("resume") == "resume"
assert routes._runtime_adapter_goal_action("clear") == "clear"
assert routes._runtime_adapter_goal_action("stop") == "clear"
assert routes._runtime_adapter_goal_action("done") == "clear"
assert routes._runtime_adapter_goal_action("ship #1925") == "set"
def test_approval_respond_does_not_fallback_to_oldest_when_explicit_id_is_stale():
routes = importlib.import_module("api.routes")
src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8")