feat: extract facts with main agent LLM, bypass mem0 GPT-nano
Build Nanobot OAuth / build (pull_request) Successful in 6m5s
Build Nanobot OAuth / cleanup (pull_request) Has been skipped

Instead of hacking mem0's provider system, use the main agent's
existing LLM (already running, already paid for) to extract facts
from conversations, then store them with infer=False.

- extract_facts(): sends conversation to provider.chat() with extraction prompt
- store_facts(): stores each fact via mem0 with infer=False
- consolidate(): calls extract_facts + store_facts instead of add_conversation
- No new files, no Dockerfile changes, no mem0 package patches
This commit is contained in:
2026-03-02 10:14:13 +00:00
committed by nanobot
parent 1a85333e4c
commit 55b0875773
4 changed files with 170 additions and 112 deletions
+98 -109
View File
@@ -45,100 +45,8 @@ class Mem0MemoryStore:
# Build custom extraction prompt tuned for nanobot conversations
from datetime import datetime
custom_prompt = f"""# Nanobot Fact Extraction Prompt
# Version: 1.0
# Date: {datetime.now().strftime("%Y-%m-%d")}
You are an information organizer for a personal AI assistant. Extract memorable facts from conversations between a user and their AI assistant.
## Context
Unlike consumer chatbots where users share personal details, this assistant is used for research, debugging, and task execution. Extract facts from BOTH user messages (what they care about / asked for) AND assistant messages (what was found / accomplished).
## What to Extract
1. **User interests and focus areas**: Topics the user asked to research or investigate
2. **Research findings**: Key facts, comparisons, or conclusions the assistant surfaced
3. **Technical work**: Systems debugged, problems solved, tools built or configured
4. **User preferences revealed through requests**: (e.g., asking for smart ring research implies interest in wearables)
5. **Project context**: What the user is working on, building, or planning
6. **Tools and tech stack**: Technologies, services, or platforms mentioned or used
## What NOT to Extract
- Command invocations or shell output
- JSON/code blobs or raw data
- Routine assistant acknowledgments ("Let me search for that", "I'll help you with that")
- Timestamps, file paths, or technical identifiers unless specifically significant
- Messages that are just "/new" or other system commands
- Tool use metadata or Python list notation artifacts
## Examples
### Example 1: Research Query with Findings
Input:
user: research smart rings that can help with ADHD focus tracking
assistant: I'll search for that. The top options are Oura Ring (best sleep data for ADHD correlation), Ultrahuman Ring AIR (real-time metabolic tracking), and RingConn (continuous SpO2). For ADHD specifically, Oura's HRV and sleep stage data correlate with focus capacity.
Output: {{"facts": [
"User researching smart rings for ADHD focus tracking",
"Oura Ring: best sleep stage data for ADHD (HRV correlates with focus)",
"Ultrahuman Ring AIR: real-time metabolic tracking",
"RingConn: continuous SpO2 monitoring"
]}}
### Example 2: Debugging/Technical Work
Input:
user: debug why mem0 is not extracting facts
assistant: Found the issue: mem0's default prompt only extracts from user messages, but nanobot users send commands not personal info. Also assistant content with tool_use arrives as a Python list which gets corrupted.
Output: {{"facts": [
"Debugged mem0 fact extraction issue",
"Root cause: default prompt ignores assistant messages",
"Assistant content corruption: list format not handled by parse_messages()"
]}}
### Example 3: Geopolitical Research
Input:
user: look at ukraine geopolitics situation
assistant: Key developments: NATO eastern flank reinforcement accelerated, grain deal negotiations stalled, EU economic impact is asymmetric with Eastern European members bearing higher costs.
Output: {{"facts": [
"User researched Ukraine geopolitics",
"NATO eastern flank reinforcement accelerated (2026)",
"Ukraine grain deal negotiations stalled",
"EU economic impact from Ukraine conflict is asymmetric, Eastern Europe most affected"
]}}
### Example 4: Skip - Just Tool Output
Input:
assistant: [{{'type': 'tool_use', 'id': 'tu_1', 'name': 'bash', ...}}]
tool: $ ls -la\\ntotal 48\\ndrwxr-xr-x 12 user staff...
Output: {{"facts": []}}
### Example 5: Skip - System Commands
Input:
user: /new
Output: {{"facts": []}}
### Example 6: Skip - No Meaningful Content
Input:
assistant: Let me help you with that.
user: ok
Output: {{"facts": []}}
## Instructions
- Today's date is {datetime.now().strftime("%Y-%m-%d")}.
- Extract from BOTH user and assistant messages.
- Prefer specific, searchable facts over vague summaries.
- Combine related user question + assistant answer into unified facts when possible.
- For transient/time-sensitive facts (location, health data, weather, notifications), ALWAYS include the date or time. Write "On 2026-03-01, Makar was in Barcelona" NOT "Makar is in Barcelona".
- Never phrase facts as present-tense universal truths when they are time-bound observations.
- Return empty list if the conversation contains only commands, tool output, or no meaningful substance.
- Respond only with the JSON object: {{"facts": ["fact1", "fact2", ...]}}, no other text.
Here is the conversation to extract facts from:
"""
today = datetime.now().strftime("%Y-%m-%d")
custom_prompt = f"Extract dated facts from this conversation as JSON: {{\"facts\": [...]}}. Today is {today}.\n\n"
# Initialize mem0 with optional config + custom prompt
# Extract only MemoryConfig-relevant fields
@@ -150,6 +58,7 @@ Here is the conversation to extract facts from:
mem0_cfg_dict[key] = raw_config[key]
logger.debug(f"Extracted for MemoryConfig: {list(mem0_cfg_dict.keys())}")
logger.debug(f"Custom prompt length: {len(custom_prompt)} chars")
self.custom_prompt = custom_prompt
mem0_cfg_dict["custom_fact_extraction_prompt"] = custom_prompt
mem0_config = MemoryConfig(**mem0_cfg_dict)
logger.debug(f"MemoryConfig created: vector_store={mem0_config.vector_store.provider if mem0_config.vector_store else None}")
@@ -243,6 +152,96 @@ Here is the conversation to extract facts from:
except Exception as e:
logger.error(f"Mem0 add failed: {e}")
async def extract_facts(
self,
messages: list[dict[str, Any]],
provider: Any,
model: str,
) -> list[str]:
"""
Extract facts from conversation using the main agent's LLM provider.
Uses the same provider/model already running (e.g. Haiku via Claude Max),
avoiding a separate LLM call to mem0's default GPT-nano.
"""
import json as _json
# Build conversation text for extraction
conv_text = ""
for msg in messages:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if isinstance(content, str) and content.strip():
conv_text += f"{role}: {content}\n\n"
if not conv_text.strip():
return []
extraction_messages = [
{"role": "user", "content": self.custom_prompt + conv_text}
]
try:
response = await provider.chat(
messages=extraction_messages,
model=model,
max_tokens=2000,
temperature=0.3,
)
# Parse the JSON response — LLMResponse.content is a string
text = response.content or ""
# Strip markdown code fences if present
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
data = _json.loads(text)
facts = data.get("facts", [])
logger.debug(f"Extracted {len(facts)} facts using {model}")
return facts
except Exception as e:
logger.error(f"Fact extraction failed: {e}")
return []
def store_facts(
self,
facts: list[str],
user_id: str,
session_id: str | None = None,
) -> None:
"""
Store pre-extracted facts in mem0 with infer=False.
Bypasses mem0's built-in LLM extraction — facts are already
in final form from extract_facts().
"""
if not facts:
return
metadata = {}
if session_id:
metadata["session_id"] = session_id
stored = 0
for fact in facts:
try:
self.memory.add(
fact,
user_id=user_id,
infer=False,
metadata=metadata if metadata else None,
)
stored += 1
except Exception as e:
logger.error(f"Failed to store fact '{fact[:50]}...': {e}")
logger.info(f"Stored {stored}/{len(facts)} facts for user {user_id}")
def get_memory_context(
self,
query: str,
@@ -312,8 +311,7 @@ Here is the conversation to extract facts from:
"""
Consolidate session messages into mem0 memory.
Unlike the original MemoryStore, mem0 handles extraction automatically,
so this just needs to feed recent messages to mem0.
Facts are extracted using the main agent's LLM provider, then stored with infer=False.
Returns True on success.
"""
@@ -400,19 +398,10 @@ Here is the conversation to extract facts from:
})
if mem0_messages:
# Debug: log what we're sending to mem0
import json
logger.debug(f"Mem0 consolidation sending {len(mem0_messages)} messages:")
for i, msg in enumerate(mem0_messages[:5]): # Log first 5
preview = msg['content'][:200] if len(msg['content']) > 200 else msg['content']
logger.debug(f" [{i}] {msg['role']}: {preview}")
# Add to mem0 - it handles extraction automatically
self.add_conversation(
mem0_messages,
user_id=user_id,
session_id=session.key
)
# Extract facts using the main agent's LLM (already paid for),
# then store with infer=False to bypass mem0's GPT-nano
facts = await self.extract_facts(mem0_messages, provider, model)
self.store_facts(facts, user_id=user_id, session_id=session.key)
# Update consolidation marker
if archive_all:
+3 -2
View File
@@ -71,9 +71,10 @@ class _BashSession:
assert self._process.stdout
assert self._process.stderr
# Send command + sentinel
# Send command + sentinel on its own line so heredoc terminators
# aren't corrupted (EOF; echo '...' ≠ EOF)
self._process.stdin.write(
command.encode() + f"; echo '{self._sentinel}'\n".encode()
command.encode() + f"\necho '{self._sentinel}'\n".encode()
)
await self._process.stdin.drain()
+68
View File
@@ -0,0 +1,68 @@
"""Test that bash tool handles heredoc commands correctly.
Reproduces the bug where `; echo '<<exit>>'` appended on the same line
as a heredoc terminator prevents bash from recognizing the terminator,
causing the session to hang forever.
"""
import asyncio
import pytest
from nanobot.agent.tools.anthropic.bash import BashTool20250124
@pytest.mark.asyncio
async def test_heredoc_command():
"""Heredoc commands must complete without hanging."""
tool = BashTool20250124()
# Simple command works
result = await tool(command="echo hello")
assert result.output == "hello"
# Heredoc command — this is the exact pattern that caused the hang
result = await asyncio.wait_for(
tool(command="cat << 'EOF'\nline1\nline2\nEOF"),
timeout=5.0,
)
assert "line1" in result.output
assert "line2" in result.output
@pytest.mark.asyncio
async def test_heredoc_append_to_file():
"""Heredoc append (the exact pattern the LLM uses) must work."""
tool = BashTool20250124()
result = await asyncio.wait_for(
tool(command="cat >> /tmp/test_heredoc_bash.txt << 'EOF'\nhello world\nEOF"),
timeout=5.0,
)
# Should complete without error
assert result.error is None or result.error == ""
# Verify the file was written
result2 = await tool(command="cat /tmp/test_heredoc_bash.txt")
assert "hello world" in result2.output
# Cleanup
await tool(command="rm -f /tmp/test_heredoc_bash.txt")
@pytest.mark.asyncio
async def test_regular_commands_still_work():
"""Ensure regular commands still work after the fix."""
tool = BashTool20250124()
# Semicolons in commands
result = await tool(command="echo a; echo b")
assert "a" in result.output
assert "b" in result.output
# Multiline script
result = await tool(command="for i in 1 2 3; do echo $i; done")
assert "1" in result.output
assert "3" in result.output
# Command with exit code
result = await tool(command="true")
assert result.output == "(no output)" or result.output is not None
+1 -1
View File
@@ -40,7 +40,7 @@ async def test_bash_tool_restart():
# Restart
result = await tool(restart=True)
assert "restarted" in result.output.lower()
assert "restarted" in (result.system or result.output or "").lower()
# Variable should be gone
result2 = await tool(command="echo $TEST_VAR")