fleet-memory/hindsight-integrations/llamaindex/hindsight_llamaindex/memory.py
DK09876 d93dfea8ce
fix(llamaindex): document_id, memory API, and ReAct trace fixes (#777)
* fix(llamaindex): use uuid for document_id and sync version metadata

- Replace timestamp-based document_id with uuid4 hex to prevent
  collisions on rapid retains (timestamp_ms can duplicate in tight loops)
- Sync __version__ in __init__.py to match pyproject.toml (0.1.2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(docs): pass memory to run() instead of ReActAgent constructor

LlamaIndex 0.14.x ReActAgent does not accept a memory parameter in
its constructor — it's silently dropped via **kwargs. Memory must be
passed to agent.run(memory=...) where AgentWorkflow picks it up.

Also fixes the undefined `tools` variable (now `tools=[]`).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(llamaindex): strip ReAct reasoning traces from retained assistant messages

HindsightMemory.put/aput now extracts only the final Answer: text from
assistant messages containing ReAct reasoning (Thought:/Action:/Observation:
prefixes), preventing internal reasoning traces from polluting long-term memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(llamaindex): fix docstring example to pass memory to run()

The HindsightMemory class docstring showed the broken pattern of passing
memory= to the ReActAgent constructor, which silently drops it. Updated
to show the correct pattern: pass memory to agent.run().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 18:33:03 +02:00

406 lines
15 KiB
Python

"""Hindsight BaseMemory implementation for LlamaIndex.
Provides automatic memory for LlamaIndex agents:
- ``put()`` retains messages to Hindsight for long-term storage
- ``get()`` recalls relevant memories and prepends them as context
- Chat history is kept in-memory for the current session
"""
import logging
import re
import time
import uuid
from typing import Any, Optional
from hindsight_client import Hindsight
from llama_index.core.bridge.pydantic import Field, PrivateAttr
from llama_index.core.llms import ChatMessage, MessageRole
from llama_index.core.memory.types import BaseMemory
logger = logging.getLogger(__name__)
DEFAULT_SYSTEM_PROMPT = (
"Below are relevant memories from previous conversations:\n{memories}\n"
"Use these memories to provide more personalized and contextual responses."
)
# Patterns for detecting ReAct-style reasoning traces in assistant messages
_REACT_PATTERN = re.compile(
r"^(Thought|Action|Action Input|Observation)\s*:", re.MULTILINE
)
_ANSWER_PATTERN = re.compile(r"^Answer\s*:\s*", re.MULTILINE)
class HindsightMemory(BaseMemory):
"""Automatic long-term memory for LlamaIndex agents via Hindsight.
On ``put()``, user and assistant messages are automatically retained
to Hindsight. On ``get()``, relevant memories are recalled and
prepended as a system message to enrich the agent's context.
This follows the same pattern as Mem0's LlamaIndex integration:
a local chat buffer for the current session, with Hindsight
providing cross-session long-term memory.
Args:
bank_id: Hindsight memory bank to operate on.
context: Source label for retain operations.
budget: Recall budget level (low/mid/high).
max_tokens: Maximum tokens for recall results.
tags: Tags applied when storing memories.
recall_tags: Tags to filter when recalling.
recall_tags_match: Tag matching mode.
system_prompt: Template for the memory system message.
Must contain ``{memories}`` placeholder.
chat_history_limit: Max messages to keep in local buffer.
Oldest messages are dropped when exceeded.
Example::
from hindsight_client import Hindsight
from hindsight_llamaindex import HindsightMemory
client = Hindsight(base_url="http://localhost:8888")
memory = HindsightMemory.from_client(
client=client,
bank_id="user-123",
mission="Track user preferences",
)
# Use with any LlamaIndex agent — pass memory to run(), not the constructor
agent = ReActAgent(tools=[], llm=llm)
response = await agent.run("Hello!", memory=memory)
"""
bank_id: str = Field(description="Hindsight memory bank ID")
context: str = Field(default="llamaindex", description="Source label for retain")
budget: str = Field(default="mid", description="Recall budget level")
max_tokens: int = Field(default=4096, description="Max tokens for recall")
tags: Optional[list[str]] = Field(default=None, description="Tags for retain")
recall_tags: Optional[list[str]] = Field(
default=None, description="Tags to filter recall"
)
recall_tags_match: str = Field(default="any", description="Tag matching mode")
system_prompt: str = Field(
default=DEFAULT_SYSTEM_PROMPT, description="Memory system message template"
)
chat_history_limit: int = Field(
default=100, description="Max messages in local buffer"
)
_client: Hindsight = PrivateAttr()
_chat_history: list[ChatMessage] = PrivateAttr(default_factory=list)
_session_id: str = PrivateAttr()
_bank_initialized: bool = PrivateAttr(default=False)
_mission: Optional[str] = PrivateAttr(default=None)
def __init__(self, client: Hindsight, mission: Optional[str] = None, **kwargs: Any):
super().__init__(**kwargs)
self._client = client
self._session_id = str(uuid.uuid4())[:8]
self._mission = mission
self._chat_history = []
self._bank_initialized = False
@classmethod
def class_name(cls) -> str:
return "HindsightMemory"
@classmethod
def from_defaults(cls, **kwargs: Any) -> "HindsightMemory":
"""Create from defaults. Prefer ``from_client()`` instead."""
raise NotImplementedError(
"Use HindsightMemory.from_client() or HindsightMemory.from_url() instead."
)
@classmethod
def from_client(
cls,
client: Hindsight,
bank_id: str,
*,
mission: Optional[str] = None,
context: str = "llamaindex",
budget: str = "mid",
max_tokens: int = 4096,
tags: Optional[list[str]] = None,
recall_tags: Optional[list[str]] = None,
recall_tags_match: str = "any",
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
chat_history_limit: int = 100,
) -> "HindsightMemory":
"""Create a HindsightMemory with a pre-configured client.
Args:
client: Hindsight client instance.
bank_id: Memory bank ID.
mission: Bank mission (creates bank on first use if set).
context: Source label for retain operations.
budget: Recall budget level.
max_tokens: Max recall tokens.
tags: Tags for retain operations.
recall_tags: Tags to filter recall.
recall_tags_match: Tag matching mode.
system_prompt: Memory system message template.
chat_history_limit: Max local buffer size.
"""
return cls(
client=client,
bank_id=bank_id,
mission=mission,
context=context,
budget=budget,
max_tokens=max_tokens,
tags=tags,
recall_tags=recall_tags,
recall_tags_match=recall_tags_match,
system_prompt=system_prompt,
chat_history_limit=chat_history_limit,
)
@classmethod
def from_url(
cls,
hindsight_api_url: str,
bank_id: str,
*,
api_key: Optional[str] = None,
**kwargs: Any,
) -> "HindsightMemory":
"""Create a HindsightMemory from an API URL.
Args:
hindsight_api_url: Hindsight API URL.
bank_id: Memory bank ID.
api_key: Optional API key.
**kwargs: Additional arguments passed to ``from_client()``.
"""
client_kwargs: dict[str, Any] = {"base_url": hindsight_api_url, "timeout": 30.0}
if api_key:
client_kwargs["api_key"] = api_key
client = Hindsight(**client_kwargs)
return cls.from_client(client=client, bank_id=bank_id, **kwargs)
def _ensure_bank(self) -> None:
if self._bank_initialized or not self._mission:
return
try:
self._client.create_bank(
bank_id=self.bank_id,
name=self.bank_id,
mission=self._mission,
)
except Exception as e:
logger.debug(f"Bank creation for {self.bank_id}: {e}")
self._bank_initialized = True
async def _aensure_bank(self) -> None:
if self._bank_initialized or not self._mission:
return
try:
await self._client.acreate_bank(
bank_id=self.bank_id,
name=self.bank_id,
mission=self._mission,
)
except Exception as e:
logger.debug(f"Bank creation for {self.bank_id}: {e}")
self._bank_initialized = True
def _generate_document_id(self) -> str:
return f"{self._session_id}-{uuid.uuid4().hex[:12]}"
@staticmethod
def _extract_clean_content(content: str, role: MessageRole) -> str:
"""Extract clean content from a message, stripping ReAct traces.
For assistant messages containing ReAct reasoning (Thought:/Action:/
Observation: prefixes), extracts only the final Answer: text.
Returns empty string if the message is purely reasoning with no answer.
User messages are returned as-is.
"""
if role != MessageRole.ASSISTANT:
return content
# Check if this looks like ReAct reasoning
if not _REACT_PATTERN.search(content):
return content
# Extract the final Answer: block
answer_match = list(_ANSWER_PATTERN.finditer(content))
if answer_match:
# Use the last Answer: block (final answer after reasoning)
last_answer = answer_match[-1]
return content[last_answer.end():].strip()
# ReAct traces with no Answer: — skip retention
return ""
def _retain_message(self, message: ChatMessage) -> None:
"""Retain a message to Hindsight (sync)."""
if message.role not in (MessageRole.USER, MessageRole.ASSISTANT):
return
content = str(message.content) if message.content else ""
content = self._extract_clean_content(content, message.role)
if not content.strip():
return
try:
self._ensure_bank()
kwargs: dict[str, Any] = {
"bank_id": self.bank_id,
"content": content,
"context": self.context,
"document_id": self._generate_document_id(),
"metadata": {"role": message.role.value, "source": "llamaindex"},
}
if self.tags:
kwargs["tags"] = self.tags
self._client.retain(**kwargs)
except Exception as e:
logger.error(f"Failed to retain message: {e}")
async def _aretain_message(self, message: ChatMessage) -> None:
"""Retain a message to Hindsight (async)."""
if message.role not in (MessageRole.USER, MessageRole.ASSISTANT):
return
content = str(message.content) if message.content else ""
content = self._extract_clean_content(content, message.role)
if not content.strip():
return
try:
await self._aensure_bank()
kwargs: dict[str, Any] = {
"bank_id": self.bank_id,
"content": content,
"context": self.context,
"document_id": self._generate_document_id(),
"metadata": {"role": message.role.value, "source": "llamaindex"},
}
if self.tags:
kwargs["tags"] = self.tags
await self._client.aretain(**kwargs)
except Exception as e:
logger.error(f"Failed to retain message: {e}")
def _recall_memories(self, query: str) -> str:
"""Recall relevant memories (sync)."""
try:
self._ensure_bank()
kwargs: dict[str, Any] = {
"bank_id": self.bank_id,
"query": query,
"budget": self.budget,
"max_tokens": self.max_tokens,
}
if self.recall_tags:
kwargs["tags"] = self.recall_tags
kwargs["tags_match"] = self.recall_tags_match
response = self._client.recall(**kwargs)
if not response.results:
return ""
lines = [r.text for r in response.results]
return "\n".join(f"- {line}" for line in lines)
except Exception as e:
logger.error(f"Failed to recall memories: {e}")
return ""
async def _arecall_memories(self, query: str) -> str:
"""Recall relevant memories (async)."""
try:
await self._aensure_bank()
kwargs: dict[str, Any] = {
"bank_id": self.bank_id,
"query": query,
"budget": self.budget,
"max_tokens": self.max_tokens,
}
if self.recall_tags:
kwargs["tags"] = self.recall_tags
kwargs["tags_match"] = self.recall_tags_match
response = await self._client.arecall(**kwargs)
if not response.results:
return ""
lines = [r.text for r in response.results]
return "\n".join(f"- {line}" for line in lines)
except Exception as e:
logger.error(f"Failed to recall memories: {e}")
return ""
# -- BaseMemory interface --
def get(self, input: Optional[str] = None, **kwargs: Any) -> list[ChatMessage]:
"""Get chat history, enriched with recalled Hindsight memories.
If ``input`` is provided, relevant memories are recalled and
prepended as a system message.
"""
messages: list[ChatMessage] = []
if input:
memories_text = self._recall_memories(input)
if memories_text:
system_content = self.system_prompt.format(memories=memories_text)
messages.append(
ChatMessage(role=MessageRole.SYSTEM, content=system_content)
)
messages.extend(self._chat_history)
return messages
async def aget(
self, input: Optional[str] = None, **kwargs: Any
) -> list[ChatMessage]:
"""Async version of get()."""
messages: list[ChatMessage] = []
if input:
memories_text = await self._arecall_memories(input)
if memories_text:
system_content = self.system_prompt.format(memories=memories_text)
messages.append(
ChatMessage(role=MessageRole.SYSTEM, content=system_content)
)
messages.extend(self._chat_history)
return messages
def get_all(self) -> list[ChatMessage]:
"""Get all messages in the local chat buffer."""
return list(self._chat_history)
def put(self, message: ChatMessage) -> None:
"""Store a message in local buffer and retain to Hindsight."""
self._chat_history.append(message)
# Trim to limit
if len(self._chat_history) > self.chat_history_limit:
self._chat_history = self._chat_history[-self.chat_history_limit :]
self._retain_message(message)
async def aput(self, message: ChatMessage) -> None:
"""Async version of put()."""
self._chat_history.append(message)
if len(self._chat_history) > self.chat_history_limit:
self._chat_history = self._chat_history[-self.chat_history_limit :]
await self._aretain_message(message)
def set(self, messages: list[ChatMessage]) -> None:
"""Set the chat history, retaining new messages to Hindsight."""
existing_len = len(self._chat_history)
self._chat_history = list(messages)
# Retain only new messages (beyond previous length)
for msg in messages[existing_len:]:
self._retain_message(msg)
async def aset(self, messages: list[ChatMessage]) -> None:
"""Async version of set()."""
existing_len = len(self._chat_history)
self._chat_history = list(messages)
for msg in messages[existing_len:]:
await self._aretain_message(msg)
def reset(self) -> None:
"""Reset the local chat buffer. Does not clear Hindsight memories."""
self._chat_history = []