Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,12 @@ async def setup(self, config: AdapterConfig) -> None:

import httpx

# Default off — see executor.py module docstring. Workspace boot
# was wedging on the plugin /a2a/health probe because the plugin
# didn't bind :8645 inside the deployed image. Falls back to the
# legacy /v1/chat/completions /health probe until that's fixed.
use_plugin = os.environ.get(
"MOLECULE_A2A_PLATFORM_ENABLED", "true"
"MOLECULE_A2A_PLATFORM_ENABLED", "false"
).strip().lower() in ("1", "true", "yes", "on")

if use_plugin:
Expand Down
37 changes: 22 additions & 15 deletions executor.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
"""A2A → hermes-agent bridge with two transports.

Default transport (``MOLECULE_A2A_PLATFORM_ENABLED=true``)
Default transport (``MOLECULE_A2A_PLATFORM_ENABLED=false``)
==========================================================
POST to ``http://127.0.0.1:8642/v1/chat/completions`` synchronously,
parse the OpenAI-shaped response, emit. No session continuity but no
plugin dependency.

This is the safe default while the plugin install path inside the
deployed image is being debugged: the staging E2E for PR #32 surfaced
a workspace boot failure where ``hermes gateway run`` did not bind
``:8645`` inside the container (root cause TBD; local
``scripts/e2e_full_chain.py`` runs against my laptop venv where the
plugin was already installed manually, so it didn't catch the
deployment-shape divergence). Flip back to plugin path with
``MOLECULE_A2A_PLATFORM_ENABLED=true`` once the image-side install
is verified.

Plugin transport (``MOLECULE_A2A_PLATFORM_ENABLED=true``)
=========================================================
POST each A2A turn to the in-container hermes-agent platform plugin's
``/a2a/inbound`` endpoint. Hermes processes the message through its full
pipeline (sessions, skills, tools, hooks) and POSTs the agent's reply
back to a callback server we run inside this executor. A correlation
table maps the inbound ``message_id`` to an ``asyncio.Future`` that the
``execute()`` call awaits — so the A2A response is delivered as soon as
hermes calls ``send()``, not by polling.

This earns single-session continuity for peer agents: every turn lands
in the same hermes daemon, which keeps its in-memory conversation state
intact across messages. Replaces the previous "subprocess per A2A
message" pattern that was ``/v1/chat/completions`` with no continuity.

Fallback transport (``MOLECULE_A2A_PLATFORM_ENABLED=false``)
============================================================
The pre-plugin path: POST to ``http://127.0.0.1:8642/v1/chat/completions``
synchronously, parse the OpenAI-shaped response, emit. No session
continuity but no plugin dependency. Operators flip this off if the
plugin path misbehaves in production.
hermes calls ``send()``, not by polling. Earns single-session continuity
for peer agents.

Wire shape
==========
Expand Down Expand Up @@ -102,7 +107,9 @@ def __init__(self, config: AdapterConfig):
# Plugin transport state. The reply server only boots if the
# plugin path is enabled; otherwise the executor degrades to
# the legacy proxy below.
self._use_plugin = _bool_env("MOLECULE_A2A_PLATFORM_ENABLED", True)
# Default false until the image-side plugin install is verified
# — see module docstring. Operators flip on per workspace via env.
self._use_plugin = _bool_env("MOLECULE_A2A_PLATFORM_ENABLED", False)
self._plugin_host = os.environ.get(
"MOLECULE_A2A_PLATFORM_HOST", _DEFAULT_PLUGIN_HOST
)
Expand Down
17 changes: 11 additions & 6 deletions tests/test_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,14 @@ async def test_setup_skips_under_smoke_mode(monkeypatch):


@pytest.mark.asyncio
async def test_setup_probes_plugin_health_by_default(monkeypatch):
"""With MOLECULE_A2A_PLATFORM_ENABLED unset, setup() must probe
/a2a/health (NOT the legacy /v1/health)."""
async def test_setup_probes_plugin_health_when_enabled(monkeypatch):
"""When MOLECULE_A2A_PLATFORM_ENABLED=true, setup() probes
/a2a/health (NOT the legacy /v1/health). Plugin path is opt-in
while the image-side install is being verified — see executor.py
module docstring."""

monkeypatch.delenv("MOLECULE_SMOKE_MODE", raising=False)
monkeypatch.delenv("MOLECULE_A2A_PLATFORM_ENABLED", raising=False)
monkeypatch.setenv("MOLECULE_A2A_PLATFORM_ENABLED", "true")

health_port = _free_port()
paths_hit: list[str] = []
Expand Down Expand Up @@ -135,10 +137,13 @@ async def health_handler(request: web.Request) -> web.Response:

@pytest.mark.asyncio
async def test_create_executor_returns_started_executor(monkeypatch):
"""create_executor() must return an executor whose reply server is
already running (start() was called)."""
"""create_executor() with plugin path enabled must return an
executor whose reply server is already running (start() was
called). Plugin path is opt-in while the image-side install is
verified — see executor.py module docstring."""

cb_port = _free_port()
monkeypatch.setenv("MOLECULE_A2A_PLATFORM_ENABLED", "true")
monkeypatch.setenv("MOLECULE_A2A_CALLBACK_PORT", str(cb_port))

cfg = AdapterConfig(model="hermes-test")
Expand Down
26 changes: 22 additions & 4 deletions tests/test_executor_plugin_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ def _free_port() -> int:


def _make_executor(monkeypatch, **env: str) -> HermesAgentProxyExecutor:
"""Test helper. Defaults MOLECULE_A2A_PLATFORM_ENABLED=true so the
plugin-path tests below operate in plugin mode by default. Tests
that exercise the chat_completions fallback override this."""
env.setdefault("MOLECULE_A2A_PLATFORM_ENABLED", "true")
for k, v in env.items():
monkeypatch.setenv(k, v)
cfg = AdapterConfig(
Expand Down Expand Up @@ -85,15 +89,29 @@ def _build_context(text: str, *, task_id: str = "task-1"):
# ---- structural -----------------------------------------------------


def test_executor_init_defaults_to_plugin_path(monkeypatch):
def test_executor_init_defaults_to_chat_completions(monkeypatch):
"""Default is the safe legacy /v1/chat/completions transport while
the image-side plugin install is being debugged. Plugin path is
opt-in via MOLECULE_A2A_PLATFORM_ENABLED=true. Port defaults still
apply for when the plugin path is enabled.

Built without _make_executor so the helper's plugin-on default
doesn't mask the prod default we want to assert here."""
monkeypatch.delenv("MOLECULE_A2A_PLATFORM_ENABLED", raising=False)
ex = _make_executor(monkeypatch)
assert ex._use_plugin is True
cfg = AdapterConfig(model="hermes-test", system_prompt="you are helpful")
ex = HermesAgentProxyExecutor(cfg)
assert ex._use_plugin is False
# Defaults still set so plugin enable just needs the one env flip.
assert ex._plugin_port == 8645
assert ex._callback_port == 8646


def test_executor_falls_back_when_plugin_disabled(monkeypatch):
def test_executor_enables_plugin_path_when_opted_in(monkeypatch):
ex = _make_executor(monkeypatch, MOLECULE_A2A_PLATFORM_ENABLED="true")
assert ex._use_plugin is True


def test_executor_disabled_explicitly(monkeypatch):
ex = _make_executor(monkeypatch, MOLECULE_A2A_PLATFORM_ENABLED="false")
assert ex._use_plugin is False

Expand Down
Loading