Compare commits

...
Author SHA1 Message Date
Nicolò Boschi b6471650f2 test(hermes-smoke): use gpt-4o + realistic content; wait via queue.join
Two improvements to make the smoke test reliable end-to-end:

1. Default model: gpt-4o-mini -> gpt-4o. The smaller model is unreliable
   on Hindsight's fact-extraction structured-output schema — the LLM
   returns ~163 output tokens but an empty `facts` array, so retain
   succeeds (200 OK) yet the bank stays empty and recall surfaces
   nothing. gpt-4o produces consistent extractions for this content.

2. Realistic content: replace synthetic Q&A ("What's my favorite
   language?" / "The user's favorite language is Rust") with a
   first-person user statement, which is what the fact extractor is
   tuned to mine.

Also switch the post-retain wait from `_sync_thread.join(60s)` to
`_retain_queue.join()` — the canonical wait-for-drain idiom under the
plugin's new single-writer model (NousResearch/hermes-agent#17005).
2026-04-28 15:16:00 +02:00
Nicolò Boschi 20b5e03e8b test(integration): add Hermes Agent embedded-mode smoke test
Drives the HindsightMemoryProvider plugin shipped with Hermes Agent against
a locally-spawned Hindsight Embedded daemon, exercising the full
sync_turn -> retain -> recall roundtrip end-to-end through the plugin's
real code path.

Run on demand only (not part of CI) via the installed Hermes venv, which
already has every dep — no new pyproject changes needed:

    HINDSIGHT_LLM_API_KEY=... \
        ~/.hermes/hermes-agent/venv/bin/python -m pytest \
        hindsight-integration-tests/tests/test_hermes_embedded_smoke.py \
        -v -s -o addopts=""

The test uses a temp HERMES_HOME so it never touches the user's real
~/.hermes profile, and tears down its daemon on exit. Skips automatically
when the LLM key (HINDSIGHT_LLM_API_KEY or OPENAI_API_KEY) isn't set or
when ~/.hermes/hermes-agent isn't installed.
2026-04-27 17:09:12 +02:00
2 changed files with 201 additions and 1 deletions
+30 -1
View File
@@ -47,6 +47,33 @@ These tests:
- Run in parallel with other tests (no port conflicts)
- Clean up automatically
### 3. Hermes Agent Smoke Tests
`test_hermes_embedded_smoke.py` drives the Hermes Agent ↔ Hindsight integration
end-to-end through the `HindsightMemoryProvider` plugin in `local_embedded`
mode. Run on demand — not part of CI.
The test reuses the installed Hermes venv (which already has every dep), so
it doesn't need `uv` or this package's lockfile.
**Prerequisites:**
```bash
hermes update # ensure plugin code at ~/.hermes/hermes-agent is current
```
**Running:**
```bash
HINDSIGHT_LLM_API_KEY=sk-... \
~/.hermes/hermes-agent/venv/bin/python -m pytest \
hindsight-integration-tests/tests/test_hermes_embedded_smoke.py \
-v -s -o addopts=""
```
(`-o addopts=""` overrides the `--timeout` flag from this package's pyproject;
`pytest-timeout` is not installed in the hermes venv and isn't needed here.)
Skipped automatically if `HINDSIGHT_LLM_API_KEY` (or `OPENAI_API_KEY`) is not set.
## Running All Tests
```bash
@@ -58,4 +85,6 @@ This runs both types. Self-contained tests won't conflict with the external serv
## Environment Variables
- `HINDSIGHT_API_URL` - Base URL for external-server tests (default: `http://localhost:8888`)
- `HINDSIGHT_API_URL` Base URL for external-server tests (default: `http://localhost:8888`)
- `HINDSIGHT_LLM_API_KEY` / `OPENAI_API_KEY` — required by the Hermes embedded smoke test
- `HINDSIGHT_LLM_PROVIDER`, `HINDSIGHT_LLM_MODEL` — override defaults (`openai` / `gpt-4o`)
@@ -0,0 +1,171 @@
"""Smoke test for the Hermes Agent ↔ Hindsight integration in embedded mode.
Drives the `HindsightMemoryProvider` plugin shipped with Hermes Agent against
a locally-spawned Hindsight Embedded daemon, exercising the full retain →
recall roundtrip end-to-end.
Run on demand via the installed Hermes venv (it already has every dep —
hermes-agent's plugin code, hindsight_embed, hindsight_client, pytest):
HINDSIGHT_LLM_API_KEY=... \
~/.hermes/hermes-agent/venv/bin/python -m pytest \
hindsight-integration-tests/tests/test_hermes_embedded_smoke.py -v -s
Skipped automatically if `HINDSIGHT_LLM_API_KEY` (or `OPENAI_API_KEY`) is not
set, since embedded mode needs an LLM to extract facts during retain.
Defaults: openai / gpt-4o. Override via `HINDSIGHT_LLM_PROVIDER` and
`HINDSIGHT_LLM_MODEL`.
Why not gpt-4o-mini for the default? The smaller model is unreliable on
Hindsight's fact-extraction structured-output schema — observed runs
where the LLM is called (>100 output tokens) but returns an empty
`facts` array, leaving the bank with 0 memory units. Recall then has
nothing to surface and the test flakes. gpt-4o produces consistent
extractions for this content.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import time
import uuid
from pathlib import Path
import pytest
HERMES_VENV_SITE = Path.home() / ".hermes" / "hermes-agent"
LLM_API_KEY = os.environ.get("HINDSIGHT_LLM_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
LLM_PROVIDER = os.environ.get("HINDSIGHT_LLM_PROVIDER", "openai")
LLM_MODEL = os.environ.get("HINDSIGHT_LLM_MODEL", "gpt-4o")
pytestmark = [
pytest.mark.skipif(
not LLM_API_KEY,
reason="HINDSIGHT_LLM_API_KEY (or OPENAI_API_KEY) not set",
),
pytest.mark.skipif(
not (HERMES_VENV_SITE / "plugins" / "memory" / "hindsight" / "__init__.py").exists(),
reason=f"Hermes plugin not found at {HERMES_VENV_SITE} — run `hermes update` first",
),
]
@pytest.fixture(scope="module")
def hermes_path():
"""Make the installed hermes-agent importable in this process."""
if str(HERMES_VENV_SITE) not in sys.path:
sys.path.insert(0, str(HERMES_VENV_SITE))
@pytest.fixture
def embedded_provider(tmp_path, monkeypatch, hermes_path):
"""Spin up a HindsightMemoryProvider in local_embedded mode.
Uses a temp HERMES_HOME so we never touch the user's real ~/.hermes.
The Hindsight daemon stores its data under that temp dir too.
"""
profile_name = f"hermes-smoke-{uuid.uuid4().hex[:8]}"
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HINDSIGHT_LLM_API_KEY", LLM_API_KEY)
config_dir = tmp_path / "hindsight"
config_dir.mkdir(parents=True, exist_ok=True)
config = {
"mode": "local_embedded",
"profile": profile_name,
"llm_provider": LLM_PROVIDER,
"llm_model": LLM_MODEL,
"llm_api_key": LLM_API_KEY,
"bank_id": f"smoke-{uuid.uuid4().hex[:8]}",
"recall_budget": "low",
"auto_retain": True,
"auto_recall": True,
"retain_async": False,
"retain_every_n_turns": 1,
}
(config_dir / "config.json").write_text(json.dumps(config, indent=2))
from plugins.memory.hindsight import HindsightMemoryProvider
provider = HindsightMemoryProvider()
provider.initialize(session_id=f"smoke-{uuid.uuid4().hex[:8]}", platform="cli")
# The plugin starts the daemon on a background thread. Force a synchronous
# boot here so the test isn't racing it. First-run setup can take ~2 min
# because the embedded daemon installs its own deps into a profile venv.
deadline = time.time() + 240.0
last_err: Exception | None = None
while time.time() < deadline:
try:
client = provider._get_client()
client._ensure_started()
if client.is_running:
break
except Exception as exc:
last_err = exc
time.sleep(2.0)
else:
provider.shutdown()
pytest.fail(f"Hindsight embedded daemon did not start within 240s (last error: {last_err!r})")
yield provider
try:
provider.shutdown()
except Exception:
pass
try:
from hindsight_embed.daemon_embed_manager import DaemonEmbedManager
DaemonEmbedManager().stop(profile_name)
except Exception:
pass
def test_retain_then_recall_roundtrip(embedded_provider):
"""Store a memorable fact, then verify recall finds it.
This exercises the full Hermes plugin path: sync_turn -> aretain_batch ->
daemon -> LLM fact extraction -> indexing -> recall -> prefetch.
Content style matters: the fact extractor mines first-person statements
the user makes about themselves. Synthetic Q&A where the assistant
asserts a fact about the user yields 0 extracted units and an empty
bank, even though the HTTP retain returns 200. Use a natural turn
where the user states the preference directly.
"""
embedded_provider.sync_turn(
user_content=(
"I've been writing Rust for the past three years and it's easily "
"my favorite programming language — the borrow checker just clicks "
"for me."
),
assistant_content=(
"Got it — I'll remember that Rust is your favorite language."
),
)
# Wait for the writer thread to drain the queued retain. Bounded by
# _DEFAULT_TIMEOUT (120s) inside the plugin per request.
embedded_provider._retain_queue.join()
deadline = time.time() + 60.0
last_result = ""
while time.time() < deadline:
embedded_provider._prefetch_result = ""
embedded_provider.queue_prefetch("favorite programming language")
if embedded_provider._prefetch_thread:
embedded_provider._prefetch_thread.join(timeout=30.0)
last_result = embedded_provider._prefetch_result
if "rust" in last_result.lower():
return
time.sleep(2.0)
pytest.fail(
f"recall did not surface the stored fact within 60s. "
f"Last prefetch result: {last_result!r}"
)