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:
parent
e5209b18b3
commit
d93dfea8ce
6 changed files with 144 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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] = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue