fleet-memory/hindsight-integrations/hermes/hindsight_hermes/tools.py
Nicolò Boschi e7c9a6832d
fix(hermes): sync lifecycle hooks for hermes-agent 0.5.0 (#741)
* fix(hermes): convert lifecycle hooks to sync for hermes-agent 0.5.0 compatibility

hermes-agent 0.5.0 calls plugin hooks synchronously via invoke_hook(),
but our pre_llm_call/post_llm_call were async — coroutines were never
awaited, so recall context injection and auto-retain silently did nothing.

Switch hooks to sync client methods and add integration tests using
the real hermes-agent PluginManager.

* fix(hermes): use proper hermes-agent dep with uv source override

Replace inline git URL with standard `hermes-agent>=0.5.0` version
constraint plus `[tool.uv.sources]` to resolve from the git tag until
0.5.0 lands on PyPI.
2026-03-30 10:44:54 +02:00

504 lines
17 KiB
Python

"""Hermes tool definitions and registration for Hindsight memory operations.
Provides retain/recall/reflect as native Hermes tools via the plugin system
or manual ``register_tools()`` call.
"""
from __future__ import annotations
import json
import logging
import os
from collections.abc import Callable
from typing import Any
from hindsight_client import Hindsight
from .config import load_config
from .errors import HindsightError
logger = logging.getLogger(__name__)
_TOOL_INSTRUCTIONS = """\
You have access to long-term memory via Hindsight tools.
- Use `hindsight_retain` to save important facts, user preferences, decisions, \
or any information that should be remembered across conversations.
- Use `hindsight_recall` to search for previously stored facts, preferences, or context.
- Use `hindsight_reflect` to synthesize a thoughtful, reasoned answer from \
what you know, rather than raw memory facts.
Proactively store information the user shares that may be useful later. \
When answering questions, check memory first for relevant context.\
"""
RETAIN_SCHEMA = {
"name": "hindsight_retain",
"description": (
"Store information to long-term memory for later retrieval. "
"Use this to save important facts, user preferences, decisions, "
"or any information that should be remembered across conversations."
),
"parameters": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The information to store in memory.",
},
},
"required": ["content"],
},
}
RECALL_SCHEMA = {
"name": "hindsight_recall",
"description": (
"Search long-term memory for relevant information. "
"Use this to find previously stored facts, preferences, or context."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query to find relevant memories.",
},
},
"required": ["query"],
},
}
REFLECT_SCHEMA = {
"name": "hindsight_reflect",
"description": (
"Synthesize a thoughtful answer from long-term memories. "
"Use this when you need a coherent summary or reasoned response "
"about what you know, rather than raw memory facts."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The question to reflect on using stored memories.",
},
},
"required": ["query"],
},
}
def _resolve_client(
client: Hindsight | None,
hindsight_api_url: str | None,
api_key: str | None,
) -> Hindsight:
"""Resolve a Hindsight client from explicit args."""
if client is not None:
return client
if hindsight_api_url is None:
raise HindsightError(
"No Hindsight API URL configured. "
"Pass client= or hindsight_api_url=, or create ~/.hindsight/hermes.json."
)
kwargs: dict[str, Any] = {"base_url": hindsight_api_url, "timeout": 30.0}
if api_key:
kwargs["api_key"] = api_key
return Hindsight(**kwargs)
def _resolve_bank_id(
args: dict[str, Any],
bank_id: str | None,
bank_resolver: Callable[[dict[str, Any]], str] | None,
) -> str:
"""Resolve the effective bank_id for an operation.
Resolution order:
1. bank_resolver(args) if set
2. Static bank_id if set
3. HINDSIGHT_BANK_ID env var
4. Raise HindsightError
"""
if bank_resolver is not None:
return bank_resolver(args)
if bank_id is not None:
return bank_id
env_bank = os.environ.get("HINDSIGHT_BANK_ID")
if env_bank:
return env_bank
raise HindsightError(
"No bank_id available. Provide bank_id=, bank_resolver=, "
"or set the HINDSIGHT_BANK_ID environment variable."
)
def register_tools(
*,
bank_id: str | None = None,
bank_resolver: Callable[[dict[str, Any]], str] | None = None,
client: Hindsight | None = None,
hindsight_api_url: str | None = None,
api_key: str | None = None,
budget: str = "mid",
max_tokens: int = 4096,
tags: list[str] | None = None,
recall_tags: list[str] | None = None,
recall_tags_match: str = "any",
toolset: str = "hindsight",
) -> None:
"""Register Hindsight memory tools into the Hermes tool registry.
This imports ``tools.registry`` lazily so that hermes-agent is not a
hard dependency of the package.
Args:
bank_id: Static memory bank ID.
bank_resolver: Callable that resolves bank_id from tool args dict.
client: Pre-configured Hindsight client.
hindsight_api_url: API URL (used if no client provided).
api_key: API key (used if no client provided).
budget: Recall/reflect budget level (low/mid/high).
max_tokens: Maximum tokens for recall results.
tags: Tags applied when storing memories via retain.
recall_tags: Tags to filter when searching memories.
recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
toolset: Hermes toolset name for grouping.
"""
from tools.registry import registry # type: ignore[import-untyped]
resolved_client = _resolve_client(client, hindsight_api_url, api_key)
created_banks: set[str] = set()
async def _ensure_bank(bid: str) -> None:
if bid in created_banks:
return
try:
await resolved_client.acreate_bank(bank_id=bid, name=bid)
created_banks.add(bid)
except Exception:
created_banks.add(bid)
async def handle_retain(args: dict[str, Any], **kwargs: Any) -> str:
try:
bid = _resolve_bank_id(args, bank_id, bank_resolver)
await _ensure_bank(bid)
retain_kwargs: dict[str, Any] = {"bank_id": bid, "content": args["content"]}
if tags:
retain_kwargs["tags"] = tags
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)})
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] = {
"bank_id": bid,
"query": args["query"],
"budget": budget,
"max_tokens": max_tokens,
}
if recall_tags:
recall_kwargs["tags"] = recall_tags
recall_kwargs["tags_match"] = recall_tags_match
response = await resolved_client.arecall(**recall_kwargs)
if not response.results:
return json.dumps({"result": "No relevant memories found."})
lines = []
for i, result in enumerate(response.results, 1):
lines.append(f"{i}. {result.text}")
return json.dumps({"result": "\n".join(lines)})
except Exception as e:
logger.error(f"Recall failed: {e}")
return json.dumps({"error": str(e)})
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] = {
"bank_id": bid,
"query": args["query"],
"budget": budget,
}
response = await resolved_client.areflect(**reflect_kwargs)
return json.dumps(
{"result": response.text or "No relevant memories found."}
)
except Exception as e:
logger.error(f"Reflect failed: {e}")
return json.dumps({"error": str(e)})
registry.register(
name="hindsight_retain",
toolset=toolset,
schema=RETAIN_SCHEMA,
handler=handle_retain,
)
registry.register(
name="hindsight_recall",
toolset=toolset,
schema=RECALL_SCHEMA,
handler=handle_recall,
)
registry.register(
name="hindsight_reflect",
toolset=toolset,
schema=REFLECT_SCHEMA,
handler=handle_reflect,
)
def register(ctx: Any) -> None:
"""Hermes plugin entry point — called via ``hermes_agent.plugins`` entry point.
Reads configuration from ``~/.hindsight/hermes.json`` and environment
variables (env vars take priority), then registers tools and hooks.
Args:
ctx: Hermes PluginContext.
"""
cfg = load_config()
hindsight_api_url = cfg.get("hindsightApiUrl")
api_key = cfg.get("hindsightApiToken")
bank_id = cfg.get("bankId")
budget = cfg.get("recallBudget", "mid")
if not hindsight_api_url and not api_key:
logger.debug(
"Hindsight plugin: not configured (need hindsightApiUrl or hindsightApiToken). "
"Create ~/.hindsight/hermes.json or set HINDSIGHT_API_URL."
)
return
resolved_client = _resolve_client(None, hindsight_api_url, api_key)
created_banks: set[str] = set()
async def _ensure_bank(bid: str) -> None:
if bid in created_banks:
return
try:
await resolved_client.acreate_bank(bank_id=bid, name=bid)
created_banks.add(bid)
except Exception:
created_banks.add(bid)
async def handle_retain(args: dict[str, Any], **kwargs: Any) -> str:
try:
bid = _resolve_bank_id(args, bank_id, None)
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)})
async def handle_recall(args: dict[str, Any], **kwargs: Any) -> str:
try:
bid = _resolve_bank_id(args, bank_id, None)
response = await resolved_client.arecall(
bank_id=bid, query=args["query"], budget=budget
)
if not response.results:
return json.dumps({"result": "No relevant memories found."})
lines = [f"{i}. {r.text}" for i, r in enumerate(response.results, 1)]
return json.dumps({"result": "\n".join(lines)})
except Exception as e:
logger.error(f"Recall failed: {e}")
return json.dumps({"error": str(e)})
async def handle_reflect(args: dict[str, Any], **kwargs: Any) -> str:
try:
bid = _resolve_bank_id(args, bank_id, None)
response = await resolved_client.areflect(
bank_id=bid, query=args["query"], budget=budget
)
return json.dumps(
{"result": response.text or "No relevant memories found."}
)
except Exception as e:
logger.error(f"Reflect failed: {e}")
return json.dumps({"error": str(e)})
ctx.register_tool(
name="hindsight_retain",
toolset="hindsight",
schema=RETAIN_SCHEMA,
handler=handle_retain,
)
ctx.register_tool(
name="hindsight_recall",
toolset="hindsight",
schema=RECALL_SCHEMA,
handler=handle_recall,
)
ctx.register_tool(
name="hindsight_reflect",
toolset="hindsight",
schema=REFLECT_SCHEMA,
handler=handle_reflect,
)
# ── Lifecycle hooks ──────────────────────────────────────────────────
# These require hermes-agent ≥ 0.5.0 which invokes pre/post_llm_call.
# On older hermes-agent the hooks are simply never called, so
# registering them is always safe.
#
# IMPORTANT: hermes-agent calls hooks synchronously via invoke_hook(),
# so these must be sync functions. We use the sync client methods
# (recall / retain / create_bank) rather than the async variants.
recall_budget = cfg.get("recallBudget", budget)
recall_max_tokens = cfg.get("recallMaxTokens", 4096)
retain_enabled = cfg.get("autoRetain", True)
recall_preamble = cfg.get("recallPromptPreamble", "")
created_banks_sync: set[str] = set()
def _ensure_bank_sync(bid: str) -> None:
if bid in created_banks_sync:
return
try:
resolved_client.create_bank(bank_id=bid, name=bid)
created_banks_sync.add(bid)
except Exception:
created_banks_sync.add(bid)
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:
_ensure_bank_sync(bank_id)
response = resolved_client.recall(
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]
header = recall_preamble or (
"# 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."
)
context = header + "\n\n" + "\n".join(lines)
return {"context": context}
except Exception as exc:
logger.warning("Hindsight pre_llm_call recall failed: %s", exc)
return None
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:
_ensure_bank_sync(bank_id)
content = f"User: {user_message}\nAssistant: {assistant_response}"
resolved_client.retain(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(
*,
bank_id: str,
client: Hindsight | None = None,
hindsight_api_url: str | None = None,
api_key: str | None = None,
query: str = "relevant context about the user",
budget: str = "low",
max_results: int = 5,
max_tokens: int = 4096,
prefix: str = "Relevant memories:\n",
tags: list[str] | None = None,
tags_match: str = "any",
) -> str:
"""Pre-recall memories for injection into system prompt.
Performs a sync recall and returns a formatted string of memories.
Silently returns empty string on failure so it never blocks the agent.
Args:
bank_id: The Hindsight memory bank to recall from.
client: Pre-configured Hindsight client (preferred).
hindsight_api_url: API URL (used if no client provided).
api_key: API key (used if no client provided).
query: The recall query to find relevant memories.
budget: Recall budget level (low/mid/high).
max_results: Maximum number of memories to include.
max_tokens: Maximum tokens for recall results.
prefix: Text prepended before the memory list.
tags: Tags to filter recall results.
tags_match: Tag matching mode (any/all/any_strict/all_strict).
Returns:
A formatted string of memories, or empty string if none found.
"""
try:
resolved_client = _resolve_client(client, hindsight_api_url, api_key)
except HindsightError:
return ""
try:
recall_kwargs: dict[str, Any] = {
"bank_id": bank_id,
"query": query,
"budget": budget,
"max_tokens": max_tokens,
}
if tags:
recall_kwargs["tags"] = tags
recall_kwargs["tags_match"] = tags_match
response = resolved_client.recall(**recall_kwargs)
results = response.results[:max_results] if response.results else []
if not results:
return ""
lines = [prefix]
for i, result in enumerate(results, 1):
lines.append(f"{i}. {result.text}")
return "\n".join(lines)
except Exception:
return ""
def get_tool_definitions() -> list[dict[str, Any]]:
"""Return tool schema dicts without registering them.
Useful for inspection or manual integration without importing Hermes.
Returns:
List of OpenAI function-calling format schema dicts.
"""
return [RETAIN_SCHEMA, RECALL_SCHEMA, REFLECT_SCHEMA]