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>
This commit is contained in:
DK09876 2026-03-30 09:33:03 -07:00 committed by GitHub
parent e5209b18b3
commit d93dfea8ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 144 additions and 17 deletions

View file

@ -37,8 +37,8 @@ async def main():
mission="Track user preferences and project context",
)
agent = ReActAgent(tools=[], llm=OpenAI(model="gpt-4o"), memory=memory)
response = await agent.run("Remember that I prefer dark mode")
agent = ReActAgent(tools=[], llm=OpenAI(model="gpt-4o"))
response = await agent.run("Remember that I prefer dark mode", memory=memory)
print(response)
asyncio.run(main())
@ -48,8 +48,8 @@ asyncio.run(main())
| Event | What Happens |
|-------|-------------|
| Agent receives input | `get(input)` recalls relevant memories from Hindsight, prepends as system message |
| Agent produces output | `put(message)` retains the message to Hindsight for future recall |
| Agent receives input | `aget(input)` recalls relevant memories from Hindsight, prepends as system message |
| Agent produces output | `aput(message)` retains the message to Hindsight for future recall |
| New session starts | Previous memories are available via recall; local chat buffer starts empty |
### `HindsightMemory.from_client()`
@ -236,7 +236,10 @@ tools = create_hindsight_tools(
include_reflect=True, # agent can still explicitly reflect
)
agent = ReActAgent(tools=tools, llm=llm, memory=memory)
agent = ReActAgent(tools=tools, llm=llm)
# Pass memory to run()
response = await agent.run("What should I prioritize?", memory=memory)
```
## Requirements

View file

@ -26,7 +26,7 @@ from .errors import HindsightError
from .memory import HindsightMemory
from .tools import HindsightToolSpec, create_hindsight_tools
__version__ = "0.1.1"
__version__ = "0.1.2"
__all__ = [
"configure",

View file

@ -7,6 +7,7 @@ Provides automatic memory for LlamaIndex agents:
"""
import logging
import re
import time
import uuid
from typing import Any, Optional
@ -23,6 +24,12 @@ DEFAULT_SYSTEM_PROMPT = (
"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.
@ -60,8 +67,9 @@ class HindsightMemory(BaseMemory):
mission="Track user preferences",
)
# Use with any LlamaIndex agent
agent = ReActAgent(tools=tools, llm=llm, memory=memory)
# 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")
@ -200,13 +208,41 @@ class HindsightMemory(BaseMemory):
self._bank_initialized = True
def _generate_document_id(self) -> str:
return f"{self._session_id}-{int(time.time() * 1000)}"
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:
@ -229,6 +265,7 @@ class HindsightMemory(BaseMemory):
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:

View file

@ -185,7 +185,7 @@ class HindsightToolSpec(BaseToolSpec):
def _generate_document_id(self) -> str:
"""Generate a unique document_id for retain operations."""
return f"{self._session_id}-{int(time.time() * 1000)}"
return f"{self._session_id}-{uuid.uuid4().hex[:12]}"
def _retain_kwargs(self, content: str) -> dict[str, Any]:
kwargs: dict[str, Any] = {

View file

@ -164,9 +164,10 @@ class TestPut:
kwargs = client.retain.call_args[1]
doc_id = kwargs["document_id"]
parts = doc_id.rsplit("-", 1)
assert len(parts) == 2
assert parts[1].isdigit()
# Auto-generated format: {session_id}-{uuid_hex_12}
assert "-" in doc_id
suffix = doc_id.rsplit("-", 1)[1]
assert len(suffix) == 12
class TestGet:
@ -295,6 +296,92 @@ class TestReset:
assert len(memory.get_all()) == 0
class TestReActStripping:
"""Tests for stripping ReAct reasoning traces from assistant messages."""
def test_plain_assistant_message_retained(self):
client = _mock_client()
memory = HindsightMemory.from_client(client=client, bank_id="test")
msg = ChatMessage(role=MessageRole.ASSISTANT, content="You use VS Code.")
memory.put(msg)
client.retain.assert_called_once()
assert client.retain.call_args[1]["content"] == "You use VS Code."
def test_react_with_answer_retains_answer_only(self):
client = _mock_client()
memory = HindsightMemory.from_client(client=client, bank_id="test")
react_content = (
"Thought: I need to recall the user's IDE preference.\n"
"Action: recall_memory\n"
"Action Input: {\"query\": \"IDE preference\"}\n"
"Observation: User prefers VS Code with dark mode.\n"
"Thought: I now have the answer.\n"
"Answer: You use VS Code with dark mode."
)
msg = ChatMessage(role=MessageRole.ASSISTANT, content=react_content)
memory.put(msg)
client.retain.assert_called_once()
assert client.retain.call_args[1]["content"] == "You use VS Code with dark mode."
def test_react_without_answer_skips_retain(self):
client = _mock_client()
memory = HindsightMemory.from_client(client=client, bank_id="test")
react_content = (
"Thought: I need to use a tool to help me answer.\n"
"Action: retain_memory\n"
"Action Input: {\"content\": \"User likes Python\"}"
)
msg = ChatMessage(role=MessageRole.ASSISTANT, content=react_content)
memory.put(msg)
client.retain.assert_not_called()
def test_react_stripping_still_adds_to_local_history(self):
"""Even when retain is skipped, the message is in local history."""
client = _mock_client()
memory = HindsightMemory.from_client(client=client, bank_id="test")
react_content = (
"Thought: I need a tool.\n"
"Action: some_tool\n"
"Action Input: {}"
)
msg = ChatMessage(role=MessageRole.ASSISTANT, content=react_content)
memory.put(msg)
# Not retained to Hindsight
client.retain.assert_not_called()
# But still in local history
assert len(memory.get_all()) == 1
def test_user_message_not_stripped(self):
"""User messages with Thought:/Action: are retained as-is."""
client = _mock_client()
memory = HindsightMemory.from_client(client=client, bank_id="test")
content = "Thought: can you explain this Action: pattern?"
msg = ChatMessage(role=MessageRole.USER, content=content)
memory.put(msg)
client.retain.assert_called_once()
assert client.retain.call_args[1]["content"] == content
def test_react_multiple_answers_uses_last(self):
client = _mock_client()
memory = HindsightMemory.from_client(client=client, bank_id="test")
react_content = (
"Thought: Let me check.\n"
"Answer: Actually, let me reconsider.\n"
"Thought: After further review.\n"
"Answer: The final answer is 42."
)
msg = ChatMessage(role=MessageRole.ASSISTANT, content=react_content)
memory.put(msg)
client.retain.assert_called_once()
assert client.retain.call_args[1]["content"] == "The final answer is 42."
class TestBankMission:
def test_creates_bank_with_mission_on_put(self):
client = _mock_client()

View file

@ -207,10 +207,10 @@ class TestRetainTool:
spec.retain_memory("content")
call_kwargs = client.retain.call_args[1]
doc_id = call_kwargs["document_id"]
# Auto-generated format: {session_id}-{timestamp_ms}
parts = doc_id.rsplit("-", 1)
assert len(parts) == 2
assert parts[1].isdigit()
# Auto-generated format: {session_id}-{uuid_hex_12}
assert "-" in doc_id
suffix = doc_id.rsplit("-", 1)[1]
assert len(suffix) == 12
def test_retain_passes_context_label(self):
client = _mock_client()