fix(hermes): use async client methods to prevent event loop deadlock (#677) (#681)

Tool handlers and lifecycle hooks now use the native async client API
(aretain, arecall, areflect, acreate_bank) instead of sync wrappers
that call loop.run_until_complete(), which deadlocks in async contexts
like Discord/Telegram gateways.
This commit is contained in:
Nicolò Boschi 2026-03-25 11:25:06 +01:00 committed by GitHub
parent 0bcbf8491b
commit 35dfd3aa0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1079 additions and 827 deletions

View file

@ -4,7 +4,12 @@ Hindsight memory integration for [Hermes Agent](https://github.com/NousResearch/
## What it does
This package registers three tools into Hermes via its plugin system:
**Automatic memory on every turn** — no tool calls required:
- **`pre_llm_call` hook** — Before each LLM call, recalls relevant memories and injects them into the system prompt. The model sees cross-session context automatically.
- **`post_llm_call` hook** — After each turn, retains the user/assistant exchange so it can be recalled in future sessions.
**Three explicit tools** for when the model wants direct control:
- **`hindsight_retain`** — Stores information to long-term memory. Hermes calls this when the user shares facts, preferences, or anything worth remembering.
- **`hindsight_recall`** — Searches long-term memory for relevant information. Returns a numbered list of matching memories.
@ -12,6 +17,8 @@ This package registers three tools into Hermes via its plugin system:
These tools appear under the `[hindsight]` toolset in Hermes's `/tools` list.
> **Note:** The lifecycle hooks require hermes-agent with [PR #2823](https://github.com/NousResearch/hermes-agent/pull/2823) or later. On older versions, only the tools are registered — hooks are silently skipped.
## Setup
### 1. Install hindsight-hermes into the Hermes venv
@ -230,7 +237,10 @@ This exposes the same retain/recall/reflect operations through Hermes's MCP inte
| `api_key` | `HINDSIGHT_API_KEY` | — | API key for authentication |
| `bank_id` | `HINDSIGHT_BANK_ID` | — | Memory bank ID |
| `budget` | `HINDSIGHT_BUDGET` | `mid` | Recall budget (low/mid/high) |
| `max_tokens` | — | `4096` | Max tokens for recall results |
| — | `HINDSIGHT_AUTO_RETAIN` | `true` | Auto-retain conversation turns via `post_llm_call` hook |
| — | `HINDSIGHT_RECALL_BUDGET` | same as `budget` | Budget for the `pre_llm_call` recall hook |
| — | `HINDSIGHT_RECALL_MAX_TOKENS` | `4096` | Max tokens for the `pre_llm_call` recall hook |
| `max_tokens` | — | `4096` | Max tokens for recall results (tools) |
| `tags` | — | — | Tags applied when storing memories |
| `recall_tags` | — | — | Tags to filter recall results |
| `recall_tags_match` | — | `any` | Tag matching mode (any/all/any_strict/all_strict) |

View file

@ -1,7 +1,13 @@
"""Hindsight-Hermes: Persistent memory tools for Hermes agents.
"""Hindsight-Hermes: Persistent memory for Hermes agents.
Provides Hindsight retain/recall/reflect as native Hermes tools via the
plugin system or manual ``register_tools()`` call.
plugin system or manual ``register_tools()`` call. When running on a
Hermes build that supports lifecycle hooks, the plugin also:
- **pre_llm_call** recalls relevant memories and injects them into the
system prompt so the model has cross-session context on every turn.
- **post_llm_call** retains the user/assistant exchange so it can be
recalled in future sessions.
Plugin usage (auto-discovery)::

View file

@ -180,29 +180,29 @@ def register_tools(
resolved_client = _resolve_client(client, hindsight_api_url, api_key)
created_banks: set[str] = set()
def _ensure_bank(bid: str) -> None:
async def _ensure_bank(bid: str) -> None:
if bid in created_banks:
return
try:
resolved_client.create_bank(bank_id=bid, name=bid)
await resolved_client.acreate_bank(bank_id=bid, name=bid)
created_banks.add(bid)
except Exception:
created_banks.add(bid)
def handle_retain(args: dict[str, Any], **kwargs: Any) -> str:
async def handle_retain(args: dict[str, Any], **kwargs: Any) -> str:
try:
bid = _resolve_bank_id(args, bank_id, bank_resolver)
_ensure_bank(bid)
await _ensure_bank(bid)
retain_kwargs: dict[str, Any] = {"bank_id": bid, "content": args["content"]}
if tags:
retain_kwargs["tags"] = tags
resolved_client.retain(**retain_kwargs)
await resolved_client.aretain(**retain_kwargs)
return json.dumps({"result": "Memory stored successfully."})
except Exception as e:
logger.error(f"Retain failed: {e}")
return json.dumps({"error": str(e)})
def handle_recall(args: dict[str, Any], **kwargs: Any) -> str:
async def handle_recall(args: dict[str, Any], **kwargs: Any) -> str:
try:
bid = _resolve_bank_id(args, bank_id, bank_resolver)
recall_kwargs: dict[str, Any] = {
@ -214,7 +214,7 @@ def register_tools(
if recall_tags:
recall_kwargs["tags"] = recall_tags
recall_kwargs["tags_match"] = recall_tags_match
response = resolved_client.recall(**recall_kwargs)
response = await resolved_client.arecall(**recall_kwargs)
if not response.results:
return json.dumps({"result": "No relevant memories found."})
lines = []
@ -225,7 +225,7 @@ def register_tools(
logger.error(f"Recall failed: {e}")
return json.dumps({"error": str(e)})
def handle_reflect(args: dict[str, Any], **kwargs: Any) -> str:
async def handle_reflect(args: dict[str, Any], **kwargs: Any) -> str:
try:
bid = _resolve_bank_id(args, bank_id, bank_resolver)
reflect_kwargs: dict[str, Any] = {
@ -233,7 +233,7 @@ def register_tools(
"query": args["query"],
"budget": budget,
}
response = resolved_client.reflect(**reflect_kwargs)
response = await resolved_client.areflect(**reflect_kwargs)
return json.dumps(
{"result": response.text or "No relevant memories found."}
)
@ -284,29 +284,29 @@ def register(ctx: Any) -> None:
resolved_client = _resolve_client(None, hindsight_api_url, api_key)
created_banks: set[str] = set()
def _ensure_bank(bid: str) -> None:
async def _ensure_bank(bid: str) -> None:
if bid in created_banks:
return
try:
resolved_client.create_bank(bank_id=bid, name=bid)
await resolved_client.acreate_bank(bank_id=bid, name=bid)
created_banks.add(bid)
except Exception:
created_banks.add(bid)
def handle_retain(args: dict[str, Any], **kwargs: Any) -> str:
async def handle_retain(args: dict[str, Any], **kwargs: Any) -> str:
try:
bid = _resolve_bank_id(args, bank_id, None)
_ensure_bank(bid)
resolved_client.retain(bank_id=bid, content=args["content"])
await _ensure_bank(bid)
await resolved_client.aretain(bank_id=bid, content=args["content"])
return json.dumps({"result": "Memory stored successfully."})
except Exception as e:
logger.error(f"Retain failed: {e}")
return json.dumps({"error": str(e)})
def handle_recall(args: dict[str, Any], **kwargs: Any) -> str:
async def handle_recall(args: dict[str, Any], **kwargs: Any) -> str:
try:
bid = _resolve_bank_id(args, bank_id, None)
response = resolved_client.recall(
response = await resolved_client.arecall(
bank_id=bid, query=args["query"], budget=budget
)
if not response.results:
@ -317,10 +317,10 @@ def register(ctx: Any) -> None:
logger.error(f"Recall failed: {e}")
return json.dumps({"error": str(e)})
def handle_reflect(args: dict[str, Any], **kwargs: Any) -> str:
async def handle_reflect(args: dict[str, Any], **kwargs: Any) -> str:
try:
bid = _resolve_bank_id(args, bank_id, None)
response = resolved_client.reflect(
response = await resolved_client.areflect(
bank_id=bid, query=args["query"], budget=budget
)
return json.dumps(
@ -349,6 +349,72 @@ def register(ctx: Any) -> None:
handler=handle_reflect,
)
# ── Lifecycle hooks ──────────────────────────────────────────────────
# These require hermes-agent ≥ the version that invokes pre/post_llm_call.
# When running on an older hermes-agent the hooks are simply never called,
# so registering them is always safe.
recall_budget = os.environ.get("HINDSIGHT_RECALL_BUDGET", budget)
recall_max_tokens = int(os.environ.get("HINDSIGHT_RECALL_MAX_TOKENS", "4096"))
retain_enabled = os.environ.get("HINDSIGHT_AUTO_RETAIN", "true").lower() in {"1", "true", "yes", "on"}
async def _on_pre_llm_call(
*,
session_id: str = "",
user_message: str = "",
conversation_history: list | None = None,
is_first_turn: bool = False,
model: str = "",
**kwargs: Any,
) -> dict[str, str] | None:
"""Recall relevant memories and inject them as system prompt context."""
if not user_message or not bank_id:
return None
try:
await _ensure_bank(bank_id)
response = await resolved_client.arecall(
bank_id=bank_id,
query=user_message,
budget=recall_budget,
max_tokens=recall_max_tokens,
)
if not response.results:
return None
lines = [f"- {r.text}" for r in response.results]
context = (
"# Hindsight Memory (persistent cross-session context)\n"
"Use this to answer questions about the user and prior sessions. "
"Do not call tools to look up information that is already present here.\n\n"
+ "\n".join(lines)
)
return {"context": context}
except Exception as exc:
logger.warning("Hindsight pre_llm_call recall failed: %s", exc)
return None
async def _on_post_llm_call(
*,
session_id: str = "",
user_message: str = "",
assistant_response: str = "",
model: str = "",
**kwargs: Any,
) -> None:
"""Retain the conversation turn so it can be recalled in future sessions."""
if not retain_enabled or not bank_id:
return
if not user_message or not assistant_response:
return
try:
await _ensure_bank(bank_id)
content = f"User: {user_message}\nAssistant: {assistant_response}"
await resolved_client.aretain(bank_id=bank_id, content=content)
except Exception as exc:
logger.warning("Hindsight post_llm_call retain failed: %s", exc)
ctx.register_hook("pre_llm_call", _on_pre_llm_call)
ctx.register_hook("post_llm_call", _on_post_llm_call)
def memory_instructions(
*,

View file

@ -33,6 +33,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.23.0",
]
[project.entry-points."hermes_agent.plugins"]
@ -52,8 +53,10 @@ packages = ["hindsight_hermes"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-asyncio>=0.23.0",
]

View file

@ -3,7 +3,7 @@
import json
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -35,6 +35,7 @@ def _clean_config():
@pytest.fixture()
def mock_client():
client = MagicMock()
# Sync methods (used by memory_instructions and register_tools sync path)
client.create_bank = MagicMock()
client.retain = MagicMock()
client.recall = MagicMock(
@ -46,6 +47,18 @@ def mock_client():
)
)
client.reflect = MagicMock(return_value=SimpleNamespace(text="Synthesized answer"))
# Async methods (used by tool handlers and hooks)
client.acreate_bank = AsyncMock()
client.aretain = AsyncMock()
client.arecall = AsyncMock(
return_value=SimpleNamespace(
results=[
SimpleNamespace(text="Memory 1"),
SimpleNamespace(text="Memory 2"),
]
)
)
client.areflect = AsyncMock(return_value=SimpleNamespace(text="Synthesized answer"))
return client
@ -133,60 +146,68 @@ class TestRegisterTools:
names = {call.kwargs["name"] for call in mock_registry.register.call_args_list}
assert names == {"hindsight_retain", "hindsight_recall", "hindsight_reflect"}
def test_retain_handler_success(self, mock_client, mock_registry):
@pytest.mark.asyncio
async def test_retain_handler_success(self, mock_client, mock_registry):
register_tools(bank_id="b", client=mock_client)
handler = mock_registry.register.call_args_list[0].kwargs["handler"]
result = json.loads(handler({"content": "hello"}))
result = json.loads(await handler({"content": "hello"}))
assert result["result"] == "Memory stored successfully."
mock_client.retain.assert_called_once_with(bank_id="b", content="hello")
mock_client.aretain.assert_called_once_with(bank_id="b", content="hello")
def test_retain_handler_with_tags(self, mock_client, mock_registry):
@pytest.mark.asyncio
async def test_retain_handler_with_tags(self, mock_client, mock_registry):
register_tools(bank_id="b", client=mock_client, tags=["tag1"])
handler = mock_registry.register.call_args_list[0].kwargs["handler"]
handler({"content": "hello"})
mock_client.retain.assert_called_once_with(bank_id="b", content="hello", tags=["tag1"])
await handler({"content": "hello"})
mock_client.aretain.assert_called_once_with(bank_id="b", content="hello", tags=["tag1"])
def test_recall_handler_success(self, mock_client, mock_registry):
@pytest.mark.asyncio
async def test_recall_handler_success(self, mock_client, mock_registry):
register_tools(bank_id="b", client=mock_client)
handler = mock_registry.register.call_args_list[1].kwargs["handler"]
result = json.loads(handler({"query": "test"}))
result = json.loads(await handler({"query": "test"}))
assert "Memory 1" in result["result"]
assert "Memory 2" in result["result"]
def test_recall_handler_no_results(self, mock_client, mock_registry):
mock_client.recall.return_value = SimpleNamespace(results=[])
@pytest.mark.asyncio
async def test_recall_handler_no_results(self, mock_client, mock_registry):
mock_client.arecall.return_value = SimpleNamespace(results=[])
register_tools(bank_id="b", client=mock_client)
handler = mock_registry.register.call_args_list[1].kwargs["handler"]
result = json.loads(handler({"query": "test"}))
result = json.loads(await handler({"query": "test"}))
assert result["result"] == "No relevant memories found."
def test_reflect_handler_success(self, mock_client, mock_registry):
@pytest.mark.asyncio
async def test_reflect_handler_success(self, mock_client, mock_registry):
register_tools(bank_id="b", client=mock_client)
handler = mock_registry.register.call_args_list[2].kwargs["handler"]
result = json.loads(handler({"query": "test"}))
result = json.loads(await handler({"query": "test"}))
assert result["result"] == "Synthesized answer"
def test_handler_returns_error_on_exception(self, mock_client, mock_registry):
mock_client.retain.side_effect = RuntimeError("boom")
@pytest.mark.asyncio
async def test_handler_returns_error_on_exception(self, mock_client, mock_registry):
mock_client.aretain.side_effect = RuntimeError("boom")
register_tools(bank_id="b", client=mock_client)
handler = mock_registry.register.call_args_list[0].kwargs["handler"]
result = json.loads(handler({"content": "hello"}))
result = json.loads(await handler({"content": "hello"}))
assert "error" in result
assert "boom" in result["error"]
def test_ensure_bank_called(self, mock_client, mock_registry):
@pytest.mark.asyncio
async def test_ensure_bank_called(self, mock_client, mock_registry):
register_tools(bank_id="b", client=mock_client)
handler = mock_registry.register.call_args_list[0].kwargs["handler"]
handler({"content": "hello"})
mock_client.create_bank.assert_called_once_with(bank_id="b", name="b")
await handler({"content": "hello"})
mock_client.acreate_bank.assert_called_once_with(bank_id="b", name="b")
def test_ensure_bank_idempotent(self, mock_client, mock_registry):
@pytest.mark.asyncio
async def test_ensure_bank_idempotent(self, mock_client, mock_registry):
register_tools(bank_id="b", client=mock_client)
handler = mock_registry.register.call_args_list[0].kwargs["handler"]
handler({"content": "first"})
handler({"content": "second"})
# create_bank should only be called once
mock_client.create_bank.assert_called_once()
await handler({"content": "first"})
await handler({"content": "second"})
# acreate_bank should only be called once
mock_client.acreate_bank.assert_called_once()
# --- register (plugin entry point) tests ---
@ -208,6 +229,123 @@ class TestRegisterPlugin:
register(ctx)
ctx.register_tool.assert_not_called()
def test_register_hooks(self, monkeypatch, mock_client):
monkeypatch.setenv("HINDSIGHT_API_URL", "http://localhost:8888")
monkeypatch.setenv("HINDSIGHT_BANK_ID", "test-bank")
ctx = MagicMock()
with patch("hindsight_hermes.tools._resolve_client", return_value=mock_client):
register(ctx)
hook_names = {call.args[0] for call in ctx.register_hook.call_args_list}
assert hook_names == {"pre_llm_call", "post_llm_call"}
# --- lifecycle hook tests ---
class TestLifecycleHooks:
"""Tests for pre_llm_call and post_llm_call hook callbacks."""
def _get_hook(self, ctx_mock, hook_name: str):
"""Extract the registered hook callback by name from the mock ctx."""
for call in ctx_mock.register_hook.call_args_list:
if call.args[0] == hook_name:
return call.args[1]
raise AssertionError(f"Hook {hook_name!r} not registered")
def _register_with_hooks(self, monkeypatch, mock_client, **env_overrides):
monkeypatch.setenv("HINDSIGHT_API_URL", "http://localhost:8888")
monkeypatch.setenv("HINDSIGHT_BANK_ID", "test-bank")
for k, v in env_overrides.items():
monkeypatch.setenv(k, v)
ctx = MagicMock()
with patch("hindsight_hermes.tools._resolve_client", return_value=mock_client):
register(ctx)
return ctx
# -- pre_llm_call --
@pytest.mark.asyncio
async def test_pre_llm_call_returns_context(self, monkeypatch, mock_client):
ctx = self._register_with_hooks(monkeypatch, mock_client)
hook = self._get_hook(ctx, "pre_llm_call")
result = await hook(
session_id="s1",
user_message="what color do I like?",
conversation_history=[],
is_first_turn=True,
model="test",
)
assert result is not None
assert "context" in result
assert "Memory 1" in result["context"]
assert "Memory 2" in result["context"]
mock_client.arecall.assert_called_once()
@pytest.mark.asyncio
async def test_pre_llm_call_returns_none_on_no_results(self, monkeypatch, mock_client):
mock_client.arecall.return_value = SimpleNamespace(results=[])
ctx = self._register_with_hooks(monkeypatch, mock_client)
hook = self._get_hook(ctx, "pre_llm_call")
result = await hook(user_message="hello")
assert result is None
@pytest.mark.asyncio
async def test_pre_llm_call_returns_none_on_empty_message(self, monkeypatch, mock_client):
ctx = self._register_with_hooks(monkeypatch, mock_client)
hook = self._get_hook(ctx, "pre_llm_call")
result = await hook(user_message="")
assert result is None
mock_client.arecall.assert_not_called()
@pytest.mark.asyncio
async def test_pre_llm_call_returns_none_on_error(self, monkeypatch, mock_client):
mock_client.arecall.side_effect = RuntimeError("connection failed")
ctx = self._register_with_hooks(monkeypatch, mock_client)
hook = self._get_hook(ctx, "pre_llm_call")
result = await hook(user_message="hello")
assert result is None
# -- post_llm_call --
@pytest.mark.asyncio
async def test_post_llm_call_retains_turn(self, monkeypatch, mock_client):
ctx = self._register_with_hooks(monkeypatch, mock_client)
hook = self._get_hook(ctx, "post_llm_call")
await hook(
session_id="s1",
user_message="remember I like green",
assistant_response="Got it, you like green!",
model="test",
)
mock_client.aretain.assert_called_once()
content = mock_client.aretain.call_args.kwargs["content"]
assert "remember I like green" in content
assert "Got it, you like green!" in content
@pytest.mark.asyncio
async def test_post_llm_call_skips_empty_messages(self, monkeypatch, mock_client):
ctx = self._register_with_hooks(monkeypatch, mock_client)
hook = self._get_hook(ctx, "post_llm_call")
await hook(user_message="", assistant_response="hello")
mock_client.aretain.assert_not_called()
@pytest.mark.asyncio
async def test_post_llm_call_skips_when_disabled(self, monkeypatch, mock_client):
ctx = self._register_with_hooks(
monkeypatch, mock_client, HINDSIGHT_AUTO_RETAIN="false"
)
hook = self._get_hook(ctx, "post_llm_call")
await hook(user_message="hi", assistant_response="hello")
mock_client.aretain.assert_not_called()
@pytest.mark.asyncio
async def test_post_llm_call_does_not_raise_on_error(self, monkeypatch, mock_client):
mock_client.aretain.side_effect = RuntimeError("boom")
ctx = self._register_with_hooks(monkeypatch, mock_client)
hook = self._get_hook(ctx, "post_llm_call")
# Should not raise
await hook(user_message="hi", assistant_response="hello")
# --- memory_instructions tests ---

File diff suppressed because it is too large Load diff