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
20 changes: 11 additions & 9 deletions .dev/refactor/hermes-executor-readiness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,23 @@ Keep the existing working runtime paths while extracting a neutral adapter bound

Current implemented adapter pieces:
- `hermit_agent/orchestrators/contracts.py`
- neutral DTO/protocol definitions
- neutral DTO definitions
- `OrchestratorSetupAdapter` for install/health surfaces
- `OrchestratorRuntimeAdapter` reserved for future non-MCP lifecycle owners
- `hermit_agent/orchestrators/hermes.py`
- thin Hermes setup/doctor/live-smoke wrapper
- runtime lifecycle methods intentionally raise `NotImplementedError`
- `hermit_agent/orchestrators/claude.py`
- thin Claude setup/health wrapper
- `hermit_agent/orchestrators/codex.py`
- thin Codex setup/health wrapper
- `hermit_agent/orchestrators/prompts.py`
- runtime `InteractivePrompt` ↔ adapter `InteractivePrompt` mapping
- `PromptReply` helper

Important current gap:
- `HermesMcpAdapter.submit_task()` is not implemented.
- `HermesMcpAdapter.emit_event()` is not implemented.
- `HermesMcpAdapter.wait_for_reply()` is not implemented.
- `HermesMcpAdapter.cancel()` is not implemented.

This is acceptable only if the canonical Hermes runtime remains the existing MCP server path and the adapter is documented as install/doctor-only. If the target architecture requires all orchestrators behind `OrchestratorAdapter`, later PRs must wire those lifecycle methods to the MCP/gateway task proxy.
Current runtime decision:
- Hermit's MCP server remains the canonical runtime task boundary.
- Claude Code, Codex, and Hermes Agent invoke Hermit through MCP tools (`run_task`, `reply_task`, `check_task`, `cancel_task`).
- Adapter wrappers currently describe setup/health/smoke only; they do not imply adapter-owned task submission or event delivery.

## PR sequence

Expand Down
21 changes: 16 additions & 5 deletions .dev/refactor/orchestrator-adapter-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@

Hermit currently supports Claude Code and Codex, but several integration details are spread across MCP server code, channel notification code, codex-channels code, install flow, and tests. Adding Hermes Agent should not mean adding a third set of special cases in the core loop.

## Target model
## Runtime boundary decision

Hermit core should expose an executor task protocol. Orchestrators should be adapters around that protocol.
Current decision: Hermit's MCP server is the canonical runtime task boundary for Claude Code, Codex, and Hermes Agent. Adapters currently own setup, health, and smoke-check DTO surfaces; they do not own task submission, event delivery, prompt replies, or cancellation.

This keeps the working runtime path honest:
- orchestrators discover Hermit through setup adapters
- orchestrators invoke Hermit through MCP tools (`run_task`, `reply_task`, `check_task`, `cancel_task`)
- prompt/event DTO helpers exist for future extraction and documentation, but do not replace MCP delivery yet

Future model, if a concrete product bug requires adapter-owned runtime delivery:

```
Orchestrator
Expand Down Expand Up @@ -102,17 +109,21 @@ Implemented scaffold: `hermit_agent/orchestrators/contracts.py` now defines the
Use this as the design target for the scaffold and later adapter extraction:

```python
class OrchestratorAdapter:
class OrchestratorSetupAdapter:
name: str

def install_or_print_instructions(self, *, cwd: str, fix: bool) -> AdapterInstallResult: ...
def health(self, *, cwd: str) -> AdapterHealth: ...

class OrchestratorRuntimeAdapter(OrchestratorSetupAdapter):
def submit_task(self, request: TaskRequest) -> TaskHandle: ...
def emit_event(self, task_id: str, event: TaskEvent) -> None: ...
def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None: ...
def cancel(self, task_id: str) -> None: ...
```

`OrchestratorAdapter` remains an alias for the future runtime protocol for compatibility, but current Claude/Codex/Hermes wrappers intentionally satisfy only `OrchestratorSetupAdapter`.

DTOs should stay orchestrator-neutral:
- `TaskRequest`
- `TaskHandle`
Expand All @@ -123,16 +134,16 @@ DTOs should stay orchestrator-neutral:
- `AdapterInstallResult`

Current test anchor:
- `tests/test_orchestrator_contracts.py` verifies immutable DTO shape, stable status/event enum values, and the structural lifecycle shape of `OrchestratorAdapter`.
- `tests/test_orchestrator_contracts.py` verifies immutable DTO shape, stable status/event enum values, the setup-only protocol shape, and the separate future runtime protocol shape.
- `tests/test_hermes_orchestrator_adapter.py` verifies `HermesMcpAdapter` maps existing Hermes install, live-smoke, and doctor helpers into `AdapterInstallResult` / `AdapterHealth` without mutating behavior.
- `tests/test_claude_orchestrator_adapter.py` verifies `ClaudeCodeMcpAdapter` maps existing Claude MCP print/fix/registration-health helpers into `AdapterInstallResult` / `AdapterHealth` without mutating runtime delivery behavior.
- `tests/test_codex_orchestrator_adapter.py` verifies `CodexAdapter` maps existing codex-channels, Codex marketplace, Codex MCP, and legacy reply-hook helpers into `AdapterInstallResult` / `AdapterHealth` without mutating runtime delivery behavior.
- `tests/test_orchestrator_prompt_mapping.py` verifies current runtime prompts can round-trip through the neutral `InteractivePrompt` / `PromptReply` DTOs while preserving method/request/thread metadata.
- `tests/test_orchestrator_event_mapping.py` verifies current Gateway SSE events, channel actions, and task status payloads can map into neutral `TaskEvent` DTOs without rewiring delivery behavior.

Next extraction candidates:
- start equivalent Claude/Codex install and health wrappers after the Hermes wrapper shape proves stable
- keep MCP server as the canonical runtime boundary unless a later smoke proves an adapter-owned runtime path is safer
- only implement `OrchestratorRuntimeAdapter` methods when a specific orchestrator needs a non-MCP lifecycle path

## Non-goals

Expand Down
2 changes: 1 addition & 1 deletion .dev/refactor/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Acceptance:

### R2.1 Define orchestrator-neutral contracts

Status: first scaffold implemented in `hermit_agent/orchestrators/contracts.py`; behavior remains on the existing Claude/Codex/Hermes paths until R2.2 extraction slices.
Status: `hermit_agent/orchestrators/contracts.py` now separates `OrchestratorSetupAdapter` from the future `OrchestratorRuntimeAdapter`. `OrchestratorAdapter` remains a compatibility alias for the runtime protocol, while current Claude/Codex/Hermes wrappers satisfy only the setup/health surface. Behavior remains on the existing MCP server path.

Target surfaces:
- task submission
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
- Added adapter prompt mapping helpers that round-trip the current runtime `InteractivePrompt` shape through the neutral `InteractivePrompt` / `PromptReply` DTOs without changing interactive delivery behavior.
- Added pure task event mapping helpers that convert existing Gateway SSE events, channel actions, and task status payloads into neutral `TaskEvent` DTOs without changing MCP/channel delivery behavior.
- Added thin Claude Code and Codex adapter wrappers that map existing setup and health helpers onto the shared adapter DTOs without changing the current MCP/channel runtime delivery paths.
- Split the adapter contract into `OrchestratorSetupAdapter` and future `OrchestratorRuntimeAdapter` protocols so current Claude/Codex/Hermes wrappers no longer imply they own task submission or prompt/event delivery while Hermit's MCP server remains the canonical runtime boundary.
- Started tracking the long-term refactor roadmap under `.dev/refactor/**` while leaving other `.dev/*` scratch files ignored.
- Removed warning-producing test mocks for setup coroutines so the suite no longer emits unawaited `init_db` / `create_api_key` runtime warnings.
- Aligned contributor and harness docs on `.venv/bin/python -m pytest tests/` and added a repo hygiene regression so stale `.venv/bin/pytest` shebangs are not reintroduced as the documented path.
Expand Down
4 changes: 4 additions & 0 deletions hermit_agent/orchestrators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
AdapterInstallStatus,
InteractivePrompt,
OrchestratorAdapter,
OrchestratorRuntimeAdapter,
OrchestratorSetupAdapter,
PromptReply,
TaskEvent,
TaskEventKind,
Expand Down Expand Up @@ -37,6 +39,8 @@
"HermesMcpAdapter",
"InteractivePrompt",
"OrchestratorAdapter",
"OrchestratorRuntimeAdapter",
"OrchestratorSetupAdapter",
"PromptReply",
"TaskEvent",
"TaskEventKind",
Expand Down
18 changes: 0 additions & 18 deletions hermit_agent/orchestrators/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@
AdapterHealthStatus,
AdapterInstallResult,
AdapterInstallStatus,
InteractivePrompt,
PromptReply,
TaskEvent,
TaskHandle,
TaskRequest,
)
from ..install_flow import inspect_claude_mcp_registration, register_claude_mcp, resolve_hermit_mcp_stdio_entry

Expand Down Expand Up @@ -58,19 +53,6 @@ def health(self, *, cwd: str) -> AdapterHealth:
status = inspect_claude_mcp_registration(entry=resolve_hermit_mcp_stdio_entry(cwd=cwd))
return _health_from_registration_status(status)

def submit_task(self, request: TaskRequest) -> TaskHandle:
raise NotImplementedError("Claude Code task submission still uses the MCP server path directly")

def emit_event(self, task_id: str, event: TaskEvent) -> None:
raise NotImplementedError("Claude Code event delivery still uses the MCP server path directly")

def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None:
raise NotImplementedError("Claude Code reply delivery still uses the MCP server path directly")

def cancel(self, task_id: str) -> None:
raise NotImplementedError("Claude Code cancellation still uses the MCP server path directly")


def _install_result_from_status(status: str, *, path: object, backup: object | None) -> AdapterInstallResult:
if status == "registered":
install_status = AdapterInstallStatus.REGISTERED
Expand Down
18 changes: 0 additions & 18 deletions hermit_agent/orchestrators/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@
AdapterHealthStatus,
AdapterInstallResult,
AdapterInstallStatus,
InteractivePrompt,
PromptReply,
TaskEvent,
TaskHandle,
TaskRequest,
)
from ..install_flow import (
ensure_codex_channels_ready,
Expand Down Expand Up @@ -103,19 +98,6 @@ def health(self, *, cwd: str) -> AdapterHealth:
message="codex-channels runtime missing — run `hermit install` or use fix=True adapter setup",
)

def submit_task(self, request: TaskRequest) -> TaskHandle:
raise NotImplementedError("Codex task submission still uses the existing Codex channel/MCP runtime paths")

def emit_event(self, task_id: str, event: TaskEvent) -> None:
raise NotImplementedError("Codex event delivery still uses the existing Codex channel/MCP runtime paths")

def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None:
raise NotImplementedError("Codex reply delivery still uses the existing Codex channel/MCP runtime paths")

def cancel(self, task_id: str) -> None:
raise NotImplementedError("Codex cancellation still uses the existing Codex channel/MCP runtime paths")


def _codex_statuses_changed(runtime_status: str, marketplace_status: str, mcp_status: str, hook_status: str) -> bool:
return any(
status in {"installed", "registered", "removed"}
Expand Down
23 changes: 21 additions & 2 deletions hermit_agent/orchestrators/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,13 @@ class AdapterInstallResult:


@runtime_checkable
class OrchestratorAdapter(Protocol):
"""Minimal lifecycle contract for higher-level Hermit orchestrators."""
class OrchestratorSetupAdapter(Protocol):
"""Setup/health contract for optional higher-level Hermit orchestrators.

The MCP server is the canonical runtime task boundary today. Setup adapters
expose registration and diagnostics without implying that they own task
submission, event delivery, prompt replies, or cancellation.
"""

name: str

Expand All @@ -131,6 +136,17 @@ def install_or_print_instructions(self, *, cwd: str, fix: bool) -> AdapterInstal
def health(self, *, cwd: str) -> AdapterHealth:
"""Return read-only adapter health for diagnostics/doctor output."""


@runtime_checkable
class OrchestratorRuntimeAdapter(OrchestratorSetupAdapter, Protocol):
"""Future adapter-owned task lifecycle contract.

Current Claude Code, Codex, and Hermes integrations use Hermit's MCP server
tools (`run_task`, `reply_task`, `check_task`, `cancel_task`) as the stable
runtime surface. Implement this protocol only for a future orchestrator that
truly owns those lifecycle operations outside the MCP tool boundary.
"""

def submit_task(self, request: TaskRequest) -> TaskHandle:
"""Submit a task to Hermit and return a stable task handle."""

Expand All @@ -142,3 +158,6 @@ def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply

def cancel(self, task_id: str) -> None:
"""Cancel an in-flight task or clear adapter-side task state."""


OrchestratorAdapter = OrchestratorRuntimeAdapter
18 changes: 0 additions & 18 deletions hermit_agent/orchestrators/hermes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@
AdapterHealthStatus,
AdapterInstallResult,
AdapterInstallStatus,
InteractivePrompt,
PromptReply,
TaskEvent,
TaskHandle,
TaskRequest,
)
from ..doctor import DiagCheck, DiagStatus, _check_hermes_mcp as check_hermes_mcp
from ..install_flow import (
Expand Down Expand Up @@ -58,19 +53,6 @@ def live_smoke(self, *, cwd: str) -> AdapterHealth:
health_status = AdapterHealthStatus.FAIL
return AdapterHealth(name=self.name, status=health_status, message=status)

def submit_task(self, request: TaskRequest) -> TaskHandle:
raise NotImplementedError("Hermes task submission still uses the MCP server path directly")

def emit_event(self, task_id: str, event: TaskEvent) -> None:
raise NotImplementedError("Hermes event delivery still uses the MCP server path directly")

def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None:
raise NotImplementedError("Hermes reply delivery still uses the MCP server path directly")

def cancel(self, task_id: str) -> None:
raise NotImplementedError("Hermes cancellation still uses the MCP server path directly")


def _install_result_from_status(status: str) -> AdapterInstallResult:
if status == "registered":
install_status = AdapterInstallStatus.REGISTERED
Expand Down
21 changes: 4 additions & 17 deletions tests/test_claude_orchestrator_adapter.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
from __future__ import annotations

import pytest

from hermit_agent.orchestrators import (
AdapterHealthStatus,
AdapterInstallStatus,
ClaudeCodeMcpAdapter,
InteractivePrompt,
TaskEvent,
TaskEventKind,
TaskRequest,
OrchestratorRuntimeAdapter,
OrchestratorSetupAdapter,
)


Expand All @@ -24,6 +20,8 @@ def fail_register(**kwargs):

result = ClaudeCodeMcpAdapter().install_or_print_instructions(cwd="/repo", fix=False)

assert isinstance(ClaudeCodeMcpAdapter(), OrchestratorSetupAdapter)
assert not isinstance(ClaudeCodeMcpAdapter(), OrchestratorRuntimeAdapter)
assert result.name == "claude-code"
assert result.status == AdapterInstallStatus.PRINTED
assert result.changed is False
Expand Down Expand Up @@ -71,14 +69,3 @@ def test_claude_adapter_health_maps_registration_status(monkeypatch):
assert "invalid" in invalid.message


def test_claude_adapter_lifecycle_methods_are_explicitly_unsupported():
adapter = ClaudeCodeMcpAdapter()

with pytest.raises(NotImplementedError, match="MCP server path"):
adapter.submit_task(TaskRequest(task="do it", cwd="/repo"))
with pytest.raises(NotImplementedError, match="MCP server path"):
adapter.emit_event("task-1", TaskEvent(task_id="task-1", kind=TaskEventKind.RUNNING))
with pytest.raises(NotImplementedError, match="MCP server path"):
adapter.wait_for_reply("task-1", InteractivePrompt(task_id="task-1", question="Continue?"))
with pytest.raises(NotImplementedError, match="MCP server path"):
adapter.cancel("task-1")
21 changes: 4 additions & 17 deletions tests/test_codex_orchestrator_adapter.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
from __future__ import annotations

import pytest

from hermit_agent.orchestrators import (
AdapterHealthStatus,
AdapterInstallStatus,
CodexAdapter,
InteractivePrompt,
TaskEvent,
TaskEventKind,
TaskRequest,
OrchestratorRuntimeAdapter,
OrchestratorSetupAdapter,
)


Expand All @@ -24,6 +20,8 @@ def fail_install(**kwargs):

result = CodexAdapter().install_or_print_instructions(cwd="/repo", fix=False)

assert isinstance(CodexAdapter(), OrchestratorSetupAdapter)
assert not isinstance(CodexAdapter(), OrchestratorRuntimeAdapter)
assert result.name == "codex"
assert result.status == AdapterInstallStatus.SKIPPED
assert result.changed is False
Expand Down Expand Up @@ -103,14 +101,3 @@ def test_codex_adapter_health_uses_runtime_version(monkeypatch):
assert "missing" in missing.message


def test_codex_adapter_lifecycle_methods_are_explicitly_unsupported():
adapter = CodexAdapter()

with pytest.raises(NotImplementedError, match="existing Codex channel"):
adapter.submit_task(TaskRequest(task="do it", cwd="/repo"))
with pytest.raises(NotImplementedError, match="existing Codex channel"):
adapter.emit_event("task-1", TaskEvent(task_id="task-1", kind=TaskEventKind.RUNNING))
with pytest.raises(NotImplementedError, match="existing Codex channel"):
adapter.wait_for_reply("task-1", InteractivePrompt(task_id="task-1", question="Continue?"))
with pytest.raises(NotImplementedError, match="existing Codex channel"):
adapter.cancel("task-1")
6 changes: 4 additions & 2 deletions tests/test_hermes_orchestrator_adapter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from hermit_agent.doctor import DiagCheck, DiagStatus
from hermit_agent.orchestrators import AdapterHealthStatus, AdapterInstallStatus, OrchestratorAdapter
from hermit_agent.orchestrators import AdapterHealthStatus, AdapterInstallStatus, OrchestratorRuntimeAdapter, OrchestratorSetupAdapter
from hermit_agent.orchestrators.hermes import HermesMcpAdapter


Expand All @@ -18,9 +18,11 @@ def fake_snippet(*, cwd: str) -> str:
lambda **kwargs: (_ for _ in ()).throw(AssertionError("print-only path must not mutate Hermes config")),
)

adapter: OrchestratorAdapter = HermesMcpAdapter()
adapter: OrchestratorSetupAdapter = HermesMcpAdapter()
result = adapter.install_or_print_instructions(cwd="/repo", fix=False)

assert isinstance(adapter, OrchestratorSetupAdapter)
assert not isinstance(adapter, OrchestratorRuntimeAdapter)
assert result.name == "hermes"
assert result.status == AdapterInstallStatus.PRINTED
assert result.changed is False
Expand Down
Loading