Skip to content

feat: expose public gateway injection seam - #1

Merged
randlee merged 11 commits into
mainfrom
feature/hermes-atm-public-injection-main
Aug 11, 2026
Merged

feat: expose public gateway injection seam#1
randlee merged 11 commits into
mainfrom
feature/hermes-atm-public-injection-main

Conversation

@randlee

@randlee randlee commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • expose the profile-aware GatewayRunner.inject_internal_message host capability
  • pass gateway_runner through the public gateway:startup hook context
  • add fail-closed profile, mode, and isolation coverage

Validation

  • uv sync --extra dev --locked in an isolated temporary environment
  • pytest tests/gateway/test_inject_internal_message.py -q (25 passed)

This is the host prerequisite for the separately installed hermes-atm package; it does not include package installation or profile configuration.

randlee added 11 commits August 10, 2026 22:48
GatewayRunner.inject_internal_message(profile, platform, chat_id, text, notice_text)
enables plugins (e.g. hermes-atm) to inject host-originated messages through
the existing adapter→gateway dispatch path with internal=True.

- Resolves adapter from _profile_adapters[profile] or self.adapters
- Constructs SessionSource + MessageEvent(internal=True)
- Supports optional notice_text for visible 📬 observability
- Fire-and-forget: queues event via adapter.handle_message()

AL17 deployable contract per c50c4232.
…17 review)

- Explicit profile must be found in _profile_adapters; no silent fallback
- SessionSource includes user_id=chat_id for correct session identity
- Notice text delivered before event construction
…AL17 review)

- Return type changed from Optional[str] to None
- profile parameter moved to end with default ''
- All return None changed to bare return
- SessionSource includes user_id=chat_id
- Docstring updated for new signature
Adds mode parameter to inject_internal_message:
- mode="queue" (default): fire-and-forget via adapter.handle_message()
- mode="steer": inject directly into running agent's turn via
  agent.steer(), falling back to queue when no agent is running

Also fixes bug from review commit 0869cc6 where event was
referenced before construction in the queue path.

Tests: 19 passing (11 queue mode + 5 steer mode + 2 hook + 1 negative)
- steer into running agent skips handle_message
- steer falls back to queue when no agent running
- steer falls back to queue when steer() returns False
- queue mode never calls steer() even when agent running
- notice_text preserved in steer mode
- strict profile resolution (fail closed on unknown profile)
- no ATM platform creation
- Resolve via self._active_profile_name() for primary profile
- Registered secondary profiles via _profile_adapters lookup
- fail closed on unknown profile (no silent fallback)
- Signature: profile='' default at end, -> None return, no mode param
- Remove _profile_adapters-is-empty-as-error heuristic
- mode='queue' (default), mode='steer' for non-interrupting injection
- steer uses _session_key_for_source + _running_agents
- falls through to queue if steer unavailable
- bare return everywhere
…steer mode

- Add * separator and required profile: str (keyword-only)
- Add mode: Literal['queue','steer'] = 'queue' with steer logic
- Replace empty-string profile default with explicit active-profile check
- Fix return None → bare return everywhere
- Update tests for keyword-only API (mock _active_profile_name)
- 19/19 tests pass
- Adds 'gateway_runner': self to the hook emit dict
- Enables hermes-atm to call runner.inject_internal_message()
  from a gateway:startup hook without private imports
…17 gaps 3-5)

- InjectInternalMessageError with code/chat_id/detail
- Profile/adapter failures raise instead of silently returning
- Isolation tests: queue and steer cannot cross sessions
- Hook context uses gateway_runner (not runner)
- test_missing_adapter uses real adapter map pattern
Three host-contract tests for PR NousResearch#82915:
1. same-profile/two-chat steer isolation — steer per-chat within profile
2. two-profiles/same-chat isolation — steer per-profile within same chat_id
3. invalid runtime mode fails closed — InjectInternalMessageError

Also adds mode validation at top of inject_internal_message: unknown
mode values now raise InjectInternalMessageError(code='invalid_mode')
rather than silently falling through to queue mode.
@randlee
randlee force-pushed the feature/hermes-atm-public-injection-main branch from dcab6f9 to 93f7c11 Compare August 11, 2026 05:55
@randlee
randlee merged commit f4fd527 into main Aug 11, 2026
randlee added a commit that referenced this pull request Aug 15, 2026
* feat: inject_internal_message — public profile-aware injection API

GatewayRunner.inject_internal_message(profile, platform, chat_id, text, notice_text)
enables plugins (e.g. hermes-atm) to inject host-originated messages through
the existing adapter→gateway dispatch path with internal=True.

- Resolves adapter from _profile_adapters[profile] or self.adapters
- Constructs SessionSource + MessageEvent(internal=True)
- Supports optional notice_text for visible 📬 observability
- Fire-and-forget: queues event via adapter.handle_message()

AL17 deployable contract per c50c4232.

* fix: fail closed on unknown profile, add user_id to SessionSource (AL17 review)

- Explicit profile must be found in _profile_adapters; no silent fallback
- SessionSource includes user_id=chat_id for correct session identity
- Notice text delivered before event construction

* fix: Optional[str]->None return, profile default='', reorder params (AL17 review)

- Return type changed from Optional[str] to None
- profile parameter moved to end with default ''
- All return None changed to bare return
- SessionSource includes user_id=chat_id
- Docstring updated for new signature

* feat: add steer vs queue mode to inject_internal_message

Adds mode parameter to inject_internal_message:
- mode="queue" (default): fire-and-forget via adapter.handle_message()
- mode="steer": inject directly into running agent's turn via
  agent.steer(), falling back to queue when no agent is running

Also fixes bug from review commit 0869cc6 where event was
referenced before construction in the queue path.

Tests: 19 passing (11 queue mode + 5 steer mode + 2 hook + 1 negative)
- steer into running agent skips handle_message
- steer falls back to queue when no agent running
- steer falls back to queue when steer() returns False
- queue mode never calls steer() even when agent running
- notice_text preserved in steer mode
- strict profile resolution (fail closed on unknown profile)
- no ATM platform creation

* fix: correct profile resolver using _active_profile_name() (AL17 review)

- Resolve via self._active_profile_name() for primary profile
- Registered secondary profiles via _profile_adapters lookup
- fail closed on unknown profile (no silent fallback)
- Signature: profile='' default at end, -> None return, no mode param
- Remove _profile_adapters-is-empty-as-error heuristic

* fix: restore mode=steer per user directive, fix return None -> return

- mode='queue' (default), mode='steer' for non-interrupting injection
- steer uses _session_key_for_source + _running_agents
- falls through to queue if steer unavailable
- bare return everywhere

* fix: apply AL17 contract — keyword-only inject_internal_message with steer mode

- Add * separator and required profile: str (keyword-only)
- Add mode: Literal['queue','steer'] = 'queue' with steer logic
- Replace empty-string profile default with explicit active-profile check
- Fix return None → bare return everywhere
- Update tests for keyword-only API (mock _active_profile_name)
- 19/19 tests pass

* fix: expose gateway_runner in gateway:startup hook context

- Adds 'gateway_runner': self to the hook emit dict
- Enables hermes-atm to call runner.inject_internal_message()
  from a gateway:startup hook without private imports

* fix: structured errors, isolation tests, gateway_runner hook name (AL17 gaps 3-5)

- InjectInternalMessageError with code/chat_id/detail
- Profile/adapter failures raise instead of silently returning
- Isolation tests: queue and steer cannot cross sessions
- Hook context uses gateway_runner (not runner)
- test_missing_adapter uses real adapter map pattern

* fix: actually raise InjectInternalMessageError in inject_internal_message (gaps 3-4)

* feat: add host-contract isolation tests + mode validation (AL17 gate)

Three host-contract tests for PR NousResearch#82915:
1. same-profile/two-chat steer isolation — steer per-chat within profile
2. two-profiles/same-chat isolation — steer per-profile within same chat_id
3. invalid runtime mode fails closed — InjectInternalMessageError

Also adds mode validation at top of inject_internal_message: unknown
mode values now raise InjectInternalMessageError(code='invalid_mode')
rather than silently falling through to queue mode.

---------

Co-authored-by: Rand Lee <randlee@users.noreply.github.com>
randlee added a commit that referenced this pull request Aug 16, 2026
* feat: inject_internal_message — public profile-aware injection API

GatewayRunner.inject_internal_message(profile, platform, chat_id, text, notice_text)
enables plugins (e.g. hermes-atm) to inject host-originated messages through
the existing adapter→gateway dispatch path with internal=True.

- Resolves adapter from _profile_adapters[profile] or self.adapters
- Constructs SessionSource + MessageEvent(internal=True)
- Supports optional notice_text for visible 📬 observability
- Fire-and-forget: queues event via adapter.handle_message()

AL17 deployable contract per c50c4232.

* fix: fail closed on unknown profile, add user_id to SessionSource (AL17 review)

- Explicit profile must be found in _profile_adapters; no silent fallback
- SessionSource includes user_id=chat_id for correct session identity
- Notice text delivered before event construction

* fix: Optional[str]->None return, profile default='', reorder params (AL17 review)

- Return type changed from Optional[str] to None
- profile parameter moved to end with default ''
- All return None changed to bare return
- SessionSource includes user_id=chat_id
- Docstring updated for new signature

* feat: add steer vs queue mode to inject_internal_message

Adds mode parameter to inject_internal_message:
- mode="queue" (default): fire-and-forget via adapter.handle_message()
- mode="steer": inject directly into running agent's turn via
  agent.steer(), falling back to queue when no agent is running

Also fixes bug from review commit 0869cc6 where event was
referenced before construction in the queue path.

Tests: 19 passing (11 queue mode + 5 steer mode + 2 hook + 1 negative)
- steer into running agent skips handle_message
- steer falls back to queue when no agent running
- steer falls back to queue when steer() returns False
- queue mode never calls steer() even when agent running
- notice_text preserved in steer mode
- strict profile resolution (fail closed on unknown profile)
- no ATM platform creation

* fix: correct profile resolver using _active_profile_name() (AL17 review)

- Resolve via self._active_profile_name() for primary profile
- Registered secondary profiles via _profile_adapters lookup
- fail closed on unknown profile (no silent fallback)
- Signature: profile='' default at end, -> None return, no mode param
- Remove _profile_adapters-is-empty-as-error heuristic

* fix: restore mode=steer per user directive, fix return None -> return

- mode='queue' (default), mode='steer' for non-interrupting injection
- steer uses _session_key_for_source + _running_agents
- falls through to queue if steer unavailable
- bare return everywhere

* fix: apply AL17 contract — keyword-only inject_internal_message with steer mode

- Add * separator and required profile: str (keyword-only)
- Add mode: Literal['queue','steer'] = 'queue' with steer logic
- Replace empty-string profile default with explicit active-profile check
- Fix return None → bare return everywhere
- Update tests for keyword-only API (mock _active_profile_name)
- 19/19 tests pass

* fix: expose gateway_runner in gateway:startup hook context

- Adds 'gateway_runner': self to the hook emit dict
- Enables hermes-atm to call runner.inject_internal_message()
  from a gateway:startup hook without private imports

* fix: structured errors, isolation tests, gateway_runner hook name (AL17 gaps 3-5)

- InjectInternalMessageError with code/chat_id/detail
- Profile/adapter failures raise instead of silently returning
- Isolation tests: queue and steer cannot cross sessions
- Hook context uses gateway_runner (not runner)
- test_missing_adapter uses real adapter map pattern

* fix: actually raise InjectInternalMessageError in inject_internal_message (gaps 3-4)

* feat: add host-contract isolation tests + mode validation (AL17 gate)

Three host-contract tests for PR NousResearch#82915:
1. same-profile/two-chat steer isolation — steer per-chat within profile
2. two-profiles/same-chat isolation — steer per-profile within same chat_id
3. invalid runtime mode fails closed — InjectInternalMessageError

Also adds mode validation at top of inject_internal_message: unknown
mode values now raise InjectInternalMessageError(code='invalid_mode')
rather than silently falling through to queue mode.

---------

Co-authored-by: Rand Lee <randlee@users.noreply.github.com>
randlee pushed a commit that referenced this pull request Aug 17, 2026
Two independent bugs let a deleted profile reappear / leave orphaned
resources on next launch:

1. hermes_cli/profiles.py's backend-process scanner required argv[0] to
   resolve to an executable literally named "hermes". Electron's
   pool-backend spawn resolves the hermes console-script shim's path and
   execs it via the interpreter directly (python3 /path/to/hermes ...), so
   argv[0] reports as "python3" and the scanner never matched the running
   backend -- delete removed the profile's files but left its live backend
   process running (still bound to a port via uvicorn), which
   accumulates across repeated delete/recreate cycles.
2. The desktop sidebar's ProfileRail only refreshed its cached profile
   list once, on mount, so a delete/create/rename from another surface
   (another window, or the CLI) left a stale ghost entry until something
   unrelated triggered a refetch. Note: a delete via this window's own
   Manage-Profiles view already refreshes the shared $profiles atom
   ProfileRail subscribes to (confirmed by reading refreshProfiles() and
   handleConfirmDelete()) -- this fix only covers the cross-window/cross-
   process staleness gap, not a duplicate of the already-merged
   NousResearch#57329's Manage-Profiles rail-refresh work.

Fix 1: recognize a python-interpreter argv[0] exec'ing a hermes-named
console-script shim via argv[1]. Fix 2: refresh the profile list on window
focus/visibilitychange, matching the existing pattern used elsewhere in
the sidebar (sidebar/index.tsx, use-background-sync.ts, star-map.tsx,
use-gateway-boot.ts all use the same focus+visibilitychange pattern).

## Related work already on main

PR NousResearch#57329 (merged) fixed the *headline* symptom from issue NousResearch#52279
(deleted profile respawns) via a different, non-overlapping mechanism:
routing profile-delete through the primary backend instead of spawning a
fresh pool backend, plus a separate recreation guard in
ensure_hermes_home() (NousResearch#49435, merged) that makes a backend spawned into a
deleted profile's directory raise FileNotFoundError instead of silently
recreating it.

This PR is NOT a duplicate of that fix. Verified: even with both of those
merged, a backend process that survives because of gap #1 above still
holds a bound port via uvicorn -- it just can no longer resurrect the
profile directory. That's real resource-hygiene, not a symptom already
covered. Gap #2 touches a different file/component (ProfileRail /
profile-switcher.tsx) than NousResearch#57329's rail-refresh half (which touched the
Manage-Profiles view's own $profiles.ts / index.tsx) and covers a
distinct staleness path (cross-window/cross-process, not same-window
delete-then-refresh).

Tests: tests/hermes_cli/test_profiles.py -- 156 passed (existing +
regression coverage for the argv[0] python-interpreter detection case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
randlee added a commit that referenced this pull request Aug 17, 2026
* feat: inject_internal_message — public profile-aware injection API

GatewayRunner.inject_internal_message(profile, platform, chat_id, text, notice_text)
enables plugins (e.g. hermes-atm) to inject host-originated messages through
the existing adapter→gateway dispatch path with internal=True.

- Resolves adapter from _profile_adapters[profile] or self.adapters
- Constructs SessionSource + MessageEvent(internal=True)
- Supports optional notice_text for visible 📬 observability
- Fire-and-forget: queues event via adapter.handle_message()

AL17 deployable contract per c50c4232.

* fix: fail closed on unknown profile, add user_id to SessionSource (AL17 review)

- Explicit profile must be found in _profile_adapters; no silent fallback
- SessionSource includes user_id=chat_id for correct session identity
- Notice text delivered before event construction

* fix: Optional[str]->None return, profile default='', reorder params (AL17 review)

- Return type changed from Optional[str] to None
- profile parameter moved to end with default ''
- All return None changed to bare return
- SessionSource includes user_id=chat_id
- Docstring updated for new signature

* feat: add steer vs queue mode to inject_internal_message

Adds mode parameter to inject_internal_message:
- mode="queue" (default): fire-and-forget via adapter.handle_message()
- mode="steer": inject directly into running agent's turn via
  agent.steer(), falling back to queue when no agent is running

Also fixes bug from review commit 0869cc6 where event was
referenced before construction in the queue path.

Tests: 19 passing (11 queue mode + 5 steer mode + 2 hook + 1 negative)
- steer into running agent skips handle_message
- steer falls back to queue when no agent running
- steer falls back to queue when steer() returns False
- queue mode never calls steer() even when agent running
- notice_text preserved in steer mode
- strict profile resolution (fail closed on unknown profile)
- no ATM platform creation

* fix: correct profile resolver using _active_profile_name() (AL17 review)

- Resolve via self._active_profile_name() for primary profile
- Registered secondary profiles via _profile_adapters lookup
- fail closed on unknown profile (no silent fallback)
- Signature: profile='' default at end, -> None return, no mode param
- Remove _profile_adapters-is-empty-as-error heuristic

* fix: restore mode=steer per user directive, fix return None -> return

- mode='queue' (default), mode='steer' for non-interrupting injection
- steer uses _session_key_for_source + _running_agents
- falls through to queue if steer unavailable
- bare return everywhere

* fix: apply AL17 contract — keyword-only inject_internal_message with steer mode

- Add * separator and required profile: str (keyword-only)
- Add mode: Literal['queue','steer'] = 'queue' with steer logic
- Replace empty-string profile default with explicit active-profile check
- Fix return None → bare return everywhere
- Update tests for keyword-only API (mock _active_profile_name)
- 19/19 tests pass

* fix: expose gateway_runner in gateway:startup hook context

- Adds 'gateway_runner': self to the hook emit dict
- Enables hermes-atm to call runner.inject_internal_message()
  from a gateway:startup hook without private imports

* fix: structured errors, isolation tests, gateway_runner hook name (AL17 gaps 3-5)

- InjectInternalMessageError with code/chat_id/detail
- Profile/adapter failures raise instead of silently returning
- Isolation tests: queue and steer cannot cross sessions
- Hook context uses gateway_runner (not runner)
- test_missing_adapter uses real adapter map pattern

* fix: actually raise InjectInternalMessageError in inject_internal_message (gaps 3-4)

* feat: add host-contract isolation tests + mode validation (AL17 gate)

Three host-contract tests for PR NousResearch#82915:
1. same-profile/two-chat steer isolation — steer per-chat within profile
2. two-profiles/same-chat isolation — steer per-profile within same chat_id
3. invalid runtime mode fails closed — InjectInternalMessageError

Also adds mode validation at top of inject_internal_message: unknown
mode values now raise InjectInternalMessageError(code='invalid_mode')
rather than silently falling through to queue mode.

---------

Co-authored-by: Rand Lee <randlee@users.noreply.github.com>
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.

1 participant