mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-22 10:32:00 +00:00
Adds a Raft platform adapter as a bundled plugin (plugins/platforms/raft/) connecting Hermes to Raft as an external agent via a wake-channel bridge. The adapter starts a loopback HTTP endpoint, spawns 'raft agent bridge' as a child process, and injects content-free wake hints into the gateway session pipeline. The agent reads/sends messages through the Raft CLI; the adapter never touches message bodies or delivery cursors. Activity observer hooks report tool/LLM/session lifecycle events via a bounded at-most-once queue. Auto-enables when RAFT_PROFILE is set. Cherry-picked from PR #47629. Authored by skyzh (@xxchan).
455 lines
16 KiB
Python
455 lines
16 KiB
Python
"""Tests for the Raft channel adapter."""
|
|
|
|
import os
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
from aiohttp.test_utils import TestClient, TestServer
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from plugins.platforms.raft.adapter import (
|
|
ACTIVITY_DRAIN_SCHEMA,
|
|
ACTIVITY_EVENT_SCHEMA,
|
|
ActivityQueue,
|
|
BRIDGE_TOKEN_HEADER,
|
|
DEFAULT_PATH,
|
|
RaftAdapter,
|
|
_ACTIVE_ADAPTERS,
|
|
_ACTIVE_ADAPTERS_LOCK,
|
|
_RAFT_CONTEXT_LOCK,
|
|
_RAFT_PROMPT_TURN_IDS,
|
|
_RAFT_SESSION_IDS,
|
|
_RAFT_TURN_IDS,
|
|
_has_content_field,
|
|
_env_enablement,
|
|
_is_connected,
|
|
_on_session_start,
|
|
_on_pre_llm_call,
|
|
_on_pre_tool_call,
|
|
_on_post_llm_call,
|
|
_on_post_tool_call,
|
|
_on_session_end,
|
|
_on_session_finalize,
|
|
check_raft_requirements,
|
|
register,
|
|
)
|
|
from gateway.session import build_session_key
|
|
|
|
RAFT_CHANNEL_SCHEMA = "raft-channel-wake.v1"
|
|
FUTURE_RAFT_CHANNEL_SCHEMA = "raft-channel-wake.v2"
|
|
|
|
|
|
def _make_config(**extra):
|
|
data = {
|
|
"bridge_token": "bridge-secret",
|
|
"runtime_session": "default",
|
|
"port": 0,
|
|
}
|
|
data.update(extra)
|
|
return PlatformConfig(enabled=True, extra=data)
|
|
|
|
|
|
def _make_adapter(**extra):
|
|
return RaftAdapter(_make_config(**extra))
|
|
|
|
|
|
def _create_app(adapter: RaftAdapter) -> web.Application:
|
|
app = web.Application()
|
|
app.router.add_get("/health", adapter._handle_health)
|
|
app.router.add_post(adapter._path, adapter._handle_wake)
|
|
app.router.add_post("/activity", adapter._handle_activity)
|
|
app.router.add_get("/activity/drain", adapter._handle_activity_drain)
|
|
return app
|
|
|
|
|
|
def _activity_event(event_id: str, **overrides):
|
|
event = {
|
|
"schema": ACTIVITY_EVENT_SCHEMA,
|
|
"eventId": event_id,
|
|
"sessionId": "session-1",
|
|
"hookEventName": "PreToolUse",
|
|
"status": "ok",
|
|
"occurredAt": "2026-06-16T06:00:00Z",
|
|
"toolName": "execute_code",
|
|
}
|
|
event.update(overrides)
|
|
return event
|
|
|
|
|
|
class TestRaftWakePayload:
|
|
def test_detects_content_fields(self):
|
|
assert _has_content_field({"text": "hello"}) is True
|
|
assert _has_content_field({"nested": {"messages": []}}) is True
|
|
assert _has_content_field({"eventId": "evt-1", "messageId": "msg-1"}) is False
|
|
|
|
|
|
class TestRaftWakeHttp:
|
|
@pytest.mark.asyncio
|
|
async def test_send_is_noop_success(self):
|
|
adapter = _make_adapter()
|
|
|
|
result = await adapter.send("default", "hello")
|
|
|
|
assert result.success is True
|
|
assert result.message_id is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_missing_bridge_token(self):
|
|
adapter = _make_adapter()
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as client:
|
|
resp = await client.post(DEFAULT_PATH, json={"eventId": "wake-1"})
|
|
assert resp.status == 401
|
|
body = await resp.json()
|
|
|
|
assert body["ok"] is False
|
|
adapter.handle_message.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_content_bearing_payload(self):
|
|
adapter = _make_adapter()
|
|
adapter.set_message_handler(AsyncMock())
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as client:
|
|
resp = await client.post(
|
|
DEFAULT_PATH,
|
|
json={"eventId": "wake-1", "text": "do work"},
|
|
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
|
|
)
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
|
|
assert body == {"ok": False, "error": "content_not_allowed"}
|
|
adapter.handle_message.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_not_ready_without_gateway_handler(self):
|
|
adapter = _make_adapter()
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as client:
|
|
resp = await client.post(
|
|
DEFAULT_PATH,
|
|
json={"eventId": "wake-1"},
|
|
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
|
|
)
|
|
assert resp.status == 503
|
|
body = await resp.json()
|
|
|
|
assert body["ok"] is False
|
|
assert body["runtimeSession"] == "default"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("schema", [RAFT_CHANNEL_SCHEMA, FUTURE_RAFT_CHANNEL_SCHEMA])
|
|
async def test_accepts_content_free_wake_as_internal_event(self, schema):
|
|
adapter = _make_adapter()
|
|
adapter.set_message_handler(AsyncMock())
|
|
adapter.handle_message = AsyncMock()
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as client:
|
|
resp = await client.post(
|
|
DEFAULT_PATH,
|
|
json={
|
|
"schema": schema,
|
|
"attemptId": "attempt-1",
|
|
"eventId": "wake-1",
|
|
"messageId": "msg-1",
|
|
"agentId": "agent-1",
|
|
"profile": "dev",
|
|
"coreSessionId": "default",
|
|
"adapterInstance": "hermes",
|
|
"occurredAt": "2026-06-11T08:00:00Z",
|
|
},
|
|
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
|
|
)
|
|
assert resp.status == 202
|
|
body = await resp.json()
|
|
|
|
assert body == {"ok": True, "runtimeSession": "default"}
|
|
|
|
adapter.handle_message.assert_awaited_once()
|
|
event = adapter.handle_message.await_args.args[0]
|
|
assert event.internal is True
|
|
assert event.message_id == "wake-1"
|
|
assert event.raw_message["schema"] == schema
|
|
assert event.raw_message["eventId"] == "wake-1"
|
|
assert event.raw_message["attemptId"] == "attempt-1"
|
|
assert event.raw_message["messageId"] == "msg-1"
|
|
assert event.source.platform == Platform("raft")
|
|
assert event.source.chat_id == "default"
|
|
assert "raft manual get" in event.text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_busy_session_queues_without_interrupt(self):
|
|
handler = AsyncMock()
|
|
adapter = _make_adapter()
|
|
adapter.set_message_handler(handler)
|
|
|
|
source = adapter.build_source(
|
|
chat_id="default",
|
|
chat_name="Raft channel",
|
|
chat_type="dm",
|
|
user_id="raft-bridge",
|
|
user_name="Raft Bridge",
|
|
)
|
|
session_key = build_session_key(source)
|
|
adapter._active_sessions[session_key] = __import__("asyncio").Event()
|
|
|
|
accepted = await adapter._accept_wake({"eventId": "wake-busy"})
|
|
|
|
assert accepted is True
|
|
handler.assert_not_called()
|
|
assert session_key in adapter._pending_messages
|
|
pending = adapter._pending_messages[session_key]
|
|
assert pending.message_id == "wake-busy"
|
|
assert "raft manual get" in pending.text
|
|
|
|
|
|
class TestRaftActivityHttp:
|
|
@pytest.mark.asyncio
|
|
async def test_activity_endpoint_auth_validation_and_drain(self):
|
|
adapter = _make_adapter()
|
|
adapter._activity_queue = ActivityQueue(cap=2)
|
|
|
|
app = _create_app(adapter)
|
|
async with TestClient(TestServer(app)) as client:
|
|
unauthorized = await client.post("/activity", json=_activity_event("evt-1"))
|
|
assert unauthorized.status == 401
|
|
|
|
unknown = await client.post(
|
|
"/activity",
|
|
json={**_activity_event("evt-1"), "transcript_path": "/tmp/session.jsonl"},
|
|
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
|
|
)
|
|
assert unknown.status == 400
|
|
|
|
for event_id in ["evt-1", "evt-2", "evt-3"]:
|
|
resp = await client.post(
|
|
"/activity",
|
|
json=_activity_event(event_id),
|
|
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
|
|
)
|
|
assert resp.status == 202
|
|
|
|
drain = await client.get(
|
|
"/activity/drain?max=10",
|
|
headers={BRIDGE_TOKEN_HEADER: "bridge-secret"},
|
|
)
|
|
assert drain.status == 200
|
|
body = await drain.json()
|
|
|
|
assert body["schema"] == ACTIVITY_DRAIN_SCHEMA
|
|
assert body["dropped"] == 1
|
|
assert [event["eventId"] for event in body["events"]] == ["evt-2", "evt-3"]
|
|
|
|
def test_hook_mapping_reports_only_raft_context(self):
|
|
adapter = _make_adapter()
|
|
with _RAFT_CONTEXT_LOCK:
|
|
_RAFT_PROMPT_TURN_IDS.clear()
|
|
_RAFT_SESSION_IDS.clear()
|
|
_RAFT_TURN_IDS.clear()
|
|
with _ACTIVE_ADAPTERS_LOCK:
|
|
_ACTIVE_ADAPTERS.add(adapter)
|
|
try:
|
|
_on_pre_tool_call(
|
|
session_id="session-1",
|
|
turn_id="turn-1",
|
|
tool_name="execute_code",
|
|
args={"cmd": "echo nope"},
|
|
)
|
|
assert adapter._activity_queue.drain(10)["events"] == []
|
|
|
|
_on_pre_llm_call(
|
|
platform="raft",
|
|
session_id="session-1",
|
|
turn_id="turn-1",
|
|
user_message="run a probe",
|
|
)
|
|
_on_pre_llm_call(
|
|
platform="raft",
|
|
session_id="session-1",
|
|
turn_id="turn-1",
|
|
user_message="run a follow-up LLM call in the same turn",
|
|
)
|
|
_on_pre_tool_call(
|
|
session_id="session-1",
|
|
turn_id="turn-1",
|
|
tool_name="execute_code",
|
|
args={"cmd": "echo ok"},
|
|
)
|
|
_on_post_tool_call(
|
|
session_id="session-1",
|
|
turn_id="turn-1",
|
|
tool_name="execute_code",
|
|
args={"cmd": "echo ok"},
|
|
result="ok",
|
|
status="ok",
|
|
duration_ms=321,
|
|
)
|
|
_on_post_llm_call(
|
|
platform="raft",
|
|
session_id="session-1",
|
|
turn_id="turn-1",
|
|
assistant_response="done",
|
|
)
|
|
_on_session_end(
|
|
platform="raft",
|
|
session_id="session-1",
|
|
turn_id="turn-1",
|
|
completed=True,
|
|
interrupted=False,
|
|
)
|
|
_on_session_finalize(
|
|
platform="raft",
|
|
session_id="session-1",
|
|
reason="shutdown",
|
|
)
|
|
drain = adapter._activity_queue.drain(10)
|
|
finally:
|
|
with _ACTIVE_ADAPTERS_LOCK:
|
|
_ACTIVE_ADAPTERS.discard(adapter)
|
|
with _RAFT_CONTEXT_LOCK:
|
|
_RAFT_PROMPT_TURN_IDS.clear()
|
|
_RAFT_SESSION_IDS.clear()
|
|
_RAFT_TURN_IDS.clear()
|
|
|
|
assert [event["hookEventName"] for event in drain["events"]] == [
|
|
"UserPromptSubmit",
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"Stop",
|
|
"SessionEnd",
|
|
]
|
|
tool_start = drain["events"][1]
|
|
assert tool_start["toolName"] == "execute_code"
|
|
assert '"cmd": "echo ok"' in tool_start["toolInput"]
|
|
tool_result = drain["events"][2]
|
|
assert tool_result["durationMs"] == 321
|
|
|
|
def test_session_start_registers_raft_profile_env_passthrough(self):
|
|
import tools.env_passthrough as env_passthrough_mod
|
|
from tools.code_execution_tool import _scrub_child_env
|
|
from tools.environments.local import _make_run_env
|
|
from tools.env_passthrough import clear_env_passthrough, is_env_passthrough
|
|
|
|
previous_config_passthrough = env_passthrough_mod._config_passthrough
|
|
clear_env_passthrough()
|
|
env_passthrough_mod._config_passthrough = frozenset()
|
|
with _RAFT_CONTEXT_LOCK:
|
|
_RAFT_PROMPT_TURN_IDS.clear()
|
|
_RAFT_SESSION_IDS.clear()
|
|
_RAFT_TURN_IDS.clear()
|
|
try:
|
|
assert "RAFT_PROFILE" not in _scrub_child_env(
|
|
{"RAFT_PROFILE": "dev"},
|
|
is_windows=False,
|
|
)
|
|
|
|
_on_session_start(session_id="session-1", turn_id="turn-1")
|
|
assert not is_env_passthrough("RAFT_PROFILE")
|
|
|
|
_on_session_start(platform="raft", session_id="session-1", turn_id="turn-1")
|
|
|
|
assert is_env_passthrough("RAFT_PROFILE")
|
|
assert _scrub_child_env({"RAFT_PROFILE": "dev"}, is_windows=False)["RAFT_PROFILE"] == "dev"
|
|
with patch.dict(os.environ, {"PATH": "/usr/bin", "RAFT_PROFILE": "dev"}, clear=True):
|
|
assert _make_run_env({})["RAFT_PROFILE"] == "dev"
|
|
finally:
|
|
clear_env_passthrough()
|
|
env_passthrough_mod._config_passthrough = previous_config_passthrough
|
|
with _RAFT_CONTEXT_LOCK:
|
|
_RAFT_PROMPT_TURN_IDS.clear()
|
|
_RAFT_SESSION_IDS.clear()
|
|
_RAFT_TURN_IDS.clear()
|
|
|
|
def test_interrupted_turn_reports_error_stop(self):
|
|
adapter = _make_adapter()
|
|
with _RAFT_CONTEXT_LOCK:
|
|
_RAFT_PROMPT_TURN_IDS.clear()
|
|
_RAFT_SESSION_IDS.clear()
|
|
_RAFT_TURN_IDS.clear()
|
|
with _ACTIVE_ADAPTERS_LOCK:
|
|
_ACTIVE_ADAPTERS.add(adapter)
|
|
try:
|
|
_on_pre_llm_call(
|
|
platform="raft",
|
|
session_id="session-1",
|
|
turn_id="turn-1",
|
|
)
|
|
_on_session_end(
|
|
platform="raft",
|
|
session_id="session-1",
|
|
turn_id="turn-1",
|
|
completed=False,
|
|
interrupted=True,
|
|
)
|
|
drain = adapter._activity_queue.drain(10)
|
|
finally:
|
|
with _ACTIVE_ADAPTERS_LOCK:
|
|
_ACTIVE_ADAPTERS.discard(adapter)
|
|
with _RAFT_CONTEXT_LOCK:
|
|
_RAFT_PROMPT_TURN_IDS.clear()
|
|
_RAFT_SESSION_IDS.clear()
|
|
_RAFT_TURN_IDS.clear()
|
|
|
|
assert [event["hookEventName"] for event in drain["events"]] == [
|
|
"UserPromptSubmit",
|
|
"Stop",
|
|
]
|
|
assert drain["events"][1]["status"] == "error"
|
|
assert drain["events"][1]["errorClass"] == "interrupted"
|
|
|
|
|
|
class TestRaftConfig:
|
|
def test_env_enablement_auto_enables_with_raft_profile(self, monkeypatch):
|
|
monkeypatch.setenv("RAFT_PROFILE", "my-agent")
|
|
|
|
extra = _env_enablement()
|
|
|
|
assert extra is not None
|
|
assert extra["enabled"] is True
|
|
|
|
def test_env_enablement_returns_none_without_profile(self, monkeypatch):
|
|
monkeypatch.delenv("RAFT_PROFILE", raising=False)
|
|
|
|
assert _env_enablement() is None
|
|
|
|
def test_is_connected_checks_bridge_token_or_enabled(self):
|
|
assert _is_connected(PlatformConfig(enabled=True, extra={"bridge_token": "tok"})) is True
|
|
assert _is_connected(PlatformConfig(enabled=True, extra={"enabled": True})) is True
|
|
assert _is_connected(PlatformConfig(enabled=True, extra={})) is False
|
|
|
|
def test_register_calls_register_platform(self):
|
|
registered = {}
|
|
hooks = {}
|
|
|
|
class FakeCtx:
|
|
def register_platform(self, **kwargs):
|
|
registered.update(kwargs)
|
|
|
|
def register_hook(self, name, handler):
|
|
hooks[name] = handler
|
|
|
|
register(FakeCtx())
|
|
|
|
assert registered["name"] == "raft"
|
|
assert registered["label"] == "Raft"
|
|
assert registered["emoji"] == "🔔"
|
|
assert "profile show" in registered["platform_hint"]
|
|
assert "manual get" in registered["platform_hint"]
|
|
assert "--profile" in registered["platform_hint"]
|
|
assert hooks == {
|
|
"on_session_start": _on_session_start,
|
|
"pre_llm_call": _on_pre_llm_call,
|
|
"pre_tool_call": _on_pre_tool_call,
|
|
"post_tool_call": _on_post_tool_call,
|
|
"post_llm_call": _on_post_llm_call,
|
|
"on_session_end": _on_session_end,
|
|
"on_session_finalize": _on_session_finalize,
|
|
}
|