From d93dfea8ce8460281a1f86d92bc82b8b26bed609 Mon Sep 17 00:00:00 2001 From: DK09876 Date: Mon, 30 Mar 2026 09:33:03 -0700 Subject: [PATCH] fix(llamaindex): document_id, memory API, and ReAct trace fixes (#777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- .../docs/sdks/integrations/llamaindex.md | 13 ++- .../hindsight_llamaindex/__init__.py | 2 +- .../llamaindex/hindsight_llamaindex/memory.py | 43 ++++++++- .../llamaindex/hindsight_llamaindex/tools.py | 2 +- .../llamaindex/tests/test_memory.py | 93 ++++++++++++++++++- .../llamaindex/tests/test_tools.py | 8 +- 6 files changed, 144 insertions(+), 17 deletions(-) diff --git a/hindsight-docs/docs/sdks/integrations/llamaindex.md b/hindsight-docs/docs/sdks/integrations/llamaindex.md index 0f95feb3..0310b98f 100644 --- a/hindsight-docs/docs/sdks/integrations/llamaindex.md +++ b/hindsight-docs/docs/sdks/integrations/llamaindex.md @@ -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 diff --git a/hindsight-integrations/llamaindex/hindsight_llamaindex/__init__.py b/hindsight-integrations/llamaindex/hindsight_llamaindex/__init__.py index f3fc80db..617c26d6 100644 --- a/hindsight-integrations/llamaindex/hindsight_llamaindex/__init__.py +++ b/hindsight-integrations/llamaindex/hindsight_llamaindex/__init__.py @@ -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", diff --git a/hindsight-integrations/llamaindex/hindsight_llamaindex/memory.py b/hindsight-integrations/llamaindex/hindsight_llamaindex/memory.py index 0b37c5e9..14d2b664 100644 --- a/hindsight-integrations/llamaindex/hindsight_llamaindex/memory.py +++ b/hindsight-integrations/llamaindex/hindsight_llamaindex/memory.py @@ -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: diff --git a/hindsight-integrations/llamaindex/hindsight_llamaindex/tools.py b/hindsight-integrations/llamaindex/hindsight_llamaindex/tools.py index b3ca47c2..925d3e32 100644 --- a/hindsight-integrations/llamaindex/hindsight_llamaindex/tools.py +++ b/hindsight-integrations/llamaindex/hindsight_llamaindex/tools.py @@ -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] = { diff --git a/hindsight-integrations/llamaindex/tests/test_memory.py b/hindsight-integrations/llamaindex/tests/test_memory.py index 303d2180..18529541 100644 --- a/hindsight-integrations/llamaindex/tests/test_memory.py +++ b/hindsight-integrations/llamaindex/tests/test_memory.py @@ -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() diff --git a/hindsight-integrations/llamaindex/tests/test_tools.py b/hindsight-integrations/llamaindex/tests/test_tools.py index 27ce33b5..e1dbbe84 100644 --- a/hindsight-integrations/llamaindex/tests/test_tools.py +++ b/hindsight-integrations/llamaindex/tests/test_tools.py @@ -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()