Add Hermes Action Bus for typed WebUI/session actions - #3030
webflow-pt-carlos wants to merge 4 commits into
Conversation
Introduces a small typed backend Action Bus for Hermes session events.
The new dispatcher provides one shared primitive:
entry point -> default_registry.dispatch(name, payload, context)
-> registered backend action
-> ActionResult
This PR ships the primitive plus one trivial builtin, echo.test, which
round-trips the dispatch path without touching the session database, the
agent, or the SSE channel. It exists so the bus can be exercised
end-to-end in unit tests and via the manual smoke test before any
session-touching action lands.
A follow-up PR adds session.nudge -- the inference-only synthetic user
turn that wakes a session from a background trigger -- together with the
load_visible_messages / append_assistant_message helpers and the
publish_session_message_appended SSE event that surfaces the appended
assistant message in open WebUI tabs.
Design choices:
- Synchronous to match the rest of api/* (ThreadingHTTPServer, plain def).
- No new dependencies, no frontend changes.
- Registry is injectable so per-test isolation needs no module-level resets.
- ActionNotFound -> 404, validation failures -> 400, dispatch -> 200.
- Idempotency cache is in-process, threading.Lock-guarded, TTL-pruned.
- CSRF is enforced via the existing _check_csrf path; /api/actions is NOT
added to the exempt list.
Tests: tests/test_action_bus.py covers registry behavior, idempotency,
the echo.test builtin, and the HTTP adapter (validation, 404, 200,
idempotency, emit_event pass-through). 23 unit tests, no test-server
fixture required. Passes under both pytest and unittest.
Does not replace api/background.py's /background and /btw slash
commands; a later PR can re-express those on top of the bus.
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude <noreply@anthropic.com>
SummaryRead the full diff (8 files, +820), the new Things I verified
try:
result = impl.run(payload, context)
except Exception as exc:
result = ActionResult(
ok=False, silent=True,
error=f"{type(exc).__name__}: {exc}",
)correctly catches Three observations worth considering1. Lazy registration check is fragile against future builtins. In if "echo.test" not in default_registry.known_actions():
try:
register_builtins(default_registry)
except ValueError:
passThe sentinel is 2. 3. try:
from api.session_events import publish_session_event
except ImportError:
return lambda _name, _payload: None
return publish_session_event
Smaller nits
VerdictFoundation looks solid for the follow-up PRs. The three observations above are non-blocking polish; the only one I'd actually act on before landing is the registration sentinel in |
PR review observation #1 ("registration sentinel is fragile against future builtins"): The previous route hook in api/routes.py gated registration on ``"echo.test" not in default_registry.known_actions()``. When a follow-up PR adds another builtin (e.g. session.nudge), that check still passes because echo.test is already registered, so the new builtin never gets picked up by register_builtins on a warm process. The bug surfaces only after a deploy and is silent -- the new builtin just returns 404. Fix: track the registration call itself, not any individual action name. api/actions/__init__.py: - Add module-level ``_BUILTINS_LOCK`` (threading.Lock) and ``_BUILTINS_REGISTERED`` flag. - ``register_builtins(default_registry)`` is now idempotent: first call registers, later calls return immediately under the lock. - ``register_builtins(custom_registry)`` -- the path tests take -- is unchanged: registers fresh every call so per-test isolation still works. - Pulled the actual registration list into a private ``_register_all_builtins(registry)`` helper so follow-up PRs only touch a single line to add their builtin; the locking and idempotency policy stays a clean wrapper around it. api/routes.py: - ``/api/actions`` hook is now a flat ``register_builtins(default_registry)`` call. Both the sentinel check and the ValueError swallow are gone. api/actions_http.py: - Per review observation #3, added an inline comment at the ``from api.session_events import publish_session_event`` site noting that the symbol intentionally lands in the session.nudge follow-up. Function-level docstring already covered the same; this is for grep readers. tests/test_action_bus.py: - New ``test_register_builtins_default_registry_is_idempotent`` locks in the contract: repeated ``register_builtins(default_registry)`` calls must not raise. Resets the module flag + clears the global registry inside the test so the assertion is deterministic. Tests: 24/24 passing (was 23/23, +1 for the new idempotency test). Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude <noreply@anthropic.com>
Two changes, both within scope of PR #1 (the bus primitive) rather than the session.nudge follow-up: 1. ``docs/rfcs/action-bus.md`` -- new RFC. The ``__init__.py`` module docstring references this file as "added in this PR" but it was never actually shipped. The RFC documents the primitive's contract end-to-end: - Action / ActionContext / ActionResult shapes - Idempotency semantics and the in-process cache trade-offs - Error handling (caught Exception -> ActionResult; ActionNotFound bubbles) - Chaining via ``context.dispatch`` (the section the reviewer suggested adding "a sentence" to -- now a full subsection with the test reference) - Registration model (default_registry singleton + per-test registries + the lock-based idempotency from 3bd75bb) - HTTP entry point shape + CSRF posture - What's out of scope for v1 + planned follow-ups 2. ``tests/test_action_bus.py`` -- two new tests under ``TestEchoTest`` that lock in the chaining contract: - ``test_action_can_chain_via_context_dispatch`` -- a registered action chains to ``echo.test`` through ``context.dispatch``, wired via ``_ctx(dispatch=reg.dispatch)``. Proves the chain pattern works and exercises the ``_ctx(**overrides)`` extensibility the reviewer flagged in observation #2. - ``test_chaining_without_dispatch_raises`` -- omitting the ``dispatch=`` override falls through to the ``_no_dispatch`` default, which raises ``RuntimeError`` that the registry catches and wraps into an error ``ActionResult``. Locks the failure mode in so a caller that forgets to opt into chaining gets a loud, structured error rather than silent wrong behavior. The follow-up session.nudge PR no longer has to grow the test surface for chaining; it can use the contract as documented and tested here. Per the reviewer's observation #2 framing ("test surface should grow ``_ctx(dispatch=registry.dispatch)`` at that point") -- moving "that point" forward into this PR keeps the follow-up's diff focused on the new action. Tests: 26/26 passing (was 24/24, +2 chaining tests). Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude <noreply@anthropic.com>
|
Thanks for the thorough read. Acted on all three observations in two commits on this branch: Observation 1 — fragile registration sentinelFixed in
The bug you described ("new builtin silently never picked up because echo.test sentinel still passes") is now structurally impossible — the flag tracks the registration call, not any individual action name, so any builtin added to Observation 2 — chaining contract / test helper dispatchBrought this one forward into this PR in
This means the follow-up Observation 3 —
|
Re-review on 3bd75bb + 6b11c4bRead both follow-up commits. All three observations are addressed cleanly, and the chaining work brought forward is a real improvement over deferring it. Observation 1 — registration sentinelThe new shape in if registry is default_registry:
global _BUILTINS_REGISTERED
with _BUILTINS_LOCK:
if _BUILTINS_REGISTERED:
return
_register_all_builtins(registry)
_BUILTINS_REGISTERED = True
else:
_register_all_builtins(registry)This fixes the silent-bug failure mode I flagged. The register_builtins(default_registry)The "lock acquire per request" cost is GIL-pinned and well below the JSON parse cost the same handler already pays, so I don't think it's worth a Observation 2 — chaining contractPulling this forward into this PR is the right call. Two things I read carefully:
try:
result = impl.run(payload, context)
except Exception as exc:
result = ActionResult(
ok=False, silent=True,
error=f"{type(exc).__name__}: {exc}",
)so the The RFC's Observation 3 — import comment
# publish_session_event intentionally does not yet exist on this
# PR's branch -- it lands in the session.nudge follow-up. Grepping
# for the symbol on master today will only return this import; the
# ImportError fallback below resolves to a no-op for every request
# until then...That's exactly the right scope. ✓ NitsI'm not going to push on the four nits — your responses are reasonable. The One small thing on the idempotency test
VerdictReady from my side. Foundation is solid for the |
Re-review nit from #3030: test_register_builtins_default_registry_is_idempotent mutates module-private state (api.actions._BUILTINS_REGISTERED, default_registry._actions) to observe a fresh registration pass. The previous version left the registry in a "first-run" state after the test, which would surprise any later test in the same process that invariant-checks default_registry.known_actions(). Fix: snapshot the pre-test state and register an addCleanup() that restores it. addCleanup is the unittest-canonical pattern -- it runs even if assertions fail, so a partial test failure does not leak state into subsequent tests. This makes the test order-independent without needing a class-level setUp/tearDown (only this one test mutates the global registry, and keeping the snapshot/restore visible at the call site is clearer than splitting it into framework hooks). Tests: 26/26 still passing. Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude <noreply@anthropic.com>
|
Thanks for the re-review — appreciate the second careful read. Acted on the order-independence concern in saved_registered = actions_pkg._BUILTINS_REGISTERED
saved_actions = dict(default_registry._actions)
def _restore_default_registry_state() -> None:
with actions_pkg._BUILTINS_LOCK:
actions_pkg._BUILTINS_REGISTERED = saved_registered
default_registry._actions.clear()
default_registry._actions.update(saved_actions)
self.addCleanup(_restore_default_registry_state)Picked 26/26 still passing. Branch tip is Thanks again for the thoughtful reviews — the two follow-up commits and this hygiene fix together turned the original "23 tests, working primitive" into "26 tests, two real bug-class fixes, full RFC, order-independent test suite." Better foundation for |
Re-review on d484f4a — test hygieneRead the follow-up commit. The change at What I verifiedThe order-independence story works end-to-end:
On the choice of
|
|
Closing the loop from my side — no further changes planned for this PR. Standing by for the CI workflow approval and merge whenever you're ready. |
|
Triage: HOLD pending deep architectural review — labels: This introduces the Hermes Action Bus — a typed backend action dispatcher with a registry, a default-singleton registration helper, and an idempotency contract. That's a new always-visible architectural contract (the dispatch primitive becomes the canonical entry point for "typed WebUI/session actions"), so we want to think carefully about which layer it belongs in before it lands in Our standing rule for this kind of new architectural contract:
Before merging, we'd like to:
Will follow up here after we've worked through the placement question. Thanks for the careful idempotency contract and the lock-around-default-registry pattern — that detail is good. |
|
Thanks for the HOLD — and for the architectural read. Sitting with it, I think you're right, and I should have started in What we were actually trying to build The Action Bus in this PR was not the end goal. The end goal was a We split the work and landed the bus + On the bus vs the feature After reading the agent codebase, I don't think the nudge feature needs a typed Action Bus at the agent layer either. Hermes already has the primitives we need: The bus work here may still be useful as a reference if you decide a shared typed entry-point dispatcher belongs in Answering your checklist (3) Yes: on this branch, Next steps from our side We plan to close this PR and re-propose against NousResearch/hermes-agent as a focused Happy to leave |
|
Thanks @webflow-pt-carlos — the typed dispatch design is thoughtful and the RFC is well-written. But we're going to decline this as-is: the PR is ~1290 lines (5 new modules + an HTTP endpoint + the 291-line RFC + 501 test lines) and the only thing it wires end-to-end is the We try hard to avoid merging speculative infrastructure ahead of its first real consumer: an abstraction we can't yet exercise against a concrete use case is hard to judge (does the shape fit? is the indirection worth it?) and becomes maintenance surface in the meantime. If you'd like to pursue this, please resubmit it bundled with the first real action (e.g. |
Summary
Adds a small typed backend Action Bus for Hermes WebUI session events.
The new dispatcher provides one shared primitive:
This PR ships the primitive plus one trivial builtin,
echo.test,which round-trips the dispatch path without touching the session DB,
the agent, or the SSE channel. It exists so the bus can be exercised
end-to-end in unit tests and via the manual smoke test before any
session-touching action lands.
A follow-up PR adds
session.nudge— the inference-only syntheticuser turn that wakes a session from a background trigger — together
with the
load_visible_messages/append_assistant_messagehelpersand the
publish_session_message_appendedSSE event that surfaces theappended assistant message in open WebUI tabs.
Splitting the work this way keeps this PR mechanically tiny and lets
the session/agent integration land in its own focused review.
Motivation
Hermes WebUI already has several ways for non-typed-user events to
reach the system: WebUI interactions, cron jobs, webhooks, gateway
messages, and internal background jobs. Today, each entry point that
needs to trigger session-related work invents its own delivery and
execution flow (
api/background.py,/background,/btw, ad-hoccron callbacks). Those entry points benefit from a shared typed
dispatcher that can:
caller reinventing the validation/auth/result layer.
This PR does not replace
api/background.py./backgroundand/btwremain the user-facing slash commands for "fork asub-conversation". A later PR can re-express those on top of the bus,
but that migration is out of scope here.
Design
Action Bus
The Action Bus is a registry plus dispatcher:
Actions are registered by name and implement one method:
The bus is intentionally synchronous: Hermes WebUI runs on
ThreadingHTTPServerand the rest ofapi/*is plaindef. Actionsfollow the same style so dispatch can happen inside any POST handler
thread without spinning an event loop.
ActionResult
Every action returns the same shape:
The result separates three concerns:
ok: whether the action completed without unrecoverable error;silent/assistant_message: whether anything should surface to chat;refresh_chat: whether open clients should refresh session state.Idempotency
ActionRegistry.dispatchaccepts an optionalidempotency_key. For agiven
(action, idempotency_key)pair, the first successful (orerrored) result is cached for a TTL (default 300s) and returned for
repeat dispatches. The cache is in-memory only, guarded by a
threading.Lockto match the WebUI's threaded request model. Aprocess restart loses pending entries — acceptable for the v1 use
cases (uptime >> typical TTL) and a durable cache can slot in behind
the same
dispatchsignature in a later PR if needed.echo.test(the only v1 builtin)echo.testis the smallest possible action: it readspayload["content"], returns it asassistant_messagewhennon-empty, and returns a silent ok otherwise. It does not touch the
session database, the agent, or the SSE channel. It exists so the
dispatch path can be exercised end-to-end in unit tests and via the
manual smoke test.
HTTP entry point
POST /api/actionsaccepts:and returns the dispatched
ActionResult.to_dict()with HTTP status200 on dispatch (regardless of
ok), 400 on a malformed request, and404 on an unknown action name. CSRF is enforced by the existing
api/routes.py::_check_csrfpath;/api/actionsis intentionallyNOT added to the CSRF-exempt allowlist — same-origin browser callers
work through the existing Origin/Host check, and non-browser callers
(curl, MCP, agent) pass through because they have no Origin header.
What lands in this PR
api/actions/types.py—ActionContext,ActionResult,Actionprotocol,
SILENT_SENTINEL.api/actions/registry.py—ActionRegistry,ActionNotFound,_IdempotencyCache, module-leveldefault_registry.api/actions/__init__.py— package exports andregister_builtins.api/actions/builtin/__init__.pyapi/actions/builtin/echo_test.py— the v1 builtin.api/actions_http.py—handle_actions_post(handler, body, registry, *, emit_event).api/routes.py— six-line route block routing/api/actionstothe adapter, with lazy idempotent
register_builtinsagainst theprocess-global registry.
tests/test_action_bus.py— 23 unit tests, no test-server fixture.No new dependencies. No async runtime. No frontend changes (the SSE
event and frontend handler land in the follow-up PR alongside the
first real consumer).
Follow-up PRs
session.nudgebuiltin + the inference-only agent invocation helperpublish_session_message_appendedSSE event + frontend handler(drafted alongside this PR).
dispatch_action("session.nudge", ...).dispatch_action(...)./backgroundand/btwon top of the bus.Testing
23 unit tests covering registry dispatch, idempotency (same key /
different keys / no key), exception handling, the
echo.testbuiltin (visible, silent, error), and the HTTP adapter (six 400
cases, 404, 200, idempotency through the adapter, and
emit_eventpass-through). No test-server fixture required.
Manual smoke test against a running WebUI:
Expected:
200with{"ok": true, "silent": false, "assistant_message": "hello bus", ...}. Repeat with the sameidempotency_key→ same body (cached). POST withoutaction→400. POST with an unregistered name →404. Curl is a non-browser caller (noOriginheader) so it passes CSRF naturally; browser callers go through the existing same-origin check.