chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:51 +08:00
commit d0e4308def
614 changed files with 74458 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
from .memory_base import MemoryBase, MemoryManager
from .builtin_stores import MemoryFactory
__all__ = [
"MemoryBase",
"MemoryManager",
"MemoryFactory",
]
+99
View File
@@ -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 :]
+55
View File
@@ -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)
+194
View File
@@ -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
+485
View File
@@ -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)
+219
View File
@@ -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
+304
View File
@@ -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
+64
View File
@@ -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",
]
+294
View File
@@ -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:]