Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e8c910ab1 | ||
|
|
cc10e20a47 | ||
|
|
34ed4345fc |
+33
-6
@@ -40,8 +40,8 @@ class AgentLoop:
|
||||
5. Sends responses back
|
||||
"""
|
||||
|
||||
# Server-side context management: Anthropic trims old tool results and preserves all
|
||||
# thinking blocks (keep="all" maximises cache hits). Client keeps full history.
|
||||
# Server-side context management: Anthropic preserves all thinking blocks
|
||||
# and clears old tool results only when approaching the 200k context limit.
|
||||
CONTEXT_MANAGEMENT = {
|
||||
"edits": [
|
||||
{
|
||||
@@ -50,7 +50,11 @@ class AgentLoop:
|
||||
},
|
||||
{
|
||||
"type": "clear_tool_uses_20250919",
|
||||
"trigger": {"type": "input_tokens", "value": 80000},
|
||||
# Raised from 80k to 195k to avoid premature cache invalidation.
|
||||
# For conversations with few tool uses (e.g., 18 uses over 182k tokens),
|
||||
# cache stability (saves 169k/turn) >> clearing benefit (13-26k one-time).
|
||||
# Leaves 5k headroom before hitting 200k standard context limit.
|
||||
"trigger": {"type": "input_tokens", "value": 195000},
|
||||
"keep": {"type": "tool_uses", "value": 5},
|
||||
},
|
||||
]
|
||||
@@ -563,10 +567,23 @@ class AgentLoop:
|
||||
reasoning_content=final_reasoning,
|
||||
)
|
||||
|
||||
# Save to session: user message + full tool chain (tool_use, tool_results, thinking, final reply)
|
||||
# Save to session: mem0 context (if present) + user message + full tool chain
|
||||
# Store current_message (not msg.content) so the time prefix is preserved
|
||||
# and cache keys match on subsequent turns
|
||||
# Include sender_id to distinguish real user messages from system-generated ones
|
||||
|
||||
# Find and save mem0 injection (appears just before current user message)
|
||||
# build_messages returns: [...history, mem0_user, mem0_asst, current_user]
|
||||
# turn_start = len(messages), so mem0 is at turn_start-3 and turn_start-2
|
||||
# This makes mem0 part of immutable history, stabilizing cache across turns
|
||||
if turn_start >= 3:
|
||||
potential_mem0_user = messages[turn_start - 3]
|
||||
potential_mem0_asst = messages[turn_start - 2]
|
||||
if (potential_mem0_user.get("role") == "user" and
|
||||
potential_mem0_user.get("content") == "[Memory context]" and
|
||||
potential_mem0_asst.get("role") == "assistant"):
|
||||
session.add_raw_message(potential_mem0_user)
|
||||
session.add_raw_message(potential_mem0_asst)
|
||||
|
||||
session.add_message("user", current_message, sender_id=msg.sender_id)
|
||||
for chain_msg in messages[turn_start:]:
|
||||
session.add_raw_message(chain_msg)
|
||||
@@ -769,7 +786,17 @@ class AgentLoop:
|
||||
reasoning_content=final_reasoning,
|
||||
)
|
||||
|
||||
# Save to session: user message + full tool chain
|
||||
# Save to session: mem0 (if present) + user message + full tool chain
|
||||
# Find and save mem0 injection for cache stability
|
||||
if turn_start >= 3:
|
||||
potential_mem0_user = messages[turn_start - 3]
|
||||
potential_mem0_asst = messages[turn_start - 2]
|
||||
if (potential_mem0_user.get("role") == "user" and
|
||||
potential_mem0_user.get("content") == "[Memory context]" and
|
||||
potential_mem0_asst.get("role") == "assistant"):
|
||||
session.add_raw_message(potential_mem0_user)
|
||||
session.add_raw_message(potential_mem0_asst)
|
||||
|
||||
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
||||
for chain_msg in messages[turn_start:]:
|
||||
session.add_raw_message(chain_msg)
|
||||
|
||||
@@ -58,346 +58,6 @@ class Mem0MemoryStore:
|
||||
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}")
|
||||
logger.debug(f"MemoryConfig.custom_fact_extraction_prompt is None: {mem0_config.custom_fact_extraction_prompt is None}")
|
||||
self.memory = Memory(config=mem0_config)
|
||||
logger.debug(f"Memory.config.custom_fact_extraction_prompt is None: {self.memory.config.custom_fact_extraction_prompt is None}")
|
||||
|
||||
logger.info("Mem0 memory system initialized with custom nanobot prompt")
|
||||
|
||||
def search_memories(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
limit: int = 5,
|
||||
session_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Search for relevant memories using semantic search.
|
||||
|
||||
Args:
|
||||
query: Search query (user's current message)
|
||||
user_id: User identifier (e.g., "telegram_12345")
|
||||
limit: Max number of memories to return
|
||||
session_id: Optional session-specific memories
|
||||
|
||||
Returns:
|
||||
List of memory dicts with 'memory' and 'score' keys
|
||||
"""
|
||||
try:
|
||||
# Search user-level memories
|
||||
user_memories = self.memory.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
results = []
|
||||
if user_memories and "results" in user_memories:
|
||||
results.extend(user_memories["results"])
|
||||
|
||||
# Optionally search session-level memories
|
||||
if session_id:
|
||||
session_memories = self.memory.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
metadata={"session_id": session_id},
|
||||
limit=limit // 2 # Reserve half for session context
|
||||
)
|
||||
if session_memories and "results" in session_memories:
|
||||
results.extend(session_memories["results"])
|
||||
|
||||
logger.debug(
|
||||
f"Mem0 search: query='{query[:50]}...', found {len(results)} memories"
|
||||
)
|
||||
return results[:limit] # Limit total results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 search failed: {e}")
|
||||
return []
|
||||
|
||||
def add_conversation(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
user_id: str,
|
||||
session_id: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Add conversation messages to memory for automatic extraction.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'
|
||||
user_id: User identifier
|
||||
session_id: Optional session identifier for session-level memories
|
||||
"""
|
||||
try:
|
||||
metadata = {}
|
||||
if session_id:
|
||||
metadata["session_id"] = session_id
|
||||
|
||||
# mem0 automatically extracts and stores relevant facts
|
||||
result = self.memory.add(
|
||||
messages,
|
||||
user_id=user_id,
|
||||
metadata=metadata if metadata else None
|
||||
)
|
||||
|
||||
facts_count = len(result.get("results", [])) if result else 0
|
||||
logger.debug(
|
||||
f"Mem0 add: {len(messages)} messages for user {user_id}, extracted {facts_count} facts"
|
||||
)
|
||||
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,
|
||||
user_id: str,
|
||||
limit: int = 5
|
||||
) -> str:
|
||||
"""
|
||||
Get formatted memory context for inclusion in system prompt.
|
||||
|
||||
Args:
|
||||
query: Current user query
|
||||
user_id: User identifier
|
||||
limit: Max memories to include
|
||||
|
||||
Returns:
|
||||
Formatted memory context string
|
||||
"""
|
||||
memories = self.search_memories(query, user_id, limit=limit)
|
||||
|
||||
if not memories:
|
||||
return ""
|
||||
|
||||
lines = ["## Relevant Memories"]
|
||||
for i, mem in enumerate(memories, 1):
|
||||
memory_text = mem.get("memory", "")
|
||||
# Include score if available for debugging
|
||||
score = mem.get("score", "")
|
||||
score_str = f" (relevance: {score:.2f})" if score else ""
|
||||
lines.append(f"{i}. {memory_text}{score_str}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def update_memory(self, memory_id: str, data: dict[str, Any]) -> None:
|
||||
"""Update a specific memory by ID."""
|
||||
try:
|
||||
self.memory.update(memory_id, data)
|
||||
logger.debug(f"Mem0 update: memory_id={memory_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 update failed: {e}")
|
||||
|
||||
def delete_memory(self, memory_id: str) -> None:
|
||||
"""Delete a specific memory by ID."""
|
||||
try:
|
||||
self.memory.delete(memory_id)
|
||||
logger.debug(f"Mem0 delete: memory_id={memory_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 delete failed: {e}")
|
||||
|
||||
def get_all_memories(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Get all memories for a user."""
|
||||
try:
|
||||
result = self.memory.get_all(user_id=user_id)
|
||||
return result.get("results", []) if result else []
|
||||
except Exception as e:
|
||||
logger.error(f"Mem0 get_all failed: {e}")
|
||||
return []
|
||||
|
||||
async def consolidate(
|
||||
self,
|
||||
session: Session,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
*,
|
||||
archive_all: bool = False,
|
||||
memory_window: int = 50,
|
||||
) -> bool:
|
||||
"""
|
||||
Consolidate session messages into mem0 memory.
|
||||
|
||||
Facts are extracted using the main agent's LLM provider, then stored with infer=False.
|
||||
|
||||
Returns True on success.
|
||||
"""
|
||||
try:
|
||||
# Extract user_id from session key (e.g., "telegram:12345" -> "telegram_12345")
|
||||
user_id = session.key.replace(":", "_")
|
||||
|
||||
# Determine which messages to consolidate
|
||||
if archive_all:
|
||||
messages_to_add = session.messages
|
||||
logger.info(
|
||||
f"Mem0 consolidation (archive_all): {len(messages_to_add)} messages"
|
||||
)
|
||||
else:
|
||||
keep_count = memory_window // 2
|
||||
if len(session.messages) <= keep_count:
|
||||
return True
|
||||
|
||||
# Get unconsolidated messages
|
||||
start_idx = session.last_consolidated
|
||||
end_idx = len(session.messages) - keep_count
|
||||
|
||||
if end_idx <= start_idx:
|
||||
return True
|
||||
|
||||
messages_to_add = session.messages[start_idx:end_idx]
|
||||
|
||||
if not messages_to_add:
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
f"Mem0 consolidation: {len(messages_to_add)} to consolidate, "
|
||||
f"{keep_count} keep"
|
||||
)
|
||||
|
||||
# Convert to mem0 format with intelligent filtering
|
||||
mem0_messages = []
|
||||
for msg in messages_to_add:
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
|
||||
# Skip tool results — raw bash output, file contents, and JSON
|
||||
# get misinterpreted by the extraction LLM as user interests
|
||||
if role == "tool":
|
||||
continue
|
||||
|
||||
# Skip system messages — they're boilerplate instructions, not facts
|
||||
if role == "system":
|
||||
continue
|
||||
|
||||
# Skip messages with no content
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Normalize assistant message content: extract text from Anthropic list format
|
||||
if role == "assistant" and isinstance(content, list):
|
||||
# Anthropic format: list of {type: "text"|"tool_use", text: "..."} blocks
|
||||
text_parts = [
|
||||
block.get("text", "")
|
||||
for block in content
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
]
|
||||
content = " ".join(text_parts).strip()
|
||||
if not content:
|
||||
continue # Skip if assistant only called tools with no text explanation
|
||||
|
||||
# Normalize user message content (could also be a list in some formats)
|
||||
if isinstance(content, list):
|
||||
text_parts = [
|
||||
block.get("text", "") if isinstance(block, dict) else str(block)
|
||||
for block in content
|
||||
]
|
||||
content = " ".join(text_parts).strip()
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Skip trivially short messages (commands like "/new")
|
||||
if len(content.strip()) < 10:
|
||||
continue
|
||||
|
||||
mem0_messages.append({
|
||||
"role": role,
|
||||
"content": content
|
||||
})
|
||||
|
||||
if mem0_messages:
|
||||
# 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)
|
||||
|
||||
@@ -326,18 +326,35 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
"""Make request to Anthropic API."""
|
||||
client = await self._get_client()
|
||||
|
||||
# Cache the last user message so conversation history is cached across turns
|
||||
if messages:
|
||||
last = messages[-1]
|
||||
if last.get("role") == "user":
|
||||
content = last["content"]
|
||||
if isinstance(content, str):
|
||||
last = {**last, "content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]}
|
||||
elif isinstance(content, list) and content:
|
||||
new_content = list(content)
|
||||
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||
last = {**last, "content": new_content}
|
||||
messages = messages[:-1] + [last]
|
||||
# Add cache breakpoints on the last TWO user messages (4-breakpoint strategy):
|
||||
# BP3: Second-to-last user message (stable history from previous turn)
|
||||
# BP4: Last user message (current turn, will become BP3 next turn)
|
||||
# This allows BP3 to reuse what BP4 cached last turn.
|
||||
user_indices = [i for i, m in enumerate(messages) if m.get("role") == "user"]
|
||||
|
||||
if len(user_indices) >= 2:
|
||||
# BP3: Second-to-last user message
|
||||
idx = user_indices[-2]
|
||||
msg = messages[idx]
|
||||
content = msg["content"]
|
||||
if isinstance(content, str):
|
||||
messages[idx] = {**msg, "content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]}
|
||||
elif isinstance(content, list) and content:
|
||||
new_content = list(content)
|
||||
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||
messages[idx] = {**msg, "content": new_content}
|
||||
|
||||
if len(user_indices) >= 1:
|
||||
# BP4: Last user message
|
||||
idx = user_indices[-1]
|
||||
msg = messages[idx]
|
||||
content = msg["content"]
|
||||
if isinstance(content, str):
|
||||
messages[idx] = {**msg, "content": [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]}
|
||||
elif isinstance(content, list) and content:
|
||||
new_content = list(content)
|
||||
new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
|
||||
messages[idx] = {**msg, "content": new_content}
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
|
||||
Reference in New Issue
Block a user