chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
This commit is contained in:
Executable
+8
@@ -0,0 +1,8 @@
|
||||
from .memory_base import MemoryBase, MemoryManager
|
||||
from .builtin_stores import MemoryFactory
|
||||
|
||||
__all__ = [
|
||||
"MemoryBase",
|
||||
"MemoryManager",
|
||||
"MemoryFactory",
|
||||
]
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
"""Lightweight append-only Blackboard memory implementation."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import List
|
||||
|
||||
from entity.configs import MemoryStoreConfig
|
||||
from entity.configs.node.memory import BlackboardMemoryConfig
|
||||
from runtime.node.agent.memory.memory_base import (
|
||||
MemoryBase,
|
||||
MemoryContentSnapshot,
|
||||
MemoryItem,
|
||||
MemoryWritePayload,
|
||||
)
|
||||
|
||||
|
||||
class BlackboardMemory(MemoryBase):
|
||||
"""Simple append-only memory: save raw outputs, retrieve by recency."""
|
||||
|
||||
def __init__(self, store: MemoryStoreConfig):
|
||||
config = store.as_config(BlackboardMemoryConfig)
|
||||
if not config:
|
||||
raise ValueError("BlackboardMemory requires a blackboard memory store configuration")
|
||||
super().__init__(store)
|
||||
self.config = config
|
||||
self.memory_path = config.memory_path
|
||||
self.max_items = config.max_items
|
||||
|
||||
# -------- Persistence --------
|
||||
def load(self) -> None:
|
||||
if not self.memory_path or not os.path.exists(self.memory_path):
|
||||
self.contents = []
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.memory_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
contents: List[MemoryItem] = []
|
||||
for raw in data:
|
||||
try:
|
||||
contents.append(MemoryItem.from_dict(raw))
|
||||
except Exception:
|
||||
continue
|
||||
self.contents = contents
|
||||
except Exception:
|
||||
# Corrupted file -> reset to empty to avoid blocking execution
|
||||
self.contents = []
|
||||
|
||||
def save(self) -> None:
|
||||
if not self.memory_path:
|
||||
return
|
||||
|
||||
os.makedirs(os.path.dirname(self.memory_path), exist_ok=True)
|
||||
payload = [item.to_dict() for item in self.contents[-self.max_items :]]
|
||||
with open(self.memory_path, "w", encoding="utf-8") as file:
|
||||
json.dump(payload, file, ensure_ascii=False, indent=2)
|
||||
|
||||
# -------- Memory operations --------
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
top_k: int,
|
||||
similarity_threshold: float,
|
||||
) -> List[MemoryItem]:
|
||||
if not self.contents:
|
||||
return []
|
||||
|
||||
if top_k <= 0 or top_k >= len(self.contents):
|
||||
return list(self.contents)
|
||||
|
||||
return list(self.contents[-top_k:])
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
snapshot = payload.output_snapshot or payload.input_snapshot
|
||||
content = (snapshot.text if snapshot else payload.inputs_text or "").strip()
|
||||
if not content:
|
||||
return
|
||||
|
||||
metadata = {
|
||||
"agent_role": payload.agent_role,
|
||||
"input_preview": (payload.inputs_text or "")[:200],
|
||||
"attachments": snapshot.attachment_overview() if snapshot else [],
|
||||
}
|
||||
|
||||
memory_item = MemoryItem(
|
||||
id=f"bb_{uuid.uuid4().hex}",
|
||||
content_summary=content,
|
||||
metadata=metadata,
|
||||
timestamp=time.time(),
|
||||
input_snapshot=payload.input_snapshot,
|
||||
output_snapshot=payload.output_snapshot,
|
||||
)
|
||||
|
||||
self.contents.append(memory_item)
|
||||
if len(self.contents) > self.max_items:
|
||||
self.contents = self.contents[-self.max_items :]
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
"""Register built-in memory stores."""
|
||||
|
||||
from entity.configs.node.memory import (
|
||||
BlackboardMemoryConfig,
|
||||
FileMemoryConfig,
|
||||
Mem0MemoryConfig,
|
||||
SimpleMemoryConfig,
|
||||
MemoryStoreConfig,
|
||||
)
|
||||
from runtime.node.agent.memory.blackboard_memory import BlackboardMemory
|
||||
from runtime.node.agent.memory.file_memory import FileMemory
|
||||
from runtime.node.agent.memory.memory_base import MemoryBase
|
||||
from runtime.node.agent.memory.simple_memory import SimpleMemory
|
||||
from runtime.node.agent.memory.registry import register_memory_store, get_memory_store_registration
|
||||
|
||||
register_memory_store(
|
||||
"simple",
|
||||
config_cls=SimpleMemoryConfig,
|
||||
factory=lambda store: SimpleMemory(store),
|
||||
summary="In-memory store that resets between runs; best for testing",
|
||||
)
|
||||
|
||||
register_memory_store(
|
||||
"file",
|
||||
config_cls=FileMemoryConfig,
|
||||
factory=lambda store: FileMemory(store),
|
||||
summary="Persists documents on disk and supports embedding search",
|
||||
)
|
||||
|
||||
register_memory_store(
|
||||
"blackboard",
|
||||
config_cls=BlackboardMemoryConfig,
|
||||
factory=lambda store: BlackboardMemory(store),
|
||||
summary="Shared blackboard memory allowing multiple nodes to read/write",
|
||||
)
|
||||
|
||||
|
||||
def _create_mem0_memory(store):
|
||||
from runtime.node.agent.memory.mem0_memory import Mem0Memory
|
||||
return Mem0Memory(store)
|
||||
|
||||
|
||||
register_memory_store(
|
||||
"mem0",
|
||||
config_cls=Mem0MemoryConfig,
|
||||
factory=_create_mem0_memory,
|
||||
summary="Mem0 managed memory with semantic search and graph relationships",
|
||||
)
|
||||
|
||||
|
||||
class MemoryFactory:
|
||||
@staticmethod
|
||||
def create_memory(store: MemoryStoreConfig) -> MemoryBase:
|
||||
registration = get_memory_store_registration(store.type)
|
||||
return registration.factory(store)
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
import openai
|
||||
from tenacity import (
|
||||
retry,
|
||||
stop_after_attempt,
|
||||
wait_random_exponential,
|
||||
)
|
||||
|
||||
from entity.configs import EmbeddingConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmbeddingBase(ABC):
|
||||
def __init__(self, embedding_config: EmbeddingConfig):
|
||||
self.config = embedding_config
|
||||
|
||||
@abstractmethod
|
||||
def get_embedding(self, text):
|
||||
...
|
||||
|
||||
def _preprocess_text(self, text: str) -> str:
|
||||
"""Preprocess text to improve embedding quality."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Remove extra whitespace
|
||||
text = re.sub(r'\s+', ' ', text.strip())
|
||||
|
||||
# Remove special characters and emoji
|
||||
text = re.sub(r'[^\w\s\u4e00-\u9fff]', ' ', text)
|
||||
|
||||
# Clean up whitespace again
|
||||
text = re.sub(r'\s+', ' ', text.strip())
|
||||
|
||||
return text
|
||||
|
||||
def _chunk_text(self, text: str, max_length: int = 500) -> List[str]:
|
||||
"""Split long text into chunks to improve embedding quality."""
|
||||
if len(text) <= max_length:
|
||||
return [text]
|
||||
|
||||
# Split by sentence boundaries
|
||||
sentences = re.split(r'[\u3002\uff01\uff1f\uff1b\n]', text)
|
||||
chunks = []
|
||||
current_chunk = ""
|
||||
|
||||
for sentence in sentences:
|
||||
sentence = sentence.strip()
|
||||
if not sentence:
|
||||
continue
|
||||
|
||||
if len(current_chunk + sentence) <= max_length:
|
||||
current_chunk += sentence + "\u3002"
|
||||
else:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
current_chunk = sentence + "\u3002"
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
|
||||
return chunks
|
||||
|
||||
class EmbeddingFactory:
|
||||
@staticmethod
|
||||
def create_embedding(embedding_config: EmbeddingConfig) -> EmbeddingBase:
|
||||
model = embedding_config.provider
|
||||
if model == 'openai':
|
||||
return OpenAIEmbedding(embedding_config)
|
||||
elif model == 'local':
|
||||
return LocalEmbedding(embedding_config)
|
||||
else:
|
||||
raise ValueError(f"Unsupported embedding model: {model}")
|
||||
|
||||
class OpenAIEmbedding(EmbeddingBase):
|
||||
def __init__(self, embedding_config: EmbeddingConfig):
|
||||
super().__init__(embedding_config)
|
||||
self.base_url = embedding_config.base_url
|
||||
self.api_key = embedding_config.api_key
|
||||
self.model_name = embedding_config.model or "text-embedding-3-small" # Default model
|
||||
self.max_length = embedding_config.params.get('max_length', 8191)
|
||||
self.use_chunking = embedding_config.params.get('use_chunking', False)
|
||||
self.chunk_strategy = embedding_config.params.get('chunk_strategy', 'average')
|
||||
self._fallback_dim = 1536 # Default; updated after first successful call
|
||||
|
||||
if self.base_url:
|
||||
self.client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
else:
|
||||
self.client = openai.OpenAI(api_key=self.api_key)
|
||||
|
||||
@retry(wait=wait_random_exponential(min=2, max=5), stop=stop_after_attempt(10))
|
||||
def get_embedding(self, text):
|
||||
# Preprocess the text
|
||||
processed_text = self._preprocess_text(text)
|
||||
|
||||
if not processed_text:
|
||||
logger.warning("Empty text after preprocessing")
|
||||
return [0.0] * self._fallback_dim
|
||||
|
||||
# Handle long text via chunking
|
||||
if self.use_chunking and len(processed_text) > self.max_length:
|
||||
return self._get_chunked_embedding(processed_text)
|
||||
|
||||
# Truncate text
|
||||
truncated_text = processed_text[:self.max_length]
|
||||
|
||||
try:
|
||||
response = self.client.embeddings.create(
|
||||
input=truncated_text,
|
||||
model=self.model_name,
|
||||
encoding_format="float"
|
||||
)
|
||||
embedding = response.data[0].embedding
|
||||
self._fallback_dim = len(embedding)
|
||||
return embedding
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting embedding: {e}")
|
||||
return [0.0] * self._fallback_dim
|
||||
|
||||
def _get_chunked_embedding(self, text: str) -> List[float]:
|
||||
"""Chunk long text, embed each chunk, then aggregate."""
|
||||
chunks = self._chunk_text(text, self.max_length // 2) # Halve the chunk length
|
||||
|
||||
if not chunks:
|
||||
return [0.0] * self._fallback_dim
|
||||
|
||||
chunk_embeddings = []
|
||||
for chunk in chunks:
|
||||
try:
|
||||
response = self.client.embeddings.create(
|
||||
input=chunk,
|
||||
model=self.model_name,
|
||||
encoding_format="float"
|
||||
)
|
||||
chunk_embeddings.append(response.data[0].embedding)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting chunk embedding: {e}")
|
||||
continue
|
||||
|
||||
if not chunk_embeddings:
|
||||
return [0.0] * self._fallback_dim
|
||||
|
||||
# Aggregation strategy
|
||||
if self.chunk_strategy == 'average':
|
||||
# Mean aggregation
|
||||
return [sum(chunk[i] for chunk in chunk_embeddings) / len(chunk_embeddings)
|
||||
for i in range(len(chunk_embeddings[0]))]
|
||||
elif self.chunk_strategy == 'weighted':
|
||||
# Weighted aggregation (earlier chunks weigh more)
|
||||
weights = [1.0 / (i + 1) for i in range(len(chunk_embeddings))]
|
||||
total_weight = sum(weights)
|
||||
return [sum(chunk[i] * weights[j] for j, chunk in enumerate(chunk_embeddings)) / total_weight
|
||||
for i in range(len(chunk_embeddings[0]))]
|
||||
else:
|
||||
# Default to the first chunk
|
||||
return chunk_embeddings[0]
|
||||
|
||||
class LocalEmbedding(EmbeddingBase):
|
||||
def __init__(self, embedding_config: EmbeddingConfig):
|
||||
super().__init__(embedding_config)
|
||||
self.model_path = embedding_config.params.get('model_path')
|
||||
self.device = embedding_config.params.get('device', 'cpu')
|
||||
self._fallback_dim = 768 # Default; updated after first successful call
|
||||
|
||||
if not self.model_path:
|
||||
raise ValueError("LocalEmbedding requires model_path parameter")
|
||||
|
||||
# Load the local embedding model (e.g., sentence-transformers)
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
self.model = SentenceTransformer(self.model_path, device=self.device)
|
||||
except ImportError:
|
||||
raise ImportError("sentence-transformers is required for LocalEmbedding")
|
||||
|
||||
def get_embedding(self, text):
|
||||
# Preprocess text before encoding
|
||||
processed_text = self._preprocess_text(text)
|
||||
|
||||
if not processed_text:
|
||||
return [0.0] * self._fallback_dim
|
||||
|
||||
try:
|
||||
embedding = self.model.encode(processed_text, convert_to_tensor=False)
|
||||
result = embedding.tolist()
|
||||
self._fallback_dim = len(result)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting local embedding: {e}")
|
||||
return [0.0] * self._fallback_dim
|
||||
Executable
+485
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
FileMemory: Memory system for vectorizing and retrieving file contents
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
import time
|
||||
|
||||
import faiss
|
||||
import numpy as np
|
||||
|
||||
from runtime.node.agent.memory.memory_base import (
|
||||
MemoryBase,
|
||||
MemoryContentSnapshot,
|
||||
MemoryItem,
|
||||
MemoryWritePayload,
|
||||
)
|
||||
from entity.configs import MemoryStoreConfig, FileSourceConfig
|
||||
from entity.configs.node.memory import FileMemoryConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileMemory(MemoryBase):
|
||||
"""
|
||||
File-based memory system that indexes and retrieves content from files/directories.
|
||||
Supports multiple file types, chunking strategies, and incremental updates.
|
||||
"""
|
||||
|
||||
def __init__(self, store: MemoryStoreConfig):
|
||||
config = store.as_config(FileMemoryConfig)
|
||||
if not config:
|
||||
raise ValueError("FileMemory requires a file memory store configuration")
|
||||
super().__init__(store)
|
||||
|
||||
if not config.file_sources:
|
||||
raise ValueError("FileMemory requires at least one file_source in configuration")
|
||||
|
||||
self.file_config = config
|
||||
self.file_sources: List[FileSourceConfig] = config.file_sources
|
||||
self.index_path = self.file_config.index_path # Path to store the index
|
||||
|
||||
# Chunking configuration
|
||||
self.chunk_size = 500 # Characters per chunk
|
||||
self.chunk_overlap = 50 # Overlapping characters between chunks
|
||||
|
||||
# File metadata cache {file_path: {hash, chunks_count, ...}}
|
||||
self.file_metadata: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def load(self) -> None:
|
||||
"""
|
||||
Load existing index or build new one from file sources.
|
||||
Validates index integrity and performs incremental updates if needed.
|
||||
"""
|
||||
if self.index_path and os.path.exists(self.index_path):
|
||||
logger.info(f"Loading existing index from {self.index_path}")
|
||||
self._load_from_file()
|
||||
|
||||
# Validate and update if files changed
|
||||
if self._validate_and_update_index():
|
||||
logger.info("Index updated due to file changes")
|
||||
self.save()
|
||||
else:
|
||||
logger.info("Building new index from file sources")
|
||||
self._build_index_from_sources()
|
||||
if self.index_path:
|
||||
self.save()
|
||||
|
||||
def save(self) -> None:
|
||||
"""Persist the memory index to disk"""
|
||||
if not self.index_path:
|
||||
logger.warning("No index_path specified, skipping save")
|
||||
return
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(self.index_path), exist_ok=True)
|
||||
|
||||
# Prepare data for serialization
|
||||
data = {
|
||||
"file_metadata": self.file_metadata,
|
||||
"contents": [item.to_dict() for item in self.contents],
|
||||
"config": {
|
||||
"chunk_size": self.chunk_size,
|
||||
"chunk_overlap": self.chunk_overlap,
|
||||
}
|
||||
}
|
||||
|
||||
# Save to JSON
|
||||
with open(self.index_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Index saved to {self.index_path} ({len(self.contents)} chunks)")
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
top_k: int,
|
||||
similarity_threshold: float,
|
||||
) -> List[MemoryItem]:
|
||||
"""
|
||||
Retrieve relevant file chunks based on query.
|
||||
|
||||
Args:
|
||||
agent_role: Agent role (not used in file memory)
|
||||
inputs: Query text
|
||||
top_k: Number of results to return
|
||||
similarity_threshold: Minimum similarity score
|
||||
|
||||
Returns:
|
||||
List of MemoryItem with file chunks
|
||||
"""
|
||||
if self.count_memories() == 0:
|
||||
return []
|
||||
|
||||
# Generate query embedding
|
||||
query_embedding = self.embedding.get_embedding(query.text)
|
||||
if isinstance(query_embedding, list):
|
||||
query_embedding = np.array(query_embedding, dtype=np.float32)
|
||||
query_embedding = query_embedding.reshape(1, -1)
|
||||
faiss.normalize_L2(query_embedding)
|
||||
|
||||
expected_dim = query_embedding.shape[1]
|
||||
|
||||
# Collect embeddings from memory items
|
||||
memory_embeddings = []
|
||||
valid_items = []
|
||||
for item in self.contents:
|
||||
if item.embedding is not None:
|
||||
if len(item.embedding) != expected_dim:
|
||||
logger.warning(
|
||||
"Skipping memory item %s: embedding dim %d != expected %d",
|
||||
item.id, len(item.embedding), expected_dim,
|
||||
)
|
||||
continue
|
||||
memory_embeddings.append(item.embedding)
|
||||
valid_items.append(item)
|
||||
|
||||
if not memory_embeddings:
|
||||
return []
|
||||
|
||||
memory_embeddings = np.array(memory_embeddings, dtype=np.float32)
|
||||
|
||||
# Build FAISS index and search
|
||||
index = faiss.IndexFlatIP(memory_embeddings.shape[1])
|
||||
index.add(memory_embeddings)
|
||||
|
||||
similarities, indices = index.search(query_embedding, min(top_k, len(valid_items)))
|
||||
|
||||
# Filter by threshold and return results
|
||||
results = []
|
||||
for i in range(len(indices[0])):
|
||||
idx = indices[0][i]
|
||||
similarity = similarities[0][i]
|
||||
|
||||
if idx != -1 and similarity >= similarity_threshold:
|
||||
results.append(valid_items[idx])
|
||||
|
||||
return results
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
"""
|
||||
FileMemory is read-only, updates are not supported.
|
||||
This method is a no-op to maintain interface compatibility.
|
||||
"""
|
||||
logger.debug("FileMemory.update() called but FileMemory is read-only")
|
||||
pass
|
||||
|
||||
# ========== Private Helper Methods ==========
|
||||
|
||||
def _load_from_file(self) -> None:
|
||||
"""Load index from JSON file"""
|
||||
try:
|
||||
with open(self.index_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.file_metadata = data.get("file_metadata", {})
|
||||
raw_contents = data.get("contents", [])
|
||||
contents: List[MemoryItem] = []
|
||||
for raw in raw_contents:
|
||||
try:
|
||||
contents.append(MemoryItem.from_dict(raw))
|
||||
except Exception:
|
||||
continue
|
||||
self.contents = contents
|
||||
|
||||
# Load config if present
|
||||
config = data.get("config", {})
|
||||
self.chunk_size = config.get("chunk_size", self.chunk_size)
|
||||
self.chunk_overlap = config.get("chunk_overlap", self.chunk_overlap)
|
||||
|
||||
logger.info(f"Loaded {len(self.contents)} chunks from index")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading index: {e}")
|
||||
self.file_metadata = {}
|
||||
self.contents = []
|
||||
|
||||
def _build_index_from_sources(self) -> None:
|
||||
"""Build index by scanning all file sources"""
|
||||
all_chunks = []
|
||||
|
||||
for source in self.file_sources:
|
||||
logger.info(f"Scanning source: {source.source_path}")
|
||||
files = self._scan_files(source)
|
||||
logger.info(f"Found {len(files)} files in {source.source_path}")
|
||||
|
||||
for file_path in files:
|
||||
chunks = self._read_and_chunk_file(file_path, source.encoding)
|
||||
all_chunks.extend(chunks)
|
||||
|
||||
logger.info(f"Total chunks to index: {len(all_chunks)}")
|
||||
|
||||
# Generate embeddings for all chunks
|
||||
self.contents = self._build_embeddings(all_chunks)
|
||||
|
||||
logger.info(f"Index built with {len(self.contents)} chunks")
|
||||
|
||||
def _validate_and_update_index(self) -> bool:
|
||||
"""
|
||||
Validate index integrity and update if files changed.
|
||||
|
||||
Returns:
|
||||
True if index was updated, False otherwise
|
||||
"""
|
||||
updated = False
|
||||
current_files = set()
|
||||
|
||||
# Scan current files
|
||||
for source in self.file_sources:
|
||||
files = self._scan_files(source)
|
||||
current_files.update(files)
|
||||
|
||||
# Check for deleted files
|
||||
indexed_files = set(self.file_metadata.keys())
|
||||
deleted_files = indexed_files - current_files
|
||||
|
||||
if deleted_files:
|
||||
logger.info(f"Removing {len(deleted_files)} deleted files from index")
|
||||
self._remove_files_from_index(deleted_files)
|
||||
updated = True
|
||||
|
||||
# Check for new or modified files
|
||||
for source in self.file_sources:
|
||||
files = self._scan_files(source)
|
||||
|
||||
for file_path in files:
|
||||
file_hash = self._compute_file_hash(file_path)
|
||||
|
||||
# New file
|
||||
if file_path not in self.file_metadata:
|
||||
logger.info(f"Indexing new file: {file_path}")
|
||||
self._index_file(file_path, source.encoding)
|
||||
updated = True
|
||||
|
||||
# Modified file
|
||||
elif self.file_metadata[file_path].get("hash") != file_hash:
|
||||
logger.info(f"Re-indexing modified file: {file_path}")
|
||||
self._remove_files_from_index([file_path])
|
||||
self._index_file(file_path, source.encoding)
|
||||
updated = True
|
||||
|
||||
return updated
|
||||
|
||||
def _scan_files(self, source: FileSourceConfig) -> List[str]:
|
||||
"""
|
||||
Scan file path and return list of matching files.
|
||||
|
||||
Args:
|
||||
source: FileSourceConfig with path and filters
|
||||
|
||||
Returns:
|
||||
List of absolute file paths
|
||||
"""
|
||||
path = Path(source.source_path).expanduser().resolve()
|
||||
|
||||
# Single file
|
||||
if path.is_file():
|
||||
if self._matches_file_types(path, source.file_types):
|
||||
return [str(path)]
|
||||
return []
|
||||
|
||||
# Directory
|
||||
if not path.is_dir():
|
||||
logger.warning(f"Path does not exist: {source.source_path}")
|
||||
return []
|
||||
|
||||
files = []
|
||||
|
||||
if source.recursive:
|
||||
# Recursive scan
|
||||
for file_path in path.rglob("*"):
|
||||
if file_path.is_file() and self._matches_file_types(file_path, source.file_types):
|
||||
files.append(str(file_path))
|
||||
else:
|
||||
# Non-recursive scan
|
||||
for file_path in path.glob("*"):
|
||||
if file_path.is_file() and self._matches_file_types(file_path, source.file_types):
|
||||
files.append(str(file_path))
|
||||
|
||||
return files
|
||||
|
||||
def _matches_file_types(self, file_path: Path, file_types: List[str]) -> bool:
|
||||
"""Check if file matches the file type filter"""
|
||||
if file_types is None:
|
||||
return True
|
||||
return file_path.suffix in file_types
|
||||
|
||||
def _read_and_chunk_file(self, file_path: str, encoding: str = "utf-8") -> List[Dict]:
|
||||
"""
|
||||
Read file and split into chunks.
|
||||
|
||||
Args:
|
||||
file_path: Path to file
|
||||
encoding: File encoding
|
||||
|
||||
Returns:
|
||||
List of chunk dictionaries with content and metadata
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding=encoding, errors='ignore') as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading file {file_path}: {e}")
|
||||
return []
|
||||
|
||||
if not content.strip():
|
||||
return []
|
||||
|
||||
# Compute file hash
|
||||
file_hash = self._compute_file_hash(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# Chunk the content
|
||||
chunks = self._chunk_text(content)
|
||||
|
||||
# Build chunk metadata
|
||||
chunk_dicts = []
|
||||
for i, chunk_text in enumerate(chunks):
|
||||
chunk_dicts.append({
|
||||
"content": chunk_text,
|
||||
"metadata": {
|
||||
"source_type": "file",
|
||||
"file_path": file_path,
|
||||
"file_name": os.path.basename(file_path),
|
||||
"file_hash": file_hash,
|
||||
"file_size": file_size,
|
||||
"chunk_index": i,
|
||||
"total_chunks": len(chunks),
|
||||
"encoding": encoding,
|
||||
}
|
||||
})
|
||||
|
||||
# Update file metadata cache
|
||||
self.file_metadata[file_path] = {
|
||||
"hash": file_hash,
|
||||
"size": file_size,
|
||||
"chunks_count": len(chunks),
|
||||
"indexed_at": time.time(),
|
||||
}
|
||||
|
||||
return chunk_dicts
|
||||
|
||||
def _chunk_text(self, text: str) -> List[str]:
|
||||
"""
|
||||
Split text into chunks with overlap.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
List of text chunks
|
||||
"""
|
||||
if len(text) <= self.chunk_size:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
start = 0
|
||||
|
||||
while start < len(text):
|
||||
end = start + self.chunk_size
|
||||
chunk = text[start:end]
|
||||
|
||||
# Try to break at sentence boundary
|
||||
if end < len(text):
|
||||
# Look for sentence endings
|
||||
last_sentence = max(
|
||||
chunk.rfind('。'),
|
||||
chunk.rfind('!'),
|
||||
chunk.rfind('?'),
|
||||
chunk.rfind('.'),
|
||||
chunk.rfind('!'),
|
||||
chunk.rfind('?'),
|
||||
chunk.rfind('\n')
|
||||
)
|
||||
|
||||
if last_sentence > self.chunk_size // 2: # Don't break too early
|
||||
chunk = chunk[:last_sentence + 1]
|
||||
end = start + last_sentence + 1
|
||||
|
||||
chunks.append(chunk.strip())
|
||||
|
||||
# Move start with overlap
|
||||
start = end - self.chunk_overlap
|
||||
|
||||
if start >= len(text):
|
||||
break
|
||||
|
||||
return [c for c in chunks if c] # Filter empty chunks
|
||||
|
||||
def _build_embeddings(self, chunks: List[Dict]) -> List[MemoryItem]:
|
||||
"""
|
||||
Generate embeddings for chunks and create MemoryItems.
|
||||
|
||||
Args:
|
||||
chunks: List of chunk dictionaries
|
||||
|
||||
Returns:
|
||||
List of MemoryItem objects
|
||||
"""
|
||||
memory_items = []
|
||||
|
||||
for chunk_dict in chunks:
|
||||
content = chunk_dict["content"]
|
||||
metadata = chunk_dict["metadata"]
|
||||
|
||||
# Generate embedding
|
||||
try:
|
||||
embedding = self.embedding.get_embedding(content)
|
||||
if isinstance(embedding, list):
|
||||
embedding = np.array(embedding, dtype=np.float32).reshape(1, -1)
|
||||
faiss.normalize_L2(embedding)
|
||||
embedding_list = embedding.tolist()[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating embedding for chunk: {e}")
|
||||
continue
|
||||
|
||||
# Create MemoryItem
|
||||
item_id = f"{metadata['file_hash']}_{metadata['chunk_index']}"
|
||||
memory_item = MemoryItem(
|
||||
id=item_id,
|
||||
content_summary=content,
|
||||
metadata=metadata,
|
||||
embedding=embedding_list,
|
||||
timestamp=time.time(),
|
||||
)
|
||||
|
||||
memory_items.append(memory_item)
|
||||
|
||||
return memory_items
|
||||
|
||||
def _compute_file_hash(self, file_path: str) -> str:
|
||||
"""Compute MD5 hash of file"""
|
||||
hash_md5 = hashlib.md5()
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
hash_md5.update(chunk)
|
||||
return hash_md5.hexdigest()[:16]
|
||||
except Exception as e:
|
||||
logger.error(f"Error computing hash for {file_path}: {e}")
|
||||
return "error"
|
||||
|
||||
def _index_file(self, file_path: str, encoding: str = "utf-8") -> None:
|
||||
"""Index a single file (helper for incremental updates)"""
|
||||
chunks = self._read_and_chunk_file(file_path, encoding)
|
||||
if chunks:
|
||||
new_items = self._build_embeddings(chunks)
|
||||
self.contents.extend(new_items)
|
||||
|
||||
def _remove_files_from_index(self, file_paths: List[str]) -> None:
|
||||
"""Remove chunks from deleted files"""
|
||||
file_paths_set = set(file_paths)
|
||||
|
||||
# Filter out chunks from deleted files
|
||||
self.contents = [
|
||||
item for item in self.contents
|
||||
if item.metadata.get("file_path") not in file_paths_set
|
||||
]
|
||||
|
||||
# Remove from metadata
|
||||
for file_path in file_paths:
|
||||
self.file_metadata.pop(file_path, None)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Mem0 managed memory store implementation."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from entity.configs import MemoryStoreConfig
|
||||
from entity.configs.node.memory import Mem0MemoryConfig
|
||||
from runtime.node.agent.memory.memory_base import (
|
||||
MemoryBase,
|
||||
MemoryContentSnapshot,
|
||||
MemoryItem,
|
||||
MemoryWritePayload,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_mem0_client(config: Mem0MemoryConfig):
|
||||
"""Lazy-import mem0ai and create a MemoryClient."""
|
||||
try:
|
||||
from mem0 import MemoryClient
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mem0ai is required for Mem0Memory. Install it with: pip install mem0ai"
|
||||
)
|
||||
|
||||
client_kwargs: Dict[str, Any] = {}
|
||||
if config.api_key:
|
||||
client_kwargs["api_key"] = config.api_key
|
||||
if config.org_id:
|
||||
client_kwargs["org_id"] = config.org_id
|
||||
if config.project_id:
|
||||
client_kwargs["project_id"] = config.project_id
|
||||
|
||||
return MemoryClient(**client_kwargs)
|
||||
|
||||
|
||||
class Mem0Memory(MemoryBase):
|
||||
"""Memory store backed by Mem0's managed cloud service.
|
||||
|
||||
Mem0 handles embeddings, storage, and semantic search server-side.
|
||||
No local persistence or embedding computation is needed.
|
||||
|
||||
Important API constraints:
|
||||
- Agent memories use role="assistant" + agent_id
|
||||
- user_id and agent_id are independent scoping dimensions and can be
|
||||
combined in both add() and search() calls.
|
||||
- search() uses filters dict; add() uses top-level kwargs.
|
||||
- SDK returns {"memories": [...]} from search.
|
||||
"""
|
||||
|
||||
def __init__(self, store: MemoryStoreConfig):
|
||||
config = store.as_config(Mem0MemoryConfig)
|
||||
if not config:
|
||||
raise ValueError("Mem0Memory requires a Mem0 memory store configuration")
|
||||
super().__init__(store)
|
||||
self.config = config
|
||||
self.client = _get_mem0_client(config)
|
||||
self.user_id = config.user_id
|
||||
self.agent_id = config.agent_id
|
||||
|
||||
# -------- Persistence (no-ops for cloud-managed store) --------
|
||||
|
||||
def load(self) -> None:
|
||||
"""No-op: Mem0 manages persistence server-side."""
|
||||
pass
|
||||
|
||||
def save(self) -> None:
|
||||
"""No-op: Mem0 manages persistence server-side."""
|
||||
pass
|
||||
|
||||
# -------- Retrieval --------
|
||||
|
||||
def _build_search_filters(self, agent_role: str) -> Dict[str, Any]:
|
||||
"""Build the filters dict for Mem0 search.
|
||||
|
||||
Mem0 search requires a filters dict for entity scoping.
|
||||
user_id and agent_id are stored as separate records, so
|
||||
when both are configured we use an OR filter to match either.
|
||||
"""
|
||||
if self.user_id and self.agent_id:
|
||||
return {
|
||||
"OR": [
|
||||
{"user_id": self.user_id},
|
||||
{"agent_id": self.agent_id},
|
||||
]
|
||||
}
|
||||
elif self.user_id:
|
||||
return {"user_id": self.user_id}
|
||||
elif self.agent_id:
|
||||
return {"agent_id": self.agent_id}
|
||||
else:
|
||||
# Fallback: use agent_role as agent_id
|
||||
return {"agent_id": agent_role}
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
top_k: int,
|
||||
similarity_threshold: float,
|
||||
) -> List[MemoryItem]:
|
||||
"""Search Mem0 for relevant memories.
|
||||
|
||||
Uses the filters dict to scope by user_id, agent_id, or both
|
||||
(via OR filter). The SDK returns {"memories": [...]}.
|
||||
"""
|
||||
if not query.text.strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
filters = self._build_search_filters(agent_role)
|
||||
search_kwargs: Dict[str, Any] = {
|
||||
"query": query.text,
|
||||
"top_k": top_k,
|
||||
"filters": filters,
|
||||
}
|
||||
if similarity_threshold >= 0:
|
||||
search_kwargs["threshold"] = similarity_threshold
|
||||
|
||||
response = self.client.search(**search_kwargs)
|
||||
|
||||
# SDK returns {"memories": [...]} — extract the list
|
||||
if isinstance(response, dict):
|
||||
raw_results = response.get("memories", response.get("results", []))
|
||||
else:
|
||||
raw_results = response
|
||||
except Exception as e:
|
||||
logger.error("Mem0 search failed: %s", e)
|
||||
return []
|
||||
|
||||
items: List[MemoryItem] = []
|
||||
for entry in raw_results:
|
||||
item = MemoryItem(
|
||||
id=entry.get("id", f"mem0_{uuid.uuid4().hex}"),
|
||||
content_summary=entry.get("memory", ""),
|
||||
metadata={
|
||||
"agent_role": agent_role,
|
||||
"score": entry.get("score"),
|
||||
"categories": entry.get("categories", []),
|
||||
"source": "mem0",
|
||||
},
|
||||
timestamp=time.time(),
|
||||
)
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
# -------- Update --------
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
"""Store user input as a memory in Mem0.
|
||||
|
||||
Only user input is sent for extraction. Assistant output is excluded
|
||||
to prevent noise memories from the LLM's responses.
|
||||
"""
|
||||
raw_input = payload.inputs_text or ""
|
||||
if not raw_input.strip():
|
||||
return
|
||||
|
||||
messages = self._build_messages(payload)
|
||||
if not messages:
|
||||
return
|
||||
|
||||
add_kwargs: Dict[str, Any] = {
|
||||
"messages": messages,
|
||||
"infer": True,
|
||||
}
|
||||
|
||||
# Include both user_id and agent_id when available — they are
|
||||
# independent scoping dimensions in Mem0, not mutually exclusive.
|
||||
if self.agent_id:
|
||||
add_kwargs["agent_id"] = self.agent_id
|
||||
if self.user_id:
|
||||
add_kwargs["user_id"] = self.user_id
|
||||
|
||||
# Fallback when neither is configured
|
||||
if "agent_id" not in add_kwargs and "user_id" not in add_kwargs:
|
||||
add_kwargs["agent_id"] = payload.agent_role
|
||||
|
||||
try:
|
||||
result = self.client.add(**add_kwargs)
|
||||
logger.info("Mem0 add result: %s", result)
|
||||
except Exception as e:
|
||||
logger.error("Mem0 add failed: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def _clean_pipeline_text(text: str) -> str:
|
||||
"""Strip ChatDev pipeline headers so Mem0 sees clean conversational text.
|
||||
|
||||
The executor wraps each input with '=== INPUT FROM <source> (<role>) ==='
|
||||
headers. Mem0's extraction LLM treats these as system metadata and skips
|
||||
them, resulting in zero memories extracted.
|
||||
"""
|
||||
cleaned = re.sub(r"===\s*INPUT FROM\s+\S+\s*\(\w+\)\s*===\s*", "", text)
|
||||
return cleaned.strip()
|
||||
|
||||
def _build_messages(self, payload: MemoryWritePayload) -> List[Dict[str, str]]:
|
||||
"""Build Mem0-compatible message list from write payload.
|
||||
|
||||
Only sends user input to Mem0. Assistant output is excluded because
|
||||
Mem0's extraction LLM processes ALL messages and extracts facts from
|
||||
assistant responses too, creating noise memories like "Assistant says
|
||||
Python is fascinating" instead of actual user facts.
|
||||
"""
|
||||
messages: List[Dict[str, str]] = []
|
||||
|
||||
raw_input = payload.inputs_text or ""
|
||||
clean_input = self._clean_pipeline_text(raw_input)
|
||||
if clean_input:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": clean_input,
|
||||
})
|
||||
|
||||
return messages
|
||||
Executable
+304
@@ -0,0 +1,304 @@
|
||||
"""Base memory abstractions with multimodal snapshots."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
import time
|
||||
|
||||
from entity.configs import MemoryAttachmentConfig, MemoryStoreConfig
|
||||
from entity.configs.node.memory import FileMemoryConfig, SimpleMemoryConfig
|
||||
from entity.enums import AgentExecFlowStage
|
||||
from entity.messages import Message, MessageBlock
|
||||
from runtime.node.agent.memory.embedding import EmbeddingBase, EmbeddingFactory
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryContentSnapshot:
|
||||
"""Lightweight serialization of a multimodal payload."""
|
||||
|
||||
text: str
|
||||
blocks: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {"text": self.text, "blocks": self.blocks}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any] | None) -> "MemoryContentSnapshot | None":
|
||||
if not payload:
|
||||
return None
|
||||
text = payload.get("text", "")
|
||||
blocks = payload.get("blocks") or []
|
||||
return cls(text=text, blocks=list(blocks))
|
||||
|
||||
@classmethod
|
||||
def from_message(cls, message: Message | str | None) -> "MemoryContentSnapshot | None":
|
||||
if message is None:
|
||||
return None
|
||||
if isinstance(message, Message):
|
||||
return cls(
|
||||
text=message.text_content(),
|
||||
blocks=[
|
||||
{
|
||||
"role": message.role.value,
|
||||
"block": block.to_dict(include_data=True),
|
||||
}
|
||||
for block in message.blocks()
|
||||
],
|
||||
)
|
||||
if isinstance(message, str):
|
||||
return cls(text=message, blocks=[])
|
||||
return cls(text=str(message), blocks=[])
|
||||
|
||||
@classmethod
|
||||
def from_messages(cls, messages: List[Message]) -> "MemoryContentSnapshot | None":
|
||||
if not messages:
|
||||
return None
|
||||
parts: List[str] = []
|
||||
blocks: List[Dict[str, Any]] = []
|
||||
for message in messages:
|
||||
parts.append(f"({message.role.value}) {message.text_content()}")
|
||||
for block in message.blocks():
|
||||
blocks.append(
|
||||
{
|
||||
"role": message.role.value,
|
||||
"block": block.to_dict(include_data=True),
|
||||
}
|
||||
)
|
||||
return cls(text="\n\n".join(parts), blocks=blocks)
|
||||
|
||||
def to_message_blocks(self) -> List[MessageBlock]:
|
||||
blocks: List[MessageBlock] = []
|
||||
for payload in self.blocks:
|
||||
block_data = payload.get("block") if isinstance(payload, dict) else None
|
||||
if not isinstance(block_data, dict):
|
||||
continue
|
||||
try:
|
||||
blocks.append(MessageBlock.from_dict(block_data))
|
||||
except Exception:
|
||||
continue
|
||||
return blocks
|
||||
|
||||
def attachment_overview(self) -> List[Dict[str, Any]]:
|
||||
attachments: List[Dict[str, Any]] = []
|
||||
for payload in self.blocks:
|
||||
block_data = payload.get("block") if isinstance(payload, dict) else None
|
||||
if not isinstance(block_data, dict):
|
||||
continue
|
||||
attachment = block_data.get("attachment")
|
||||
if attachment:
|
||||
attachments.append(
|
||||
{
|
||||
"role": payload.get("role"),
|
||||
"attachment_id": attachment.get("attachment_id"),
|
||||
"mime_type": attachment.get("mime_type"),
|
||||
"name": attachment.get("name"),
|
||||
"size": attachment.get("size"),
|
||||
}
|
||||
)
|
||||
return attachments
|
||||
|
||||
@classmethod
|
||||
def from_blocks(
|
||||
cls,
|
||||
*,
|
||||
text: str,
|
||||
blocks: List[MessageBlock],
|
||||
role: str = "input",
|
||||
) -> "MemoryContentSnapshot":
|
||||
serialized = [
|
||||
{
|
||||
"role": role,
|
||||
"block": block.to_dict(include_data=True),
|
||||
}
|
||||
for block in blocks
|
||||
]
|
||||
return cls(text=text, blocks=serialized)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryItem:
|
||||
id: str
|
||||
content_summary: str
|
||||
metadata: Dict[str, Any]
|
||||
embedding: Optional[List[float]] = None
|
||||
timestamp: float | None = None
|
||||
input_snapshot: MemoryContentSnapshot | None = None
|
||||
output_snapshot: MemoryContentSnapshot | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.timestamp is None:
|
||||
self.timestamp = time.time()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"content_summary": self.content_summary,
|
||||
"metadata": self.metadata,
|
||||
"embedding": self.embedding,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
if self.input_snapshot:
|
||||
payload["input_snapshot"] = self.input_snapshot.to_dict()
|
||||
if self.output_snapshot:
|
||||
payload["output_snapshot"] = self.output_snapshot.to_dict()
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Dict[str, Any]) -> "MemoryItem":
|
||||
return cls(
|
||||
id=payload["id"],
|
||||
content_summary=payload.get("content_summary", ""),
|
||||
metadata=payload.get("metadata") or {},
|
||||
embedding=payload.get("embedding"),
|
||||
timestamp=payload.get("timestamp"),
|
||||
input_snapshot=MemoryContentSnapshot.from_dict(payload.get("input_snapshot")),
|
||||
output_snapshot=MemoryContentSnapshot.from_dict(payload.get("output_snapshot")),
|
||||
)
|
||||
|
||||
def attachments(self) -> List[Dict[str, Any]]:
|
||||
attachments: List[Dict[str, Any]] = []
|
||||
if self.input_snapshot:
|
||||
attachments.extend(self.input_snapshot.attachment_overview())
|
||||
if self.output_snapshot:
|
||||
attachments.extend(self.output_snapshot.attachment_overview())
|
||||
return attachments
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryWritePayload:
|
||||
agent_role: str
|
||||
inputs_text: str
|
||||
input_snapshot: MemoryContentSnapshot | None
|
||||
output_snapshot: MemoryContentSnapshot | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryRetrievalResult:
|
||||
formatted_text: str
|
||||
items: List[MemoryItem]
|
||||
|
||||
def has_multimodal(self) -> bool:
|
||||
return any(
|
||||
(item.input_snapshot and item.input_snapshot.blocks)
|
||||
or (item.output_snapshot and item.output_snapshot.blocks)
|
||||
for item in self.items
|
||||
)
|
||||
|
||||
def attachment_overview(self) -> List[Dict[str, Any]]:
|
||||
attachments: List[Dict[str, Any]] = []
|
||||
for item in self.items:
|
||||
attachments.extend(item.attachments())
|
||||
return attachments
|
||||
|
||||
|
||||
class MemoryBase:
|
||||
def __init__(self, store: MemoryStoreConfig):
|
||||
self.store = store
|
||||
self.name = store.name
|
||||
self.contents: List[MemoryItem] = []
|
||||
|
||||
embedding_cfg = None
|
||||
simple_cfg = store.as_config(SimpleMemoryConfig)
|
||||
file_cfg = store.as_config(FileMemoryConfig)
|
||||
if simple_cfg and simple_cfg.embedding:
|
||||
embedding_cfg = simple_cfg.embedding
|
||||
elif file_cfg and file_cfg.embedding:
|
||||
embedding_cfg = file_cfg.embedding
|
||||
|
||||
self.embedding: EmbeddingBase | None = (
|
||||
EmbeddingFactory.create_embedding(embedding_cfg) if embedding_cfg else None
|
||||
)
|
||||
|
||||
def count_memories(self) -> int:
|
||||
return len(self.contents)
|
||||
|
||||
def load(self) -> None: # pragma: no cover - implemented by subclasses
|
||||
raise NotImplementedError
|
||||
|
||||
def save(self) -> None: # pragma: no cover - implemented by subclasses
|
||||
raise NotImplementedError
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
top_k: int,
|
||||
similarity_threshold: float,
|
||||
) -> List[MemoryItem]:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MemoryManager:
|
||||
def __init__(self, attachments: List[MemoryAttachmentConfig], stores: Dict[str, MemoryBase]):
|
||||
self.attachments = attachments
|
||||
self.memories: Dict[str, MemoryBase] = {}
|
||||
for attachment in attachments:
|
||||
memory = stores.get(attachment.name)
|
||||
if not memory:
|
||||
raise ValueError(f"memory store {attachment.name} not found")
|
||||
self.memories[attachment.name] = memory
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
current_stage: AgentExecFlowStage,
|
||||
) -> MemoryRetrievalResult | None:
|
||||
results: List[tuple[str, MemoryItem, float]] = []
|
||||
for attachment in self.attachments:
|
||||
if attachment.retrieve_stage and current_stage not in attachment.retrieve_stage:
|
||||
continue
|
||||
if not attachment.read:
|
||||
continue
|
||||
memory = self.memories.get(attachment.name)
|
||||
if not memory:
|
||||
continue
|
||||
items = memory.retrieve(agent_role, query, attachment.top_k, attachment.similarity_threshold)
|
||||
for item in items:
|
||||
combined_score = self._score_memory(item, query.text)
|
||||
results.append((attachment.name, item, combined_score))
|
||||
|
||||
if not results:
|
||||
return None
|
||||
|
||||
results.sort(key=lambda entry: entry[2], reverse=True)
|
||||
formatted = ["===== Related Memories ====="]
|
||||
grouped: Dict[str, List[MemoryItem]] = {}
|
||||
for name, item, _ in results:
|
||||
grouped.setdefault(name, []).append(item)
|
||||
for name, items in grouped.items():
|
||||
formatted.append(f"\n--- {name} ---")
|
||||
for idx, item in enumerate(items, 1):
|
||||
formatted.append(f"{idx}. {item.content_summary}")
|
||||
formatted.append("\n===== End of Memory =====")
|
||||
|
||||
ordered_items = [item for _, item, _ in results]
|
||||
return MemoryRetrievalResult(formatted_text="\n".join(formatted), items=ordered_items)
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
for attachment in self.attachments:
|
||||
if not attachment.write:
|
||||
continue
|
||||
memory = self.memories.get(attachment.name)
|
||||
if not memory:
|
||||
continue
|
||||
memory.update(payload)
|
||||
memory.save()
|
||||
|
||||
def _score_memory(self, memory_item: MemoryItem, query: str) -> float:
|
||||
current_time = time.time()
|
||||
age_hours = (current_time - (memory_item.timestamp or current_time)) / 3600
|
||||
time_decay = max(0.1, 1.0 - age_hours / (24 * 30))
|
||||
length = len(memory_item.content_summary)
|
||||
if length < 20:
|
||||
length_factor = 0.5
|
||||
elif length > 200:
|
||||
length_factor = 0.8
|
||||
else:
|
||||
length_factor = 1.0
|
||||
query_words = set(query.lower().split())
|
||||
content_words = set(memory_item.content_summary.lower().split())
|
||||
relevance = len(query_words & content_words) / len(query_words) if query_words else 0.0
|
||||
return 0.7 * time_decay * length_factor + 0.3 * relevance
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
"""Registry for memory store implementations."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from typing import Any, Callable, Dict, Type
|
||||
|
||||
from schema_registry import register_memory_store_schema
|
||||
from utils.registry import Registry, RegistryEntry, RegistryError
|
||||
from entity.configs import MemoryStoreConfig
|
||||
from runtime.node.agent.memory.memory_base import MemoryBase
|
||||
|
||||
memory_store_registry = Registry("memory_store")
|
||||
_BUILTINS_LOADED = False
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MemoryStoreRegistration:
|
||||
name: str
|
||||
config_cls: Type[Any]
|
||||
factory: Callable[["MemoryStoreConfig"], "MemoryBase"]
|
||||
summary: str | None = None
|
||||
|
||||
|
||||
def _ensure_builtins_loaded() -> None:
|
||||
global _BUILTINS_LOADED
|
||||
if not _BUILTINS_LOADED:
|
||||
import_module("runtime.node.agent.memory.builtin_stores")
|
||||
_BUILTINS_LOADED = True
|
||||
|
||||
|
||||
def register_memory_store(
|
||||
name: str,
|
||||
*,
|
||||
config_cls: Type[Any],
|
||||
factory: Callable[["MemoryStoreConfig"], "MemoryBase"],
|
||||
summary: str | None = None,
|
||||
) -> None:
|
||||
if name in memory_store_registry.names():
|
||||
raise RegistryError(f"Memory store '{name}' already registered")
|
||||
entry = MemoryStoreRegistration(name=name, config_cls=config_cls, factory=factory, summary=summary)
|
||||
memory_store_registry.register(name, target=entry)
|
||||
register_memory_store_schema(name, config_cls=config_cls, summary=summary)
|
||||
|
||||
|
||||
def get_memory_store_registration(name: str) -> MemoryStoreRegistration:
|
||||
_ensure_builtins_loaded()
|
||||
entry: RegistryEntry = memory_store_registry.get(name)
|
||||
registration = entry.load()
|
||||
if not isinstance(registration, MemoryStoreRegistration):
|
||||
raise RegistryError(f"Entry '{name}' is not a MemoryStoreRegistration")
|
||||
return registration
|
||||
|
||||
|
||||
def iter_memory_store_registrations() -> Dict[str, MemoryStoreRegistration]:
|
||||
_ensure_builtins_loaded()
|
||||
return {name: entry.load() for name, entry in memory_store_registry.items()}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"memory_store_registry",
|
||||
"MemoryStoreRegistration",
|
||||
"register_memory_store",
|
||||
"get_memory_store_registration",
|
||||
"iter_memory_store_registrations",
|
||||
]
|
||||
Executable
+294
@@ -0,0 +1,294 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
from entity.configs import MemoryStoreConfig
|
||||
from entity.configs.node.memory import SimpleMemoryConfig
|
||||
from runtime.node.agent.memory.memory_base import (
|
||||
MemoryBase,
|
||||
MemoryContentSnapshot,
|
||||
MemoryItem,
|
||||
MemoryWritePayload,
|
||||
)
|
||||
import faiss
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SimpleMemory(MemoryBase):
|
||||
def __init__(self, store: MemoryStoreConfig):
|
||||
config = store.as_config(SimpleMemoryConfig)
|
||||
if not config:
|
||||
raise ValueError("SimpleMemory requires a simple memory store configuration")
|
||||
super().__init__(store)
|
||||
self.config = config
|
||||
# Optimized prompt templates for clarity
|
||||
self.retrieve_prompt = "Query: {input}"
|
||||
self.update_prompt = "Input: {input}\nOutput: {output}"
|
||||
self.memory_path = self.config.memory_path # auto
|
||||
|
||||
# Content extraction configuration
|
||||
self.max_content_length = 500 # Maximum content length
|
||||
self.min_content_length = 20 # Minimum content length
|
||||
|
||||
def _extract_key_content(self, content: str) -> str:
|
||||
"""Extract key content while stripping redundant text."""
|
||||
# Remove redundant whitespace
|
||||
content = re.sub(r'\s+', ' ', content.strip())
|
||||
|
||||
# Skip heavy processing for short snippets
|
||||
if len(content) <= 100:
|
||||
return content
|
||||
|
||||
# Remove common templated instructions
|
||||
content = re.sub(r'(?:Agent|Model) Role:.*?\n\n', '', content)
|
||||
content = re.sub("(?:You are|\u4f60\u662f\u4e00\u4f4d).*?(?:,|\uff0c)", '', content)
|
||||
content = re.sub("(?:User will input|\u7528\u6237\u4f1a\u8f93\u5165).*?(?:,|\uff0c)", '', content)
|
||||
content = re.sub("(?:You need to|\u4f60\u9700\u8981).*?(?:,|\uff0c)", '', content)
|
||||
|
||||
# Extract key sentences while skipping very short ones
|
||||
sentences = re.split(r'[\u3002\uff01\uff1f\uff1b\n]', content)
|
||||
key_sentences = [s.strip() for s in sentences if len(s.strip()) >= self.min_content_length]
|
||||
|
||||
# Fallback to original content when no sentence survives
|
||||
if not key_sentences:
|
||||
return content[:self.max_content_length]
|
||||
|
||||
# Recombine and limit the number of sentences (max 3)
|
||||
extracted_content = '\u3002'.join(key_sentences[:3])
|
||||
if len(extracted_content) > self.max_content_length:
|
||||
extracted_content = extracted_content[:self.max_content_length] + "..."
|
||||
|
||||
return extracted_content.strip()
|
||||
|
||||
def _generate_content_hash(self, content: str) -> str:
|
||||
"""Generate a content hash used for deduplication."""
|
||||
return hashlib.md5(content.encode('utf-8')).hexdigest()[:8]
|
||||
|
||||
def load(self) -> None:
|
||||
if self.memory_path and os.path.exists(self.memory_path) and self.memory_path.endswith(".json"):
|
||||
try:
|
||||
with open(self.memory_path) as file:
|
||||
raw_data = json.load(file)
|
||||
contents = []
|
||||
for raw in raw_data:
|
||||
try:
|
||||
contents.append(MemoryItem.from_dict(raw))
|
||||
except Exception:
|
||||
continue
|
||||
self.contents = contents
|
||||
except Exception:
|
||||
self.contents = []
|
||||
|
||||
def save(self) -> None:
|
||||
if self.memory_path and self.memory_path.endswith(".json"):
|
||||
os.makedirs(os.path.dirname(self.memory_path), exist_ok=True)
|
||||
with open(self.memory_path, "w") as file:
|
||||
json.dump([item.to_dict() for item in self.contents], file, indent=2, ensure_ascii=False)
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
agent_role: str,
|
||||
query: MemoryContentSnapshot,
|
||||
top_k: int,
|
||||
similarity_threshold: float,
|
||||
) -> List[MemoryItem]:
|
||||
if self.count_memories() == 0 or not self.embedding:
|
||||
return []
|
||||
|
||||
# Build an optimized query for retrieval
|
||||
query_text = self.retrieve_prompt.format(input=query.text)
|
||||
query_text = self._extract_key_content(query_text)
|
||||
|
||||
inputs_embedding = self.embedding.get_embedding(query_text)
|
||||
if isinstance(inputs_embedding, list):
|
||||
inputs_embedding = np.array(inputs_embedding, dtype=np.float32)
|
||||
inputs_embedding = inputs_embedding.reshape(1, -1)
|
||||
faiss.normalize_L2(inputs_embedding)
|
||||
|
||||
expected_dim = inputs_embedding.shape[1]
|
||||
|
||||
memory_embeddings = []
|
||||
valid_items = []
|
||||
for item in self.contents:
|
||||
if item.embedding is not None:
|
||||
if len(item.embedding) != expected_dim:
|
||||
logger.warning(
|
||||
"Skipping memory item %s: embedding dim %d != expected %d",
|
||||
item.id, len(item.embedding), expected_dim,
|
||||
)
|
||||
continue
|
||||
memory_embeddings.append(item.embedding)
|
||||
valid_items.append(item)
|
||||
|
||||
if not memory_embeddings:
|
||||
return []
|
||||
|
||||
memory_embeddings = np.array(memory_embeddings, dtype=np.float32)
|
||||
|
||||
# Use an efficient inner-product index
|
||||
index = faiss.IndexFlatIP(memory_embeddings.shape[1])
|
||||
index.add(memory_embeddings)
|
||||
|
||||
# Retrieve extra candidates for reranking
|
||||
retrieval_k = min(top_k * 3, len(valid_items))
|
||||
similarities, indices = index.search(inputs_embedding, retrieval_k)
|
||||
|
||||
# Filter and rerank the candidates
|
||||
candidates = []
|
||||
for i in range(len(indices[0])):
|
||||
idx = indices[0][i]
|
||||
similarity = similarities[0][i]
|
||||
|
||||
if idx != -1 and similarity >= similarity_threshold:
|
||||
item = valid_items[idx]
|
||||
# Calculate an auxiliary semantic similarity score
|
||||
semantic_score = self._calculate_semantic_similarity(query_text, item.content_summary)
|
||||
# Combine similarity metrics
|
||||
combined_score = 0.7 * similarity + 0.3 * semantic_score
|
||||
candidates.append((item, combined_score))
|
||||
|
||||
# Sort by the combined score and return the top_k items
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
results = [item for item, score in candidates[:top_k]]
|
||||
|
||||
return results
|
||||
|
||||
def _calculate_semantic_similarity(self, query: str, content: str) -> float:
|
||||
"""Compute a semantic similarity value."""
|
||||
# Enhanced semantic similarity computation
|
||||
query_lower = query.lower()
|
||||
content_lower = content.lower()
|
||||
|
||||
# 1. Token overlap (Jaccard similarity)
|
||||
query_words = set(query_lower.split())
|
||||
content_words = set(content_lower.split())
|
||||
|
||||
if not query_words or not content_words:
|
||||
jaccard_sim = 0.0
|
||||
else:
|
||||
intersection = query_words & content_words
|
||||
union = query_words | content_words
|
||||
jaccard_sim = len(intersection) / len(union) if union else 0.0
|
||||
|
||||
# 2. Longest common subsequence similarity
|
||||
lcs_sim = self._calculate_lcs_similarity(query_lower, content_lower)
|
||||
|
||||
# 3. Keyword match score
|
||||
keyword_sim = self._calculate_keyword_similarity(query_lower, content_lower)
|
||||
|
||||
# 4. Length penalty factor (avoid overly short/long matches)
|
||||
length_factor = self._calculate_length_factor(query_lower, content_lower)
|
||||
|
||||
# Weighted final score
|
||||
final_score = (0.4 * jaccard_sim +
|
||||
0.3 * lcs_sim +
|
||||
0.2 * keyword_sim +
|
||||
0.1 * length_factor)
|
||||
|
||||
return min(final_score, 1.0)
|
||||
|
||||
def _calculate_lcs_similarity(self, s1: str, s2: str) -> float:
|
||||
"""Compute longest common subsequence similarity."""
|
||||
m, n = len(s1), len(s2)
|
||||
dp = [[0] * (n + 1) for _ in range(m + 1)]
|
||||
|
||||
for i in range(1, m + 1):
|
||||
for j in range(1, n + 1):
|
||||
if s1[i-1] == s2[j-1]:
|
||||
dp[i][j] = dp[i-1][j-1] + 1
|
||||
else:
|
||||
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
|
||||
|
||||
lcs_length = dp[m][n]
|
||||
return lcs_length / max(len(s1), len(s2)) if max(len(s1), len(s2)) > 0 else 0.0
|
||||
|
||||
def _calculate_keyword_similarity(self, query: str, content: str) -> float:
|
||||
"""Compute keyword match similarity."""
|
||||
# Extract potential keywords (length >= 2)
|
||||
query_keywords = set(word for word in query.split() if len(word) >= 2)
|
||||
content_keywords = set(word for word in content.split() if len(word) >= 2)
|
||||
|
||||
if not query_keywords:
|
||||
return 0.0
|
||||
|
||||
matches = query_keywords & content_keywords
|
||||
return len(matches) / len(query_keywords)
|
||||
|
||||
def _calculate_length_factor(self, query: str, content: str) -> float:
|
||||
"""Penalize matches that deviate too much in length."""
|
||||
query_len = len(query)
|
||||
content_len = len(content)
|
||||
|
||||
if content_len == 0:
|
||||
return 0.0
|
||||
|
||||
# Ideal length ratio range
|
||||
ideal_ratio_min = 0.5
|
||||
ideal_ratio_max = 2.0
|
||||
|
||||
ratio = content_len / query_len
|
||||
|
||||
if ideal_ratio_min <= ratio <= ideal_ratio_max:
|
||||
return 1.0
|
||||
elif ratio < ideal_ratio_min:
|
||||
return ratio / ideal_ratio_min
|
||||
else:
|
||||
return max(0.1, ideal_ratio_max / ratio)
|
||||
|
||||
def update(self, payload: MemoryWritePayload) -> None:
|
||||
if not self.embedding:
|
||||
return
|
||||
|
||||
snapshot = payload.output_snapshot
|
||||
if not snapshot or not snapshot.text.strip():
|
||||
return
|
||||
|
||||
raw_content = self.update_prompt.format(
|
||||
input=payload.inputs_text,
|
||||
output=snapshot.text,
|
||||
)
|
||||
extracted_content = self._extract_key_content(raw_content)
|
||||
|
||||
if len(extracted_content) < self.min_content_length:
|
||||
return
|
||||
|
||||
content_hash = self._generate_content_hash(extracted_content)
|
||||
for existing_item in self.contents:
|
||||
existing_hash = self._generate_content_hash(existing_item.content_summary)
|
||||
if existing_hash == content_hash:
|
||||
return
|
||||
|
||||
embedding_vector = self.embedding.get_embedding(extracted_content)
|
||||
if isinstance(embedding_vector, list):
|
||||
embedding_vector = np.array(embedding_vector, dtype=np.float32)
|
||||
if embedding_vector is None:
|
||||
return
|
||||
embedding_array = np.array(embedding_vector, dtype=np.float32).reshape(1, -1)
|
||||
faiss.normalize_L2(embedding_array)
|
||||
|
||||
metadata = {
|
||||
"agent_role": payload.agent_role,
|
||||
"input_preview": (payload.inputs_text or "")[:200],
|
||||
"content_length": len(extracted_content),
|
||||
"attachments": snapshot.attachment_overview(),
|
||||
}
|
||||
|
||||
memory_item = MemoryItem(
|
||||
id=f"{content_hash}_{int(time.time())}",
|
||||
content_summary=extracted_content,
|
||||
metadata=metadata,
|
||||
embedding=embedding_array.tolist()[0],
|
||||
input_snapshot=payload.input_snapshot,
|
||||
output_snapshot=snapshot,
|
||||
)
|
||||
|
||||
self.contents.append(memory_item)
|
||||
|
||||
max_memories = 1000
|
||||
if len(self.contents) > max_memories:
|
||||
self.contents = self.contents[-max_memories:]
|
||||
Reference in New Issue
Block a user