Skip to content

feat(runtime): route goal through adapter seam#2544

Merged
2 commits merged into
nesquena:masterfrom
Michaelyklam:feat/1925-runtime-adapter-queue-goal
May 18, 2026
Merged

feat(runtime): route goal through adapter seam#2544
2 commits merged into
nesquena:masterfrom
Michaelyklam:feat/1925-runtime-adapter-queue-goal

Conversation

@Michaelyklam
Copy link
Copy Markdown
Contributor

Thinking Path

  • RFC: Make WebUI a thin observability/control client over Hermes Agent runtime #1925 has shipped the journal/replay baseline, default-off RuntimeAdapter seam, cancel routing, and approval/clarify routing.
  • PR docs(runtime): define queue goal control gate #2509 / v0.51.90 accepted the Slice 3c queue/continue + goal gate before implementation.
  • The safe next code slice is still a protocol translator: add the queue/goal adapter methods and route the accepted legacy goal control path through RuntimeAdapter.update_goal(...) when HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal is enabled.
  • Queue remains explicitly scoped to the existing browser-side /queue drain into /api/chat/start; this PR does not invent a backend queue or continuation scheduler.

What Changed

  • Added queue_message(...) and update_goal(...) to the RuntimeAdapter protocol and LegacyJournalRuntimeAdapter.
  • Added delegate wiring for queue and goal controls without adding new long-lived queues, callback registries, cached agents, runner processes, or sidecars.
  • Routed /api/goal through LegacyJournalRuntimeAdapter.update_goal(...) only under the default-off legacy-journal adapter flag.
  • Preserved the public /api/goal response shape by returning the legacy goal payload rather than adapter-only fields.
  • Updated the RFC: Make WebUI a thin observability/control client over Hermes Agent runtime #1925 RFC status and CHANGELOG.md for the Slice 3c implementation.
  • Replaced the docs-only guard test from docs(runtime): define queue goal control gate #2509 with implementation tests for the new adapter methods and goal route.

Why It Matters

This advances #1925’s control-plane migration without jumping to runner/sidecar execution ownership. Goal controls now cross the same adapter seam as cancel, approval, and clarify under the opt-in flag, while post-turn goal evaluation and continuation remain owned by the existing agent conversation loop until the later runner/sidecar slice.

Verification

  • env -u HERMES_CONFIG_PATH -u HERMES_WEBUI_HOST /home/michael/.hermes/hermes-agent/venv/bin/python -m pytest tests/test_runtime_adapter_seam.py -q15 passed
  • env -u HERMES_CONFIG_PATH -u HERMES_WEBUI_HOST /home/michael/.hermes/hermes-agent/venv/bin/python -m pytest tests/test_runtime_adapter_seam.py tests/test_goal_command_webui.py tests/test_issue_1932_goal_hook_unrelated_turns.py tests/test_stage326_pending_goal_continuation_race.py tests/test_1062_busy_input_modes.py tests/test_cmd_idle_fallback.py -q83 passed
  • env -u HERMES_CONFIG_PATH -u HERMES_WEBUI_HOST /home/michael/.hermes/hermes-agent/venv/bin/python -m pytest tests/ -q5956 passed, 6 skipped, 3 xpassed, 8 subtests passed in 92.72s
  • /home/michael/.hermes/hermes-agent/venv/bin/python -m py_compile api/runtime_adapter.py api/routes.py tests/test_runtime_adapter_seam.py tests/test_goal_command_webui.py
  • git diff --check

Risks / Follow-ups

  • /queue remains browser-side queued input that drains through the existing chat-start path; this PR adds the adapter delegate seam but does not create a backend queue API or durable scheduler.
  • update_goal(...) controls goal state mutations only. Post-turn goal evaluation and continuation still live in the existing agent loop, per the docs(runtime): define queue goal control gate #2509 gate.
  • No runner/sidecar, no execution-survives-WebUI-restart behavior, and no durable pending queue/goal scheduler are introduced here.

Model Used

OpenAI Codex GPT-5.5 via Hermes Agent, with terminal/file tools for code changes, tests, git, and GitHub PR workflow.

Refs #1925

@Michaelyklam Michaelyklam force-pushed the feat/1925-runtime-adapter-queue-goal branch from 17b8b74 to b23fb6c Compare May 18, 2026 17:30
@Michaelyklam
Copy link
Copy Markdown
Contributor Author

CI is green on the amended Slice 3c implementation head b23fb6c.

Verification recap:

  • GitHub Actions: test (3.11), test (3.12), and test (3.13) all passed.
  • Local focused queue/goal/runtime-adapter suite: 83 passed.
  • Local full suite: 5956 passed, 6 skipped, 3 xpassed, 8 subtests passed.
  • py_compile and git diff --check passed.

Scope remains intentionally narrow: adapter delegate methods plus /api/goal flag-gated routing only. No backend queue scheduler, goal scheduler, runner/sidecar ownership move, or public response-shape expansion.

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Reviewing head b23fb6c: api/runtime_adapter.py diff, api/routes.py:7798-7910 for the goal route, the two new tests, and the RFC update.

The slice does what it advertises and the response‑shape preservation test at tests/test_goal_command_webui.py:233-273 is the right gate ("no run_id, no active_controls leaking into the public body"). The agent‑side contract is unchanged because the route never calls into the agent for goal updates — it's all goal_command_payload(...) under api/goals.py:421. So the layering is clean: WebUI‑only seam, no agent‑repo coupling introduced.

A few observations worth weighing before merge.

1. Under the flag, the adapter is a structural no‑op — accepted / safe_message are computed and discarded

The route at api/routes.py:7895-7902 unwraps the ControlResult by reading control_result.payload:

if runtime_adapter_enabled():
    adapter = LegacyJournalRuntimeAdapter(goal_delegate=_legacy_goal_update)
    control_result = adapter.update_goal(
        s.session_id,
        _runtime_adapter_goal_action(goal_args),
        goal_args,
    )
    payload = dict(control_result.payload)
else:
    payload = _legacy_goal_update(s.session_id, _runtime_adapter_goal_action(goal_args), goal_args)

Then payload.get("ok", True) controls the HTTP status. The accepted: bool / safe_message: str | None fields that _active_control_result(value) populates at runtime_adapter.py:118-126 are computed but never read by the consumer. That's consistent with the Slice 3c "preserve response shape" goal, but it does mean the seam isn't actually mediating anything under the flag — it's a passthrough that happens to box and unbox a dict.

That's a real cost: anyone reading this in six months will assume ControlResult.accepted carries semantic weight here. Suggest a one‑line comment at routes.py:7895 making explicit that the dict pass‑through is intentional and accepted isn't read, e.g.:

# Slice 3c: the adapter is a structural seam; we deliberately use the
# legacy payload (not control_result.accepted / .safe_message) so the
# public /goal response shape is identical with and without the flag.

2. _runtime_adapter_goal_action(goal_args) is computed twice and then discarded

The action label is passed into both the adapter call and the legacy branch:

adapter.update_goal(s.session_id, _runtime_adapter_goal_action(goal_args), goal_args)
# and
payload = _legacy_goal_update(s.session_id, _runtime_adapter_goal_action(goal_args), goal_args)

The delegate signature is _legacy_goal_update(session_id, _action, text) and the action parameter is prefixed _ because it's deliberately ignored — goal_command_payload(session_id, text, ...) parses the action out of text itself at goals.py:447-499. So the action mapping is currently a typed contract for a future runner but not exercised anywhere. That's fine; just consider whether the RFC update at docs/rfcs/hermes-run-adapter-contract.md should add a note clarifying that Slice 3c maps actions for the protocol surface only and that legacy action parsing still wins.

A small bug shadow here: _runtime_adapter_goal_action("set foo") returns "set", which is correct, but _runtime_adapter_goal_action("status") returns "status" — both rely on text being the full goal_args to do the real work. If a future slice routes through _action instead of text, "set foo" loses the "foo" body. Worth a test case asserting that swap is not yet attempted.

3. queue_message is added to the protocol + adapter + tests but has no caller in this PR

Routes diff shows only the goal route is wired. grep queue_message api/ returns only api/runtime_adapter.py. That's OK if the next PR adds /api/chat/queue or similar, but a dangling protocol method is more confusing to next contributors than a TODO comment. Two cheap options: (a) drop queue_message from this PR and add it in the slice that uses it; (b) keep it and add a one‑sentence note in the RFC ("queue_message added to the contract here; routing PR follows in Slice 3c.2") so the staging is explicit.

4. Minor — _active_control_result(value) semantics

# api/runtime_adapter.py:118-126
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),
    )

Two small things:

  • payload=dict(value) is a shallow copy. If a delegate returns a dict containing nested mutable state (e.g. lists of pending controls), a downstream consumer could mutate the legacy state object. Today goal_command_payload returns scalars + small dicts only, so it's safe, but a copy.deepcopy or an explicit "payload is read‑only" docstring would future‑proof this once a delegate returns nested structures.
  • The status fallback chain value.get("status") or value.get("action") collapses two different concepts (HTTP‑ish status vs goal action verb). For the goal payload that's deliberate (you want "status"/"pause"/"set" to surface as status), but the same shape applied to a cancel or approval delegate would conflate them. Consider naming this _goal_control_result if it stays goal‑specific, or documenting the conflation.

What looks great

  • The _runtime_adapter_goal_action whitelist (set / pause / resume / clear / status / edit) matches goal_command_payload's real action space at goals.py:448-510 — no stray verbs.
  • Test at test_runtime_adapter_seam.py:115-135 exercises both delegates and asserts call shape, which is exactly what the seam needs.
  • RFC update accurately marks Slice 3c shipped in v0.51.90 via docs(runtime): define queue goal control gate #2509 and frames this PR as the implementation of the previously accepted gate.

Test plan suggestions beyond what's there: (a) flag on + args="set foo" to confirm _runtime_adapter_goal_action returns "set" but the legacy parser sees the full "set foo" and creates the goal; (b) flag on + delegate returning {"ok": False, "error": "agent_running"} produces HTTP 409 (the existing 409 branch at routes.py:7906 should still fire because we read payload.get("ok"), not control_result.accepted).

@Michaelyklam
Copy link
Copy Markdown
Contributor Author

Thanks — I pushed follow-up commit 6a68bab1 to address the review notes before merge.

What changed:

  • Added an explicit route comment that the legacy-journal /api/goal path intentionally passes through the legacy payload instead of deriving public HTTP behavior from ControlResult.accepted / .safe_message.
  • Computes _runtime_adapter_goal_action(goal_args) once and reuses the bounded label for both branches.
  • Clarified in the RFC that queue_message(...) is staged into the protocol before route wiring, and that update_goal(...) keeps the full legacy text authoritative for set <goal text> semantics.
  • Documented _active_control_result(...) status semantics so the status/action fallback is visibly an adapter summary, not public-route behavior.
  • Added the two suggested regressions:
    • adapter flag + args="set foo" preserves the full set foo text through the legacy goal parser / kickoff payload;
    • adapter flag + active stream returns the legacy agent_running payload as HTTP 409 by reading payload["ok"], not ControlResult.accepted.

Verification:

  • env -u HERMES_CONFIG_PATH -u HERMES_WEBUI_HOST /home/michael/.hermes/hermes-agent/venv/bin/python -m pytest tests/test_runtime_adapter_seam.py tests/test_goal_command_webui.py -q28 passed
  • env -u HERMES_CONFIG_PATH -u HERMES_WEBUI_HOST /home/michael/.hermes/hermes-agent/venv/bin/python -m pytest tests/test_runtime_adapter_seam.py tests/test_goal_command_webui.py tests/test_issue_1932_goal_hook_unrelated_turns.py tests/test_stage326_pending_goal_continuation_race.py tests/test_1062_busy_input_modes.py tests/test_cmd_idle_fallback.py -q85 passed
  • py_compile on touched Python files/tests → passed
  • git diff --check → passed

No scope expansion: still no backend queue scheduler, goal scheduler, runner/sidecar ownership move, new runtime-surrogate state, or public response-shape expansion.

@Michaelyklam
Copy link
Copy Markdown
Contributor Author

Follow-up CI is green on head 6a68bab1.

  • test (3.11) passed
  • test (3.12) passed
  • test (3.13) passed
  • mergeable: MERGEABLE

So the review-feedback follow-up is ready for maintainer review/release batching.

@nesquena-hermes nesquena-hermes closed this pull request by merging all changes into nesquena:master in 718a4c7 May 18, 2026
Michaelyklam pushed a commit to Michaelyklam/hermes-webui that referenced this pull request May 18, 2026
eleboucher pushed a commit to eleboucher/homelab that referenced this pull request May 19, 2026
… 0.51.92) (#560)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/nesquena/hermes-webui](https://github.com/nesquena/hermes-webui) | patch | `0.51.90` → `0.51.92` |

---

### Release Notes

<details>
<summary>nesquena/hermes-webui (ghcr.io/nesquena/hermes-webui)</summary>

### [`v0.51.92`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v05192--2026-05-19--Release-BP-stage-385--7-PR-full-sweep-batch--RFC-Slice-3c-clarification--workspace-tree-icon-alignment--project-move-cache-refresh--auto-compression-handoff-metadata--Grok-OAuth-provider-catalog--anonymous-custom-endpoint-picker-fallback--PWA-standalone-reload--pull-to-refresh)

[Compare Source](nesquena/hermes-webui@v0.51.91...v0.51.92)

##### Fixed

- **PR [#&#8203;2563](nesquena/hermes-webui#2563 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (closes [#&#8203;2554](nesquena/hermes-webui#2554)) — Align workspace-tree file rows with sibling directory rows by reserving the same expand/collapse toggle slot for files via a new `.file-tree-toggle-placeholder` element. Expanded directories now show child files stepped in at the same icon column as child folders. Directory toggles and file interactions are unchanged; source-level regression coverage and before/after PNGs included.
- **PR [#&#8203;2561](nesquena/hermes-webui#2561 by [@&#8203;nanookclaw](https://github.com/nanookclaw) (closes [#&#8203;2551](nesquena/hermes-webui#2551)) — Refresh the authoritative `_allSessions` cache when the project picker moves a session to/from a project. Previous code mutated only the shallow sidebar row copy, so `renderSessionListFromCache()` re-read the unchanged cache and repainted a stale project dot until the next `/api/sessions` poll healed the UI. Both the "Removed from project" and "Moved to <project>" branches now write the new `project_id` into `_allSessions[idx]` before re-rendering.
- **PR [#&#8203;2567](nesquena/hermes-webui#2567 by [@&#8203;dso2ng](https://github.com/dso2ng) (refs [#&#8203;2477](nesquena/hermes-webui#2477)) — Surface automatic-compression handoff metadata through the `compressed` SSE event so the active browser stream keeps its completion card even after the backend rotates the session id from the origin to a compressed continuation. The event now carries both `old_session_id` and `new_session_id`/`continuation_session_id`; the frontend `compressed` listener accepts either, and the automatic-compression detail line names the compressed continuation session so the done state isn't silently dropped.
- **PR [#&#8203;2568](nesquena/hermes-webui#2568 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (closes [#&#8203;2545](nesquena/hermes-webui#2545)) — Add the Hermes Agent `xai-oauth` provider to the WebUI's OAuth provider catalog so Grok OAuth accounts authenticated via the Hermes CLI appear in Settings → Providers and the `/api/models` picker. The provider is treated as CLI-managed OAuth (no WebUI API-key form) and uses the live Hermes CLI model catalog when available with a Grok 4.20 static fallback.
- **PR [#&#8203;2550](nesquena/hermes-webui#2550 by [@&#8203;espokaos-ops](https://github.com/espokaos-ops) (refs [#&#8203;2542](nesquena/hermes-webui#2542)) — Keep anonymous custom OpenAI-compatible endpoints in the model picker even when the configured `/v1/models` probe fails. Lightweight relays and llama-server-style deployments that authenticate `/v1/chat/completions` but not `/v1/models` no longer have their provider group silently dropped from the picker. Users can type a model id manually in the free-form input when no live catalog is available.

##### Added

- **PR [#&#8203;2548](nesquena/hermes-webui#2548 by [@&#8203;espokaos-ops](https://github.com/espokaos-ops) — Add a PWA-standalone reload affordance. A small refresh button appears in the app titlebar (visible only under `@media (display-mode: standalone), (display-mode: fullscreen)`) so users running the WebUI as an installed home-screen PWA can reload without re-launching the app. Adds a complementary pull-to-refresh gesture on the messages container with an 80px threshold and a smooth-scroll-to-top guard so accidental triggers while reading history feel intentional. 4-viewport screenshots (390/1280/1440/1920, light/dark, hover/idle) included under `docs/pr-media/2548/`.

##### Documentation

- **PR [#&#8203;2560](nesquena/hermes-webui#2560 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (refs [#&#8203;1925](nesquena/hermes-webui#1925)) — Clarify the RuntimeAdapter Slice 3c state after [#&#8203;2544](nesquena/hermes-webui#2544) shipped. The RFC now distinguishes shipped `/api/goal` routing through `RuntimeAdapter.update_goal(...)` from the still-staged `queue_message(...)` protocol method, and explicitly warns not to add a new server-side queue endpoint or queue scheduler merely for adapter symmetry while `/queue` remains browser-side queue/drain behavior.

### [`v0.51.91`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v05191--2026-05-18--Release-BO-stage-384--5-PR-full-sweep-batch--reasoning-replay-history-fix--archive-extract-per-session-inbox--fallback-streaming-warnings--sanitized-custom-provider-env-hints--Slice-3c-queuegoal-adapter-routing)

[Compare Source](nesquena/hermes-webui@v0.51.90...v0.51.91)

##### Fixed

- **PR [#&#8203;2536](nesquena/hermes-webui#2536 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (closes [#&#8203;2514](nesquena/hermes-webui#2514), refs [#&#8203;2535](nesquena/hermes-webui#2535)) — Stop reasoning-only Thinking entries from being replayed into provider-facing history as blank assistant turns. Long WebUI sessions were accumulating duplicated stale Thinking blocks and inflated Activity/tool metadata on later turns when reasoning-only display entries (from interrupted/canceled turns) got reinserted into the restored conversation history. The fix keeps visible Thinking cards in the transcript while filtering them out of provider-facing replay. Settled compact Activity rerenders now also clear previously inserted Thinking rows before rebuilding the visible transcript.
- **PR [#&#8203;2520](nesquena/hermes-webui#2520 by [@&#8203;OneFat3](https://github.com/OneFat3) (refs [#&#8203;2247](nesquena/hermes-webui#2247)) — Route archive extraction (`/api/upload/extract`) through the per-session attachment inbox (`_session_attachment_dir`) instead of hardcoded `Path(s.workspace)`, matching the single-file upload path. Extracted archives now land at `<attachment_root>/<session_id>/<archive_stem>/` so session deletion cleanup covers them and per-session isolation is preserved when `HERMES_WEBUI_ATTACHMENT_DIR` is configured.
- **PR [#&#8203;2505](nesquena/hermes-webui#2505 by [@&#8203;cyberdyne187](https://github.com/cyberdyne187) — Surface provider fallback and rate-limit lifecycle notices as auto-clearing fallback warnings in the streaming composer status. The new bridge in `_agent_status_callback` matches agent lifecycle messages containing `rate limited` / `switching to fallback` / `falling back` / `fallback activated` / `trying fallback` and emits them as `warning` events with `type=fallback`, so the existing `static/messages.js` warning channel surfaces them with the correct auto-clear contract instead of letting them drop silently.
- **PR [#&#8203;2556](nesquena/hermes-webui#2556 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (closes [#&#8203;2541](nesquena/hermes-webui#2541)) — Sanitize auto-generated custom-provider API-key environment variable names so endpoint-derived provider ids such as `custom:gpu.local-8000` use POSIX-safe names like `CUSTOM_GPU_LOCAL_8000_API_KEY`. Runtime custom-provider key resolution now checks the sanitized env var first and falls back to the legacy punctuation-preserving name with a one-shot deprecation warning. Configured literal `api_key` values and explicit `key_env` config are unchanged.

##### Documentation

- **PR [#&#8203;2544](nesquena/hermes-webui#2544 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (refs [#&#8203;1925](nesquena/hermes-webui#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.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEwMS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/560
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants