ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
# Multi-Provider Support for User Memory System
The User Memory System now supports multiple LLM providers, allowing you to choose the best provider for your needs.
### Alibaba Cloud DashScope / Bailian (Qwen)
- **Provider names**: `dashscope`, `qwen`, or `bailian` (aliases)
- **API key**: `DASHSCOPE_API_KEY`
- **Default model**: `qwen3.7-plus`
- **Base URL**: `https://dashscope.aliyuncs.com/compatible-mode/v1`
- For an international-region key, set `DASHSCOPE_BASE_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1`.
## Supported Providers
### 1. **Kimi/Moonshot** (Default)
- **Provider names**: `kimi` or `moonshot`
- **API Key**: `MOONSHOT_API_KEY`
- **Base URL**: `https://api.moonshot.cn/v1`
- **Default Model**: `kimi-k3`
### 2. **SiliconFlow**
- **Provider name**: `siliconflow`
- **API Key**: `SILICONFLOW_API_KEY`
- **Base URL**: `https://api.siliconflow.cn/v1`
- **Default Model**: `Qwen/Qwen3-235B-A22B-Thinking-2507`
### 3. **Doubao**
- **Provider name**: `doubao`
- **API Key**: `DOUBAO_API_KEY`
- **Base URL**: `https://ark.cn-beijing.volces.com/api/v3`
- **Default Model**: `doubao-seed-1-6-thinking-250715`
### 4. **OpenRouter**
- **Provider name**: `openrouter`
- **API Key**: `OPENROUTER_API_KEY`
- **Base URL**: `https://openrouter.ai/api/v1`
- **Default Model**: `google/gemini-3.5-flash`
- **Supported Models**:
- `google/gemini-3.5-flash` - Google's Gemini 3.5 Flash model
- `openai/gpt-5` - OpenAI's GPT-5 model
- `anthropic/claude-sonnet-4` - Anthropic's Claude Sonnet 4 model
## Configuration
### Environment Variables
Set the appropriate API key for your chosen provider:
```bash
# For Kimi/Moonshot
export MOONSHOT_API_KEY="your-api-key-here"
# For SiliconFlow
export SILICONFLOW_API_KEY="your-api-key-here"
# For Doubao
export DOUBAO_API_KEY="your-api-key-here"
# For OpenRouter
export OPENROUTER_API_KEY="your-api-key-here"
# Set default provider (optional, defaults to 'kimi')
export PROVIDER="siliconflow"
# Override default model (optional)
export MODEL_NAME="your-custom-model-name"
```
### Command-Line Usage
```bash
# Use default provider (from env or 'kimi')
python main.py --mode interactive
# Specify provider
python main.py --provider siliconflow --mode interactive
# Alibaba Cloud Model Studio / Bailian
export DASHSCOPE_API_KEY="your-dashscope-key"
python main.py --provider dashscope --mode interactive
# `qwen` and `bailian` are accepted aliases for `dashscope`.
# Specify provider and model
python main.py --provider doubao --model "doubao-seed-1-6-thinking-250715" --mode demo
# Full example with all options
python main.py \
--provider siliconflow \
--model "Qwen/Qwen3-235B-A22B-Thinking-2507" \
--memory-mode enhanced_notes \
--mode interactive \
--user my_user
# Using OpenRouter with specific models
python main.py --provider openrouter --model "google/gemini-3.5-flash" --mode interactive
python main.py --provider openrouter --model "openai/gpt-5" --mode demo
python main.py --provider openrouter --model "anthropic/claude-sonnet-4" --mode evaluation
```
## Python API Usage
### UserMemoryAgent
```python
from agent import UserMemoryAgent, UserMemoryConfig
from config import MemoryMode
# Using SiliconFlow
agent = UserMemoryAgent(
user_id="user123",
provider="siliconflow",
model="Qwen/Qwen3-235B-A22B-Thinking-2507", # Optional, uses default if not specified
config=UserMemoryConfig(memory_mode=MemoryMode.NOTES)
)
# Execute a task
result = agent.execute_task("Remember that I prefer Python for programming")
```
### ConversationalAgent
```python
from conversational_agent import ConversationalAgent, ConversationConfig
# Using Doubao
agent = ConversationalAgent(
user_id="user456",
provider="doubao",
model="doubao-seed-1-6-thinking-250715", # Optional
config=ConversationConfig(enable_memory_context=True),
memory_mode=MemoryMode.ENHANCED_NOTES
)
# Have a conversation
response = agent.chat("Hello, I'm John and I work at TechCorp")
```
### BackgroundMemoryProcessor
```python
from background_memory_processor import BackgroundMemoryProcessor, MemoryProcessorConfig
# Using Kimi (default)
processor = BackgroundMemoryProcessor(
user_id="user789",
provider="kimi", # or "moonshot"
config=MemoryProcessorConfig(
conversation_interval=2
),
memory_mode=MemoryMode.JSON_CARDS
)
# Using OpenRouter with specific model
processor = BackgroundMemoryProcessor(
user_id="user_openrouter",
provider="openrouter",
model="google/gemini-3.5-flash", # or "openai/gpt-5", "anthropic/claude-sonnet-4"
config=MemoryProcessorConfig(
conversation_interval=1
),
memory_mode=MemoryMode.ENHANCED_NOTES
)
# Start background processing
processor.start_background_processing()
```
## Testing Providers
Run the test script to verify provider configuration:
```bash
python test_providers.py
```
This will test each configured provider and show which ones are properly set up.
## Provider Selection Guidelines
Choose your provider based on:
1. **Kimi/Moonshot**: Best for Chinese language support and general tasks
2. **SiliconFlow**: High-performance option with Qwen models
3. **Doubao**: ByteDance's offering with strong reasoning capabilities
4. **OpenRouter**: Access to multiple top-tier models including:
- **Google Gemini 2.5 Pro**: Advanced multimodal understanding and reasoning
- **OpenAI GPT-5**: Latest generation language model with superior capabilities
- **Anthropic Claude Sonnet 4**: Strong reasoning with constitutional AI safety
## Troubleshooting
### API Key Not Found
If you see an error about missing API keys:
1. Check that the environment variable is set correctly
2. Verify the API key is valid
3. Ensure you're using the correct provider name
### Connection Errors
If you encounter connection issues:
1. Verify your network connection
2. Check if the provider's API endpoint is accessible
3. Ensure your API key has the necessary permissions
### Model Not Available
If a model is not available:
1. Check the provider's documentation for available models
2. Use the default model by not specifying the `--model` parameter
3. Update to a currently available model
## Adding New Providers
To add support for a new provider, update the following files:
1. **config.py**: Add API key and base URL configuration
2. **agent.py**: Add provider case in `__init__` method
3. **conversational_agent.py**: Add provider case in `__init__` method
4. **background_memory_processor.py**: No changes needed (uses UserMemoryAgent)
5. **main.py**: Add provider to choices in argparse
Example for adding a new provider:
```python
# In agent.py __init__ method
elif self.provider == "new_provider":
self.client = OpenAI(
api_key=api_key,
base_url="https://api.newprovider.com/v1"
)
self.model = model or "default-model-name"
elif self.provider == "openrouter":
self.client = OpenAI(
api_key=api_key,
base_url="https://openrouter.ai/api/v1"
)
self.model = model or "google/gemini-3.5-flash"
```
+321
View File
@@ -0,0 +1,321 @@
# User Memory System / 用户记忆系统
> Companion material for *AI Agents in Depth*, Chapter 3 — long-term user memory with separated conversation vs background processing, multiple memory modes, multi-provider support.
> 配套《深入理解 AI Agent》第 3 章——长期用户记忆:对话与后台记忆处理分离、多种记忆模式、多模型提供商。
← [Chapter 3 index / 返回第 3 章目录](../README.md)
---
## Code map
- **Run first:** python main.py --mode demo --memory-mode enhanced_notes.
- **Start here:** conversational_agent.py::ConversationalAgent.chat reads memory without directly persisting it.
- **Core behavior:** background_memory_processor.py::BackgroundMemoryProcessor.process_recent_conversations extracts candidates and applies updates.
- **State / protocol:** memory_manager.py owns mode-specific storage; conversation history remains separate.
- **Verifier:** user-memory-evaluation and the evaluation mode compare evidence, not only generated summaries.
- **Experiment variable:** notes, enhanced notes, JSON cards and advanced JSON cards.
- **Skip on first pass:** provider adapters, streaming presentation and benchmark helpers.
## English
### Key features
- **Separated architecture**: conversational agent vs background memory processor
- **Memory modes**: notes → enhanced notes → JSON cards → advanced JSON cards
- **Providers**: Alibaba Cloud DashScope/Bailian (Qwen), Kimi/Moonshot, SiliconFlow, Doubao, OpenRouter
- **React + tools** for structured memory ops
- **Streaming** with tool calls
- **Evaluation** integration with `user-memory-evaluation`
- **Background processing** on conversation intervals
- **Persistent** JSON storage + conversation history
### Installation
Python 3.12 with the root `ch3` extra, plus at least one LLM API key.
```bash
# From the repository root: use the shared Chapter 3 environment
uv sync --locked --python 3.12 --extra ch3
# Activate it before changing directories:
# macOS/Linux:
source .venv/bin/activate
# Windows PowerShell: .venv\Scripts\Activate.ps1
# Windows cmd: .venv\Scripts\activate.bat
# pip fallback when uv is not installed:
# python -m pip install -e ".[ch3]"
cd chapter3/user-memory
# Single-project compatibility path, still supported during migration:
# python -m pip install -r requirements.txt
cp env.example .env
# DASHSCOPE_API_KEY / MOONSHOT_API_KEY / SILICONFLOW_API_KEY / DOUBAO_API_KEY / OPENROUTER_API_KEY
```
### Quick start
```bash
python quickstart.py
python main.py --mode interactive --user your_name
# interactive: memory | process | save | reset | quit/exit
python main.py --mode demo --memory-mode enhanced_notes
python main.py --mode evaluation --memory-mode advanced_json_cards
```
### Architecture
```
User Interface
→ Conversational Agent (dialogue, read memory, stream; no direct writes)
→ Background Memory Processor (analyze, update via tools)
→ Memory Manager (notes / JSON cards storage)
```
**Core modules:** `conversational_agent.py`, `background_memory_processor.py`, `agent.py` (UserMemoryAgent + tools), `memory_manager.py`.
### Memory modes
1. **`notes`** — short facts/preferences
2. **`enhanced_notes`** — contextual paragraphs
3. **`json_cards`** — hierarchical JSON
4. **`advanced_json_cards`** — full cards with backstory, person, relationship, timestamps
### Execution modes
```bash
python main.py --mode interactive \
--user john_doe \
--memory-mode enhanced_notes \
--conversation-interval 2
python main.py --mode demo --provider siliconflow --memory-mode json_cards
python main.py --mode evaluation --memory-mode advanced_json_cards --provider kimi
```
### Providers
| Provider | Models (examples) | Notes |
|----------|-------------------|--------|
| DashScope / Bailian (Qwen) | qwen3.7-plus | Alibaba Cloud Model Studio; `qwen` and `bailian` are aliases |
| Kimi/Moonshot | kimi-k3 | Chinese, general |
| SiliconFlow | Qwen3-235B-… | High performance |
| Doubao | doubao-seed-1-6-thinking-… | ByteDance |
| OpenRouter | Gemini / GPT / Claude | Multi-model |
```bash
python main.py --provider siliconflow --model "Qwen/Qwen3-235B-A22B-Thinking-2507"
python main.py --provider openrouter --model "google/gemini-3.5-flash"
python main.py --provider doubao --model "doubao-seed-1-6-thinking-250715"
python main.py --provider dashscope --model "qwen3.7-plus"
```
### API usage
```python
from conversational_agent import ConversationalAgent, ConversationConfig
from config import MemoryMode
agent = ConversationalAgent(
user_id="user123",
provider="kimi",
config=ConversationConfig(enable_memory_context=True, temperature=0.7),
memory_mode=MemoryMode.ENHANCED_NOTES
)
response = agent.chat("Hi, I'm Alice and I work at TechCorp")
```
```python
from background_memory_processor import BackgroundMemoryProcessor, MemoryProcessorConfig
processor = BackgroundMemoryProcessor(
user_id="user123",
provider="kimi",
config=MemoryProcessorConfig(conversation_interval=2, enable_auto_processing=True),
memory_mode=MemoryMode.JSON_CARDS
)
processor.start_background_processing()
results = processor.process_recent_conversations()
```
```python
from agent import UserMemoryAgent, UserMemoryConfig
agent = UserMemoryAgent(
user_id="user123",
provider="siliconflow",
config=UserMemoryConfig(enable_memory_updates=True, memory_mode=MemoryMode.ADVANCED_JSON_CARDS)
)
result = agent.execute_task("Remember that I prefer Python and my email is john@example.com")
```
### Evaluation
```bash
python main.py --mode evaluation --memory-mode advanced_json_cards
```
Uses test cases from `user-memory-evaluation` (histories → question → score/feedback; 60+ cases).
### Advanced configuration
```bash
PROVIDER=kimi
# For DashScope/Bailian, use PROVIDER=dashscope (or qwen/bailian) and set DASHSCOPE_API_KEY.
MODEL_TEMPERATURE=0.3
MODEL_MAX_TOKENS=4096
MEMORY_MODE=enhanced_notes
MAX_MEMORY_ITEMS=100
MEMORY_UPDATE_TEMPERATURE=0.2
SESSION_TIMEOUT=3600
MAX_CONTEXT_LENGTH=8000
MEMORY_STORAGE_DIR=data/memories
CONVERSATION_HISTORY_DIR=data/conversations
```
```bash
python main.py \
--mode interactive \
--user custom_user \
--memory-mode advanced_json_cards \
--provider openrouter \
--model "google/gemini-3.5-flash" \
--conversation-interval 3 \
--background-processing True \
--no-verbose
```
### Project structure
```
user-memory/
├── main.py, quickstart.py, agent.py
├── conversational_agent.py, background_memory_processor.py
├── memory_manager.py, config.py, conversation_history.py
├── memory_operation_formatter.py, run_evaluation.py, locomo_benchmark.py
├── PROVIDERS.md, requirements.txt, env.example
├── data/{memories,conversations}/, logs/
```
### Development smoke tests
```bash
python quickstart.py
python -c "from memory_manager import NotesMemoryManager; m=NotesMemoryManager('smoke'); print(m.consolidate_memories())"
```
### Notes / license
Background processing is async; tools logged; streaming supported; state persists. Educational materials.
---
## 中文
### 关键特性
- **分离架构**:对话 Agent 与后台记忆处理器解耦
- **多种记忆模式**:简单笔记 → 增强笔记 → JSON 卡片 → Advanced JSON Cards
- **多提供商**Kimi、SiliconFlow、豆包、OpenRouter
- **React + 工具** 结构化记忆操作
- **流式输出**、**评测集成**、**按间隔后台更新**、**JSON 持久化**
### 安装
```bash
# 在仓库根目录使用统一的第 3 章环境
uv sync --locked --python 3.12 --extra ch3
# 切换目录前先激活环境:
# macOS/Linux
source .venv/bin/activate
# Windows PowerShell.venv\Scripts\Activate.ps1
# Windows cmd.venv\Scripts\activate.bat
# 未安装 uv 时可用 pip 兜底:
# python -m pip install -e ".[ch3]"
cd chapter3/user-memory
# 迁移期间仍支持单项目兼容路径:
# python -m pip install -r requirements.txt
cp env.example .env
# 配置 MOONSHOT_API_KEY / SILICONFLOW_API_KEY / DOUBAO_API_KEY / OPENROUTER_API_KEY
```
### 快速开始
```bash
python quickstart.py
python main.py --mode interactive --user your_name
# memory | process | save | reset | quit/exit
python main.py --mode demo --memory-mode enhanced_notes
python main.py --mode evaluation --memory-mode advanced_json_cards
```
### 架构
用户界面 → **ConversationalAgent**(对话、读记忆、流式)+ **BackgroundMemoryProcessor**(分析并写记忆)→ **MemoryManager**(笔记/JSON 卡片)。
核心文件:`conversational_agent.py``background_memory_processor.py``agent.py``memory_manager.py`
### 记忆模式
1. **`notes`** — 短事实
2. **`enhanced_notes`** — 带上下文的段落
3. **`json_cards`** — 层次化 JSON
4. **`advanced_json_cards`** — 含 backstory / person / relationship 等完整卡片
### 运行模式
```bash
python main.py --mode interactive --user john_doe --memory-mode enhanced_notes --conversation-interval 2
python main.py --mode demo --provider siliconflow --memory-mode json_cards
python main.py --mode evaluation --memory-mode advanced_json_cards --provider kimi
```
### 提供商
见 English 表与 `--provider` / `--model` 示例。
### 编程接口
见 English 节 `ConversationalAgent` / `BackgroundMemoryProcessor` / `UserMemoryAgent` 示例。
### 评测
```bash
python main.py --mode evaluation --memory-mode advanced_json_cards
```
对接 `user-memory-evaluation` 的用例与打分。
### 高级配置与项目结构
环境变量、`main.py` CLI 参数、目录树与 English 节相同。
### 冒烟测试
```bash
python quickstart.py
python -c "from memory_manager import NotesMemoryManager; m=NotesMemoryManager('smoke'); print(m.consolidate_memories())"
```
### 说明
记忆后台异步处理;工具调用可记录;支持流式;状态跨会话持久。教学材料。
---
## Notes / 说明
### OpenRouter 通用回退 / Universal OpenRouter fallback
Primary provider keys take precedence; else `OPENROUTER_API_KEY` routes chat LLM via OpenRouter with automatic model id mapping. See `env.example`. Related: [`../user-memory-evaluation/`](../user-memory-evaluation/), [`../mem0/`](../mem0/), [`../memobase/`](../memobase/).
+969
View File
@@ -0,0 +1,969 @@
"""
User Memory Agent with Kimi K3 and React pattern
Following the system-hint project's tool-based approach
"""
import json
import os
import sys
import logging
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass, field
from datetime import datetime
import uuid
from openai import OpenAI
from config import Config, MemoryMode, openrouter_model_id, PROVIDER_DEFAULT_MODELS
from memory_manager import create_memory_manager, BaseMemoryManager
from conversation_history import ConversationHistory, ConversationTurn
def _reasoning_safe_temperature(model, requested=1.0):
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
Return 1 for those; otherwise the requested value so non-reasoning
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
m = str(model or "").lower().replace("/", "-")
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
@dataclass
class ToolCall:
"""Represents a single tool call with tracking"""
tool_name: str
arguments: Dict[str, Any]
result: Optional[Any] = None
error: Optional[str] = None
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
@dataclass
class UserMemoryConfig:
"""Configuration for the user memory agent"""
enable_memory_updates: bool = True
enable_conversation_history: bool = True
enable_memory_search: bool = True
memory_mode: MemoryMode = MemoryMode.NOTES
max_memory_context: int = 10 # Max memory items to include in context
save_trajectory: bool = True
trajectory_file: str = "memory_trajectory.json"
class UserMemoryAgent:
"""
User Memory Agent with tool-based React pattern
"""
def __init__(self,
user_id: str,
api_key: Optional[str] = None,
provider: Optional[str] = None,
model: Optional[str] = None,
config: Optional[UserMemoryConfig] = None,
verbose: bool = True):
"""
Initialize the agent
Args:
user_id: Unique user identifier
api_key: API key (defaults to env based on provider)
provider: LLM provider ('dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot')
model: Model name (defaults to provider's default)
config: Agent configuration
verbose: Enable verbose logging
"""
self.user_id = user_id
self.verbose = verbose
self.config = config or UserMemoryConfig()
# Determine provider
self.provider = (provider or Config.PROVIDER).lower()
self.provider = {"qwen": "dashscope", "bailian": "dashscope"}.get(
self.provider, self.provider
)
# Get API key for provider
api_key = api_key or Config.get_api_key(self.provider)
# Universal OpenRouter fallback: primary provider key absent but
# OPENROUTER_API_KEY present -> route this agent through OpenRouter.
if not api_key and self.provider != "openrouter" and Config.OPENROUTER_API_KEY:
model = openrouter_model_id(model or PROVIDER_DEFAULT_MODELS.get(self.provider))
self.provider = "openrouter"
api_key = Config.OPENROUTER_API_KEY
if not api_key:
raise ValueError(
f"API key required for provider '{self.provider}'. Set the "
f"provider's key or OPENROUTER_API_KEY to use the OpenRouter fallback."
)
# Configure client based on provider
if self.provider == "dashscope":
self.client = OpenAI(
api_key=api_key,
base_url=Config.DASHSCOPE_BASE_URL
)
self.model = model or PROVIDER_DEFAULT_MODELS["dashscope"]
elif self.provider == "siliconflow":
self.client = OpenAI(
api_key=api_key,
base_url="https://api.siliconflow.cn/v1"
)
self.model = model or "Qwen/Qwen3-235B-A22B-Thinking-2507"
elif self.provider == "doubao":
self.client = OpenAI(
api_key=api_key,
base_url="https://ark.cn-beijing.volces.com/api/v3"
)
self.model = model or os.getenv("ARK_MODEL", "doubao-seed-1-6-250615")
elif self.provider == "kimi" or self.provider == "moonshot":
self.client = OpenAI(
api_key=api_key,
base_url="https://api.moonshot.cn/v1"
)
self.model = model or "kimi-k3"
elif self.provider == "openrouter":
self.client = OpenAI(
api_key=api_key,
base_url="https://openrouter.ai/api/v1"
)
# Default to Gemini 3.5 Flash, but allow any of the supported models
self.model = model or "google/gemini-3.5-flash"
# Supported models: google/gemini-3.5-flash, openai/gpt-5, anthropic/claude-sonnet-4
else:
raise ValueError(f"Unsupported provider: {self.provider}. Use 'dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot', or 'openrouter'")
# Initialize memory manager
self.memory_manager = create_memory_manager(user_id, self.config.memory_mode)
# Initialize conversation history
self.conversation_history = ConversationHistory(user_id) if self.config.enable_conversation_history else None
# Track tool calls
self.tool_calls: List[ToolCall] = []
self.tool_call_counts: Dict[str, int] = {}
# Initialize conversation
self.conversation = []
self.session_id = self._start_session()
# Initialize system prompt
self._init_system_prompt()
logger.info(f"UserMemoryAgent initialized for user {user_id} with {self.provider} provider using {self.model}")
def _start_session(self) -> str:
"""Start a new session"""
return f"session-{uuid.uuid4().hex[:8]}"
def _init_system_prompt(self):
"""Initialize the system prompt with memory context based on memory mode"""
base_prompt = """You are an intelligent assistant with persistent memory across conversations.
You have access to various tools to manage user memories and search conversation history. If you want to add, update, or delete multiple memories, you should use multiple tool calls at once. After you have finished updating memories, you should output STOP without any other text.
The full history of the latest conversation is automatically loaded in the context below.
## Key Behaviors:
1. All user memories are automatically loaded and shown in the "USER MEMORIES" section below
2. Proactively update memories when learning new information about the user
3. Reference relevant memories when responding
4. Maintain consistency with previously stored information
5. Be personalized based on what you know about the user
"""
# Add mode-specific memory instructions
if self.config.memory_mode == MemoryMode.NOTES:
memory_instructions = """## Memory Management:
- All user memories are pre-loaded in the context below
- Use `add_memory` to store new important information about the user
- Use `update_memory` to modify existing memories
- Use `delete_memory` to remove outdated or incorrect memories
Keep memories as simple facts or preferences."""
elif self.config.memory_mode == MemoryMode.ENHANCED_NOTES:
memory_instructions = """## Memory Management:
- All user memories are pre-loaded in the context below
- Use `add_memory` to store new important information about the user
- Use `update_memory` to modify existing memories
- Use `delete_memory` to remove outdated or incorrect memories
IMPORTANT: Each note should contain all important factual information and user preferences in a complete, contextual manner.
Notes can be full paragraphs that capture the complete context, not just simple key-value pairs.
Example of good enhanced notes:
- "User works at TechCorp as a senior software engineer, specializing in machine learning. They've been there for 3 years and enjoy the collaborative culture."
- "User's email is john.doe@techcorp.com for work and johndoe.personal@gmail.com for personal matters. They prefer work emails during business hours only."
- "User has two children: Sarah (8 years old, loves soccer) and Michael (5 years old, interested in dinosaurs). Both attend Oakwood Elementary School."
Extract all factual information from the conversation that may be useful for future interactions."""
elif self.config.memory_mode == MemoryMode.JSON_CARDS:
memory_instructions = """## Memory Management:
- All user memories are pre-loaded in the context below
- Use `add_memory` to store new memory cards with structured data
- Use `update_memory` to modify existing memory cards
- Use `delete_memory` to remove outdated memory cards
Memory cards use a hierarchical structure: category -> subcategory -> key -> value
Example operations:
1. Adding a memory card:
content: {"category": "personal", "subcategory": "contact", "key": "email", "value": "user@example.com"}
2. Updating a memory card:
memory_id: "personal.contact.email"
content: {"value": "newemail@example.com"}
3. Structure examples:
- personal.preferences.coding_style -> "prefers functional programming"
- work.projects.current -> "developing AI chatbot"
- family.children.sarah -> {"age": 8, "interests": ["soccer", "reading"]}"""
elif self.config.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
memory_instructions = """## Memory Management:
- All user memory cards are pre-loaded in the context below
- Use `add_memory` to store complete memory card objects
- Use `update_memory` to modify existing memory cards
- Use `delete_memory` to remove memory cards
Memory cards are complete JSON objects within categories. Each card MUST include:
- backstory: Context about when/why this information was learned (1-2 sentences)
- date_created: Current timestamp (YYYY-MM-DD HH:MM:SS)
- person: Who this relates to (e.g., "John Smith (primary)", "Sarah Smith (daughter)")
- relationship: Role/relationship (e.g., "primary account holder", "family member")
- Additional relevant fields based on the information type
Example memory card operations:
1. Adding a complete memory card:
content: {
"category": "financial",
"card_key": "bank_account_primary",
"card": {
"backstory": "User shared their banking details while setting up automatic bill payments",
"date_created": "2024-01-15 10:30:00",
"person": "John Smith (primary)",
"relationship": "primary account holder",
"bank_name": "Chase Bank",
"account_type": "checking",
"account_ending": "4567",
"routing_number": "021000021",
"purpose": "primary checking for bills"
}
}
2. Adding a medical memory card:
content: {
"category": "medical",
"card_key": "doctor_dermatologist_sarah",
"card": {
"backstory": "User needed to schedule a dermatology appointment for their daughter's skin condition",
"date_created": "2024-01-16 14:00:00",
"person": "Sarah Smith (daughter)",
"relationship": "family member",
"doctor_name": "Dr. Emily Johnson",
"specialty": "Pediatric Dermatology",
"clinic": "Children's Health Center",
"phone": "555-0123",
"condition_treated": "eczema"
}
}
CRITICAL: The backstory and person fields prevent confusion. For example, without proper person identification,
a dermatologist for a child might be mistakenly suggested for an elderly parent's Alzheimer's care."""
else:
memory_instructions = """## Memory Management:
- All user memories are pre-loaded in the context below
- Use `add_memory` to store new important information about the user
- Use `update_memory` to modify existing memories
- Use `delete_memory` to remove outdated or incorrect memories"""
system_content = base_prompt + memory_instructions + """
Current Memory Context will be provided with each message."""
self.conversation = [
{
"role": "system",
"content": system_content
}
]
def _get_memory_context(self) -> str:
"""Get current memory context as a string"""
context_parts = []
# Add memory summary
context_parts.append("=== USER MEMORIES ===")
context_parts.append(self.memory_manager.get_context_string())
context_parts.append("")
# Add ALL conversation history if available
if self.conversation_history and self.config.enable_conversation_history:
# Get ALL conversation history, not just recent
all_conversations = self.conversation_history.conversations if hasattr(self.conversation_history, 'conversations') else []
if all_conversations:
context_parts.append("=== FULL CONVERSATION HISTORY ===")
context_parts.append(f"Total conversations: {len(all_conversations)}")
context_parts.append("")
for turn in all_conversations:
context_parts.append(f"[Session: {turn.session_id}, Turn {turn.turn_number}]")
context_parts.append(f"User: {turn.user_message}")
context_parts.append(f"Assistant: {turn.assistant_message}")
context_parts.append("")
return "\n".join(context_parts)
def _get_tools_description(self) -> List[Dict[str, Any]]:
"""Get tool descriptions for the model"""
tools = []
# Memory management tools
if self.config.enable_memory_updates:
tools.extend([
{
"type": "function",
"function": {
"name": "add_memory",
"description": "Add a new memory about the user",
"parameters": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The memory content to store"
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional tags for categorizing the memory"
}
},
"required": ["content"]
}
}
},
{
"type": "function",
"function": {
"name": "update_memory",
"description": "Update an existing memory",
"parameters": {
"type": "object",
"properties": {
"memory_id": {
"type": "string",
"description": "ID of the memory to update"
},
"content": {
"type": "string",
"description": "New content for the memory"
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional new tags"
}
},
"required": ["memory_id", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "delete_memory",
"description": "Delete a memory",
"parameters": {
"type": "object",
"properties": {
"memory_id": {
"type": "string",
"description": "ID of the memory to delete"
}
},
"required": ["memory_id"]
}
}
}
])
return tools
def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Tuple[Any, Optional[str]]:
"""
Execute a tool and return the result
Returns:
Tuple of (result, error_message)
"""
try:
if tool_name == "add_memory":
result = self._tool_add_memory(**arguments)
elif tool_name == "update_memory":
result = self._tool_update_memory(**arguments)
elif tool_name == "delete_memory":
result = self._tool_delete_memory(**arguments)
else:
error = f"Unknown tool: {tool_name}"
return {"error": error}, error
return result, None
except Exception as e:
error_msg = f"Tool '{tool_name}' failed: {str(e)}"
logger.error(error_msg)
return {"error": error_msg}, error_msg
# Tool implementations
def _tool_add_memory(self, content: Any, tags: List[str] = None) -> Dict[str, Any]:
"""Add a new memory"""
if self.config.memory_mode in [MemoryMode.NOTES, MemoryMode.ENHANCED_NOTES]:
# Both basic and enhanced notes use the same storage, just different prompts
if isinstance(content, dict):
# If content is a dict, extract string representation
content_str = str(content)
else:
content_str = content
memory_id = self.memory_manager.add_memory(
content=content_str,
session_id=self.session_id,
tags=tags or []
)
elif self.config.memory_mode == MemoryMode.JSON_CARDS:
# Basic JSON cards mode
if isinstance(content, dict):
# Content should already have the structure
memory_content = content
else:
try:
parsed_content = json.loads(content)
except (TypeError, json.JSONDecodeError):
parsed_content = None
if isinstance(parsed_content, dict):
memory_content = parsed_content
else:
# Fallback for legacy "key: value" style content.
parts = str(content).split(':')
if len(parts) >= 2:
category = "personal"
subcategory = "info"
key = parts[0].strip().replace(' ', '_').lower()
value = ':'.join(parts[1:]).strip()
else:
category = "general"
subcategory = "notes"
key = f"note_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
value = content
memory_content = {
'category': category,
'subcategory': subcategory,
'key': key,
'value': value
}
memory_id = self.memory_manager.add_memory(
content=memory_content,
session_id=self.session_id
)
elif self.config.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
# Advanced JSON cards mode
if not isinstance(content, dict):
try:
content = json.loads(content)
except (json.JSONDecodeError, TypeError):
return {
"success": False,
"message": "Advanced JSON cards mode requires properly structured JSON content"
}
memory_id = self.memory_manager.add_memory(
content=content,
session_id=self.session_id
)
else:
return {
"success": False,
"message": f"Unknown memory mode: {self.config.memory_mode}"
}
return {
"success": True,
"memory_id": memory_id,
"message": f"Memory added successfully"
}
def _tool_update_memory(self, memory_id: str, content: Any, tags: List[str] = None) -> Dict[str, Any]:
"""Update an existing memory"""
if self.config.memory_mode in [MemoryMode.NOTES, MemoryMode.ENHANCED_NOTES]:
# Both basic and enhanced notes use the same storage
if isinstance(content, dict):
content_str = str(content)
else:
content_str = content
success = self.memory_manager.update_memory(
memory_id=memory_id,
content=content_str,
session_id=self.session_id,
tags=tags
)
elif self.config.memory_mode == MemoryMode.JSON_CARDS:
# Basic JSON cards mode
if isinstance(content, dict):
memory_content = content
else:
# For JSON cards, parse the memory_id and content
parts = memory_id.split('.')
if len(parts) == 3:
memory_content = {'value': content}
else:
return {
"success": False,
"message": "Invalid memory_id format for JSON cards"
}
success = self.memory_manager.update_memory(
memory_id=memory_id,
content=memory_content,
session_id=self.session_id
)
elif self.config.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
# Advanced JSON cards mode
if not isinstance(content, dict):
try:
content = json.loads(content)
except (json.JSONDecodeError, TypeError):
return {
"success": False,
"message": "Advanced JSON cards mode requires properly structured JSON content"
}
success = self.memory_manager.update_memory(
memory_id=memory_id,
content=content,
session_id=self.session_id
)
else:
success = False
return {
"success": success,
"message": "Memory updated successfully" if success else "Memory not found"
}
def _tool_delete_memory(self, memory_id: str) -> Dict[str, Any]:
"""Delete a memory"""
self.memory_manager.delete_memory(memory_id)
return {
"success": True,
"message": f"Memory {memory_id} deleted"
}
def _save_trajectory(self, iteration: int, final_answer: Optional[str] = None):
"""Save current trajectory to file for debugging"""
if not self.config.save_trajectory:
return
trajectory_data = {
"timestamp": datetime.now().isoformat(),
"iteration": iteration,
"user_id": self.user_id,
"session_id": self.session_id,
"model": self.model,
"conversation": self.conversation,
"tool_calls": [
{
"tool_name": call.tool_name,
"arguments": call.arguments,
"result": call.result,
"error": call.error,
"timestamp": call.timestamp
}
for call in self.tool_calls
],
"memory_state": self.memory_manager.get_context_string(),
"final_answer": final_answer
}
try:
with open(self.config.trajectory_file, 'w', encoding='utf-8') as f:
json.dump(trajectory_data, f, indent=2, ensure_ascii=False)
if self.verbose:
logger.info(f"Trajectory saved to {self.config.trajectory_file}")
except Exception as e:
logger.warning(f"Failed to save trajectory: {e}")
def execute_task(self, task: str, max_iterations: int = 15) -> Dict[str, Any]:
"""
Execute a task using React pattern with tool calls and streaming support
Args:
task: The task/message from user
max_iterations: Maximum number of tool call iterations
Returns:
Task execution result
"""
# Add user message with memory context
memory_context = self._get_memory_context()
full_message = f"{task}\n\n{memory_context}"
# Log the full prompt
logger.info(f"User request: {task}")
if memory_context:
logger.info(f"Memory context added: {memory_context}")
self.conversation.append({"role": "user", "content": full_message})
iteration = 0
final_answer = None
while iteration < max_iterations:
iteration += 1
logger.info(f"Iteration {iteration}/{max_iterations}")
# Save trajectory
self._save_trajectory(iteration)
logger.info(f"Sending streaming request to {self.provider.upper()} API")
logger.info(f"Full conversation: {self.conversation}")
logger.info(f"Tools available: {self._get_tools_description()}")
try:
# Create streaming response
stream = self.client.chat.completions.create(
model=self.model,
messages=self.conversation,
tools=self._get_tools_description(),
tool_choice="auto",
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=4096,
stream=True # Enable streaming
)
# Collect streaming data
collected_content = []
current_tool_calls = []
# Process the stream
collected_reasoning = [] # Separate collection for reasoning content
for chunk in stream:
if chunk.choices and chunk.choices[0].delta:
delta = chunk.choices[0].delta
# Handle reasoning field (for o1 models and similar)
if hasattr(delta, 'reasoning') and delta.reasoning:
reasoning = delta.reasoning
collected_reasoning.append(reasoning)
# Stream reasoning to console if verbose
if self.verbose:
if len(collected_reasoning) == 1: # First reasoning chunk
print("\n🤔 Reasoning: ", end="", flush=True)
print(reasoning, end="", flush=True)
# Handle regular content streaming
if hasattr(delta, 'content') and delta.content:
content = delta.content
collected_content.append(content)
# Stream to console if verbose
if self.verbose:
if len(collected_content) == 1 and not collected_reasoning: # First content chunk
print("\nAssistant: ", end="", flush=True)
print(content, end="", flush=True)
# Handle tool calls in streaming
if hasattr(delta, 'tool_calls') and delta.tool_calls:
for tool_call_delta in delta.tool_calls:
if tool_call_delta.index is not None:
# Ensure we have enough tool calls in the list
while len(current_tool_calls) <= tool_call_delta.index:
current_tool_calls.append({
"id": "",
"type": "function",
"function": {"name": "", "arguments": ""}
})
# Update tool call data
if tool_call_delta.id:
current_tool_calls[tool_call_delta.index]["id"] = tool_call_delta.id
if tool_call_delta.function:
if tool_call_delta.function.name:
current_tool_calls[tool_call_delta.index]["function"]["name"] = tool_call_delta.function.name
# Print tool call name when first detected
if self.verbose:
print(f"\n🔧 Tool Call [{tool_call_delta.index}]: {tool_call_delta.function.name}", end="", flush=True)
if tool_call_delta.function.arguments:
current_tool_calls[tool_call_delta.index]["function"]["arguments"] += tool_call_delta.function.arguments
# Stream tool arguments in verbose mode
if self.verbose:
# Print arguments as they stream (they come in chunks)
print(tool_call_delta.function.arguments, end="", flush=True)
# Add newline after streaming content, reasoning or tool calls
if self.verbose and (collected_content or collected_reasoning or current_tool_calls):
print() # New line after streaming
# Construct complete message matching OpenAI API structure
# Keep reasoning, content, and tool_calls as separate fields
complete_message = {
"role": "assistant"
}
# Add reasoning field if present
if collected_reasoning:
reasoning_text = "".join(collected_reasoning)
complete_message["reasoning"] = reasoning_text
# Add content field if present
if collected_content:
complete_message["content"] = "".join(collected_content)
else:
complete_message["content"] = None
# Add tool_calls field if present
if current_tool_calls:
complete_message["tool_calls"] = current_tool_calls
# Always append the message if it has reasoning, content, or tool calls
# This preserves all assistant output including reasoning
if complete_message.get("reasoning") or complete_message.get("content") or current_tool_calls:
self.conversation.append(complete_message)
# Handle tool calls if present
if current_tool_calls:
for tool_call in current_tool_calls:
function_name = tool_call["function"]["name"]
# The assistant message with tool_calls is already in
# self.conversation; bailing out on malformed arguments
# would leave this tool_call_id unanswered and every
# later request would be rejected by the provider.
# Answer it with an error message instead.
try:
function_args = json.loads(tool_call["function"]["arguments"] or "{}")
except json.JSONDecodeError as exc:
error_msg = f"Invalid tool arguments (not valid JSON): {exc}"
logger.info(f" ❌ Error: {error_msg}")
if self.verbose:
print(f"\n ❌ Tool Error: {error_msg}")
self.conversation.append({
"role": "tool",
"tool_call_id": tool_call["id"],
"content": json.dumps({"error": error_msg})
})
continue
# Track tool call count
self.tool_call_counts[function_name] = self.tool_call_counts.get(function_name, 0) + 1
call_number = self.tool_call_counts[function_name]
logger.info(f"Executing tool: {function_name} (call #{call_number})")
if self.verbose:
print(f"\n⚡ Executing: {function_name} (call #{call_number})")
# Execute the tool
result, error = self._execute_tool(function_name, function_args)
# Log and display result
if error:
logger.info(f" ❌ Error: {error}")
if self.verbose:
print(f"\n ❌ Tool Error: {error}")
else:
logger.info(f" ✅ Success: {json.dumps(result)[:200]}")
if self.verbose:
result_str = json.dumps(result, ensure_ascii=False)
if len(result_str) > 200:
result_str = result_str[:200] + "..."
print(f"\n ✅ Tool Result: {result_str}")
# Record tool call
tool_call_record = ToolCall(
tool_name=function_name,
arguments=function_args,
result=result if not error else None,
error=error
)
self.tool_calls.append(tool_call_record)
# Add tool result to conversation
self.conversation.append({
"role": "tool",
"tool_call_id": tool_call["id"],
"content": json.dumps(result)
})
# Continue to next iteration for more processing
continue
elif complete_message.get("content") or complete_message.get("reasoning"):
# No tool calls but has content or reasoning - this is the final answer
# Prioritize content over reasoning for the final answer
final_answer = complete_message.get("content") or complete_message.get("reasoning")
# Log both reasoning and content if present
if complete_message.get("reasoning") and complete_message.get("content"):
logger.info(f"Response complete with reasoning and content")
else:
logger.info(f"Response complete (no more tool calls): {final_answer}")
# Save conversation to history (use content if available, otherwise reasoning)
if self.conversation_history:
self.conversation_history.add_turn(
session_id=self.session_id,
user_message=task,
assistant_message=final_answer
)
# Save final trajectory
self._save_trajectory(iteration, final_answer)
break # Break when no more tool calls
except Exception as e:
logger.error(f"Error during streaming task execution: {str(e)}")
self._save_trajectory(iteration)
return {
"error": str(e),
"tool_calls": self.tool_calls,
"iterations": iteration,
"trajectory_file": self.config.trajectory_file if self.config.save_trajectory else None
}
# Save final trajectory
self._save_trajectory(iteration, final_answer)
# Prepare result with all relevant information
result = {
"final_answer": final_answer,
"tool_calls": self.tool_calls,
"iterations": iteration,
"success": final_answer is not None,
"memory_state": self.memory_manager.get_context_string(),
"trajectory_file": self.config.trajectory_file if self.config.save_trajectory else None
}
# Include reasoning if it was collected in the last message
if self.conversation and isinstance(self.conversation[-1], dict):
last_message = self.conversation[-1]
if last_message.get("role") == "assistant" and last_message.get("reasoning"):
result["reasoning"] = last_message["reasoning"]
return result
def chat(self, message: str, stream: bool = False) -> str:
"""
Simple chat interface (wraps execute_task for compatibility)
Args:
message: User message
stream: Whether to stream the response (only works when tools are disabled)
Returns:
Assistant response
"""
if stream and not self.config.enable_memory_updates:
# Stream response when tools are disabled
return self._chat_stream(message)
else:
# Use regular execution with tools
result = self.execute_task(message)
return result.get('final_answer', result.get('error', 'I apologize, but I was unable to generate a response.'))
def _chat_stream(self, message: str) -> str:
"""
Stream chat response (only when tools are disabled)
Args:
message: User message
Returns:
Assistant response
"""
# Get memory context
memory_context = self._get_memory_context()
if memory_context:
full_message = f"Current Memory Context:\n{memory_context}\n\nUser Message: {message}"
else:
full_message = message
# Log the full prompt
logger.info(f"User request: {message}")
if memory_context:
logger.info(f"Memory context added: {memory_context}")
logger.info(f"Full prompt sent to API (streaming): {full_message}")
# Add to conversation
self.conversation.append({"role": "user", "content": full_message})
try:
# Stream the response
stream = self.client.chat.completions.create(
model=self.model,
messages=self.conversation,
temperature=_reasoning_safe_temperature(self.model, 0.3),
max_tokens=4096,
stream=True
)
# Collect and stream response
assistant_message = ""
logger.info("Streaming response...")
print("\nAssistant: ", end='', flush=True)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
delta = chunk.choices[0].delta.content
assistant_message += delta
# Stream output in real-time
print(delta, end='', flush=True)
print() # New line after streaming
# Log the complete response
logger.info(f"Assistant response (streamed): {assistant_message}")
# Add to conversation
self.conversation.append({"role": "assistant", "content": assistant_message})
# Save to history if available
if self.conversation_history:
self.conversation_history.add_turn(
session_id=self.session_id,
user_message=message,
assistant_message=assistant_message
)
return assistant_message
except Exception as e:
logger.error(f"Error during streaming: {str(e)}")
return f"Error: {str(e)}"
def reset(self):
"""Reset the agent's state for a new conversation"""
self.tool_calls = []
self.tool_call_counts = {}
self.session_id = self._start_session()
self._init_system_prompt()
logger.info("Agent state reset")
@@ -0,0 +1,649 @@
"""
Background Memory Processor - Analyzes conversation context and updates memories
Runs separately from the main conversational agent
"""
import json
import logging
import threading
import time
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass, field
from datetime import datetime
from openai import OpenAI
from config import Config, MemoryMode
from memory_manager import create_memory_manager, BaseMemoryManager
from conversation_history import ConversationHistory
from agent import UserMemoryAgent, UserMemoryConfig
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
@dataclass
class MemoryUpdate:
"""Represents a memory update decision"""
action: str # 'add', 'update', 'delete', 'none'
memory_id: Optional[str] = None
content: Optional[str] = None
reason: Optional[str] = None
tags: List[str] = field(default_factory=list)
@dataclass
class MemoryProcessorConfig:
"""Configuration for the background memory processor"""
conversation_interval: int = 1 # Process after N conversation rounds (default: every round)
min_conversation_turns: int = 1 # Minimum turns before processing
context_window: int = 10 # Number of recent turns to analyze
enable_auto_processing: bool = True
temperature: float = 0.3 # Lower temperature for analysis
output_operations: bool = True # Output detailed memory operations
class BackgroundMemoryProcessor:
"""
Background processor that analyzes conversations and updates memory
Runs separately from the main conversation flow
"""
def __init__(self,
user_id: str,
api_key: Optional[str] = None,
provider: Optional[str] = None,
model: Optional[str] = None,
config: Optional[MemoryProcessorConfig] = None,
memory_mode: MemoryMode = MemoryMode.NOTES,
verbose: bool = True):
"""
Initialize the background memory processor
Args:
user_id: Unique user identifier
api_key: API key (defaults to env based on provider)
provider: LLM provider ('dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot')
model: Model name (defaults to provider's default)
config: Processor configuration
memory_mode: Memory storage mode
verbose: Enable verbose logging
"""
self.user_id = user_id
self.verbose = verbose
self.config = config or MemoryProcessorConfig()
self.memory_mode = memory_mode
self.provider = provider
self.model = model
# Initialize UserMemoryAgent for analysis
agent_config = UserMemoryConfig(
memory_mode=memory_mode,
enable_memory_updates=True, # Agent will use its tools to update memory
enable_memory_search=True, # Enable memory search tool
enable_conversation_history=False,
save_trajectory=False # Don't save trajectory for background processing
)
self.analysis_agent = UserMemoryAgent(
user_id=user_id,
api_key=api_key,
provider=provider,
model=model,
config=agent_config,
verbose=self.verbose
)
# Initialize managers
self.memory_manager = create_memory_manager(user_id, memory_mode)
self.conversation_history = ConversationHistory(user_id)
# Background processing state
self.processing_thread = None
self.stop_processing = False
self.last_processed_timestamp = None
self.processing_lock = threading.Lock()
self.conversation_count = 0 # Track conversation rounds
self.last_processed_count = 0 # Track last processed conversation count
self.processed_turn_ids = set() # Track which turns have been processed
logger.info(f"BackgroundMemoryProcessor initialized for user {user_id} with provider {provider or Config.PROVIDER}")
def analyze_conversation(self, conversation_context: List[Dict[str, str]]) -> List[MemoryUpdate]:
"""
Analyze conversation context and determine memory updates
Args:
conversation_context: List of conversation messages
Returns:
List of memory updates to apply
"""
if len(conversation_context) < self.config.min_conversation_turns * 2:
return []
try:
# Use UserMemoryAgent to analyze the conversation
if self.verbose:
logger.info("Analyzing conversation using UserMemoryAgent...")
# Format the conversation for the agent
conversation_str = "\n".join([
f"{msg['role'].upper()}: {msg['content']}"
for msg in conversation_context
])
# Create a task for the agent to analyze and update memories
task = f"""Analyze this recent conversation and update my memory accordingly.
Extract any important facts, preferences, or information that should be remembered.
Recent Conversation:
{conversation_str}
Please review this conversation and:
1. Add any new important information as memories
2. Update existing memories if there's new or changed information
3. Delete any memories that are no longer accurate
Focus on extracting factual information that would be useful for future conversations."""
# Execute the task using the agent's tool system
result = self.analysis_agent.execute_task(task)
if self.verbose:
logger.info(f"Memory update task completed: {result.get('success', False)}")
# Since the agent directly updates memories via tools, we don't need to return updates
# The memories are already updated in the memory manager
# Return empty list as updates were applied directly
return []
except Exception as e:
logger.error(f"Failed to analyze conversation: {e}")
return []
def apply_memory_updates(self, updates: List[MemoryUpdate]) -> Dict[str, Any]:
"""
Apply memory updates to the memory manager
Args:
updates: List of memory updates to apply
Returns:
Summary of applied updates
"""
results = {
'added': 0,
'updated': 0,
'deleted': 0,
'failed': 0,
'details': []
}
for update in updates:
try:
if update.action == 'add' and update.content:
# Handle different memory modes
if self.memory_mode in [MemoryMode.NOTES, MemoryMode.ENHANCED_NOTES]:
memory_id = self.memory_manager.add_memory(
content=update.content,
session_id=f"background-{datetime.now().isoformat()}",
tags=update.tags
)
elif self.memory_mode == MemoryMode.JSON_CARDS:
# Parse content as JSON for JSON cards mode
try:
if isinstance(update.content, str):
content_dict = json.loads(update.content)
else:
content_dict = update.content
except (json.JSONDecodeError, TypeError):
# Fallback to simple parsing
parts = str(update.content).split(':')
if len(parts) >= 2:
content_dict = {
'category': 'personal',
'subcategory': 'info',
'key': parts[0].strip().replace(' ', '_').lower(),
'value': ':'.join(parts[1:]).strip()
}
else:
content_dict = {
'category': 'general',
'subcategory': 'notes',
'key': f"note_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
'value': update.content
}
memory_id = self.memory_manager.add_memory(
content=content_dict,
session_id=f"background-{datetime.now().isoformat()}"
)
elif self.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
# For advanced JSON cards, expect proper structure
try:
if isinstance(update.content, str):
content_dict = json.loads(update.content)
else:
content_dict = update.content
except (json.JSONDecodeError, TypeError):
# Skip if can't parse
results['failed'] += 1
continue
# Extract card data from the nested structure
card_data = content_dict.get('card', {})
memory_id = self.memory_manager.add_memory(
content=content_dict,
session_id=f"background-{datetime.now().isoformat()}",
backstory=update.reason or '',
person=card_data.get('person', 'User'),
relationship=card_data.get('relationship', 'primary account holder')
)
else:
memory_id = self.memory_manager.add_memory(
content=update.content,
session_id=f"background-{datetime.now().isoformat()}",
tags=update.tags
)
results['added'] += 1
# Format content for display
if isinstance(update.content, dict):
# For JSON modes, show a summary
if self.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
card_key = update.content.get('card_key', 'unknown')
category = update.content.get('category', 'unknown')
display_content = f"{category}.{card_key}"
else:
display_content = json.dumps(update.content, ensure_ascii=False)[:100]
else:
display_content = str(update.content)[:50]
results['details'].append(f"Added: {display_content}...")
# Always print to console for demo purposes
print(f" 📝 [ADD] Memory: {display_content}")
if self.verbose:
logger.info(f"Added memory: {display_content}")
elif update.action == 'update' and update.memory_id and update.content:
# Handle different memory modes
if self.memory_mode in [MemoryMode.NOTES, MemoryMode.ENHANCED_NOTES]:
success = self.memory_manager.update_memory(
memory_id=update.memory_id,
content=update.content,
session_id=f"background-{datetime.now().isoformat()}",
tags=update.tags
)
elif self.memory_mode == MemoryMode.JSON_CARDS:
# Parse content for JSON cards mode
try:
if isinstance(update.content, str):
content_dict = json.loads(update.content)
else:
content_dict = update.content
except (json.JSONDecodeError, TypeError):
# Simple value update
content_dict = {'value': update.content}
success = self.memory_manager.update_memory(
memory_id=update.memory_id,
content=content_dict,
session_id=f"background-{datetime.now().isoformat()}"
)
elif self.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
# For advanced JSON cards, expect proper structure
try:
if isinstance(update.content, str):
content_dict = json.loads(update.content)
else:
content_dict = update.content
except (json.JSONDecodeError, TypeError):
# Skip if can't parse
results['failed'] += 1
continue
success = self.memory_manager.update_memory(
memory_id=update.memory_id,
content=content_dict,
session_id=f"background-{datetime.now().isoformat()}"
)
else:
success = self.memory_manager.update_memory(
memory_id=update.memory_id,
content=update.content,
session_id=f"background-{datetime.now().isoformat()}",
tags=update.tags
)
if success:
results['updated'] += 1
# Format content for display
if isinstance(update.content, dict):
if self.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
card_key = update.content.get('card_key', 'unknown')
category = update.content.get('category', 'unknown')
display_content = f"{category}.{card_key}"
else:
display_content = json.dumps(update.content, ensure_ascii=False)[:100]
else:
display_content = str(update.content)[:50]
results['details'].append(f"Updated {update.memory_id}: {display_content}...")
# Always print to console for demo purposes
print(f" ✏️ [UPDATE] Memory (ID: {update.memory_id[:8] if len(update.memory_id) > 8 else update.memory_id}): {display_content}")
else:
results['failed'] += 1
if self.verbose:
if isinstance(update.content, dict):
display_content = json.dumps(update.content, ensure_ascii=False)[:100]
else:
display_content = str(update.content)[:50]
logger.info(f"Updated memory {update.memory_id}: {display_content}")
elif update.action == 'delete' and update.memory_id:
self.memory_manager.delete_memory(update.memory_id)
results['deleted'] += 1
results['details'].append(f"Deleted: {update.memory_id}")
# Always print to console for demo purposes
print(f" 🗑️ [DELETE] Memory ID: {update.memory_id}")
if self.verbose:
logger.info(f"Deleted memory: {update.memory_id}")
except Exception as e:
logger.error(f"Failed to apply update {update.action}: {e}")
results['failed'] += 1
return results
def process_recent_conversations(self) -> Dict[str, Any]:
"""
Process recent conversations and update memories
Returns:
Processing results with list of operations
"""
with self.processing_lock:
# Mark the current conversations as accounted for up front.
# Without this, an early return below leaves last_processed_count
# stale, so should_process() stays True and the background loop
# re-triggers every second forever.
self.last_processed_count = self.conversation_count
self.last_processed_timestamp = datetime.now()
# Reload history from disk: the main agent writes turns through its
# own ConversationHistory instance, so this instance's in-memory
# list is stale unless we re-read the file.
self.conversation_history.load_history()
# Get recent conversation turns
recent_turns = self.conversation_history.get_recent_turns(
limit=self.config.context_window
)
if not recent_turns:
return {
'message': 'No recent conversations to process',
'operations': [],
'summary': {'added': 0, 'updated': 0, 'deleted': 0}
}
# Filter out already processed turns
unprocessed_turns = []
for turn in recent_turns:
# Create a unique ID for each turn
turn_id = f"{turn.session_id}_{turn.turn_number}_{turn.timestamp}"
if turn_id not in self.processed_turn_ids:
unprocessed_turns.append(turn)
self.processed_turn_ids.add(turn_id)
# If all turns have been processed, nothing to do
if not unprocessed_turns:
return {
'message': 'No new conversations to process',
'operations': [],
'summary': {'added': 0, 'updated': 0, 'deleted': 0}
}
# Convert to conversation format
conversation_context = []
for turn in unprocessed_turns:
conversation_context.append({
'role': 'user',
'content': turn.user_message
})
conversation_context.append({
'role': 'assistant',
'content': turn.assistant_message
})
# Analyze conversation - this now directly updates memories via agent tools
# The agent will process the conversation and use its tools to update memories
_ = self.analyze_conversation(conversation_context)
# Get the tool call history from the agent to report what was done
tool_calls = getattr(self.analysis_agent, 'tool_calls', [])
# Create operations list from tool calls
operations = []
summary = {'added': 0, 'updated': 0, 'deleted': 0}
for tool_call in tool_calls:
if tool_call.tool_name == 'add_memory':
operations.append({
'action': 'add',
'content': tool_call.arguments.get('content'),
'result': tool_call.result
})
if tool_call.result and tool_call.result.get('success'):
summary['added'] += 1
elif tool_call.tool_name == 'update_memory':
operations.append({
'action': 'update',
'memory_id': tool_call.arguments.get('memory_id'),
'content': tool_call.arguments.get('content'),
'result': tool_call.result
})
if tool_call.result and tool_call.result.get('success'):
summary['updated'] += 1
elif tool_call.tool_name == 'delete_memory':
operations.append({
'action': 'delete',
'memory_id': tool_call.arguments.get('memory_id'),
'result': tool_call.result
})
if tool_call.result and tool_call.result.get('success'):
summary['deleted'] += 1
# Clear tool calls for next run
self.analysis_agent.tool_calls = []
# Format final results
final_results = {
'analyzed_turns': len(unprocessed_turns),
'operations': operations,
'summary': summary,
'details': operations # Operations are the details
}
return final_results
def should_process(self) -> bool:
"""
Check if memory processing should be triggered based on conversation count
Returns:
True if processing should occur
"""
if self.conversation_count == 0:
return False
# Check if we've reached the conversation interval
conversations_since_last = self.conversation_count - self.last_processed_count
should_process = conversations_since_last >= self.config.conversation_interval
# Debug logging to understand the issue
if should_process and self.verbose:
logger.debug(f"Should process: conv_count={self.conversation_count}, last_processed={self.last_processed_count}, interval={self.config.conversation_interval}")
return should_process
def increment_conversation_count(self):
"""
Increment the conversation counter
"""
self.conversation_count += 1
if self.verbose:
logger.info(f"Conversation count: {self.conversation_count}, Last processed: {self.last_processed_count}")
def _background_processing_loop(self):
"""
Background loop for automatic memory processing based on conversation count
"""
logger.info(f"Starting background memory processing (interval: every {self.config.conversation_interval} conversations)")
while not self.stop_processing:
try:
# Check every second if we should process
time.sleep(1)
if self.stop_processing:
break
# Check if we should process based on conversation count
if self.should_process():
if self.verbose:
logger.info(f"Processing triggered: conversations={self.conversation_count}, last_processed={self.last_processed_count}")
results = self.process_recent_conversations()
if self.config.output_operations and results:
self._output_operations(results)
if self.verbose:
logger.info(f"Background processing results: {results.get('summary')}")
logger.info(f"Updated last_processed_count to {self.last_processed_count}")
except Exception as e:
logger.error(f"Error in background processing: {e}")
logger.info("Background memory processing stopped")
def _output_operations(self, results: Dict[str, Any]):
"""
Output memory operations in a formatted way
Args:
results: Processing results with operations
"""
operations = results.get('operations', [])
summary = results.get('summary', {})
# Don't log anything if there's no actual conversation to process
if results.get('message') in ['No recent conversations to process', 'No new conversations to process']:
return
if not operations:
# Only log when there were conversations analyzed but no updates needed
if results.get('analyzed_turns', 0) > 0:
logger.info("📝 Memory Operations: None (no updates needed)")
return
logger.info(f"\n📝 Memory Operations ({len(operations)} total):")
logger.info("-" * 50)
for i, op in enumerate(operations, 1):
icon = {
'add': '',
'update': '📝',
'delete': '🗑️'
}.get(op['action'], '')
logger.info(f"{i}. {icon} {op['action'].upper()}")
if op.get('content'):
logger.info(f" Content: {op['content']}")
if op.get('memory_id'):
logger.info(f" Memory ID: {op['memory_id']}")
if op.get('reason'):
logger.info(f" Reason: {op['reason']}")
if op.get('tags'):
logger.info(f" Tags: {', '.join(op['tags'])}")
logger.info("")
logger.info(f"Summary: {summary.get('added', 0)} added, {summary.get('updated', 0)} updated, {summary.get('deleted', 0)} deleted")
logger.info("-" * 50)
def start_background_processing(self):
"""Start the background memory processing thread"""
if self.processing_thread and self.processing_thread.is_alive():
logger.warning("Background processing already running")
return
self.stop_processing = False
# Clear processed turns when starting fresh
self.processed_turn_ids.clear()
self.processing_thread = threading.Thread(
target=self._background_processing_loop,
daemon=True
)
self.processing_thread.start()
logger.info("Background memory processing started")
def stop_background_processing(self):
"""Stop the background memory processing thread"""
self.stop_processing = True
if self.processing_thread:
self.processing_thread.join(timeout=5)
logger.info("Background memory processing stopped")
def process_conversation_batch(self, conversation_contexts: List[List[Dict[str, str]]]) -> List[Dict[str, Any]]:
"""
Process multiple conversation contexts in batch
Args:
conversation_contexts: List of conversation contexts
Returns:
List of processing results
"""
results = []
for context in conversation_contexts:
updates = self.analyze_conversation(context)
operations = []
for update in updates:
operation = {
'action': update.action,
'content': update.content,
}
if update.memory_id:
operation['memory_id'] = update.memory_id
operations.append(operation)
if updates:
apply_result = self.apply_memory_updates(updates)
result = {
'operations': operations,
'summary': {
'added': apply_result['added'],
'updated': apply_result['updated'],
'deleted': apply_result['deleted']
}
}
else:
result = {
'message': 'No updates needed',
'operations': [],
'summary': {'added': 0, 'updated': 0, 'deleted': 0}
}
results.append(result)
return results
+231
View File
@@ -0,0 +1,231 @@
"""
Configuration module for User Memory System
"""
import os
from typing import Optional, Dict, Any
from dotenv import load_dotenv
from enum import Enum
# Load environment variables
load_dotenv()
def _reasoning_safe_temperature(model, requested=1.0):
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
Return 1 for those; otherwise the requested value so non-reasoning
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
m = str(model or "").lower().replace("/", "-")
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
def openrouter_model_id(model) -> str:
"""Map a provider-native model name to an OpenRouter model id, used by the
universal OpenRouter fallback. An explicit OPENROUTER_MODEL env var wins."""
override = os.getenv("OPENROUTER_MODEL")
if override:
return override
m = (model or "").strip()
if not m:
return "openai/gpt-5.6-luna"
if "/" in m:
return m # already an OpenRouter-style id (e.g. openai/gpt-5.6-luna)
ml = m.lower()
if ml.startswith(("gpt-", "o1", "o3", "o4", "chatgpt")):
return "openai/" + m
if ml.startswith("claude-"):
return "anthropic/claude-opus-4.8"
if ml.startswith("kimi"):
# kimi-k3 is not on OpenRouter; moonshotai/kimi-k2.6 is the closest hosted id.
return "moonshotai/kimi-k2.6"
# Provider-native ids (kimi-*/doubao-*/qwen/deepseek-*) not hosted on
# OpenRouter under the same name -> a widely-available OpenAI chat model.
return "openai/gpt-5.6-luna"
# Default model per provider, used to map onto an OpenRouter model id when the
# primary provider key is missing but OPENROUTER_API_KEY is present.
PROVIDER_DEFAULT_MODELS = {
"dashscope": "qwen3.7-plus",
"siliconflow": "Qwen/Qwen3-235B-A22B-Thinking-2507",
"doubao": os.getenv("ARK_MODEL", "doubao-seed-1-6-250615"),
"kimi": "kimi-k3",
"moonshot": "kimi-k3",
}
class MemoryMode(Enum):
"""Memory management modes"""
NOTES = "notes" # Simple notes/facts (basic)
ENHANCED_NOTES = "enhanced_notes" # Enhanced notes with paragraphs containing full context
JSON_CARDS = "json_cards" # Hierarchical JSON memory cards (basic)
ADVANCED_JSON_CARDS = "advanced_json_cards" # Advanced JSON cards with complete card objects
class Config:
"""Configuration settings for the user memory system"""
# Provider Configuration
PROVIDER: str = os.getenv("PROVIDER", "kimi").lower() # Default to kimi
# API Keys for different providers
MOONSHOT_API_KEY: str = os.getenv("MOONSHOT_API_KEY", "") # For kimi/moonshot
SILICONFLOW_API_KEY: str = os.getenv("SILICONFLOW_API_KEY", "")
DASHSCOPE_API_KEY: str = os.getenv("DASHSCOPE_API_KEY", "")
# Ark is the provider's canonical product name; older companion docs used
# DOUBAO_API_KEY. Accept both without copying credentials into local files.
DOUBAO_API_KEY: str = os.getenv("DOUBAO_API_KEY") or os.getenv("ARK_API_KEY", "")
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
# Base URLs for different providers
MOONSHOT_BASE_URL: str = "https://api.moonshot.cn/v1"
SILICONFLOW_BASE_URL: str = "https://api.siliconflow.cn/v1"
DASHSCOPE_BASE_URL: str = os.getenv(
"DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"
)
DOUBAO_BASE_URL: str = "https://ark.cn-beijing.volces.com/api/v3"
OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
# Model Configuration
MODEL_NAME: str = os.getenv("MODEL_NAME", "") # Can override default model for provider
MODEL_TEMPERATURE: float = float(os.getenv("MODEL_TEMPERATURE", "0.3"))
MODEL_MAX_TOKENS: int = int(os.getenv("MODEL_MAX_TOKENS", "4096"))
# Memory Configuration
MEMORY_MODE: MemoryMode = MemoryMode(os.getenv("MEMORY_MODE", "notes").lower())
MAX_MEMORY_ITEMS: int = int(os.getenv("MAX_MEMORY_ITEMS", "100"))
MEMORY_UPDATE_TEMPERATURE: float = float(os.getenv("MEMORY_UPDATE_TEMPERATURE", "0.2"))
# Dify Configuration for conversation history search
DIFY_API_KEY: str = os.getenv("DIFY_API_KEY", "")
DIFY_BASE_URL: str = os.getenv("DIFY_BASE_URL", "https://api.dify.ai/v1")
DIFY_DATASET_ID: str = os.getenv("DIFY_DATASET_ID", "")
ENABLE_HISTORY_SEARCH: bool = os.getenv("ENABLE_HISTORY_SEARCH", "false").lower() == "true"
# Session Configuration
SESSION_TIMEOUT: int = int(os.getenv("SESSION_TIMEOUT", "3600")) # seconds
MAX_CONTEXT_LENGTH: int = int(os.getenv("MAX_CONTEXT_LENGTH", "8000")) # tokens
# LOCOMO Benchmark Configuration
LOCOMO_DATASET_PATH: str = os.getenv("LOCOMO_DATASET_PATH", "data/locomo")
LOCOMO_OUTPUT_DIR: str = os.getenv("LOCOMO_OUTPUT_DIR", "results/locomo")
# Logging Configuration
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
LOG_FILE: Optional[str] = os.getenv("LOG_FILE", "logs/user_memory.log")
LOG_FORMAT: str = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
# Storage paths
MEMORY_STORAGE_DIR: str = os.getenv("MEMORY_STORAGE_DIR", "data/memories")
CONVERSATION_HISTORY_DIR: str = os.getenv("CONVERSATION_HISTORY_DIR", "data/conversations")
@classmethod
def get_api_key(cls, provider: Optional[str] = None) -> Optional[str]:
"""
Get API key for the specified provider
Args:
provider: Provider name (defaults to configured provider)
Returns:
API key or None if not found
"""
provider = (provider or cls.PROVIDER).lower()
provider = {"qwen": "dashscope", "bailian": "dashscope"}.get(provider, provider)
if provider in ["kimi", "moonshot"]:
return cls.MOONSHOT_API_KEY
elif provider == "dashscope":
return cls.DASHSCOPE_API_KEY
elif provider == "siliconflow":
return cls.SILICONFLOW_API_KEY
elif provider == "doubao":
return cls.DOUBAO_API_KEY
elif provider == "openrouter":
return cls.OPENROUTER_API_KEY
else:
return None
@classmethod
def validate(cls, provider: Optional[str] = None) -> bool:
"""
Validate required configuration
Args:
provider: Provider to validate (defaults to configured provider)
Returns:
True if configuration is valid
"""
provider = (provider or cls.PROVIDER).lower()
provider = {"qwen": "dashscope", "bailian": "dashscope"}.get(provider, provider)
api_key = cls.get_api_key(provider)
if not api_key:
print(f"ERROR: API key for provider '{provider}' is not set")
if provider in ["kimi", "moonshot"]:
print("Please set MOONSHOT_API_KEY in .env file or as environment variable")
elif provider == "siliconflow":
print("Please set SILICONFLOW_API_KEY in .env file or as environment variable")
elif provider == "dashscope":
print("Please set DASHSCOPE_API_KEY in .env file or as environment variable")
elif provider == "doubao":
print("Please set DOUBAO_API_KEY in .env file or as environment variable")
elif provider == "openrouter":
print("Please set OPENROUTER_API_KEY in .env file or as environment variable")
else:
print(f"Unknown provider: {provider}")
return False
if cls.ENABLE_HISTORY_SEARCH and not cls.DIFY_API_KEY:
print("WARNING: History search enabled but DIFY_API_KEY not set")
print("History search will be disabled")
cls.ENABLE_HISTORY_SEARCH = False
return True
@classmethod
def create_directories(cls):
"""Create necessary directories if they don't exist"""
os.makedirs(cls.MEMORY_STORAGE_DIR, exist_ok=True)
os.makedirs(cls.CONVERSATION_HISTORY_DIR, exist_ok=True)
os.makedirs(cls.LOCOMO_OUTPUT_DIR, exist_ok=True)
os.makedirs(os.path.dirname(cls.LOG_FILE) or "logs", exist_ok=True)
@classmethod
def get_model_config(cls) -> Dict[str, Any]:
"""
Get model configuration as dictionary
Returns:
Model configuration dict
"""
return {
"model": cls.MODEL_NAME,
"temperature": _reasoning_safe_temperature(cls.MODEL_NAME, cls.MODEL_TEMPERATURE),
"max_tokens": cls.MODEL_MAX_TOKENS
}
@classmethod
def print_config(cls):
"""Print current configuration (hiding sensitive data)"""
print("\n" + "="*50)
print("USER MEMORY SYSTEM CONFIGURATION")
print("="*50)
print(f"Provider: {cls.PROVIDER}")
print(f"Model: {cls.MODEL_NAME or '(using provider default)'}")
print(f"Memory Mode: {cls.MEMORY_MODE.value}")
print(f"Max Memory Items: {cls.MAX_MEMORY_ITEMS}")
print(f"History Search: {'Enabled' if cls.ENABLE_HISTORY_SEARCH else 'Disabled'}")
# Show which API keys are set
print(f"\nAPI Keys:")
print(f" Kimi/Moonshot: {'✓ Set' if cls.MOONSHOT_API_KEY else '✗ Not set'}")
print(f" DashScope/Bailian: {'✓ Set' if cls.DASHSCOPE_API_KEY else '✗ Not set'}")
print(f" SiliconFlow: {'✓ Set' if cls.SILICONFLOW_API_KEY else '✗ Not set'}")
print(f" Doubao: {'✓ Set' if cls.DOUBAO_API_KEY else '✗ Not set'}")
print(f" OpenRouter: {'✓ Set' if cls.OPENROUTER_API_KEY else '✗ Not set'}")
print(f" Dify: {'✓ Set' if cls.DIFY_API_KEY else '✗ Not set'}")
print(f"\nLog Level: {cls.LOG_LEVEL}")
print("="*50 + "\n")
@@ -0,0 +1,352 @@
"""
Conversation History Management with optional Dify integration for vector search
"""
import json
import os
import logging
from typing import List, Dict, Any, Optional
from dataclasses import dataclass, asdict
from datetime import datetime
import requests
from config import Config
logger = logging.getLogger(__name__)
@dataclass
class ConversationTurn:
"""Represents a single conversation turn"""
session_id: str
user_message: str
assistant_message: str
timestamp: str
turn_number: int = 0
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'ConversationTurn':
"""Create from dictionary"""
return cls(**data)
class ConversationHistory:
"""Manages conversation history with optional vector search"""
def __init__(self, user_id: str):
"""
Initialize conversation history manager
Args:
user_id: User identifier
"""
self.user_id = user_id
self.history_file = os.path.join(
Config.CONVERSATION_HISTORY_DIR,
f"{user_id}_history.json"
)
self.conversations: List[ConversationTurn] = []
self.load_history()
# Initialize Dify client if configured
self.dify_client = None
if Config.ENABLE_HISTORY_SEARCH and Config.DIFY_API_KEY:
self.dify_client = DifySearchClient()
def load_history(self):
"""Load conversation history from storage"""
if os.path.exists(self.history_file):
try:
with open(self.history_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.conversations = [
ConversationTurn.from_dict(turn)
for turn in data.get('conversations', [])
]
logger.info(f"Loaded {len(self.conversations)} conversation turns for user {self.user_id}")
except Exception as e:
logger.error(f"Error loading conversation history: {e}")
self.conversations = []
else:
self.conversations = []
def save_history(self):
"""Save conversation history to storage"""
try:
os.makedirs(os.path.dirname(self.history_file) or ".", exist_ok=True)
# Write to a temp file then atomically replace: a crash mid-dump
# must not truncate the only copy of the persisted data.
tmp_file = self.history_file + '.tmp'
with open(tmp_file, 'w', encoding='utf-8') as f:
data = {
'user_id': self.user_id,
'updated_at': datetime.now().isoformat(),
'conversations': [turn.to_dict() for turn in self.conversations]
}
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(tmp_file, self.history_file)
logger.info(f"Saved {len(self.conversations)} conversation turns")
except Exception as e:
logger.error(f"Error saving conversation history: {e}")
def add_turn(self, session_id: str, user_message: str, assistant_message: str):
"""
Add a conversation turn
Args:
session_id: Session identifier
user_message: User's message
assistant_message: Assistant's response
"""
turn = ConversationTurn(
session_id=session_id,
user_message=user_message,
assistant_message=assistant_message,
timestamp=datetime.now().isoformat(),
turn_number=len(self.conversations) + 1
)
self.conversations.append(turn)
self.save_history()
# Index in Dify if available
if self.dify_client:
self.dify_client.index_conversation(turn)
def get_recent_turns(self, limit: int = 10) -> List[ConversationTurn]:
"""
Get recent conversation turns
Args:
limit: Maximum number of turns to return
Returns:
List of recent conversation turns
"""
# limit<=0 → []; list[-0:] would return the full list.
if limit <= 0 or not self.conversations:
return []
return self.conversations[-limit:]
def get_session_turns(self, session_id: str) -> List[ConversationTurn]:
"""
Get all turns from a specific session
Args:
session_id: Session identifier
Returns:
List of conversation turns from the session
"""
return [turn for turn in self.conversations if turn.session_id == session_id]
def search_history(self, query: str, limit: int = 5) -> List[ConversationTurn]:
"""
Search conversation history
Args:
query: Search query
limit: Maximum number of results
Returns:
List of matching conversation turns
"""
if limit <= 0 or not self.conversations:
return []
# Try vector search with Dify if available
if self.dify_client:
return self.dify_client.search_conversations(query, self.user_id, limit)
# Fallback to simple text search
query_lower = query.lower()
results = []
for turn in self.conversations:
if (query_lower in turn.user_message.lower() or
query_lower in turn.assistant_message.lower()):
results.append(turn)
if len(results) >= limit:
break
return results
class DifySearchClient:
"""Client for Dify vector search integration"""
def __init__(self):
"""Initialize Dify client"""
self.api_key = Config.DIFY_API_KEY
self.base_url = Config.DIFY_BASE_URL
self.dataset_id = Config.DIFY_DATASET_ID
if not self.api_key or not self.dataset_id:
logger.warning("Dify API key or dataset ID not configured")
self.enabled = False
else:
self.enabled = True
self.headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json'
}
def index_conversation(self, turn: ConversationTurn):
"""
Index a conversation turn in Dify
Args:
turn: Conversation turn to index
"""
if not self.enabled:
return
try:
# Prepare document for indexing
document = {
'name': f"{turn.session_id}_{turn.turn_number}",
'text': f"User: {turn.user_message}\n\nAssistant: {turn.assistant_message}",
'metadata': {
'session_id': turn.session_id,
'timestamp': turn.timestamp,
'turn_number': str(turn.turn_number)
}
}
# Index document in Dify
url = f"{self.base_url}/datasets/{self.dataset_id}/documents"
response = requests.post(
url,
headers=self.headers,
json={'documents': [document]}, timeout=30
)
if response.status_code == 200:
logger.debug(f"Indexed conversation turn {turn.session_id}_{turn.turn_number}")
else:
logger.error(f"Failed to index conversation: {response.text}")
except Exception as e:
logger.error(f"Error indexing conversation in Dify: {e}")
def search_conversations(
self,
query: str,
user_id: str,
limit: int = 5
) -> List[ConversationTurn]:
"""
Search conversations using Dify vector search
Args:
query: Search query
user_id: User identifier
limit: Maximum number of results
Returns:
List of matching conversation turns
"""
if not self.enabled:
return []
try:
# Perform vector search
url = f"{self.base_url}/datasets/{self.dataset_id}/search"
payload = {
'query': query,
'limit': limit,
'retrieval_model': {
'search_method': 'semantic_search',
'reranking_enable': True,
'reranking_model': {
'reranking_provider_name': 'cohere',
'reranking_model_name': 'rerank-multilingual-v2.0'
},
'top_k': limit * 2,
'score_threshold_enabled': True,
'score_threshold': 0.5
}
}
response = requests.post(
url,
headers=self.headers,
json=payload, timeout=30
)
if response.status_code == 200:
results = response.json()
turns = []
for record in results.get('records', []):
metadata = record.get('metadata', {})
# Parse the text back into user and assistant messages
text = record.get('segment', {}).get('content', '')
parts = text.split('\n\nAssistant: ')
if len(parts) == 2:
user_msg = parts[0].replace('User: ', '')
assistant_msg = parts[1]
turn = ConversationTurn(
session_id=metadata.get('session_id', ''),
user_message=user_msg,
assistant_message=assistant_msg,
timestamp=metadata.get('timestamp', ''),
turn_number=int(metadata.get('turn_number', 0))
)
turns.append(turn)
return turns
else:
logger.error(f"Failed to search conversations: {response.text}")
return []
except Exception as e:
logger.error(f"Error searching conversations in Dify: {e}")
return []
class SimpleEmbeddingSearch:
"""Simple embedding-based search without external dependencies"""
def __init__(self):
"""Initialize simple embedding search"""
# This is a placeholder for a simple embedding search
# In production, you might use sentence-transformers or similar
pass
def search(
self,
query: str,
documents: List[str],
limit: int = 5
) -> List[int]:
"""
Simple text similarity search
Args:
query: Search query
documents: List of documents to search
limit: Maximum number of results
Returns:
Indices of top matching documents
"""
# Simple keyword-based scoring
query_words = set(query.lower().split())
scores = []
for i, doc in enumerate(documents):
doc_words = set(doc.lower().split())
# Calculate Jaccard similarity
intersection = query_words & doc_words
union = query_words | doc_words
score = len(intersection) / len(union) if union else 0
scores.append((i, score))
# Sort by score and return top indices
scores.sort(key=lambda x: x[1], reverse=True)
return [idx for idx, _ in scores[:limit]]
@@ -0,0 +1,305 @@
"""
Conversational Agent - Focuses purely on conversation without direct memory management
Memory updates are handled by a separate background process
"""
import json
import logging
import os
from typing import List, Dict, Any, Optional
from dataclasses import dataclass
from datetime import datetime
import uuid
from openai import OpenAI
from config import Config, openrouter_model_id, PROVIDER_DEFAULT_MODELS
from conversation_history import ConversationHistory, ConversationTurn
from memory_manager import create_memory_manager, BaseMemoryManager, MemoryMode
def _reasoning_safe_temperature(model, requested=1.0):
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
Return 1 for those; otherwise the requested value so non-reasoning
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
m = str(model or "").lower().replace("/", "-")
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
@dataclass
class ConversationConfig:
"""Configuration for the conversational agent"""
enable_memory_context: bool = True # Include memory in context but don't update
enable_conversation_history: bool = True
max_memory_context: int = 10
temperature: float = 0.7
max_tokens: int = 4096
class ConversationalAgent:
"""
Pure conversational agent that focuses on dialogue
Reads memory for context but doesn't update it directly
"""
def __init__(self,
user_id: str,
api_key: Optional[str] = None,
provider: Optional[str] = None,
model: Optional[str] = None,
config: Optional[ConversationConfig] = None,
memory_mode: MemoryMode = MemoryMode.NOTES,
verbose: bool = True):
"""
Initialize the conversational agent
Args:
user_id: Unique user identifier
api_key: API key (defaults to env based on provider)
provider: LLM provider ('dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot')
model: Model name (defaults to provider's default)
config: Agent configuration
memory_mode: Memory storage mode
verbose: Enable verbose logging
"""
self.user_id = user_id
self.verbose = verbose
self.config = config or ConversationConfig()
self.memory_mode = memory_mode
# Determine provider
self.provider = (provider or Config.PROVIDER).lower()
self.provider = {"qwen": "dashscope", "bailian": "dashscope"}.get(
self.provider, self.provider
)
# Get API key for provider
api_key = api_key or Config.get_api_key(self.provider)
# Universal OpenRouter fallback: primary provider key absent but
# OPENROUTER_API_KEY present -> route this agent through OpenRouter.
if not api_key and self.provider != "openrouter" and Config.OPENROUTER_API_KEY:
model = openrouter_model_id(model or PROVIDER_DEFAULT_MODELS.get(self.provider))
self.provider = "openrouter"
api_key = Config.OPENROUTER_API_KEY
if not api_key:
raise ValueError(
f"API key required for provider '{self.provider}'. Set the "
f"provider's key or OPENROUTER_API_KEY to use the OpenRouter fallback."
)
# Configure client based on provider
if self.provider == "dashscope":
self.client = OpenAI(
api_key=api_key,
base_url=Config.DASHSCOPE_BASE_URL
)
self.model = model or PROVIDER_DEFAULT_MODELS["dashscope"]
elif self.provider == "siliconflow":
self.client = OpenAI(
api_key=api_key,
base_url="https://api.siliconflow.cn/v1"
)
self.model = model or "Qwen/Qwen3-235B-A22B-Thinking-2507"
elif self.provider == "doubao":
self.client = OpenAI(
api_key=api_key,
base_url="https://ark.cn-beijing.volces.com/api/v3"
)
self.model = model or os.getenv("ARK_MODEL", "doubao-seed-1-6-250615")
elif self.provider == "kimi" or self.provider == "moonshot":
self.client = OpenAI(
api_key=api_key,
base_url="https://api.moonshot.cn/v1"
)
self.model = model or "kimi-k3"
elif self.provider == "openrouter":
self.client = OpenAI(
api_key=api_key,
base_url="https://openrouter.ai/api/v1"
)
# Default to Gemini 2.5 Pro, but allow any of the supported models
self.model = model or "google/gemini-3.5-flash"
# Supported models: google/gemini-3.5-flash, openai/gpt-5, anthropic/claude-sonnet-4
else:
raise ValueError(f"Unsupported provider: {self.provider}. Use 'dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot', or 'openrouter'")
# Initialize memory manager (read-only access)
self.memory_manager = create_memory_manager(user_id, memory_mode)
# Initialize conversation history
self.conversation_history = ConversationHistory(user_id) if self.config.enable_conversation_history else None
# Track current session
self.session_id = self._generate_session_id()
self.conversation = []
# Initialize system prompt
self._init_system_prompt()
logger.info(f"ConversationalAgent initialized for user {user_id} with {self.provider} provider using {self.model}")
def _generate_session_id(self) -> str:
"""Generate a unique session ID"""
return f"session-{uuid.uuid4().hex[:8]}"
def _init_system_prompt(self):
"""Initialize the system prompt"""
system_content = """You are a helpful and personalized assistant. You have access to information about the user from previous conversations, which helps you provide personalized and contextual responses.
You MUST analyze the context, user's questions and memories in detail, and provide a comprehensive and detailed response.
"""
self.conversation = [
{
"role": "system",
"content": system_content
}
]
def _get_memory_context(self) -> str:
"""Get current memory context as a string"""
if not self.config.enable_memory_context:
return ""
context_parts = []
# The background processor writes memory through its own manager
# instance; reload from disk so its updates are visible within the
# session (same reason main.py reloads after processing, and the
# same fix ConversationHistory got for issue #181).
self.memory_manager.load_memory()
# Add memory summary
memory_str = self.memory_manager.get_context_string()
if memory_str:
context_parts.append("=== USER CONTEXT ===")
context_parts.append(memory_str)
context_parts.append("")
# Keep raw conversation turns scoped to the active session. Persisted
# turns from earlier sessions are input to the background memory
# processor, but the conversational agent should learn about those
# sessions only through the structured long-term memory above.
if self.conversation_history:
session_turns = self.conversation_history.get_session_turns(
self.session_id
)
if session_turns:
context_parts.append("=== CURRENT SESSION HISTORY ===")
context_parts.append(f"Total turns: {len(session_turns)}")
context_parts.append("")
for turn in session_turns:
context_parts.append(f"[Session: {turn.session_id}, Turn {turn.turn_number}, Time: {turn.timestamp}]")
context_parts.append(f"User: {turn.user_message}")
context_parts.append(f"Assistant: {turn.assistant_message}")
context_parts.append("")
return "\n".join(context_parts)
def get_conversation_context(self) -> List[Dict[str, str]]:
"""
Get the full conversation context for background memory processing
Returns:
List of conversation messages
"""
# Return a copy of the conversation without system prompt
return [msg for msg in self.conversation[1:] if msg.get('role') != 'system']
def chat(self, message: str) -> str:
"""
Have a conversation with the user
Args:
message: User message
Returns:
Assistant response
"""
# Add memory context to the user message
memory_context = self._get_memory_context()
if memory_context:
full_message = f"{message}\n\n{memory_context}"
else:
full_message = message
# Log the full prompt if verbose
if self.verbose:
logger.info(f"User request: {message}")
if memory_context:
logger.info(f"Memory context added: {memory_context}")
logger.info(f"Full prompt sent to API: {full_message}")
# Persist only the raw message; the memory/history context block is
# sent transiently as this call's last message. Persisting
# full_message would embed the entire history inside every user turn
# of a conversation that already contains the previous turns natively,
# so tokens per turn would grow O(N^2) across the session.
self.conversation.append({"role": "user", "content": message})
api_messages = self.conversation[:-1] + [{"role": "user", "content": full_message}]
try:
# Call the model with streaming
stream = self.client.chat.completions.create(
model=self.model,
messages=api_messages,
temperature=_reasoning_safe_temperature(self.model, self.config.temperature),
max_tokens=self.config.max_tokens,
stream=True
)
# Collect streamed response
assistant_message = ""
if self.verbose:
logger.info("Streaming response...")
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
delta = chunk.choices[0].delta.content
assistant_message += delta
# Always stream output to show real-time response
print(delta, end='', flush=True)
print() # New line after streaming
# Add assistant response to conversation
self.conversation.append({
"role": "assistant",
"content": assistant_message
})
# Save to conversation history
if self.conversation_history:
self.conversation_history.add_turn(
session_id=self.session_id,
user_message=message,
assistant_message=assistant_message
)
if self.verbose:
logger.info(f"User: {message}")
logger.info(f"Assistant: {assistant_message}")
return assistant_message
except Exception as e:
error_msg = f"Error during conversation: {str(e)}"
logger.error(error_msg)
return f"I apologize, but I encountered an error: {str(e)}"
def reset_session(self):
"""Start a new conversation session"""
self.session_id = self._generate_session_id()
self._init_system_prompt()
logger.info(f"Started new session: {self.session_id}")
def get_session_id(self) -> str:
"""Get the current session ID"""
return self.session_id
@@ -0,0 +1,13 @@
{
"user_id": "default_user",
"updated_at": "2025-09-18T21:54:06.558179",
"conversations": [
{
"session_id": "session-5853d246",
"user_message": "What was my checking account number again? I need it to set up my direct deposit at work.",
"assistant_message": "Your First National Bank Premium Checking account number is **4429853327**.\n\nFor the direct-deposit form youll also need the routing number: **123006800**.",
"timestamp": "2025-09-18T21:54:06.557899",
"turn_number": 1
}
]
}
@@ -0,0 +1,13 @@
{
"user_id": "test_user_loop",
"updated_at": "2026-07-29T21:11:56.728444",
"conversations": [
{
"session_id": "session-1",
"user_message": "你好,我是小明",
"assistant_message": "你好小明!",
"timestamp": "2026-07-29T21:11:56.728341",
"turn_number": 1
}
]
}
@@ -0,0 +1,149 @@
{
"user_id": "default_user",
"type": "advanced_json_cards",
"updated_at": "2025-09-18T21:53:57.922717",
"categories": {
"financial": {
"bank_account_first_national_checking": {
"backstory": "User opened a Premium Checking account with First National Bank during a phone conversation",
"date_created": "2024-12-19 10:00:00",
"person": "Michael James Robertson (primary)",
"relationship": "primary account holder",
"bank_name": "First National Bank",
"account_type": "Premium Checking",
"account_number": "4429853327",
"routing_number": "123006800",
"minimum_balance": 2500,
"interest_rate": "0.5% APY",
"benefits": [
"no monthly fees with minimum balance",
"free checks",
"free domestic wire transfers",
"0.5% APY on balance"
],
"initial_deposit": 5000,
"debit_card": true,
"debit_card_pin": "4827",
"online_banking_username": "MRobertson503",
"_metadata": {
"created_at": "2025-09-18T21:53:57.920563",
"updated_at": "2025-09-18T21:53:57.920569",
"source": "session-54cf7c3c"
}
},
"bank_account_first_national_savings": {
"backstory": "User opened a Basic Savings account alongside Premium Checking during the same conversation",
"date_created": "2024-12-19 10:00:00",
"person": "Michael James Robertson (primary)",
"relationship": "primary account holder",
"bank_name": "First National Bank",
"account_type": "Basic Savings",
"account_number": "4429853328",
"minimum_balance": 100,
"initial_deposit": 500,
"auto_transfer": {
"amount": 200,
"frequency": "monthly",
"day": 15,
"from": "checking",
"to": "savings"
},
"_metadata": {
"created_at": "2025-09-18T21:53:57.921206",
"updated_at": "2025-09-18T21:53:57.921208",
"source": "session-54cf7c3c"
}
},
"bank_account_wells_fargo": {
"backstory": "User provided Wells Fargo account details for initial deposit transfer",
"date_created": "2024-12-19 10:00:00",
"person": "Michael James Robertson (primary)",
"relationship": "primary account holder",
"bank_name": "Wells Fargo",
"account_number": "8847293001",
"routing_number": "121000248",
"purpose": "funding new First National Bank accounts",
"_metadata": {
"created_at": "2025-09-18T21:53:57.922292",
"updated_at": "2025-09-18T21:53:57.922293",
"source": "session-54cf7c3c"
}
}
},
"personal": {
"personal_info_michael": {
"backstory": "User provided personal information while opening bank accounts",
"date_created": "2024-12-19 10:00:00",
"person": "Michael James Robertson (primary)",
"relationship": "primary account holder",
"full_name": "Michael James Robertson",
"date_of_birth": "1985-03-15",
"ssn": "547-82-9163",
"drivers_license": {
"state": "Oregon",
"number": "D758392"
},
"address": {
"street": "1847 Maple Street, Apartment 3B",
"city": "Portland",
"state": "Oregon",
"zip": "97205",
"years_at_address": 2.5
},
"phone": "503-555-8924",
"email": "mrobertson85@email.com",
"birth_city": "Denver, Colorado",
"mother_maiden_name": "Harrison",
"first_pet": "Buddy (golden retriever)",
"_metadata": {
"created_at": "2025-09-18T21:53:57.921694",
"updated_at": "2025-09-18T21:53:57.921695",
"source": "session-54cf7c3c"
}
}
},
"employment": {
"employment_techcorp": {
"backstory": "User shared employment details during bank account opening process",
"date_created": "2024-12-19 10:00:00",
"person": "Michael James Robertson (primary)",
"relationship": "primary account holder",
"employer": "TechCorp Industries",
"position": "Software Engineer",
"employment_status": "full-time",
"years_employed": 4,
"annual_income": 125000,
"direct_deposit_setup": true,
"_metadata": {
"created_at": "2025-09-18T21:53:57.922018",
"updated_at": "2025-09-18T21:53:57.922019",
"source": "session-54cf7c3c"
}
}
},
"preferences": {
"banking_preferences": {
"backstory": "User expressed preferences during bank account setup conversation",
"date_created": "2024-12-19 10:00:00",
"person": "Michael James Robertson (primary)",
"relationship": "primary account holder",
"statement_preference": "electronic",
"check_design": "standard",
"international_travel": true,
"overdraft_protection": "linked to savings",
"atm_fee_reimbursement": true,
"foreign_transaction_fee": "3%",
"security_questions": [
"first pet name",
"birth city",
"mother's maiden name"
],
"_metadata": {
"created_at": "2025-09-18T21:53:57.922667",
"updated_at": "2025-09-18T21:53:57.922668",
"source": "session-54cf7c3c"
}
}
}
}
}
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""
Demonstration of conversation-based memory processing
Shows how memory operations are triggered per conversation round
"""
import os
import sys
from dotenv import load_dotenv
from conversational_agent import ConversationalAgent, ConversationConfig
from background_memory_processor import BackgroundMemoryProcessor, MemoryProcessorConfig
from config import Config, MemoryMode
from memory_manager import create_memory_manager
# Load environment variables
load_dotenv()
def demonstrate_conversation_processing():
"""Demonstrate the conversation-based memory processing"""
print("="*70)
print("DEMONSTRATION: Conversation-Based Memory Processing")
print("="*70)
# Check API key
if not Config.MOONSHOT_API_KEY:
print("\n❌ Please set MOONSHOT_API_KEY environment variable")
return
# Setup
user_id = "demo_conv_user"
memory_mode = MemoryMode.NOTES
print(f"\n📋 Configuration:")
print(f" • User ID: {user_id}")
print(f" • Memory Mode: {memory_mode.value}")
print(f" • Processing: After EACH conversation round")
print(f" • Output: List of memory operations\n")
# Initialize components
agent = ConversationalAgent(
user_id=user_id,
memory_mode=memory_mode,
verbose=False
)
processor = BackgroundMemoryProcessor(
user_id=user_id,
memory_mode=memory_mode,
config=MemoryProcessorConfig(
conversation_interval=1, # Process after each conversation
min_conversation_turns=1,
output_operations=True
),
verbose=False
)
print("="*70)
print("DEMONSTRATION BEGINS")
print("="*70)
# Conversation rounds
conversations = [
{
"round": 1,
"message": "Hello! I'm Jennifer, a data scientist specializing in NLP and computer vision.",
"expected_ops": ["add"]
},
{
"round": 2,
"message": "I work at DataCorp and use Python with scikit-learn and transformers daily.",
"expected_ops": ["add"]
},
{
"round": 3,
"message": "Actually, let me correct that - I work at AI Innovations, not DataCorp.",
"expected_ops": ["update"]
},
{
"round": 4,
"message": "I'm also learning Rust for high-performance computing tasks.",
"expected_ops": ["add"]
},
{
"round": 5,
"message": "What programming languages do I know?",
"expected_ops": [] # Query, no updates expected
}
]
for conv in conversations:
print(f"\n{'='*70}")
print(f"CONVERSATION ROUND {conv['round']}")
print(f"{'='*70}")
# User message
print(f"\n👤 User: {conv['message']}")
# Get response
response = agent.chat(conv['message'])
print(f"\n🤖 Assistant: {response[:200]}..." if len(response) > 200 else f"\n🤖 Assistant: {response}")
# Increment conversation counter
processor.increment_conversation_count()
# Process memory
print(f"\n📝 Processing Memory (Round {conv['round']})...")
print("-"*50)
results = processor.process_recent_conversations()
# Display operations
operations = results.get('operations', [])
if operations:
print(f"Memory Operations: {len(operations)} operation(s)")
print()
for i, op in enumerate(operations, 1):
icon = {
'add': ' ADD',
'update': '📝 UPDATE',
'delete': '🗑️ DELETE'
}.get(op['action'], '❓ UNKNOWN')
print(f"Operation {i}: {icon}")
if op.get('content'):
content = op['content']
if len(content) > 100:
content = content[:97] + "..."
print(f" Content: {content}")
if op.get('memory_id'):
print(f" Memory ID: {op['memory_id']}")
if op.get('reason'):
print(f" Reason: {op['reason'][:100]}...")
print()
else:
print("Memory Operations: None (no updates needed)")
# Show if operations match expectations
actual_ops = [op['action'] for op in operations]
expected = conv['expected_ops']
if set(actual_ops) == set(expected) or (not actual_ops and not expected):
print("✅ Operations as expected")
else:
print(f"⚠️ Expected {expected}, got {actual_ops}")
# Final memory state
print(f"\n{'='*70}")
print("FINAL MEMORY STATE")
print("="*70)
memory_manager = create_memory_manager(user_id, memory_mode)
memory_content = memory_manager.get_context_string()
print("\nStored Memories:")
print("-"*50)
if memory_content:
lines = memory_content.split('\n')
for line in lines:
if line.strip():
print(f"{line.strip()}")
else:
print(" (No memories)")
print(f"\n{'='*70}")
print("SUMMARY")
print("="*70)
print("\n✅ Demonstration Complete!")
print("\nKey Points:")
print(" 1. Memory processing occurs after EACH conversation round")
print(" 2. Operations list shows exactly what changes (0, 1, or more operations)")
print(" 3. Each operation includes action type, content")
print(" 4. No memory updates for simple queries (demonstrating intelligent processing)")
print(" 5. Updates are incremental and context-aware")
def demonstrate_interval_processing():
"""Demonstrate processing with different conversation intervals"""
print("\n" + "="*70)
print("DEMONSTRATION: Variable Conversation Intervals")
print("="*70)
if not Config.MOONSHOT_API_KEY:
print("\n❌ Please set MOONSHOT_API_KEY environment variable")
return
# Test with interval = 3 (process every 3 conversations)
user_id = "demo_interval_user"
print(f"\n📋 Configuration:")
print(f" • Conversation Interval: 3 (process every 3rd conversation)")
print(f" • This simulates batched processing\n")
agent = ConversationalAgent(user_id=user_id, memory_mode=MemoryMode.NOTES)
processor = BackgroundMemoryProcessor(
user_id=user_id,
memory_mode=MemoryMode.NOTES,
config=MemoryProcessorConfig(
conversation_interval=3, # Process every 3 conversations
min_conversation_turns=1
),
verbose=False
)
messages = [
"I'm Tom and I work in finance.",
"I use Excel and Python for data analysis.",
"I'm learning SQL for database work.", # Should trigger processing here
"I also manage a team of 5 analysts.",
"We focus on risk assessment.",
"Our main tool is Bloomberg Terminal." # Should trigger processing here
]
for i, msg in enumerate(messages, 1):
print(f"\n[Round {i}] User: {msg}")
response = agent.chat(msg)
print(f"Assistant: {response[:100]}...")
processor.increment_conversation_count()
if processor.should_process():
print(f"\n🔔 Processing triggered after conversation {i}!")
results = processor.process_recent_conversations()
ops = results.get('operations', [])
print(f" Operations: {len(ops)} memory update(s)")
for op in ops:
print(f" - {op['action']}: {op.get('content', '')[:50]}...")
else:
remaining = 3 - (i % 3) if (i % 3) != 0 else 3
print(f" [Will process in {remaining} more conversation(s)]")
print("\n✅ Interval demonstration complete!")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Demonstrate conversation-based processing")
parser.add_argument(
"--mode",
choices=["single", "interval", "both"],
default="single",
help="Demonstration mode"
)
args = parser.parse_args()
Config.create_directories()
if args.mode == "single":
demonstrate_conversation_processing()
elif args.mode == "interval":
demonstrate_interval_processing()
else:
demonstrate_conversation_processing()
print("\n" + "="*70 + "\n")
demonstrate_interval_processing()
+54
View File
@@ -0,0 +1,54 @@
# User Memory System Configuration
# Provider Selection (default: kimi)
# Options: dashscope (== qwen/bailian), kimi (== moonshot), siliconflow, doubao, openrouter
PROVIDER=kimi
# API Keys — set the one(s) for the provider(s) you use
MOONSHOT_API_KEY=your_moonshot_api_key_here # for kimi / moonshot
DASHSCOPE_API_KEY=your_dashscope_api_key_here # for dashscope / qwen / bailian
SILICONFLOW_API_KEY=your_siliconflow_api_key_here
DOUBAO_API_KEY=your_doubao_api_key_here
# OpenRouter: usable as an explicit PROVIDER, and also a universal fallback —
# if the configured provider's key is missing but OPENROUTER_API_KEY is set,
# the agent auto-routes through OpenRouter (kimi-k3 -> moonshotai/kimi-k2.6;
# set OPENROUTER_MODEL to override).
OPENROUTER_API_KEY=your_openrouter_api_key_here
# Model Settings
# Leave MODEL_NAME empty to use each provider's built-in default
# (dashscope -> qwen3.7-plus, kimi -> kimi-k3, siliconflow -> Qwen/Qwen3-235B-A22B-Thinking-2507,
# doubao -> doubao-seed-1-6-thinking-250715, openrouter -> google/gemini-3.5-flash)
MODEL_NAME=kimi-k3
MODEL_TEMPERATURE=0.3
# Reasoning models (e.g. kimi-k3, gpt-5) require max_tokens >= 2048 and only
# accept temperature=1; the code applies temperature=1 automatically for them.
MODEL_MAX_TOKENS=4096
# Memory Configuration
# Options: notes, enhanced_notes, json_cards, advanced_json_cards
MEMORY_MODE=notes
MAX_MEMORY_ITEMS=100
MEMORY_UPDATE_TEMPERATURE=0.2
# Dify Configuration (Optional - for conversation history search)
DIFY_API_KEY=your_dify_api_key_here
DIFY_BASE_URL=https://api.dify.ai/v1
DIFY_DATASET_ID=your_dataset_id
ENABLE_HISTORY_SEARCH=false
# Session Configuration
SESSION_TIMEOUT=3600
MAX_CONTEXT_LENGTH=8000
# LOCOMO Benchmark Paths
LOCOMO_DATASET_PATH=data/locomo
LOCOMO_OUTPUT_DIR=results/locomo
# Storage Paths
MEMORY_STORAGE_DIR=data/memories
CONVERSATION_HISTORY_DIR=data/conversations
# Logging
LOG_LEVEL=INFO
LOG_FILE=logs/user_memory.log
+549
View File
@@ -0,0 +1,549 @@
"""
LOCOMO Benchmark Integration for User Memory System
Based on: https://github.com/AlibabaResearch/DAMO-ConvAI/tree/main/LOCOMO
"""
import json
import os
import logging
import time
from typing import Dict, List, Any, Tuple
from dataclasses import dataclass, asdict
from datetime import datetime
import requests
from agent import UserMemoryAgent
from config import Config, MemoryMode
logger = logging.getLogger(__name__)
@dataclass
class LOCOMOResult:
"""Result of a LOCOMO benchmark test"""
test_id: str
memory_mode: str
success: bool
score: float
response_time: float
memory_retrievals: int
memory_updates: int
details: Dict[str, Any]
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary"""
return asdict(self)
class LOCOMOBenchmark:
"""LOCOMO benchmark implementation for memory systems"""
def __init__(self, dataset_path: str = None):
"""
Initialize LOCOMO benchmark
Args:
dataset_path: Path to LOCOMO dataset
"""
self.dataset_path = dataset_path or Config.LOCOMO_DATASET_PATH
self.results_dir = Config.LOCOMO_OUTPUT_DIR
self.test_cases = []
self.load_dataset()
def load_dataset(self):
"""Load LOCOMO dataset"""
# Try to load from local file
local_file = os.path.join(self.dataset_path, "locomo_test_cases.json")
if os.path.exists(local_file):
with open(local_file, 'r', encoding='utf-8') as f:
self.test_cases = json.load(f)
logger.info(f"Loaded {len(self.test_cases)} LOCOMO test cases")
else:
# If local file doesn't exist, create sample test cases
self.test_cases = self._create_sample_test_cases()
os.makedirs(self.dataset_path, exist_ok=True)
with open(local_file, 'w', encoding='utf-8') as f:
json.dump(self.test_cases, f, indent=2, ensure_ascii=False)
logger.info(f"Created {len(self.test_cases)} sample LOCOMO test cases")
def _create_sample_test_cases(self) -> List[Dict[str, Any]]:
"""Create sample test cases based on LOCOMO structure"""
return [
{
"test_id": "personal_info_retention",
"category": "memory_retention",
"conversations": [
{
"turn": 1,
"user": "Hi, I'm Alice. I work as a software engineer at TechCorp.",
"expected_memory": ["name: Alice", "occupation: software engineer", "company: TechCorp"]
},
{
"turn": 2,
"user": "What do you remember about me?",
"expected_response_contains": ["Alice", "software engineer", "TechCorp"]
}
]
},
{
"test_id": "preference_tracking",
"category": "preference_memory",
"conversations": [
{
"turn": 1,
"user": "I prefer Python over Java for backend development, and I love dark theme IDEs.",
"expected_memory": ["language_preference: Python", "theme_preference: dark"]
},
{
"turn": 2,
"user": "What programming setup would you recommend for me?",
"expected_response_contains": ["Python", "dark theme"]
}
]
},
{
"test_id": "context_switching",
"category": "context_management",
"conversations": [
{
"turn": 1,
"user": "I'm planning a trip to Tokyo next month for a tech conference.",
"expected_memory": ["travel_plan: Tokyo", "purpose: tech conference"]
},
{
"turn": 2,
"user": "By the way, my favorite color is blue.",
"expected_memory": ["favorite_color: blue"]
},
{
"turn": 3,
"user": "What should I pack for my trip?",
"expected_response_contains": ["Tokyo", "conference", "tech"]
}
]
},
{
"test_id": "memory_update",
"category": "memory_modification",
"conversations": [
{
"turn": 1,
"user": "I live in San Francisco.",
"expected_memory": ["location: San Francisco"]
},
{
"turn": 2,
"user": "Actually, I just moved to Seattle last week.",
"expected_memory": ["location: Seattle"],
"memory_should_not_contain": ["San Francisco"]
},
{
"turn": 3,
"user": "Where do I live?",
"expected_response_contains": ["Seattle"],
"response_should_not_contain": ["San Francisco"]
}
]
},
{
"test_id": "multi_session_continuity",
"category": "session_continuity",
"sessions": [
{
"session_id": "session_1",
"conversations": [
{
"turn": 1,
"user": "I'm learning Spanish and practice 30 minutes every day.",
"expected_memory": ["learning: Spanish", "practice_duration: 30 minutes daily"]
}
]
},
{
"session_id": "session_2",
"conversations": [
{
"turn": 1,
"user": "How long have I been practicing my language studies?",
"expected_response_contains": ["Spanish", "30 minutes"]
}
]
}
]
},
{
"test_id": "complex_reasoning",
"category": "reasoning_with_memory",
"conversations": [
{
"turn": 1,
"user": "I'm allergic to peanuts and shellfish.",
"expected_memory": ["allergies: peanuts, shellfish"]
},
{
"turn": 2,
"user": "I'm thinking of trying Thai food. Any recommendations?",
"expected_response_contains": ["avoid", "peanut", "shellfish"],
"reasoning_check": "Should warn about common allergens in Thai cuisine"
}
]
},
{
"test_id": "temporal_memory",
"category": "time_awareness",
"conversations": [
{
"turn": 1,
"user": "My birthday is on June 15th.",
"expected_memory": ["birthday: June 15"]
},
{
"turn": 2,
"user": "How many days until my birthday?",
"response_type": "temporal_calculation",
"expected_response_contains": ["June 15"]
}
]
},
{
"test_id": "conflicting_information",
"category": "conflict_resolution",
"conversations": [
{
"turn": 1,
"user": "I have 2 cats named Fluffy and Whiskers.",
"expected_memory": ["pets: 2 cats", "pet_names: Fluffy, Whiskers"]
},
{
"turn": 2,
"user": "I got a new dog yesterday! Now I have 3 pets.",
"expected_memory": ["pets: 3 total", "has_dog: true"],
"reasoning_check": "Should reconcile pet count"
},
{
"turn": 3,
"user": "Tell me about my pets.",
"expected_response_contains": ["2 cats", "1 dog", "Fluffy", "Whiskers", "3 pets"]
}
]
}
]
def run_single_test(
self,
agent: UserMemoryAgent,
test_case: Dict[str, Any]
) -> LOCOMOResult:
"""
Run a single test case
Args:
agent: User memory agent
test_case: Test case definition
Returns:
Test result
"""
test_id = test_case.get("test_id", "unknown")
start_time = time.time()
try:
if "sessions" in test_case:
# Multi-session test
return self._run_multi_session_test(agent, test_case)
else:
# Single session test
return self._run_single_session_test(agent, test_case)
except Exception as e:
logger.error(f"Error running test {test_id}: {e}")
return LOCOMOResult(
test_id=test_id,
memory_mode=agent.memory_mode.value,
success=False,
score=0.0,
response_time=time.time() - start_time,
memory_retrievals=0,
memory_updates=0,
details={"error": str(e)}
)
def _run_single_session_test(
self,
agent: UserMemoryAgent,
test_case: Dict[str, Any]
) -> LOCOMOResult:
"""Run a single session test"""
test_id = test_case.get("test_id", "unknown")
start_time = time.time()
# Start new session
session_id = agent.start_session()
total_score = 0.0
total_checks = 0
details = {
"session_id": session_id,
"turns": []
}
# Run each conversation turn
for conv in test_case.get("conversations", []):
turn_num = conv.get("turn", 0)
user_message = conv.get("user", "")
# Get agent response
response = agent.chat(user_message)
turn_details = {
"turn": turn_num,
"user": user_message,
"response": response,
"checks": []
}
# Check expected response contents
if "expected_response_contains" in conv:
for expected in conv["expected_response_contains"]:
contains = expected.lower() in response.lower()
turn_details["checks"].append({
"type": "response_contains",
"expected": expected,
"found": contains
})
total_score += 1.0 if contains else 0.0
total_checks += 1
# Check response should not contain
if "response_should_not_contain" in conv:
for unexpected in conv["response_should_not_contain"]:
not_contains = unexpected.lower() not in response.lower()
turn_details["checks"].append({
"type": "response_not_contains",
"unexpected": unexpected,
"correct": not_contains
})
total_score += 1.0 if not_contains else 0.0
total_checks += 1
# Check memory updates
if "expected_memory" in conv:
memory_summary = agent.get_memory_summary()
for expected_mem in conv["expected_memory"]:
contains = expected_mem.lower() in memory_summary.lower()
turn_details["checks"].append({
"type": "memory_contains",
"expected": expected_mem,
"found": contains
})
total_score += 1.0 if contains else 0.0
total_checks += 1
details["turns"].append(turn_details)
# Calculate final score
final_score = (total_score / total_checks) if total_checks > 0 else 0.0
return LOCOMOResult(
test_id=test_id,
memory_mode=agent.memory_mode.value,
success=final_score >= 0.7, # 70% threshold for success
score=final_score,
response_time=time.time() - start_time,
memory_retrievals=len(details["turns"]),
memory_updates=len([t for t in details["turns"] if any(c["type"] == "memory_contains" for c in t.get("checks", []))]),
details=details
)
def _run_multi_session_test(
self,
agent: UserMemoryAgent,
test_case: Dict[str, Any]
) -> LOCOMOResult:
"""Run a multi-session test"""
test_id = test_case.get("test_id", "unknown")
start_time = time.time()
total_score = 0.0
total_checks = 0
details = {
"sessions": []
}
# Run each session
for session_def in test_case.get("sessions", []):
# Start new session
session_id = agent.start_session()
session_details = {
"session_id": session_id,
"turns": []
}
# Run conversations in this session
for conv in session_def.get("conversations", []):
turn_num = conv.get("turn", 0)
user_message = conv.get("user", "")
# Get agent response
response = agent.chat(user_message)
turn_details = {
"turn": turn_num,
"user": user_message,
"response": response,
"checks": []
}
# Check expected response contents
if "expected_response_contains" in conv:
for expected in conv["expected_response_contains"]:
contains = expected.lower() in response.lower()
turn_details["checks"].append({
"type": "response_contains",
"expected": expected,
"found": contains
})
total_score += 1.0 if contains else 0.0
total_checks += 1
# Check memory updates
if "expected_memory" in conv:
memory_summary = agent.get_memory_summary()
for expected_mem in conv["expected_memory"]:
contains = expected_mem.lower() in memory_summary.lower()
turn_details["checks"].append({
"type": "memory_contains",
"expected": expected_mem,
"found": contains
})
total_score += 1.0 if contains else 0.0
total_checks += 1
session_details["turns"].append(turn_details)
details["sessions"].append(session_details)
# Calculate final score
final_score = (total_score / total_checks) if total_checks > 0 else 0.0
return LOCOMOResult(
test_id=test_id,
memory_mode=agent.memory_mode.value,
success=final_score >= 0.7,
score=final_score,
response_time=time.time() - start_time,
memory_retrievals=sum(len(s["turns"]) for s in details["sessions"]),
memory_updates=sum(len([t for t in s["turns"] if any(c["type"] == "memory_contains" for c in t.get("checks", []))]) for s in details["sessions"]),
details=details
)
def run_benchmark(
self,
memory_modes: List[MemoryMode] = None,
test_ids: List[str] = None
) -> Dict[str, List[LOCOMOResult]]:
"""
Run full benchmark
Args:
memory_modes: List of memory modes to test (defaults to all)
test_ids: Specific test IDs to run (defaults to all)
Returns:
Results grouped by memory mode
"""
memory_modes = memory_modes or [MemoryMode.NOTES, MemoryMode.JSON_CARDS]
test_cases_to_run = self.test_cases
if test_ids:
test_cases_to_run = [tc for tc in self.test_cases if tc.get("test_id") in test_ids]
results = {}
for mode in memory_modes:
logger.info(f"Running benchmark with {mode.value} memory mode")
mode_results = []
for test_case in test_cases_to_run:
# Create fresh agent for each test
user_id = f"benchmark_user_{test_case.get('test_id', 'unknown')}"
agent = UserMemoryAgent(
user_id=user_id,
memory_mode=mode,
enable_streaming=False,
verbose=False
)
# CRITICAL: Clear any existing memory for this user before test
# This ensures clean test runs even if the same test_id is run multiple times
if hasattr(agent, 'memory_manager') and hasattr(agent.memory_manager, 'clear_all_memories'):
agent.memory_manager.clear_all_memories()
logger.info(f"Cleared memory for user {user_id} before test")
# Run test
result = self.run_single_test(agent, test_case)
mode_results.append(result)
logger.info(f"Test {result.test_id}: {'' if result.success else ''} (Score: {result.score:.2f})")
results[mode.value] = mode_results
# Save results
self._save_results(results)
return results
def _save_results(self, results: Dict[str, List[LOCOMOResult]]):
"""Save benchmark results"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
result_file = os.path.join(
self.results_dir,
f"locomo_results_{timestamp}.json"
)
os.makedirs(self.results_dir, exist_ok=True)
# Convert results to dict
results_dict = {
mode: [r.to_dict() for r in mode_results]
for mode, mode_results in results.items()
}
# Add summary statistics
summary = {}
for mode, mode_results in results.items():
scores = [r.score for r in mode_results]
summary[mode] = {
"total_tests": len(mode_results),
"passed": sum(1 for r in mode_results if r.success),
"failed": sum(1 for r in mode_results if not r.success),
"average_score": sum(scores) / len(scores) if scores else 0,
"average_response_time": sum(r.response_time for r in mode_results) / len(mode_results) if mode_results else 0
}
output = {
"timestamp": timestamp,
"summary": summary,
"results": results_dict
}
with open(result_file, 'w', encoding='utf-8') as f:
json.dump(output, f, indent=2, ensure_ascii=False)
logger.info(f"Results saved to {result_file}")
# Print summary
print("\n" + "="*60)
print("LOCOMO BENCHMARK SUMMARY")
print("="*60)
for mode, stats in summary.items():
print(f"\n{mode.upper()} Mode:")
print(f" Total Tests: {stats['total_tests']}")
print(f" Passed: {stats['passed']}")
print(f" Failed: {stats['failed']}")
print(f" Average Score: {stats['average_score']:.2%}")
print(f" Average Response Time: {stats['average_response_time']:.2f}s")
print("="*60)
+956
View File
@@ -0,0 +1,956 @@
#!/usr/bin/env python3
"""
Main entry point for User Memory System with Separated Architecture
Conversational agent handles dialogue, background processor handles memory
"""
import os
import sys
import json
import logging
import argparse
import time
from pathlib import Path
from typing import Optional
from conversational_agent import ConversationalAgent, ConversationConfig
from background_memory_processor import BackgroundMemoryProcessor, MemoryProcessorConfig
from config import Config, MemoryMode
# Add evaluation framework support
# We load it dynamically only when needed to avoid import conflicts
EVALUATION_AVAILABLE = False
UserMemoryEvaluationFramework = None
TestCase = None
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def print_section(title: str):
"""Print a formatted section header"""
print("\n" + "="*80)
print(f" {title}")
print("="*80)
def print_result(result: dict):
"""Print formatted result"""
if result.get('success'):
print("\n✅ Task completed successfully!")
if result.get('final_answer'):
print("\n📝 Final Answer:")
print("-"*40)
print(result['final_answer'])
else:
print("\n❌ Task failed!")
if result.get('error'):
print(f"Error: {result['error']}")
print(f"\n📊 Statistics:")
print(f" - Iterations: {result.get('iterations', 0)}")
print(f" - Tool calls: {len(result.get('tool_calls', []))}")
if result.get('trajectory_file'):
print(f"\n💾 Trajectory saved to: {result['trajectory_file']}")
# Show tool call summary
if result.get('tool_calls'):
print(f"\n🔧 Tool Call Summary:")
tool_summary = {}
for call in result['tool_calls']:
tool_name = call.tool_name
if tool_name not in tool_summary:
tool_summary[tool_name] = {
'count': 0,
'success': 0,
'failed': 0
}
tool_summary[tool_name]['count'] += 1
if call.error:
tool_summary[tool_name]['failed'] += 1
else:
tool_summary[tool_name]['success'] += 1
for tool_name, stats in tool_summary.items():
print(f" - {tool_name}: {stats['count']} calls "
f"({stats['success']} success, {stats['failed']} failed)")
# Show memory state
if result.get('memory_state'):
print(f"\n💭 Memory State:")
print("-"*40)
memory_preview = result['memory_state'][:500]
if len(result['memory_state']) > 500:
memory_preview += "..."
print(memory_preview)
def interactive_mode(user_id: str, memory_mode: MemoryMode = MemoryMode.NOTES,
enable_background_processing: bool = True,
conversation_interval: int = 1,
provider: Optional[str] = None,
model: Optional[str] = None):
"""Run the agent in interactive mode with separated architecture"""
print_section(f"Interactive Mode - Conversational Agent (User: {user_id})")
# Determine provider and get API key
provider = (provider or Config.PROVIDER).lower()
api_key = Config.get_api_key(provider)
if not api_key:
print(f"❌ Error: Please set API key for provider '{provider}'")
if provider in ["kimi", "moonshot"]:
print(" export MOONSHOT_API_KEY='your-api-key-here'")
elif provider in ["dashscope", "qwen", "bailian"]:
print(" export DASHSCOPE_API_KEY='your-api-key-here'")
elif provider == "siliconflow":
print(" export SILICONFLOW_API_KEY='your-api-key-here'")
elif provider == "doubao":
print(" export DOUBAO_API_KEY='your-api-key-here'")
elif provider == "openrouter":
print(" export OPENROUTER_API_KEY='your-api-key-here'")
return
# Initialize conversational agent
conv_config = ConversationConfig(
enable_memory_context=True,
enable_conversation_history=True
)
agent = ConversationalAgent(
user_id=user_id,
api_key=api_key,
provider=provider,
model=model,
config=conv_config,
memory_mode=memory_mode,
verbose=True
)
# Initialize and start background memory processor if enabled
memory_processor = None
if enable_background_processing:
proc_config = MemoryProcessorConfig(
conversation_interval=conversation_interval,
min_conversation_turns=1,
context_window=10,
enable_auto_processing=True,
output_operations=True
)
memory_processor = BackgroundMemoryProcessor(
user_id=user_id,
api_key=api_key,
provider=provider,
model=model,
config=proc_config,
memory_mode=memory_mode,
verbose=True
)
memory_processor.start_background_processing()
print(f"\n🧠 Background memory processing enabled (every {conversation_interval} conversation{'s' if conversation_interval > 1 else ''})")
print("\n✅ Conversational agent initialized")
print(f"📦 Memory Mode: {memory_mode.value}")
print(f"🆔 Session: {agent.get_session_id()}")
print(f"🔄 Background Processing: {'Enabled' if enable_background_processing else 'Disabled'}")
if enable_background_processing:
print(f"📊 Processing Trigger: Every {conversation_interval} conversation{'s' if conversation_interval > 1 else ''}")
print("\nAvailable commands:")
print(" 'memory' - Show current memory state")
print(" 'process' - Manually trigger memory processing")
print(" 'save' - Save memory immediately")
print(" 'reset' - Start new conversation session")
print(" 'quit' - Exit immediately without saving")
print(" 'exit' - Exit immediately without saving")
print("\nOr enter any message to chat.")
conversation_count = 0
while True:
try:
print("\n" + "-"*60)
user_input = input("You > ").strip()
if not user_input:
continue
if user_input.lower() in ['quit', 'exit']:
# Immediate exit without saving
if memory_processor:
memory_processor.stop_background_processing()
print("👋 Goodbye! (Exited without saving)")
break
elif user_input.lower() == 'save':
# Save memory immediately
print("\n💾 Saving memory...")
if memory_processor:
results = memory_processor.process_recent_conversations()
print(f"✅ Memory saved: {results}")
else:
print("⚠️ Background processing is disabled. Memory is saved after each conversation.")
continue
elif user_input.lower() == 'memory':
print("\n💭 Current Memory State:")
print("-"*40)
# Reload first: the background processor writes through its
# own manager instance, so the agent's copy can be stale.
agent.memory_manager.load_memory()
print(agent.memory_manager.get_context_string())
elif user_input.lower() == 'process':
if memory_processor:
print("\n🔄 Manually triggering memory processing...")
results = memory_processor.process_recent_conversations()
# Display operations
operations = results.get('operations', [])
if operations:
print(f"\n📝 Memory Operations ({len(operations)} total):")
for i, op in enumerate(operations, 1):
icon = {'add': '', 'update': '📝', 'delete': '🗑️'}.get(op['action'], '')
print(f"{i}. {icon} {op['action'].upper()}: {op.get('content', op.get('memory_id', 'N/A'))}")
else:
print("️ No memory updates needed")
summary = results.get('summary', {})
print(f"\nSummary: {summary.get('added', 0)} added, {summary.get('updated', 0)} updated, {summary.get('deleted', 0)} deleted")
else:
print("❌ Background processing not enabled")
elif user_input.lower() == 'reset':
agent.reset_session()
print("✅ Started new conversation session")
conversation_count = 0
else:
# Have a conversation
response = agent.chat(user_input)
print(f"\n🤖 Assistant: {response}")
conversation_count += 1
# Increment conversation counter in processor
if memory_processor:
memory_processor.increment_conversation_count()
# Check if processing will trigger
if memory_processor.should_process():
print(f"\n[Memory processing triggered after {conversation_interval} conversation{'s' if conversation_interval > 1 else ''}]")
# Give a moment for background thread to process
time.sleep(2)
elif conversation_interval > 1:
conversations_until_process = conversation_interval - (conversation_count % conversation_interval)
if conversations_until_process < conversation_interval:
print(f"\n[Memory processing in {conversations_until_process} more conversation{'s' if conversations_until_process > 1 else ''}]")
except KeyboardInterrupt:
print("\n\n⚠️ Interrupted. Type 'save' to save memory, or 'quit'/'exit' to exit immediately without saving.")
except Exception as e:
print(f"\n❌ Error: {str(e)}")
logger.error(f"Error in interactive mode: {e}", exc_info=True)
# Cleanup
if memory_processor:
memory_processor.stop_background_processing()
def demo_memory_system(memory_mode: MemoryMode = None, provider: Optional[str] = None, model: Optional[str] = None):
"""Demonstrate the separated memory system architecture"""
print_section("Demo: Separated Memory Architecture")
# Determine provider and get API key
provider = (provider or Config.PROVIDER).lower()
api_key = Config.get_api_key(provider)
if not api_key:
print(f"❌ Please set API key for provider '{provider}'")
if provider in ["dashscope", "qwen", "bailian"]:
print(" export DASHSCOPE_API_KEY='your-api-key-here'")
return
# Create test user
user_id = "demo_user"
# Use provided memory_mode or prompt for it
if memory_mode is None:
memory_mode = select_memory_mode_interactive()
# Initialize conversational agent
conv_config = ConversationConfig(
enable_memory_context=True,
enable_conversation_history=True
)
agent = ConversationalAgent(
user_id=user_id,
api_key=api_key,
provider=provider,
model=model,
config=conv_config,
memory_mode=memory_mode,
verbose=True
)
# Initialize background processor
proc_config = MemoryProcessorConfig(
conversation_interval=2, # Process every 2 conversations for demo
min_conversation_turns=1,
output_operations=True
)
processor = BackgroundMemoryProcessor(
user_id=user_id,
api_key=api_key,
provider=provider,
model=model,
config=proc_config,
memory_mode=memory_mode,
verbose=True
)
# Session 1: Have conversations
print("\n📝 Session 1: Having conversations")
print("-"*40)
messages = [
"Hi! My name is Alice and I work as a product manager at TechCorp.",
"I prefer Python for scripting and use VS Code as my IDE. I also like dark themes.",
"I'm currently working on a new mobile app project for our company."
]
for message in messages:
print(f"\n👤 User: {message}")
response = agent.chat(message)
print(f"🤖 Assistant: {response[:200]}..." if len(response) > 200 else f"🤖 Assistant: {response}")
time.sleep(1) # Brief pause between messages
# Process memories
print("\n\n🔄 Processing conversation for memory updates...")
print("-"*40)
# Increment conversation count to trigger processing
for _ in range(len(messages)):
processor.increment_conversation_count()
# Process conversations
results = processor.process_recent_conversations()
# Display operations
operations = results.get('operations', [])
if operations:
print(f"\n📝 Memory Operations ({len(operations)} total):")
for i, op in enumerate(operations, 1):
icon = {'add': '', 'update': '📝', 'delete': '🗑️'}.get(op['action'], '')
print(f"{i}. {icon} {op['action'].upper()}: {op.get('content', op.get('memory_id', 'N/A'))}")
else:
print("️ No memory updates needed")
summary = results.get('summary', {})
print(f"\n✅ Summary: {summary.get('added', 0)} added, {summary.get('updated', 0)} updated, {summary.get('deleted', 0)} deleted")
# Start new session to test memory persistence
print("\n\n📝 Session 2: Testing memory persistence")
print("-"*40)
agent.reset_session()
test_message = "What do you know about me and my work?"
print(f"\n👤 User: {test_message}")
response = agent.chat(test_message)
print(f"🤖 Assistant: {response}")
# Show final memory state
print("\n\n💭 Final Memory State:")
print("-"*40)
print(agent.memory_manager.get_context_string())
def run_evaluation_mode(user_id: str, memory_mode: MemoryMode, verbose: bool = True, provider: Optional[str] = None, model: Optional[str] = None):
"""Run evaluation mode using the evaluation framework"""
# Import the evaluation framework with proper module isolation
from pathlib import Path
eval_framework_path = Path(__file__).parent.parent / "user-memory-evaluation"
try:
# Save the current modules to avoid conflicts
saved_modules = {}
conflicting_modules = ['config', 'models', 'evaluator', 'framework']
# Temporarily remove conflicting modules from sys.modules
for module_name in conflicting_modules:
if module_name in sys.modules:
saved_modules[module_name] = sys.modules[module_name]
del sys.modules[module_name]
# Temporarily add evaluation framework path with highest priority
original_path = sys.path.copy()
sys.path.insert(0, str(eval_framework_path))
# Import evaluation framework modules
import config as eval_config
import models as eval_models
import evaluator as eval_evaluator
import framework as eval_framework
# Get the class we need
framework_class = eval_framework.UserMemoryEvaluationFramework
# Restore original path
sys.path = original_path
# Remove evaluation modules from sys.modules to avoid future conflicts
for module_name in conflicting_modules:
if module_name in sys.modules:
del sys.modules[module_name]
# Restore original modules
for module_name, module in saved_modules.items():
sys.modules[module_name] = module
except Exception as e:
# Restore on error
sys.path = original_path if 'original_path' in locals() else sys.path
for module_name, module in saved_modules.items():
sys.modules[module_name] = module
print(f"❌ Error: Could not load evaluation framework: {e}")
print("Please ensure user-memory-evaluation is properly installed.")
import traceback
traceback.print_exc()
sys.exit(1)
print_section("Evaluation Mode - Test Case Based Evaluation")
# Initialize evaluation framework
framework = framework_class()
if not framework.test_suite:
print("❌ Error: No test cases loaded")
sys.exit(1)
print(f"\n✅ Loaded {len(framework.test_suite.test_cases)} test cases")
# Determine provider and get API key
provider = (provider or Config.PROVIDER).lower()
api_key = Config.get_api_key(provider)
if not api_key:
print(f"❌ Error: Please set API key for provider '{provider}'")
if provider in ["dashscope", "qwen", "bailian"]:
print(" export DASHSCOPE_API_KEY='your-api-key-here'")
sys.exit(1)
# Initialize agents without incorrect parameters
# ConversationConfig is a dataclass and doesn't take parameters in __init__
conv_config = ConversationConfig()
conv_config.enable_memory_context = True
conv_config.enable_conversation_history = True
mem_config = MemoryProcessorConfig()
mem_config.verbose = verbose
# Initialize agents with correct parameters
agent = ConversationalAgent(
user_id=user_id,
api_key=api_key,
provider=provider,
model=model,
config=conv_config,
memory_mode=memory_mode,
verbose=verbose
)
processor = BackgroundMemoryProcessor(
user_id=user_id,
api_key=api_key,
provider=provider,
model=model,
config=mem_config,
memory_mode=memory_mode, # Pass memory_mode here!
verbose=verbose
)
while True:
print("\n" + "-"*60)
print("Options:")
print("1. Run a test case")
print("2. View current memory state")
print("3. Clear memory and start fresh")
print("4. Exit evaluation mode")
choice = input("\nEnter your choice (1-4): ").strip()
if choice == "1":
# First list test cases, then let user choose
print("\n📋 Available Test Cases:")
framework.display_test_case_summary(show_full_titles=True, by_category=True)
# Now let user select a test case
test_id = input("\nEnter test case ID to run (or 'cancel' to go back): ").strip()
if test_id.lower() == 'cancel':
continue
test_case = framework.get_test_case(test_id)
if not test_case:
print(f"❌ Test case '{test_id}' not found")
continue
print(f"\n{'='*60}")
print(f"Running Test Case: {test_case.title}")
print(f"Category: {test_case.category}")
print("="*60)
# CRITICAL: Clear ALL memory and conversation state before test
print("\n🧹 Clearing all memory and conversation state before test...")
# 1. Clear memory managers for both agent and processor
if hasattr(agent.memory_manager, 'clear_all_memories'):
agent.memory_manager.clear_all_memories()
# Verify memory is cleared
memory_check = agent.memory_manager.get_context_string()
if "No previous memory" not in memory_check:
print(f" ⚠️ Warning: Agent memory may not be fully cleared")
else:
print(f" ✅ Agent memory cleared successfully")
if hasattr(processor.memory_manager, 'clear_all_memories'):
processor.memory_manager.clear_all_memories()
# Verify memory is cleared
memory_check = processor.memory_manager.get_context_string()
if "No previous memory" not in memory_check:
print(f" ⚠️ Warning: Processor memory may not be fully cleared")
else:
print(f" ✅ Processor memory cleared successfully")
# 2. Clear conversation history completely
if agent.conversation_history:
agent.conversation_history.conversations = []
agent.conversation_history.save_history()
print(f" ✅ Cleared conversation history for user {user_id}")
if processor.conversation_history:
processor.conversation_history.conversations = []
processor.conversation_history.save_history()
print(f" ✅ Cleared processor conversation history")
# 3. Reset agent conversation state
agent.conversation = []
agent._init_system_prompt()
# 4. Reset any tool call counts
if hasattr(agent, 'tool_call_counts'):
agent.tool_call_counts = {}
print(f" ✅ All memory and state cleared - ready for test case")
# Process conversation histories
print(f"\n📚 Processing {len(test_case.conversation_histories)} conversation histories...")
# Build conversation contexts from test case histories
conversation_contexts = []
for i, history in enumerate(test_case.conversation_histories, 1):
print(f"\nConversation {i}/{len(test_case.conversation_histories)}: {history.conversation_id}")
# Build conversation context for this history
conversation = []
# Process each message in the conversation
for msg in history.messages:
if msg.role.value == "user":
conversation.append({"role": "user", "content": msg.content})
elif msg.role.value == "assistant":
conversation.append({"role": "assistant", "content": msg.content})
conversation_contexts.append(conversation)
# Also add to the agent's conversation history for context
# This is needed for the agent to have context when answering the question
if agent.conversation_history and hasattr(agent.conversation_history, 'add_turn'):
# Add pairs of user/assistant messages
user_msg = None
for msg in history.messages:
if msg.role.value == "user":
user_msg = msg.content
elif msg.role.value == "assistant" and user_msg:
agent.conversation_history.add_turn(
session_id=f"eval_{history.conversation_id}",
user_message=user_msg,
assistant_message=msg.content
)
user_msg = None
# Process all conversations through the memory processor
if conversation_contexts:
print(f"\n💾 Processing memory for all conversations...")
try:
results = processor.process_conversation_batch(conversation_contexts)
# Summarize results
total_added = sum(r.get('summary', {}).get('added', 0) for r in results)
total_updated = sum(r.get('summary', {}).get('updated', 0) for r in results)
total_deleted = sum(r.get('summary', {}).get('deleted', 0) for r in results)
print(f" ✅ Memory processing complete:")
print(f" - Added: {total_added} memories")
print(f" - Updated: {total_updated} memories")
print(f" - Deleted: {total_deleted} memories")
except Exception as e:
print(f" ⚠️ Memory processing error: {e}")
# CRITICAL: Clear conversation history to simulate a new session
# The evaluation should test whether STRUCTURED MEMORIES work,
# not whether raw conversation history works.
# The agent must rely only on processed memories to answer the question.
if agent.conversation_history:
# Save the current conversation history (for record keeping)
saved_conversations = agent.conversation_history.conversations if hasattr(agent.conversation_history, 'conversations') else []
# Clear the conversations list to simulate a fresh session
agent.conversation_history.conversations = []
print("\n🔄 Cleared conversation history - starting fresh session")
print(" (Agent will use only structured memories)")
# Reset the agent's conversation to start fresh
agent.conversation = []
agent._init_system_prompt()
# CRITICAL: Reload the agent's memory manager to get the memories saved by the processor
# The processor and agent have separate memory manager instances, so we need to reload
# from file to get the memories that were just saved
agent.memory_manager.load_memory()
# Display what memories are available
memory_context = agent.memory_manager.get_context_string()
if memory_context:
print("\n💾 Available memories:")
print("-"*40)
print(memory_context[:500] + "..." if len(memory_context) > 500 else memory_context)
print("-"*40)
else:
print("\n⚠️ No structured memories available")
# Now answer the user question
print(f"\n{'='*60}")
print("USER QUESTION:")
print("-"*60)
print(test_case.user_question)
print("="*60)
# Get agent response (now only using structured memories)
print("\n🤔 Generating response...")
response = agent.chat(test_case.user_question)
print("\n📝 Agent Response:")
print("-"*60)
print(response)
print("-"*60)
# Restore conversation history after evaluation
if agent.conversation_history and 'saved_conversations' in locals():
agent.conversation_history.conversations = saved_conversations
# Evaluate the response
print("\n⚖️ Evaluating response...")
result = framework.submit_and_evaluate(test_id, response)
if result:
# Display evaluation result
is_passed = result.passed if result.passed is not None else result.reward >= 0.6
status = "✅ PASSED" if is_passed else "❌ FAILED"
print(f"\n{'='*60}")
print("EVALUATION RESULT:")
print("-"*60)
print(f"Status: {status}")
print(f"Reward Score: {result.reward:.3f}/1.000")
if result.reasoning:
print(f"\nReasoning:")
print(result.reasoning)
if result.suggestions:
print(f"\nSuggestions:")
print(result.suggestions)
print("="*60)
else:
print("❌ Evaluation failed")
# Clear conversation history for next test
agent.conversation_history = []
elif choice == "2":
# View current memory
print("\n📄 Current Memory State:")
print("-"*60)
print(processor.memory_manager.get_context_string())
elif choice == "3":
# Clear memory
if input("\n⚠️ Are you sure you want to clear all memory? (yes/no): ").lower() == "yes":
# Clear memory using the new method
if hasattr(agent.memory_manager, 'clear_all_memories'):
agent.memory_manager.clear_all_memories()
if hasattr(processor.memory_manager, 'clear_all_memories'):
processor.memory_manager.clear_all_memories()
# Clear conversation history
if agent.conversation_history:
agent.conversation_history.conversations = []
agent.conversation_history.save_history()
# Reset agent conversation
agent.conversation = []
agent._init_system_prompt()
print("✅ Memory and conversation history cleared")
elif choice == "4":
print("\nExiting evaluation mode...")
break
else:
print(f"❌ Invalid choice: {choice}")
def select_mode_interactive() -> str:
"""
Interactively prompt the user to select an execution mode
Returns:
Selected mode string ('evaluation', 'interactive', or 'demo')
"""
print("\n" + "="*60)
print(" 🚀 SELECT EXECUTION MODE")
print("="*60)
print("\n1. Evaluation Mode")
print(" - Run test cases from user-memory-evaluation framework")
print(" - Test memory system with predefined scenarios")
print(" - Get performance scores and feedback")
print("\n2. Interactive Mode")
print(" - Chat with the agent in real-time")
print(" - Memory processes automatically in background")
print(" - Commands: memory, process, save, reset, quit/exit")
print("\n3. Demo Mode")
print(" - Quick demonstration of memory system")
print(" - Shows how conversations are processed into memories")
print(" - Tests memory persistence across sessions")
print("\n" + "-"*60)
while True:
try:
choice = input("\nSelect mode (1-3): ").strip()
if choice == '1':
print("✅ Selected: Evaluation Mode")
return "evaluation"
elif choice == '2':
print("✅ Selected: Interactive Mode")
return "interactive"
elif choice == '3':
print("✅ Selected: Demo Mode")
return "demo"
else:
print("❌ Invalid choice. Please enter 1, 2, or 3.")
except KeyboardInterrupt:
print("\n\n⚠️ Operation cancelled by user")
sys.exit(0)
except Exception as e:
print(f"❌ Error: {e}")
def select_memory_mode_interactive() -> MemoryMode:
"""
Interactively prompt the user to select a memory mode
Returns:
Selected MemoryMode
"""
print("\n" + "="*60)
print(" 📝 SELECT MEMORY MODE")
print("="*60)
print("\n1. Simple Notes (Basic)")
print(" - Store simple facts and preferences")
print(" - Each memory is a single line or fact")
print(" - Example: 'User email: john@example.com'")
print("\n2. Enhanced Notes")
print(" - Store comprehensive contextual information")
print(" - Each memory can be a full paragraph with context")
print(" - Example: 'User works at TechCorp as a senior engineer,")
print(" specializing in ML for 3 years...'")
print("\n3. JSON Cards (Basic)")
print(" - Hierarchical structured memory")
print(" - Format: category → subcategory → key → value")
print(" - Example: personal.contact.email → 'john@example.com'")
print("\n4. Advanced JSON Cards")
print(" - Complete memory card objects with metadata")
print(" - Each card includes backstory, person, relationship")
print(" - Prevents confusion between different contexts")
print(" - Example: Medical card for child vs elderly parent")
print("\n" + "-"*60)
while True:
try:
choice = input("\nSelect mode (1-4): ").strip()
if choice == '1':
print("✅ Selected: Simple Notes Mode")
return MemoryMode.NOTES
elif choice == '2':
print("✅ Selected: Enhanced Notes Mode")
return MemoryMode.ENHANCED_NOTES
elif choice == '3':
print("✅ Selected: JSON Cards Mode")
return MemoryMode.JSON_CARDS
elif choice == '4':
print("✅ Selected: Advanced JSON Cards Mode")
return MemoryMode.ADVANCED_JSON_CARDS
else:
print("❌ Invalid choice. Please enter 1, 2, 3, or 4.")
except KeyboardInterrupt:
print("\n\n⚠️ Operation cancelled by user")
sys.exit(0)
except Exception as e:
print(f"❌ Error: {e}")
def main():
"""Main function with command-line argument support"""
parser = argparse.ArgumentParser(
description="User Memory Agent with React Pattern - Following system-hint architecture"
)
parser.add_argument(
"--mode",
choices=["interactive", "demo", "evaluation"],
default=None,
help="Execution mode (if not specified, prompts interactively)"
)
parser.add_argument(
"--user",
type=str,
default="default_user",
help="User ID for memory system (default: default_user)"
)
parser.add_argument(
"--background-processing",
type=bool,
default=True,
help="Enable background memory processing (default: True)"
)
parser.add_argument(
"--conversation-interval",
type=int,
default=1,
help="Process memory after N conversations (default: 1 - every conversation)"
)
parser.add_argument(
"--memory-mode",
choices=["notes", "enhanced_notes", "json_cards", "advanced_json_cards"],
help="Memory mode (prompts interactively if not specified)"
)
parser.add_argument(
"--provider",
choices=["dashscope", "qwen", "bailian", "siliconflow", "doubao", "kimi", "moonshot", "openrouter"],
default=None,
help="LLM provider (defaults to env PROVIDER or 'kimi')"
)
parser.add_argument(
"--model",
type=str,
default=None,
help="Model name (defaults to provider's default model)"
)
parser.add_argument(
"--no-verbose",
action="store_true",
help="Disable verbose output (verbose is enabled by default)"
)
args = parser.parse_args()
# Set verbose based on no-verbose flag (default is verbose=True)
verbose = not args.no_verbose
# Determine provider
provider = args.provider or Config.PROVIDER
# Validate configuration
if not Config.validate(provider):
sys.exit(1)
# Create necessary directories
Config.create_directories()
# Select execution mode if not specified
execution_mode = args.mode
if execution_mode is None:
# Prompt user to select mode
execution_mode = select_mode_interactive()
# Configure memory mode
if args.memory_mode:
# Mode specified via command line
mode_map = {
"notes": MemoryMode.NOTES,
"enhanced_notes": MemoryMode.ENHANCED_NOTES,
"json_cards": MemoryMode.JSON_CARDS,
"advanced_json_cards": MemoryMode.ADVANCED_JSON_CARDS
}
memory_mode = mode_map[args.memory_mode]
else:
# Interactive mode selection
memory_mode = select_memory_mode_interactive()
print("\n" + "🧠"*40)
print(" USER MEMORY SYSTEM - SEPARATED ARCHITECTURE")
print("🧠"*40)
if execution_mode == "demo":
demo_memory_system(memory_mode, provider, args.model)
elif execution_mode == "evaluation":
run_evaluation_mode(args.user, memory_mode, verbose, provider, args.model)
elif execution_mode == "interactive":
interactive_mode(
user_id=args.user,
memory_mode=memory_mode,
enable_background_processing=args.background_processing,
conversation_interval=args.conversation_interval,
provider=provider,
model=args.model
)
else:
# This should not happen, but handle it gracefully
print(f"❌ Unknown execution mode: {execution_mode}")
sys.exit(1)
print("\n👋 Thank you for using User Memory Agent!")
if __name__ == "__main__":
main()
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
用户记忆离线命令行工具 (memory_cli)
这是一个**离线**的记忆运维 CLI,直接操作 memory_manager 的持久化存储,
无需任何大模型 API,即可演示用户记忆系统的完整生命周期:
提取(手动写入)→ 存储 → 更新 → 去重 / 版本化冲突消解 → 跨会话回忆。
与 main.py 的分工:
* main.py —— 完整的对话 / 后台记忆处理 / 评测流程,需要 LLM API。
* memory_cli.py —— 单条记忆的增删查改与整理逻辑,纯本地可运行,
便于在没有 API Key 的情况下检验存储、去重与冲突消解的行为。
子命令:
add 写入一条记忆(模拟从某次会话中提取到的事实)
query 按关键词检索记忆(跨会话回忆)
update 按 ID 更新一条已有记忆
consolidate 对记忆做去重与版本化冲突消解(无需 API)
show 打印某个用户当前的全部记忆
demo 运行一个多会话离线示例,展示记忆在后续会话中被复用
extract 从一段对话中自动提取记忆(需要 LLM API)
示例:
python memory_cli.py demo
python memory_cli.py add --user alice --session s1 \
--content "喜欢靠窗座位" --tags seat_preference
python memory_cli.py query --user alice --query 座位
python memory_cli.py consolidate --user alice
"""
import argparse
import sys
from config import Config, MemoryMode
from memory_manager import create_memory_manager
# 记忆模式字符串 -> 枚举,供各子命令共用
MODE_MAP = {
"notes": MemoryMode.NOTES,
"enhanced_notes": MemoryMode.ENHANCED_NOTES,
"json_cards": MemoryMode.JSON_CARDS,
"advanced_json_cards": MemoryMode.ADVANCED_JSON_CARDS,
}
def _apply_store_path(store_path):
"""若指定了 --store-path,则重定向记忆存储目录(不影响默认数据)。"""
if store_path:
Config.MEMORY_STORAGE_DIR = store_path
Config.create_directories()
def _build_manager(args):
"""按命令行参数构造对应的记忆管理器(先设置存储目录再实例化)。"""
_apply_store_path(getattr(args, "store_path", None))
mode = MODE_MAP[args.memory_mode] if getattr(args, "memory_mode", None) else Config.MEMORY_MODE
manager = create_memory_manager(args.user, mode)
manager.verbose = True
return manager, mode
def cmd_add(args):
"""写入一条记忆。仅 notes / enhanced_notes 模式支持自由文本写入。"""
manager, mode = _build_manager(args)
if mode not in (MemoryMode.NOTES, MemoryMode.ENHANCED_NOTES):
print("❌ add 子命令仅支持 notes / enhanced_notes 模式(JSON 卡片请用 main.py 的对话流程生成)")
return 1
tags = [t.strip() for t in args.tags.split(",") if t.strip()] if args.tags else []
note_id = manager.add_memory(args.content, args.session, tags=tags)
print(f"✅ 已写入记忆,ID={note_id}")
return 0
def cmd_query(args):
"""按关键词检索记忆——用于演示“在后续会话中回忆起用户信息”。"""
manager, _ = _build_manager(args)
results = manager.search_memories(args.query)
if not results:
print(f"🔍 未检索到与“{args.query}”相关的记忆")
return 0
print(f"🔍 检索到 {len(results)} 条与“{args.query}”相关的记忆:")
for item in results:
if hasattr(item, "content"): # MemoryNote
tags = f" [tags: {', '.join(item.tags)}]" if item.tags else ""
print(f" - ({item.note_id[:8]}) {item.content}{tags}")
else: # (memory_path, data) tuple from JSON managers
path, data = item
print(f" - {path}: {data}")
return 0
def cmd_update(args):
"""按 ID 更新一条已有记忆(模拟用户提供了更新后的信息)。"""
manager, _ = _build_manager(args)
tags = [t.strip() for t in args.tags.split(",") if t.strip()] if args.tags else None
ok = manager.update_memory(args.id, args.content, args.session, tags=tags)
print("✅ 更新成功" if ok else "⚠️ 未找到对应 ID 的记忆,更新失败")
return 0 if ok else 1
def cmd_consolidate(args):
"""去重 + 版本化冲突消解(纯离线,无需 API)。"""
manager, _ = _build_manager(args)
if not hasattr(manager, "consolidate_memories"):
print("ℹ️ 当前记忆模式的整理由写入时的键覆盖自动完成,无需显式 consolidate。")
return 0
report = manager.consolidate_memories(resolve_conflicts=not args.no_conflict)
print("\n===== 记忆整理报告 =====")
print(f"整理前条数: {report['initial_count']}")
print(f"删除重复项: {report['duplicates_removed']}")
print(f"消解冲突数: {len(report['conflicts_resolved'])}")
for c in report["conflicts_resolved"]:
print(f" ⚔️ 属性“{c['attribute']}”: 保留「{c['kept']}」,"
f"废弃 {c['superseded']}")
print(f"整理后条数: {report['final_count']}")
return 0
def cmd_show(args):
"""打印某用户当前的全部记忆(即注入模型上下文的字符串)。"""
manager, mode = _build_manager(args)
print(f"\n===== 用户 {args.user} 的记忆(模式: {mode.value}=====")
print(manager.get_context_string())
return 0
def cmd_demo(args):
"""多会话离线示例:写入 → 冲突/重复 → 整理 → 后续会话回忆。
使用独立的 user_id 和临时存储目录,绝不触碰 data/ 下的真实用户数据。
"""
import tempfile
Config.MEMORY_STORAGE_DIR = args.store_path or tempfile.mkdtemp(prefix="memcli_demo_")
Config.create_directories()
user_id = "demo_user"
mgr = create_memory_manager(user_id, MemoryMode.NOTES)
mgr.verbose = False
# 从干净状态开始,避免重复运行 demo 时叠加旧数据
if hasattr(mgr, "clear_all_memories"):
mgr.notes = []
print("\n" + "=" * 62)
print(" 用户记忆多会话演示(离线,无需 API)")
print(f" 存储目录: {Config.MEMORY_STORAGE_DIR}")
print("=" * 62)
# ---- 会话 1(较早):首次了解用户偏好 ----
print("\n[会话 1 · 2024-03-01] 用户初次交流,Agent 提取到以下事实:")
mgr.add_memory("用户偏好靠窗座位", "session_2024_03", tags=["seat_preference"])
mgr.add_memory("用户家住北京朝阳区", "session_2024_03", tags=["home_address"])
mgr.add_memory("用户喜欢川菜", "session_2024_03", tags=["food_preference"])
for n in mgr.notes:
print(f" + {n.content} [{n.tags[0]}]")
# ---- 会话 2(较晚):用户搬家(冲突)并重复提到座位偏好(重复)----
print("\n[会话 2 · 2024-09-15] 用户提供了更新后的信息:")
mgr.add_memory("用户已搬到上海浦东", "session_2024_09", tags=["home_address"])
mgr.add_memory("用户偏好靠窗座位", "session_2024_09", tags=["seat_preference"]) # 重复
print(" + 用户已搬到上海浦东 [home_address] (与会话1的北京住址冲突)")
print(" + 用户偏好靠窗座位 [seat_preference] (与会话1重复)")
print(f"\n 整理前共有 {len(mgr.notes)} 条记忆(含 1 条重复、1 处冲突)")
# ---- 记忆整理:去重 + 版本化冲突消解 ----
print("\n[后台整理] 运行 consolidate_memories():去重 + 按更新时间消解冲突")
report = mgr.consolidate_memories(resolve_conflicts=True)
print(f" 删除重复: {report['duplicates_removed']}")
for c in report["conflicts_resolved"]:
print(f" 冲突消解: 属性“{c['attribute']}”保留「{c['kept']}」,废弃 {c['superseded']}")
print(f" 整理后共有 {report['final_count']} 条记忆")
# ---- 会话 3(更晚):后续会话中回忆用户信息 ----
print("\n[会话 3 · 2025-01-20] 用户问:“帮我订张机票,你还记得我住哪吗?”")
hits = mgr.search_memories("home_address")
recalled = hits[0].content if hits else "(无相关记忆)"
print(f" Agent 检索记忆(home_address) → 回忆到:{recalled}")
print(f" ✅ Agent 回复:已按您在上海浦东的地址为您推荐航班。")
print(" (注意:这里回忆到的是消解冲突后的最新住址,而非会话1的旧址)")
print("\n最终记忆快照:")
print(mgr.get_context_string())
return 0
def cmd_extract(args):
"""从一段对话中自动提取记忆——需要 LLM API(在线)。
此子命令的参数解析与校验可离线验证;实际提取会调用后台记忆处理器,
需要配置对应 provider 的 API Key。
"""
provider = args.provider or Config.PROVIDER
if not Config.get_api_key(provider):
print(f"⚠️ extract 需要 LLM API:未检测到 provider '{provider}' 的 API Key。")
print(" 请在 .env 中配置对应的 *_API_KEY 后重试(参数解析已通过)。")
return 2
# 读取对话文本:--conversation 可为文件路径或直接的文本
import os
text = args.conversation
if text and os.path.isfile(text):
with open(text, "r", encoding="utf-8") as f:
text = f.read()
if not text:
print("❌ 请通过 --conversation 提供对话文本或文件路径")
return 1
_apply_store_path(args.store_path)
mode = MODE_MAP[args.memory_mode] if args.memory_mode else Config.MEMORY_MODE
from background_memory_processor import BackgroundMemoryProcessor
processor = BackgroundMemoryProcessor(
user_id=args.user, provider=provider, model=args.model, memory_mode=mode, verbose=True
)
# 将纯文本对话拆成 user/assistant 轮次交给处理器分析
lines = [ln for ln in text.splitlines() if ln.strip()]
conversation = [{"role": "user" if i % 2 == 0 else "assistant", "content": ln}
for i, ln in enumerate(lines)]
processor.analyze_conversation(conversation)
print("\n✅ 提取完成,当前记忆:")
print(processor.memory_manager.get_context_string())
return 0
def build_parser():
parser = argparse.ArgumentParser(
prog="memory_cli.py",
description="用户记忆离线命令行工具:增/查/改/整理记忆,演示跨会话记忆的存储与冲突消解(无需 API)。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="command", metavar="子命令")
def add_common(p, need_mode=True):
p.add_argument("--user", default="default_user", help="用户 ID(默认: default_user")
p.add_argument("--store-path", default=None,
help="记忆存储目录(默认: data/memories,可指定其它路径以免影响真实数据)")
if need_mode:
p.add_argument("--memory-mode", choices=list(MODE_MAP.keys()), default=None,
help="记忆存储格式(默认取环境变量 MEMORY_MODE")
p_add = sub.add_parser("add", help="写入一条记忆(模拟从会话中提取到的事实)")
add_common(p_add)
p_add.add_argument("--session", default="cli_session", help="来源会话 ID(默认: cli_session")
p_add.add_argument("--content", required=True, help="记忆内容文本")
p_add.add_argument("--tags", default=None, help="标签,逗号分隔;第一个标签作为冲突消解的属性键")
p_add.set_defaults(func=cmd_add)
p_query = sub.add_parser("query", help="按关键词检索记忆(跨会话回忆)")
add_common(p_query)
p_query.add_argument("--query", required=True, help="检索关键词")
p_query.set_defaults(func=cmd_query)
p_update = sub.add_parser("update", help="按 ID 更新一条已有记忆")
add_common(p_update)
p_update.add_argument("--id", required=True, help="要更新的记忆 ID")
p_update.add_argument("--session", default="cli_session", help="本次更新的会话 ID")
p_update.add_argument("--content", required=True, help="更新后的记忆内容")
p_update.add_argument("--tags", default=None, help="更新后的标签,逗号分隔")
p_update.set_defaults(func=cmd_update)
p_cons = sub.add_parser("consolidate", help="去重 + 版本化冲突消解(纯离线)")
add_common(p_cons)
p_cons.add_argument("--no-conflict", action="store_true",
help="只做去重,不做冲突消解")
p_cons.set_defaults(func=cmd_consolidate)
p_show = sub.add_parser("show", help="打印某用户当前的全部记忆")
add_common(p_show)
p_show.set_defaults(func=cmd_show)
p_demo = sub.add_parser("demo", help="多会话离线示例:写入→冲突/重复→整理→后续会话回忆")
p_demo.add_argument("--store-path", default=None,
help="演示数据的存储目录(默认: 临时目录,不触碰 data/)")
p_demo.set_defaults(func=cmd_demo)
p_ext = sub.add_parser("extract", help="从对话中自动提取记忆(需要 LLM API)")
add_common(p_ext)
p_ext.add_argument("--conversation", required=True, help="对话文本或对话文件路径")
p_ext.add_argument("--provider", default=None,
choices=["dashscope", "qwen", "bailian", "siliconflow", "doubao", "kimi", "moonshot", "openrouter"],
help="LLM 提供商(默认取环境变量 PROVIDER)")
p_ext.add_argument("--model", default=None, help="模型名称(默认使用提供商默认模型)")
p_ext.set_defaults(func=cmd_extract)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if not getattr(args, "command", None):
parser.print_help()
return 0
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+799
View File
@@ -0,0 +1,799 @@
"""
Memory Manager module for handling different memory mechanisms
"""
import json
import os
import uuid
from datetime import datetime
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass, field, asdict
from abc import ABC, abstractmethod
import logging
from config import Config, MemoryMode
logger = logging.getLogger(__name__)
def _normalize_text(text: str) -> str:
"""Normalize text for duplicate detection: lowercase and collapse whitespace.
Used by the offline consolidation/dedup logic so that notes that differ only
in casing or spacing are recognised as the same fact.
"""
return " ".join(str(text or "").lower().split())
@dataclass
class MemoryNote:
"""Represents a single memory note"""
note_id: str
content: str
session_id: str
created_at: str
updated_at: str
tags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'MemoryNote':
"""Create from dictionary"""
return cls(**data)
@dataclass
class MemoryCard:
"""Represents a memory card in JSON structure"""
category: str
subcategory: str
key: str
value: Any
session_id: str
created_at: str
updated_at: str
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'MemoryCard':
"""Create from dictionary"""
return cls(**data)
class BaseMemoryManager(ABC):
"""Base class for memory managers"""
def __init__(self, user_id: str, verbose: bool = False):
"""
Initialize memory manager
Args:
user_id: Unique identifier for the user
verbose: Whether to print detailed operations
"""
self.user_id = user_id
self.verbose = verbose
self.memory_file = os.path.join(Config.MEMORY_STORAGE_DIR, f"{user_id}_memory.json")
self.load_memory()
@abstractmethod
def load_memory(self):
"""Load memory from storage"""
pass
@abstractmethod
def save_memory(self):
"""Save memory to storage"""
pass
@abstractmethod
def add_memory(self, content: Any, session_id: str, **kwargs):
"""Add a new memory item"""
pass
@abstractmethod
def update_memory(self, memory_id: str, content: Any, session_id: str, **kwargs):
"""Update an existing memory item"""
pass
@abstractmethod
def delete_memory(self, memory_id: str):
"""Delete a memory item"""
pass
@abstractmethod
def get_context_string(self) -> str:
"""Get memory as a formatted string for LLM context"""
pass
@abstractmethod
def search_memories(self, query: str) -> List[Any]:
"""Search memories by query"""
pass
class NotesMemoryManager(BaseMemoryManager):
"""Memory manager using notes list approach"""
def __init__(self, user_id: str, verbose: bool = False):
self.notes: List[MemoryNote] = []
super().__init__(user_id, verbose)
def load_memory(self):
"""Load notes from storage"""
if os.path.exists(self.memory_file):
try:
with open(self.memory_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.notes = [MemoryNote.from_dict(note) for note in data.get('notes', [])]
logger.info(f"Loaded {len(self.notes)} notes for user {self.user_id}")
except Exception as e:
logger.error(f"Error loading notes: {e}")
self.notes = []
else:
self.notes = []
logger.info(f"No existing memory file for user {self.user_id}")
def save_memory(self):
"""Save notes to storage"""
try:
os.makedirs(os.path.dirname(self.memory_file) or ".", exist_ok=True)
# Write to a temp file then atomically replace: a crash mid-dump
# must not truncate the only copy of the persisted data.
tmp_file = self.memory_file + '.tmp'
with open(tmp_file, 'w', encoding='utf-8') as f:
data = {
'user_id': self.user_id,
'type': 'notes',
'updated_at': datetime.now().isoformat(),
'notes': [note.to_dict() for note in self.notes]
}
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(tmp_file, self.memory_file)
logger.info(f"Saved {len(self.notes)} notes for user {self.user_id}")
except Exception as e:
logger.error(f"Error saving notes: {e}")
def add_memory(self, content: str, session_id: str, tags: List[str] = None):
"""Add a new note"""
note = MemoryNote(
note_id=str(uuid.uuid4()),
content=content,
session_id=session_id,
created_at=datetime.now().isoformat(),
updated_at=datetime.now().isoformat(),
tags=tags or []
)
self.notes.append(note)
if self.verbose:
print(f" Added memory note (ID: {note.note_id[:8]}...):")
print(f" Content: {content[:100]}..." if len(content) > 100 else f" Content: {content}")
if tags:
print(f" Tags: {', '.join(tags)}")
# Keep only the most recent notes if limit exceeded
if len(self.notes) > Config.MAX_MEMORY_ITEMS:
# Sort by updated_at and keep the most recent
old_count = len(self.notes)
self.notes.sort(key=lambda n: n.updated_at, reverse=True)
self.notes = self.notes[:Config.MAX_MEMORY_ITEMS]
if self.verbose:
removed_count = old_count - len(self.notes)
print(f" 🗑️ Removed {removed_count} oldest memory notes (limit: {Config.MAX_MEMORY_ITEMS})")
self.save_memory()
return note.note_id
def update_memory(self, memory_id: str, content: str, session_id: str, tags: List[str] = None):
"""Update an existing note"""
for note in self.notes:
if note.note_id == memory_id:
old_content = note.content
note.content = content
note.session_id = session_id
note.updated_at = datetime.now().isoformat()
if tags is not None:
note.tags = tags
if self.verbose:
print(f" 📝 Updated memory note (ID: {memory_id[:8]}...):")
print(f" Old: {old_content[:100]}..." if len(old_content) > 100 else f" Old: {old_content}")
print(f" New: {content[:100]}..." if len(content) > 100 else f" New: {content}")
if tags:
print(f" Tags: {', '.join(tags)}")
self.save_memory()
return True
if self.verbose:
print(f" ⚠️ Memory note not found for update (ID: {memory_id[:8]}...)")
return False
def delete_memory(self, memory_id: str):
"""Delete a note"""
original_count = len(self.notes)
deleted_note = None
for note in self.notes:
if note.note_id == memory_id:
deleted_note = note
break
self.notes = [note for note in self.notes if note.note_id != memory_id]
if self.verbose:
if deleted_note:
print(f" 🗑️ Deleted memory note (ID: {memory_id[:8]}...):")
print(f" Content: {deleted_note.content[:100]}..." if len(deleted_note.content) > 100 else f" Content: {deleted_note.content}")
elif original_count == len(self.notes):
print(f" ⚠️ Memory note not found for deletion (ID: {memory_id[:8]}...)")
self.save_memory()
def clear_all_memories(self):
"""Clear all memories for this user - useful for testing"""
self.notes = []
self.save_memory()
logger.info(f"Cleared all memories for user {self.user_id}")
print(f" 🧹 Cleared all memories for user {self.user_id}")
def get_context_string(self) -> str:
"""Get notes as formatted string for LLM context"""
if not self.notes:
return "No previous memory notes available."
context = "User Memory Notes:\n\n"
for i, note in enumerate(self.notes, 1):
context += f"Note {i} (ID: {note.note_id}, Session: {note.session_id}):\n"
context += f" Content: {note.content}\n"
if note.tags:
context += f" Tags: {', '.join(note.tags)}\n"
context += f" Updated: {note.updated_at}\n\n"
return context
def search_memories(self, query: str) -> List[MemoryNote]:
"""Search notes by query (simple text search)"""
query_lower = query.lower()
results = []
for note in self.notes:
if query_lower in note.content.lower() or any(query_lower in tag.lower() for tag in note.tags):
results.append(note)
return results
def consolidate_memories(self, resolve_conflicts: bool = True) -> Dict[str, Any]:
"""Deterministically deduplicate and (optionally) conflict-resolve notes.
This is the offline counterpart to the LLM-driven memory maintenance in
the background processor. It runs without any API call so the storage /
dedup / versioned-conflict logic can be exercised and inspected directly.
Two passes:
1. Dedup - notes whose normalized content is identical are merged into
one (the most recently updated is kept, tags are unioned).
2. Conflict resolution - remaining notes are grouped by their
"attribute key" (the first tag, e.g. "home_address"). If notes in a
group carry different content they describe conflicting versions of
the same attribute; the most recently updated one wins and the older
versions are superseded. This is version-based conflict detection.
Args:
resolve_conflicts: When False only the dedup pass runs.
Returns:
A report dict describing what was merged / superseded and the final
note count. Nothing is written unless something actually changed.
"""
report: Dict[str, Any] = {
"duplicates_removed": 0,
"merged_notes": [],
"conflicts_resolved": [],
"initial_count": len(self.notes),
"final_count": len(self.notes),
}
# --- Pass 1: deduplicate identical content ---------------------------
by_content: Dict[str, MemoryNote] = {}
deduped: List[MemoryNote] = []
for note in self.notes:
norm = _normalize_text(note.content)
existing = by_content.get(norm)
if existing is None:
by_content[norm] = note
deduped.append(note)
continue
# Duplicate found - keep whichever is newer, union the tags.
keeper, dropped = (existing, note) if existing.updated_at >= note.updated_at else (note, existing)
keeper.tags = sorted(set(keeper.tags) | set(dropped.tags))
keeper.created_at = min(existing.created_at, note.created_at)
keeper.updated_at = max(existing.updated_at, note.updated_at)
if keeper is note: # replace the reference we already stored
idx = deduped.index(existing)
deduped[idx] = note
by_content[norm] = note
report["duplicates_removed"] += 1
report["merged_notes"].append(keeper.content)
# --- Pass 2: resolve conflicting versions of the same attribute ------
if resolve_conflicts:
groups: Dict[str, List[MemoryNote]] = {}
singletons: List[MemoryNote] = []
for note in deduped:
attr = note.tags[0] if note.tags else None
if attr is None:
singletons.append(note)
else:
groups.setdefault(attr, []).append(note)
kept: List[MemoryNote] = list(singletons)
for attr, members in groups.items():
distinct = {_normalize_text(m.content) for m in members}
if len(members) == 1 or len(distinct) == 1:
# No conflict: single note, or identical content under one attr.
kept.extend(members)
continue
winner = max(members, key=lambda m: m.updated_at)
superseded = [m for m in members if m is not winner]
kept.append(winner)
report["conflicts_resolved"].append({
"attribute": attr,
"kept": winner.content,
"superseded": [m.content for m in superseded],
})
deduped = kept
changed = len(deduped) != len(self.notes)
self.notes = deduped
report["final_count"] = len(self.notes)
if self.verbose and (report["duplicates_removed"] or report["conflicts_resolved"]):
print(f" 🧹 Consolidated memories: {report['initial_count']}{report['final_count']} notes")
for c in report["conflicts_resolved"]:
print(f" ⚔️ Conflict on '{c['attribute']}': kept \"{c['kept']}\", "
f"superseded {c['superseded']}")
if changed:
self.save_memory()
return report
class JSONMemoryManager(BaseMemoryManager):
"""Memory manager using hierarchical JSON cards approach"""
def __init__(self, user_id: str, verbose: bool = False):
self.memory_cards: Dict[str, Dict[str, Dict[str, Any]]] = {}
super().__init__(user_id, verbose)
def load_memory(self):
"""Load JSON memory cards from storage"""
if os.path.exists(self.memory_file):
try:
with open(self.memory_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.memory_cards = data.get('memory_cards', {})
logger.info(f"Loaded memory cards for user {self.user_id}")
except Exception as e:
logger.error(f"Error loading memory cards: {e}")
self.memory_cards = {}
else:
self.memory_cards = {}
logger.info(f"No existing memory file for user {self.user_id}")
def save_memory(self):
"""Save JSON memory cards to storage"""
try:
os.makedirs(os.path.dirname(self.memory_file) or ".", exist_ok=True)
# Write to a temp file then atomically replace: a crash mid-dump
# must not truncate the only copy of the persisted data.
tmp_file = self.memory_file + '.tmp'
with open(tmp_file, 'w', encoding='utf-8') as f:
data = {
'user_id': self.user_id,
'type': 'json_cards',
'updated_at': datetime.now().isoformat(),
'memory_cards': self.memory_cards
}
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(tmp_file, self.memory_file)
logger.info(f"Saved memory cards for user {self.user_id}")
except Exception as e:
logger.error(f"Error saving memory cards: {e}")
def add_memory(self, content: Dict[str, Any], session_id: str, **kwargs):
"""
Add a new memory card
Args:
content: Dictionary with 'category', 'subcategory', 'key', and 'value'
session_id: Session identifier
"""
category = content.get('category', 'general')
subcategory = content.get('subcategory', 'info')
key = content.get('key', str(uuid.uuid4()))
value = content.get('value')
if category not in self.memory_cards:
self.memory_cards[category] = {}
if subcategory not in self.memory_cards[category]:
self.memory_cards[category][subcategory] = {}
self.memory_cards[category][subcategory][key] = {
'value': value,
'source': session_id,
'updated_at': datetime.now().isoformat()
}
if self.verbose:
print(f" Added JSON memory card: {category}.{subcategory}.{key}")
value_str = str(value)[:100] + "..." if len(str(value)) > 100 else str(value)
print(f" Value: {value_str}")
self.save_memory()
return f"{category}.{subcategory}.{key}"
def update_memory(self, memory_id: str, content: Dict[str, Any], session_id: str, **kwargs):
"""Update an existing memory card"""
parts = memory_id.split('.')
if len(parts) != 3:
return False
category, subcategory, key = parts
if (category in self.memory_cards and
subcategory in self.memory_cards[category] and
key in self.memory_cards[category][subcategory]):
old_value = self.memory_cards[category][subcategory][key]['value']
value = content.get('value')
self.memory_cards[category][subcategory][key] = {
'value': value,
'source': session_id,
'updated_at': datetime.now().isoformat()
}
if self.verbose:
print(f" 📝 Updated JSON memory card: {category}.{subcategory}.{key}")
old_str = str(old_value)[:100] + "..." if len(str(old_value)) > 100 else str(old_value)
new_str = str(value)[:100] + "..." if len(str(value)) > 100 else str(value)
print(f" Old: {old_str}")
print(f" New: {new_str}")
self.save_memory()
return True
if self.verbose:
print(f" ⚠️ JSON memory card not found for update: {memory_id}")
return False
def delete_memory(self, memory_id: str):
"""Delete a memory card"""
parts = memory_id.split('.')
if len(parts) != 3:
if self.verbose:
print(f" ⚠️ Invalid memory ID format for deletion: {memory_id}")
return
category, subcategory, key = parts
if (category in self.memory_cards and
subcategory in self.memory_cards[category] and
key in self.memory_cards[category][subcategory]):
deleted_value = self.memory_cards[category][subcategory][key]['value']
del self.memory_cards[category][subcategory][key]
if self.verbose:
print(f" 🗑️ Deleted JSON memory card: {category}.{subcategory}.{key}")
value_str = str(deleted_value)[:100] + "..." if len(str(deleted_value)) > 100 else str(deleted_value)
print(f" Value: {value_str}")
# Clean up empty subcategories and categories
if not self.memory_cards[category][subcategory]:
del self.memory_cards[category][subcategory]
if not self.memory_cards[category]:
del self.memory_cards[category]
self.save_memory()
else:
if self.verbose:
print(f" ⚠️ JSON memory card not found for deletion: {memory_id}")
def clear_all_memories(self):
"""Clear all memories for this user - useful for testing"""
self.memory_cards = {}
self.save_memory()
logger.info(f"Cleared all memories for user {self.user_id}")
print(f" 🧹 Cleared all memories for user {self.user_id}")
def get_context_string(self) -> str:
"""Get memory cards as formatted string for LLM context"""
if not self.memory_cards:
return "No previous memory cards available."
context = "User Memory Cards (Hierarchical JSON):\n\n"
context += json.dumps(self.memory_cards, indent=2, ensure_ascii=False)
return context
def search_memories(self, query: str) -> List[Tuple[str, Any]]:
"""Search memory cards by query"""
query_lower = query.lower()
results = []
for category, subcategories in self.memory_cards.items():
for subcategory, items in subcategories.items():
for key, data in items.items():
memory_path = f"{category}.{subcategory}.{key}"
value_str = str(data.get('value', '')).lower()
if (query_lower in category.lower() or
query_lower in subcategory.lower() or
query_lower in key.lower() or
query_lower in value_str):
results.append((memory_path, data))
return results
class AdvancedJSONMemoryManager(BaseMemoryManager):
"""
Advanced JSON memory manager with complete memory card objects
Structure: categories -> memory_card_key -> memory card (arbitrary JSON)
"""
def __init__(self, user_id: str, verbose: bool = False):
self.categories: Dict[str, Dict[str, Dict[str, Any]]] = {}
super().__init__(user_id, verbose)
def load_memory(self):
"""Load advanced JSON memory cards from storage"""
if os.path.exists(self.memory_file):
try:
with open(self.memory_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self.categories = data.get('categories', {})
logger.info(f"Loaded advanced memory cards for user {self.user_id}")
except Exception as e:
logger.error(f"Error loading advanced memory cards: {e}")
self.categories = {}
else:
self.categories = {}
logger.info(f"No existing memory file for user {self.user_id}")
def save_memory(self):
"""Save advanced JSON memory cards to storage"""
try:
os.makedirs(os.path.dirname(self.memory_file) or ".", exist_ok=True)
# Write to a temp file then atomically replace: a crash mid-dump
# must not truncate the only copy of the persisted data.
tmp_file = self.memory_file + '.tmp'
with open(tmp_file, 'w', encoding='utf-8') as f:
data = {
'user_id': self.user_id,
'type': 'advanced_json_cards',
'updated_at': datetime.now().isoformat(),
'categories': self.categories
}
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(tmp_file, self.memory_file)
logger.info(f"Saved advanced memory cards for user {self.user_id}")
except Exception as e:
logger.error(f"Error saving advanced memory cards: {e}")
def add_memory(self, content: Dict[str, Any], session_id: str, **kwargs):
"""
Add a new memory card
Args:
content: Dictionary with 'category', 'card_key', and 'card' (complete memory card object)
session_id: Session identifier
Returns:
Memory ID in format: category.card_key
"""
category = content.get('category', 'general')
card_key = content.get('card_key')
card = content.get('card', {})
if not card_key:
card_key = str(uuid.uuid4())
if category not in self.categories:
self.categories[category] = {}
# Add metadata to the card
card['_metadata'] = {
'created_at': datetime.now().isoformat(),
'updated_at': datetime.now().isoformat(),
'source': session_id
}
# Ensure required fields
if 'backstory' not in card:
card['backstory'] = kwargs.get('backstory', '')
if 'date_created' not in card:
card['date_created'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if 'person' not in card:
card['person'] = kwargs.get('person', 'Unknown')
if 'relationship' not in card:
card['relationship'] = kwargs.get('relationship', 'primary account holder')
self.categories[category][card_key] = card
self.save_memory()
return f"{category}.{card_key}"
def update_memory(self, memory_id: str, content: Dict[str, Any], session_id: str, **kwargs):
"""
Update an existing memory card
Args:
memory_id: Memory ID in format category.card_key
content: Complete new memory card or partial updates
session_id: Session identifier
Returns:
True if successful, False otherwise
"""
parts = memory_id.split('.', 1)
if len(parts) != 2:
return False
category, card_key = parts
if category not in self.categories or card_key not in self.categories[category]:
return False
card = content.get('card', content)
# Preserve existing metadata
if '_metadata' in self.categories[category][card_key]:
old_metadata = self.categories[category][card_key]['_metadata']
card['_metadata'] = {
'created_at': old_metadata.get('created_at', datetime.now().isoformat()),
'updated_at': datetime.now().isoformat(),
'source': session_id
}
else:
card['_metadata'] = {
'created_at': datetime.now().isoformat(),
'updated_at': datetime.now().isoformat(),
'source': session_id
}
# Update the card
self.categories[category][card_key] = card
self.save_memory()
return True
def delete_memory(self, memory_id: str):
"""Delete a memory card"""
parts = memory_id.split('.', 1)
if len(parts) != 2:
return
category, card_key = parts
if category in self.categories and card_key in self.categories[category]:
del self.categories[category][card_key]
# Clean up empty categories
if not self.categories[category]:
del self.categories[category]
self.save_memory()
def clear_all_memories(self):
"""Clear all memories for this user"""
self.categories = {}
self.save_memory()
logger.info(f"Cleared all memories for user {self.user_id}")
print(f" 🧹 Cleared all memories for user {self.user_id}")
def get_context_string(self) -> str:
"""Get memory cards as formatted string for LLM context"""
if not self.categories:
return "No previous memory cards available."
context = "User Memory Cards (Advanced JSON Structure):\n\n"
for category, cards in self.categories.items():
context += f"Category: {category}\n"
for card_key, card in cards.items():
# Remove internal metadata from display
display_card = {k: v for k, v in card.items() if k != '_metadata'}
context += f" Card '{card_key}':\n"
context += f" {json.dumps(display_card, indent=4, ensure_ascii=False)}\n"
return context
def search_memories(self, query: str) -> List[Tuple[str, Any]]:
"""Search memory cards by query"""
query_lower = query.lower()
results = []
for category, cards in self.categories.items():
for card_key, card in cards.items():
memory_id = f"{category}.{card_key}"
# Search in category, card_key, and all card fields
card_str = json.dumps(card, ensure_ascii=False).lower()
if (query_lower in category.lower() or
query_lower in card_key.lower() or
query_lower in card_str):
results.append((memory_id, card))
return results
def create_memory_manager(user_id: str, mode: MemoryMode = None) -> BaseMemoryManager:
"""
Factory function to create appropriate memory manager
Args:
user_id: User identifier
mode: Memory mode (defaults to config setting)
Returns:
Memory manager instance
"""
mode = mode or Config.MEMORY_MODE
if mode == MemoryMode.NOTES or mode == MemoryMode.ENHANCED_NOTES:
# Both basic and enhanced notes use the same manager
# The difference is in the prompts used by the agent
return NotesMemoryManager(user_id)
elif mode == MemoryMode.JSON_CARDS:
return JSONMemoryManager(user_id)
elif mode == MemoryMode.ADVANCED_JSON_CARDS:
return AdvancedJSONMemoryManager(user_id)
else:
raise ValueError(f"Unknown memory mode: {mode}")
def ensure_memory_cleared(memory_manager: BaseMemoryManager, description: str = "memory") -> bool:
"""
Ensures that all memory is cleared for a given memory manager.
Used primarily for testing and evaluation to ensure clean state before each test case.
Args:
memory_manager: The memory manager to clear
description: Description for logging (e.g., "agent memory", "processor memory")
Returns:
True if memory was successfully cleared, False otherwise
"""
if not memory_manager:
logger.warning(f"No memory manager provided for {description}")
return False
try:
# Clear all memories
if hasattr(memory_manager, 'clear_all_memories'):
memory_manager.clear_all_memories()
else:
logger.warning(f"Memory manager for {description} doesn't support clear_all_memories()")
return False
# Verify memory is cleared by checking the context string
context = memory_manager.get_context_string()
is_cleared = "No previous memory" in context
if is_cleared:
logger.info(f"{description} cleared successfully")
else:
logger.warning(f"⚠️ {description} may not be fully cleared. Context: {context[:100]}...")
return is_cleared
except Exception as e:
logger.error(f"Error clearing {description}: {e}")
return False
@@ -0,0 +1,142 @@
"""
Formatter for memory operations output
Provides consistent formatting for memory operation lists
"""
from typing import List, Dict, Any
import json
def format_memory_operations(operations: List[Dict[str, Any]], verbose: bool = False) -> str:
"""
Format memory operations for display
Args:
operations: List of memory operations
verbose: Whether to show detailed output
Returns:
Formatted string representation of operations
"""
if not operations:
return "📝 Memory Operations: None (no updates needed)"
lines = []
lines.append(f"📝 Memory Operations ({len(operations)} total):")
lines.append("-" * 50)
for i, op in enumerate(operations, 1):
# Choose icon based on action
icon_map = {
'add': '',
'update': '📝',
'delete': '🗑️'
}
action = str(op.get('action') or 'unknown').lower()
icon = icon_map.get(action, '')
# Main operation line
lines.append(f"{i}. {icon} {action.upper()}")
if op.get('memory_id'):
lines.append(f" Memory ID: {op['memory_id']}")
if op.get('content'):
content = op['content']
# Truncate if too long and not verbose
if not verbose and len(content) > 100:
content = content[:97] + "..."
lines.append(f" Content: {content}")
# Reason
if op.get('reason'):
lines.append(f" Reason: {op['reason']}")
# Tags
if op.get('tags'):
lines.append(f" Tags: {', '.join(op['tags'])}")
lines.append("") # Empty line between operations
return "\n".join(lines)
def format_operation_summary(summary: Dict[str, int]) -> str:
"""
Format operation summary statistics
Args:
summary: Dictionary with counts of operations
Returns:
Formatted summary string
"""
added = summary.get('added', 0)
updated = summary.get('updated', 0)
deleted = summary.get('deleted', 0)
failed = summary.get('failed', 0)
parts = []
if added > 0:
parts.append(f"{added} added")
if updated > 0:
parts.append(f"{updated} updated")
if deleted > 0:
parts.append(f"{deleted} deleted")
if failed > 0:
parts.append(f"{failed} failed")
if not parts:
return "No operations performed"
return "Summary: " + ", ".join(parts)
def display_memory_operations(results: Dict[str, Any], verbose: bool = False):
"""
Display memory operations from processing results
Args:
results: Processing results containing operations
verbose: Whether to show detailed output
"""
operations = results.get('operations', [])
summary = results.get('summary', {})
# Display operations
print(format_memory_operations(operations, verbose))
# Display summary
print(format_operation_summary(summary))
print("-" * 50)
def operations_to_json(operations: List[Dict[str, Any]], pretty: bool = True) -> str:
"""
Convert operations to JSON string
Args:
operations: List of memory operations
pretty: Whether to pretty-print the JSON
Returns:
JSON string representation
"""
if pretty:
return json.dumps(operations, indent=2, ensure_ascii=False)
else:
return json.dumps(operations, ensure_ascii=False)
def filter_operations_by_action(operations: List[Dict[str, Any]], action: str) -> List[Dict[str, Any]]:
"""
Filter operations by action type
Args:
operations: List of memory operations
action: Action type to filter ('add', 'update', 'delete')
Returns:
Filtered list of operations
"""
return [op for op in operations if op.get('action') == action]
File diff suppressed because one or more lines are too long
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
Quick start script for User Memory System with Separated Architecture
Demonstrates conversation-based memory processing
"""
import os
import sys
import time
from dotenv import load_dotenv
from conversational_agent import ConversationalAgent, ConversationConfig
from background_memory_processor import BackgroundMemoryProcessor, MemoryProcessorConfig
from config import Config, MemoryMode
from memory_manager import create_memory_manager
from memory_operation_formatter import display_memory_operations
# Load environment variables
load_dotenv()
def quickstart():
"""Run a quick demonstration of the memory system with separated architecture"""
print("\n" + "="*60)
print("🚀 USER MEMORY SYSTEM - QUICK START")
print(" (Conversation-Based Memory Processing)")
print("="*60)
# Check configuration
if not Config.MOONSHOT_API_KEY:
print("\n❌ ERROR: MOONSHOT_API_KEY not found!")
print("\nPlease set up your .env file with:")
print(" MOONSHOT_API_KEY=your_api_key_here")
print("\nYou can get an API key from: https://platform.moonshot.cn/")
sys.exit(1)
# Create directories
Config.create_directories()
# Setup demo user
user_id = "quickstart_user"
memory_mode = MemoryMode.NOTES
print(f"\n📌 Setting up separated architecture:")
print(f" • User: {user_id}")
print(f" • Memory Mode: {memory_mode.value}")
print(f" • Processing: After each conversation round")
# Initialize conversational agent
print("\n🤖 Initializing conversational agent...")
agent = ConversationalAgent(
user_id=user_id,
memory_mode=memory_mode,
config=ConversationConfig(
enable_memory_context=True,
enable_conversation_history=True
),
verbose=False
)
# Initialize background memory processor
print("🧠 Initializing memory processor...")
processor = BackgroundMemoryProcessor(
user_id=user_id,
memory_mode=memory_mode,
config=MemoryProcessorConfig(
conversation_interval=1, # Process after each conversation
min_conversation_turns=1,
output_operations=True
),
verbose=False
)
print("✅ System initialized\n")
# Session 1: Introduction
print("="*60)
print("SESSION 1: INTRODUCTION & LEARNING")
print("="*60)
intro_messages = [
"Hi! I'm Alex, a software developer who loves Python and machine learning.",
"I'm currently working on a recommendation system project using PyTorch.",
"I prefer dark themes in my IDE and always use type hints in my Python code."
]
for i, msg in enumerate(intro_messages, 1):
print(f"\n[Conversation Round {i}]")
print(f"👤 User: {msg}")
# Have conversation
response = agent.chat(msg)
print(f"🤖 Assistant: {response[:150]}..." if len(response) > 150 else f"🤖 Assistant: {response}")
# Trigger memory processing after each conversation
processor.increment_conversation_count()
print(f"\n📝 Processing memory after conversation {i}...")
results = processor.process_recent_conversations()
# Display memory operations
operations = results.get('operations', [])
if operations:
print("\nMemory Operations:")
for j, op in enumerate(operations, 1):
icon = {'add': '', 'update': '📝', 'delete': '🗑️'}.get(op['action'], '')
print(f" {j}. {icon} {op['action'].upper()}: {op.get('content', '')[:80]}...")
else:
print(" ️ No memory updates needed")
summary = results.get('summary', {})
if any(summary.values()):
print(f" Summary: {summary.get('added', 0)} added, {summary.get('updated', 0)} updated")
# Show current memory state
print("\n" + "="*40)
print("💾 MEMORY STATE AFTER SESSION 1")
print("="*40)
memory_manager = create_memory_manager(user_id, memory_mode)
print(memory_manager.get_context_string())
# Session 2: Testing memory recall and updates
print("\n" + "="*60)
print("SESSION 2: MEMORY RECALL & UPDATES")
print("="*60)
# Start new conversation session
agent.reset_session()
print("🔄 Started new conversation session\n")
recall_messages = [
"What do you remember about my work and preferences?",
"Actually, I recently switched from PyTorch to JAX for better performance.",
"Can you recommend tools for my recommendation system based on what you know about me?"
]
for i, msg in enumerate(recall_messages, 1):
print(f"\n[Conversation Round {i}]")
print(f"👤 User: {msg}")
# Have conversation
response = agent.chat(msg)
# Show full response for memory recall questions
if "remember" in msg.lower() or "recommend" in msg.lower():
print(f"🤖 Assistant: {response}")
else:
print(f"🤖 Assistant: {response[:150]}..." if len(response) > 150 else f"🤖 Assistant: {response}")
# Trigger memory processing
processor.increment_conversation_count()
print(f"\n📝 Processing memory after conversation {i}...")
results = processor.process_recent_conversations()
# Display memory operations
operations = results.get('operations', [])
if operations:
print("\nMemory Operations:")
for j, op in enumerate(operations, 1):
icon = {'add': '', 'update': '📝', 'delete': '🗑️'}.get(op['action'], '')
content = op.get('content', op.get('memory_id', 'N/A'))
print(f" {j}. {icon} {op['action'].upper()}: {content[:80]}...")
if op.get('reason'):
print(f" Reason: {op['reason'][:80]}...")
else:
print(" ️ No memory updates needed")
summary = results.get('summary', {})
if any(summary.values()):
print(f" Summary: {summary.get('added', 0)} added, {summary.get('updated', 0)} updated")
# Final memory state
print("\n" + "="*40)
print("💾 FINAL MEMORY STATE")
print("="*40)
memory_manager = create_memory_manager(user_id, memory_mode)
final_memory = memory_manager.get_context_string()
print(final_memory if final_memory else "No memories stored")
# Summary
print("\n" + "="*60)
print("✨ QUICK START COMPLETED!")
print("="*60)
print("\n🎯 Key Features Demonstrated:")
print(" • Separated conversation and memory processing")
print(" • Memory operations after each conversation round")
print(" • Clear list of add/update/delete operations")
print(" • Memory persistence across sessions")
print("\n📚 Next Steps:")
print(" 1. Interactive mode: python main.py --mode interactive --user your_name")
print(" 2. Adjust processing: --conversation-interval 2 (process every 2 conversations)")
print(" 3. Manual processing: --background-processing False")
print(" 4. Try JSON cards: --memory-mode json_cards")
print(" 5. Run full demo: python main.py --mode demo")
if __name__ == "__main__":
quickstart()
+23
View File
@@ -0,0 +1,23 @@
# User Memory System Requirements
# Core dependencies
openai>=1.35.0
python-dotenv>=1.0.0
# Logging and utilities
colorama>=0.4.6
rich>=13.0.0
pyyaml>=6.0.0
pydantic>=2.0.0
# Optional: For Dify integration (vector search)
requests>=2.31.0
# Optional: For advanced embedding search (if not using Dify)
# sentence-transformers>=2.2.2
# numpy>=1.24.0
# faiss-cpu>=1.7.4
# Development dependencies
pytest>=7.4.0
pytest-asyncio>=0.21.0
+557
View File
@@ -0,0 +1,557 @@
#!/usr/bin/env python3
"""Live sequential-memory campaign for Experiments 3-1 and 3-2.
Unlike the offline keyword fixture, this runner sends every historical session
to a real memory writer one at a time. From session two onward the writer is
given only the previous *memory state* and the new session; prior raw sessions
are deliberately absent. A fresh answer is then generated from memory alone
and graded by a different provider/model.
The default is a six-case smoke campaign (two per layer). Use ``--all`` for the
authoritative 60-case × four-mode comparison required by the manuscript.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import json
import os
import statistics
import sys
import threading
import time
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, Iterable, List
import yaml
from openai import OpenAI
HERE = Path(__file__).resolve().parent
CHAPTER = HERE.parent
sys.path.insert(0, str(CHAPTER))
from experiment_utils import ChatRecorder, jsonable, sha256_file, write_campaign_evidence
ARK_ENDPOINT = "https://ark.cn-beijing.volces.com/api/v3"
MOONSHOT_ENDPOINT = "https://api.moonshot.cn/v1"
MODES = ("notes", "enhanced_notes", "json_cards", "advanced_json_cards")
MODE_INSTRUCTIONS = {
"notes": (
"Store memory as an array of minimal standalone factual notes. Split a "
"complex statement into atomic facts; keep exact names, identifiers and dates."
),
"enhanced_notes": (
"Store memory as an array of contextual paragraphs. Each paragraph must retain "
"the entity, event, time, status, and relationships needed to interpret it."
),
"json_cards": (
"Store memory as a hierarchical JSON object using category/subcategory/key/value "
"organization. Preserve multi-entity distinctions and historical status."
),
"advanced_json_cards": (
"Store memory as an array of cards. Every card must include category, card_key, "
"backstory, person, relationship, timestamp, status, and a facts object. Keep "
"conflicting instructions as ordered versions rather than silently merging them."
),
}
def parse_json(text: str) -> Dict[str, Any]:
text = (text or "").strip()
if "```" in text:
parts = text.split("```")
text = parts[1]
if text.startswith("json"):
text = text[4:]
return json.loads(text.strip())
def load_cases(root: Path, args: argparse.Namespace) -> List[Dict[str, Any]]:
paths = sorted(root.glob("layer*/*.yaml"))
cases = []
wanted = set(args.case or [])
by_layer: Dict[str, int] = defaultdict(int)
for path in paths:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if wanted and data.get("test_id") not in wanted:
continue
layer = data.get("category")
if not args.all and not wanted and by_layer[layer] >= args.per_layer:
continue
data["_path"] = str(path.resolve())
cases.append(data)
by_layer[layer] += 1
if wanted:
missing = wanted - {c["test_id"] for c in cases}
if missing:
raise ValueError(f"Unknown test ids: {sorted(missing)}")
return cases
def format_history(history: Dict[str, Any]) -> str:
metadata = json.dumps(history.get("metadata") or {}, ensure_ascii=False)
lines = [
f"conversation_id={history.get('conversation_id')}",
f"timestamp={history.get('timestamp')}",
f"metadata={metadata}",
]
for message in history.get("messages", []):
lines.append(f"{str(message.get('role', '')).upper()}: {message.get('content', '')}")
return "\n".join(lines)
def initial_memory(mode: str) -> Any:
return [] if mode != "json_cards" else {}
def memory_prompt(mode: str, memory: Any, history: Dict[str, Any], session_index: int) -> List[Dict[str, str]]:
system = (
"You are a long-term memory writer. Select only facts that may help a future "
"assistant, but retain exact values, ownership, event status, dates, provenance, "
"and relationships. Apply updates without losing still-valid facts. Never answer "
"the conversation. Return JSON only as {\"memory\": ...}. " + MODE_INSTRUCTIONS[mode]
)
user = (
f"MEMORY MODE: {mode}\nSESSION INDEX: {session_index}\n\n"
"CURRENT MEMORY STATE (the only retained information from older sessions):\n"
f"{json.dumps(memory, ensure_ascii=False)}\n\n"
"NEW SESSION (analyze this session, then replace the memory state):\n"
f"{format_history(history)}"
)
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def answer_prompt(mode: str, memory: Any, question: str) -> List[Dict[str, str]]:
return [
{
"role": "system",
"content": (
"You are an assistant in a brand-new session. The supplied long-term memory "
"is your only source about this user: you cannot access earlier raw dialogue. "
"Answer accurately, resolve ambiguity, connect sessions, and proactively warn "
"about material risks. Do not invent facts."
),
},
{
"role": "user",
"content": (
f"MEMORY MODE: {mode}\nLONG-TERM MEMORY:\n"
f"{json.dumps(memory, ensure_ascii=False)}\n\nUSER QUESTION:\n{question}"
),
},
]
def judge_prompt(case: Dict[str, Any], answer: str) -> List[Dict[str, str]]:
source = "\n\n".join(format_history(h) for h in case["conversation_histories"])
system = (
"You are a strict independent judge of a memory assistant. Use only the authoritative "
"conversation source. Score precision, recall, reasoning, and proactivity from 1 to 4. "
"A material unsupported or contradicted factual claim is a hallucination veto. Return "
"JSON only."
)
user = f"""AUTHORITATIVE SOURCE:
{source}
QUESTION: {case['user_question']}
ANSWER: {answer}
EVALUATION CRITERIA: {case['evaluation_criteria']}
EXPECTED BEHAVIOR: {case.get('expected_behavior', '')}
Return exactly:
{{"dimensions": {{"precision": {{"score": 1, "reasoning": "...", "evidence": []}},
"recall": {{"score": 1, "reasoning": "...", "evidence": []}},
"reasoning": {{"score": 1, "reasoning": "...", "evidence": []}},
"proactivity": {{"score": 1, "reasoning": "...", "evidence": []}}}},
"hallucination": {{"detected": false, "claims": [], "reasoning": "..."}},
"overall_reasoning": "..."}}
Scale: 4 fully meets the concrete criterion; 3 meets the core with only a minor
defect; 2 has a material omission; 1 misses/contradicts the core. Asking a
targeted clarification is correct when several entities plausibly match.
"""
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def judge_summary(raw: Dict[str, Any]) -> Dict[str, Any]:
dims = raw.get("dimensions") or {}
scores = {}
for name in ("precision", "recall", "reasoning", "proactivity"):
score = int((dims.get(name) or {}).get("score", 1))
scores[name] = min(4, max(1, score))
hallucination = bool((raw.get("hallucination") or {}).get("detected"))
passed = not hallucination and all(scores[x] >= 3 for x in ("precision", "recall", "reasoning"))
reward = 0.0 if hallucination else statistics.mean(scores.values()) / 4.0
return {"scores": scores, "hallucination_veto": hallucination, "passed": passed, "reward": reward}
class Campaign:
def __init__(self, args: argparse.Namespace):
ark_key = os.getenv("ARK_API_KEY") or os.getenv("DOUBAO_API_KEY")
moonshot_key = os.getenv("MOONSHOT_API_KEY")
if not ark_key or not moonshot_key:
raise RuntimeError("ARK_API_KEY and MOONSHOT_API_KEY are both required")
self.args = args
self.writer_client = OpenAI(
api_key=ark_key, base_url=args.writer_endpoint, timeout=args.timeout, max_retries=3
)
self.judge_client = OpenAI(
api_key=moonshot_key, base_url=args.judge_endpoint, timeout=args.timeout, max_retries=3
)
self.checkpoint_dir = args.checkpoint_dir.resolve()
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
self.checkpoint_signature = {
"writer_endpoint": args.writer_endpoint,
"writer_model": args.writer_model,
"judge_endpoint": args.judge_endpoint,
"judge_model": args.judge_model,
"seed": args.seed,
}
def _checkpoint_path(self, test_id: str, mode: str) -> Path:
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in test_id)
return self.checkpoint_dir / f"{safe_id}--{mode}.json"
@staticmethod
def _write_checkpoint(path: Path, payload: Dict[str, Any]) -> None:
temporary = path.with_suffix(f".{threading.get_ident()}.tmp")
temporary.write_text(
json.dumps(jsonable(payload), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
@staticmethod
def _successful_call(calls: List[Dict[str, Any]], purpose: str) -> Dict[str, Any] | None:
for call in reversed(calls):
choices = (call.get("response") or {}).get("choices") or []
finish_reason = choices[0].get("finish_reason") if choices else None
if (
call.get("purpose") == purpose
and "response" in call
and "error" not in call
and finish_reason != "length"
):
return call
return None
@staticmethod
def _content_from_call(call: Dict[str, Any]) -> str:
return call["response"]["choices"][0]["message"]["content"]
def run_one(self, case: Dict[str, Any], mode: str) -> Dict[str, Any]:
checkpoint_path = self._checkpoint_path(case["test_id"], mode)
if checkpoint_path.exists():
checkpoint = json.loads(checkpoint_path.read_text(encoding="utf-8"))
if checkpoint.get("signature") != self.checkpoint_signature:
raise RuntimeError(
f"checkpoint signature mismatch for {case['test_id']} {mode}; "
"use a different --checkpoint-dir"
)
else:
checkpoint = {
"schema_version": "chapter3-memory-checkpoint-v1",
"signature": self.checkpoint_signature,
"test_id": case["test_id"],
"mode": mode,
"status": "running",
"memory_states": [],
"writer_calls": [],
"judge_calls": [],
}
if checkpoint.get("status") == "completed" and checkpoint.get("result"):
result = dict(checkpoint["result"])
result["_receipts"] = checkpoint.get("writer_calls", []) + checkpoint.get("judge_calls", [])
result["_resumed"] = True
return result
writer: ChatRecorder
judge: ChatRecorder
def persist_calls() -> None:
checkpoint["writer_calls"] = writer.calls
checkpoint["judge_calls"] = judge.calls
checkpoint["updated_at_epoch"] = time.time()
self._write_checkpoint(checkpoint_path, checkpoint)
class JobRecorder(ChatRecorder):
def create(inner_self, *, purpose: str, **request: Any) -> Any:
try:
return super(JobRecorder, inner_self).create(purpose=purpose, **request)
finally:
persist_calls()
writer = JobRecorder(self.writer_client, "ark", self.args.writer_endpoint)
judge = JobRecorder(self.judge_client, "moonshot", self.args.judge_endpoint)
writer.calls = list(checkpoint.get("writer_calls", []))
judge.calls = list(checkpoint.get("judge_calls", []))
states = list(checkpoint.get("memory_states", []))
memory: Any = states[-1]["memory"] if states else initial_memory(mode)
for index, history in enumerate(case["conversation_histories"], start=1):
if index <= len(states):
continue
messages = memory_prompt(mode, memory, history, index)
purpose = f"3-1/3-2 memory update {case['test_id']} {mode} session {index}"
prior_call = self._successful_call(writer.calls, purpose)
if prior_call:
content = self._content_from_call(prior_call)
else:
response = writer.create(
purpose=purpose,
model=self.args.writer_model,
messages=messages,
temperature=0,
seed=self.args.seed,
max_tokens=self.args.memory_max_tokens,
response_format={"type": "json_object"},
)
content = response.choices[0].message.content
parsed = parse_json(content)
memory = parsed.get("memory", parsed)
states.append(
{
"session_index": index,
"conversation_id": history.get("conversation_id"),
"memory": memory,
"isolation": {
"prior_raw_histories_supplied": 0,
"current_memory_supplied": True,
"new_history_supplied": history.get("conversation_id"),
},
}
)
checkpoint["memory_states"] = states
persist_calls()
answer_purpose = f"3-1/3-2 answer {case['test_id']} {mode}"
answer_call = self._successful_call(writer.calls, answer_purpose)
if answer_call:
answer = self._content_from_call(answer_call) or ""
else:
answer_response = writer.create(
purpose=answer_purpose,
model=self.args.writer_model,
messages=answer_prompt(mode, memory, case["user_question"]),
temperature=0,
seed=self.args.seed,
max_tokens=self.args.answer_max_tokens,
)
answer = answer_response.choices[0].message.content or ""
checkpoint["answer"] = answer
persist_calls()
judge_purpose = f"3-1/3-2 independent judge {case['test_id']} {mode}"
prior_judge = self._successful_call(judge.calls, judge_purpose)
if prior_judge:
judge_content = self._content_from_call(prior_judge)
else:
judge_response = judge.create(
purpose=judge_purpose,
model=self.args.judge_model,
messages=judge_prompt(case, answer),
temperature=0,
seed=self.args.seed,
max_tokens=self.args.judge_max_tokens,
response_format={"type": "json_object"},
)
judge_content = judge_response.choices[0].message.content
judge_raw = parse_json(judge_content)
result = {
"test_id": case["test_id"],
"layer": case["category"],
"title": case["title"],
"mode": mode,
"session_count": len(case["conversation_histories"]),
"memory_states": states,
"answer": answer,
"judge": judge_summary(judge_raw),
"judge_raw": judge_raw,
}
checkpoint["status"] = "completed"
checkpoint["result"] = result
persist_calls()
result["_receipts"] = writer.calls + judge.calls
result["_resumed"] = False
return result
def aggregate(results: List[Dict[str, Any]]) -> Dict[str, Any]:
groups: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(lambda: defaultdict(list))
for result in results:
groups[result["mode"]][result["layer"]].append(result)
output: Dict[str, Any] = {}
for mode, layers in groups.items():
output[mode] = {}
all_rows = []
for layer, rows in sorted(layers.items()):
all_rows.extend(rows)
output[mode][layer] = {
"n": len(rows),
"pass_rate": sum(r["judge"]["passed"] for r in rows) / len(rows),
"mean_reward": statistics.mean(r["judge"]["reward"] for r in rows),
"hallucination_rate": sum(r["judge"]["hallucination_veto"] for r in rows) / len(rows),
}
output[mode]["overall"] = {
"n": len(all_rows),
"pass_rate": sum(r["judge"]["passed"] for r in all_rows) / len(all_rows),
"mean_reward": statistics.mean(r["judge"]["reward"] for r in all_rows),
"hallucination_rate": sum(r["judge"]["hallucination_veto"] for r in all_rows) / len(all_rows),
}
return output
def token_totals(calls: Iterable[Dict[str, Any]]) -> Dict[str, int]:
totals = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
for call in calls:
usage = call.get("usage") or {}
for key in totals:
totals[key] += int(usage.get(key) or 0)
return totals
def mode_call_stats(calls: List[Dict[str, Any]]) -> Dict[str, Any]:
output = {}
for mode in MODES:
selected = [call for call in calls if f" {mode}" in str(call.get("purpose", ""))]
latencies = [float(call.get("latency_ms") or 0) for call in selected]
output[mode] = {
"api_calls": len(selected),
"token_usage": token_totals(selected),
"latency_ms": {
"total": sum(latencies),
"mean_per_call": statistics.mean(latencies) if latencies else 0,
},
}
return output
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Live sequential memory comparison for Experiments 3-1/3-2")
parser.add_argument("--all", action="store_true", help="run all 60 cases (authoritative campaign)")
parser.add_argument("--case", action="append", help="run a specific test id (repeatable)")
parser.add_argument("--per-layer", type=int, default=2, help="default smoke cases per layer")
parser.add_argument("--mode", action="append", choices=MODES, help="memory mode (default: all four)")
parser.add_argument("--workers", type=int, default=4)
parser.add_argument("--seed", type=int, default=37)
parser.add_argument("--writer-model", default=os.getenv("ARK_MODEL", "doubao-seed-1-6-250615"))
parser.add_argument("--judge-model", default=os.getenv("MEMORY_JUDGE_MODEL", "moonshot-v1-32k"))
parser.add_argument("--writer-endpoint", default=ARK_ENDPOINT)
parser.add_argument("--judge-endpoint", default=MOONSHOT_ENDPOINT)
parser.add_argument("--timeout", type=float, default=180)
parser.add_argument("--memory-max-tokens", type=int, default=6000)
parser.add_argument("--answer-max-tokens", type=int, default=1200)
parser.add_argument("--judge-max-tokens", type=int, default=1800)
parser.add_argument(
"--checkpoint-dir",
type=Path,
default=HERE / "validation" / "checkpoints" / "full-60x4",
help="per-case/mode resumable raw-call checkpoints",
)
parser.add_argument(
"--test-cases-dir",
type=Path,
default=CHAPTER / "user-memory-evaluation" / "test_cases",
)
return parser
def main() -> int:
args = build_parser().parse_args()
cases = load_cases(args.test_cases_dir.resolve(), args)
modes = tuple(args.mode or MODES)
expected_total = len(cases) * len(modes)
print(f"Running {len(cases)} cases × {len(modes)} modes = {expected_total} evaluations")
campaign = Campaign(args)
results = []
calls: List[Dict[str, Any]] = []
errors = []
jobs = [(case, mode) for case in cases for mode in modes]
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
future_map = {pool.submit(campaign.run_one, case, mode): (case["test_id"], mode) for case, mode in jobs}
for future in concurrent.futures.as_completed(future_map):
test_id, mode = future_map[future]
try:
result = future.result()
calls.extend(result.pop("_receipts", []))
resumed = result.pop("_resumed", False)
results.append(result)
marker = "resumed" if resumed else "live"
print(f"[{len(results)}/{expected_total}] {test_id} {mode}: reward={result['judge']['reward']:.3f} ({marker})")
except Exception as exc:
errors.append({"test_id": test_id, "mode": mode, "type": type(exc).__name__, "error": str(exc)})
print(f"[ERROR] {test_id} {mode}: {exc}", file=sys.stderr)
results.sort(key=lambda r: (r["test_id"], r["mode"]))
full_suite = (
len(cases) == 60
and set(modes) == set(MODES)
and len(results) == 240
and not errors
)
status = "passed" if full_suite else ("partial" if results else "blocked")
isolation_ok = all(
state["isolation"]["prior_raw_histories_supplied"] == 0
for result in results
for state in result["memory_states"]
)
evidence = {
"status": status,
"scope": {
"dataset_cases_available": len(list(args.test_cases_dir.glob("layer*/*.yaml"))),
"cases_run": len(cases),
"modes": list(modes),
"evaluations_completed": len(results),
"evaluations_expected": expected_total,
"layers": sorted({c["category"] for c in cases}),
},
"configuration": {
"writer_provider": "ark",
"writer_endpoint": args.writer_endpoint,
"writer_model": args.writer_model,
"writer_seed": args.seed,
"judge_provider": "moonshot",
"judge_endpoint": args.judge_endpoint,
"judge_model": args.judge_model,
"judge_is_external_to_writer": True,
"workers": args.workers,
"memory_max_tokens": args.memory_max_tokens,
"answer_max_tokens": args.answer_max_tokens,
"judge_max_tokens": args.judge_max_tokens,
},
"acceptance": {
"all_60_cases": len(cases) == 60,
"twenty_per_layer": all(sum(c["category"] == layer for c in cases) == 20 for layer in ("layer1", "layer2", "layer3")),
"all_four_modes": set(modes) == set(MODES),
"sequential_memory_only": isolation_ok,
"independent_llm_judge": True,
"all_calls_succeeded": not errors,
"passed": full_suite and isolation_ok,
},
"summary": {
"aggregate": aggregate(results) if results else {},
"token_usage": token_totals(calls),
"by_mode": mode_call_stats(calls),
"api_calls": len(calls),
"errors": len(errors),
},
"errors": errors,
"results": results,
}
manifest = write_campaign_evidence(
HERE,
"3-1-and-3-2",
evidence,
calls,
input_paths=[HERE / "run_evaluation.py", *[c["_path"] for c in cases]],
)
print(json.dumps(manifest["summary"], ensure_ascii=False, indent=2))
print(f"Canonical evidence: {HERE / 'validation' / 'latest.json'}")
return 0 if not errors else 1
if __name__ == "__main__":
raise SystemExit(main())
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# User Memory System Setup Script
echo "=========================================="
echo "User Memory System - Setup"
echo "=========================================="
# Check Python version
python_version=$(python3 --version 2>&1 | awk '{print $2}')
echo "✓ Python version: $python_version"
# Create virtual environment if it doesn't exist
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
python3 -m venv venv
echo "✓ Virtual environment created"
else
echo "✓ Virtual environment already exists"
fi
# Activate virtual environment
source venv/bin/activate
echo "✓ Virtual environment activated"
# Install requirements
echo "Installing dependencies..."
pip install -q --upgrade pip
pip install -q -r requirements.txt
echo "✓ Dependencies installed"
# Create .env file if it doesn't exist
if [ ! -f ".env" ]; then
cp env.example .env
echo "✓ Created .env file from template"
echo ""
echo "⚠️ IMPORTANT: Please edit .env and add your MOONSHOT_API_KEY"
echo " Get your API key from: https://platform.moonshot.cn/"
else
echo "✓ .env file already exists"
fi
# Create necessary directories
mkdir -p data/memories
mkdir -p data/conversations
mkdir -p data/locomo
mkdir -p results/locomo
mkdir -p logs
echo "✓ Created necessary directories"
# Run tests
echo ""
echo "Running system tests..."
python test_memory_system.py
echo ""
echo "=========================================="
echo "Setup Complete!"
echo "=========================================="
echo ""
echo "Next steps:"
echo "1. Edit .env and add your MOONSHOT_API_KEY"
echo "2. Run: python quickstart.py"
echo "3. Run: python main.py interactive <your_name>"
echo ""
echo "For more information, see README.md"
@@ -0,0 +1,87 @@
"""
Regression test for https://github.com/bojieli/ai-agent-book/issues/181
BackgroundMemoryProcessor entered an infinite processing loop because:
1. Its ConversationHistory instance loaded the history file once at startup
and never reloaded, so turns saved by the main agent's separate instance
were invisible -> process_recent_conversations() always returned early.
2. On that early return, last_processed_count was never updated, so
should_process() stayed True and the background thread re-triggered
every second forever.
This test simulates the interactive-mode flow without any API calls.
"""
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
# Use an isolated data dir and a dummy API key before importing project modules
_tmpdir = tempfile.mkdtemp(prefix="user_memory_test_")
os.environ["CONVERSATION_HISTORY_DIR"] = os.path.join(_tmpdir, "conversations")
os.environ.setdefault("MOONSHOT_API_KEY", "test-dummy-key")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import Config
Config.create_directories()
from conversation_history import ConversationHistory
from background_memory_processor import BackgroundMemoryProcessor
class TestBackgroundProcessorLoop(unittest.TestCase):
def setUp(self):
self.user_id = "test_user_loop"
history_file = os.path.join(
os.environ["CONVERSATION_HISTORY_DIR"], f"{self.user_id}_history.json"
)
if os.path.exists(history_file):
os.remove(history_file)
def _make_processor(self):
processor = BackgroundMemoryProcessor(
user_id=self.user_id, provider="kimi", verbose=False
)
# Avoid real LLM calls: analysis is a no-op
processor.analyze_conversation = lambda ctx: []
return processor
def test_new_turns_from_other_instance_are_seen(self):
"""Processor must see turns saved by a separate ConversationHistory."""
processor = self._make_processor()
# Simulate the main agent saving a turn through its own instance
agent_history = ConversationHistory(self.user_id)
agent_history.add_turn("session-1", "你好,我是小明", "你好小明!")
processor.increment_conversation_count()
self.assertTrue(processor.should_process())
results = processor.process_recent_conversations()
self.assertEqual(results.get("analyzed_turns"), 1)
self.assertEqual(processor.last_processed_count, 1)
self.assertFalse(processor.should_process())
def test_no_infinite_loop_when_nothing_new(self):
"""should_process() must go False after a no-op processing run."""
processor = self._make_processor()
processor.increment_conversation_count()
results = processor.process_recent_conversations()
self.assertIn("message", results) # early return: nothing to process
self.assertFalse(
processor.should_process(),
"should_process() stayed True after no-op run -> infinite loop",
)
# And it must trigger again once a new conversation arrives
processor.increment_conversation_count()
self.assertTrue(processor.should_process())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,31 @@
import pytest
from memory_manager import NotesMemoryManager, MemoryNote
def test_consolidate_memories_preserves_earliest_created_at():
"""Verify deduplicating identical memory notes retains the earliest created_at timestamp."""
mgr = NotesMemoryManager(user_id="test_created_at_user")
mgr.notes = [
MemoryNote(
note_id="note_old",
content="User prefers Python for AI development",
session_id="s1",
created_at="2026-01-01T10:00:00",
updated_at="2026-01-01T10:00:00",
tags=["pref"]
),
MemoryNote(
note_id="note_new",
content="User prefers Python for AI development",
session_id="s2",
created_at="2026-01-10T15:00:00",
updated_at="2026-01-10T15:00:00",
tags=["language"]
)
]
report = mgr.consolidate_memories(resolve_conflicts=False)
assert len(mgr.notes) == 1
assert mgr.notes[0].created_at == "2026-01-01T10:00:00"
assert mgr.notes[0].updated_at == "2026-01-10T15:00:00"
assert mgr.notes[0].tags == ["language", "pref"]
@@ -0,0 +1,63 @@
"""Regression coverage for evaluation menu memory display."""
import builtins
import types
from pathlib import Path
def test_evaluation_option_two_prints_memory_manager_context(monkeypatch, capsys):
monkeypatch.syspath_prepend(str(Path(__file__).parent))
import main
from config import MemoryMode
class FakeTestSuite:
test_cases = [object()]
class FakeFramework:
def __init__(self):
self.test_suite = FakeTestSuite()
fake_evaluation_modules = {
"config": types.SimpleNamespace(),
"models": types.SimpleNamespace(),
"evaluator": types.SimpleNamespace(),
"framework": types.SimpleNamespace(UserMemoryEvaluationFramework=FakeFramework),
}
real_import = builtins.__import__
def import_fake_evaluation_module(name, globals=None, locals=None, fromlist=(), level=0):
if level == 0 and name in fake_evaluation_modules:
return fake_evaluation_modules[name]
return real_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", import_fake_evaluation_module)
class FakeMemoryManager:
def get_context_string(self):
return "User Memory Notes:\n\nNote 1: Prefers Python"
class FakeConversationHistory:
def __init__(self):
self.conversations = []
class FakeAgent:
def __init__(self, *args, **kwargs):
self.memory_manager = FakeMemoryManager()
self.conversation_history = FakeConversationHistory()
self.conversation = []
class FakeProcessor:
def __init__(self, *args, **kwargs):
self.memory_manager = FakeMemoryManager()
inputs = iter(["2", "4"])
monkeypatch.setattr(main.Config, "get_api_key", lambda provider: "test-key")
monkeypatch.setattr(main, "ConversationalAgent", FakeAgent)
monkeypatch.setattr(main, "BackgroundMemoryProcessor", FakeProcessor)
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
main.run_evaluation_mode("default_user", MemoryMode.NOTES, provider="moonshot", model="test-model")
output = capsys.readouterr().out
assert "Current Memory State" in output
assert "Note 1: Prefers Python" in output
@@ -0,0 +1,16 @@
import pytest
from memory_operation_formatter import format_memory_operations
def test_format_memory_operations_includes_memory_id_when_content_present():
operations = [
{
"action": "update",
"memory_id": "mem_101",
"content": "User prefers dark mode.",
"reason": "User updated preference",
}
]
result = format_memory_operations(operations)
assert "Memory ID: mem_101" in result
assert "Content: User prefers dark mode." in result
@@ -0,0 +1,37 @@
"""Regression test for JSON cards tool payload parsing."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from agent import UserMemoryAgent
from config import MemoryMode
def test_json_cards_add_memory_parses_stringified_json():
captured = {}
class FakeMemoryManager:
def add_memory(self, content, session_id, **kwargs):
captured["content"] = content
captured["session_id"] = session_id
return "banking.first_national.checking_account_number"
agent = UserMemoryAgent.__new__(UserMemoryAgent)
agent.config = type("Cfg", (), {"memory_mode": MemoryMode.JSON_CARDS})()
agent.memory_manager = FakeMemoryManager()
agent.session_id = "session-test"
result = agent._tool_add_memory(
content='{"category": "banking", "subcategory": "first_national", "key": "checking_account_number", "value": "4429853327"}'
)
assert captured["session_id"] == "session-test"
assert captured["content"] == {
"category": "banking",
"subcategory": "first_national",
"key": "checking_account_number",
"value": "4429853327",
}
assert result["success"] is True
assert result["memory_id"] == "banking.first_national.checking_account_number"
@@ -0,0 +1,43 @@
"""Regression tests: os.makedirs(os.path.dirname(p)) must not raise
FileNotFoundError when p is a bare filename with no directory component
(e.g. LOG_FILE=debug.log, or an empty storage dir env var)."""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import Config
from conversation_history import ConversationHistory
from memory_manager import NotesMemoryManager
def test_create_directories_with_bare_log_file(tmp_path, monkeypatch):
"""LOG_FILE='debug.log' (no dir) used to crash create_directories()."""
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(Config, "LOG_FILE", "debug.log")
monkeypatch.setattr(Config, "MEMORY_STORAGE_DIR", str(tmp_path / "mem"))
monkeypatch.setattr(Config, "CONVERSATION_HISTORY_DIR", str(tmp_path / "conv"))
monkeypatch.setattr(Config, "LOCOMO_OUTPUT_DIR", str(tmp_path / "locomo"))
Config.create_directories() # must not raise
def test_save_memory_with_empty_storage_dir(tmp_path, monkeypatch):
"""MEMORY_STORAGE_DIR='' makes memory_file a bare filename; save must persist."""
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(Config, "MEMORY_STORAGE_DIR", "")
mgr = NotesMemoryManager("u1")
assert mgr.memory_file == "u1_memory.json"
mgr.add_memory("favorite color is blue", session_id="s1")
assert (tmp_path / "u1_memory.json").exists()
def test_save_history_with_empty_history_dir(tmp_path, monkeypatch):
"""CONVERSATION_HISTORY_DIR='' makes history_file a bare filename; save must persist."""
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(Config, "CONVERSATION_HISTORY_DIR", "")
hist = ConversationHistory("u1")
assert hist.history_file == "u1_history.json"
hist.add_turn("s1", "hi", "hello")
assert (tmp_path / "u1_history.json").exists()
@@ -0,0 +1,31 @@
"""get_recent_turns(limit=0) must return [], not the full history."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import Config
from conversation_history import ConversationHistory, ConversationTurn
def test_limit_zero_returns_empty(tmp_path, monkeypatch):
monkeypatch.setattr(Config, "CONVERSATION_HISTORY_DIR", str(tmp_path))
monkeypatch.setattr(Config, "ENABLE_HISTORY_SEARCH", False)
hist = ConversationHistory("u1")
hist.conversations = [
ConversationTurn("s", "u0", "a0", "t0", 1),
ConversationTurn("s", "u1", "a1", "t1", 2),
ConversationTurn("s", "u2", "a2", "t2", 3),
]
assert hist.get_recent_turns(0) == []
def test_positive_limit_still_returns_recent(tmp_path, monkeypatch):
monkeypatch.setattr(Config, "CONVERSATION_HISTORY_DIR", str(tmp_path))
monkeypatch.setattr(Config, "ENABLE_HISTORY_SEARCH", False)
hist = ConversationHistory("u1")
t1 = ConversationTurn("s", "u1", "a1", "t1", 1)
t2 = ConversationTurn("s", "u2", "a2", "t2", 2)
t3 = ConversationTurn("s", "u3", "a3", "t3", 3)
hist.conversations = [t1, t2, t3]
assert hist.get_recent_turns(2) == [t2, t3]
@@ -0,0 +1,38 @@
"""Regression test for ConversationHistory.search_history limit=0 handling.
Proves contract: Requesting limit<=0 from search_history returns an empty list
without executing text or vector search.
Locks out bug where limit=0 appended the first match before breaking, returning 1 result instead of 0.
"""
import sys
from pathlib import Path
SRC = Path(__file__).resolve().parent
sys.path.insert(0, str(SRC))
from conversation_history import ConversationHistory # noqa: E402
from config import Config # noqa: E402
def make_history(tmp_path, monkeypatch):
monkeypatch.setattr(Config, "CONVERSATION_HISTORY_DIR", str(tmp_path))
monkeypatch.setattr(Config, "ENABLE_HISTORY_SEARCH", False)
return ConversationHistory("user1")
def test_search_history_limit_zero_returns_empty_list(tmp_path, monkeypatch):
history = make_history(tmp_path, monkeypatch)
history.add_turn("session-1", "Hello assistant", "Hello user")
history.add_turn("session-1", "Search for something", "Here is your result")
results = history.search_history("hello", limit=0)
assert results == []
def test_search_history_limit_negative_returns_empty_list(tmp_path, monkeypatch):
history = make_history(tmp_path, monkeypatch)
history.add_turn("session-1", "Hello assistant", "Hello user")
results = history.search_history("hello", limit=-1)
assert results == []
@@ -0,0 +1,81 @@
"""Regression tests for issue #493's cross-session history leak."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from conversational_agent import ConversationConfig, ConversationalAgent
from conversation_history import ConversationHistory, ConversationTurn
class StubMemoryManager:
def __init__(self, context: str):
self.context = context
self.load_count = 0
def load_memory(self):
self.load_count += 1
def get_context_string(self) -> str:
return self.context
def make_agent(history: ConversationHistory, session_id: str):
"""Build the prompt-only portion of an agent without an API client."""
agent = ConversationalAgent.__new__(ConversationalAgent)
agent.config = ConversationConfig()
agent.memory_manager = StubMemoryManager("Preferred editor: VS Code")
agent.conversation_history = history
agent.session_id = session_id
return agent
def test_memory_context_excludes_raw_turns_from_previous_sessions():
history = ConversationHistory.__new__(ConversationHistory)
history.conversations = [
ConversationTurn(
"session-old",
"My private detail from the old session",
"I will remember that detail",
"2026-07-28T10:00:00",
1,
),
ConversationTurn(
"session-current",
"What did we discuss in this session?",
"We discussed the current task",
"2026-07-29T10:00:00",
2,
),
]
agent = make_agent(history, "session-current")
context = agent._get_memory_context()
assert "Preferred editor: VS Code" in context
assert "=== CURRENT SESSION HISTORY ===" in context
assert "What did we discuss in this session?" in context
assert "My private detail from the old session" not in context
assert "session-old" not in context
def test_new_session_uses_structured_memory_without_old_raw_history():
history = ConversationHistory.__new__(ConversationHistory)
history.conversations = [
ConversationTurn(
"session-old",
"I work at TechCorp",
"Thanks for sharing",
"2026-07-28T10:00:00",
1,
)
]
agent = make_agent(history, "session-new")
context = agent._get_memory_context()
assert "Preferred editor: VS Code" in context
assert "I work at TechCorp" not in context
assert "CURRENT SESSION HISTORY" not in context
assert agent.memory_manager.load_count == 1
@@ -0,0 +1,64 @@
"""Regression tests for providers that end streams with ``choices=[]``."""
import os
import sys
from types import SimpleNamespace
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from agent import UserMemoryAgent
from conversational_agent import ConversationConfig, ConversationalAgent
class StubCompletions:
def create(self, **kwargs):
return [
SimpleNamespace(
choices=[
SimpleNamespace(delta=SimpleNamespace(content="Hello"))
]
),
SimpleNamespace(choices=[]),
]
def stub_client():
return SimpleNamespace(
chat=SimpleNamespace(completions=StubCompletions())
)
def test_conversational_agent_ignores_empty_choices_chunk():
agent = ConversationalAgent.__new__(ConversationalAgent)
agent.config = ConversationConfig(
enable_memory_context=False,
enable_conversation_history=False,
)
agent.verbose = False
agent.model = "test-model"
agent.client = stub_client()
agent.conversation = []
agent.conversation_history = None
agent.session_id = "session-test"
assert agent.chat("Hi") == "Hello"
assert agent.conversation[-1] == {
"role": "assistant",
"content": "Hello",
}
def test_user_memory_agent_ignores_empty_choices_chunk():
agent = UserMemoryAgent.__new__(UserMemoryAgent)
agent._get_memory_context = lambda: ""
agent.model = "test-model"
agent.client = stub_client()
agent.conversation = []
agent.conversation_history = None
agent.session_id = "session-test"
assert agent._chat_stream("Hi") == "Hello"
assert agent.conversation[-1] == {
"role": "assistant",
"content": "Hello",
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More