Skip to content

feat: add Hermes Agent adapter#226

Open
SauliusDev wants to merge 3 commits into
bmad-code-org:mainfrom
SauliusDev:feat/hermes-agent-adapter
Open

feat: add Hermes Agent adapter#226
SauliusDev wants to merge 3 commits into
bmad-code-org:mainfrom
SauliusDev:feat/hermes-agent-adapter

Conversation

@SauliusDev

@SauliusDev SauliusDev commented Jul 21, 2026

Copy link
Copy Markdown

Summary

  • add an interactive, attachable Hermes tmux adapter and bundled Hermes profile
  • register the user-scoped Hermes post_llm_call relay and translate it into BMAD Stop events
  • preserve application config.yaml files in isolated worktrees; Hermes hooks remain user-scoped
  • validate Hermes YAML hook registration, including scan-only probe behavior for its user-scoped config

Verification

  • GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null uv run pytest -q (2698 passed, 18 skipped)
  • uv run ruff check src tests
  • uv build
  • live hermes hooks test post_llm_call relay check
  • real tmux injection/relay integration test

Note: the default environment has a global core.hooksPath that bypasses two test-local rejecting hooks; the full suite is run with global Git config isolated so those tests exercise their intended local hooks.

Summary by CodeRabbit

  • New Features
    • Added support for running workflows through the Hermes interactive adapter.
    • Added a built-in Hermes profile with user-scoped hook configuration.
    • Added the bmad-loop relay command for forwarding Hermes events.
    • Added automatic Hermes hook registration while preserving existing hooks.
  • Bug Fixes
    • Improved startup validation and error handling when interactive sessions exit unexpectedly.
    • Added safer handling for user-scoped probing and worktree configuration.

Copilot AI review requested due to automatic review settings July 21, 2026 08:55
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds an interactive Hermes adapter, Hermes-specific profile validation and configuration, user-scoped hook installation, canonical event relaying, Hermes-aware probing, and CLI adapter dispatch.

Changes

Hermes integration

Layer / File(s) Summary
Profile and Hermes configuration contract
src/bmad_loop/adapters/profile.py, src/bmad_loop/data/profiles/hermes.toml, tests/test_profile.py
Profiles support adapter and hook_scope, validate Hermes-specific combinations, and define a built-in Hermes profile.
Interactive Hermes session lifecycle
src/bmad_loop/adapters/generic.py, src/bmad_loop/adapters/hermes.py, tests/test_hermes_adapter.py
Session preparation and launch are separated; Hermes launches interactively, verifies startup, injects prompts, and reports early exits.
Hermes hook merge and event relay
src/bmad_loop/hermes_hooks.py, src/bmad_loop/cli.py, tests/test_hermes_hooks.py
Managed Hermes hooks are merged or removed while unrelated entries remain, and bmad-loop relay writes canonical event files atomically.
Installation, dispatch, and probe integration
src/bmad_loop/install.py, src/bmad_loop/cli.py, src/bmad_loop/probe.py, tests/test_cli.py, tests/test_install.py, tests/test_probe.py
Installation, registration detection, adapter dispatch, validation, and probing now handle user-scoped Hermes configuration.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HermesAdapter
  participant tmux
  participant Hermes
  participant bmad_loop_relay
  participant RunEvents
  HermesAdapter->>tmux: launch interactive Hermes session
  tmux->>Hermes: start CLI process
  HermesAdapter->>tmux: verify pane and inject prompt
  Hermes->>bmad_loop_relay: invoke Stop hook with payload
  bmad_loop_relay->>RunEvents: write canonical event file
Loading

Suggested reviewers: pbean, dracic

Poem

I’m a rabbit with Hermes in flight,
Sending Stop events cleanly tonight.
Through tmux I hop,
Then relay at the top—
Canonical trails glowing bright.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.40% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding a Hermes Agent adapter.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds first-class support for the Hermes Agent by introducing a dedicated tmux-based adapter, a bundled Hermes profile, and a user-scoped hook relay that converts Hermes post_llm_call hooks into canonical BMAD Stop events.

Changes:

  • Add HermesAdapter/HermesDevAdapter and route adapter selection via profile metadata.
  • Implement user-scoped Hermes hook registration/validation and a bmad-loop relay <Event> command for hook payload ingestion.
  • Update probing behavior to avoid live probing for user-scoped Hermes hooks (falls back to scan with guidance), with comprehensive new tests.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/test_profile.py Verifies bundled Hermes profile fields and rejects invalid adapter/hook_scope.
tests/test_probe.py Ensures user-scoped Hermes probing falls back to scan and avoids JSON probe hook merging.
tests/test_install.py Validates Hermes user-scoped relay registration idempotency and detection.
tests/test_hermes_hooks.py Unit tests for Hermes YAML hook merge/remove and event relay emission.
tests/test_hermes_adapter.py Tests Hermes tmux startup/prompt injection behavior and an optional tmux E2E relay path.
tests/test_cli.py Confirms _make_adapters dispatches Hermes profiles to Hermes adapters.
src/bmad_loop/probe.py Uses centralized hooks_registered and adds scan fallback for user-scoped Hermes hooks during probe.
src/bmad_loop/install.py Adds Hermes YAML hook detection/registration and makes hook script copying conditional on project-scoped profiles.
src/bmad_loop/hermes_hooks.py Implements Hermes config path resolution, YAML hook merging/removal, and canonical event relay writer.
src/bmad_loop/data/profiles/hermes.toml Introduces bundled Hermes profile configured for user-scoped hooks and interactive tmux usage.
src/bmad_loop/cli.py Adds relay subcommand and validates user-scoped hook registration via shared install logic.
src/bmad_loop/adapters/profile.py Adds adapter + hook_scope to profile schema with validation and Hermes dialect support.
src/bmad_loop/adapters/hermes.py New interactive Hermes adapter that injects the initial prompt after session startup.
src/bmad_loop/adapters/generic.py Refactors session startup into _prepare_session/_launch_session to support Hermes startup injection.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/bmad_loop/install.py
Comment on lines +266 to +273
if dialect == "hermes-config-yaml":
return any(
any(
isinstance(entry, dict) and entry.get("command") == HERMES_RELAY_COMMAND
for entry in container.get(event, [])
)
for event in events
)
Comment thread src/bmad_loop/install.py
Comment on lines +423 to +441
try:
loaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except yaml.YAMLError:
print(f"FAIL: {config_path} is not valid YAML; fix it and re-run init")
return 1
if loaded is not None:
if not isinstance(loaded, dict):
print(f"FAIL: {config_path} must contain a YAML mapping; fix it and re-run init")
return 1
config = loaded
try:
config, changed = merge_hermes_stop_hook(config)
except ValueError as exc:
print(f"FAIL: {config_path}: {exc}")
return 1
if changed:
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
print(f" hooks registered ({profile.name}): {config_path}")

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/bmad_loop/cli.py (1)

461-466: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Hook-config path resolution duplicated with install.hooks_registered.

The hook_config computed here re-derives the same hook_scope == "user" vs project-scoped branching that install.hooks_registered performs internally (see src/bmad_loop/install.py lines 277-293). It's harmless today since both sides compute the identical path, but it's two independent copies of one decision — a future change to the scoping rule in one spot risks the reported config_path silently diverging from the path actually checked for registration.

Consider having install.hooks_registered (or a small shared helper) expose the resolved path alongside the boolean, so cmd_validate doesn't need to reimplement the branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bmad_loop/cli.py` around lines 461 - 466, Eliminate the duplicated
hook-scope path branching between cmd_validate and install.hooks_registered.
Update hooks_registered or introduce a shared resolver to return the resolved
hook configuration path together with the registration result, then have
cmd_validate reuse that path for hook_config and preserve the existing reported
config_path behavior.
src/bmad_loop/install.py (1)

418-446: 🗄️ Data Integrity & Integration | 🔵 Trivial | 🏗️ Heavy lift

Rewriting the user's live Hermes config.yaml via yaml.safe_load/yaml.safe_dump silently drops comments and formatting.

PyYAML's safe_load/safe_dump do not preserve comments, anchors, or the original block/flow style — every field survives, but any hand-written comments in the user's ~/.hermes/config.yaml are gone after the first bmad-loop init --cli hermes that needs to add the relay hook. Since this file is owned by the user (not by bmad-loop), that's a real, if narrow, loss of their content, unlike the JSON hook configs (which the CLIs there don't typically comment).

Consider using ruamel.yaml's round-trip loader/dumper here to preserve comments/formatting when mutating a config bmad-loop doesn't own.

♻️ Sketch using ruamel.yaml for round-trip preservation
-import yaml
+from ruamel.yaml import YAML
+
+_yaml = YAML()
+_yaml.preserve_quotes = True
...
-        try:
-            loaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
-        except yaml.YAMLError:
+        try:
+            loaded = _yaml.load(config_path.read_text(encoding="utf-8"))
+        except Exception:
             print(f"FAIL: {config_path} is not valid YAML; fix it and re-run init")
             return 1
...
-        config_path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
+        with config_path.open("w", encoding="utf-8") as f:
+            _yaml.dump(config, f)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bmad_loop/install.py` around lines 418 - 446, Update
_register_user_scoped_hooks to use ruamel.yaml’s round-trip loader and dumper
when reading and writing the user-owned Hermes config, preserving comments,
anchors, and block/flow formatting while applying merge_hermes_stop_hook. Retain
the existing YAML mapping validation, error handling, and registration behavior,
and add or reuse the project’s ruamel.yaml dependency.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bmad_loop/adapters/profile.py`:
- Around line 151-157: Update the validation around hook_scope and dialect in
the profile adapter validation flow so their correlation is enforced for every
dialect, including "none": require user-scoped hooks to use "hermes-config-yaml"
with "config.yaml", and reject "hermes-config-yaml" unless hook_scope is "user".
Keep the existing config_path safety checks for non-user scopes in their
separate conditional path.

---

Nitpick comments:
In `@src/bmad_loop/cli.py`:
- Around line 461-466: Eliminate the duplicated hook-scope path branching
between cmd_validate and install.hooks_registered. Update hooks_registered or
introduce a shared resolver to return the resolved hook configuration path
together with the registration result, then have cmd_validate reuse that path
for hook_config and preserve the existing reported config_path behavior.

In `@src/bmad_loop/install.py`:
- Around line 418-446: Update _register_user_scoped_hooks to use ruamel.yaml’s
round-trip loader and dumper when reading and writing the user-owned Hermes
config, preserving comments, anchors, and block/flow formatting while applying
merge_hermes_stop_hook. Retain the existing YAML mapping validation, error
handling, and registration behavior, and add or reuse the project’s ruamel.yaml
dependency.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 11d112e2-0391-45ac-8c25-432b00d0ffcd

📥 Commits

Reviewing files that changed from the base of the PR and between 03d7297 and 9ff5dc4.

📒 Files selected for processing (14)
  • src/bmad_loop/adapters/generic.py
  • src/bmad_loop/adapters/hermes.py
  • src/bmad_loop/adapters/profile.py
  • src/bmad_loop/cli.py
  • src/bmad_loop/data/profiles/hermes.toml
  • src/bmad_loop/hermes_hooks.py
  • src/bmad_loop/install.py
  • src/bmad_loop/probe.py
  • tests/test_cli.py
  • tests/test_hermes_adapter.py
  • tests/test_hermes_hooks.py
  • tests/test_install.py
  • tests/test_probe.py
  • tests/test_profile.py

Comment on lines +151 to +157
if hook_scope == "user":
if dialect != "hermes-config-yaml" or config_path != "config.yaml":
raise fail(
'user-scoped hooks require dialect = "hermes-config-yaml" '
'and config_path = "config.yaml"'
)
elif not config_path or is_absolute_path(config_path) or has_parent_ref(config_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent dialect == "none" from bypassing hook_scope == "user" validation.

Currently, if dialect == "none", the validation for user-scoped hooks is skipped entirely because it is inside the else: block. This allows an invalid state where a profile defines hook_scope = "user" but has dialect = "none" (which would erroneously cause it to be handled as a hookless HTTP adapter).

Additionally, this block does not explicitly restrict dialect = "hermes-config-yaml" to hook_scope = "user", which could cause hooks_registered to attempt to parse a project-scoped YAML configuration as JSON.

Lift the scope and dialect correlation checks out of the else block to enforce them universally.

🛠️ Proposed fix

Replace lines 142-158 with the following:

-    if dialect == "none":
-        # hookless: nothing is ever registered, so a config_path or events map
-        # is a contradiction — reject rather than silently ignore.
-        if hooks_d.get("config_path") or hooks_d.get("events"):
-            raise fail('hookless profiles (dialect = "none") must not set hooks.config_path/events')
-        config_path = ""
-        events: dict[str, str] = {}
-    else:
-        config_path = str(hooks_d.get("config_path", ""))
-        if hook_scope == "user":
-            if dialect != "hermes-config-yaml" or config_path != "config.yaml":
-                raise fail(
-                    'user-scoped hooks require dialect = "hermes-config-yaml" '
-                    'and config_path = "config.yaml"'
-                )
-        elif not config_path or is_absolute_path(config_path) or has_parent_ref(config_path):
-            raise fail("hooks.config_path must be a project-relative path")
+    config_path = str(hooks_d.get("config_path", ""))
+
+    if hook_scope == "user":
+        if dialect != "hermes-config-yaml" or config_path != "config.yaml":
+            raise fail(
+                'user-scoped hooks require dialect = "hermes-config-yaml" '
+                'and config_path = "config.yaml"'
+            )
+    elif dialect == "hermes-config-yaml":
+        raise fail('dialect = "hermes-config-yaml" requires hook_scope = "user"')
+
+    if dialect == "none":
+        # hookless: nothing is ever registered, so a config_path or events map
+        # is a contradiction — reject rather than silently ignore.
+        if hooks_d.get("config_path") or hooks_d.get("events"):
+            raise fail('hookless profiles (dialect = "none") must not set hooks.config_path/events')
+        config_path = ""
+        events: dict[str, str] = {}
+    else:
+        if not config_path or is_absolute_path(config_path) or has_parent_ref(config_path):
+            raise fail("hooks.config_path must be a project-relative path")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if hook_scope == "user":
if dialect != "hermes-config-yaml" or config_path != "config.yaml":
raise fail(
'user-scoped hooks require dialect = "hermes-config-yaml" '
'and config_path = "config.yaml"'
)
elif not config_path or is_absolute_path(config_path) or has_parent_ref(config_path):
config_path = str(hooks_d.get("config_path", ""))
if hook_scope == "user":
if dialect != "hermes-config-yaml" or config_path != "config.yaml":
raise fail(
'user-scoped hooks require dialect = "hermes-config-yaml" '
'and config_path = "config.yaml"'
)
elif dialect == "hermes-config-yaml":
raise fail('dialect = "hermes-config-yaml" requires hook_scope = "user"')
if dialect == "none":
# hookless: nothing is ever registered, so a config_path or events map
# is a contradiction — reject rather than silently ignore.
if hooks_d.get("config_path") or hooks_d.get("events"):
raise fail('hookless profiles (dialect = "none") must not set hooks.config_path/events')
config_path = ""
events: dict[str, str] = {}
else:
if not config_path or is_absolute_path(config_path) or has_parent_ref(config_path):
raise fail("hooks.config_path must be a project-relative path")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bmad_loop/adapters/profile.py` around lines 151 - 157, Update the
validation around hook_scope and dialect in the profile adapter validation flow
so their correlation is enforced for every dialect, including "none": require
user-scoped hooks to use "hermes-config-yaml" with "config.yaml", and reject
"hermes-config-yaml" unless hook_scope is "user". Keep the existing config_path
safety checks for non-user scopes in their separate conditional path.

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