From f05db3b245b02f9c8579d89ceaacca542ff586f8 Mon Sep 17 00:00:00 2001 From: Jianhao Pu Date: Tue, 7 Jul 2026 08:25:34 -0700 Subject: [PATCH 1/2] feat: add Hermes Agent memory-provider integration Ship a Hermes Agent memory-provider plugin at `integrations/hermes/` and an `everos integrations install/uninstall hermes` installer that symlinks the bundle into `~/.hermes/plugins/everos/`. Hermes discovers user-installed plugins and loads them via the MemoryProvider subclass-fallback path, so no `hermes-agent` PR is required. The plugin is an HTTP client to the EverOS v1 API: - sync_turn -> POST /memory/add (two mixed-role MessageItems, ms timestamps) - on_session_end -> POST /memory/flush - prefetch -> POST /memory/search (two-phase, 1.5s hot-path wait) - on_memory_write -> mirror target="user" only - tools: everos_search / everos_list / everos_add / everos_flush - circuit breaker (5 transient failures -> 120s cooldown) OSS setup writes `~/.everos/everos.toml` ([llm]/[embedding]/[rerank]) and seeds `ome.toml` for the agent track when enabled. The bundle lives outside `src/everos/`, so it is unconstrained by the DDD/import-linter contracts and uses stdlib logging (it runs in the Hermes process). 136 hermetic tests cover the client, config, setup, formatting, provider lifecycle + tools + circuit breaker, the Hermes-side CLI, and the installer. Verified end-to-end with real models (LLM + embedding via OpenRouter): extraction writes episode markdown and search recalls the atomic fact. --- integrations/hermes/README.md | 148 ++++ integrations/hermes/__init__.py | 699 +++++++++++++++++ integrations/hermes/_client.py | 309 ++++++++ integrations/hermes/_config.py | 182 +++++ integrations/hermes/_constants.py | 53 ++ integrations/hermes/_formatting.py | 130 +++ integrations/hermes/_setup.py | 368 +++++++++ integrations/hermes/_types.py | 363 +++++++++ integrations/hermes/cli.py | 386 +++++++++ integrations/hermes/plugin.yaml | 5 + .../entrypoints/cli/commands/integrations.py | 254 ++++++ src/everos/entrypoints/cli/main.py | 3 +- tests/helpers/hermes_stub.py | 162 ++++ .../integration/test_hermes_plugin_install.py | 139 ++++ .../test_cli/test_integrations_command.py | 265 +++++++ tests/unit/test_integrations/__init__.py | 0 .../test_integrations/test_hermes/__init__.py | 0 .../test_integrations/test_hermes/conftest.py | 51 ++ .../test_integrations/test_hermes/test_cli.py | 355 +++++++++ .../test_hermes/test_client.py | 353 +++++++++ .../test_hermes/test_config.py | 225 ++++++ .../test_hermes/test_formatting.py | 176 +++++ .../test_hermes/test_provider.py | 737 ++++++++++++++++++ .../test_hermes/test_setup.py | 232 ++++++ 24 files changed, 5594 insertions(+), 1 deletion(-) create mode 100644 integrations/hermes/README.md create mode 100644 integrations/hermes/__init__.py create mode 100644 integrations/hermes/_client.py create mode 100644 integrations/hermes/_config.py create mode 100644 integrations/hermes/_constants.py create mode 100644 integrations/hermes/_formatting.py create mode 100644 integrations/hermes/_setup.py create mode 100644 integrations/hermes/_types.py create mode 100644 integrations/hermes/cli.py create mode 100644 integrations/hermes/plugin.yaml create mode 100644 src/everos/entrypoints/cli/commands/integrations.py create mode 100644 tests/helpers/hermes_stub.py create mode 100644 tests/integration/test_hermes_plugin_install.py create mode 100644 tests/unit/test_entrypoints/test_cli/test_integrations_command.py create mode 100644 tests/unit/test_integrations/__init__.py create mode 100644 tests/unit/test_integrations/test_hermes/__init__.py create mode 100644 tests/unit/test_integrations/test_hermes/conftest.py create mode 100644 tests/unit/test_integrations/test_hermes/test_cli.py create mode 100644 tests/unit/test_integrations/test_hermes/test_client.py create mode 100644 tests/unit/test_integrations/test_hermes/test_config.py create mode 100644 tests/unit/test_integrations/test_hermes/test_formatting.py create mode 100644 tests/unit/test_integrations/test_hermes/test_provider.py create mode 100644 tests/unit/test_integrations/test_hermes/test_setup.py diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md new file mode 100644 index 000000000..0b1f37fdb --- /dev/null +++ b/integrations/hermes/README.md @@ -0,0 +1,148 @@ +# EverOS Memory Provider + +EverOS memory provider plugin for Hermes Agent. Supports both vendor-hosted EverOS servers (platform mode) and local self-hosted instances (OSS mode) with your own LLM / embedding / rerank endpoints. Server-side md-first memory extraction, hybrid search, and per-session flushing. + +## Requirements + +- An EverOS server running and reachable (e.g. `everos server start`). +- `httpx` (declared in `plugin.yaml`; Hermes installs it automatically). + +## Install + +From the EverOS checkout, symlink this bundle into Hermes' plugin directory: + +```bash +everos integrations install hermes +``` + +This links `integrations/hermes/` to `$HERMES_HOME/plugins/everos` (default +`~/.hermes/plugins/everos`). To point at a non-default bundle source, set +`EVEROS_HERMES_PLUGIN_SOURCE=/path/to/bundle` or pass `--source PATH`. To +replace an existing real directory at the target, pass `--force`. + +> The bundle ships in-repo (`integrations/hermes/`), not in the EverOS +> wheel. Wheel-only installs (no checkout) therefore need +> `--source PATH` (or `EVEROS_HERMES_PLUGIN_SOURCE`) pointing at a +> separate checkout of the bundle. + +Then activate EverOS as the memory provider: + +```bash +hermes memory setup # select "everos" +``` + +Or, non-interactively: + +```bash +hermes everos setup --mode platform --api-url http://127.0.0.1:8000 --api-key sk-... +hermes config set memory.provider everos +``` + +> `hermes everos setup` only writes/merges `$HERMES_HOME/everos.json`. For a +> full OSS-mode setup (writing `~/.everos/everos.toml` + `ome.toml`), use +> `hermes memory setup` and select "everos". + +To remove the bundle later: + +```bash +everos integrations uninstall hermes +``` + +The `memory.provider` setting in Hermes config is left untouched — clear it +manually with `hermes config set memory.provider ''` if needed. + +## Config + +Behavioral settings live in `$HERMES_HOME/everos.json` (set them via +`hermes memory setup` or `hermes everos setup`). Only the secret +`EVEROS_API_KEY` belongs in `~/.hermes/.env`. + +| Key | Default | Description | +|-----|---------|-------------| +| `mode` | `platform` | `platform` (vendor-hosted EverOS) or `oss` (self-hosted) | +| `api_url` | `http://127.0.0.1:8000` | EverOS API base URL | +| `api_key` | — | EverOS API key (CLI-only; secret — prefer `EVEROS_API_KEY` env). Read from `EVEROS_API_KEY` / `everos.json` ONLY by the `hermes everos` CLI subcommands (status/search/flush). The provider itself does not authenticate: EverOS is loopback/no-auth by default. `_config.load_config` does not load it. | +| `user_id` | `hermes-user` | User identifier (user-track scope) | +| `agent_id` | `hermes` | Agent identifier (agent-track scope; used when `--owner agent`) | +| `app_id` | `default` | EverOS app scope | +| `project_id` | `default` | EverOS project scope | +| `agent_track_enabled` | `false` | Enables the agent-track OME extractors (`extract_agent_case`, `extract_agent_skill`, `trigger_skill_clustering`) in `~/.everos/ome.toml`. OSS mode only. | +| `everos_root` | `~/.everos` | EverOS root directory (OSS mode; `null` in platform mode) | + +The EverOS server itself is configured via `~/.everos/everos.toml` +(see `everos config show`). The plugin only needs to know how to reach it +and which scope to read/write. + +Example `everos.json`: + +```json +{ + "mode": "platform", + "api_url": "http://127.0.0.1:8000", + "user_id": "hermes-user", + "agent_id": "hermes", + "app_id": "default", + "project_id": "default" +} +``` + +## CLI + +When EverOS is the active memory provider, `hermes everos ` is +available: + +| Subcommand | Description | +|------------|-------------| +| `status` | Print reachability, active mode/user/scope, and circuit-breaker state | +| `search QUERY` | One-off search against the active config; prints JSON | +| `flush` | POST `/memory/flush` for the current session | +| `setup` | Non-interactive shortcut: write/merge `everos.json` | + +```bash +hermes everos status +hermes everos search "the user's preferred editor" --top-k 5 +hermes everos flush --session-id my-session +hermes everos setup --mode oss --api-url http://127.0.0.1:8000 +``` + +## Tools + +The provider exposes four agent-facing tools: + +| Tool | Description | +|------|-------------| +| `everos_search` | Hybrid search by meaning; returns ranked episodes / profiles / agent cases / skills | +| `everos_list` | List stored memories (paginated, unranked) | +| `everos_add` | Buffer a fact (add_messages) then flush the session; extraction runs on flush | +| `everos_flush` | Flush the current session's buffered messages for extraction | + +## Troubleshooting + +### "EverOS temporarily unavailable" + +The circuit breaker trips after 5 consecutive failures and pauses API calls +for 2 minutes. It resets automatically. + +- Check the EverOS server is running: `everos server start` or `curl http://127.0.0.1:8000/health`. +- Check `api_url` in `$HERMES_HOME/everos.json` points at the right server. +- Run `hermes everos status` to see the breaker state and reachability. + +### Server unreachable + +```bash +curl http://127.0.0.1:8000/health +``` + +If this fails, start the EverOS server (`everos server start`) or fix +`api_url`. The plugin degrades gracefully — search returns no results and +writes are buffered until the breaker resets. + +### Memories not appearing + +- `everos_add` buffers the fact (`add_messages`) and immediately flushes the + session — extraction runs on flush. For full LLM-backed extraction use the + normal turn flow. +- Search uses hybrid matching — try broader queries. +- Confirm `user_id` / `agent_id` / `app_id` / `project_id` match between + sessions (`$HERMES_HOME/everos.json`). +- Run `hermes everos flush` to force extraction of buffered messages. diff --git a/integrations/hermes/__init__.py b/integrations/hermes/__init__.py new file mode 100644 index 000000000..60e602c69 --- /dev/null +++ b/integrations/hermes/__init__.py @@ -0,0 +1,699 @@ +"""EverOS memory provider for the Hermes Agent. + +Wires the Phase 1 plugin modules (``_client`` / ``_config`` / ``_formatting`` +/ ``_setup`` / ``_types`` / ``_constants``) into the Hermes ``MemoryProvider`` +ABC. This is the only module in the bundle that imports Hermes symbols +(``agent.memory_provider``, ``hermes_constants``, ``tools.registry``, +``utils``); everything else is Hermes-agnostic so it can be unit-tested in +isolation. + +The provider mirrors each turn into EverOS via a background sync thread, +prefetches relevant memory before the agent answers, exposes four tools +(``everos_search`` / ``everos_list`` / ``everos_add`` / ``everos_flush``), +and trips a circuit breaker after repeated transient failures (mem0 parity). +""" + +from __future__ import annotations + +import atexit +import json +import logging +import re +import threading +import time +from pathlib import Path +from typing import Any + +from agent.memory_provider import MemoryProvider +from hermes_constants import get_hermes_home +from tools.registry import tool_error +from utils import atomic_json_write + +from ._client import EverOSApiClient +from ._config import ( + get_scope_ids, + is_configured, + load_config, + resolve_agent_id, + resolve_user_id, +) +from ._constants import ( + _BREAKER_COOLDOWN_SECS, + _BREAKER_THRESHOLD, + _DEFAULT_AGENT_ID, + _DEFAULT_API_URL, + _DEFAULT_SEARCH_METHOD, + _DEFAULT_TOOL_SEARCH_TOP_K, + _MIRROR_TARGETS, + _PREFETCH_WAIT_SECS, + TOOL_ADD, + TOOL_FLUSH, + TOOL_LIST, + TOOL_SEARCH, +) +from ._formatting import ( + format_memory_write_message, + format_prefetch, + format_tool_result, +) +from ._setup import post_setup as _run_setup +from ._types import EverOSClientError, MessageItem, ScopeIds + +logger = logging.getLogger(__name__) + +# Trivial prompts that should not trigger a prefetch (mem0 parity). +_TRIVIAL_PROMPT_RE = re.compile( + r"^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|" + r"continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|" + r"lgtm|k)$", + re.IGNORECASE, +) + +# EverOS error codes that warrant circuit-breaker trips (transient). Client +# errors (INVALID_INPUT / NOT_FOUND / ...) are not transient and do not count. +_TRANSIENT_CODES: frozenset[str] = frozenset( + { + "", + "CLIENT_CLOSED", + "CONFIGURATION_ERROR", + "EXTERNAL_SERVICE_UNAVAILABLE", + "INTERNAL_ERROR", + } +) + +# ── OpenAI function-calling tool schemas ──────────────────────────────────── + +_SEARCH_SCHEMA: dict[str, Any] = { + "name": TOOL_SEARCH, + "description": ( + "Search the EverOS memory store for relevant episodes, atomic " + "facts, and the user profile." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural-language search query.", + }, + "top_k": { + "type": "integer", + "default": _DEFAULT_TOOL_SEARCH_TOP_K, + "description": "Maximum number of results (1-100).", + }, + "method": { + "type": "string", + "enum": ["keyword", "vector", "hybrid", "agentic"], + "default": "hybrid", + }, + "owner": { + "type": "string", + "enum": ["user", "agent"], + "default": "user", + }, + }, + "required": ["query"], + }, +} + +_LIST_SCHEMA: dict[str, Any] = { + "name": TOOL_LIST, + "description": "List memories of a given type from the EverOS store.", + "parameters": { + "type": "object", + "properties": { + "memory_type": { + "type": "string", + "enum": ["episode", "profile", "agent_case", "agent_skill"], + "default": "episode", + }, + "page": { + "type": "integer", + "default": 1, + "minimum": 1, + }, + "page_size": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 100, + }, + "owner": { + "type": "string", + "enum": ["user", "agent"], + "default": "user", + }, + }, + "required": [], + }, +} + +_ADD_SCHEMA: dict[str, Any] = { + "name": TOOL_ADD, + "description": "Store a fact in the EverOS memory store for the user.", + "parameters": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The fact to remember.", + }, + }, + "required": ["content"], + }, +} + +_FLUSH_SCHEMA: dict[str, Any] = { + "name": TOOL_FLUSH, + "description": "Flush the buffered session messages for extraction.", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, +} + + +class EverosMemoryProvider(MemoryProvider): + """Hermes ``MemoryProvider`` backed by an EverOS server.""" + + def __init__(self) -> None: + self._config: dict[str, Any] | None = None + self._client: EverOSApiClient | None = None + self._session_id: str | None = None + self._user_id: str | None = None + self._agent_id: str | None = None + self._scope: ScopeIds | None = None + self._sync_thread: threading.Thread | None = None + self._mirror_thread: threading.Thread | None = None + self._prefetch_thread: threading.Thread | None = None + self._prefetch_query: str | None = None + self._prefetch_result: str | None = None + self._prefetch_done: bool = False + self._consecutive_failures: int = 0 + self._breaker_open_until: float = 0.0 + self._breaker_lock: threading.Lock = threading.Lock() + self._sync_lock: threading.Lock = threading.Lock() + self._prefetch_lock: threading.Lock = threading.Lock() + self._atexit_registered: bool = False + self._init_error: str = "" + + # ── identity / availability ─────────────────────────────────────────── + + @property + def name(self) -> str: + return "everos" + + def is_available(self) -> bool: + cfg = load_config(get_hermes_home()) + return is_configured(cfg) + + # ── lifecycle ───────────────────────────────────────────────────────── + + def initialize(self, session_id: str, **kwargs: Any) -> None: + self._config = load_config(get_hermes_home()) + self._user_id = resolve_user_id( + self._config, kwargs.get("user_id"), kwargs.get("user_id_alt") + ) + self._agent_id = resolve_agent_id(self._config) + self._scope = get_scope_ids(self._config) + try: + self._client = EverOSApiClient( + self._config.get("api_url") or _DEFAULT_API_URL + ) + except Exception as exc: + self._init_error = str(exc) + self._client = None + logger.warning("EverOS client init failed: %s", exc) + self._session_id = session_id + if not self._atexit_registered: + atexit.register(self._shutdown_client) + self._atexit_registered = True + + # ── circuit breaker ─────────────────────────────────────────────────── + + def _is_breaker_open(self) -> bool: + with self._breaker_lock: + if self._consecutive_failures < _BREAKER_THRESHOLD: + return False + if time.monotonic() >= self._breaker_open_until: + self._consecutive_failures = 0 + self._breaker_open_until = 0.0 + return False + return True + + def _record_success(self) -> None: + with self._breaker_lock: + self._consecutive_failures = 0 + + def _record_failure(self) -> None: + with self._breaker_lock: + self._consecutive_failures += 1 + if self._consecutive_failures >= _BREAKER_THRESHOLD: + self._breaker_open_until = time.monotonic() + _BREAKER_COOLDOWN_SECS + logger.warning( + "EverOS circuit breaker opened after %d failures", + self._consecutive_failures, + ) + + @staticmethod + def _is_transient(code: str) -> bool: + return code in _TRANSIENT_CODES + + # ── turn sync ───────────────────────────────────────────────────────── + + def sync_turn( + self, + user_content: str, + assistant_content: str, + *, + session_id: str = "", + messages: list[dict[str, Any]] | None = None, + ) -> None: + if self._client is None or self._is_breaker_open(): + return + now_ms = time.time_ns() // 1_000_000 + user_msg: MessageItem = { + "sender_id": self._user_id, + "role": "user", + "timestamp": now_ms, + "content": user_content, + } + asst_msg: MessageItem = { + "sender_id": self._agent_id, + "role": "assistant", + "timestamp": now_ms + 1, + "content": assistant_content, + } + sid = session_id or self._session_id + with self._sync_lock: + prev = self._sync_thread + if prev is not None: + prev.join(timeout=5.0) + if prev.is_alive(): + return # avoid duplicate writes + thread = threading.Thread( + target=self._sync_worker, + args=(sid, [user_msg, asst_msg]), + name="everos-sync", + daemon=True, + ) + self._sync_thread = thread + thread.start() + + def _sync_worker(self, session_id: str, messages: list[MessageItem]) -> None: + client = self._client + scope = self._scope + if client is None or scope is None: + return + try: + client.add_messages(session_id, scope.app_id, scope.project_id, messages) + self._record_success() + except EverOSClientError as exc: + if self._is_transient(exc.code): + self._record_failure() + logger.warning("EverOS sync_turn add failed: %s", exc) + except Exception: + logger.warning("EverOS sync_worker error", exc_info=True) + + # ── prefetch ────────────────────────────────────────────────────────── + + def prefetch(self, query: str, *, session_id: str = "") -> str: + if self._client is None or self._is_breaker_open(): + return "" + cached = self._consume_prefetch_result(query) + if cached is not None: + return cached + self._start_prefetch(query) + thread = self._prefetch_thread + if thread is not None: + thread.join(timeout=_PREFETCH_WAIT_SECS) + return self._consume_prefetch_result(query) or "" + + def queue_prefetch(self, query: str, *, session_id: str = "") -> None: + if self._is_trivial_prompt(query): + return + self._start_prefetch(query) + + def _start_prefetch(self, query: str) -> None: + with self._prefetch_lock: + self._prefetch_query = query + self._prefetch_result = None + self._prefetch_done = False + thread = threading.Thread( + target=self._prefetch_worker, + args=(query,), + name="everos-prefetch", + daemon=True, + ) + self._prefetch_thread = thread + thread.start() + + def _prefetch_worker(self, query: str) -> None: + client = self._client + scope = self._scope + if client is None or scope is None: + return + try: + data = client.search( + self._user_id, + None, + scope.app_id, + scope.project_id, + query, + include_profile=True, + top_k=_DEFAULT_TOOL_SEARCH_TOP_K, + method=_DEFAULT_SEARCH_METHOD, + ) + body = format_prefetch(query, data) + with self._prefetch_lock: + if self._prefetch_query == query: + self._prefetch_result = body + self._prefetch_done = True + self._record_success() + except EverOSClientError as exc: + if self._is_transient(exc.code): + self._record_failure() + else: + logger.warning("EverOS prefetch failed: %s", exc) + except Exception: + logger.warning("EverOS prefetch error", exc_info=True) + + def _consume_prefetch_result(self, query: str) -> str | None: + with self._prefetch_lock: + if self._prefetch_query == query and self._prefetch_done: + result = self._prefetch_result + self._prefetch_result = None + self._prefetch_done = False + self._prefetch_query = None + return result + return None + + @staticmethod + def _is_trivial_prompt(query: str) -> bool: + q = (query or "").strip().lower() + if not q or q.startswith("/"): + return True + return bool(_TRIVIAL_PROMPT_RE.match(q)) + + # ── session / memory-write hooks ────────────────────────────────────── + + def on_session_end(self, messages: list[dict[str, Any]]) -> None: + if self._sync_thread is not None: + self._sync_thread.join(timeout=10.0) + if ( + self._client is not None + and not self._is_breaker_open() + and self._scope is not None + ): + try: + self._client.flush_session( + self._session_id, + self._scope.app_id, + self._scope.project_id, + ) + except EverOSClientError as exc: + logger.warning("EverOS flush on session end failed: %s", exc) + except Exception: + logger.warning("EverOS on_session_end error", exc_info=True) + self._shutdown_client() + + def on_memory_write( + self, + action: str, + target: str, + content: str, + metadata: dict[str, Any] | None = None, + ) -> None: + effective = "add" if action in ("add", "replace") else action + if ( + effective != "add" + or target not in _MIRROR_TARGETS + or not content + or self._client is None + or self._is_breaker_open() + or self._scope is None + ): + return + msg = format_memory_write_message( + content, self._user_id, time.time_ns() // 1_000_000 + ) + sid = self._session_id + scope = self._scope + client = self._client + + def _mirror() -> None: + try: + client.add_messages(sid, scope.app_id, scope.project_id, [msg]) + client.flush_session(sid, scope.app_id, scope.project_id) + self._record_success() + except EverOSClientError as exc: + if self._is_transient(exc.code): + self._record_failure() + logger.warning("EverOS memory-write mirror failed: %s", exc) + except Exception: + logger.warning("EverOS mirror error", exc_info=True) + + self._mirror_thread = threading.Thread( + target=_mirror, name="everos-mirror", daemon=True + ) + self._mirror_thread.start() + + # ── tools ───────────────────────────────────────────────────────────── + + def get_tool_schemas(self) -> list[dict[str, Any]]: + return [_SEARCH_SCHEMA, _LIST_SCHEMA, _ADD_SCHEMA, _FLUSH_SCHEMA] + + def handle_tool_call( + self, tool_name: str, args: dict[str, Any], **kwargs: Any + ) -> str: + if self._client is None: + return tool_error(f"EverOS backend not initialized: {self._init_error}") + if self._is_breaker_open(): + return tool_error( + "EverOS temporarily unavailable (circuit breaker open). " + "Will retry automatically." + ) + scope = self._scope + client = self._client + if scope is None: + return tool_error("EverOS scope not initialized") + + if tool_name == TOOL_SEARCH: + return self._tool_search(client, scope, args) + if tool_name == TOOL_LIST: + return self._tool_list(client, scope, args) + if tool_name == TOOL_ADD: + return self._tool_add(client, scope, args) + if tool_name == TOOL_FLUSH: + return self._tool_flush(client, scope) + return tool_error(f"Unknown tool: {tool_name}") + + def _tool_search( + self, + client: EverOSApiClient, + scope: ScopeIds, + args: dict[str, Any], + ) -> str: + query = args.get("query") + if not isinstance(query, str) or not query: + return tool_error("Missing required parameter: query") + top_k = args.get("top_k") or _DEFAULT_TOOL_SEARCH_TOP_K + if not isinstance(top_k, int): + top_k = _DEFAULT_TOOL_SEARCH_TOP_K + top_k = max(1, min(100, top_k)) + method = args.get("method") or _DEFAULT_SEARCH_METHOD + owner = args.get("owner") or "user" + user_id, agent_id, include_profile = ( + (self._user_id, None, True) + if owner == "user" + else (None, self._agent_id, False) + ) + try: + data = client.search( + user_id, + agent_id, + scope.app_id, + scope.project_id, + query, + top_k=top_k, + method=method, + include_profile=include_profile, + ) + except EverOSClientError as exc: + if self._is_transient(exc.code): + self._record_failure() + return tool_error(f"EverOS search failed: {exc}") + return format_tool_result( + { + "results": data, + "count": len(data.get("episodes") or []), + } + ) + + def _tool_list( + self, + client: EverOSApiClient, + scope: ScopeIds, + args: dict[str, Any], + ) -> str: + memory_type = args.get("memory_type") or "episode" + page = args.get("page") or 1 + if not isinstance(page, int) or page < 1: + page = 1 + page_size = args.get("page_size") or 20 + if not isinstance(page_size, int): + page_size = 20 + page_size = max(1, min(100, page_size)) + owner = args.get("owner") or "user" + user_id, agent_id = ( + (self._user_id, None) if owner == "user" else (None, self._agent_id) + ) + try: + data = client.get( + user_id, + agent_id, + scope.app_id, + scope.project_id, + memory_type, + page=page, + page_size=page_size, + ) + except EverOSClientError as exc: + if self._is_transient(exc.code): + self._record_failure() + return tool_error(f"EverOS list failed: {exc}") + items = data.get(f"{memory_type}s") or [] + return format_tool_result( + { + "results": items, + "total_count": data.get("total_count", 0), + "count": len(items), + } + ) + + def _tool_add( + self, + client: EverOSApiClient, + scope: ScopeIds, + args: dict[str, Any], + ) -> str: + content = args.get("content") + if not isinstance(content, str) or not content: + return tool_error("Missing required parameter: content") + msg = format_memory_write_message( + content, self._user_id, time.time_ns() // 1_000_000 + ) + try: + client.add_messages(self._session_id, scope.app_id, scope.project_id, [msg]) + client.flush_session(self._session_id, scope.app_id, scope.project_id) + except EverOSClientError as exc: + if self._is_transient(exc.code): + self._record_failure() + return tool_error(f"EverOS add failed: {exc}") + return format_tool_result({"result": "Fact stored."}) + + def _tool_flush(self, client: EverOSApiClient, scope: ScopeIds) -> str: + try: + resp = client.flush_session( + self._session_id, scope.app_id, scope.project_id + ) + except EverOSClientError as exc: + if self._is_transient(exc.code): + self._record_failure() + return tool_error(f"EverOS flush failed: {exc}") + return format_tool_result({"status": resp.get("status")}) + + # ── prompt / config ─────────────────────────────────────────────────── + + def system_prompt_block(self) -> str: + mode = (self._config or {}).get("mode", "oss") + user = self._user_id or "unknown" + return ( + "## EverOS Memory Active\n" + f"- Mode: {mode}\n" + f"- User: {user}\n" + "- Call `everos_search` before answering context-dependent " + "questions about the user or prior conversations.\n" + ) + + def get_config_schema(self) -> list[dict[str, Any]]: + return [ + { + "key": "api_url", + "description": "EverOS API base URL.", + "default": _DEFAULT_API_URL, + }, + { + "key": "mode", + "description": "EverOS deployment mode.", + "choices": ["platform", "oss"], + "default": "oss", + }, + { + "key": "user_id", + "description": "User identifier for memory scoping.", + "default": "hermes-user", + }, + { + "key": "agent_id", + "description": "Agent identifier for memory scoping.", + "default": _DEFAULT_AGENT_ID, + }, + { + "key": "app_id", + "description": "Application identifier for memory scoping.", + "default": "default", + }, + { + "key": "project_id", + "description": "Project identifier for memory scoping.", + "default": "default", + }, + { + "key": "agent_track_enabled", + "description": "Whether agent-scoped memory tracking is enabled.", + "choices": ["true", "false"], + "default": "false", + }, + ] + + def save_config(self, values: dict[str, Any], hermes_home: str) -> None: + path = Path(hermes_home) / "everos.json" + existing: dict[str, Any] = {} + if path.exists(): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + if isinstance(raw, dict): + existing = raw + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to read existing %s: %s", path, exc) + merged = {**existing, **values} + atomic_json_write(path, merged, mode=0o600) + + def post_setup(self, hermes_home: str, config: dict[str, Any]) -> None: + _run_setup(Path(hermes_home), config, interactive=True) + + def backup_paths(self) -> list[str]: + root = Path.home() / ".everos" + return [str(root)] if root.exists() else [] + + # ── teardown ────────────────────────────────────────────────────────── + + def shutdown(self) -> None: + if self._prefetch_thread is not None: + self._prefetch_thread.join(timeout=5.0) + if self._sync_thread is not None: + self._sync_thread.join(timeout=5.0) + if self._mirror_thread is not None and self._mirror_thread.is_alive(): + self._mirror_thread.join(timeout=5.0) + self._shutdown_client() + + def _shutdown_client(self) -> None: + client = self._client + if client is not None: + try: + client.close() + except Exception: + logger.warning("Error closing EverOS client", exc_info=True) + self._client = None diff --git a/integrations/hermes/_client.py b/integrations/hermes/_client.py new file mode 100644 index 000000000..8803c777a --- /dev/null +++ b/integrations/hermes/_client.py @@ -0,0 +1,309 @@ +"""Synchronous HTTP client for the EverOS memory API. + +Wraps :class:`httpx.AsyncClient` running on a single dedicated background +event-loop thread (daemon) so the public surface stays synchronous — the +Hermes memory-provider ABC is sync, and this avoids blocking a global loop or +spawning a loop per call. All network / server errors are normalised to +:class:`EverOSClientError` with a machine-readable ``code`` field so the +provider's circuit breaker can classify transient vs client failures. + +This module is Hermes-agnostic: it depends only on the stdlib, ``httpx``, and +its sibling ``_types`` / ``_constants`` modules. It must not import anything +from ``everos.*`` (this code runs inside the Hermes process). +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +from collections.abc import Coroutine, Sequence +from typing import Any, TypeVar + +import httpx + +from ._constants import _ADD_BATCH_SIZE, _DEFAULT_APP_ID, _DEFAULT_PROJECT_ID +from ._types import ( + AddResponse, + EverOSClientError, + FlushResponse, + GetData, + MessageItem, + SearchData, +) + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + +_ADD_PATH = "/api/v1/memory/add" +_FLUSH_PATH = "/api/v1/memory/flush" +_SEARCH_PATH = "/api/v1/memory/search" +_GET_PATH = "/api/v1/memory/get" + +# Future-result backstop: httpx (timeout=self._timeout) fires first; the extra +# grace ensures the mapped httpx.TimeoutException wins over a bare +# concurrent.futures.TimeoutError (which would leave a zombie coroutine). +_FUTURE_GRACE_SECS = 5.0 + +# Optional query params forwarded from **kwargs (None => omitted from body). +_SEARCH_KWARGS = ( + "method", + "top_k", + "radius", + "min_score", + "include_profile", + "enable_llm_rerank", + "filters", +) +_GET_KWARGS = ("page", "page_size", "sort_by", "sort_order", "filters") + + +class EverOSApiClient: + """Synchronous client for the EverOS ``/api/v1/memory/*`` endpoints. + + The event-loop thread and ``httpx.AsyncClient`` are created lazily on the + first request. Call :meth:`close` to tear them down. + """ + + def __init__(self, base_url: str, *, timeout: float = 30.0) -> None: + self._base_url = base_url.rstrip("/") + self._timeout = timeout + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + self._client: httpx.AsyncClient | None = None + self._lock = threading.Lock() + self._closed = False + + # ── loop / transport plumbing ─────────────────────────────────────────── + + def _ensure_loop(self) -> None: + if self._loop is not None: + return + with self._lock: + # Re-check under the lock: close() may have nulled _loop (and set + # _closed) between the outer check and acquiring the lock. A worker + # that passed the _closed gate in _run() before close() ran must + # not resurrect a fresh loop on a closed client. + if self._closed: + raise EverOSClientError("client is closed", code="CLIENT_CLOSED") + if self._loop is not None: + return + loop = asyncio.new_event_loop() + thread = threading.Thread( + target=self._run_loop, + args=(loop,), + name="everos-api-loop", + daemon=True, + ) + thread.start() + self._loop = loop + self._thread = thread + # Build the AsyncClient inside the loop thread so its async + # lifecycle is bound to the loop that will use it. + init = asyncio.run_coroutine_threadsafe(self._make_client(), loop) + init.result() + + @staticmethod + def _run_loop(loop: asyncio.AbstractEventLoop) -> None: + asyncio.set_event_loop(loop) + loop.run_forever() + + async def _make_client(self) -> None: + self._client = httpx.AsyncClient( + base_url=self._base_url, + timeout=self._timeout, + headers={"Content-Type": "application/json"}, + ) + + def _run(self, coro: Coroutine[Any, Any, T]) -> T: + """Post ``coro`` to the background loop and wait for its result.""" + if self._closed: + raise EverOSClientError("client is closed", code="CLIENT_CLOSED") + self._ensure_loop() + assert self._loop is not None + fut = asyncio.run_coroutine_threadsafe(coro, self._loop) + try: + return fut.result(timeout=self._timeout + _FUTURE_GRACE_SECS) + except TimeoutError as exc: + # Future did not resolve in time (loop stalled or request hung). + raise EverOSClientError( + "request timed out waiting for event loop", + code="EXTERNAL_SERVICE_UNAVAILABLE", + ) from exc + + async def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]: + assert self._client is not None + try: + resp = await self._client.post(path, json=body) + except (httpx.ConnectError, httpx.TimeoutException) as exc: + raise EverOSClientError( + str(exc) or exc.__class__.__name__, + code="EXTERNAL_SERVICE_UNAVAILABLE", + ) from exc + if resp.status_code >= 400: + self._raise_for_status(resp) + try: + envelope = resp.json() + except ValueError as exc: + raise EverOSClientError( + f"HTTP {resp.status_code}: non-JSON response", + code="INTERNAL_ERROR", + ) from exc + data = envelope.get("data") if isinstance(envelope, dict) else None + if not isinstance(data, dict): + raise EverOSClientError( + "response envelope missing data object", + code="INTERNAL_ERROR", + ) + return data + + @staticmethod + def _raise_for_status(resp: httpx.Response) -> None: + try: + envelope = resp.json() + except ValueError: + raise EverOSClientError( + f"HTTP {resp.status_code}: {resp.text[:200]}", + code="INTERNAL_ERROR", + ) from None + error = envelope.get("error") if isinstance(envelope, dict) else None + if isinstance(error, dict): + code = str(error.get("code") or "INTERNAL_ERROR") + message = str(error.get("message") or f"HTTP {resp.status_code}") + raise EverOSClientError(message, code=code) + raise EverOSClientError( + f"HTTP {resp.status_code}: {resp.text[:200]}", + code="INTERNAL_ERROR", + ) + + # ── scope / owner helpers ─────────────────────────────────────────────── + + @staticmethod + def _apply_scope(body: dict[str, Any], app_id: str, project_id: str) -> None: + """Omit app_id/project_id when they equal the server default.""" + if app_id != _DEFAULT_APP_ID: + body["app_id"] = app_id + if project_id != _DEFAULT_PROJECT_ID: + body["project_id"] = project_id + + @staticmethod + def _owner_key(user_id: str | None, agent_id: str | None) -> str: + """Return the owner field name to populate, enforcing XOR.""" + if (user_id is None) == (agent_id is None): + raise ValueError( + "exactly one of user_id / agent_id must be provided", + ) + return "user_id" if user_id is not None else "agent_id" + + # ── public API ────────────────────────────────────────────────────────── + + def add_messages( + self, + session_id: str, + app_id: str, + project_id: str, + messages: Sequence[MessageItem], + ) -> AddResponse: + """POST /api/v1/memory/add, chunking into batches of _ADD_BATCH_SIZE. + + Sends sequential requests and merges results: ``message_count`` is the + sum across batches, ``status`` is the last batch's status. + """ + body_base: dict[str, Any] = {"session_id": session_id} + self._apply_scope(body_base, app_id, project_id) + total = len(messages) + message_count = 0 + status: str | None = None + # max(total, 1) ensures an empty input still hits the server (which + # rejects it with 422) rather than silently no-op'ing. + for start in range(0, max(total, 1), _ADD_BATCH_SIZE): + batch = list(messages[start : start + _ADD_BATCH_SIZE]) + body = {**body_base, "messages": batch} + resp = self._run(self._post(_ADD_PATH, body)) + message_count += int(resp.get("message_count", 0)) + status = resp.get("status") + assert status is not None + return {"message_count": message_count, "status": status} # type: ignore[return-value] + + def flush_session( + self, + session_id: str, + app_id: str, + project_id: str, + ) -> FlushResponse: + """POST /api/v1/memory/flush.""" + body: dict[str, Any] = {"session_id": session_id} + self._apply_scope(body, app_id, project_id) + return self._run(self._post(_FLUSH_PATH, body)) # type: ignore[return-value] + + def search( + self, + user_id: str | None, + agent_id: str | None, + app_id: str, + project_id: str, + query: str, + **kwargs: Any, + ) -> SearchData: + """POST /api/v1/memory/search. Returns the ``data`` object. + + Exactly one of ``user_id`` / ``agent_id`` must be set. Accepted kwargs: + method, top_k, radius, min_score, include_profile, enable_llm_rerank, + filters. ``None``-valued kwargs are omitted so server defaults apply. + """ + owner = self._owner_key(user_id, agent_id) + body: dict[str, Any] = { + "query": query, + owner: user_id if owner == "user_id" else agent_id, + } + self._apply_scope(body, app_id, project_id) + for key in _SEARCH_KWARGS: + if key in kwargs and kwargs[key] is not None: + body[key] = kwargs[key] + return self._run(self._post(_SEARCH_PATH, body)) # type: ignore[return-value] + + def get( + self, + user_id: str | None, + agent_id: str | None, + app_id: str, + project_id: str, + memory_type: str, + **kwargs: Any, + ) -> GetData: + """POST /api/v1/memory/get. Returns the ``data`` object. + + Exactly one of ``user_id`` / ``agent_id`` must be set. Accepted kwargs: + page, page_size, sort_by, sort_order, filters. + """ + owner = self._owner_key(user_id, agent_id) + body: dict[str, Any] = { + "memory_type": memory_type, + owner: user_id if owner == "user_id" else agent_id, + } + self._apply_scope(body, app_id, project_id) + for key in _GET_KWARGS: + if key in kwargs and kwargs[key] is not None: + body[key] = kwargs[key] + return self._run(self._post(_GET_PATH, body)) # type: ignore[return-value] + + def close(self) -> None: + """Shut down the loop thread and close the httpx client.""" + if self._closed: + return + self._closed = True + loop = self._loop + client = self._client + if loop is not None and client is not None: + try: + fut = asyncio.run_coroutine_threadsafe(client.aclose(), loop) + fut.result(timeout=10.0) + except Exception: + logger.debug("error closing httpx client", exc_info=True) + loop.call_soon_threadsafe(loop.stop) + if self._thread is not None: + self._thread.join(timeout=5.0) + self._client = None + self._loop = None + self._thread = None diff --git a/integrations/hermes/_config.py b/integrations/hermes/_config.py new file mode 100644 index 000000000..f44dc6c84 --- /dev/null +++ b/integrations/hermes/_config.py @@ -0,0 +1,182 @@ +"""Hermes-agnostic config loading and resolution for the EverOS plugin. + +This module deliberately imports only stdlib + the local ``_constants`` / +``_types`` siblings. It must remain importable without a Hermes runtime so +unit tests can exercise it in isolation. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import tempfile +from pathlib import Path +from typing import Any + +from ._constants import ( + _DEFAULT_AGENT_ID, + _DEFAULT_API_URL, + _DEFAULT_APP_ID, + _DEFAULT_PROJECT_ID, + _DEFAULT_USER_ID, + _SCOPE_ID_CHARSET, + _SCOPE_ID_MAX_LEN, + _SCOPE_ID_MIN_LEN, + _SCOPE_TRAVERSAL_TOKENS, +) +from ._types import ScopeIds + +logger = logging.getLogger(__name__) + +_SCOPE_ID_RE = re.compile(_SCOPE_ID_CHARSET) +_VALID_MODES = frozenset({"platform", "oss"}) + + +def _atomic_write_text(path: Path, text: str, mode: int | None = None) -> Path: + """Atomically write ``text`` to ``path`` via a temp file + ``os.replace``. + + If ``mode`` is given the destination is chmod-ed to it (e.g. 0o600). + Hermes-agnostic — does NOT call ``utils.atomic_json_write``. + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=str(path.parent), + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + if mode is not None: + os.chmod(tmp_path, mode) + os.replace(tmp_path, path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + if mode is not None: + # os.replace carries the mode of the temp file, but be explicit in + # case the destination was pre-created with looser perms. + os.chmod(path, mode) + return path + + +def _defaults() -> dict[str, Any]: + return { + "api_url": _DEFAULT_API_URL, + "mode": "platform", + "user_id": _DEFAULT_USER_ID, + "agent_id": _DEFAULT_AGENT_ID, + "app_id": _DEFAULT_APP_ID, + "project_id": _DEFAULT_PROJECT_ID, + "agent_track_enabled": False, + "everos_root": None, + } + + +def load_config(hermes_home: Path) -> dict[str, Any]: + """Merge defaults with ``$HERMES_HOME/everos.json`` (json wins). + + Env vars ``EVEROS_API_URL`` / ``EVEROS_USER_ID`` / ``EVEROS_AGENT_ID`` / + ``EVEROS_MODE`` act as a fallback layer between the defaults and the JSON + file: the JSON file still overrides them when present. + + Returns a plain dict; never raises on a malformed/missing JSON file — + the defaults are returned instead (mem0 parity). + """ + cfg = _defaults() + + # Env layer — only set when the operator actually exported a value. + env_api_url = os.environ.get("EVEROS_API_URL") + if env_api_url: + cfg["api_url"] = env_api_url + env_user_id = os.environ.get("EVEROS_USER_ID") + if env_user_id: + cfg["user_id"] = env_user_id + env_agent_id = os.environ.get("EVEROS_AGENT_ID") + if env_agent_id: + cfg["agent_id"] = env_agent_id + env_mode = os.environ.get("EVEROS_MODE") + if env_mode: + cfg["mode"] = env_mode + + config_path = Path(hermes_home) / "everos.json" + if config_path.exists(): + try: + file_cfg = json.loads(config_path.read_text(encoding="utf-8")) + if isinstance(file_cfg, dict): + cfg.update( + {k: v for k, v in file_cfg.items() if v is not None and v != ""} + ) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to read %s: %s", config_path, exc) + + return cfg + + +def resolve_user_id( + config: dict[str, Any], + kwargs_user_id: str | None, + kwargs_user_id_alt: str | None, +) -> str: + """Resolve the canonical user_id. + + Priority: ``config["user_id"]`` (if set and != ``_DEFAULT_USER_ID``) > + ``kwargs_user_id`` > ``kwargs_user_id_alt`` > ``_DEFAULT_USER_ID``. + + The literal ``_DEFAULT_USER_ID`` is treated as unset (mem0 parity: an + operator-configured id must be a real id, not the placeholder). + """ + configured = config.get("user_id") + if configured and configured != _DEFAULT_USER_ID: + return configured + if kwargs_user_id: + return kwargs_user_id + if kwargs_user_id_alt: + return kwargs_user_id_alt + return _DEFAULT_USER_ID + + +def resolve_agent_id(config: dict[str, Any]) -> str: + """Resolve the canonical agent_id.""" + return config.get("agent_id") or _DEFAULT_AGENT_ID + + +def _validate_scope_id(value: Any, field: str, default: str) -> str: + if value is None or value == "": + return default + if not isinstance(value, str): + raise ValueError(f"{field} must be a string, got {type(value).__name__}") + if value in _SCOPE_TRAVERSAL_TOKENS: + raise ValueError(f"{field} must not be a path-traversal token: {value!r}") + if not _SCOPE_ID_RE.fullmatch(value): + raise ValueError( + f"{field} contains characters outside the allowed charset " + f"{_SCOPE_ID_CHARSET!r}: {value!r}" + ) + if not (_SCOPE_ID_MIN_LEN <= len(value) <= _SCOPE_ID_MAX_LEN): + raise ValueError( + f"{field} length must be in " + f"[{_SCOPE_ID_MIN_LEN}, {_SCOPE_ID_MAX_LEN}]: {value!r}" + ) + return value + + +def get_scope_ids(config: dict[str, Any]) -> ScopeIds: + """Validate and return the ``(app_id, project_id)`` pair.""" + app_id = _validate_scope_id(config.get("app_id"), "app_id", _DEFAULT_APP_ID) + project_id = _validate_scope_id( + config.get("project_id"), "project_id", _DEFAULT_PROJECT_ID + ) + return ScopeIds(app_id=app_id, project_id=project_id) + + +def is_configured(config: dict[str, Any]) -> bool: + """True if ``api_url`` is non-empty or ``mode`` is recognized. No network.""" + api_url = config.get("api_url") + if api_url: + return True + return config.get("mode") in _VALID_MODES diff --git a/integrations/hermes/_constants.py b/integrations/hermes/_constants.py new file mode 100644 index 000000000..58fcbf1eb --- /dev/null +++ b/integrations/hermes/_constants.py @@ -0,0 +1,53 @@ +"""Provider-level constants and small runtime invariants. + +Keep this module dependency-free: it is imported by other plugin modules and is +a contract boundary for tests. +""" + +from __future__ import annotations + +from collections.abc import Set + +# ── Circuit breaker / async timeouts ───────────────────────────────────────── + +_BREAKER_THRESHOLD = 5 +_BREAKER_COOLDOWN_SECS = 120.0 +_PREFETCH_WAIT_SECS = 1.5 +_MAX_PREFETCH_CHARS = 4000 +_ADD_BATCH_SIZE = 500 + +# ── Defaults ─────────────────────────────────────────────────────────────────── + +_DEFAULT_USER_ID = "hermes-user" +_DEFAULT_AGENT_ID = "hermes" +_DEFAULT_APP_ID = "default" +_DEFAULT_PROJECT_ID = "default" +_DEFAULT_API_URL = "http://127.0.0.1:8000" + +# ── EverOS scope-id validation ───────────────────────────────────────────────── + +_SCOPE_ID_MIN_LEN = 1 +_SCOPE_ID_MAX_LEN = 128 +# Note: docs/api.md §ScopeId lists ^[a-zA-Z0-9_.-]+$, but the server-side +# PathSafeId validator in src/everos/entrypoints/api/routes/memorize.py also +# accepts '@' and '+'. We match the server so valid ids are not rejected. +_SCOPE_ID_CHARSET = r"^[a-zA-Z0-9_.@+-]+$" +_SCOPE_TRAVERSAL_TOKENS: Set[str] = frozenset({".", ".."}) + +# ── Search defaults ────────────────────────────────────────────────────────── + +_DEFAULT_SEARCH_METHOD = "hybrid" +# Default for the agent-facing tool only. EverOS API default is -1. +_DEFAULT_TOOL_SEARCH_TOP_K = 5 + +# ── Tool names ───────────────────────────────────────────────────────────────── + +TOOL_SEARCH = "everos_search" +TOOL_LIST = "everos_list" +TOOL_ADD = "everos_add" +TOOL_FLUSH = "everos_flush" + +# ── Memory-write mirroring scope ─────────────────────────────────────────────── + +# Hermes `target` values that should be mirrored into EverOS's user track. +_MIRROR_TARGETS: Set[str] = frozenset({"user"}) diff --git a/integrations/hermes/_formatting.py b/integrations/hermes/_formatting.py new file mode 100644 index 000000000..bb0a19244 --- /dev/null +++ b/integrations/hermes/_formatting.py @@ -0,0 +1,130 @@ +"""Hermes-agnostic formatting helpers for the EverOS plugin. + +Turns ``SearchData`` and other API shapes into the strings the Hermes agent +sees (prefetch block, tool-result JSON, mirror messages). Stdlib + local +``_constants`` / ``_types`` only. +""" + +from __future__ import annotations + +import json +import logging + +from ._constants import _MAX_PREFETCH_CHARS +from ._types import MessageItem, SearchData, SearchEpisodeItem, SearchProfileItem + +logger = logging.getLogger(__name__) + + +def _truncate(text: str, max_chars: int) -> str: + """Truncate ``text`` to ``max_chars`` at the nearest preceding word boundary. + + Appends ``" …"`` when truncation actually happens. Returns the original + string unchanged when it already fits. + """ + if len(text) <= max_chars: + return text + if max_chars <= 0: + return " …" + # Reserve room for the ellipsis token. + budget = max_chars - len(" …") + if budget <= 0: + return " …" + slice_end = budget + # Walk back to a whitespace boundary so we do not split a word. + boundary = text.rfind(" ", 0, slice_end) + if boundary > 0: + slice_end = boundary + return text[:slice_end].rstrip() + " …" + + +def _profile_one_line(profile: SearchProfileItem) -> str: + """Render a profile dict as a single summary line.""" + data = profile.get("profile_data") or {} + if isinstance(data, dict) and data: + # Prefer a small set of common keys, fall back to the first kv pair. + for key in ("name", "summary", "bio", "description", "title"): + if key in data and data[key]: + return str(data[key]).replace("\n", " ").strip() + first_key = next(iter(data)) + return f"{first_key}: {data[first_key]}".replace("\n", " ").strip() + uid = profile.get("user_id") or profile.get("id") or "user" + return f"profile for {uid}" + + +def _format_episode(ep: SearchEpisodeItem) -> str: + """Render one episode block (subject + truncated episode + atomic facts).""" + subject = ep.get("subject") or "(no subject)" + episode_text = (ep.get("episode") or "").strip() + facts = ep.get("atomic_facts") or [] + + lines = [f"- **Episode**: {subject}"] + if episode_text: + lines.append(episode_text) + for fact in facts: + content = (fact.get("content") or "").strip() + if content: + lines.append(f" - {content}") + return "\n".join(lines) + + +def format_prefetch( + query: str, + search_data: SearchData, + *, + max_chars: int = _MAX_PREFETCH_CHARS, +) -> str: + """Build the markdown prefetch block injected into the agent context. + + Episodes are sorted by ``score`` descending. Atomic facts are nested + under their parent episode (may be empty). The whole block is truncated + to ``max_chars`` at a word boundary, appending ``" …"`` when it spills. + + Returns ``""`` when there are no episodes and no profiles. + """ + episodes = list(search_data.get("episodes") or []) + profiles = list(search_data.get("profiles") or []) + + if not episodes and not profiles: + return "" + + header = "## EverOS Memory" + sections: list[str] = [header] + + if profiles: + # One-line summary from the highest-scoring profile (or the only one). + ranked = sorted( + profiles, + key=lambda p: p.get("score") if p.get("score") is not None else -1.0, + reverse=True, + ) + sections.append(f"- **Profile**: {_profile_one_line(ranked[0])}") + + for ep in sorted(episodes, key=lambda e: e.get("score", 0.0), reverse=True): + sections.append(_format_episode(ep)) + + body = "\n".join(sections) + truncated = _truncate(body, max_chars) + # Keep the query out of the visible block — it is only used as context + # for logging/debugging and is intentionally not surfaced to the agent + # here, matching mem0's prefetch contract. + logger.debug("format_prefetch query=%r len=%d", query, len(truncated)) + return truncated + + +def format_tool_result(data: object) -> str: + """Serialize a tool result payload as JSON (the inner serializer).""" + return json.dumps(data, ensure_ascii=False) + + +def format_memory_write_message( + content: str, user_id: str, timestamp_ms: int +) -> MessageItem: + """Build a user-role ``MessageItem`` for mirroring into EverOS.""" + item: MessageItem = { + "sender_id": user_id, + "role": "user", + "timestamp": timestamp_ms, + "content": content, + } + return item diff --git a/integrations/hermes/_setup.py b/integrations/hermes/_setup.py new file mode 100644 index 000000000..de7bde7ed --- /dev/null +++ b/integrations/hermes/_setup.py @@ -0,0 +1,368 @@ +"""Setup wizard for the EverOS Hermes plugin — Hermes-agnostic. + +Uses only stdlib + the local ``_config._atomic_write_text`` helper. The +real Hermes provider (``__init__.py``, Phase 2) adapts ``post_setup`` here +to the ABC signature ``post_setup(self, hermes_home: str, config: dict)``. +""" + +from __future__ import annotations + +import json +import logging +import tomllib +from pathlib import Path +from typing import Any + +from ._config import _atomic_write_text +from ._constants import _DEFAULT_AGENT_ID, _DEFAULT_API_URL, _DEFAULT_USER_ID + +logger = logging.getLogger(__name__) + +_TOML_FILE_MODE = 0o600 +_JSON_FILE_MODE = 0o600 + +# Optional TOML section fields beyond model/api_key/base_url. +_TOML_OPTIONAL_FIELDS = ( + "timeout_seconds", + "max_retries", + "batch_size", + "max_concurrent", +) + + +def _toml_section(name: str, fields: dict[str, Any]) -> str: + """Render one ``[name]`` TOML section as a string block.""" + lines = [f"[{name}]"] + for key in ("model", "api_key", "base_url"): + if key in fields and fields[key] is not None: + lines.append(f"{key} = {_toml_value(fields[key])}") + for key in _TOML_OPTIONAL_FIELDS: + if key in fields and fields[key] is not None: + lines.append(f"{key} = {_toml_value(fields[key])}") + lines.append("") + return "\n".join(lines) + + +def _toml_value(value: Any) -> str: + """Render a scalar (or inline array of scalars) as a TOML rvalue.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)) and not isinstance(value, bool): + return str(value) + if isinstance(value, list): + return "[" + ", ".join(_toml_value(v) for v in value) + "]" + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False) + # datetime and other objects — quote their str form. + return json.dumps(str(value), ensure_ascii=False) + + +def write_everos_toml( + everos_root: Path, + *, + llm: dict[str, Any], + embedding: dict[str, Any], + rerank: dict[str, Any] | None, +) -> Path: + """Write ``everos.toml`` under ``~/.everos`` (or ``everos_root``). + + Sections: ``[llm]`` and ``[embedding]`` are always written; ``[rerank]`` + only when ``rerank`` is provided. Each section emits ``model``, + ``api_key``, ``base_url`` plus any of the optional knobs + (``timeout_seconds`` / ``max_retries`` / ``batch_size`` / + ``max_concurrent``) that the caller supplied. + + The file is chmod 600. Returns the written path. + """ + root = Path(everos_root).expanduser() if everos_root else Path.home() / ".everos" + root.mkdir(parents=True, exist_ok=True) + path = root / "everos.toml" + + blocks = [ + "# EverOS configuration — written by the Hermes EverOS plugin setup.", + "", + _toml_section("llm", llm), + _toml_section("embedding", embedding), + ] + if rerank: + blocks.append(_toml_section("rerank", rerank)) + + text = "\n".join(blocks) + _atomic_write_text(path, text, mode=_TOML_FILE_MODE) + return path + + +def _emit_table(lines: list[str], path: list[str], table: dict[str, Any]) -> None: + """Emit one TOML table's scalars, then its sub-tables (recursively). + + Scalars belonging to the current table are emitted first (TOML requires + them to appear before any sub-table header). Sub-tables are then emitted + as ``[a.b.c]`` headers with their own contents. + """ + wrote_scalar = False + for key, value in table.items(): + if not isinstance(value, dict): + lines.append(f"{key} = {_toml_value(value)}") + wrote_scalar = True + if wrote_scalar: + lines.append("") + for key, value in table.items(): + if isinstance(value, dict): + sub_path = [*path, key] + lines.append(f"[{'.'.join(sub_path)}]") + _emit_table(lines, sub_path, value) + + +def _emit_toml(data: dict[str, Any]) -> str: + """Render a parsed-TOML dict back to TOML text (section-aware). + + Top-level scalars are emitted as ``key = value``; nested dicts become + ``[section]`` tables (recursing for sub-tables). Scalar values only — + booleans as ``true``/``false``, strings quoted, numbers bare, lists as + inline arrays. No ``tomli_w`` dependency. + """ + lines: list[str] = [] + _emit_table(lines, [], data) + return "\n".join(lines) + + +def seed_ome_toml(everos_root: Path, *, agent_track: bool) -> Path | None: + """Write/merge ``ome.toml`` enabling the agent-track extractors. + + Returns ``None`` when ``agent_track`` is falsy. Otherwise ensures + ``extract_agent_case``, ``extract_agent_skill`` and + ``trigger_skill_clustering`` are all enabled under ``[strategies.]``, + leaving any other keys/sections the file already carries untouched. + + Reads the existing file with ``tomllib`` (stdlib) and re-emits via the + section-aware ``_emit_toml`` helper so the on-disk shape matches the + ``TomlRoot`` schema (``[strategies.]`` tables, not flat top-level + keys). chmod 600. + """ + if not agent_track: + return None + + root = Path(everos_root).expanduser() if everos_root else Path.home() / ".everos" + root.mkdir(parents=True, exist_ok=True) + path = root / "ome.toml" + + data: dict[str, Any] = {} + if path.exists(): + try: + with path.open("rb") as fh: + data = tomllib.load(fh) + except (OSError, tomllib.TOMLDecodeError) as exc: + logger.warning("Failed to parse %s: %s; starting fresh", path, exc) + data = {} + + strategies = data.setdefault("strategies", {}) + for name in ( + "extract_agent_case", + "extract_agent_skill", + "trigger_skill_clustering", + ): + strategies.setdefault(name, {})["enabled"] = True + + header = "# OME scheduler config — written by the Hermes EverOS plugin setup." + body = _emit_toml(data).rstrip("\n") + text = f"{header}\n\n{body}\n" if body else f"{header}\n" + _atomic_write_text(path, text, mode=_TOML_FILE_MODE) + return path + + +def build_everos_json( + api_url: str, + mode: str, + user_id: str, + agent_id: str, + agent_track_enabled: bool, + everos_root: str | None, +) -> dict[str, Any]: + """Build the dict that gets written to ``$HERMES_HOME/everos.json``.""" + return { + "api_url": api_url, + "mode": mode, + "user_id": user_id, + "agent_id": agent_id, + "agent_track_enabled": bool(agent_track_enabled), + "everos_root": everos_root, + } + + +def _write_everos_json(hermes_home: Path, payload: dict[str, Any]) -> Path: + path = Path(hermes_home) / "everos.json" + text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" + _atomic_write_text(path, text, mode=_JSON_FILE_MODE) + return path + + +def post_setup( + hermes_home: Path, + config: dict[str, Any], + *, + interactive: bool = True, + inputs: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Run the setup wizard and persist the resulting config files. + + Parameters + ---------- + hermes_home: + The Hermes home directory; ``everos.json`` is written here. + config: + Existing plugin config (merged defaults + env + previous JSON). + interactive: + When True and ``inputs`` is None, prompt the operator on stdin for + the values the wizard needs. When False (or when ``inputs`` is + supplied), no prompting happens — used by tests and non-interactive + installs. + inputs: + Pre-collected wizard answers for non-interactive runs. Recognised + keys: ``mode``, ``api_url``, ``user_id``, ``agent_id``, + ``agent_track_enabled``, ``everos_root``, ``llm``, ``embedding``, + ``rerank``. + + Returns the final ``everos.json`` dict (also written to disk). + """ + hermes_home = Path(hermes_home) + hermes_home.mkdir(parents=True, exist_ok=True) + + inputs = dict(inputs or {}) + if interactive and not inputs: + inputs = _prompt_wizard(config) + + mode = inputs.get("mode") or config.get("mode") or "platform" + if mode not in ("platform", "oss"): + raise ValueError(f"unknown EverOS mode: {mode!r}") + + user_id = inputs.get("user_id") or config.get("user_id") or _DEFAULT_USER_ID + agent_id = inputs.get("agent_id") or config.get("agent_id") or _DEFAULT_AGENT_ID + agent_track_enabled = bool( + inputs.get("agent_track_enabled", config.get("agent_track_enabled", False)) + ) + everos_root = inputs.get("everos_root") or config.get("everos_root") + + if mode == "platform": + api_url = inputs.get("api_url") or config.get("api_url") or _DEFAULT_API_URL + payload = build_everos_json( + api_url=api_url, + mode=mode, + user_id=user_id, + agent_id=agent_id, + agent_track_enabled=agent_track_enabled, + everos_root=None, + ) + _write_everos_json(hermes_home, payload) + return payload + + # mode == "oss" + if everos_root: + oss_root = Path(everos_root).expanduser() + else: + oss_root = Path.home() / ".everos" + llm = inputs.get("llm") or {} + embedding = inputs.get("embedding") or {} + rerank = inputs.get("rerank") + write_everos_toml(oss_root, llm=llm, embedding=embedding, rerank=rerank) + seed_ome_toml(oss_root, agent_track=agent_track_enabled) + + api_url = inputs.get("api_url") or config.get("api_url") or _DEFAULT_API_URL + payload = build_everos_json( + api_url=api_url, + mode=mode, + user_id=user_id, + agent_id=agent_id, + agent_track_enabled=agent_track_enabled, + everos_root=str(oss_root), + ) + _write_everos_json(hermes_home, payload) + return payload + + +def _prompt_wizard(config: dict[str, Any]) -> dict[str, Any]: + """Interactively prompt the operator for setup values on stdin.""" + inputs: dict[str, Any] = {} + + default_mode = config.get("mode") or "platform" + mode = _prompt("Mode (platform/oss)", default=default_mode).strip().lower() + if mode: + inputs["mode"] = mode + + if inputs.get("mode", default_mode) == "platform": + default_api_url = config.get("api_url") or _DEFAULT_API_URL + api_url = _prompt("EverOS API URL", default=default_api_url).strip() + if api_url: + inputs["api_url"] = api_url + else: + default_root = config.get("everos_root") or str(Path.home() / ".everos") + root = _prompt("EverOS root", default=default_root).strip() + if root: + inputs["everos_root"] = root + inputs["llm"] = _prompt_provider("LLM", config.get("llm") or {}) + inputs["embedding"] = _prompt_provider( + "Embedding", config.get("embedding") or {} + ) + rerank = _prompt_provider("Rerank", config.get("rerank") or {}, allow_skip=True) + if rerank is not None: + inputs["rerank"] = rerank + + default_user_id = config.get("user_id") or _DEFAULT_USER_ID + user_id = _prompt("User id", default=default_user_id).strip() + if user_id: + inputs["user_id"] = user_id + + default_agent_id = config.get("agent_id") or _DEFAULT_AGENT_ID + agent_id = _prompt("Agent id", default=default_agent_id).strip() + if agent_id: + inputs["agent_id"] = agent_id + + default_track = "y" if config.get("agent_track_enabled") else "n" + agent_track_raw = ( + _prompt("Enable agent track? (y/n)", default=default_track).strip().lower() + ) + inputs["agent_track_enabled"] = agent_track_raw in ("y", "yes", "true", "1") + + return inputs + + +def _prompt_provider( + label: str, + existing: dict[str, Any], + *, + allow_skip: bool = False, +) -> dict[str, Any] | None: + """Prompt for a provider section (model / api_key / base_url + knobs).""" + if allow_skip: + skip = ( + _prompt(f"Configure {label}? (y/n)", default="n" if not existing else "y") + .strip() + .lower() + ) + if skip not in ("y", "yes", "true", "1"): + return None + result: dict[str, Any] = {} + model = _prompt(f"{label} model", default=existing.get("model", "")).strip() + if model: + result["model"] = model + api_key = _prompt(f"{label} api_key", default=existing.get("api_key", "")).strip() + if api_key: + result["api_key"] = api_key + base_url = _prompt( + f"{label} base_url", default=existing.get("base_url", "") + ).strip() + if base_url: + result["base_url"] = base_url + return result or (dict(existing) if existing else None) + + +def _prompt(label: str, default: str | None = None) -> str: + """Read one line from stdin with a ``[default]`` hint.""" + suffix = f" [{default}]" if default else "" + print(f" {label}{suffix}: ", end="", flush=True) + try: + import sys + + val = sys.stdin.readline().rstrip("\n") + except Exception: + val = "" + return val or (default or "") diff --git a/integrations/hermes/_types.py b/integrations/hermes/_types.py new file mode 100644 index 000000000..3a65e82a7 --- /dev/null +++ b/integrations/hermes/_types.py @@ -0,0 +1,363 @@ +"""Type definitions for the EverOS Hermes memory-provider plugin. + +These shapes mirror the EverOS HTTP API v1 contract documented in +``EverOS/docs/api.md``. They are intentionally plain ``TypedDict`` / dataclass +shapes so they can be consumed without importing anything EverOS-specific at +runtime (the plugin runs in the Hermes process). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Literal, NotRequired, Required, TypedDict + +# ── /memory/add shape ──────────────────────────────────────────────────────── + +Role = Literal["user", "assistant", "tool"] +ContentType = Literal["text", "image", "audio", "doc", "pdf", "html", "email"] +AddStatus = Literal["accumulated", "extracted"] + + +class ContentItem(TypedDict): + """A single content block within an EverOS ``MessageItem.content`` list.""" + + type: Required[ContentType] + text: NotRequired[str | None] + uri: NotRequired[str | None] + base64: NotRequired[str | None] + ext: NotRequired[str | None] + name: NotRequired[str | None] + extras: NotRequired[dict[str, object] | None] + + +class ToolFunction(TypedDict): + """The function being invoked in a tool call.""" + + name: Required[str] + arguments: Required[str] + + +class ToolCall(TypedDict): + """An OpenAI-shaped tool invocation attached to an assistant turn.""" + + id: Required[str] + type: NotRequired[str] + function: Required[ToolFunction] + + +class MessageItem(TypedDict): + """One turn in a ``POST /api/v1/memory/add`` request. + + ``content`` may be a plain string (text shorthand) or a list of content + blocks. All other keys match the EverOS v1 contract. + """ + + sender_id: Required[str] + sender_name: NotRequired[str | None] + role: Required[Role] + timestamp: Required[int] + content: Required[str | Sequence[ContentItem]] + tool_calls: NotRequired[Sequence[ToolCall] | None] + tool_call_id: NotRequired[str | None] + + +class AddRequest(TypedDict): + """``POST /api/v1/memory/add`` request body.""" + + session_id: Required[str] + messages: Required[Sequence[MessageItem]] + app_id: NotRequired[str] + project_id: NotRequired[str] + + +class AddResponse(TypedDict): + """``POST /api/v1/memory/add`` payload inside the success envelope.""" + + message_count: Required[int] + status: Required[AddStatus] + + +# ── /memory/flush shape ────────────────────────────────────────────────────── + +FlushStatus = Literal["extracted", "no_extraction"] + + +class FlushRequest(TypedDict): + """``POST /api/v1/memory/flush`` request body.""" + + session_id: Required[str] + app_id: NotRequired[str] + project_id: NotRequired[str] + + +class FlushResponse(TypedDict): + """``POST /api/v1/memory/flush`` payload inside the success envelope.""" + + status: Required[FlushStatus] + + +# ── /memory/search shape ───────────────────────────────────────────────────── + +SearchMethod = Literal["keyword", "vector", "hybrid", "agentic"] + +# The EverOS filter DSL is a recursive boolean tree of predicates. A precise +# TypedDict is too restrictive; callers build plain dicts that match the DSL. +FilterNode = dict[str, object] + + +class SearchRequest(TypedDict): + """``POST /api/v1/memory/search`` request body. + + EverOS requires exactly one of ``user_id`` or ``agent_id``; type hints + cannot express XOR, so callers must enforce it. + """ + + query: Required[str] + user_id: NotRequired[str | None] + agent_id: NotRequired[str | None] + app_id: NotRequired[str] + project_id: NotRequired[str] + method: NotRequired[SearchMethod] + top_k: NotRequired[int] + radius: NotRequired[float | None] + min_score: NotRequired[float | None] + include_profile: NotRequired[bool] + enable_llm_rerank: NotRequired[bool] + filters: NotRequired[FilterNode | None] + + +class SearchAtomicFactItem(TypedDict): + """Atomic fact nested under a ``SearchEpisodeItem``.""" + + id: Required[str] + content: Required[str] + score: Required[float] + + +class SearchEpisodeItem(TypedDict): + """A single episode hit from ``POST /api/v1/memory/search``.""" + + id: Required[str] + user_id: Required[str | None] + app_id: Required[str] + project_id: Required[str] + session_id: Required[str] + timestamp: Required[str] + sender_ids: Required[Sequence[str]] + summary: Required[str] + subject: Required[str] + episode: Required[str] + type: Required[str] + score: Required[float] + atomic_facts: Required[Sequence[SearchAtomicFactItem]] + + +class SearchProfileItem(TypedDict): + """User profile returned when ``include_profile=True``.""" + + id: Required[str] + user_id: Required[str | None] + app_id: Required[str] + project_id: Required[str] + profile_data: Required[dict[str, object]] + score: Required[float | None] + + +class SearchAgentCaseItem(TypedDict): + """Agent-track case hit from search.""" + + id: Required[str] + agent_id: Required[str] + app_id: Required[str] + project_id: Required[str] + session_id: Required[str] + task_intent: Required[str] + approach: Required[str] + quality_score: Required[float] + key_insight: Required[str | None] + timestamp: Required[str] + score: Required[float] + + +class SearchAgentSkillItem(TypedDict): + """Agent-track skill hit from search.""" + + id: Required[str] + agent_id: Required[str] + app_id: Required[str] + project_id: Required[str] + name: Required[str] + description: Required[str] + content: Required[str] + confidence: Required[float] + maturity_score: Required[float] + source_case_ids: Required[Sequence[str]] + score: Required[float] + + +class UnprocessedMessage(TypedDict): + """Raw buffered message returned by search under narrow conditions.""" + + id: Required[str] + app_id: Required[str] + project_id: Required[str] + session_id: Required[str] + sender_id: Required[str] + sender_name: Required[str | None] + role: Required[Role] + content: Required[str | Sequence[ContentItem]] + timestamp: Required[str] + tool_calls: Required[Sequence[ToolCall] | None] + tool_call_id: Required[str | None] + + +class SearchData(TypedDict): + """The ``data`` object returned by ``POST /api/v1/memory/search``.""" + + episodes: Required[Sequence[SearchEpisodeItem]] + profiles: Required[Sequence[SearchProfileItem]] + agent_cases: Required[Sequence[SearchAgentCaseItem]] + agent_skills: Required[Sequence[SearchAgentSkillItem]] + unprocessed_messages: Required[Sequence[UnprocessedMessage]] + + +# ── /memory/get shape ──────────────────────────────────────────────────────── + +MemoryType = Literal["episode", "profile", "agent_case", "agent_skill"] +SortBy = Literal["timestamp", "updated_at"] +SortOrder = Literal["asc", "desc"] + + +class GetRequest(TypedDict): + """``POST /api/v1/memory/get`` request body. + + EverOS requires exactly one of ``user_id`` or ``agent_id``; type hints + cannot express XOR, so callers must enforce it. + """ + + memory_type: Required[MemoryType] + user_id: NotRequired[str | None] + agent_id: NotRequired[str | None] + app_id: NotRequired[str] + project_id: NotRequired[str] + page: NotRequired[int] + page_size: NotRequired[int] + sort_by: NotRequired[SortBy] + sort_order: NotRequired[SortOrder] + filters: NotRequired[FilterNode | None] + + +class GetEpisodeItem(TypedDict): + """Unranked episode listing item.""" + + id: Required[str] + user_id: Required[str | None] + app_id: Required[str] + project_id: Required[str] + session_id: Required[str] + timestamp: Required[str] + sender_ids: Required[Sequence[str]] + summary: Required[str] + subject: Required[str] + episode: Required[str] + type: Required[str] + + +class GetProfileItem(TypedDict): + """Unranked profile listing item.""" + + id: Required[str] + user_id: Required[str | None] + app_id: Required[str] + project_id: Required[str] + profile_data: Required[dict[str, object]] + + +class GetAgentCaseItem(TypedDict): + """Unranked agent-case listing item.""" + + id: Required[str] + agent_id: Required[str] + app_id: Required[str] + project_id: Required[str] + session_id: Required[str] + task_intent: Required[str] + approach: Required[str] + quality_score: Required[float] + key_insight: Required[str | None] + timestamp: Required[str] + + +class GetAgentSkillItem(TypedDict): + """Unranked agent-skill listing item.""" + + id: Required[str] + agent_id: Required[str] + app_id: Required[str] + project_id: Required[str] + name: Required[str] + description: Required[str] + content: Required[str] + confidence: Required[float] + maturity_score: Required[float] + source_case_ids: Required[Sequence[str]] + + +class GetData(TypedDict): + """The ``data`` object returned by ``POST /api/v1/memory/get``.""" + + episodes: Required[Sequence[GetEpisodeItem]] + profiles: Required[Sequence[GetProfileItem]] + agent_cases: Required[Sequence[GetAgentCaseItem]] + agent_skills: Required[Sequence[GetAgentSkillItem]] + total_count: Required[int] + count: Required[int] + + +# ── Envelopes and errors ───────────────────────────────────────────────────── + + +class ErrorDetail(TypedDict): + """Nested ``error`` object in an EverOS error response.""" + + code: Required[str] + message: Required[str] + timestamp: Required[str] + path: Required[str] + + +class ErrorEnvelope(TypedDict): + """EverOS non-2xx response envelope.""" + + request_id: Required[str] + error: Required[ErrorDetail] + + +class SuccessEnvelope(TypedDict): + """EverOS 2xx response envelope wrapping endpoint-specific data.""" + + request_id: Required[str] + data: Required[dict[str, object]] + + +# ── Internal client exceptions ─────────────────────────────────────────────── + + +class EverOSClientError(Exception): + """Raised when the EverOS server returns a non-2xx or the request fails.""" + + def __init__(self, message: str, *, code: str = "") -> None: + super().__init__(message) + self.code = code + + +# ── Config data shape ──────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class ScopeIds: + """Validated EverOS ``app_id`` / ``project_id`` pair.""" + + app_id: str + project_id: str diff --git a/integrations/hermes/cli.py b/integrations/hermes/cli.py new file mode 100644 index 000000000..28b535041 --- /dev/null +++ b/integrations/hermes/cli.py @@ -0,0 +1,386 @@ +"""Hermes-side CLI for the EverOS memory plugin (``hermes everos ``). + +Runs in the Hermes process when EverOS is the active memory provider. The +module is loaded *before* the provider is initialised (Hermes' plugin CLI +discovery imports ``cli.py`` purely to build the argparse tree), so it stays +lightweight: stdlib + the local ``._constants`` / ``._config`` helpers only. +``httpx`` and ``hermes_constants`` are lazy-imported inside command handlers +so a partially-installed environment fails clearly at call time instead of +at import time. + +Subcommands: + +- ``status`` — reachability + active mode/user/scope + breaker state. +- ``search`` — one-off search against the active config; prints JSON. +- ``flush`` — POST ``/memory/flush`` for a session. +- ``setup`` — non-interactive shortcut that writes/merges ``everos.json``. + +Config lives at ``$HERMES_HOME/everos.json`` (see ``integrations/hermes/ +README.md``). Secret ``EVEROS_API_KEY`` belongs in ``~/.hermes/.env``. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +from pathlib import Path +from typing import Any + +from ._config import _atomic_write_text +from ._constants import ( + _DEFAULT_AGENT_ID, + _DEFAULT_API_URL, + _DEFAULT_APP_ID, + _DEFAULT_PROJECT_ID, + _DEFAULT_SEARCH_METHOD, + _DEFAULT_TOOL_SEARCH_TOP_K, + _DEFAULT_USER_ID, +) + +logger = logging.getLogger(__name__) + +_CONFIG_FILE_NAME = "everos.json" +_OWNER_CHOICES = ("user", "agent") + +# Substrings that mark a config key as secret: anything matching these is +# redacted when echoing config to stdout so secrets never leak via `setup`. +_SECRET_KEY_SUFFIXES = ("_key", "_token") + + +def _redact(values: dict[str, Any]) -> dict[str, Any]: + """Return a shallow copy of ``values`` with secret fields masked. + + Any key ending in ``_key`` or ``_token`` (e.g. ``api_key``, + ``refresh_token``) is replaced with ``"***"`` so ``hermes everos setup`` + can safely echo the written values without leaking secrets to stdout. + """ + return { + k: ("***" if isinstance(k, str) and k.endswith(_SECRET_KEY_SUFFIXES) else v) + for k, v in values.items() + } + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + + +def _hermes_home() -> Path: + """Resolve ``$HERMES_HOME`` (env or ``~/.hermes``). + + Prefers ``hermes_constants.get_hermes_home()`` when available so the + plugin honours Hermes' own resolution logic. + """ + try: + from hermes_constants import get_hermes_home # type: ignore[import-not-found] + + return Path(get_hermes_home()) + except Exception: + env = os.environ.get("HERMES_HOME") + if env: + return Path(env).expanduser() + return Path.home() / ".hermes" + + +def _default_config() -> dict[str, Any]: + return { + "mode": "platform", + "api_url": _DEFAULT_API_URL, + "api_key": os.environ.get("EVEROS_API_KEY", ""), + "user_id": _DEFAULT_USER_ID, + "agent_id": _DEFAULT_AGENT_ID, + "app_id": _DEFAULT_APP_ID, + "project_id": _DEFAULT_PROJECT_ID, + } + + +def _load_config() -> dict[str, Any]: + """Load config from defaults + ``$HERMES_HOME/everos.json`` overrides.""" + cfg = _default_config() + path = _hermes_home() / _CONFIG_FILE_NAME + if path.is_file(): + try: + file_cfg = json.loads(path.read_text(encoding="utf-8")) + if isinstance(file_cfg, dict): + cfg.update({k: v for k, v in file_cfg.items() if v not in (None, "")}) + except Exception as exc: # noqa: BLE001 - best-effort config read + logger.warning("Failed to read %s: %s", path, exc) + return cfg + + +def _save_config(values: dict[str, Any]) -> Path: + """Merge ``values`` into ``$HERMES_HOME/everos.json`` (atomic-ish).""" + home = _hermes_home() + home.mkdir(parents=True, exist_ok=True) + path = home / _CONFIG_FILE_NAME + existing: dict[str, Any] = {} + if path.is_file(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + existing = loaded + except Exception: # noqa: BLE001 - best-effort + existing = {} + existing.update({k: v for k, v in values.items() if v is not None}) + text = json.dumps(existing, indent=2, sort_keys=True) + _atomic_write_text(path, text, mode=0o600) + return path + + +# --------------------------------------------------------------------------- +# EverOS HTTP client helpers +# --------------------------------------------------------------------------- + + +def _client(cfg: dict[str, Any]): + """Return a configured httpx client bound to the active ``api_url``.""" + import httpx # type: ignore[import-not-found] + + headers: dict[str, str] = {} + api_key = cfg.get("api_key") or os.environ.get("EVEROS_API_KEY", "") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return httpx.Client( + base_url=str(cfg.get("api_url", _DEFAULT_API_URL)).rstrip("/"), + headers=headers, + timeout=10.0, + ) + + +def _health_check(cfg: dict[str, Any]) -> dict[str, Any]: + """Lightweight GET ``/health`` probe.""" + try: + resp = _client(cfg).get("/health", timeout=5.0) + return { + "reachable": True, + "status_code": resp.status_code, + "body": resp.json() + if resp.headers.get("content-type", "").startswith("application/json") + else resp.text, + } + except Exception as exc: # noqa: BLE001 - network probe + return {"reachable": False, "error": str(exc)} + + +# --------------------------------------------------------------------------- +# Argparse registration +# --------------------------------------------------------------------------- + + +def register_cli(subparser: argparse.ArgumentParser) -> None: + """Build the ``hermes everos`` argparse subcommand tree. + + Called by Hermes' plugin CLI registration system during argparse setup. + The *subparser* is the parser for ``hermes everos``. + """ + subs = subparser.add_subparsers(dest="everos_action") + + subs.add_parser("status", help="Show EverOS reachability and active config.") + + search_p = subs.add_parser( + "search", help="One-off memory search; prints JSON results." + ) + search_p.add_argument("query", help="Search query.") + search_p.add_argument( + "--top-k", + type=int, + default=_DEFAULT_TOOL_SEARCH_TOP_K, + help=f"Max results (default: {_DEFAULT_TOOL_SEARCH_TOP_K}).", + ) + search_p.add_argument( + "--method", + default=_DEFAULT_SEARCH_METHOD, + choices=["keyword", "vector", "hybrid", "agentic"], + help=f"Search method (default: {_DEFAULT_SEARCH_METHOD}).", + ) + search_p.add_argument( + "--owner", + choices=list(_OWNER_CHOICES), + default=_OWNER_CHOICES[0], + help="Scope owner: 'user' (user_id) or 'agent' (agent_id). " + "Default: user. Independent of --mode.", + ) + + flush_p = subs.add_parser( + "flush", help="Flush the current session's buffered messages." + ) + flush_p.add_argument( + "--session-id", + default="", + help="Session id to flush (defaults to a CLI-derived session).", + ) + flush_p.add_argument( + "--owner", + choices=list(_OWNER_CHOICES), + default=_OWNER_CHOICES[0], + help="Scope owner: 'user' (user_id) or 'agent' (agent_id). " + "Default: user. Independent of --mode.", + ) + + setup_p = subs.add_parser( + "setup", + help="Non-interactive shortcut: write/merge everos.json.", + ) + setup_p.add_argument("--mode", choices=["platform", "oss"], default="platform") + setup_p.add_argument("--api-url", default="", help="EverOS API base URL.") + setup_p.add_argument("--api-key", default="", help="EverOS API key.") + setup_p.add_argument("--user-id", default="", help="User identifier.") + setup_p.add_argument("--agent-id", default="", help="Agent identifier.") + setup_p.add_argument("--app-id", default="", help="EverOS app_id (scope).") + setup_p.add_argument("--project-id", default="", help="EverOS project_id (scope).") + + subparser.set_defaults(func=everos_command) + + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + + +def everos_command(args: argparse.Namespace) -> int: + action = getattr(args, "everos_action", None) + if not action: + print("Usage: hermes everos {status|search|flush|setup}") + return 2 + try: + if action == "status": + _cmd_status(args) + elif action == "search": + _cmd_search(args) + elif action == "flush": + _cmd_flush(args) + elif action == "setup": + _cmd_setup(args) + else: + print(f"Unknown everos action: {action}") + return 2 + return 0 + except Exception as exc: # noqa: BLE001 - top-level CLI guard + logger.error("everos command '%s' failed: %s", action, exc) + print(f"everos {action}: {exc}") + return 1 + + +# --------------------------------------------------------------------------- +# Subcommand implementations +# --------------------------------------------------------------------------- + + +def _active_scope( + cfg: dict[str, Any], *, owner: str = _OWNER_CHOICES[0] +) -> dict[str, Any]: + """Return the user_id/agent_id/scope block for the given owner. + + ``owner`` selects ``user_id`` vs ``agent_id`` directly, independent of + ``mode`` (which is only ever ``platform`` or ``oss``). + """ + mode = cfg.get("mode", "platform") + scope: dict[str, Any] = { + "mode": mode, + "app_id": cfg.get("app_id", _DEFAULT_APP_ID), + "project_id": cfg.get("project_id", _DEFAULT_PROJECT_ID), + } + if owner == _OWNER_CHOICES[1]: + scope["agent_id"] = cfg.get("agent_id", _DEFAULT_AGENT_ID) + else: + scope["user_id"] = cfg.get("user_id", _DEFAULT_USER_ID) + return scope + + +def _breaker_state() -> dict[str, Any] | None: + """Best-effort read of the active provider's circuit-breaker state. + + The provider runs in the Hermes process; if it is loaded and exposes + breaker state, surface it. Otherwise return ``None``. + """ + try: + from agent import memory_provider # type: ignore[import-not-found] + + provider = getattr(memory_provider, "get_active_provider", lambda: None)() + if provider is None: + return None + breaker = {} + for attr in ("_consecutive_failures", "_breaker_open_until"): + val = getattr(provider, attr, None) + if val is not None: + breaker[attr.lstrip("_")] = val + return breaker or None + except Exception: # noqa: BLE001 - best-effort + return None + + +def _cmd_status(args: argparse.Namespace) -> None: + cfg = _load_config() + health = _health_check(cfg) + scope = _active_scope(cfg) + breaker = _breaker_state() + print( + json.dumps( + { + "config": { + "api_url": cfg.get("api_url"), + "mode": cfg.get("mode"), + }, + "scope": scope, + "health": health, + "circuit_breaker": breaker, + }, + indent=2, + sort_keys=True, + ) + ) + + +def _cmd_search(args: argparse.Namespace) -> None: + cfg = _load_config() + scope = _active_scope(cfg, owner=args.owner) + body: dict[str, Any] = { + "query": args.query, + "method": args.method, + "top_k": args.top_k, + "app_id": scope["app_id"], + "project_id": scope["project_id"], + } + if args.owner == _OWNER_CHOICES[1]: + body["agent_id"] = scope["agent_id"] + else: + body["user_id"] = scope["user_id"] + resp = _client(cfg).post("/api/v1/memory/search", json=body) + resp.raise_for_status() + print(json.dumps(resp.json(), indent=2, sort_keys=True)) + + +def _cmd_flush(args: argparse.Namespace) -> None: + cfg = _load_config() + scope = _active_scope(cfg, owner=args.owner) + fallback = scope.get("agent_id") or scope.get("user_id") + session_id = args.session_id or f"everos-cli-{fallback}" + body: dict[str, Any] = { + "session_id": session_id, + "app_id": scope["app_id"], + "project_id": scope["project_id"], + } + resp = _client(cfg).post("/api/v1/memory/flush", json=body) + resp.raise_for_status() + print(json.dumps(resp.json(), indent=2, sort_keys=True)) + + +def _cmd_setup(args: argparse.Namespace) -> None: + values: dict[str, Any] = {"mode": args.mode} + if args.api_url: + values["api_url"] = args.api_url + if args.api_key: + values["api_key"] = args.api_key + if args.user_id: + values["user_id"] = args.user_id + if args.agent_id: + values["agent_id"] = args.agent_id + if args.app_id: + values["app_id"] = args.app_id + if args.project_id: + values["project_id"] = args.project_id + path = _save_config(values) + print(f"Wrote {path}") + print(json.dumps(_redact(values), indent=2, sort_keys=True)) diff --git a/integrations/hermes/plugin.yaml b/integrations/hermes/plugin.yaml new file mode 100644 index 000000000..1651fd57b --- /dev/null +++ b/integrations/hermes/plugin.yaml @@ -0,0 +1,5 @@ +name: everos +version: 0.1.0 +description: "EverOS memory provider for Hermes Agent. Supports both vendor-hosted EverOS servers and local self-hosted instances with user-supplied LLM / embedding / rerank endpoints." +pip_dependencies: + - httpx diff --git a/src/everos/entrypoints/cli/commands/integrations.py b/src/everos/entrypoints/cli/commands/integrations.py new file mode 100644 index 000000000..ee1768e87 --- /dev/null +++ b/src/everos/entrypoints/cli/commands/integrations.py @@ -0,0 +1,254 @@ +"""``everos integrations`` — install EverOS bundles into third-party tools. + +Currently ships the Hermes memory-provider bundle (``integrations/hermes/``). +``install`` symlinks the bundle into ``$HERMES_HOME/plugins/everos`` so Hermes +picks it up as a user-namespace memory plugin; ``uninstall`` removes the +symlink. Real directories at the target path are never deleted automatically +— ``install`` will only ``rmtree`` one when ``--force`` (or an accepted +prompt) explicitly authorises it, and ``uninstall`` refuses outright. + +This module is ``everos.entrypoints`` code: it manipulates paths only and +does not import the bundle (``integrations/hermes/*``). +""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +import typer + +import everos +from everos.core.observability.logging import get_logger + +app = typer.Typer( + name="integrations", + help="Install EverOS integrations into third-party tools.", + no_args_is_help=True, +) + +logger = get_logger(__name__) + +_BUNDLE_REL = Path("integrations") / "hermes" +_PLUGIN_SUBDIR = Path("plugins") / "everos" +_SUPPORTED_TARGETS = frozenset({"hermes"}) +_ENV_SOURCE = "EVEROS_HERMES_PLUGIN_SOURCE" +_ENV_HERMES_HOME = "HERMES_HOME" + + +def _resolve_bundle_source(explicit: str | None) -> Path: + """Resolve the bundle source directory. + + Priority: ``EVEROS_HERMES_PLUGIN_SOURCE`` env > ``--source`` flag > walk + up from ``everos.__file__`` to a repo root containing + ``integrations/hermes/``. + """ + from_env = os.environ.get(_ENV_SOURCE) + if from_env: + return Path(from_env).expanduser().resolve() + + if explicit: + return Path(explicit).expanduser().resolve() + + here = Path(everos.__file__).resolve() + for parent in here.parents: + candidate = parent / _BUNDLE_REL + if candidate.is_dir(): + return candidate.resolve() + + raise typer.BadParameter( + f"Could not locate {_BUNDLE_REL} by walking up from {here}. " + f"Pass --source PATH or set {_ENV_SOURCE}." + ) + + +def _resolve_hermes_home() -> Path: + """Resolve the Hermes home directory (``HERMES_HOME`` env or ``~/.hermes``).""" + from_env = os.environ.get(_ENV_HERMES_HOME) + if from_env: + return Path(from_env).expanduser().resolve() + return Path("~/.hermes").expanduser().resolve() + + +def _target_path(hermes_home: Path) -> Path: + return hermes_home / _PLUGIN_SUBDIR + + +@app.command("install") +def install( + target: str = typer.Argument( + "hermes", + help="Integration target. Currently only 'hermes' is supported.", + ), + source: str | None = typer.Option( + None, + "--source", + help="Override the bundle source path (defaults to env/walk-up).", + ), + force: bool = typer.Option( + False, + "--force", + help="Overwrite an existing real directory at the target path.", + ), +) -> None: + """Symlink the EverOS bundle into a third-party tool's plugin directory.""" + if target not in _SUPPORTED_TARGETS: + raise typer.BadParameter( + f"Unsupported integration target: {target!r}. " + f"Supported: {', '.join(sorted(_SUPPORTED_TARGETS))}." + ) + + bundle_src = _resolve_bundle_source(source) + if not bundle_src.is_dir(): + raise typer.BadParameter(f"Bundle source not found: {bundle_src}") + + hermes_home = _resolve_hermes_home() + plugins_dir = hermes_home / "plugins" + plugins_dir.mkdir(parents=True, exist_ok=True) + target_path = _target_path(hermes_home) + + if target_path.is_symlink(): + target_path.unlink() + elif target_path.exists() and target_path.is_dir(): + # Real directory — refuse unless explicitly authorised. + if not force: + confirm = typer.confirm( + f"{target_path} is a real directory. Replace it with a symlink " + "to the EverOS bundle? (Its contents will be deleted.)", + default=False, + ) + if not confirm: + logger.info( + "everos.integration.skipped", + target=str(target_path), + reason="real_dir_no_force", + ) + typer.echo("Aborted; target left untouched.") + raise typer.Exit(code=1) + shutil.rmtree(target_path) + elif target_path.exists(): + # A file (not a dir, not a symlink) — refuse; too surprising to clobber. + typer.echo( + f"Refusing to replace {target_path}: not a directory or symlink. " + "Remove it manually and re-run." + ) + raise typer.Exit(code=1) + + target_path.symlink_to(bundle_src, target_is_directory=True) + + logger.info( + "everos.integration.installed", + target=str(target_path), + source=str(bundle_src), + integration=target, + ) + typer.secho(f"linked: {target_path} -> {bundle_src}", fg=typer.colors.GREEN) + typer.echo( + "\nNext steps:\n" + " 1. Ensure an EverOS server is running (everos server start).\n" + " 2. Run `hermes memory setup` and select 'everos' to activate it.\n" + " 3. Verify with `hermes everos status`." + ) + + +@app.command("uninstall") +def uninstall( + target: str = typer.Argument( + "hermes", + help="Integration target. Currently only 'hermes' is supported.", + ), + source: str | None = typer.Option( + None, + "--source", + help="Override the bundle source path for the ownership check " + "(defaults to env/walk-up). Use when the bundle was installed via " + "--source and that path is still reachable.", + ), + force: bool = typer.Option( + False, + "--force", + help="Skip the ownership check and unlink the symlink directly. " + "Prompts for confirmation unless --yes is also given.", + ), + yes: bool = typer.Option( + False, + "--yes", + "-y", + help="Skip the confirmation prompt implied by --force.", + ), +) -> None: + """Remove the EverOS bundle symlink from a third-party tool's plugin dir.""" + if target not in _SUPPORTED_TARGETS: + raise typer.BadParameter( + f"Unsupported integration target: {target!r}. " + f"Supported: {', '.join(sorted(_SUPPORTED_TARGETS))}." + ) + + hermes_home = _resolve_hermes_home() + target_path = _target_path(hermes_home) + + if not target_path.exists() and not target_path.is_symlink(): + typer.echo(f"Nothing to remove: {target_path} does not exist.") + return + + if target_path.is_dir() and not target_path.is_symlink(): + # Real directory — refuse to delete. + typer.echo( + f"Refusing to remove {target_path}: it is a real directory, not a " + "symlink. Remove it manually if you really intend to." + ) + raise typer.Exit(code=1) + + if not target_path.is_symlink(): + typer.echo(f"Refusing to remove {target_path}: not a symlink.") + raise typer.Exit(code=1) + + resolved = target_path.resolve() + + if force: + if not yes and not typer.confirm( + f"Remove {target_path} without verifying which bundle it points at?", + default=False, + ): + logger.info( + "everos.integration.uninstall.skipped", + target=str(target_path), + reason="force_not_confirmed", + ) + typer.echo("Aborted; symlink left in place.") + raise typer.Exit(code=1) + bundle_src: Path | None = None + else: + try: + bundle_src = _resolve_bundle_source(source) + except typer.BadParameter: + typer.echo( + f"Could not re-resolve the EverOS bundle source to verify " + f"{target_path} (it points at {resolved}). Pass --source PATH " + "to point at the bundle directory, or --force to unlink " + "without checking." + ) + raise typer.Exit(code=1) from None + if resolved != bundle_src: + typer.echo( + f"Refusing to remove {target_path}: it points at {resolved}, " + f"not the EverOS bundle ({bundle_src}). Pass --force to " + "unlink anyway." + ) + raise typer.Exit(code=1) + + target_path.unlink() + logger.info( + "everos.integration.uninstalled", + target=str(target_path), + integration=target, + forced=force, + source=str(bundle_src) if bundle_src is not None else None, + ) + typer.secho(f"removed: {target_path}", fg=typer.colors.GREEN) + typer.echo( + "\nThe `memory.provider` setting in Hermes config was left untouched. " + "If you want to clear it, run:\n" + " hermes config set memory.provider ''" + ) diff --git a/src/everos/entrypoints/cli/main.py b/src/everos/entrypoints/cli/main.py index b7338bda1..40b3ae0d9 100644 --- a/src/everos/entrypoints/cli/main.py +++ b/src/everos/entrypoints/cli/main.py @@ -13,7 +13,7 @@ import typer -from .commands import cascade, config_cmd, demo, init_cmd, server +from .commands import cascade, config_cmd, demo, init_cmd, integrations, server app = typer.Typer( name="everos", @@ -25,6 +25,7 @@ app.add_typer(server.app, name="server") app.add_typer(cascade.app, name="cascade") app.add_typer(config_cmd.app, name="config") +app.add_typer(integrations.app, name="integrations") # ``init`` is a top-level leaf command (not a Typer group) — match the # idiomatic ``alembic init`` / ``django-admin startproject`` shape. diff --git a/tests/helpers/hermes_stub.py b/tests/helpers/hermes_stub.py new file mode 100644 index 000000000..c9abe88ae --- /dev/null +++ b/tests/helpers/hermes_stub.py @@ -0,0 +1,162 @@ +"""Minimal Hermes-runtime stubs for testing the EverOS plugin in isolation. + +The EverOS test suite cannot import the real ``agent.memory_provider`` because +Hermes is not a dependency of EverOS. Tests inject this module via +``sys.modules`` so plugin imports resolve to these stubs. +""" + +from __future__ import annotations + +import json +import os +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any + +# ── hermes_constants stub ──────────────────────────────────────────────────── + + +def get_hermes_home() -> Path: + """Return the active Hermes home directory.""" + return Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser() + + +# ── tools.registry stub ──────────────────────────────────────────────────────── + + +def tool_error(message: str, **extra: object) -> str: + """Return a JSON error string matching Hermes's tool_error helper.""" + return json.dumps({"error": str(message), **extra}, ensure_ascii=False) + + +def tool_result(data: object = None, **kwargs: object) -> str: + """Return a JSON success string matching Hermes's tool_result helper.""" + if data is not None: + return json.dumps(data, ensure_ascii=False) + return json.dumps(kwargs, ensure_ascii=False) + + +# ── utils stub ───────────────────────────────────────────────────────────────── + + +def atomic_json_write( + path: Path, + data: object, + *, + indent: int = 2, + mode: int | None = None, + **dump_kwargs: object, +) -> None: + """Atomic JSON write stripped-down for tests.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text( + json.dumps(data, indent=indent, ensure_ascii=False, **dump_kwargs) + ) + if mode is not None: + tmp_path.chmod(mode) + tmp_path.replace(path) + + +# ── agent.memory_provider stub ─────────────────────────────────────────────── + + +class MemoryProvider(ABC): + """Minimal ABC matching the methods the EverOS plugin calls/implements.""" + + @property + @abstractmethod + def name(self) -> str: + """Short identifier for this provider.""" + + # Core lifecycle + @abstractmethod + def is_available(self) -> bool: + """Return True if the provider is configured.""" + + @abstractmethod + def initialize(self, session_id: str, **kwargs: object) -> None: + """Initialize for a session.""" + + # Optional recall / persistence hooks + def system_prompt_block(self) -> str: + return "" + + def prefetch(self, query: str, *, session_id: str = "") -> str: + return "" + + def queue_prefetch(self, query: str, *, session_id: str = "") -> None: + return None + + def sync_turn( + self, + user_content: str, + assistant_content: str, + *, + session_id: str = "", + messages: list[dict[str, Any]] | None = None, + ) -> None: + return None + + def on_memory_write( + self, + action: str, + target: str, + content: str, + metadata: dict[str, Any] | None = None, + ) -> None: + return None + + def on_session_end(self, messages: list[dict[str, Any]]) -> None: + return None + + def on_turn_start(self, turn_number: int, message: str, **kwargs: object) -> None: + return None + + def on_session_switch( + self, + new_session_id: str, + *, + parent_session_id: str = "", + reset: bool = False, + rewound: bool = False, + **kwargs: object, + ) -> None: + return None + + def on_pre_compress(self, messages: list[dict[str, Any]]) -> str: + return "" + + def on_delegation( + self, + task: str, + result: str, + *, + child_session_id: str = "", + **kwargs: object, + ) -> None: + return None + + # Tools + @abstractmethod + def get_tool_schemas(self) -> list[dict[str, Any]]: + """Return tool schemas exposed by the provider.""" + + def handle_tool_call( + self, tool_name: str, args: dict[str, Any], **kwargs: object + ) -> str: + return tool_error(f"Provider does not handle tool {tool_name}") + + # Config + def get_config_schema(self) -> list[dict[str, Any]]: + return [] + + def save_config(self, values: dict[str, Any], hermes_home: str) -> None: + return None + + def backup_paths(self) -> list[str]: + return [] + + def shutdown(self) -> None: + return None diff --git a/tests/integration/test_hermes_plugin_install.py b/tests/integration/test_hermes_plugin_install.py new file mode 100644 index 000000000..b664a0421 --- /dev/null +++ b/tests/integration/test_hermes_plugin_install.py @@ -0,0 +1,139 @@ +"""Integration smoke: ``everos integrations install`` + plugin load. + +Runs the real ``install`` command (Typer CliRunner) against the actual +``integrations/hermes/`` bundle and a throwaway ``HERMES_HOME``, then +imports the symlinked bundle with Hermes stubs injected and asserts the +``EverosMemoryProvider`` is loadable as a ``MemoryProvider`` subclass. + +Deviation from the written spec: the bundle's ``__init__.py`` does not +define a ``register(ctx)`` function — Hermes' plugin loader +(``plugins/memory/__init__.py:_load_provider_from_dir``) falls back to +instantiate a ``MemoryProvider`` subclass found on the module. This test +pins that fallback path (the actual contract) rather than the absent +``register`` entry point. +""" + +from __future__ import annotations + +import importlib +import json +import sys +import types +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from everos.entrypoints.cli.commands import integrations as integrations_mod + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_REAL_BUNDLE = _REPO_ROOT / "integrations" / "hermes" + + +@pytest.fixture +def hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + home = tmp_path / "hermes-home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("EVEROS_HERMES_PLUGIN_SOURCE", raising=False) + return home + + +def _install_symlink(hermes_home: Path) -> Path: + """Run the real install command against the real bundle; return target.""" + result = CliRunner().invoke( + integrations_mod.app, + ["install", "hermes", "--source", str(_REAL_BUNDLE)], + ) + assert result.exit_code == 0, result.output + target = hermes_home / "plugins" / "everos" + assert target.is_symlink(), f"expected symlink at {target}" + assert target.resolve() == _REAL_BUNDLE.resolve() + return target + + +def _inject_hermes_stubs() -> None: + """Install the Hermes-runtime stubs into ``sys.modules``. + + Idempotent: existing entries are left in place so re-injection does not + clobber a previously-imported plugin module. + """ + import tests.helpers.hermes_stub as stub + + mods = { + "agent": types.ModuleType("agent"), + "agent.memory_provider": stub, + "hermes_constants": stub, + "tools": types.ModuleType("tools"), + "tools.registry": stub, + "utils": stub, + } + mods["agent"].memory_provider = stub # type: ignore[attr-defined] + mods["tools"].registry = stub # type: ignore[attr-defined] + for name, mod in mods.items(): + sys.modules.setdefault(name, mod) + if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + + +def test_install_creates_symlink_pointing_at_bundle(hermes_home: Path): + target = _install_symlink(hermes_home) + assert target.is_symlink() + assert target.resolve() == _REAL_BUNDLE.resolve() + # The bundle's plugin manifest is reachable through the symlink. + assert (target / "plugin.yaml").is_file() + assert (target / "__init__.py").is_file() + + +def test_installed_plugin_loads_as_memory_provider(hermes_home: Path): + _install_symlink(hermes_home) + _inject_hermes_stubs() + + # Import the bundle as a package. The symlink makes the real bundle + # reachable through HERMES_HOME too, but importing by the canonical + # name exercises the same __init__.py the Hermes loader would exec. + plugin = importlib.import_module("integrations.hermes") + + # The plugin does not expose register(ctx); Hermes' loader falls back to + # finding a MemoryProvider subclass. Pin that contract. + assert not hasattr(plugin, "register"), ( + "bundle now defines register(); update this test to the new path" + ) + from tests.helpers.hermes_stub import MemoryProvider + + assert hasattr(plugin, "EverosMemoryProvider") + provider_cls = plugin.EverosMemoryProvider + assert isinstance(provider_cls, type) + assert issubclass(provider_cls, MemoryProvider) + # The provider is instantiable (the loader's fallback does this). + provider = provider_cls() + assert provider.name == "everos" + + +def test_installed_plugin_handles_tool_call_end_to_end(hermes_home: Path): + _install_symlink(hermes_home) + _inject_hermes_stubs() + + plugin = importlib.import_module("integrations.hermes") + from tests.helpers.hermes_stub import MemoryProvider + + provider = plugin.EverosMemoryProvider() + assert isinstance(provider, MemoryProvider) + # The four tool schemas are exposed regardless of backend availability. + schemas = {s["name"] for s in provider.get_tool_schemas()} + assert schemas == {"everos_search", "everos_list", "everos_add", "everos_flush"} + # Without a configured client, tool calls surface a clean error. + provider._client = None + provider._init_error = "not configured" + out = provider.handle_tool_call("everos_search", {"query": "x"}) + assert "error" in json.loads(out) + + +def test_uninstall_after_install_round_trip(hermes_home: Path): + target = _install_symlink(hermes_home) + result = CliRunner().invoke( + integrations_mod.app, + ["uninstall", "hermes", "--source", str(_REAL_BUNDLE)], + ) + assert result.exit_code == 0, result.output + assert not target.exists() diff --git a/tests/unit/test_entrypoints/test_cli/test_integrations_command.py b/tests/unit/test_entrypoints/test_cli/test_integrations_command.py new file mode 100644 index 000000000..188edd935 --- /dev/null +++ b/tests/unit/test_entrypoints/test_cli/test_integrations_command.py @@ -0,0 +1,265 @@ +"""``everos integrations`` — installer CLI contract tests. + +Pins the ``install`` / ``uninstall`` Typer commands against a fake bundle +directory and a throwaway ``HERMES_HOME``. No real bundle walk-up is +exercised — ``--source`` (or ``EVEROS_HERMES_PLUGIN_SOURCE``) short-circuits +the walk-up. Uses ``typer.testing.CliRunner`` only; no real HTTP, no real +plugins loaded. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from everos.entrypoints.cli.commands import integrations as integrations_mod + + +@pytest.fixture +def runner() -> CliRunner: + # ``mix_stderr=False`` keeps stdout/stderr split for message assertions. + return CliRunner() + + +@pytest.fixture +def hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + home = tmp_path / "hermes-home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +@pytest.fixture +def bundle(tmp_path: Path) -> Path: + src = tmp_path / "bundle" + src.mkdir() + (src / "__init__.py").write_text("") + (src / "plugin.yaml").write_text("name: everos\n") + return src + + +@pytest.fixture(autouse=True) +def _clear_source_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("EVEROS_HERMES_PLUGIN_SOURCE", raising=False) + + +# ── --help smoke ──────────────────────────────────────────────────────────── + + +def test_install_help_exits_zero(runner: CliRunner): + result = runner.invoke(integrations_mod.app, ["install", "--help"]) + assert result.exit_code == 0 + assert "hermes" in result.stdout + + +def test_uninstall_help_exits_zero(runner: CliRunner): + result = runner.invoke(integrations_mod.app, ["uninstall", "--help"]) + assert result.exit_code == 0 + assert "hermes" in result.stdout + + +# ── install ───────────────────────────────────────────────────────────────── + + +def _target(hermes_home: Path) -> Path: + return hermes_home / "plugins" / "everos" + + +def test_install_symlinks_bundle(runner: CliRunner, hermes_home: Path, bundle: Path): + result = runner.invoke( + integrations_mod.app, ["install", "hermes", "--source", str(bundle)] + ) + assert result.exit_code == 0, result.output + target = _target(hermes_home) + assert target.is_symlink() + assert target.resolve() == bundle.resolve() + + +def test_install_is_idempotent(runner: CliRunner, hermes_home: Path, bundle: Path): + args = ["install", "hermes", "--source", str(bundle)] + first = runner.invoke(integrations_mod.app, args) + second = runner.invoke(integrations_mod.app, args) + assert first.exit_code == 0 + assert second.exit_code == 0 + target = _target(hermes_home) + assert target.is_symlink() + assert target.resolve() == bundle.resolve() + + +def test_install_refuses_real_dir_without_force( + runner: CliRunner, hermes_home: Path, bundle: Path +): + target = _target(hermes_home) + target.parent.mkdir(parents=True, exist_ok=True) + target.mkdir() + (target / "precious.txt").write_text("keep me") + + result = runner.invoke( + integrations_mod.app, + ["install", "hermes", "--source", str(bundle)], + input="n\n", + ) + assert result.exit_code == 1 + # Real dir is left untouched. + assert not target.is_symlink() + assert (target / "precious.txt").exists() + + +def test_install_force_replaces_real_dir( + runner: CliRunner, hermes_home: Path, bundle: Path +): + target = _target(hermes_home) + target.parent.mkdir(parents=True, exist_ok=True) + target.mkdir() + (target / "old.txt").write_text("bye") + + result = runner.invoke( + integrations_mod.app, + ["install", "hermes", "--source", str(bundle), "--force"], + ) + assert result.exit_code == 0, result.output + assert target.is_symlink() + assert target.resolve() == bundle.resolve() + assert not (target / "old.txt").exists() + + +def test_install_missing_source_exits_nonzero( + runner: CliRunner, hermes_home: Path, tmp_path: Path +): + missing = tmp_path / "does-not-exist" + result = runner.invoke( + integrations_mod.app, ["install", "hermes", "--source", str(missing)] + ) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + +def test_install_walk_up_failure_exits_nonzero( + runner: CliRunner, + hermes_home: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + # Simulate a non-editable install: everos.__file__ lives somewhere with no + # integrations/hermes ancestor, and no --source / env is supplied. + import everos + + fake_file = tmp_path / "deep" / "everos" / "__init__.py" + fake_file.parent.mkdir(parents=True) + fake_file.write_text("") + monkeypatch.setattr(everos, "__file__", str(fake_file)) + + result = runner.invoke(integrations_mod.app, ["install", "hermes"]) + assert result.exit_code != 0 + assert "could not locate" in result.output.lower() + + +def test_install_uses_env_source( + runner: CliRunner, hermes_home: Path, bundle: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("EVEROS_HERMES_PLUGIN_SOURCE", str(bundle)) + result = runner.invoke(integrations_mod.app, ["install", "hermes"]) + assert result.exit_code == 0, result.output + target = _target(hermes_home) + assert target.is_symlink() + assert target.resolve() == bundle.resolve() + + +# ── uninstall ─────────────────────────────────────────────────────────────── + + +def test_uninstall_removes_symlink(runner: CliRunner, hermes_home: Path, bundle: Path): + runner.invoke(integrations_mod.app, ["install", "hermes", "--source", str(bundle)]) + target = _target(hermes_home) + assert target.is_symlink() + + result = runner.invoke( + integrations_mod.app, ["uninstall", "hermes", "--source", str(bundle)] + ) + assert result.exit_code == 0, result.output + assert not target.exists() + + +def test_uninstall_refuses_real_dir(runner: CliRunner, hermes_home: Path, bundle: Path): + target = _target(hermes_home) + target.parent.mkdir(parents=True, exist_ok=True) + target.mkdir() + + result = runner.invoke( + integrations_mod.app, ["uninstall", "hermes", "--source", str(bundle)] + ) + assert result.exit_code == 1 + assert target.is_dir() + assert not target.is_symlink() + + +def test_uninstall_force_unlinks_without_ownership_check( + runner: CliRunner, hermes_home: Path, tmp_path: Path +): + # Symlink pointing somewhere other than the bundle. + other = tmp_path / "other" + other.mkdir() + target = _target(hermes_home) + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to(other, target_is_directory=True) + + result = runner.invoke( + integrations_mod.app, + ["uninstall", "hermes", "--force", "--yes"], + ) + assert result.exit_code == 0, result.output + assert not target.exists() + + +def test_uninstall_refuses_mismatched_target( + runner: CliRunner, hermes_home: Path, bundle: Path, tmp_path: Path +): + other = tmp_path / "other" + other.mkdir() + target = _target(hermes_home) + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to(other, target_is_directory=True) + + result = runner.invoke( + integrations_mod.app, ["uninstall", "hermes", "--source", str(bundle)] + ) + assert result.exit_code == 1 + assert target.is_symlink() + + +def test_uninstall_nothing_to_remove( + runner: CliRunner, hermes_home: Path, bundle: Path +): + result = runner.invoke( + integrations_mod.app, ["uninstall", "hermes", "--source", str(bundle)] + ) + assert result.exit_code == 0 + assert "nothing to remove" in result.output.lower() + + +def test_uninstall_walk_up_failure_message( + runner: CliRunner, + hermes_home: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + # Symlink that dangles-resolves to a path the bundle check cannot match, + # and walk-up cannot find a bundle either. + import everos + + fake_file = tmp_path / "deep" / "everos" / "__init__.py" + fake_file.parent.mkdir(parents=True) + fake_file.write_text("") + monkeypatch.setattr(everos, "__file__", str(fake_file)) + + other = tmp_path / "other" + other.mkdir() + target = _target(hermes_home) + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to(other, target_is_directory=True) + + result = runner.invoke(integrations_mod.app, ["uninstall", "hermes"]) + assert result.exit_code == 1 + assert "could not re-resolve" in result.output.lower() diff --git a/tests/unit/test_integrations/__init__.py b/tests/unit/test_integrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_integrations/test_hermes/__init__.py b/tests/unit/test_integrations/test_hermes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_integrations/test_hermes/conftest.py b/tests/unit/test_integrations/test_hermes/conftest.py new file mode 100644 index 000000000..4704b193b --- /dev/null +++ b/tests/unit/test_integrations/test_hermes/conftest.py @@ -0,0 +1,51 @@ +"""Import shim for the Hermes-agnostic plugin submodules. + +The plugin's ``integrations/hermes/__init__.py`` wires the Hermes +``MemoryProvider`` ABC and imports Hermes-only symbols (``agent``, +``hermes_constants``, ``tools``, ``utils``) that are not installable from +this repo. The logic modules under test (``_client`` / ``_config`` / +``_formatting`` / ``_setup`` / ``_types`` / ``_constants``) are +Hermes-agnostic; once their parent package is registered they import +cleanly with no stubbing of Hermes symbols. + +We register a synthetic ``hermes`` package in ``sys.modules`` (with +``__path__`` pointing at the real plugin directory) so importing +``hermes._client`` etc. initialises only that submodule — the Hermes +shell in ``__init__.py`` is never executed. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from types import ModuleType + +_PLUGIN_DIR = Path(__file__).resolve().parents[4] / "integrations" / "hermes" + +_SUBMODULES = ( + "_constants", + "_types", + "_client", + "_config", + "_formatting", + "_setup", +) + + +def _install_synthetic_hermes_package() -> None: + if "hermes" in sys.modules: + return + pkg = ModuleType("hermes") + pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined] + pkg.__package__ = "hermes" + sys.modules["hermes"] = pkg + + +def _ensure_loaded() -> None: + _install_synthetic_hermes_package() + for name in _SUBMODULES: + importlib.import_module(f"hermes.{name}") + + +_ensure_loaded() diff --git a/tests/unit/test_integrations/test_hermes/test_cli.py b/tests/unit/test_integrations/test_hermes/test_cli.py new file mode 100644 index 000000000..0a2b41ed4 --- /dev/null +++ b/tests/unit/test_integrations/test_hermes/test_cli.py @@ -0,0 +1,355 @@ +"""Contract tests for ``integrations/hermes/cli.py`` (the ``hermes everos`` CLI). + +Pins the Hermes-side CLI surface discovered by Hermes' plugin loader: + +- ``register_cli`` builds the four subcommands (status/search/flush/setup) + with the expected argument routing. +- ``_active_scope`` selects ``user_id`` vs ``agent_id`` from ``owner``. +- ``_load_config`` / ``_save_config`` read/write ``$HERMES_HOME/everos.json`` + (0o600 atomic on write). +- ``_redact`` masks any ``*_key`` / ``*_token`` field so ``hermes everos + setup`` never echoes a secret to stdout. +- ``_cmd_search`` / ``_cmd_flush`` happy paths route the right body to the + EverOS HTTP client (faked via ``httpx.MockTransport``). +- ``_breaker_state`` returns ``None`` when no provider is loaded. + +Hermes-only symbols (``agent``, ``hermes_constants``, ``tools``, ``utils``) +are injected via ``sys.modules`` (see ``tests.helpers.hermes_stub``) so the +plugin bundle imports cleanly without a Hermes runtime. No real network. +""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import stat +import sys +import types +from pathlib import Path +from typing import Any + +import httpx +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[4] + + +@pytest.fixture(scope="module", autouse=True) +def _hermes_stubs(): + """Inject Hermes stubs into ``sys.modules`` and import the plugin CLI. + + Mirrors ``test_provider.py``: points ``agent``/``hermes_constants``/ + ``tools``/``utils`` at ``tests.helpers.hermes_stub`` and adds the repo + root to ``sys.path`` so ``integrations.hermes.cli`` imports as a real + package. + """ + import tests.helpers.hermes_stub as stub + + mods = { + "agent": types.ModuleType("agent"), + "agent.memory_provider": stub, + "hermes_constants": stub, + "tools": types.ModuleType("tools"), + "tools.registry": stub, + "utils": stub, + } + mods["agent"].memory_provider = stub # type: ignore[attr-defined] + mods["tools"].registry = stub # type: ignore[attr-defined] + for name, mod in mods.items(): + sys.modules.setdefault(name, mod) + sys.path.insert(0, str(_REPO_ROOT)) + try: + import importlib + + cli = importlib.import_module("integrations.hermes.cli") + yield cli + finally: + with contextlib.suppress(ValueError): + sys.path.remove(str(_REPO_ROOT)) + + +@pytest.fixture +def cli(_hermes_stubs): + return _hermes_stubs + + +@pytest.fixture +def hermes_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A throwaway ``$HERMES_HOME`` so config reads/writes are hermetic.""" + home = tmp_path / "hermes-home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("EVEROS_API_KEY", raising=False) + return home + + +# ── argparse registration ─────────────────────────────────────────────────── + + +def _build_parser(cli) -> argparse.ArgumentParser: + """Build a top-level parser with the ``everos`` subcommand wired up.""" + parser = argparse.ArgumentParser(prog="hermes") + subs = parser.add_subparsers(dest="cmd") + everos_p = subs.add_parser("everos") + cli.register_cli(everos_p) + return parser + + +def test_register_cli_builds_four_subcommands(cli): + parser = _build_parser(cli) + # Each subcommand parses cleanly and sets the everos_action dest. ``search`` + # takes a positional query; the others take only optional flags. + argv_for = { + "status": ["everos", "status"], + "search": ["everos", "search", "q"], + "flush": ["everos", "flush"], + "setup": ["everos", "setup"], + } + for sub, argv in argv_for.items(): + assert parser.parse_args(argv).everos_action == sub + # An unknown subcommand is rejected by argparse. + with pytest.raises(SystemExit): + parser.parse_args(["everos", "bogus"]) + + +def test_register_cli_search_defaults_and_choices(cli): + parser = _build_parser(cli) + args = parser.parse_args(["everos", "search", "hello"]) + assert args.everos_action == "search" + assert args.query == "hello" + assert args.owner == "user" + assert args.method == "hybrid" + assert args.top_k == 5 + assert args.func is cli.everos_command + + +def test_register_cli_setup_accepts_all_flags(cli): + parser = _build_parser(cli) + args = parser.parse_args( + [ + "everos", + "setup", + "--mode", + "oss", + "--api-url", + "http://x", + "--api-key", + "sk-secret", + "--user-id", + "alice", + "--agent-id", + "bot", + "--app-id", + "app", + "--project-id", + "proj", + ] + ) + assert args.mode == "oss" + assert args.api_url == "http://x" + assert args.api_key == "sk-secret" + assert args.user_id == "alice" + + +def test_register_cli_flush_defaults(cli): + parser = _build_parser(cli) + args = parser.parse_args(["everos", "flush"]) + assert args.everos_action == "flush" + assert args.session_id == "" + assert args.owner == "user" + + +# ── _active_scope ─────────────────────────────────────────────────────────── + + +def test_active_scope_user_selects_user_id(cli): + cfg = { + "mode": "platform", + "user_id": "alice", + "agent_id": "bot", + "app_id": "app", + "project_id": "proj", + } + scope = cli._active_scope(cfg, owner="user") + assert scope["user_id"] == "alice" + assert "agent_id" not in scope + assert scope["app_id"] == "app" + assert scope["project_id"] == "proj" + assert scope["mode"] == "platform" + + +def test_active_scope_agent_selects_agent_id(cli): + cfg = { + "mode": "oss", + "user_id": "alice", + "agent_id": "bot", + "app_id": "app", + "project_id": "proj", + } + scope = cli._active_scope(cfg, owner="agent") + assert scope["agent_id"] == "bot" + assert "user_id" not in scope + assert scope["mode"] == "oss" + + +def test_active_scope_defaults_when_keys_missing(cli): + scope = cli._active_scope({}, owner="user") + assert scope["user_id"] == "hermes-user" + assert scope["app_id"] == "default" + assert scope["project_id"] == "default" + + +# ── _load_config / _save_config ───────────────────────────────────────────── + + +def test_load_config_defaults_when_no_file(cli, hermes_home: Path): + cfg = cli._load_config() + assert cfg["mode"] == "platform" + assert cfg["api_url"] == "http://127.0.0.1:8000" + assert cfg["user_id"] == "hermes-user" + assert cfg["api_key"] == "" + assert not (hermes_home / "everos.json").exists() + + +def test_load_config_reads_hermes_home_json(cli, hermes_home: Path): + (hermes_home / "everos.json").write_text( + json.dumps({"mode": "oss", "user_id": "alice", "api_key": "sk-x"}) + ) + cfg = cli._load_config() + assert cfg["mode"] == "oss" + assert cfg["user_id"] == "alice" + assert cfg["api_key"] == "sk-x" + # Defaults still present for unspecified keys. + assert cfg["app_id"] == "default" + + +def test_save_config_writes_mode_0600(cli, hermes_home: Path): + path = cli._save_config({"api_url": "http://new", "mode": "oss"}) + assert path == hermes_home / "everos.json" + data = json.loads(path.read_text(encoding="utf-8")) + assert data["api_url"] == "http://new" + assert data["mode"] == "oss" + mode = stat.S_IMODE(path.stat().st_mode) + assert mode == 0o600 + + +def test_save_config_merges_existing(cli, hermes_home: Path): + cli._save_config({"api_url": "http://a", "user_id": "alice"}) + cli._save_config({"mode": "oss"}) + data = json.loads((hermes_home / "everos.json").read_text(encoding="utf-8")) + assert data["api_url"] == "http://a" + assert data["user_id"] == "alice" + assert data["mode"] == "oss" + + +# ── _redact ───────────────────────────────────────────────────────────────── + + +def test_redact_masks_api_key(cli): + out = cli._redact({"api_key": "sk-secret", "mode": "platform"}) + assert out["api_key"] == "***" + assert out["mode"] == "platform" + + +def test_redact_masks_key_and_token_suffixes(cli): + out = cli._redact( + {"refresh_token": "rt", "service_key": "sk", "user_id": "alice", "mode": "oss"} + ) + assert out["refresh_token"] == "***" + assert out["service_key"] == "***" + assert out["user_id"] == "alice" + assert out["mode"] == "oss" + + +def test_redact_does_not_mutate_input(cli): + values = {"api_key": "sk-secret", "mode": "platform"} + cli._redact(values) + assert values["api_key"] == "sk-secret" + + +# ── _cmd_search / _cmd_flush happy paths ──────────────────────────────────── + + +def _mock_client_factory(handler): + """Return a callable that builds an httpx.Client backed by MockTransport.""" + return lambda cfg: httpx.Client( + base_url="http://test", transport=httpx.MockTransport(handler) + ) + + +def test_cmd_search_routes_body_and_prints_json( + cli, + hermes_home: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"data": {"episodes": []}}) + + monkeypatch.setattr(cli, "_client", _mock_client_factory(handler)) + + parser = _build_parser(cli) + args = parser.parse_args(["everos", "search", "tea", "--owner", "agent"]) + assert cli.everos_command(args) == 0 + out = capsys.readouterr().out + payload = json.loads(out) + assert payload["data"]["episodes"] == [] + assert captured["url"].endswith("/api/v1/memory/search") + assert captured["body"]["query"] == "tea" + assert captured["body"]["agent_id"] == "hermes" + assert "user_id" not in captured["body"] + + +def test_cmd_flush_routes_session_and_scope( + cli, + hermes_home: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +): + captured: dict[str, Any] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"data": {"status": "extracted"}}) + + monkeypatch.setattr(cli, "_client", _mock_client_factory(handler)) + + parser = _build_parser(cli) + args = parser.parse_args( + ["everos", "flush", "--session-id", "sess-42", "--owner", "user"] + ) + assert cli.everos_command(args) == 0 + out = capsys.readouterr().out + payload = json.loads(out) + assert payload["data"]["status"] == "extracted" + assert captured["body"]["session_id"] == "sess-42" + + +def test_cmd_setup_redacts_api_key_in_stdout( + cli, hermes_home: Path, capsys: pytest.CaptureFixture[str] +): + parser = _build_parser(cli) + args = parser.parse_args( + ["everos", "setup", "--api-key", "sk-topsecret", "--mode", "oss"] + ) + assert cli.everos_command(args) == 0 + out = capsys.readouterr().out + assert "sk-topsecret" not in out + assert "***" in out + # The written file retains the real key (redaction is echo-only). + data = json.loads((hermes_home / "everos.json").read_text(encoding="utf-8")) + assert data["api_key"] == "sk-topsecret" + + +# ── _breaker_state ────────────────────────────────────────────────────────── + + +def test_breaker_state_returns_none_when_no_provider(cli): + # The hermes_stub does not expose an active provider, so the best-effort + # read must degrade to None rather than raising. + assert cli._breaker_state() is None diff --git a/tests/unit/test_integrations/test_hermes/test_client.py b/tests/unit/test_integrations/test_hermes/test_client.py new file mode 100644 index 000000000..78300eaeb --- /dev/null +++ b/tests/unit/test_integrations/test_hermes/test_client.py @@ -0,0 +1,353 @@ +"""Contract tests for ``integrations/hermes/_client.py``. + +Pins the synchronous ``EverOSApiClient`` surface: + +- ``add_messages`` payload shape (roles / sender_ids / ms timestamps / + content), default-scope omission of ``app_id``/``project_id``, chunking + into ``_ADD_BATCH_SIZE`` batches with merged ``message_count``. +- ``flush_session`` payload. +- ``search`` / ``get`` body construction, ``include_profile`` / ``top_k`` / + ``method`` / pagination forwarding, and the ``user_id``/``agent_id`` XOR. +- Error normalisation: server ``EXTERNAL_SERVICE_UNAVAILABLE`` / + ``INVALID_INPUT`` codes, ``httpx.ConnectError`` → transient, non-JSON 4xx + → ``INTERNAL_ERROR``, and ``CLIENT_CLOSED`` after ``close()``. + +Network is faked via ``httpx.MockTransport`` injected through a patched +``_make_client`` (no ``respx`` / ``requests_mock``). +""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest +from hermes._client import EverOSApiClient +from hermes._constants import _ADD_BATCH_SIZE +from hermes._types import EverOSClientError + + +def _ok(data: dict[str, Any]) -> httpx.Response: + return httpx.Response(200, json={"request_id": "t", "data": data}) + + +def _err( + status: int, + code: str, + *, + message: str = "boom", +) -> httpx.Response: + return httpx.Response( + status, + json={ + "request_id": "t", + "error": { + "code": code, + "message": message, + "timestamp": "2026-01-01T00:00:00Z", + "path": "/api/v1/memory/x", + }, + }, + ) + + +@pytest.fixture +def make_client(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest): + """Return a factory ``(handler, **kwargs) -> EverOSApiClient``. + + The factory wires the client's ``httpx.AsyncClient`` to an + ``httpx.MockTransport`` built from ``handler`` by patching + ``_make_client``. Created clients are closed at test teardown. + """ + import hermes._client as client_mod + + real_async_client = client_mod.httpx.AsyncClient + created: list[EverOSApiClient] = [] + + def factory( + handler, + *, + base_url: str = "http://test.local", + timeout: float = 5.0, + ) -> EverOSApiClient: + transport = httpx.MockTransport(handler) + + async def _make(self: EverOSApiClient) -> None: + self._client = real_async_client( + base_url=self._base_url, + timeout=self._timeout, + headers={"Content-Type": "application/json"}, + transport=transport, + ) + + monkeypatch.setattr(client_mod.EverOSApiClient, "_make_client", _make) + client = client_mod.EverOSApiClient(base_url, timeout=timeout) + created.append(client) + return client + + request.addfinalizer(lambda: [c.close() for c in created]) + return factory + + +def _msg(i: int) -> dict[str, Any]: + return { + "sender_id": "u1" if i % 2 == 0 else "a1", + "role": "user" if i % 2 == 0 else "assistant", + "timestamp": 1000 + i, + "content": f"message-{i}", + } + + +# ── add_messages ──────────────────────────────────────────────────────────── + + +def test_add_messages_payload_and_default_scope_omission(make_client) -> None: + seen: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(json.loads(request.content)) + return _ok({"message_count": 2, "status": "accumulated"}) + + client = make_client(handler) + messages = [_msg(0), _msg(1)] + resp = client.add_messages("sess-1", "default", "default", messages) + + assert resp == {"message_count": 2, "status": "accumulated"} + assert len(seen) == 1 + body = seen[0] + assert body["session_id"] == "sess-1" + assert "app_id" not in body + assert "project_id" not in body + assert body["messages"] == messages + assert [m["role"] for m in body["messages"]] == ["user", "assistant"] + assert [m["sender_id"] for m in body["messages"]] == ["u1", "a1"] + assert [m["timestamp"] for m in body["messages"]] == [1000, 1001] + assert [m["content"] for m in body["messages"]] == [ + "message-0", + "message-1", + ] + + +def test_add_messages_includes_non_default_scope(make_client) -> None: + seen: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(json.loads(request.content)) + return _ok({"message_count": 1, "status": "accumulated"}) + + client = make_client(handler) + client.add_messages("s", "myapp", "myproj", [_msg(0)]) + body = seen[0] + assert body["app_id"] == "myapp" + assert body["project_id"] == "myproj" + + +def test_add_messages_chunks_over_batch_size_and_merges_count(make_client) -> None: + assert _ADD_BATCH_SIZE == 500 + calls: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + calls.append(body) + n = len(body["messages"]) + return _ok({"message_count": n, "status": "accumulated"}) + + client = make_client(handler) + messages = [_msg(i) for i in range(501)] + resp = client.add_messages("s", "default", "default", messages) + + assert len(calls) == 2 + assert len(calls[0]["messages"]) == 500 + assert len(calls[1]["messages"]) == 1 + assert resp["message_count"] == 501 + assert resp["status"] == "accumulated" + + +# ── flush_session ─────────────────────────────────────────────────────────── + + +def test_flush_session_payload(make_client) -> None: + seen: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(json.loads(request.content)) + return _ok({"status": "extracted"}) + + client = make_client(handler) + resp = client.flush_session("sess-7", "default", "default") + assert resp == {"status": "extracted"} + assert seen[0] == {"session_id": "sess-7"} + + +# ── search ────────────────────────────────────────────────────────────────── + + +def test_search_user_body_and_forwarded_kwargs(make_client) -> None: + seen: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(json.loads(request.content)) + return _ok( + { + "episodes": [], + "profiles": [], + "agent_cases": [], + "agent_skills": [], + "unprocessed_messages": [], + } + ) + + client = make_client(handler) + client.search( + "u-1", + None, + "default", + "default", + "hello", + include_profile=True, + top_k=7, + method="hybrid", + ) + body = seen[0] + assert body["query"] == "hello" + assert body["user_id"] == "u-1" + assert "agent_id" not in body + assert body["include_profile"] is True + assert body["top_k"] == 7 + assert body["method"] == "hybrid" + assert "app_id" not in body + assert "project_id" not in body + + +def test_search_agent_owner(make_client) -> None: + seen: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(json.loads(request.content)) + return _ok( + { + "episodes": [], + "profiles": [], + "agent_cases": [], + "agent_skills": [], + "unprocessed_messages": [], + } + ) + + client = make_client(handler) + client.search(None, "a-1", "default", "default", "q") + body = seen[0] + assert body["agent_id"] == "a-1" + assert "user_id" not in body + + +@pytest.mark.parametrize("user_id, agent_id", [(None, None), ("u", "a")]) +def test_search_xor_enforced( + make_client, user_id: str | None, agent_id: str | None +) -> None: + client = make_client(lambda r: _ok({})) + with pytest.raises(ValueError, match="exactly one"): + client.search(user_id, agent_id, "default", "default", "q") + + +# ── get ───────────────────────────────────────────────────────────────────── + + +def test_get_body_and_pagination_forwarding(make_client) -> None: + seen: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(json.loads(request.content)) + return _ok( + { + "episodes": [], + "profiles": [], + "agent_cases": [], + "agent_skills": [], + "total_count": 0, + "count": 0, + } + ) + + client = make_client(handler) + client.get( + "u-1", + None, + "default", + "default", + "episode", + page=2, + page_size=15, + sort_by="timestamp", + sort_order="desc", + ) + body = seen[0] + assert body["memory_type"] == "episode" + assert body["user_id"] == "u-1" + assert body["page"] == 2 + assert body["page_size"] == 15 + assert body["sort_by"] == "timestamp" + assert body["sort_order"] == "desc" + assert "agent_id" not in body + + +@pytest.mark.parametrize("user_id, agent_id", [(None, None), ("u", "a")]) +def test_get_xor_enforced( + make_client, user_id: str | None, agent_id: str | None +) -> None: + client = make_client(lambda r: _ok({})) + with pytest.raises(ValueError, match="exactly one"): + client.get(user_id, agent_id, "default", "default", "episode") + + +# ── error mapping ─────────────────────────────────────────────────────────── + + +def test_error_503_external_service_unavailable(make_client) -> None: + client = make_client(lambda r: _err(503, "EXTERNAL_SERVICE_UNAVAILABLE")) + with pytest.raises(EverOSClientError) as exc: + client.search("u", None, "default", "default", "q") + assert exc.value.code == "EXTERNAL_SERVICE_UNAVAILABLE" + + +def test_error_422_invalid_input(make_client) -> None: + client = make_client(lambda r: _err(422, "INVALID_INPUT")) + with pytest.raises(EverOSClientError) as exc: + client.flush_session("s", "default", "default") + assert exc.value.code == "INVALID_INPUT" + + +def test_connection_refused_maps_to_unavailable(make_client) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused") + + client = make_client(handler) + with pytest.raises(EverOSClientError) as exc: + client.search("u", None, "default", "default", "q") + assert exc.value.code == "EXTERNAL_SERVICE_UNAVAILABLE" + + +def test_non_json_4xx_maps_to_internal_error(make_client) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + content=b"bad", + headers={"content-type": "text/html"}, + ) + + client = make_client(handler) + with pytest.raises(EverOSClientError) as exc: + client.search("u", None, "default", "default", "q") + assert exc.value.code == "INTERNAL_ERROR" + + +# ── close() ───────────────────────────────────────────────────────────────── + + +def test_method_after_close_raises_client_closed(make_client) -> None: + client = make_client(lambda r: _ok({})) + client.close() + with pytest.raises(EverOSClientError) as exc: + client.search("u", None, "default", "default", "q") + assert exc.value.code == "CLIENT_CLOSED" diff --git a/tests/unit/test_integrations/test_hermes/test_config.py b/tests/unit/test_integrations/test_hermes/test_config.py new file mode 100644 index 000000000..02fa564d9 --- /dev/null +++ b/tests/unit/test_integrations/test_hermes/test_config.py @@ -0,0 +1,225 @@ +"""Contract tests for ``integrations/hermes/_config.py``. + +Pins the Hermes-agnostic config layer: + +- ``load_config`` merges defaults → env → JSON (JSON wins); a missing or + malformed JSON file is silently skipped (defaults + env remain). +- ``resolve_user_id`` priority chain: configured (non-default) > kwargs + ``user_id`` > kwargs ``user_id_alt`` > ``_DEFAULT_USER_ID``; the literal + default placeholder is treated as unset. +- ``resolve_agent_id`` falls back to ``_DEFAULT_AGENT_ID``. +- ``get_scope_ids`` validates the charset, rejects path-traversal tokens + (``.`` / ``..``), enforces length 1..128, and defaults empty values. +- ``is_configured`` is true on a non-empty ``api_url`` or a recognised + ``mode``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from hermes._config import ( + get_scope_ids, + is_configured, + load_config, + resolve_agent_id, + resolve_user_id, +) +from hermes._constants import ( + _DEFAULT_AGENT_ID, + _DEFAULT_API_URL, + _DEFAULT_APP_ID, + _DEFAULT_PROJECT_ID, + _DEFAULT_USER_ID, +) + +# ── load_config ───────────────────────────────────────────────────────────── + + +def test_load_config_defaults_when_no_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + for var in ("EVEROS_API_URL", "EVEROS_USER_ID", "EVEROS_AGENT_ID", "EVEROS_MODE"): + monkeypatch.delenv(var, raising=False) + cfg = load_config(tmp_path) + assert cfg["api_url"] == _DEFAULT_API_URL + assert cfg["mode"] == "platform" + assert cfg["user_id"] == _DEFAULT_USER_ID + assert cfg["agent_id"] == _DEFAULT_AGENT_ID + assert cfg["app_id"] == _DEFAULT_APP_ID + assert cfg["project_id"] == _DEFAULT_PROJECT_ID + assert cfg["agent_track_enabled"] is False + assert cfg["everos_root"] is None + + +def test_load_config_env_overrides_defaults( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("EVEROS_API_URL", "http://env.example") + monkeypatch.setenv("EVEROS_USER_ID", "env-user") + monkeypatch.setenv("EVEROS_AGENT_ID", "env-agent") + monkeypatch.setenv("EVEROS_MODE", "oss") + cfg = load_config(tmp_path) + assert cfg["api_url"] == "http://env.example" + assert cfg["user_id"] == "env-user" + assert cfg["agent_id"] == "env-agent" + assert cfg["mode"] == "oss" + + +def test_load_config_json_wins_over_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("EVEROS_USER_ID", "env-user") + monkeypatch.setenv("EVEROS_API_URL", "http://env.example") + (tmp_path / "everos.json").write_text( + json.dumps( + { + "user_id": "json-user", + "api_url": "http://json.example", + "mode": "platform", + "agent_track_enabled": True, + "everos_root": "/var/everos", + } + ), + encoding="utf-8", + ) + cfg = load_config(tmp_path) + assert cfg["user_id"] == "json-user" + assert cfg["api_url"] == "http://json.example" + assert cfg["agent_track_enabled"] is True + assert cfg["everos_root"] == "/var/everos" + + +def test_load_config_skips_malformed_json( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("EVEROS_USER_ID", "env-user") + (tmp_path / "everos.json").write_text("{not valid json", encoding="utf-8") + cfg = load_config(tmp_path) + # Env value survives; defaults fill the rest. + assert cfg["user_id"] == "env-user" + assert cfg["api_url"] == _DEFAULT_API_URL + + +def test_load_config_skips_empty_json_values( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("EVEROS_USER_ID", "env-user") + (tmp_path / "everos.json").write_text( + json.dumps({"user_id": "", "api_url": None, "mode": "oss"}), + encoding="utf-8", + ) + cfg = load_config(tmp_path) + assert cfg["user_id"] == "env-user" + assert cfg["api_url"] == _DEFAULT_API_URL + assert cfg["mode"] == "oss" + + +# ── resolve_user_id ───────────────────────────────────────────────────────── + + +def test_resolve_user_id_configured_wins() -> None: + assert resolve_user_id({"user_id": "real-user"}, "kw", "alt") == "real-user" + + +def test_resolve_user_id_default_placeholder_is_unset() -> None: + # The literal _DEFAULT_USER_ID is treated as unset → fall through to kwargs. + assert resolve_user_id({"user_id": _DEFAULT_USER_ID}, "kw-user", "alt") == "kw-user" + + +def test_resolve_user_id_kwargs_user_then_alt() -> None: + assert resolve_user_id({}, "kw-user", "alt") == "kw-user" + + +def test_resolve_user_id_kwargs_alt_when_no_user() -> None: + assert resolve_user_id({}, None, "alt-user") == "alt-user" + + +def test_resolve_user_id_falls_back_to_default() -> None: + assert resolve_user_id({}, None, None) == _DEFAULT_USER_ID + + +# ── resolve_agent_id ──────────────────────────────────────────────────────── + + +def test_resolve_agent_id_uses_config() -> None: + assert resolve_agent_id({"agent_id": "real-agent"}) == "real-agent" + + +def test_resolve_agent_id_default_when_missing() -> None: + assert resolve_agent_id({}) == _DEFAULT_AGENT_ID + + +# ── get_scope_ids ─────────────────────────────────────────────────────────── + + +def test_get_scope_ids_valid() -> None: + ids = get_scope_ids({"app_id": "my.app-1", "project_id": "proj_2"}) + assert ids.app_id == "my.app-1" + assert ids.project_id == "proj_2" + + +def test_get_scope_ids_defaults_when_empty() -> None: + ids = get_scope_ids({}) + assert ids.app_id == _DEFAULT_APP_ID + assert ids.project_id == _DEFAULT_PROJECT_ID + + +def test_get_scope_ids_none_values_default() -> None: + ids = get_scope_ids({"app_id": None, "project_id": None}) + assert ids.app_id == _DEFAULT_APP_ID + assert ids.project_id == _DEFAULT_PROJECT_ID + + +def test_get_scope_ids_rejects_bad_charset() -> None: + with pytest.raises(ValueError, match="charset"): + get_scope_ids({"app_id": "a/b"}) + + +def test_get_scope_ids_rejects_traversal_dot() -> None: + with pytest.raises(ValueError, match="traversal"): + get_scope_ids({"app_id": "."}) + + +def test_get_scope_ids_rejects_traversal_double_dot() -> None: + with pytest.raises(ValueError, match="traversal"): + get_scope_ids({"project_id": ".."}) + + +def test_get_scope_ids_rejects_too_long() -> None: + too_long = "a" * 129 + with pytest.raises(ValueError, match="length"): + get_scope_ids({"app_id": too_long}) + + +def test_get_scope_ids_accepts_max_length() -> None: + max_len = "a" * 128 + ids = get_scope_ids({"app_id": max_len}) + assert ids.app_id == max_len + + +def test_get_scope_ids_rejects_non_string() -> None: + with pytest.raises(ValueError, match="must be a string"): + get_scope_ids({"app_id": 123}) + + +# ── is_configured ─────────────────────────────────────────────────────────── + + +def test_is_configured_true_with_api_url() -> None: + assert is_configured({"api_url": "http://x", "mode": ""}) is True + + +def test_is_configured_true_with_recognised_mode() -> None: + assert is_configured({"api_url": "", "mode": "platform"}) is True + assert is_configured({"api_url": "", "mode": "oss"}) is True + + +def test_is_configured_false_with_empty_url_and_unknown_mode() -> None: + assert is_configured({"api_url": "", "mode": "weird"}) is False + + +def test_is_configured_false_when_both_missing() -> None: + assert is_configured({"api_url": "", "mode": ""}) is False diff --git a/tests/unit/test_integrations/test_hermes/test_formatting.py b/tests/unit/test_integrations/test_hermes/test_formatting.py new file mode 100644 index 000000000..81e527305 --- /dev/null +++ b/tests/unit/test_integrations/test_hermes/test_formatting.py @@ -0,0 +1,176 @@ +"""Contract tests for ``integrations/hermes/_formatting.py``. + +Pins the Hermes-agnostic formatting helpers: + +- ``format_prefetch`` emits the ``## EverOS Memory`` header, sorts episodes + by ``score`` descending, nests atomic facts under their parent episode, + truncates at a word boundary appending ``" …"`` when it overflows, and + returns ``""`` when there are no episodes and no profiles. A profile + block is rendered as a one-liner when present. +- ``format_tool_result`` serialises via ``json.dumps``. +- ``format_memory_write_message`` builds a ``user``-role ``MessageItem`` + with the required fields. +""" + +from __future__ import annotations + +import json + +from hermes._formatting import ( + format_memory_write_message, + format_prefetch, + format_tool_result, +) + + +def _episode( + subject: str, + score: float, + *, + episode: str = "", + facts: list[str] | None = None, +) -> dict: + return { + "id": f"ep-{subject}", + "user_id": "u", + "app_id": "default", + "project_id": "default", + "session_id": "s", + "timestamp": "2026-01-01T00:00:00Z", + "sender_ids": ["u"], + "summary": subject, + "subject": subject, + "episode": episode, + "type": "dialogue", + "score": score, + "atomic_facts": [ + {"id": f"f{i}", "content": c, "score": 1.0} + for i, c in enumerate(facts or []) + ], + } + + +def _profile(name: str, score: float) -> dict: + return { + "id": f"p-{name}", + "user_id": "u", + "app_id": "default", + "project_id": "default", + "profile_data": {"name": name}, + "score": score, + } + + +def _search_data(episodes=None, profiles=None) -> dict: + return { + "episodes": episodes or [], + "profiles": profiles or [], + "agent_cases": [], + "agent_skills": [], + "unprocessed_messages": [], + } + + +# ── format_prefetch ───────────────────────────────────────────────────────── + + +def test_format_prefetch_empty_returns_empty_string() -> None: + assert format_prefetch("q", _search_data()) == "" + + +def test_format_prefetch_header_and_episode_block() -> None: + data = _search_data( + episodes=[ + _episode("alpha", 0.9, episode="alpha body", facts=["fact-a", "fact-b"]), + ] + ) + out = format_prefetch("q", data) + assert out.startswith("## EverOS Memory") + assert "**Episode**: alpha" in out + assert "alpha body" in out + assert "- fact-a" in out + assert "- fact-b" in out + + +def test_format_prefetch_sorts_episodes_by_score_desc() -> None: + data = _search_data( + episodes=[ + _episode("low", 0.1), + _episode("high", 0.99), + _episode("mid", 0.5), + ] + ) + out = format_prefetch("q", data) + high_pos = out.find("**Episode**: high") + mid_pos = out.find("**Episode**: mid") + low_pos = out.find("**Episode**: low") + assert high_pos < mid_pos < low_pos + + +def test_format_prefetch_atomic_facts_nested_under_parent() -> None: + data = _search_data( + episodes=[ + _episode("ep1", 0.5, facts=["a", "b"]), + _episode("ep2", 0.4, facts=["c"]), + ] + ) + out = format_prefetch("q", data) + # Facts of ep1 appear between ep1 subject and ep2 subject. + ep1_pos = out.find("**Episode**: ep1") + ep2_pos = out.find("**Episode**: ep2") + fact_a = out.find("- a") + fact_b = out.find("- b") + fact_c = out.find("- c") + assert ep1_pos < fact_a < fact_b < ep2_pos < fact_c + + +def test_format_prefetch_profile_one_liner_when_present() -> None: + data = _search_data( + profiles=[_profile("Alice", 0.8)], + ) + out = format_prefetch("q", data) + assert out.startswith("## EverOS Memory") + assert "**Profile**: Alice" in out + + +def test_format_prefetch_truncates_at_word_boundary_with_ellipsis() -> None: + long_body = "word " * 5000 # well over any reasonable budget + data = _search_data( + episodes=[_episode("ep", 0.5, episode=long_body)], + ) + out = format_prefetch("q", data, max_chars=120) + assert out.endswith(" …") + assert len(out) <= 120 + + +def test_format_prefetch_truncation_keeps_header_when_budget_small() -> None: + data = _search_data(episodes=[_episode("ep", 0.5, episode="x" * 5000)]) + out = format_prefetch("q", data, max_chars=40) + # Even truncated, the header is the first section; budget > header len. + assert "## EverOS Memory" in out + assert out.endswith(" …") + + +# ── format_tool_result ────────────────────────────────────────────────────── + + +def test_format_tool_result_is_json_dumps() -> None: + payload = {"count": 3, "items": ["a", "b", "c"], "nested": {"k": 1}} + assert format_tool_result(payload) == json.dumps(payload, ensure_ascii=False) + + +def test_format_tool_result_preserves_unicode() -> None: + assert format_tool_result({"k": "café"}) == '{"k": "café"}' + + +# ── format_memory_write_message ───────────────────────────────────────────── + + +def test_format_memory_write_message_fields() -> None: + msg = format_memory_write_message("hello world", "u-1", 12345) + assert msg["sender_id"] == "u-1" + assert msg["role"] == "user" + assert msg["timestamp"] == 12345 + assert msg["content"] == "hello world" + # Required MessageItem keys are all present. + assert {"sender_id", "role", "timestamp", "content"} <= set(msg) diff --git a/tests/unit/test_integrations/test_hermes/test_provider.py b/tests/unit/test_integrations/test_hermes/test_provider.py new file mode 100644 index 000000000..206485451 --- /dev/null +++ b/tests/unit/test_integrations/test_hermes/test_provider.py @@ -0,0 +1,737 @@ +"""Contract tests for ``integrations/hermes/__init__.py`` (the provider). + +Pins the ``EverosMemoryProvider`` lifecycle / tools / circuit-breaker +behaviour against a fake EverOS client. Hermes-only symbols +(``agent.memory_provider``, ``hermes_constants``, ``tools.registry``, +``utils``) are injected via ``sys.modules`` (see ``tests.helpers. +hermes_stub``) so the plugin bundle imports cleanly without a Hermes +runtime. No network, no ``respx`` / ``requests_mock`` — the fake client +records calls and returns canned ``SearchData`` / ``GetData`` / +``AddResponse`` / ``FlushResponse`` dicts. +""" + +from __future__ import annotations + +import contextlib +import json +import stat +import sys +import types +from pathlib import Path +from typing import Any + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[4] + + +@pytest.fixture(scope="module", autouse=True) +def _hermes_stubs(): + """Inject Hermes stubs into ``sys.modules`` and import the plugin. + + The plugin's ``__init__.py`` imports ``agent.memory_provider``, + ``hermes_constants``, ``tools.registry`` and ``utils`` — none of which + are installable from this repo. We point them all at + ``tests.helpers.hermes_stub`` (which carries every symbol the plugin + references) and add the repo root to ``sys.path`` so + ``integrations.hermes`` imports as a real package. + """ + import tests.helpers.hermes_stub as stub + + mods = { + "agent": types.ModuleType("agent"), + "agent.memory_provider": stub, + "hermes_constants": stub, + "tools": types.ModuleType("tools"), + "tools.registry": stub, + "utils": stub, + } + mods["agent"].memory_provider = stub # type: ignore[attr-defined] + mods["tools"].registry = stub # type: ignore[attr-defined] + for name, mod in mods.items(): + sys.modules.setdefault(name, mod) + sys.path.insert(0, str(_REPO_ROOT)) + try: + import importlib + + plugin = importlib.import_module("integrations.hermes") + yield plugin + finally: + with contextlib.suppress(ValueError): + sys.path.remove(str(_REPO_ROOT)) + + +# ── canned API response shapes ────────────────────────────────────────────── + + +def _empty_search_data() -> dict[str, Any]: + return { + "episodes": [], + "profiles": [], + "agent_cases": [], + "agent_skills": [], + "unprocessed_messages": [], + } + + +def _search_data_with_episode() -> dict[str, Any]: + ep = { + "id": "e1", + "user_id": "alice", + "app_id": "default", + "project_id": "default", + "session_id": "sess-1", + "timestamp": "2026-01-01T00:00:00Z", + "sender_ids": ["alice"], + "summary": "sum", + "subject": "subj", + "episode": "Alice likes tea.", + "type": "episode", + "score": 0.9, + "atomic_facts": [{"id": "f1", "content": "Alice likes tea.", "score": 0.9}], + } + return {**_empty_search_data(), "episodes": [ep]} + + +def _empty_get_data() -> dict[str, Any]: + return { + "episodes": [], + "profiles": [], + "agent_cases": [], + "agent_skills": [], + "total_count": 0, + "count": 0, + } + + +class FakeEverOSClient: + """Recording stand-in for ``EverOSApiClient``. + + All methods return canned dicts; ``raise_on`` maps a method name to an + error code that the method should raise as ``EverOSClientError``. + """ + + def __init__( + self, + *, + search_data: dict[str, Any] | None = None, + get_data: dict[str, Any] | None = None, + add_response: dict[str, Any] | None = None, + flush_response: dict[str, Any] | None = None, + raise_on: dict[str, str] | None = None, + ) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + self.closed = False + self._search_data = ( + search_data if search_data is not None else _empty_search_data() + ) + self._get_data = get_data if get_data is not None else _empty_get_data() + self._add_response = add_response or { + "message_count": 1, + "status": "accumulated", + } + self._flush_response = flush_response or {"status": "extracted"} + self._raise_on = raise_on or {} + + def _maybe_raise(self, method: str) -> None: + code = self._raise_on.get(method) + if code is not None: + from integrations.hermes._types import EverOSClientError + + raise EverOSClientError("boom", code=code) + + def add_messages( + self, + session_id: str, + app_id: str, + project_id: str, + messages: list[dict[str, Any]], + ) -> dict[str, Any]: + self._maybe_raise("add_messages") + self.calls.append( + ( + "add_messages", + { + "session_id": session_id, + "app_id": app_id, + "project_id": project_id, + "messages": list(messages), + }, + ) + ) + return self._add_response + + def flush_session( + self, session_id: str, app_id: str, project_id: str + ) -> dict[str, Any]: + self._maybe_raise("flush_session") + self.calls.append( + ( + "flush_session", + { + "session_id": session_id, + "app_id": app_id, + "project_id": project_id, + }, + ) + ) + return self._flush_response + + def search( + self, + user_id: str | None, + agent_id: str | None, + app_id: str, + project_id: str, + query: str, + **kwargs: Any, + ) -> dict[str, Any]: + self._maybe_raise("search") + self.calls.append( + ( + "search", + { + "user_id": user_id, + "agent_id": agent_id, + "app_id": app_id, + "project_id": project_id, + "query": query, + **kwargs, + }, + ) + ) + return self._search_data + + def get( + self, + user_id: str | None, + agent_id: str | None, + app_id: str, + project_id: str, + memory_type: str, + **kwargs: Any, + ) -> dict[str, Any]: + self._maybe_raise("get") + self.calls.append( + ( + "get", + { + "user_id": user_id, + "agent_id": agent_id, + "app_id": app_id, + "project_id": project_id, + "memory_type": memory_type, + **kwargs, + }, + ) + ) + return self._get_data + + def close(self) -> None: + self.closed = True + self.calls.append(("close", {})) + + +@pytest.fixture +def plugin(_hermes_stubs): + return _hermes_stubs + + +@pytest.fixture +def make_provider(plugin, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Return a factory that builds an initialised provider + fake client.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("EVEROS_API_URL", raising=False) + monkeypatch.delenv("EVEROS_USER_ID", raising=False) + monkeypatch.delenv("EVEROS_AGENT_ID", raising=False) + monkeypatch.delenv("EVEROS_MODE", raising=False) + + def _make( + *, + fake: FakeEverOSClient | None = None, + config: dict[str, Any] | None = None, + **init_kwargs: Any, + ): + if config is not None: + (tmp_path / "everos.json").write_text(json.dumps(config)) + prov = plugin.EverosMemoryProvider() + prov.initialize("sess-1", **init_kwargs) + if fake is not None: + prov._client = fake + return prov + + return _make + + +# ── identity / availability ───────────────────────────────────────────────── + + +def test_name_and_availability(make_provider, plugin): + prov = make_provider() + assert prov.name == "everos" + # Defaults carry a non-empty api_url → is_available() is True. + assert prov.is_available() is True + + +def test_is_available_false_when_unconfigured( + make_provider, plugin, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(plugin, "is_configured", lambda _cfg: False) + prov = make_provider() + assert prov.is_available() is False + + +# ── initialize ─────────────────────────────────────────────────────────────── + + +def test_initialize_builds_state_from_config_and_kwargs(make_provider): + fake = FakeEverOSClient() + prov = make_provider( + fake=fake, + config={ + "app_id": "myapp", + "project_id": "myproj", + "agent_id": "myagent", + }, + user_id="alice", + ) + assert prov._session_id == "sess-1" + assert prov._user_id == "alice" + assert prov._agent_id == "myagent" + assert prov._scope is not None + assert prov._scope.app_id == "myapp" + assert prov._scope.project_id == "myproj" + assert prov._client is fake + assert prov._init_error == "" + prov.shutdown() + + +def test_initialize_bad_url_leaves_client_none( + make_provider, plugin, monkeypatch: pytest.MonkeyPatch +): + def _boom(_url: str): + raise ValueError("bad url") + + monkeypatch.setattr(plugin, "EverOSApiClient", _boom) + prov = make_provider() + assert prov._client is None + assert "bad url" in prov._init_error + + +# ── prefetch ──────────────────────────────────────────────────────────────── + + +def test_prefetch_cache_hit_returns_formatted_context(make_provider): + fake = FakeEverOSClient(search_data=_search_data_with_episode()) + prov = make_provider(fake=fake) + # Prime the cache: simulate a completed prefetch for this query. + prov._prefetch_query = "tea" + prov._prefetch_result = "## EverOS Memory\ncached" + prov._prefetch_done = True + out = prov.prefetch("tea") + assert out == "## EverOS Memory\ncached" + # The worker must not have been started (cache hit). + assert not any(c[0] == "search" for c in fake.calls) + prov.shutdown() + + +def test_prefetch_first_turn_starts_worker_and_joins(make_provider): + fake = FakeEverOSClient(search_data=_search_data_with_episode()) + prov = make_provider(fake=fake) + out = prov.prefetch("tea") + assert "tea" in out.lower() or "everos" in out.lower() + assert any(c[0] == "search" for c in fake.calls) + # The prefetch thread has been joined by prefetch() itself. + assert prov._prefetch_thread is not None + assert not prov._prefetch_thread.is_alive() + prov.shutdown() + + +def test_prefetch_empty_results_returns_empty_string(make_provider): + fake = FakeEverOSClient(search_data=_empty_search_data()) + prov = make_provider(fake=fake) + assert prov.prefetch("nothing") == "" + prov.shutdown() + + +def test_prefetch_breaker_open_returns_empty(make_provider): + fake = FakeEverOSClient(search_data=_search_data_with_episode()) + prov = make_provider(fake=fake) + prov._consecutive_failures = 5 + prov._breaker_open_until = _now_plus(1000.0) + assert prov.prefetch("tea") == "" + assert not any(c[0] == "search" for c in fake.calls) + prov.shutdown() + + +# ── sync_turn ─────────────────────────────────────────────────────────────── + + +def test_sync_turn_enqueues_user_then_assistant_with_adjacent_timestamps( + make_provider, +): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + prov.sync_turn("hello", "hi there") + thread = prov._sync_thread + assert thread is not None + thread.join(timeout=2.0) + add_calls = [c for c in fake.calls if c[0] == "add_messages"] + assert add_calls, "add_messages was not called" + msgs = add_calls[-1][1]["messages"] + assert len(msgs) == 2 + assert msgs[0]["role"] == "user" + assert msgs[1]["role"] == "assistant" + assert msgs[1]["timestamp"] - msgs[0]["timestamp"] == 1 + prov.shutdown() + + +def test_sync_turn_joins_previous_thread(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + prov.sync_turn("first", "a1") + prov.sync_turn("second", "a2") + thread = prov._sync_thread + assert thread is not None + thread.join(timeout=2.0) + add_calls = [c for c in fake.calls if c[0] == "add_messages"] + assert len(add_calls) == 2 + prov.shutdown() + + +def test_sync_turn_skips_when_breaker_open(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + prov._consecutive_failures = 5 + prov._breaker_open_until = _now_plus(1000.0) + prov.sync_turn("x", "y") + assert prov._sync_thread is None + assert not any(c[0] == "add_messages" for c in fake.calls) + prov.shutdown() + + +# ── on_session_end ────────────────────────────────────────────────────────── + + +def test_on_session_end_flushes_and_closes_client(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + prov.sync_turn("hi", "hello") + prov.on_session_end([]) + assert any(c[0] == "flush_session" for c in fake.calls) + assert fake.closed is True + assert prov._client is None + + +# ── on_memory_write ───────────────────────────────────────────────────────── + + +def test_on_memory_write_mirrors_add_user(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + prov.on_memory_write("add", "user", "Alice likes tea.") + mirror = prov._mirror_thread + assert mirror is not None + mirror.join(timeout=2.0) + add_calls = [c for c in fake.calls if c[0] == "add_messages"] + flush_calls = [c for c in fake.calls if c[0] == "flush_session"] + assert add_calls and flush_calls + assert add_calls[-1][1]["messages"][0]["content"] == "Alice likes tea." + prov.shutdown() + + +def test_on_memory_write_skips_memory_target(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + prov.on_memory_write("add", "memory", "ignored") + assert prov._mirror_thread is None + assert not fake.calls + prov.shutdown() + + +def test_on_memory_write_remove_is_noop(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + prov.on_memory_write("remove", "user", "x") + assert prov._mirror_thread is None + assert not fake.calls + prov.shutdown() + + +# ── handle_tool_call ──────────────────────────────────────────────────────── + + +def test_handle_tool_call_search_happy_path(make_provider): + fake = FakeEverOSClient(search_data=_search_data_with_episode()) + prov = make_provider(fake=fake) + out = prov.handle_tool_call("everos_search", {"query": "tea"}) + payload = json.loads(out) + assert "results" in payload + assert payload["count"] == 1 + assert any(c[0] == "search" for c in fake.calls) + prov.shutdown() + + +def test_handle_tool_call_list_happy_path(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + out = prov.handle_tool_call("everos_list", {"memory_type": "episode"}) + payload = json.loads(out) + assert "results" in payload + assert any(c[0] == "get" for c in fake.calls) + prov.shutdown() + + +def test_handle_tool_call_add_happy_path(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + out = prov.handle_tool_call("everos_add", {"content": "a fact"}) + payload = json.loads(out) + assert payload["result"] == "Fact stored." + assert any(c[0] == "add_messages" for c in fake.calls) + assert any(c[0] == "flush_session" for c in fake.calls) + prov.shutdown() + + +def test_handle_tool_call_flush_happy_path(make_provider): + fake = FakeEverOSClient(flush_response={"status": "extracted"}) + prov = make_provider(fake=fake) + out = prov.handle_tool_call("everos_flush", {}) + payload = json.loads(out) + assert payload["status"] == "extracted" + prov.shutdown() + + +def test_handle_tool_call_search_missing_query_returns_error(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + out = prov.handle_tool_call("everos_search", {}) + assert "error" in json.loads(out) + prov.shutdown() + + +def test_handle_tool_call_add_missing_content_returns_error(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + out = prov.handle_tool_call("everos_add", {}) + assert "error" in json.loads(out) + prov.shutdown() + + +def test_handle_tool_call_unknown_tool_returns_error(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + out = prov.handle_tool_call("everos_bogus", {}) + assert "error" in json.loads(out) + prov.shutdown() + + +def test_handle_tool_call_backend_down_returns_error(make_provider): + prov = make_provider() + prov._client = None + prov._init_error = "init failed" + out = prov.handle_tool_call("everos_search", {"query": "x"}) + assert "error" in json.loads(out) + + +def test_handle_tool_call_list_transient_failure_trips_breaker(make_provider): + fake = FakeEverOSClient(raise_on={"get": "EXTERNAL_SERVICE_UNAVAILABLE"}) + prov = make_provider(fake=fake) + out = prov.handle_tool_call("everos_list", {"memory_type": "episode"}) + payload = json.loads(out) + assert "error" in payload + assert prov._consecutive_failures == 1 + prov.shutdown() + + +def test_handle_tool_call_flush_transient_failure_trips_breaker(make_provider): + fake = FakeEverOSClient(raise_on={"flush_session": "INTERNAL_ERROR"}) + prov = make_provider(fake=fake) + out = prov.handle_tool_call("everos_flush", {}) + payload = json.loads(out) + assert "error" in payload + assert prov._consecutive_failures == 1 + prov.shutdown() + + +# ── get_tool_schemas / system_prompt_block ───────────────────────────────── + + +def test_get_tool_schemas_openai_shape(make_provider): + prov = make_provider() + schemas = prov.get_tool_schemas() + assert len(schemas) == 4 + names = {s["name"] for s in schemas} + assert names == {"everos_search", "everos_list", "everos_add", "everos_flush"} + for schema in schemas: + assert schema["parameters"]["type"] == "object" + assert "properties" in schema["parameters"] + assert "description" in schema + prov.shutdown() + + +def test_system_prompt_block_mentions_everos_search(make_provider): + prov = make_provider() + block = prov.system_prompt_block() + assert "everos" in block.lower() + assert "everos_search" in block + prov.shutdown() + + +# ── circuit breaker ───────────────────────────────────────────────────────── + + +def test_breaker_opens_after_five_transient_failures(make_provider, plugin): + prov = make_provider() + for _ in range(5): + prov._record_failure() + assert prov._is_breaker_open() is True + prov.shutdown() + + +def test_breaker_skips_calls_when_open(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + prov._consecutive_failures = 5 + prov._breaker_open_until = _now_plus(1000.0) + prov.sync_turn("x", "y") + assert not any(c[0] == "add_messages" for c in fake.calls) + prov.shutdown() + + +def test_breaker_recovers_after_cooldown(make_provider): + fake = FakeEverOSClient() + prov = make_provider(fake=fake) + prov._consecutive_failures = 5 + prov._breaker_open_until = _now_minus(1.0) # cooldown elapsed + assert prov._is_breaker_open() is False + assert prov._consecutive_failures == 0 + prov.sync_turn("x", "y") + thread = prov._sync_thread + assert thread is not None + thread.join(timeout=2.0) + assert any(c[0] == "add_messages" for c in fake.calls) + prov.shutdown() + + +def test_invalid_input_does_not_trip_breaker(make_provider, plugin): + + fake = FakeEverOSClient(raise_on={"add_messages": "INVALID_INPUT"}) + prov = make_provider(fake=fake) + prov.sync_turn("x", "y") + thread = prov._sync_thread + assert thread is not None + thread.join(timeout=2.0) + # Client errors must not count toward the breaker. + assert prov._consecutive_failures == 0 + assert prov._is_breaker_open() is False + prov.shutdown() + + +def test_is_transient_classification(plugin): + assert plugin.EverosMemoryProvider._is_transient("INTERNAL_ERROR") is True + assert plugin.EverosMemoryProvider._is_transient("CLIENT_CLOSED") is True + assert plugin.EverosMemoryProvider._is_transient("INVALID_INPUT") is False + + +# ── backup_paths ──────────────────────────────────────────────────────────── + + +def test_backup_paths_returns_everos_root_when_present( + make_provider, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import pathlib + + everos_dir = tmp_path / ".everos" + everos_dir.mkdir() + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + prov = make_provider() + assert prov.backup_paths() == [str(everos_dir)] + prov.shutdown() + + +def test_backup_paths_empty_when_absent( + make_provider, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import pathlib + + monkeypatch.setattr(pathlib.Path, "home", lambda: tmp_path) + prov = make_provider() + assert prov.backup_paths() == [] + prov.shutdown() + + +# ── shutdown ──────────────────────────────────────────────────────────────── + + +def test_shutdown_joins_threads_and_closes_client(make_provider): + fake = FakeEverOSClient(search_data=_search_data_with_episode()) + prov = make_provider(fake=fake) + prov.sync_turn("hi", "hello") + prov.prefetch("tea") + prov.shutdown() + assert fake.closed is True + assert prov._client is None + + +# ── config schema / save_config / post_setup ─────────────────────────────── + + +def test_get_config_schema_has_seven_entries(make_provider): + prov = make_provider() + schema = prov.get_config_schema() + assert len(schema) == 7 + for entry in schema: + assert "key" in entry + assert "description" in entry + keys = {e["key"] for e in schema} + assert "api_url" in keys + assert "agent_track_enabled" in keys + prov.shutdown() + + +def test_save_config_writes_mode_0600(make_provider, tmp_path: Path): + prov = make_provider() + prov.save_config({"api_url": "http://x"}, str(tmp_path)) + path = tmp_path / "everos.json" + assert path.exists() + data = json.loads(path.read_text(encoding="utf-8")) + assert data["api_url"] == "http://x" + mode = stat.S_IMODE(path.stat().st_mode) + assert mode == 0o600 + prov.shutdown() + + +def test_post_setup_delegates_to_setup_module( + make_provider, plugin, monkeypatch: pytest.MonkeyPatch +): + calls: list[tuple[Any, ...]] = [] + + def _spy(hermes_home: Path, config: dict[str, Any], *, interactive: bool = True): + calls.append((hermes_home, config, interactive)) + return config + + monkeypatch.setattr(plugin, "_run_setup", _spy) + prov = make_provider() + cfg = {"mode": "platform"} + prov.post_setup("/tmp/hermes-home", cfg) + assert len(calls) == 1 + assert calls[0][0] == Path("/tmp/hermes-home") + assert calls[0][1] is cfg + assert calls[0][2] is True + prov.shutdown() + + +# ── helpers ───────────────────────────────────────────────────────────────── + + +def _now_plus(seconds: float) -> float: + import time + + return time.monotonic() + seconds + + +def _now_minus(seconds: float) -> float: + import time + + return time.monotonic() - seconds diff --git a/tests/unit/test_integrations/test_hermes/test_setup.py b/tests/unit/test_integrations/test_hermes/test_setup.py new file mode 100644 index 000000000..56cb5ee14 --- /dev/null +++ b/tests/unit/test_integrations/test_hermes/test_setup.py @@ -0,0 +1,232 @@ +"""Contract tests for ``integrations/hermes/_setup.py``. + +Pins the Hermes-agnostic setup wizard: + +- ``write_everos_toml`` emits a ``tomllib``-parseable file with + ``[llm]`` / ``[embedding]`` (and ``[rerank]`` when provided) sections + carrying the caller's keys, chmod 600, and omits ``[rerank]`` when None. +- ``seed_ome_toml`` enables the three agent-track strategies under + ``[strategies.*]``, returns ``None`` when ``agent_track`` is falsy, and + merges into an existing ``ome.toml`` without duplicate keys or section + flattening. +- ``build_everos_json`` returns the expected dict. +- ``post_setup`` in non-interactive mode writes ``everos.json`` (platform) + or ``everos.toml`` + ``everos.json`` (+ ``ome.toml`` when the agent track + is on) for oss mode, all parseable. +""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +from hermes._setup import ( + build_everos_json, + post_setup, + seed_ome_toml, + write_everos_toml, +) + +_TOML_MODE = 0o600 + + +# ── write_everos_toml ─────────────────────────────────────────────────────── + + +def test_write_everos_toml_sections_keys_and_mode(tmp_path: Path) -> None: + path = write_everos_toml( + tmp_path, + llm={ + "model": "gpt-4", + "api_key": "secret", + "base_url": "http://llm", + "timeout_seconds": 30, + "max_retries": 2, + }, + embedding={"model": "text-embed", "base_url": "http://emb"}, + rerank={"model": "rerank-1", "base_url": "http://rr"}, + ) + assert path == tmp_path / "everos.toml" + assert path.exists() + assert (path.stat().st_mode & 0o777) == _TOML_MODE + with path.open("rb") as fh: + data = tomllib.load(fh) + assert data["llm"]["model"] == "gpt-4" + assert data["llm"]["api_key"] == "secret" + assert data["llm"]["base_url"] == "http://llm" + assert data["llm"]["timeout_seconds"] == 30 + assert data["llm"]["max_retries"] == 2 + assert data["embedding"]["model"] == "text-embed" + assert data["embedding"]["base_url"] == "http://emb" + assert data["rerank"]["model"] == "rerank-1" + assert data["rerank"]["base_url"] == "http://rr" + + +def test_write_everos_toml_omits_rerank_when_none(tmp_path: Path) -> None: + path = write_everos_toml( + tmp_path, + llm={"model": "m"}, + embedding={"model": "e"}, + rerank=None, + ) + with path.open("rb") as fh: + data = tomllib.load(fh) + assert "llm" in data + assert "embedding" in data + assert "rerank" not in data + + +# ── seed_ome_toml ─────────────────────────────────────────────────────────── + + +def test_seed_ome_toml_writes_three_strategies(tmp_path: Path) -> None: + path = seed_ome_toml(tmp_path, agent_track=True) + assert path is not None + assert path.exists() + assert (path.stat().st_mode & 0o777) == _TOML_MODE + with path.open("rb") as fh: + data = tomllib.load(fh) + strategies = data["strategies"] + for name in ( + "extract_agent_case", + "extract_agent_skill", + "trigger_skill_clustering", + ): + assert strategies[name]["enabled"] is True + + +def test_seed_ome_toml_returns_none_when_disabled(tmp_path: Path) -> None: + assert seed_ome_toml(tmp_path, agent_track=False) is None + assert not (tmp_path / "ome.toml").exists() + + +def test_seed_ome_toml_merges_without_duplicates_or_flattening( + tmp_path: Path, +) -> None: + existing = ( + "# pre-existing\n\n[strategies.foo]\nenabled = false\n\n[other]\nkey = 1\n" + ) + (tmp_path / "ome.toml").write_text(existing, encoding="utf-8") + path = seed_ome_toml(tmp_path, agent_track=True) + assert path is not None + with path.open("rb") as fh: + data = tomllib.load(fh) + # Pre-existing keys are preserved. + assert data["strategies"]["foo"]["enabled"] is False + assert data["other"]["key"] == 1 + # The three agent-track strategies are enabled. + for name in ( + "extract_agent_case", + "extract_agent_skill", + "trigger_skill_clustering", + ): + assert data["strategies"][name]["enabled"] is True + # No flattening: strategies is a nested table, not top-level scalar keys. + assert "extract_agent_case" in data["strategies"] + assert "extract_agent_case" not in data + + +# ── build_everos_json ─────────────────────────────────────────────────────── + + +def test_build_everos_json_returns_expected_dict() -> None: + payload = build_everos_json( + api_url="http://api", + mode="oss", + user_id="u-1", + agent_id="a-1", + agent_track_enabled=True, + everos_root="/root", + ) + assert payload == { + "api_url": "http://api", + "mode": "oss", + "user_id": "u-1", + "agent_id": "a-1", + "agent_track_enabled": True, + "everos_root": "/root", + } + + +def test_build_everos_json_normalises_agent_track_bool() -> None: + payload = build_everos_json( + api_url="", + mode="platform", + user_id="u", + agent_id="a", + agent_track_enabled=1, # truthy non-bool + everos_root=None, + ) + assert payload["agent_track_enabled"] is True + + +# ── post_setup ────────────────────────────────────────────────────────────── + + +def test_post_setup_platform_writes_only_everos_json(tmp_path: Path) -> None: + hermes_home = tmp_path / "hermes" + inputs = { + "mode": "platform", + "api_url": "http://api", + "user_id": "u", + "agent_id": "a", + "agent_track_enabled": False, + } + payload = post_setup(hermes_home, {}, interactive=False, inputs=inputs) + json_path = hermes_home / "everos.json" + assert json_path.exists() + assert (json_path.stat().st_mode & 0o777) == _TOML_MODE + assert json.loads(json_path.read_text(encoding="utf-8")) == payload + assert payload["mode"] == "platform" + assert payload["everos_root"] is None + # Platform mode does not write everos.toml under hermes_home. + assert not (hermes_home / "everos.toml").exists() + + +def test_post_setup_oss_writes_toml_json_and_ome(tmp_path: Path) -> None: + hermes_home = tmp_path / "hermes" + everos_root = tmp_path / "everos" + inputs = { + "mode": "oss", + "everos_root": str(everos_root), + "api_url": "http://api", + "user_id": "u", + "agent_id": "a", + "agent_track_enabled": True, + "llm": {"model": "m", "api_key": "k", "base_url": "http://llm"}, + "embedding": {"model": "e", "base_url": "http://emb"}, + "rerank": None, + } + payload = post_setup(hermes_home, {}, interactive=False, inputs=inputs) + + assert (hermes_home / "everos.json").exists() + assert (everos_root / "everos.toml").exists() + assert (everos_root / "ome.toml").exists() # agent_track on + + with (everos_root / "everos.toml").open("rb") as fh: + toml_data = tomllib.load(fh) + assert toml_data["llm"]["model"] == "m" + assert toml_data["embedding"]["model"] == "e" + assert "rerank" not in toml_data + + json_data = json.loads((hermes_home / "everos.json").read_text(encoding="utf-8")) + assert json_data == payload + assert json_data["mode"] == "oss" + assert json_data["everos_root"] == str(everos_root) + + +def test_post_setup_oss_skips_ome_when_agent_track_off(tmp_path: Path) -> None: + hermes_home = tmp_path / "hermes" + everos_root = tmp_path / "everos" + inputs = { + "mode": "oss", + "everos_root": str(everos_root), + "agent_track_enabled": False, + "llm": {"model": "m"}, + "embedding": {"model": "e"}, + } + post_setup(hermes_home, {}, interactive=False, inputs=inputs) + assert (everos_root / "everos.toml").exists() + assert not (everos_root / "ome.toml").exists() + assert (hermes_home / "everos.json").exists() From 6da0b779d46f40b73a49103b1334c4c6daf834fd Mon Sep 17 00:00:00 2001 From: Jianhao Pu Date: Tue, 7 Jul 2026 09:05:08 -0700 Subject: [PATCH 2/2] docs: add Hermes Agent integration guide Add a dedicated docs page (docs/hermes-integration.md) covering install, modes, config, the write/recall lifecycle, agent tools, the Hermes-side CLI, resilience, and troubleshooting. Link it from the docs index (Tutorials) and document the new `everos integrations` subcommand (install/uninstall) in cli.md. --- docs/cli.md | 38 ++++++-- docs/hermes-integration.md | 180 +++++++++++++++++++++++++++++++++++++ docs/index.md | 1 + 3 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 docs/hermes-integration.md diff --git a/docs/cli.md b/docs/cli.md index e24e6e5ea..0b8d96a14 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -32,19 +32,43 @@ everos │ └── show [--root PATH] Show effective configuration ├── server │ └── start [--host] [--port] [--root] [--reload] [--log-level] Start the HTTP API server (uvicorn) -└── cascade [--root PATH] Inspect / operate the md → LanceDB sync queue - ├── status Queue / LSN summary - ├── sync [PATH] Drain the queue now (optional PATH force-enqueues) - └── fix [--apply] List failed rows / re-enqueue retryable ones +├── cascade [--root PATH] Inspect / operate the md → LanceDB sync queue +│ ├── status Queue / LSN summary +│ ├── sync [PATH] Drain the queue now (optional PATH force-enqueues) +│ └── fix [--apply] List failed rows / re-enqueue retryable ones +└── integrations Install EverOS into third-party tools + ├── install [hermes] [--source PATH] [--force] + └── uninstall [hermes] [--source PATH] [--force] [--yes] ``` Each subcommand lives in its own module under [`entrypoints/cli/commands/`](../src/everos/entrypoints/cli/commands/) and is registered in `cli/main.py`. The CLI is intentionally small — hot-path business (`/add` `/flush` `/search` `/get`) is the **HTTP API**, not the -CLI; the CLI covers setup (`init`), running the server, and index ops -(`cascade`). There is no `reindex` command — rebuild by deleting -`/.index/lancedb` and restarting, or run `everos cascade sync`. +CLI; the CLI covers setup (`init`), running the server, index ops +(`cascade`), and external-tool install (`integrations`). There is no +`reindex` command — rebuild by deleting `/.index/lancedb` and +restarting, or run `everos cascade sync`. + +## `everos integrations` + +Install EverOS integrations into third-party tools. Currently ships the +Hermes Agent memory-provider plugin — see +[hermes-integration.md](hermes-integration.md) for the full walkthrough. + +```bash +everos integrations install hermes [--source PATH] [--force] +everos integrations uninstall hermes [--source PATH] [--force] [--yes] +``` + +`install` symlinks the bundle at `integrations/hermes/` into +`$HERMES_HOME/plugins/everos/` so Hermes discovers it. The bundle source +resolves in this order: `EVEROS_HERMES_PLUGIN_SOURCE` env → `--source` +flag → repo-root walk-up from `everos.__file__` (covers editable/dev +installs). An existing symlink is replaced; a real directory requires +`--force`. `uninstall` removes the symlink only if it points at this +bundle (`--force` skips the ownership check; `--source` overrides the +expected path). `~/.everos` memory data is never touched. ## `everos server start` diff --git a/docs/hermes-integration.md b/docs/hermes-integration.md new file mode 100644 index 000000000..b3e5845db --- /dev/null +++ b/docs/hermes-integration.md @@ -0,0 +1,180 @@ +# Hermes Agent Integration + +Wire [Hermes Agent](https://github.com/NousResearch/hermes-agent) to an +EverOS server as its long-term, cross-session memory backend — durable +markdown memory, semantic + lexical recall, and offline consolidation, +with no `hermes-agent` PR required. + +The integration ships **inside the EverOS repo** as a Hermes +`MemoryProvider` plugin bundle at +[`integrations/hermes/`](../integrations/hermes/). The +`everos integrations install hermes` command symlinks the bundle into +`~/.hermes/plugins/everos/`; Hermes discovers user-installed plugins and +loads them via the `MemoryProvider` subclass-fallback path. + +## Prerequisites + +- An EverOS server you can reach (local or remote). See + [QUICKSTART](../QUICKSTART.md) for install + `everos server start`. +- Hermes Agent installed (`hermes` on your `PATH`). +- For **OSS mode** (local EverOS with self-supplied models): an + OpenAI-protocol LLM, embedding, and (optionally) rerank endpoint — + e.g. DeepInfra, OpenRouter, OpenAI, Together, or a local vLLM/Ollama + exposing the OpenAI shape. + +## Install + +```bash +# from the EverOS checkout (editable/dev install — the common case) +everos integrations install hermes + +# or, for a non-editable install, point at the bundle explicitly: +everos integrations install hermes --source /path/to/EverOS/integrations/hermes + +# activate it in Hermes +hermes config set memory.provider everos +``` + +Verify Hermes discovered the plugin: + +```bash +hermes memory status +# → Provider: everos · Plugin: installed ✓ · Status: available ✓ +# Installed plugins: … everos (local) ← active +``` + +Then configure it with `hermes memory setup` (select `everos`) or by +editing `$HERMES_HOME/everos.json` directly (see [Config](#config)). + +## Modes + +| Mode | When | Config | +|---|---|---| +| `platform` | A vendor-hosted / remote EverOS server | `api_url` only | +| `oss` (default) | EverOS runs locally with your own LLM / embedding / rerank | `api_url` + `~/.everos/everos.toml` (the setup wizard writes it) | + +`hermes memory setup` walks you through either mode. In OSS mode the +wizard writes `~/.everos/everos.toml` with `[llm]`, `[embedding]`, and +`[rerank]` blocks (chmod 600) — see +[configuration.md](configuration.md) for the full key reference. When +`agent_track_enabled` is set, it also seeds `~/.everos/ome.toml` to turn +on the agent-track OME strategies (`extract_agent_case`, +`extract_agent_skill`, `trigger_skill_clustering`). + +## Config + +Behavioral settings live in `$HERMES_HOME/everos.json`. Secrets belong in +`$HERMES_HOME/.env` (e.g. `EVEROS_API_KEY`), not `everos.json`. + +| Key | Default | Description | +|---|---|---| +| `api_url` | `http://127.0.0.1:8000` | EverOS server URL | +| `mode` | `oss` | `platform` or `oss` | +| `user_id` | `hermes-user` | EverOS user id to index your memories under. When unset, the gateway-native id (Telegram/Discord/Slack) flows through so the same human gets one merged store | +| `agent_id` | `hermes` | EverOS agent id (agent-track attribution) | +| `app_id` | `default` | EverOS app scope | +| `project_id` | `default` | EverOS project scope | +| `agent_track_enabled` | `false` | Also write/search the agent track (cases + skills) | +| `api_key` | — | CLI-only; read by the `hermes everos` subcommands. The provider itself does not authenticate (EverOS is loopback/no-auth by default) | + +`app_id` / `project_id` must match `^[a-zA-Z0-9_.@+-]+$` (the server-side +`PathSafeId` charset) and reject `.` / `..`. + +## How it works + +### Write path (every turn → durable memory) + +After each completed Hermes turn, `sync_turn` builds two `MessageItem`s +(user + assistant, Unix-ms timestamps) and `POST /api/v1/memory/add`s them +on a daemon thread. EverOS buffers them per `session_id`; its boundary +detector decides when a segment is done, then the extractor (an LLM) +writes an **Episode** to markdown synchronously — +`~/.everos/.../users//episodes/episode-.md`. At session end, +`on_session_end` calls `POST /memory/flush` to force extraction of any +pending buffer. + +Markdown is the source of truth; SQLite (state) and LanceDB +(vectors + BM25) are derived and rebuildable. See +[how-memory-works.md](how-memory-works.md). + +### Recall path (every turn → relevant context injected) + +Before each turn, `prefetch` fires a background `POST /memory/search` +(`include_profile=True`) and waits up to 1.5 s. The result — top episodes +(subject + truncated narrative) + nested atomic facts + a profile +one-liner, score-sorted — is injected into the agent's context for that +turn. If the server is slow, the result surfaces on the next turn +(mem0-style two-phase). + +### Long-term, consolidating + +Episodes persist across Hermes restarts and chats. The Offline Memory +Engine runs in the background: `extract_atomic_facts` (single-sentence +facts), `extract_user_profile` (an evolving `user.md`), and — opt-in, +weekly cron — `reflect_episodes`, which merges fragmented episodes about +the same topic into one narrative and soft-archives the originals. See +[reflection.md](reflection.md). + +## Tools exposed to the agent + +Beyond passive prefetch, the agent gets four tools: + +| Tool | Maps to | Purpose | +|---|---|---| +| `everos_search` | `POST /memory/search` | Semantic + lexical hybrid recall | +| `everos_list` | `POST /memory/get` | Paginated browse of episodes / profile / cases / skills | +| `everos_add` | `POST /memory/add` + `/flush` | Buffer a fact and force extraction | +| `everos_flush` | `POST /memory/flush` | Force extraction of the current session buffer | + +`everos_search` / `everos_list` take an `owner` (`user` | `agent`) to +select the user vs agent track, independent of `mode`. + +## Hermes-side CLI + +When `memory.provider` is `everos`, Hermes exposes `hermes everos …`: + +```bash +hermes everos status # reachability + active config/scope + breaker state +hermes everos search "QUERY" [--top-k N] [--method hybrid|vector|keyword|agentic] [--owner user|agent] +hermes everos flush [--session-id ID] +hermes everos setup --mode oss --api-url ... --user-id ... # non-interactive everos.json writer +``` + +`hermes everos setup` only manages `everos.json`; for full OSS setup +(including `~/.everos/everos.toml`) use `hermes memory setup`. + +## Resilience + +A circuit breaker trips after 5 consecutive transient failures (server +down, LLM/embedding 503) and pauses calls for 120 s. A down EverOS server +never crashes Hermes or stalls the conversation — it degrades to built-in +`MEMORY.md`/`USER.md` until the server recovers. Client errors +(`INVALID_INPUT`, `NOT_FOUND`, …) do **not** trip the breaker. + +## Troubleshooting + +| Symptom | Likely cause / fix | +|---|---| +| `hermes memory status` shows `Status: not available` | `everos.json` missing or `api_url` empty — run `hermes memory setup` | +| `everos_search` returns 503 | The query-embedding step failed — check `~/.everos/everos.toml` `[embedding]` is reachable | +| Memories not appearing after `everos_add` | `everos_add` buffers + flushes; extraction runs on flush. If extraction fails, check the EverOS server logs and the `[llm]` config | +| Recall misses a just-written episode | LanceDB indexing is async (sub-second, up to ~10–15 s under load); retry, or `everos cascade sync` | +| `hermes everos …` command not found | The CLI is gated on `memory.provider == everos` — run `hermes config set memory.provider everos` | + +## Uninstall + +```bash +everos integrations uninstall hermes # removes the dev symlink +# revert the provider if you no longer use it: +hermes config set memory.provider '' +``` + +`~/.everos` (your actual memory data) is preserved. + +## See also + +- [Bundle README](../integrations/hermes/README.md) — full setup/config/tool reference +- [how-memory-works.md](how-memory-works.md) — the write→index→read pipeline +- [api.md](api.md) — the EverOS HTTP API v1 contract this plugin calls +- [configuration.md](configuration.md) — `everos.toml` / env-var reference +- [cli.md](cli.md) — the `everos` CLI, including `everos integrations` diff --git a/docs/index.md b/docs/index.md index 466a2d8f6..345b62b96 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,7 @@ before wiring it into a real workflow. | Doc | Purpose | |---|---| | [everos-demo.md](everos-demo.md) | `everos demo` — local educational TUI to feel the memory lifecycle before configuring keys | +| [hermes-integration.md](hermes-integration.md) | Wire Hermes Agent to EverOS as a long-term memory backend — install, config, lifecycle, tools | ## Reference