Files
FarmHouse-Holdings/engine/hermes_client.py
T
fez5587andClaude Fable 5 9b6e7a23f6 Slice 1: dispatcher loop + objective decomposition — first live objective completed
- engine/dispatcher.py: 10s tick, bounded in-flight (3), ready->kanban dispatch
  with budget mapping (goal_max_turns/max_runtime_seconds), poll->AgentMessage
  extraction->events, wall-clock cap with run termination, blocked triage state.
- engine/protocol.py: schema-enforced pool calls — raw report -> AgentMessage,
  objective brief -> task decomposition (3.6s live).
- engine/main.py: POST /companies/{slug}/objectives (manager decompose ->
  child work items, role-matched round-robin), GET work-items.
- schema: work_item.hermes_task_id/dispatched_at, employee.role.
- Hermes integration fixes discovered live:
  * profiles must use the pool custom endpoint (provider config written into
    per-profile config; 'ollama' provider = Ollama Cloud, wants API key)
  * kanban toolset was globally disabled in deployed Hermes config — removed
    from agent.disabled_toolsets (backup in scratchpad)
  * no_skills profiles (73 bundled skills drowned 9B tool discipline)
  * souls spell out kanban_show first, kanban_complete/kanban_block with
    required args last — after this, tasks complete reliably.
- Live result: GasWatch objective decomposed into 3 tasks, all completed by
  qwen3.5:9b employees (mvp_features.md, onepager.md, report.md), 4 cost
  entries totaling ~24 min wall, full event trail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 09:49:26 -05:00

165 lines
6.7 KiB
Python

"""Adapter for Hermes Agent 0.19.0 (ADR-001).
The ONLY module allowed to talk to Hermes. Engine business logic imports this,
never httpx-to-Hermes directly, so a Hermes upgrade is contained here.
Two Hermes surfaces:
* Dashboard API (:30433) — cookie session auth (login endpoint), profiles,
kanban orchestration, runs. This is the integration surface.
* api_server (:30432) — OpenAI-compatible bearer-auth chat; one completion =
one full agent run. Used later for one-shot manager reasoning if needed.
Event flow back to the engine is POLLING (kanban task status + runs): Hermes
webhooks are inbound triggers, not outbound delivery.
"""
from typing import Any
import httpx
from engine.config import settings
class HermesError(RuntimeError):
pass
class HermesClient:
"""Async client with lazy cookie login and one retry on session expiry."""
def __init__(self) -> None:
self._http = httpx.AsyncClient(base_url=settings.hermes_dashboard_url, timeout=30)
self._logged_in = False
async def aclose(self) -> None:
await self._http.aclose()
# ------------------------------------------------------------ auth
async def _login(self) -> None:
r = await self._http.post(
"/auth/password-login",
json={
"provider": "basic",
"username": settings.hermes_username,
"password": settings.hermes_password,
},
)
if r.status_code != 200 or not r.json().get("ok"):
raise HermesError(f"Hermes login failed: {r.status_code} {r.text[:200]}")
self._logged_in = True
async def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
if not self._logged_in:
await self._login()
r = await self._http.request(method, path, **kwargs)
if r.status_code == 401: # session expired mid-flight
await self._login()
r = await self._http.request(method, path, **kwargs)
if r.status_code >= 400:
raise HermesError(f"{method} {path} -> {r.status_code}: {r.text[:300]}")
return r
async def _json(self, method: str, path: str, **kwargs) -> Any:
r = await self._request(method, path, **kwargs)
return r.json() if r.content else None
# ------------------------------------------------------------ health
async def status(self) -> dict:
return await self._json("GET", "/api/status")
# ------------------------------------------------------------ profiles (= employees)
async def list_profiles(self) -> list[dict]:
data = await self._json("GET", "/api/profiles")
return data if isinstance(data, list) else data.get("profiles", [])
async def create_profile(self, name: str, *, soul: str, model: str,
description: str = "") -> None:
"""Create employee persona: profile + soul (system prompt) + model binding.
no_skills: lean prompt — 73 bundled skills drown a 9B model's tool
discipline; employees get capabilities via explicit toolsets instead.
"""
await self._json("POST", "/api/profiles", json={
"name": name,
"no_skills": True,
"description": description,
})
await self._json("PUT", f"/api/profiles/{name}/soul", json={"content": soul})
# no-clone profiles have a bare config with no custom endpoint defined;
# declare the pool endpoint directly, same shape as the default profile
await self._json("PUT", "/api/config", json={
"profile": name,
"config": {"model": {
"default": model,
"provider": "custom",
"base_url": f"{settings.ollama_pool_url}/v1",
}},
})
async def get_soul(self, name: str) -> str:
data = await self._json("GET", f"/api/profiles/{name}/soul")
return data.get("content", "") if isinstance(data, dict) else str(data)
async def delete_profile(self, name: str) -> None:
await self._json("DELETE", f"/api/profiles/{name}")
# ------------------------------------------------------------ kanban (= work dispatch)
async def create_task(self, title: str, *, body: str, assignee: str, tenant: str,
idempotency_key: str, max_runtime_seconds: int = 1800,
goal_max_turns: int = 15, priority: int = 3,
workspace_path: str | None = None) -> dict:
"""One engine work-item dispatch = one kanban task.
tenant = FarmHouse company slug (board isolation).
goal_max_turns / max_runtime_seconds carry the work item's runaway budget.
idempotency_key = engine work_item UUID — safe re-dispatch after crashes.
"""
payload = {
"title": title,
"body": body,
"assignee": assignee,
"tenant": tenant,
"priority": priority,
"idempotency_key": idempotency_key,
"max_runtime_seconds": max_runtime_seconds,
"goal_max_turns": goal_max_turns,
"triage": False,
}
if workspace_path:
payload["workspace_kind"] = "path"
payload["workspace_path"] = workspace_path
data = await self._json("POST", "/api/plugins/kanban/tasks", json=payload)
return data.get("task", data)
async def get_task(self, task_id: str) -> dict:
data = await self._json("GET", f"/api/plugins/kanban/tasks/{task_id}")
return data.get("task", data)
async def update_task(self, task_id: str, **fields) -> dict:
data = await self._json("PATCH", f"/api/plugins/kanban/tasks/{task_id}", json=fields)
return data.get("task", data)
async def delete_task(self, task_id: str) -> None:
await self._json("DELETE", f"/api/plugins/kanban/tasks/{task_id}")
async def board(self, tenant: str | None = None) -> dict:
params = {"tenant": tenant} if tenant else {}
return await self._json("GET", "/api/plugins/kanban/board", params=params)
async def dispatch_now(self) -> dict | None:
"""Force a dispatch tick instead of waiting for Hermes' 60s interval."""
return await self._json("POST", "/api/plugins/kanban/dispatch")
async def get_run(self, run_id: str) -> dict:
return await self._json("GET", f"/api/plugins/kanban/runs/{run_id}")
async def active_workers(self) -> list[dict]:
data = await self._json("GET", "/api/plugins/kanban/workers/active")
return data if isinstance(data, list) else data.get("workers", [])
async def terminate_run(self, run_id: str) -> None:
await self._json("POST", f"/api/plugins/kanban/runs/{run_id}/terminate")