-
-
- 马 上 有 钱
- |
-
-
- |
-
- 马 到 成 功
- |
-
+
+| Category | Method | Function | Key components |
+| Context Management | check_context | 📊 Check context size | ContextChecker — checks whether context exceeds thresholds and splits messages |
+compact_memory | 📦 Compact history into summary | Compactor — ReActAgent that generates structured context summaries |
+compact_tool_result | ✂️ Compact long tool outputs | ToolResultCompactor — truncates long tool outputs and stores them in tool_result/ while keeping file references in messages |
+pre_reasoning_hook | 🔄 Pre-reasoning hook | compact_tool_result + check_context + compact_memory + summary_memory (async) |
+| Long-term Memory | summary_memory | 📝 Persist important memory to files | Summarizer — ReActAgent + file tools (read / write / edit) |
+memory_search | 🔍 Semantic memory search | MemorySearch — hybrid retrieval with vectors + BM25 |
+| - | start | 🚀 Start memory system | Initialize file storage, file watcher, and embedding cache; clean up expired tool result files |
+| - | close | 📕 Shutdown and cleanup | Clean up tool result files, stop file watcher, and persist embedding cache |
-### When Is Memory Written?
-
-| Scenario | Written to | Trigger |
-|---------------------------------------------|------------------------|------------------------------------|
-| Auto-compact when context is too long | `memory/YYYY-MM-DD.md` | Automatic in background |
-| User runs `/compact` | `memory/YYYY-MM-DD.md` | Manual compact + background save |
-| User runs `/new` | `memory/YYYY-MM-DD.md` | New conversation + background save |
-| User says "remember this" | `MEMORY.md` or log | Agent writes via `write` tool |
-| Agent finds important decisions/preferences | `MEMORY.md` | Agent writes proactively |
-
-### Memory Retrieval Tools
-
-| Method | Tool | When to use | Example |
-|-----------------|-----------------|----------------------------------|---------------------------------------|
-| Semantic search | `memory_search` | Unsure where it is, fuzzy lookup | "Earlier discussion about deployment" |
-| Direct read | `read` | Know the date or file | Read `memory/2025-02-13.md` |
-
-Search uses **vector + BM25 hybrid retrieval** (vector weight 0.7, BM25 weight 0.3), so queries using both natural
-language and exact
-keywords can match.
-
-### Built-in Tools
-
-| Tool | Function | Details |
-|-----------------|----------------|------------------------------------------------------------|
-| `memory_search` | Search memory | Vector + BM25 hybrid search over MEMORY.md and memory/*.md |
-| `bash` | Run commands | Execute bash commands with timeout and output truncation |
-| `ls` | List directory | Show directory structure |
-| `read` | Read file | Text and images supported, with segmented reading |
-| `edit` | Edit file | Replace after exact text match |
-| `write` | Write file | Create or overwrite, auto-create directories |
-| `execute_code` | Run Python | Execute code snippets |
-| `web_search` | Web search | Search via Tavily |
-
---
-## 🚀 Quick Start
+### 🚀 Quick start
-### Installation
+#### Installation
+
+**Install from source:**
```bash
-pip install -U reme-ai
+git clone https://github.com/agentscope-ai/ReMe.git
+cd ReMe
+pip install -e ".[light]"
```
-### Environment Variables
-
-API keys are set via environment variables; you can put them in a `.env` file in the project root:
-
-| Variable | Description | Example |
-|---------------------------|----------------------------------|-----------------------------------------------------|
-| `REME_LLM_API_KEY` | LLM API key | `sk-xxx` |
-| `REME_LLM_BASE_URL` | LLM base URL | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
-| `REME_EMBEDDING_API_KEY` | Embedding API key | `sk-xxx` |
-| `REME_EMBEDDING_BASE_URL` | Embedding base URL | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
-| `TAVILY_API_KEY` | Tavily search API key (optional) | `tvly-xxx` |
-
-### Using ReMeCli
-
-#### Start ReMeCli
+**Update to the latest version:**
```bash
-remecli config=cli
+git pull
+pip install -e ".[light]"
```
-#### ReMeCli System Commands
+#### Environment variables
-> Year of the Horse easter egg: `/horse` — fireworks, galloping animation, and random horse-year blessings.
+`ReMeLight` uses environment variables to configure the embedding model and storage backends:
-Commands starting with `/` control session state:
+| Variable | Description | Example |
+|----------------------|-------------------------------|-----------------------------------------------------|
+| `LLM_API_KEY` | LLM API key | `sk-xxx` |
+| `LLM_BASE_URL` | LLM base URL | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
+| `EMBEDDING_API_KEY` | Embedding API key (optional) | `sk-xxx` |
+| `EMBEDDING_BASE_URL` | Embedding base URL (optional) | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
-| Command | Description | Waits for response |
-|------------|--------------------------------------------------------------------|--------------------|
-| `/compact` | Manually compact current conversation and save to long-term memory | Yes |
-| `/new` | Start new conversation; history saved to long-term memory | No |
-| `/clear` | Clear everything, **without saving** | No |
-| `/history` | View uncompressed messages in current conversation | No |
-| `/help` | Show command list | No |
-| `/exit` | Exit | No |
-
-**Difference between the three commands**
-
-| Command | Compact summary | Long-term memory | Message history |
-|------------|-----------------|------------------|-----------------|
-| `/compact` | New summary | Saved | Keep recent |
-| `/new` | Cleared | Saved | Cleared |
-| `/clear` | Cleared | Not saved | Cleared |
-
-> `/clear` permanently deletes; nothing is persisted anywhere.
-
-### Using the ReMe Package
-
-#### File-Based ReMe
+#### Python usage
```python
import asyncio
-from reme import ReMeFb
+from reme.reme_light import ReMeLight
async def main():
- # Initialize and start
- reme = ReMeFb(
- default_llm_config={
- "backend": "openai", # Backend type, OpenAI-compatible API
- "model_name": "qwen3.5-plus", # Model name
- },
- default_file_store_config={
- "backend": "chroma", # Store backend: sqlite/chroma/local
- "fts_enabled": True, # Enable full-text search
- "vector_enabled": False, # Enable vector search (set False if no embedding service)
- },
- context_window_tokens=128000, # Model context window size (tokens)
- reserve_tokens=36000, # Tokens reserved for output
- keep_recent_tokens=20000, # Tokens to keep for recent messages
- vector_weight=0.7, # Vector search weight (0–1) for hybrid search
- candidate_multiplier=3.0, # Candidate multiplier for recall
+ # Initialize ReMeLight
+ reme = ReMeLight(
+ default_as_llm_config={"model_name": "qwen3.5-35b-a3b"},
+ # default_embedding_model_config={"model_name": "text-embedding-v4"},
+ default_file_store_config={"fts_enabled": True, "vector_enabled": False},
)
await reme.start()
- messages = [
- {"role": "user", "content": "I prefer Python 3.12"},
- {"role": "assistant", "content": "Noted, you prefer Python 3.12"},
- ]
+ messages = [...] # List of conversation messages
- # Check if context exceeds limit
- result = await reme.context_check(messages)
- print(f"Compact result: {result}")
+ # 1. Compact long tool outputs (prevent tool results from blowing up context)
+ messages = await reme.compact_tool_result(messages)
- # Compact conversation to summary
- summary = await reme.compact(messages_to_summarize=messages)
- print(f"Summary: {summary}")
+ # 2. Compact conversation history into a structured summary
+ summary = await reme.compact_memory(
+ messages=messages,
+ previous_summary="",
+ max_input_length=128000, # Model context window (tokens)
+ compact_ratio=0.7, # Trigger compaction when exceeding max_input_length * 0.7
+ language="zh", # Summary language (e.g., "zh" / "")
+ )
- # Write important memory to files (ReAct Agent does this automatically)
- await reme.summary(messages=messages, date="2026-02-28")
+ # 3. Submit summary task asynchronously (non-blocking, writes to memory/YYYY-MM-DD.md)
+ reme.add_async_summary_task(messages=messages)
- # Semantic search over memory
- results = await reme.memory_search(query="Python version preference", max_results=5)
- print(f"Search results: {results}")
+ # 4. Pre-reasoning hook (auto compact tool results + generate summaries)
+ processed_messages, compressed_summary = await reme.pre_reasoning_hook(
+ messages=messages,
+ system_prompt="You are a helpful AI assistant.",
+ compressed_summary="",
+ max_input_length=128000,
+ compact_ratio=0.7,
+ memory_compact_reserve=10000,
+ enable_tool_result_compact=True,
+ tool_result_compact_keep_n=3,
+ )
- # Read specified memory file
- content = await reme.memory_get(path="MEMORY.md")
- print(f"Memory content: {content}")
+ # 5. Semantic memory search (vector + BM25 hybrid retrieval)
+ result = await reme.memory_search(query="Python version preference", max_results=5)
- # Close (save embedding cache, stop file watcher)
+ # 6. Create in-session memory instance (manages context for one conversation)
+ from reme.memory.file_based.reme_in_memory_memory import ReMeInMemoryMemory
+ memory = ReMeInMemoryMemory()
+ for msg in messages:
+ await memory.add(msg)
+ token_stats = await memory.estimate_tokens(max_input_length=128000)
+ print(f"Current context usage: {token_stats['context_usage_ratio']:.1f}%")
+ print(f"Message token count: {token_stats['messages_tokens']}")
+ print(f"Estimated total tokens: {token_stats['estimated_tokens']}")
+
+ # 7. Wait for background summary tasks to complete before shutdown
+ summary_result = await reme.await_summary_tasks()
+
+ # Shutdown ReMeLight
await reme.close()
@@ -265,10 +197,230 @@ if __name__ == "__main__":
asyncio.run(main())
```
-#### Vector-Based ReMe
+> 📂 Full example: [test_reme_light.py](tests/light/test_reme_light.py)
+> 📋 Sample run log: [test_reme_light_log.txt](tests/light/test_reme_light_log.txt) (223,838 tokens → 1,105 tokens, 99.5%
+> compression)
+
+### Architecture of the file-based ReMeLight memory system
+
+[CoPaw MemoryManager](https://github.com/agentscope-ai/CoPaw/blob/main/src/copaw/agents/memory/memory_manager.py)
+inherits
+`ReMeLight` and integrates its memory capabilities into the agent reasoning loop:
+
+```mermaid
+graph LR
+ Agent[Agent] -->|Before each reasoning step| Hook[pre_reasoning_hook]
+ Hook --> TC[compact_tool_result
Compact tool outputs]
+ TC --> CC[check_context
Token counting]
+ CC -->|Exceeds limit| CM[compact_memory
Generate summary]
+ CC -->|Exceeds limit| SM[summary_memory
Async persistence]
+ SM -->|ReAct + FileIO| Files[memory/*.md]
+ Agent -->|Explicit call| Search[memory_search
Vector+BM25]
+ Agent -->|In - session| InMem[ReMeInMemoryMemory
Token-aware memory]
+ Files -.->|FileWatcher| Store[(FileStore
Vector+FTS index)]
+ Search --> Store
+```
+
+---
+
+#### 1. `check_context` — context checking
+
+[ContextChecker](reme/memory/file_based/components/context_checker.py) uses token counting to determine whether the
+context exceeds thresholds and automatically splits messages into a "to compact" group and a "to keep" group.
+
+```mermaid
+graph LR
+ M[messages] --> H[AsMsgHandler
Token counting]
+ H --> C{total > threshold?}
+ C -->|No| K[Return all messages]
+ C -->|Yes| S[Keep from tail
reserve tokens]
+ S --> CP[messages_to_compact
Earlier messages]
+ S --> KP[messages_to_keep
Recent messages]
+ S --> V{is_valid
Tool calls aligned?}
+```
+
+- **Core logic**: keep `reserve` tokens from the tail; mark the rest as messages to compact.
+- **Integrity guarantee**: preserves complete user-assistant turns and tool_use/tool_result pairs without splitting
+ them.
+
+---
+
+#### 2. `compact_memory` — conversation compaction
+
+[Compactor](reme/memory/file_based/components/compactor.py) uses a ReActAgent to compact conversation history into a *
+*structured context summary**.
+
+```mermaid
+graph LR
+ M[messages] --> H[AsMsgHandler
format_msgs_to_str]
+ H --> A[ReActAgent
reme_compactor]
+ P[previous_summary] -->|Incremental update| A
+ A --> S[Structured summary
Goal/Progress/Decisions...]
+```
+
+**Summary structure** (context checkpoints):
+
+| Field | Description |
+|-----------------------|------------------------------------------------------------------------|
+| `## Goal` | User goals |
+| `## Constraints` | Constraints and preferences |
+| `## Progress` | Task progress |
+| `## Key Decisions` | Key decisions |
+| `## Next Steps` | Next step plans |
+| `## Critical Context` | Critical data such as file paths, function names, error messages, etc. |
+
+- **Incremental updates**: when `previous_summary` is provided, new conversations are merged into the existing summary.
+
+---
+
+#### 3. `summary_memory` — persistent memory
+
+[Summarizer](reme/memory/file_based/components/summarizer.py) uses a **ReAct + file tools** pattern so that the AI can
+decide what to write and where to write it.
+
+```mermaid
+graph LR
+ M[messages] --> A[ReActAgent
reme_summarizer]
+ A -->|read| R[Read memory/YYYY-MM-DD.md]
+ R --> T{Reason: how to merge?}
+ T -->|write| W[Overwrite]
+ T -->|edit| E[Edit in place]
+ W --> F[memory/YYYY-MM-DD.md]
+ E --> F
+```
+
+**File tools** ([FileIO](reme/memory/file_based/tools/file_io.py)):
+
+| Tool | Function |
+|---------|-----------------------|
+| `read` | Read file content |
+| `write` | Overwrite file |
+| `edit` | Find-and-replace edit |
+
+---
+
+#### 4. `compact_tool_result` — tool result compaction
+
+[ToolResultCompactor](reme/memory/file_based/components/tool_result_compactor.py) addresses the problem of long tool
+outputs bloating the context.
+
+```mermaid
+graph LR
+ M[messages] --> L{Iterate tool_result
len > threshold?}
+ L -->|No| K[Keep as-is]
+ L -->|Yes| T[truncate_text
Truncate to threshold]
+ T --> S[Write full content
tool_result/uuid.txt]
+ S --> R[Append file path reference
to message]
+ R --> C[cleanup_expired_files
Delete expired files]
+```
+
+- **Auto cleanup**: expired files (older than `retention_days`) are deleted automatically during `start` / `close` /
+ `compact_tool_result`.
+
+---
+
+#### 5. `memory_search` — memory retrieval
+
+[MemorySearch](reme/memory/file_based/tools/memory_search.py) provides **vector + BM25 hybrid retrieval**.
+
+```mermaid
+graph LR
+ Q[query] --> E[Embedding
Vectorization]
+ E --> V[vector_search
Semantic similarity]
+ Q --> B[BM25
Keyword matching]
+ V -->|" weight: 0.7 "| M[Deduplicate + weighted merge]
+ B -->|" weight: 0.3 "| M
+ M --> F[min_score filter]
+ F --> R[Top-N results]
+```
+
+- **Fusion mechanism**: vector weight 0.7 + BM25 weight 0.3 — balancing semantic similarity and exact matches.
+
+---
+
+#### 6. `ReMeInMemoryMemory` — in-session memory
+
+[ReMeInMemoryMemory](reme/memory/file_based/reme_in_memory_memory.py) extends AgentScope's `InMemoryMemory` to provide
+token-aware memory management.
+
+```mermaid
+graph LR
+ C[content] --> G[get_memory
exclude_mark=COMPRESSED]
+ G --> F[Filter out compressed messages]
+ F --> P{prepend_summary?}
+ P -->|Yes| S[Prepend previous summary]
+ S --> O[Output messages]
+ P -->|No| O
+```
+
+| Function | Description |
+|----------------------------------|---------------------------------------------------|
+| `get_memory` | Filter messages by mark and auto-append summary |
+| `estimate_tokens` | Estimate token usage of the context |
+| `state_dict` / `load_state_dict` | Serialize/deserialize state (session persistence) |
+
+---
+
+#### 7. `pre_reasoning_hook` — pre-reasoning processing
+
+This is a unified entry point that wires all the above components together and automatically manages context before each
+reasoning step.
+
+```mermaid
+graph LR
+ M[messages] --> TC[compact_tool_result
Compact long tool outputs]
+ TC --> CC[check_context
Compute remaining space]
+ CC --> D{messages_to_compact
Non-empty?}
+ D -->|No| K[Return original messages + summary]
+ D -->|Yes| V{is_valid?}
+ V -->|No| K
+ V -->|Yes| CM[compact_memory
Sync summary generation]
+ V -->|Yes| SM[add_async_summary_task
Async persistence]
+ CM --> R[Return messages_to_keep + new summary]
+```
+
+**Execution flow**:
+
+1. `compact_tool_result` — compact long tool outputs.
+2. `check_context` — check whether the context exceeds limits.
+3. `compact_memory` — generate compact summary (sync).
+4. `summary_memory` — persist memory (async in the background).
+
+---
+
+## 🗃️ Vector-based memory system
+
+[ReMe Vector Based](reme/reme.py) is the core class for the vector-based memory system. It manages three types of
+memories:
+
+| Memory type | Use case |
+|-----------------------|-------------------------------------------------------------------|
+| **Personal memory** | Records user preferences and habits |
+| **Procedural memory** | Records task execution experience and patterns of success/failure |
+| **Tool memory** | Records tool usage experience and parameter tuning |
+
+### Core capabilities
+
+| Method | Function | Description |
+|--------------------|--------------|-------------------------------------------------------------|
+| `summarize_memory` | 🧠 Summarize | Automatically extract and store memories from conversations |
+| `retrieve_memory` | 🔍 Retrieve | Retrieve related memories based on a query |
+| `add_memory` | ➕ Add | Manually add memories into the vector store |
+| `get_memory` | 📖 Get | Get a single memory by ID |
+| `update_memory` | ✏️ Update | Update existing memory content or metadata |
+| `delete_memory` | 🗑️ Delete | Delete a specific memory |
+| `list_memory` | 📋 List | List memories with filtering and sorting |
+
+### Installation and environment variables
+
+Installation and environment configuration are the same as [ReMeLight](#installation).
+API keys are configured via environment variables and can be stored in a `.env` file at the project root.
+
+### Python usage
```python
import asyncio
+
from reme import ReMe
@@ -278,7 +430,7 @@ async def main():
working_dir=".reme",
default_llm_config={
"backend": "openai",
- "model_name": "qwen3-30b-a3b-thinking-2507",
+ "model_name": "qwen3.5-plus",
},
default_embedding_model_config={
"backend": "openai",
@@ -293,33 +445,34 @@ async def main():
messages = [
{"role": "user", "content": "Help me write a Python script", "time_created": "2026-02-28 10:00:00"},
- {"role": "assistant", "content": "Sure, I'll help you write it", "time_created": "2026-02-28 10:00:05"},
+ {"role": "assistant", "content": "Sure, I'll help you with that.", "time_created": "2026-02-28 10:00:05"},
]
- # 1. Summarize memory from conversation (auto-extract user preferences, task experience, etc.)
+ # 1. Summarize memories from conversation (automatically extract user preferences, task experience, etc.)
result = await reme.summarize_memory(
messages=messages,
user_name="alice", # Personal memory
- # task_name="code_writing", # Task memory
+ # task_name="code_writing", # Procedural memory
)
- print(f"Summarize result: {result}")
+ print(f"Summary result: {result}")
- # 2. Retrieve relevant memory
+ # 2. Retrieve related memories
memories = await reme.retrieve_memory(
query="Python programming",
- # user_name="alice",
+ user_name="alice",
+ # task_name="code_writing",
)
- print(f"Retrieve result: {memories}")
+ print(f"Retrieved memories: {memories}")
- # 3. Manually add memory
+ # 3. Manually add a memory
memory_node = await reme.add_memory(
- memory_content="User prefers concise code style",
+ memory_content="The user prefers concise code style.",
user_name="alice",
)
print(f"Added memory: {memory_node}")
memory_id = memory_node.memory_id
- # 4. Get single memory by ID
+ # 4. Get a single memory by ID
fetched_memory = await reme.get_memory(memory_id=memory_id)
print(f"Fetched memory: {fetched_memory}")
@@ -327,11 +480,11 @@ async def main():
updated_memory = await reme.update_memory(
memory_id=memory_id,
user_name="alice",
- memory_content="User prefers concise, well-commented code style",
+ memory_content="The user prefers concise code with comments.",
)
print(f"Updated memory: {updated_memory}")
- # 6. List all memories for user (with filtering and sorting)
+ # 6. List all memories for the user (supports filtering and sorting)
all_memories = await reme.list_memory(
user_name="alice",
limit=10,
@@ -340,11 +493,11 @@ async def main():
)
print(f"User memory list: {all_memories}")
- # 7. Delete specified memory
+ # 7. Delete a specific memory
await reme.delete_memory(memory_id=memory_id)
print(f"Deleted memory: {memory_id}")
- # 8. Delete all memories (use with caution)
+ # 8. Delete all memories (use with care)
# await reme.delete_all()
await reme.close()
@@ -354,118 +507,21 @@ if __name__ == "__main__":
asyncio.run(main())
```
----
-
-## 🏛️ Technical Architecture
-
-### File-Based ReMe Core Architecture
-
-```mermaid
-graph TB
- User[User / Agent] --> ReMeFb[File based ReMe]
- ReMeFb --> ContextCheck[Context Check]
- ReMeFb --> Compact[Context Compact]
- ReMeFb --> Summary[Memory Summary]
- ReMeFb --> Search[Memory Retrieval]
- ContextCheck --> FbContextChecker[Check Token Limit]
- Compact --> FbCompactor[Compact History to Summary]
- Summary --> FbSummarizer[ReAct Agent + File Tools]
- Search --> MemorySearch[Vector + BM25 Hybrid Search]
- FbSummarizer --> FileTools[read / write / edit]
- FileTools --> MemoryFiles[memory/*.md]
- MemoryFiles -.->|File change| FileWatcher[Async File Watcher]
- FileWatcher -->|Update index| FileStore[Local DB]
- MemorySearch --> FileStore
-```
-
-#### Memory Summary: ReAct + File Tools
-
-[Summarizer](reme/memory/file_based/fb_summarizer.py) is the core component for memory summarization. It uses the
-**ReAct + file tools** pattern.
+### Technical architecture
```mermaid
graph LR
- A[Receive conversation] --> B{Think: What's worth recording?}
- B --> C[Act: read memory/YYYY-MM-DD.md]
- C --> D{Think: How to merge with existing content?}
- D --> E[Act: edit to update file]
- E --> F{Think: Anything missing?}
- F -->|Yes| B
- F -->|No| G[Done]
-```
-
-#### File Tool Set
-
-Summarizer is equipped with file operation tools so the AI can work directly on memory files:
-
-| Tool | Function | Use case |
-|---------|-------------------|-----------------------------------------|
-| `read` | Read file content | View existing memory, avoid duplicates |
-| `write` | Overwrite file | Create new memory file or major rewrite |
-| `edit` | Edit part of file | Append or modify specific sections |
-
-#### Context Compaction
-
-When a conversation gets too long, [Compactor](reme/memory/file_based/fb_compactor.py) compresses history into a concise
-summary — like **meeting minutes**, turning long discussion into key points.
-
-```mermaid
-graph LR
- A[Messages 1..N] --> B[📦 Compact summary]
-C[Recent messages] --> D[Keep as-is]
-B --> E[New context]
-D --> E
-```
-
-The compact summary includes what’s needed to continue:
-
-| Content | Description |
-|----------------|---------------------------------------------|
-| 🎯 Goals | What the user wants to accomplish |
-| ⚙️ Constraints | Requirements and preferences mentioned |
-| 📈 Progress | Completed / in progress / blocked tasks |
-| 🔑 Decisions | Decisions made and reasons |
-| 📌 Context | Key data such as file paths, function names |
-
-#### Memory Retrieval
-
-[MemorySearch](reme/memory/tools/chunk/memory_search.py) provides **vector + BM25 hybrid retrieval**. The two methods
-complement each other:
-
-| Retrieval | Strength | Weakness |
-|---------------------|-------------------------------------------------|----------------------------------------|
-| **Vector semantic** | Captures similar meaning with different wording | Weaker on exact token match |
-| **BM25 full-text** | Strong exact token match | No synonym or paraphrase understanding |
-
-**Fusion**: Both retrieval paths are used; results are combined by weighted sum (vector 0.7 + BM25 0.3), so both
-natural-language queries and exact lookups get reliable results.
-
-```mermaid
-graph LR
- Q[Search query] --> V[Vector search × 0.7]
-Q --> B[BM25 × 0.3]
-V --> M[Dedupe + weighted merge]
-B --> M
-M --> R[Top-N results]
-```
-
----
-
-### Vector-Based ReMe Core Architecture
-
-```mermaid
-graph TB
User[User / Agent] --> ReMe[Vector Based ReMe]
- ReMe --> Summarize[Memory Summarize]
- ReMe --> Retrieve[Memory Retrieve]
- ReMe --> CRUD[CRUD]
+ ReMe --> Summarize[Summarize memories]
+ ReMe --> Retrieve[Retrieve memories]
+ ReMe --> CRUD[CRUD operations]
Summarize --> PersonalSum[PersonalSummarizer]
Summarize --> ProceduralSum[ProceduralSummarizer]
Summarize --> ToolSum[ToolSummarizer]
Retrieve --> PersonalRet[PersonalRetriever]
Retrieve --> ProceduralRet[ProceduralRetriever]
Retrieve --> ToolRet[ToolRetriever]
- PersonalSum --> VectorStore[Vector DB]
+ PersonalSum --> VectorStore[Vector database]
ProceduralSum --> VectorStore
ToolSum --> VectorStore
PersonalRet --> VectorStore
@@ -473,19 +529,60 @@ graph TB
ToolRet --> VectorStore
```
+### Experimental results
+
+Coming soon...
+
---
-## ⭐ Community & Support
+## 🧪 Procedural memory paper
-- **Star & Watch**: Star helps more agent developers discover ReMe; Watch keeps you updated on new releases and
- features.
-- **Share your work**: In Issues or Discussions, share what ReMe unlocks for your agents — we’re happy to highlight
- great community examples.
-- **Need a new feature?** Open a Feature Request; we’ll iterate with the community.
-- **Code contributions**: All forms of code contribution are welcome. See
- the [Contribution Guide](docs/contribution.md).
-- **Acknowledgments**: Thanks to OpenClaw, Mem0, MemU, CoPaw, and other open-source projects for inspiration and
- support.
+> Our procedural (task) memory paper is available on [arXiv](https://arxiv.org/abs/2512.10696).
+
+### 🌍 [Appworld benchmark](benchmark/appworld/quickstart.md)
+
+We evaluate ReMe on the Appworld environment using Qwen3-8B (non-thinking mode):
+
+| Method | Avg@4 | Pass@4 |
+|----------|---------------------|---------------------|
+| w/o ReMe | 0.1497 | 0.3285 |
+| w/ ReMe | 0.1706 **(+2.09%)** | 0.3631 **(+3.46%)** |
+
+Pass@K measures the probability that at least one of K generated candidates successfully completes the task (score=1).
+The current experiments use an internal AppWorld environment, which may differ slightly from the public version.
+
+For more details on how to reproduce the experiments, see [quickstart.md](benchmark/appworld/quickstart.md).
+
+### 🔧 [BFCL-V3 benchmark](benchmark/bfcl/quickstart.md)
+
+We evaluate ReMe on the BFCL-V3 multi-turn-base task (random split 50 train / 150 val) using Qwen3-8B (thinking mode):
+
+| Method | Avg@4 | Pass@4 |
+|----------|---------------------|---------------------|
+| w/o ReMe | 0.4033 | 0.5955 |
+| w/ ReMe | 0.4450 **(+4.17%)** | 0.6577 **(+6.22%)** |
+
+For more details on how to reproduce the experiments, see [quickstart.md](benchmark/bfcl/quickstart.md).
+
+## ⭐ Community & support
+
+- **Star & Watch**: Starring helps more agent developers discover ReMe; Watching keeps you up to date with new releases
+ and features.
+- **Share your results**: Share how ReMe empowers your agents in Issues or Discussions — we are happy to showcase great
+ community use cases.
+- **Need a new feature?** Open a feature request; we’ll evolve ReMe together with the community.
+- **Code contributions**: All forms of contributions are welcome. Please see
+ the [contribution guide](docs/contribution.md).
+- **Acknowledgements**: We thank excellent open-source projects such as OpenClaw, Mem0, MemU, and CoPaw for their
+ inspiration and support.
+
+### Contributors
+
+Thanks to all who have contributed to ReMe:
+
+
+
+
---
@@ -504,10 +601,19 @@ graph TB
## ⚖️ License
-This project is open source under the Apache License 2.0. See the [LICENSE](./LICENSE) file for details.
+This project is open-sourced under the Apache License 2.0. See [LICENSE](./LICENSE) for details.
---
-## 📈 Star History
+## 🤔 Why ReMe?
+
+ReMe stands for **Remember Me** and **Refine Me**, symbolizing our goal to help AI agents "remember" users and "refine"
+themselves through interactions. We hope ReMe is not just a cold memory module, but a partner that truly helps agents
+understand users, accumulate experience, and continuously evolve.
+
+---
+
+## 📈 Star history
[](https://www.star-history.com/#agentscope-ai/ReMe&Date)
+
diff --git a/README_ZH.md b/README_ZH.md
index 2dbf02f3..c3de853f 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -14,6 +14,7 @@
+
@@ -30,14 +31,28 @@
ReMe 让智能体拥有**真正的记忆力**——旧对话自动浓缩,重要信息持久保存,下次对话自动想起来。
+
+你可以用 ReMe 做什么
+
+
+
+- **个人助理**:为 [CoPaw](https://github.com/agentscope-ai/CoPaw) 等智能体提供长期记忆,记住用户偏好和历史对话。
+- **编程助手**:记录代码风格偏好、项目上下文,跨会话保持一致的开发体验。
+- **客服机器人**:记录用户问题历史、偏好设置,提供个性化服务。
+- **任务自动化**:从历史任务中学习成功/失败模式,持续优化执行策略。
+- **知识问答**:构建可检索的知识库,支持语义搜索和精确匹配。
+- **多轮对话**:自动压缩长对话,在有限上下文窗口内保留关键信息。
+
+
---
-## 📁 基于文件的 ReMe
+## 📁 基于文件的记忆系统 (ReMeLight)
> 记忆即文件,文件即记忆
将**记忆视为文件**——可读、可编辑、可复制。
+[CoPaw](https://github.com/agentscope-ai/CoPaw) 通过继承 `ReMeLight` 实现了长期记忆和上下文的管理。
| 传统记忆系统 | File Based ReMe |
|-----------|-----------------|
@@ -47,35 +62,323 @@ ReMe 让智能体拥有**真正的记忆力**——旧对话自动浓缩,重
| 🚫 难迁移 | 📦 复制即迁移 |
```
-.reme/
-├── MEMORY.md # 长期记忆:用户偏好、项目配置等不常变的信息
-└── memory/
- └── YYYY-MM-DD.md # 每日日志:当天的工作记录,压缩时自动写入
+working_dir/
+├── MEMORY.md # 长期记忆:用户偏好等持久信息
+├── memory/
+│ └── YYYY-MM-DD.md # 每日日记:对话结束后自动写入
+└── tool_result/ # 超长工具输出缓存(自动管理,超期自动清理)
+ └── .txt
```
### 核心能力
-[ReMe File Based](reme/reme_fb.py) 是基于文件的记忆系统的核心类,就像一个**智能秘书**,帮你管理所有记忆相关的事务:
+[ReMeLight](reme/reme_light.py) 是该记忆系统的核心类,为 AI Agent 提供完整的记忆管理能力:
-| 方法 | 功能 | 关键组件 |
-|-----------------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `start` | 🚀 启动记忆系统 | [BaseFileStore](reme/core/file_store/base_file_store.py)(本地文件store)
[BaseFileWatcher](reme/core/file_watcher/base_file_watcher.py)(文件监控)
[BaseEmbeddingModel](reme/core/embedding/base_embedding_model.py)(Embedding 缓存) |
-| `close` | 📕 关闭并保存 | 关闭文件store、停止文件监控、保存 Embedding 缓存 |
-| `context_check` | 📏 检查上下文是否超限 | [ContextChecker](reme/memory/file_based/fb_context_checker.py) |
-| `compact` | 📦 压缩历史对话为摘要 | [Compactor](reme/memory/file_based/fb_compactor.py) |
-| `summary` | 📝 将重要记忆写入文件 | [Summarizer](reme/memory/file_based/fb_summarizer.py) |
-| `memory_search` | 🔍 语义搜索记忆 | [MemorySearch](reme/memory/tools/chunk/memory_search.py) |
-| `memory_get` | 📖 读取指定记忆文件 | [MemoryGet](reme/memory/tools/chunk/memory_get.py) |
+
+| 类别 | 方法 | 功能 | 关键组件 |
+| 上下文管理 | check_context | 📊 检查上下文大小 | ContextChecker — 检查上下文是否超出阈值并拆分 Message |
+compact_memory | 📦 压缩历史对话为摘要 | Compactor — ReActAgent 生成结构化上下文摘要 |
+compact_tool_result | ✂️ 压缩超长工具输出 | ToolResultCompactor — 截断超长的工具调用结果并转存到 tool_result/,消息中保留文件引用 |
+pre_reasoning_hook | 🔄 推理前预处理钩子 | compact_tool_result + check_context + compact_memory + summary_memory(async) |
+| 长期记忆 | summary_memory | 📝 将重要记忆写入文件 | Summarizer — ReActAgent + 文件工具(read / write / edit) |
+memory_search | 🔍 语义搜索记忆 | MemorySearch — 向量 + BM25 混合检索 |
+| - | start | 🚀 启动记忆系统 | 初始化文件存储、文件监控、Embedding 缓存;清理过期工具结果文件 |
+| - | close | 📕 关闭并清理 | 清理工具结果文件、停止文件监控、保存 Embedding 缓存 |
+
-## 🗃️ 基于向量库的 ReMe
+---
+
+### 🚀 快速开始
+
+#### 安装
+
+**从源码安装:**
+
+```bash
+git clone https://github.com/agentscope-ai/ReMe.git
+cd ReMe
+pip install -e ".[light]"
+```
+
+**更新到最新版本:**
+
+```bash
+git pull
+pip install -e ".[light]"
+```
+
+#### 环境变量
+
+`ReMeLight` 环境变量配置 Embedding 和存储后端
+
+| Variable | Description | Example |
+|----------------------|-------------------------|-----------------------------------------------------|
+| `LLM_API_KEY` | LLM API key | `sk-xxx` |
+| `LLM_BASE_URL` | LLM base URL | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
+| `EMBEDDING_API_KEY` | Embedding API key (可选) | `sk-xxx` |
+| `EMBEDDING_BASE_URL` | Embedding base URL (可选) | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
+
+#### Python 使用
+
+```python
+import asyncio
+
+from reme.reme_light import ReMeLight
+
+
+async def main():
+ # 初始化 ReMeLight
+ reme = ReMeLight(
+ default_as_llm_config={"model_name": "qwen3.5-35b-a3b"},
+ # default_embedding_model_config={"model_name": "text-embedding-v4"},
+ default_file_store_config={"fts_enabled": True, "vector_enabled": False},
+ )
+ await reme.start()
+
+ messages = [...] # 对话消息列表
+
+ # 1. 压缩超长工具输出(防止工具结果撑爆上下文)
+ messages = await reme.compact_tool_result(messages)
+
+ # 2. 将历史对话压缩为结构化摘要(可传入上轮摘要,实现增量更新)
+ summary = await reme.compact_memory(
+ messages=messages,
+ previous_summary="",
+ max_input_length=128000, # 模型上下文窗口(tokens)
+ compact_ratio=0.7, # 达到 max_input_length * 0.7 时触发压缩
+ language="zh", # 摘要语言(zh / "")
+ )
+
+ # 3. 后台异步提交摘要任务(不阻塞对话,摘要写入 memory/YYYY-MM-DD.md)
+ reme.add_async_summary_task(messages=messages)
+
+ # 4. 推理前预处理钩子(自动压缩工具结果 + 生成摘要)
+ processed_messages, compressed_summary = await reme.pre_reasoning_hook(
+ messages=messages,
+ system_prompt="你是一个有帮助的 AI 助手。",
+ compressed_summary="",
+ max_input_length=128000,
+ compact_ratio=0.7,
+ memory_compact_reserve=10000,
+ enable_tool_result_compact=True,
+ tool_result_compact_keep_n=3,
+ )
+
+ # 5. 语义搜索记忆(向量 + BM25 混合检索)
+ result = await reme.memory_search(query="Python 版本偏好", max_results=5)
+
+ # 6. 创建会话内存实例(管理单次对话的上下文)
+ from reme.memory.file_based.reme_in_memory_memory import ReMeInMemoryMemory
+ memory = ReMeInMemoryMemory()
+ for msg in messages:
+ await memory.add(msg)
+ token_stats = await memory.estimate_tokens(max_input_length=128000)
+ print(f"当前上下文使用率: {token_stats['context_usage_ratio']:.1f}%")
+ print(f"消息 Token 数: {token_stats['messages_tokens']}")
+ print(f"预估总 Token 数: {token_stats['estimated_tokens']}")
+
+ # 7. 关闭前等待后台任务完成
+ summary_result = await reme.await_summary_tasks()
+
+ # 关闭 ReMeLight
+ await reme.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+> 📂 完整示例代码:[test_reme_light.py](tests/light/test_reme_light.py)
+> 📋 运行结果示例:[test_reme_light_log.txt](tests/light/test_reme_light_log.txt)(223,838 tokens → 1,105 tokens,压缩率99.5%)
+
+### 基于文件的 ReMeLight 记忆系统架构
+
+[CoPaw MemoryManager](https://github.com/agentscope-ai/CoPaw/blob/main/src/copaw/agents/memory/memory_manager.py) 继承
+`ReMeLight`,将记忆能力集成到 Agent 推理流程中:
+
+```mermaid
+graph LR
+ Agent[Agent] -->|每轮推理前| Hook[pre_reasoning_hook]
+ Hook --> TC[compact_tool_result
压缩工具输出]
+ TC --> CC[check_context
Token 计数]
+ CC -->|超限| CM[compact_memory
生成摘要]
+ CC -->|超限| SM[summary_memory
异步持久化]
+ SM -->|ReAct + FileIO| Files[memory/*.md]
+ Agent -->|主动调用| Search[memory_search
向量+BM25]
+ Agent -->|会话内存| InMem[ReMeInMemoryMemory
Token感知内存]
+ Files -.->|FileWatcher| Store[(FileStore
向量+FTS索引)]
+ Search --> Store
+```
+
+---
+
+#### 1. check_context — 上下文检查
+
+[ContextChecker](reme/memory/file_based/components/context_checker.py) 基于 Token 计数判断上下文是否超限,自动拆分为「待压缩」和「保留」两组消息。
+
+```mermaid
+graph LR
+ M[messages] --> H[AsMsgHandler
Token 计数]
+ H --> C{total > threshold?}
+ C -->|否| K[返回全部消息]
+ C -->|是| S[从尾部向前保留
reserve tokens]
+ S --> CP[messages_to_compact
早期消息]
+ S --> KP[messages_to_keep
近期消息]
+ S --> V{is_valid
工具调用对齐?}
+```
+
+- **核心逻辑**:从尾部向前保留 `reserve` tokens,超出部分标记为待压缩
+- **完整性保证**:不拆分 user-assistant 对话对,不拆分 tool_use/tool_result 配对
+
+---
+
+#### 2. compact_memory — 对话压缩
+
+[Compactor](reme/memory/file_based/components/compactor.py) 使用 ReActAgent 将历史对话压缩为**结构化上下文摘要**。
+
+```mermaid
+graph LR
+ M[messages] --> H[AsMsgHandler
format_msgs_to_str]
+ H --> A[ReActAgent
reme_compactor]
+ P[previous_summary] -->|增量更新| A
+ A --> S[结构化摘要
Goal/Progress/Decisions...]
+```
+
+**摘要结构**(上下文检查点):
+
+| 字段 | 说明 |
+|-----------------------|--------------------|
+| `## Goal` | 用户目标 |
+| `## Constraints` | 约束和偏好 |
+| `## Progress` | 任务进展 |
+| `## Key Decisions` | 关键决策 |
+| `## Next Steps` | 下一步计划 |
+| `## Critical Context` | 文件路径、函数名、错误信息等关键数据 |
+
+- **增量更新**:传入 `previous_summary` 时,自动将新对话与旧摘要合并
+
+---
+
+#### 3. summary_memory — 记忆持久化
+
+[Summarizer](reme/memory/file_based/components/summarizer.py) 采用 **ReAct + 文件工具** 模式,让 AI 自主决定写什么、写到哪。
+
+```mermaid
+graph LR
+ M[messages] --> A[ReActAgent
reme_summarizer]
+ A -->|read| R[读取 memory/YYYY-MM-DD.md]
+ R --> T{思考: 如何合并?}
+ T -->|write| W[覆盖写入]
+ T -->|edit| E[精确替换]
+ W --> F[memory/YYYY-MM-DD.md]
+ E --> F
+```
+
+**文件工具**([FileIO](reme/memory/file_based/tools/file_io.py)):
+
+| 工具 | 功能 |
+|---------|---------|
+| `read` | 读取文件内容 |
+| `write` | 覆盖写入文件 |
+| `edit` | 精确匹配后替换 |
+
+---
+
+#### 4. compact_tool_result — 工具结果压缩
+
+[ToolResultCompactor](reme/memory/file_based/components/tool_result_compactor.py) 解决工具输出过长导致上下文膨胀的问题。
+
+```mermaid
+graph LR
+ M[messages] --> L{遍历 tool_result
len > threshold?}
+ L -->|否| K[保留原样]
+ L -->|是| T[truncate_text
截断到 threshold]
+ T --> S[完整内容写入
tool_result/uuid.txt]
+ S --> R[消息追加文件路径引用]
+ R --> C[cleanup_expired_files
清理过期文件]
+```
+
+- **自动清理**:过期文件(超过 `retention_days`)在 `start`/`close`/`compact_tool_result` 时自动删除
+
+---
+
+#### 5. memory_search — 记忆检索
+
+[MemorySearch](reme/memory/file_based/tools/memory_search.py) 提供**向量 + BM25 混合检索**能力。
+
+```mermaid
+graph LR
+ Q[query] --> E[Embedding
向量化]
+ E --> V[vector_search
语义相似]
+ Q --> B[BM25
关键词匹配]
+ V -->|" weight: 0.7 "| M[去重 + 加权融合]
+ B -->|" weight: 0.3 "| M
+ M --> F[min_score 过滤]
+ F --> R[Top-N 结果]
+```
+
+- **融合机制**:向量权重 0.7 + BM25 权重 0.3,兼顾语义相似和精确匹配
+
+---
+
+#### 6. ReMeInMemoryMemory — 会话内存
+
+[ReMeInMemoryMemory](reme/memory/file_based/reme_in_memory_memory.py) 扩展 AgentScope 的 `InMemoryMemory`,提供 Token
+感知的内存管理。
+
+```mermaid
+graph LR
+ C[content] --> G[get_memory
exclude_mark=COMPRESSED]
+ G --> F[排除已压缩消息]
+ F --> P{prepend_summary?}
+ P -->|是| S[头部插入 previous-summary]
+ S --> O[输出 messages]
+ P -->|否| O
+```
+
+| 功能 | 说明 |
+|----------------------------------|-------------------|
+| `get_memory` | 按标记过滤,自动追加压缩摘要 |
+| `estimate_tokens` | 估算上下文 Token 用量 |
+| `state_dict` / `load_state_dict` | 状态序列化/反序列化(会话持久化) |
+
+---
+
+#### 7. pre_reasoning_hook — 推理前预处理
+
+整合上述组件的统一入口,在每轮推理前自动管理上下文。
+
+```mermaid
+graph LR
+ M[messages] --> TC[compact_tool_result
压缩超长工具输出]
+ TC --> CC[check_context
计算剩余空间]
+ CC --> D{messages_to_compact
非空?}
+ D -->|否| K[返回原消息 + 原摘要]
+ D -->|是| V{is_valid?}
+ V -->|否| K
+ V -->|是| CM[compact_memory
同步生成摘要]
+ V -->|是| SM[add_async_summary_task
异步持久化]
+ CM --> R[返回 messages_to_keep + 新摘要]
+```
+
+**执行流程**:
+
+1. `compact_tool_result` — 压缩超长工具输出
+2. `check_context` — 检查上下文是否超限
+3. `compact_memory` — 生成压缩摘要(同步)
+4. `summary_memory` — 持久化记忆(异步后台)
+
+---
+
+## 🗃️ 基于向量库的记忆系统
[ReMe Vector Based](reme/reme.py) 是基于向量库的记忆系统核心类,支持三种记忆类型的统一管理:
-| 记忆类型 | 用途 | 使用场景 |
-|--------------|------------------|-------------|
-| **个人记忆** | 记录用户偏好、习惯 | `user_name` |
-| **任务/程序性记忆** | 记录任务执行经验、成功/失败模式 | `task_name` |
-| **工具记忆** | 记录工具使用经验、参数优化 | `tool_name` |
+| 记忆类型 | 用途 |
+|--------------|------------------|
+| **个人记忆** | 记录用户偏好、习惯 |
+| **任务/程序性记忆** | 记录任务执行经验、成功/失败模式 |
+| **工具记忆** | 记录工具使用经验、参数优化 |
### 核心能力
@@ -89,177 +392,15 @@ ReMe 让智能体拥有**真正的记忆力**——旧对话自动浓缩,重
| `delete_memory` | 🗑️ 删除记忆 | 删除指定记忆 |
| `list_memory` | 📋 列出记忆 | 列出某类记忆,支持过滤和排序 |
----
+### 安装与环境变量
-## 💻 ReMeCli:基于文件记忆的终端助手
+安装和环境变量配置与 [ReMeLight 一致](#安装),通过环境变量设置 API 密钥,可写在项目根目录的 `.env` 文件中。
-
-
-
- 马 上 有 钱
- |
-
-
- |
-
- 马 到 成 功
- |
-
-
-
-### 什么时候会写记忆?
-
-| 场景 | 写到哪 | 怎么触发 |
-|------------------|------------------------|----------------------|
-| 上下文超长自动压缩 | `memory/YYYY-MM-DD.md` | 后台自动 |
-| 用户执行 `/compact` | `memory/YYYY-MM-DD.md` | 手动压缩 + 后台保存 |
-| 用户执行 `/new` | `memory/YYYY-MM-DD.md` | 新对话 + 后台保存 |
-| 用户说"记住这个" | `MEMORY.md` 或日志 | Agent 用 `write` 工具写入 |
-| Agent 发现了重要决策/偏好 | `MEMORY.md` | Agent 主动写 |
-
-### 记忆检索工具
-
-| 方式 | 工具 | 什么时候用 | 举例 |
-|------|-----------------|------------|--------------------------|
-| 语义搜索 | `memory_search` | 不确定记在哪,模糊找 | "之前关于部署的讨论" |
-| 直接读 | `read` | 知道是哪天、哪个文件 | 读 `memory/2025-02-13.md` |
-
-搜索用的是**向量 + BM25 混合检索**(向量权重 0.7,BM25 权重 0.3),无论自然语言还是精确关键词都能命中。
-
-### 内置工具
-
-| 工具 | 功能 | 细节 |
-|-----------------|----------|----------------------------------------|
-| `memory_search` | 搜记忆 | MEMORY.md 和 memory/*.md 里做向量+BM25 混合检索 |
-| `bash` | 跑命令 | 执行 bash 命令,有超时和输出截断 |
-| `ls` | 看目录 | 列目录结构 |
-| `read` | 读文件 | 文本和图片都行,支持分段读 |
-| `edit` | 改文件 | 精确匹配文本后替换 |
-| `write` | 写文件 | 创建或覆盖,自动建目录 |
-| `execute_code` | 跑 Python | 运行代码片段 |
-| `web_search` | 联网搜索 | 通过 Tavily |
-
----
-
-## 🚀 快速开始
-
-### 安装
-
-```bash
-pip install -U reme-ai
-```
-
-### 环境变量
-
-API 密钥通过环境变量设置,可写在项目根目录的 `.env` 文件中:
-
-| 环境变量 | 说明 | 示例 |
-|---------------------------|-----------------------|-----------------------------------------------------|
-| `REME_LLM_API_KEY` | LLM 的 API Key | `sk-xxx` |
-| `REME_LLM_BASE_URL` | LLM 的 Base URL | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
-| `REME_EMBEDDING_API_KEY` | Embedding 的 API Key | `sk-xxx` |
-| `REME_EMBEDDING_BASE_URL` | Embedding 的 Base URL | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
-| `TAVILY_API_KEY` | Tavily 搜索 API Key(可选) | `tvly-xxx` |
-
-### 使用 ReMeCli
-
-#### 启动 ReMeCli
-
-```bash
-remecli config=cli
-```
-
-#### ReMeCli 系统命令
-
-> 马年彩蛋:`/horse` 触发——烟花、奔马动画和随机马年祝福。
-
-对话里输入 `/` 开头的命令控制状态:
-
-| 命令 | 说明 | 需等待响应 |
-|------------|---------------------|-------|
-| `/compact` | 手动压缩当前对话,同时后台存到长期记忆 | 是 |
-| `/new` | 开始新对话,历史后台保存到长期记忆 | 否 |
-| `/clear` | 清空一切,**不保存** | 否 |
-| `/history` | 看当前对话里未压缩的消息 | 否 |
-| `/help` | 看命令列表 | 否 |
-| `/exit` | 退出 | 否 |
-
-**三个命令的区别**
-
-| 命令 | 压缩摘要 | 长期记忆 | 消息历史 |
-|------------|-------|------|-------|
-| `/compact` | 生成新摘要 | 保存 | 保留最近的 |
-| `/new` | 清空 | 保存 | 清空 |
-| `/clear` | 清空 | 不保存 | 清空 |
-
-> `/clear` 是真删,删了就没了,不会存到任何地方。
-
-### 使用 ReMe Package
-
-#### 基于文件的 ReMe
+### Python 使用
```python
import asyncio
-from reme import ReMeFb
-
-
-async def main():
- # 初始化并启动
- reme = ReMeFb(
- default_llm_config={
- "backend": "openai", # 后端类型,支持 openai 兼容接口
- "model_name": "qwen3.5-plus", # 模型名称
- },
- default_file_store_config={
- "backend": "chroma", # 存储后端,支持 sqlite/chroma/local
- "fts_enabled": True, # 是否启用全文搜索
- "vector_enabled": False, # 是否启用向量搜索(无 embedding 服务可设为 False)
- },
- context_window_tokens=128000, # 模型上下文窗口大小(tokens)
- reserve_tokens=36000, # 预留给输出的 token 数量
- keep_recent_tokens=20000, # 保留最近消息的 token 数量
- vector_weight=0.7, # 向量搜索权重(0-1),用于混合搜索
- candidate_multiplier=3.0, # 候选结果倍数,用于召回更多候选项
- )
- await reme.start()
-
- messages = [
- {"role": "user", "content": "我喜欢用 Python 3.12"},
- {"role": "assistant", "content": "好的,已记录你偏好 Python 3.12"},
- ]
-
- # 检查上下文是否超限
- result = await reme.context_check(messages)
- print(f"压缩结论: {result}")
-
- # 压缩对话为摘要
- summary = await reme.compact(messages_to_summarize=messages)
- print(f"摘要: {summary}")
-
- # 将重要记忆写入文件(ReAct Agent 自动操作)
- await reme.summary(messages=messages, date="2026-02-28")
-
- # 语义搜索记忆
- results = await reme.memory_search(query="Python 版本偏好", max_results=5)
- print(f"搜索结果: {results}")
-
- # 读取指定记忆文件
- content = await reme.memory_get(path="MEMORY.md")
- print(f"记忆内容: {content}")
-
- # 关闭(保存 Embedding 缓存、停止文件监控)
- await reme.close()
-
-
-if __name__ == "__main__":
- asyncio.run(main())
-```
-
-#### 基于向量库的 ReMe
-
-```python
-import asyncio
from reme import ReMe
@@ -269,7 +410,7 @@ async def main():
working_dir=".reme",
default_llm_config={
"backend": "openai",
- "model_name": "qwen3-30b-a3b-thinking-2507",
+ "model_name": "qwen3.5-plus",
},
default_embedding_model_config={
"backend": "openai",
@@ -346,102 +487,10 @@ if __name__ == "__main__":
asyncio.run(main())
```
-## 🏛️ 技术架构
-
-### 基于文件的 ReMe 核心架构
-
-```mermaid
-graph TB
- User[用户 / Agent] --> ReMeFb[File based ReMe]
- ReMeFb --> ContextCheck[上下文检查]
- ReMeFb --> Compact[上下文压缩]
- ReMeFb --> Summary[记忆总结]
- ReMeFb --> Search[记忆检索]
- ContextCheck --> FbContextChecker[检查 Token 是否超限]
- Compact --> FbCompactor[压缩历史对话为摘要]
- Summary --> FbSummarizer[ReAct Agent + 文件工具]
- Search --> MemorySearch[向量 + BM25 混合检索]
- FbSummarizer --> FileTools[read / write / edit]
- FileTools --> MemoryFiles[memory/*.md]
- MemoryFiles -.->|文件变更| FileWatcher[异步文件监控]
- FileWatcher -->|更新索引| FileStore[本地数据库]
- MemorySearch --> FileStore
-```
-
-#### 记忆总结:ReAct + 文件工具
-
-[Summarizer](reme/memory/file_based/fb_summarizer.py) 是记忆总结的核心组件,它采用 **ReAct + 文件工具** 模式。
+### 技术架构
```mermaid
graph LR
- A[接收对话] --> B{思考: 有什么值得记录?}
- B --> C[行动: read memory/YYYY-MM-DD.md]
- C --> D{思考: 如何与现有内容合并?}
- D --> E[行动: edit 更新文件]
- E --> F{思考: 还有遗漏吗?}
- F -->|是| B
- F -->|否| G[完成]
-```
-
-#### 文件工具集
-
-Summarizer 配备了一套文件操作工具,让 AI 能够直接操作记忆文件:
-
-| 工具 | 功能 | 使用场景 |
-|---------|--------|--------------|
-| `read` | 读取文件内容 | 查看现有记忆,避免重复 |
-| `write` | 覆盖写入文件 | 创建新记忆文件或大幅重构 |
-| `edit` | 编辑文件局部 | 追加新内容或修改特定部分 |
-
-#### 上下文压缩
-
-当对话过长时,[Compactor](reme/memory/file_based/fb_compactor.py) 负责将历史对话压缩为精华摘要——就像写**会议纪要**
-,把冗长的讨论浓缩成关键要点。
-
-```mermaid
-graph LR
- A[消息1..N] --> B[📦 压缩摘要]
-C[最近消息] --> D[保留原样]
-B --> E[新的上下文]
-D --> E
-```
-
-压缩摘要包含继续工作所需的关键信息:
-
-| 内容 | 说明 |
-|--------|---------------|
-| 🎯 目标 | 用户想要完成什么 |
-| ⚙️ 约束 | 用户提到的要求和偏好 |
-| 📈 进展 | 已完成/进行中/阻塞的任务 |
-| 🔑 决策 | 做出的决策及原因 |
-| 📌 上下文 | 文件路径、函数名等关键数据 |
-
-#### 记忆检索
-
-[MemorySearch](reme/memory/tools/chunk/memory_search.py) 提供**向量 + BM25 混合检索**能力,两种方式优势互补:
-
-| 检索方式 | 优势 | 劣势 |
-|-------------|-----------------|----------------|
-| **向量语义** | 捕捉意义相近但措辞不同的内容 | 对精确 token 匹配较弱 |
-| **BM25 全文** | 精确 token 命中效果极佳 | 无法理解同义词和改写 |
-
-**融合机制**:同时使用两路召回,按权重加权求和(向量 0.7 + BM25 0.3),确保无论是「自然语言提问」还是「精确查找」都能获得可靠结果。
-
-```mermaid
-graph LR
- Q[搜索查询] --> V[向量搜索 × 0.7]
-Q --> B[BM25 × 0.3]
-V --> M[去重 + 加权融合]
-B --> M
-M --> R[Top-N 结果]
-```
-
----
-
-### 基于向量库的 ReMe 核心架构
-
-```mermaid
-graph TB
User[用户 / Agent] --> ReMe[Vector Based ReMe]
ReMe --> Summarize[记忆总结]
ReMe --> Retrieve[记忆检索]
@@ -460,6 +509,41 @@ graph TB
ToolRet --> VectorStore
```
+### 实验效果
+
+Coming soon...
+
+---
+
+## 🧪 程序化记忆论文
+
+> 我们的程序性(任务)记忆论文已在 [arXiv](https://arxiv.org/abs/2512.10696) 发布
+
+### 🌍 [Appworld 实验](benchmark/appworld/quickstart.md)
+
+我们在 Appworld 环境上使用 Qwen3-8B(非思考模式)进行评测:
+
+| 方法 | Avg@4 | Pass@4 |
+|---------|---------------------|---------------------|
+| 无 ReMe | 0.1497 | 0.3285 |
+| 使用 ReMe | 0.1706 **(+2.09%)** | 0.3631 **(+3.46%)** |
+
+Pass@K 衡量在生成 K 个候选中,至少一个成功完成任务(score=1)的概率。
+当前实验使用的是内部 AppWorld 环境,可能与对外版本存在轻微差异。
+
+关于如何复现实验的更多细节,见 [quickstart.md](benchmark/appworld/quickstart.md)
+
+### 🔧 [BFCL-V3 实验](benchmark/bfcl/quickstart.md)
+
+我们在 BFCL-V3 multi-turn-base 任务(随机划分 50 train / 150 val)上,使用 Qwen3-8B(思考模式)进行评测:
+
+| 方法 | Avg@4 | Pass@4 |
+|---------|---------------------|---------------------|
+| 无 ReMe | 0.4033 | 0.5955 |
+| 使用 ReMe | 0.4450 **(+4.17%)** | 0.6577 **(+6.22%)** |
+
+关于如何复现实验的更多细节,见 [quickstart.md](benchmark/bfcl/quickstart.md)
+
## ⭐ 社区与支持
- **Star 与 Watch**:Star 可让更多智能体开发者发现 ReMe;Watch 可助你第一时间获知新版本与特性。
@@ -468,6 +552,14 @@ graph TB
- **代码贡献**:欢迎任何形式的代码贡献,请参阅 [贡献指南](docs/contribution.md)。
- **致谢**:感谢 OpenClaw、Mem0、MemU、CoPaw 等优秀的开源项目,为项目带来诸多启发与帮助。
+### 贡献者
+
+感谢所有为 ReMe 做出贡献的朋友们:
+
+
+
+
+
---
## 📄 引用
@@ -489,6 +581,13 @@ graph TB
---
+## 🤔 为什么叫 ReMe?
+
+ReMe 是 **Remember Me** 和 **Refine Me** 的缩写,寓意让 AI 智能体「记住我」并在交互中「精进自我」。我们希望 ReMe
+不只是一个冷冰冰的记忆模块,而是能让智能体真正理解用户、积累经验、持续进化的伙伴。
+
+---
+
## 📈 Star 历史
[](https://www.star-history.com/#agentscope-ai/ReMe&Date)
diff --git a/docs/cookbook/appworld/quickstart.md b/benchmark/appworld/quickstart.md
similarity index 80%
rename from docs/cookbook/appworld/quickstart.md
rename to benchmark/appworld/quickstart.md
index 78f6a1a8..45ea0d78 100644
--- a/docs/cookbook/appworld/quickstart.md
+++ b/benchmark/appworld/quickstart.md
@@ -9,7 +9,7 @@ This guide helps you quickly set up and run AppWorld experiments with ReMe integ
```bash
git clone https://github.com/agentscope-ai/ReMe.git
-cd ReMe/cookbook/appworld
+cd ReMe/benchmark/appworld
```
### 2. Appworld Environment Setup
@@ -56,26 +56,16 @@ pip install .
Launch the ReMe service to enable memory library functionality:
```bash
-reme \
+reme2 \
backend=http \
http.port=8002 \
- llm.default.model_name=qwen-max-latest \
- embedding_model.default.model_name=text-embedding-v4 \
- vector_store.default.backend=elasticsearch
+ llms.default.model_name=qwen3-8b \
+ embedding_models.default.model_name=text-embedding-v4 \
+ vector_stores.default.backend=es \
+ vector_stores.default.collection_name=appworld \
+ vector_stores.default.hosts=http://xx.yy.zz.mm:nn
```
-add memories for appworld:
-```bash
-curl -X POST "http://0.0.0.0:8002/vector_store" \
- -H "Content-Type: application/json" \
- -d '{
- "workspace_id": "appworld",
- "action": "load",
- "path": "./docs/library"
- }'
-```
-Now you have loaded the ReMe memory library to enable memory-based agent!
-
### 4. Common Issues
**AppWorld data not found**: Ensure `appworld download data` completed successfully
@@ -95,21 +85,21 @@ python run_appworld.py
```
**What this does:**
-- Runs AppWorld tasks on the development dataset
+- Runs AppWorld tasks on the test-normal set
- Compares agent performance with ReMe memory (`use_memory=True`) vs without memory
- Uses multiple workers for parallel processing
- Runs each task multiple times for statistical significance
- Results are automatically saved to `./exp_result/` directory
**Configuration options in `run_appworld.py`:**
-- `max_workers`: Number of parallel workers (default: 8)
-- `num_runs`: Number of times each task is repeated (default: 1)
+- `max_workers`: Number of parallel workers (default: 16)
+- `num_runs`: Number of times each task is repeated (default: 4)
- `batch_size`: Number of concurrent tasks per batch (default: 8)
- `num_trials`: Maximum number of self-reflections, failure-aware reflection mechanism is triggered when num_trials>1 (default: 1)
-- `model_name`: Task execution model
-- `use_memory`: Whether to use ReMe memory library
-- `use_memory_addition`: Whether to enable selective addition
-- `use_memory_deletion`: Whether to enable utility-based deletion
+- `model_name`: Task execution model (default: "qwen3-8b")
+- `use_memory`: Whether to use ReMe memory library (default: True)
+- `use_memory_addition`: Whether to enable selective addition (default: False)
+- `use_memory_deletion`: Whether to enable utility-based deletion (default: False)
### 2. View Experiment Results
diff --git a/benchmark/bfcl/default_ids.py b/benchmark/bfcl/default_ids.py
new file mode 100644
index 00000000..43f4065e
--- /dev/null
+++ b/benchmark/bfcl/default_ids.py
@@ -0,0 +1,206 @@
+# pylint: disable=C0114
+DEFAULT_TRAIN_IDS: set[str] = {
+ "multi_turn_base_102",
+ "multi_turn_base_107",
+ "multi_turn_base_110",
+ "multi_turn_base_114",
+ "multi_turn_base_115",
+ "multi_turn_base_118",
+ "multi_turn_base_122",
+ "multi_turn_base_123",
+ "multi_turn_base_128",
+ "multi_turn_base_13",
+ "multi_turn_base_130",
+ "multi_turn_base_132",
+ "multi_turn_base_133",
+ "multi_turn_base_143",
+ "multi_turn_base_144",
+ "multi_turn_base_146",
+ "multi_turn_base_15",
+ "multi_turn_base_158",
+ "multi_turn_base_169",
+ "multi_turn_base_17",
+ "multi_turn_base_172",
+ "multi_turn_base_176",
+ "multi_turn_base_182",
+ "multi_turn_base_187",
+ "multi_turn_base_197",
+ "multi_turn_base_199",
+ "multi_turn_base_22",
+ "multi_turn_base_23",
+ "multi_turn_base_24",
+ "multi_turn_base_36",
+ "multi_turn_base_40",
+ "multi_turn_base_44",
+ "multi_turn_base_47",
+ "multi_turn_base_48",
+ "multi_turn_base_5",
+ "multi_turn_base_51",
+ "multi_turn_base_59",
+ "multi_turn_base_63",
+ "multi_turn_base_65",
+ "multi_turn_base_66",
+ "multi_turn_base_67",
+ "multi_turn_base_68",
+ "multi_turn_base_70",
+ "multi_turn_base_75",
+ "multi_turn_base_77",
+ "multi_turn_base_78",
+ "multi_turn_base_79",
+ "multi_turn_base_81",
+ "multi_turn_base_83",
+ "multi_turn_base_93",
+}
+
+DEFAULT_VAL_IDS: set[str] = {
+ "multi_turn_base_0",
+ "multi_turn_base_1",
+ "multi_turn_base_10",
+ "multi_turn_base_100",
+ "multi_turn_base_101",
+ "multi_turn_base_103",
+ "multi_turn_base_104",
+ "multi_turn_base_105",
+ "multi_turn_base_106",
+ "multi_turn_base_108",
+ "multi_turn_base_109",
+ "multi_turn_base_11",
+ "multi_turn_base_111",
+ "multi_turn_base_112",
+ "multi_turn_base_113",
+ "multi_turn_base_116",
+ "multi_turn_base_117",
+ "multi_turn_base_119",
+ "multi_turn_base_12",
+ "multi_turn_base_120",
+ "multi_turn_base_121",
+ "multi_turn_base_124",
+ "multi_turn_base_125",
+ "multi_turn_base_126",
+ "multi_turn_base_127",
+ "multi_turn_base_129",
+ "multi_turn_base_131",
+ "multi_turn_base_134",
+ "multi_turn_base_135",
+ "multi_turn_base_136",
+ "multi_turn_base_137",
+ "multi_turn_base_138",
+ "multi_turn_base_139",
+ "multi_turn_base_14",
+ "multi_turn_base_140",
+ "multi_turn_base_141",
+ "multi_turn_base_142",
+ "multi_turn_base_145",
+ "multi_turn_base_147",
+ "multi_turn_base_148",
+ "multi_turn_base_149",
+ "multi_turn_base_150",
+ "multi_turn_base_151",
+ "multi_turn_base_152",
+ "multi_turn_base_153",
+ "multi_turn_base_154",
+ "multi_turn_base_155",
+ "multi_turn_base_156",
+ "multi_turn_base_157",
+ "multi_turn_base_159",
+ "multi_turn_base_16",
+ "multi_turn_base_160",
+ "multi_turn_base_161",
+ "multi_turn_base_162",
+ "multi_turn_base_163",
+ "multi_turn_base_164",
+ "multi_turn_base_165",
+ "multi_turn_base_166",
+ "multi_turn_base_167",
+ "multi_turn_base_168",
+ "multi_turn_base_170",
+ "multi_turn_base_171",
+ "multi_turn_base_173",
+ "multi_turn_base_174",
+ "multi_turn_base_175",
+ "multi_turn_base_177",
+ "multi_turn_base_178",
+ "multi_turn_base_179",
+ "multi_turn_base_18",
+ "multi_turn_base_180",
+ "multi_turn_base_181",
+ "multi_turn_base_183",
+ "multi_turn_base_184",
+ "multi_turn_base_185",
+ "multi_turn_base_186",
+ "multi_turn_base_188",
+ "multi_turn_base_189",
+ "multi_turn_base_19",
+ "multi_turn_base_190",
+ "multi_turn_base_191",
+ "multi_turn_base_192",
+ "multi_turn_base_193",
+ "multi_turn_base_194",
+ "multi_turn_base_195",
+ "multi_turn_base_196",
+ "multi_turn_base_198",
+ "multi_turn_base_2",
+ "multi_turn_base_20",
+ "multi_turn_base_21",
+ "multi_turn_base_25",
+ "multi_turn_base_26",
+ "multi_turn_base_27",
+ "multi_turn_base_28",
+ "multi_turn_base_29",
+ "multi_turn_base_3",
+ "multi_turn_base_30",
+ "multi_turn_base_31",
+ "multi_turn_base_32",
+ "multi_turn_base_33",
+ "multi_turn_base_34",
+ "multi_turn_base_35",
+ "multi_turn_base_37",
+ "multi_turn_base_38",
+ "multi_turn_base_39",
+ "multi_turn_base_4",
+ "multi_turn_base_41",
+ "multi_turn_base_42",
+ "multi_turn_base_43",
+ "multi_turn_base_45",
+ "multi_turn_base_46",
+ "multi_turn_base_49",
+ "multi_turn_base_50",
+ "multi_turn_base_52",
+ "multi_turn_base_53",
+ "multi_turn_base_54",
+ "multi_turn_base_55",
+ "multi_turn_base_56",
+ "multi_turn_base_57",
+ "multi_turn_base_58",
+ "multi_turn_base_6",
+ "multi_turn_base_60",
+ "multi_turn_base_61",
+ "multi_turn_base_62",
+ "multi_turn_base_64",
+ "multi_turn_base_69",
+ "multi_turn_base_7",
+ "multi_turn_base_71",
+ "multi_turn_base_72",
+ "multi_turn_base_73",
+ "multi_turn_base_74",
+ "multi_turn_base_76",
+ "multi_turn_base_8",
+ "multi_turn_base_80",
+ "multi_turn_base_82",
+ "multi_turn_base_84",
+ "multi_turn_base_85",
+ "multi_turn_base_86",
+ "multi_turn_base_87",
+ "multi_turn_base_88",
+ "multi_turn_base_89",
+ "multi_turn_base_9",
+ "multi_turn_base_90",
+ "multi_turn_base_91",
+ "multi_turn_base_92",
+ "multi_turn_base_94",
+ "multi_turn_base_95",
+ "multi_turn_base_96",
+ "multi_turn_base_97",
+ "multi_turn_base_98",
+ "multi_turn_base_99",
+}
diff --git a/benchmark/bfcl/init_task_memory_pool.py b/benchmark/bfcl/init_task_memory_pool.py
index bc38046f..2a4fcc87 100644
--- a/benchmark/bfcl/init_task_memory_pool.py
+++ b/benchmark/bfcl/init_task_memory_pool.py
@@ -114,6 +114,9 @@ def post_to_summarizer(trajectories: List[Any], service_url: str) -> Dict[str, A
request_data = {
"trajectories": trajectory_dicts,
+ "success_threshold": 1.0,
+ "enable_soft_comparison": True,
+ "validation_threshold": 0.5,
}
try:
@@ -156,6 +159,9 @@ def process_trajectories_with_threads(
results.append(result)
if "memory_list" in result["metadata"]:
print(f'✅ Group {group_index} processed: {result["metadata"].get("memory_list", 0)}')
+ memory_list = result["metadata"].get("memory_list", [])
+ response = requests.post(url=f"{service_url}/add_task_memory", json={"memory_list": memory_list})
+ response.raise_for_status()
else:
print(f"❌ Group {group_index} processed: error")
except Exception as e:
@@ -174,7 +180,7 @@ def main():
"""Main function to convert JSONL to memories using ReMe service."""
parser = argparse.ArgumentParser(description="Convert JSONL to memories using ReMe service")
parser.add_argument("--jsonl_file", type=str, required=True, help="Path to the JSONL file")
- parser.add_argument("--service_url", type=str, default="http://localhost:8001", help="ReMe service URL")
+ parser.add_argument("--service_url", type=str, default="http://localhost:8002", help="ReMe service URL")
parser.add_argument("--output_file", type=str, help="Output file to save results (optional)")
parser.add_argument("--n_threads", type=int, default=4, help="Number of threads for processing")
@@ -226,21 +232,4 @@ def main():
if __name__ == "__main__":
- import sys
-
- if len(sys.argv) > 1:
- main()
- else:
- print("Running in compatibility mode...")
- with open("exp_result/qwen3-8b/with_think/bfcl-multi-turn-base-train_wo-exp.jsonl", "r") as f:
- data = [json.loads(line) for line in f]
-
- grouped_trajectories = group_trajectories_by_task_id(data)
- print(f"Total groups: {len(grouped_trajectories)}")
-
- results = process_trajectories_with_threads(
- grouped_trajectories,
- "http://localhost:8001",
- n_threads=4,
- )
- print(f"Processed {len(results)} groups")
+ main()
diff --git a/benchmark/bfcl/local_file_to_library.py b/benchmark/bfcl/local_file_to_library.py
deleted file mode 100644
index a2d9ec15..00000000
--- a/benchmark/bfcl/local_file_to_library.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""Load the library data and convert them to the new format"""
-
-import json
-
-with open("../../file_vector_store/bfcl_test.jsonl", "r", encoding="utf-8") as f:
- bfcl = [json.loads(line) for line in f]
-
-new_bfcl = []
-for exp in bfcl:
- new_exp = {}
- new_exp["workspace_id"] = exp["workspace_id"]
- new_exp["memory_id"] = exp["unique_id"]
- new_exp["memory_type"] = exp["metadata"]["memory_type"]
-
- new_exp["when_to_use"] = exp["content"]
- new_exp["content"] = exp["metadata"]["content"]
- new_exp["score"] = exp["metadata"]["score"]
-
- new_exp["time_created"] = exp["metadata"]["time_created"]
- new_exp["time_modified"] = exp["metadata"]["time_modified"]
- new_exp["author"] = exp["metadata"]["author"]
-
- new_exp["metadata"] = exp["metadata"]["metadata"]
- new_exp["metadata"]["utility"] = 0
- new_exp["metadata"]["freq"] = 0
-
- new_bfcl.append(new_exp)
-
-with open("../../library/bfcl_test.jsonl", "w", encoding="utf-8") as f:
- f.writelines(json.dumps(item, ensure_ascii=False) + "\n" for item in new_bfcl)
diff --git a/benchmark/bfcl/quickstart.md b/benchmark/bfcl/quickstart.md
new file mode 100644
index 00000000..c80ef75f
--- /dev/null
+++ b/benchmark/bfcl/quickstart.md
@@ -0,0 +1,129 @@
+# BFCL
+Experiment Quick Start Guide
+
+This guide helps you quickly set up and run BFCL experiments with ReMe integration.
+
+## Env Setup
+
+### 1. BFCL installation
+
+#### Clone the repository
+```bash
+cd ReMe/benchmark/bfcl
+git clone https://github.com/ShishirPatil/gorilla.git
+cd gorilla
+git checkout ea13468
+```
+
+#### Change directory to the `berkeley-function-call-leaderboard`
+```bash
+cd berkeley-function-call-leaderboard
+```
+
+#### Install the package in editable mode
+```bash
+pip install -e .
+cd ../..
+pip install -r requirements.txt
+```
+
+#### Move the dataset to the data folder under bfcl
+```bash
+cp -r gorilla/berkeley-function-call-leaderboard/bfcl_eval/data ./
+```
+
+#### Preprocess the data to get the suitable data format
+```bash
+python preprocess.py
+```
+
+**Note**: The original BFCL data is designed as a benchmark dataset and does not have a train/validation split, you can use ``split_into_trainval.py`` to split data into train and validation sets.
+
+```bash
+python split_into_trainval.py --input ./data/multiturn_data_base.jsonl --train ./data/multiturn_data_base_train.jsonl --val ./data/multiturn_data_base_val.jsonl
+```
+
+### 2. Start ReMe Service
+
+After collecting trajectories, Launch the ReMe service (make sure you have installed ReMe environment, if not please follow the steps in the [ReMe Installation Guide](https://github.com/agentscope-ai/ReMe/blob/main/doc/README.md) to install):
+
+```bash
+reme2 \
+ backend=http \
+ http.port=8002 \
+ llms.default.model_name=qwen3-8b \
+ embedding_models.default.model_name=text-embedding-v4 \
+ vector_stores.default.backend=local \
+ vector_stores.default.collection_name=bfcl
+```
+
+
+Option: init the task memory pool from scratch
+
+- First, collect agent trajectories on training data set without task memory:
+
+ ```bash
+ # important: num_runs = 8, use_memory = False, experiment_suffix="wo-memory", data_path="data/multiturn_data_base_train.jsonl"
+ python run_bfcl.py
+ ```
+
+- Second, using ReMe to construct the initial task memory pool:
+ ```bash
+ python init_task_memory_pool.py --jsonl_file ./exp_result/qwen3-8b/with_think/bfcl-multi-turn-base_wo-memory.jsonl
+ ```
+
+ > Parameters:
+ > `jsonl_file`: Path to the collloaded trajectories
+ > `service_url`: ReMe service URL (default: `http://localhost:8002`)
+ > `n_threads`: Number of threads for processing
+ > `output_file`: Output file to save results (optional)
+
+ Now you have inited the task memory pool using `local` backend. Then, run the following `curl` command to dump the memory library:
+ ```bash
+ curl -X POST "http://0.0.0.0:8002/dump_memory" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "dump_file_path": "./library/bfcl.jsonl",
+ }'
+ ```
+
+- Next time, you can import this previously exported task memory data to populate the new started workspace with existing knowledge:
+ ```bash
+ curl -X POST "http://0.0.0.0:8002/load_memory" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "load_file_path": "./library/bfcl.jsonl",
+ "clear_existing": true
+ }'
+ ```
+
+
+### 3. Run Experiments on Validation Set
+
+Run you can compare agent performance on the validation set with task memory (`use_memory=True`) and without task memory:
+
+```bash
+# remember to change the configuration options, e.g., `data_path=./data/multiturn_data_base_val.jsonl`
+python run_bfcl.py
+```
+
+**Note**:
+- `max_workers`: Number of parallel workers
+- `num_runs`: Number of times each task is repeated
+- `model_name`: LLM model name
+- `enable_thinking`: Control the model's thinking mode
+- `data_path`: Path to the training dataset (default: `./data/multiturn_data_base_val.jsonl`)
+- `answer_path`: Path to the possible answer, which are used to evaluate the model's output function (default: `./data/possible_answer`)
+- Results are automatically saved to `./exp_result/{model_name}/{no_think/with_think}` directory
+
+After running experiments, analyze the statistical results:
+
+```bash
+python run_exp_statistic.py
+```
+
+**What this script does:**
+- Processes all result files in `./exp_result/`
+- Calculates best@k&pass@k metrics for different k values
+- Generates a summary table showing performance comparisons
+- Saves results to `experiment_summary.csv`
diff --git a/benchmark/bfcl/requirements.txt b/benchmark/bfcl/requirements.txt
index 86ebcb1f..445bf2b3 100644
--- a/benchmark/bfcl/requirements.txt
+++ b/benchmark/bfcl/requirements.txt
@@ -2,4 +2,5 @@ jinja2
loguru
openai
ray
-pandas
\ No newline at end of file
+pandas
+soundfile
\ No newline at end of file
diff --git a/benchmark/bfcl/run_bfcl.py b/benchmark/bfcl/run_bfcl.py
index c01071ce..6ea8c325 100644
--- a/benchmark/bfcl/run_bfcl.py
+++ b/benchmark/bfcl/run_bfcl.py
@@ -131,7 +131,7 @@ def main():
run_agent(
max_workers=max_workers,
model_name=model_name,
- dataset_name="bfcl-multi-turn-base",
+ dataset_name="bfcl-multi-turn-base-val",
experiment_suffix="w-fixed-memory",
data_path="data/multiturn_data_base_val.jsonl",
answer_path=Path("data/possible_answer"),
diff --git a/benchmark/bfcl/run_exp_statistic.py b/benchmark/bfcl/run_exp_statistic.py
index 9eb9b3c8..18efcc8d 100644
--- a/benchmark/bfcl/run_exp_statistic.py
+++ b/benchmark/bfcl/run_exp_statistic.py
@@ -141,7 +141,7 @@ def run_exp_statistic():
# Sort columns by the number in column name (best@8, best@4, best@2, best@1)
# best_columns = [col for col in df.columns if col.startswith('best@')]
- best_columns = df.columns
+ best_columns = list(df.columns)
best_columns.sort(key=lambda x: x, reverse=False)
df = df[best_columns]
diff --git a/benchmark/bfcl/split_into_trainval.py b/benchmark/bfcl/split_into_trainval.py
index 82155855..e217def7 100644
--- a/benchmark/bfcl/split_into_trainval.py
+++ b/benchmark/bfcl/split_into_trainval.py
@@ -4,16 +4,46 @@ import argparse
import json
import random
+from default_ids import DEFAULT_TRAIN_IDS, DEFAULT_VAL_IDS
-def split_jsonl(input_file, train_file, val_file, ratio=0.8):
+
+def split_jsonl(
+ input_file: str,
+ train_file: str,
+ val_file: str,
+ ratio: float = 0.75,
+ random_split: bool = False,
+) -> None:
"""Split the JSONL file into train and validation sets."""
with open(input_file, "r", encoding="utf-8") as f:
data = [json.loads(line) for line in f]
- random.shuffle(data)
- split_idx = int(len(data) * ratio)
- train_data = data[:split_idx]
- val_data = data[split_idx:]
+ if random_split:
+ random.shuffle(data)
+ split_idx = int(len(data) * ratio)
+ train_data = data[:split_idx]
+ val_data = data[split_idx:]
+ else:
+ train_data = []
+ val_data = []
+ unknown_ids: list[str] = []
+ for obj in data:
+ if "id" not in obj:
+ raise ValueError(f"Missing 'id' field in input file: {input_file}")
+ obj_id = str(obj["id"])
+ if obj_id in DEFAULT_TRAIN_IDS:
+ train_data.append(obj)
+ elif obj_id in DEFAULT_VAL_IDS:
+ val_data.append(obj)
+ else:
+ unknown_ids.append(obj_id)
+
+ if len(train_data) + len(val_data) != len(data):
+ missing = len(data) - (len(train_data) + len(val_data))
+ examples = ", ".join(unknown_ids) if unknown_ids else "(none)"
+ raise ValueError(
+ f"{missing} samples in {input_file} not found in train_ref/val_ref id sets. Examples: {examples}",
+ )
with open(train_file, "w", encoding="utf-8") as f:
for item in train_data:
@@ -29,6 +59,11 @@ if __name__ == "__main__":
parser.add_argument("--train", required=True, help="Path to output train file")
parser.add_argument("--val", required=True, help="Path to output validation file")
parser.add_argument("--ratio", type=float, default=0.5, help="Train ratio (default: 0.8)")
-
+ parser.add_argument(
+ "--random",
+ action="store_true",
+ help="Whether to randomly split input into train/val. "
+ "If false, split strictly by default train/val id sets (see default_ids.py).",
+ )
args = parser.parse_args()
- split_jsonl(args.input, args.train, args.val, args.ratio)
+ split_jsonl(args.input, args.train, args.val, args.ratio, args.random)
diff --git a/benchmark/halumem/scripts.sh b/benchmark/halumem/cat_correct_scripts.sh
similarity index 100%
rename from benchmark/halumem/scripts.sh
rename to benchmark/halumem/cat_correct_scripts.sh
diff --git a/benchmark/halumem/eval_scripts.sh b/benchmark/halumem/eval_scripts.sh
new file mode 100755
index 00000000..c8a06881
--- /dev/null
+++ b/benchmark/halumem/eval_scripts.sh
@@ -0,0 +1,5 @@
+clear && python benchmark/halumem/eval_reme.py \
+ --data_path /Users/yuli/workspace/HaluMem/data/HaluMem-Medium.jsonl \
+ --reme_model_name qwen3.5-plus \
+ --batch_size 10000 \
+ --algo_version default
\ No newline at end of file
diff --git a/benchmark/locomo/eval_reme.py b/benchmark/locomo/eval_reme.py
new file mode 100644
index 00000000..82d91031
--- /dev/null
+++ b/benchmark/locomo/eval_reme.py
@@ -0,0 +1,1107 @@
+"""
+Simplified evaluation script for ReMe on Locomo benchmark.
+
+This script performs a simplified evaluation pipeline:
+1. Load Locomo data
+2. Process each user's sessions with ReMe (summary + retrieve)
+3. Evaluate question answering
+4. Generate metrics and statistics
+
+Usage:
+ python bench/halumem/eval_reme_simple.py --data_path locomo10.json \
+ --top_k 20 --user_num 100 --max_concurrency 20
+"""
+
+import asyncio
+import json
+import os
+import re
+import shutil
+import time
+from pathlib import Path
+from datetime import datetime, timezone, timedelta
+from dataclasses import dataclass
+from typing import Any
+import yaml
+from loguru import logger
+from reme.core.enumeration import Role
+from reme.core.schema import Message
+
+
+from reme.reme import ReMe
+
+
+# ==================== Configuration ====================
+@dataclass
+class EvalConfig:
+ """Evaluation configuration parameters."""
+
+ data_path: str
+ top_k: int = 20
+ user_num: int = 1
+ max_concurrency: int = 2
+ batch_size: int = 40
+ output_dir: str = "bench_results/reme"
+ reme_model_name: str = "qwen-flash"
+ eval_model_name: str = "qwen3-max"
+ algo_version: str = "locomo"
+ enable_thinking_params: bool = False
+
+
+# ==================== Utilities ====================
+
+
+class DataLoader:
+ """Handles loading and parsing of HaluMem data."""
+
+ @staticmethod
+ def load_jsonl(file_path: str) -> list[dict]:
+ """Load all entries from a JSONL file."""
+ with open(file_path, "r", encoding="utf-8") as f:
+ return [json.loads(line.strip()) for line in f if line.strip()]
+
+ @staticmethod
+ def load_json(file_path: str) -> dict:
+ """Load dict from a JSON file."""
+ with open(file_path, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+ @staticmethod
+ def format_dialogue_messages(
+ dialogue: list[dict],
+ speaker_a: str,
+ base_timestamp: datetime,
+ time_interval: int,
+ ) -> list[dict]:
+ """Format dialogue into ReMe message format with conversation_time."""
+
+ return [
+ {
+ "role": "user" if turn["speaker"] == speaker_a else "assistant",
+ "name": turn["speaker"],
+ "content": turn["text"],
+ "time_created": (base_timestamp + timedelta(seconds=idx * time_interval)).strftime("%Y-%m-%d %H:%M:%S"),
+ }
+ for idx, turn in enumerate(dialogue)
+ ]
+
+ @staticmethod
+ def format_dialogue_for_eval(dialogue: list[dict], user_name: str = None) -> str:
+ """Format dialogue into string for evaluation."""
+ formatted_turns = []
+ for turn in dialogue:
+ timestamp = (
+ datetime.strptime(
+ turn["timestamp"],
+ "%b %d, %Y, %H:%M:%S",
+ )
+ .replace(tzinfo=timezone.utc)
+ .strftime("%Y-%m-%d %H:%M:%S")
+ )
+
+ # Use user_name if role is 'user' and user_name is provided
+ role = user_name if turn["role"] == "user" and user_name else turn["role"]
+
+ formatted_turns.append(
+ f"Role: {role}\n" f"Content: {turn['content']}\n" f"Time: {timestamp}",
+ )
+ return "\n\n".join(formatted_turns)
+
+
+class FileManager:
+ """Manages file I/O operations."""
+
+ def __init__(self, base_dir: str):
+ self.base_dir = Path(base_dir)
+ self.tmp_dir = self.base_dir
+ self.tmp_dir.mkdir(parents=True, exist_ok=True)
+
+ def get_user_dir(self, user_name: str) -> Path:
+ """Get the directory path for a user."""
+ user_dir = self.tmp_dir / user_name
+ user_dir.mkdir(parents=True, exist_ok=True)
+ return user_dir
+
+ def get_session_file(self, user_name: str, session_id: int) -> Path:
+ """Get the file path for a specific session."""
+ return self.get_user_dir(user_name) / f"session_{session_id}.json"
+
+ def get_question_file(self, user_name: str) -> Path:
+ """Get the file path for a specific question."""
+ return self.get_user_dir(user_name) / "questions.json"
+
+ def save_session(self, user_name: str, session_id: int, data: dict):
+ """Save session data to file."""
+ file_path = self.get_session_file(user_name, session_id)
+ with open(file_path, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ logger.info(f"✅ Saved session {session_id} to {file_path}")
+
+ def save_question(self, user_name: str, data: dict):
+ """Save question data to file"""
+ file_path = self.get_question_file(user_name)
+ with open(file_path, "w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ logger.info(f"✅ Saved question to {file_path}")
+
+ def load_session(self, user_name: str, session_id: int) -> dict | None:
+ """Load session data from file."""
+ file_path = self.get_session_file(user_name, session_id)
+ if not file_path.exists():
+ return None
+ with open(file_path, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+ def user_has_cache(self, user_name: str) -> bool:
+ """Check if user has cached results."""
+ user_dir = self.get_user_dir(user_name)
+ return any(f.name.startswith("session_") and f.suffix == ".json" for f in user_dir.iterdir())
+
+ def combine_results(self, output_file: str):
+ """Combine all user session files into a single JSONL file."""
+ with open(output_file, "w", encoding="utf-8") as f_out:
+ for user_dir in self.tmp_dir.iterdir():
+ if not user_dir.is_dir():
+ continue
+
+ session_files = sorted(
+ [f for f in user_dir.iterdir() if f.name.startswith("session_") and f.suffix == ".json"],
+ )
+
+ if not session_files:
+ continue
+
+ # Load first session to get user metadata
+ with open(session_files[0], "r", encoding="utf-8") as f_in:
+ first_session = json.load(f_in)
+
+ user_data = {
+ "uuid": first_session["uuid"],
+ "user_name": first_session["user_name"],
+ "sessions": [],
+ }
+
+ # Load all sessions
+ for session_file in session_files:
+ with open(session_file, "r", encoding="utf-8") as f_in:
+ session_data = json.load(f_in)
+ # Remove redundant user metadata
+ session_data.pop("uuid", None)
+ session_data.pop("user_name", None)
+ user_data["sessions"].append(session_data)
+
+ question_file = user_dir / "questions.json"
+ if not question_file.exists():
+ continue
+ with open(question_file, "r", encoding="utf-8") as f_in:
+ question_data = json.load(f_in)
+ user_data["evaluation_results"] = {
+ "question_answering_records": question_data,
+ }
+
+ f_out.write(json.dumps(user_data, ensure_ascii=False) + "\n")
+
+
+# ==================== Memory Operations ====================
+
+
+class MemoryProcessor:
+ """Handles ReMe memory operations."""
+
+ def __init__(
+ self,
+ reme: ReMe,
+ eval_model_name: str = "qwen3-max",
+ algo_version: str = "locomo",
+ enable_thinking_params: bool = False,
+ ):
+ self.reme = reme
+ self.eval_model_name = eval_model_name
+ self.algo_version = algo_version
+ self.enable_thinking_params = enable_thinking_params
+
+ async def add_memories(
+ self,
+ user_id: str,
+ messages: list[dict],
+ batch_size: int = 10000,
+ ) -> tuple[list[str], list, float]:
+ """
+ Add memories in batches using ReMe and return extracted memory contents.
+
+ Returns:
+ tuple: (extracted_memories, agent_messages, total_duration_ms)
+ """
+ extracted_memories = []
+ summary_messages = []
+ total_duration_ms = 0
+
+ for i in range(0, len(messages), batch_size):
+ batch = messages[i : i + batch_size]
+ start = time.time()
+
+ # Use new summary API
+ result = await self.reme.summarize_memory(
+ messages=batch,
+ user_name=user_id,
+ version=self.algo_version,
+ return_dict=True,
+ enable_time_filter=True,
+ enable_thinking_params=self.enable_thinking_params,
+ )
+
+ duration_ms = (time.time() - start) * 1000
+ total_duration_ms += duration_ms
+
+ extracted_memories.extend([m.model_dump(exclude_none=True) for m in result["answer"]])
+ summary_messages.extend([m.simple_dump(enable_argument_dict=True) for m in result["messages"]])
+
+ return extracted_memories, summary_messages, total_duration_ms
+
+ async def search_memory(
+ self,
+ query: str,
+ user_id: str,
+ top_k: int = 20,
+ ) -> tuple[dict, list, float]:
+ """
+ Search memory using ReMe and return structured answer with reasoning.
+
+ Returns:
+ tuple: (answer_dict, agent_messages, duration_ms)
+ answer_dict contains: {"reasoning": str, "answer": str, "memories": str}
+ """
+ start = time.time()
+
+ # Retrieve memories from ReMe using new API
+ result = await self.reme.retrieve_memory(
+ query=query,
+ retrieve_top_k=top_k,
+ user_name=user_id,
+ version=self.algo_version,
+ return_dict=True,
+ enable_time_filter=True,
+ enable_thinking_params=self.enable_thinking_params,
+ )
+
+ # Extract memories from response
+ memories = result["answer"]
+ agent_messages = [x.simple_dump(enable_argument_dict=True) for x in result["messages"]]
+ retrieved_nodes = [x.model_dump(exclude_none=True) for x in result["retrieved_nodes"]]
+
+ # Use LLM to generate structured answer from memories
+ answer_result = await answer_question_with_memories(
+ reme=self.reme,
+ question=query,
+ memories=memories,
+ user_id=user_id,
+ model_name=self.eval_model_name,
+ )
+
+ # Add original memories to the result
+ answer_result["memories"] = memories
+ answer_result["retrieved_nodes"] = retrieved_nodes
+
+ duration_ms = (time.time() - start) * 1000
+ return answer_result, agent_messages, duration_ms
+
+
+# ==================== Evaluation Functions ====================
+
+
+async def answer_question_with_memories(
+ reme: ReMe,
+ question: str,
+ memories: str,
+ user_id: str = None,
+ model_name: str = "qwen3-30b-a3b-instruct-2507",
+):
+ """
+ Answer a question using retrieved memories with PROMPT_MEMZERO_JSON template.
+
+ Args:
+ reme: ReMe instance with default_llm and prompt_handler
+ question: The question to answer
+ memories: The retrieved memories (formatted as context)
+ user_id: Optional user ID for context formatting
+ model_name: Model name to use for LLM request
+
+ Returns:
+ dict with 'reasoning' and 'answer' fields
+ """
+ # Format context with memories
+ if user_id:
+ context = reme.prompt_handler.prompt_format(
+ "TEMPLATE_MEMOS",
+ user_id=user_id,
+ memories=memories,
+ )
+ else:
+ context = f"Memories:\n{memories}"
+
+ # Use PROMPT_MEMZERO_JSON template for structured JSON response
+ prompt = reme.prompt_handler.prompt_format(
+ "PROMPT_MEMZERO_JSON",
+ context=context,
+ question=question,
+ )
+
+ result = await reme.get_llm("qwen3_max_instruct").simple_request_for_json(
+ prompt=prompt,
+ model_name=model_name,
+ )
+
+ return result
+
+
+async def evaluation_for_question(
+ reme: ReMe,
+ question: str,
+ golden_answer: str,
+ generated_answer: str,
+ model_name: str = "qwen3-max",
+):
+ """
+ Question-Answering Evaluation with optional Dialogue Context.
+
+ Args:
+ reme: ReMe instance with default_llm and prompt_handler
+ question: The question string to be evaluated.
+ golden_answer: The reference (gold-standard) answer.
+ generated_answer: The answer produced by the memory system.
+ model_name: Model name to use for LLM request
+
+ Returns:
+ dict with 'reasoning' and 'evaluation_result' fields
+ """
+ await asyncio.sleep(10)
+ # Use configured prompts
+ system_prompt = reme.prompt_handler.prompt_format(
+ "SYSTEM_PROMPT",
+ )
+ user_prompt = reme.prompt_handler.prompt_format(
+ "USER_PROMPT",
+ question=question,
+ golden_answer=golden_answer,
+ generated_answer=generated_answer,
+ )
+
+ reme_result = await reme.get_llm("qwen3_max_instruct").chat(
+ messages=[
+ Message(role=Role.SYSTEM, content=system_prompt),
+ Message(role=Role.USER, content=user_prompt),
+ ],
+ model_name=model_name,
+ )
+
+ content = reme_result.content
+ match = re.search(r'"label"\s*:\s*"([^"]*?)"', content)
+ if match:
+ label = match.group(1)
+ else:
+ label = "WRONG"
+ result = {
+ "reasoning": content,
+ "evaluation_result": label.strip().upper() == "CORRECT",
+ }
+ return result
+
+
+# ==================== Evaluation ====================
+
+
+class QuestionAnsweringEvaluator:
+ """Evaluates question answering performance."""
+
+ def __init__(self, memory_processor: MemoryProcessor, reme: ReMe, top_k: int, eval_model_name: str = "qwen3-max"):
+ self.memory_processor = memory_processor
+ self.reme = reme
+ self.top_k = top_k
+ self.eval_model_name = eval_model_name
+
+ async def evaluate_questions(
+ self,
+ questions: list[dict],
+ user_name: str,
+ uuid: str,
+ ) -> list[dict]:
+ """Evaluate all questions for a conversation."""
+ results = []
+
+ for qa in questions:
+ if qa["category"] == 5:
+ continue
+ answer_dict, agent_messages, duration_ms = await self.memory_processor.search_memory(
+ query=qa["question"],
+ user_id=user_name,
+ top_k=self.top_k,
+ )
+
+ # Extract answer and reasoning from the structured response
+ system_answer = answer_dict.get("answer", "")
+ system_reasoning = answer_dict.get("reasoning", "")
+ retrieved_memories = answer_dict.get("memories", "")
+ retrieved_nodes = answer_dict.get("retrieved_nodes", "")
+
+ # Evaluate response
+ eval_result = await evaluation_for_question(
+ reme=self.reme,
+ question=qa["question"],
+ golden_answer=qa["answer"],
+ generated_answer=system_answer,
+ model_name=self.eval_model_name,
+ )
+
+ eval_result_original_answer = await evaluation_for_question(
+ reme=self.reme,
+ question=qa["question"],
+ golden_answer=qa["answer"],
+ generated_answer=retrieved_memories,
+ model_name=self.eval_model_name,
+ )
+
+ # Build result record
+ qa_result = {
+ **qa,
+ "uuid": uuid,
+ "system_response": system_answer,
+ "system_reasoning": system_reasoning,
+ "retrieved_memories": retrieved_memories,
+ "retrieved_nodes": retrieved_nodes,
+ "retrieve_messages": agent_messages,
+ "search_duration_ms": duration_ms,
+ "result_type": eval_result.get("evaluation_result"),
+ "question_answering_reasoning": eval_result.get("reasoning", ""),
+ "original_result_type": eval_result_original_answer.get("evaluation_result"),
+ "original_question_answering_reasoning": eval_result_original_answer.get("reasoning", ""),
+ }
+ results.append(qa_result)
+
+ return results
+
+
+class MetricsAggregator:
+ """Aggregates evaluation metrics."""
+
+ @staticmethod
+ def _compute_single_metric(qa_records: list[dict], result_key: str) -> dict[str, Any]:
+ """Compute metrics for a single result type key."""
+ total = len(qa_records)
+ if total == 0:
+ return {
+ "correct_qa_ratio(all)": 0,
+ "correct_qa_ratio(valid)": 0,
+ "qa_valid_num": 0,
+ "qa_num": 0,
+ "category_1_accuracy": 0.0,
+ "category_2_accuracy": 0.0,
+ "category_3_accuracy": 0.0,
+ "category_4_accuracy": 0.0,
+ }
+
+ correct = 0
+ valid = 0
+
+ category_1_correct = 0
+ category_1_num = 0
+ category_1_valid = 0
+ category_2_correct = 0
+ category_2_num = 0
+ category_2_valid = 0
+ category_3_correct = 0
+ category_3_num = 0
+ category_3_valid = 0
+ category_4_correct = 0
+ category_4_num = 0
+ category_4_valid = 0
+
+ for qa in qa_records:
+ result_type = qa.get(result_key, "")
+ category = qa.get("category", 0)
+ if category == 1:
+ category_1_num += 1
+ elif category == 2:
+ category_2_num += 1
+ elif category == 3:
+ category_3_num += 1
+ elif category == 4:
+ category_4_num += 1
+
+ if result_type is not None and category in [1, 2, 3, 4]:
+ valid += 1
+ if result_type is True:
+ correct += 1
+
+ if category == 1:
+ category_1_valid += 1
+ if result_type is True:
+ category_1_correct += 1
+ elif category == 2:
+ category_2_valid += 1
+ if result_type is True:
+ category_2_correct += 1
+ elif category == 3:
+ category_3_valid += 1
+ if result_type is True:
+ category_3_correct += 1
+ elif category == 4:
+ category_4_valid += 1
+ if result_type is True:
+ category_4_correct += 1
+
+ metrics = {
+ "correct_qa_ratio(all)": correct / total,
+ "qa_valid_num": valid,
+ "qa_num": total,
+ "category_1_accuracy": category_1_correct / category_1_num if category_1_num > 0 else 0,
+ "category_1_num": category_1_num,
+ "category_1_valid_num": category_1_valid,
+ "category_2_accuracy": category_2_correct / category_2_num if category_2_num > 0 else 0,
+ "category_2_num": category_2_num,
+ "category_2_valid_num": category_2_valid,
+ "category_3_accuracy": category_3_correct / category_3_num if category_3_num > 0 else 0,
+ "category_3_num": category_3_num,
+ "category_3_valid_num": category_3_valid,
+ "category_4_accuracy": category_4_correct / category_4_num if category_4_num > 0 else 0,
+ "category_4_num": category_4_num,
+ "category_4_valid_num": category_4_valid,
+ }
+
+ if valid > 0:
+ metrics.update(
+ {
+ "correct_qa_ratio(valid)": correct / valid,
+ },
+ )
+ else:
+ metrics.update(
+ {
+ "correct_qa_ratio(valid)": 0,
+ },
+ )
+
+ return metrics
+
+ @staticmethod
+ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
+ """Compute question answering metrics for both result_type and original_result_type."""
+ return {
+ "with_llm_answer": MetricsAggregator._compute_single_metric(qa_records, "result_type"),
+ "with_original_memories": MetricsAggregator._compute_single_metric(qa_records, "original_result_type"),
+ }
+
+ @staticmethod
+ def compute_time_metrics(eval_results_file: str) -> dict[str, float]:
+ """Compute timing metrics from evaluation results."""
+ add_duration = 0
+ search_duration = 0
+
+ with open(eval_results_file, "r", encoding="utf-8") as f:
+ for line in f:
+ if not line.strip():
+ continue
+ user_data = json.loads(line)
+
+ for session in user_data["sessions"]:
+ add_duration += session.get("add_dialogue_duration_ms", 0)
+
+ eval_results = user_data.get("evaluation_results", {})
+ for qa in eval_results.get("question_answering_records", []):
+ search_duration += qa.get("search_duration_ms", 0)
+
+ # Convert to minutes
+ return {
+ "add_dialogue_duration_time": add_duration / 1000 / 60,
+ "search_memory_duration_time": search_duration / 1000 / 60,
+ "total_duration_time": (add_duration + search_duration) / 1000 / 60,
+ }
+
+
+# ==================== Evaluator ====================
+
+
+class LocomoEvaluator:
+ """
+ LOCOMO 评估器核心类
+ 用于评估 MemAgent 的记忆完整性、记忆准确性和问答准确性
+ """
+
+ def __init__(self, config: EvalConfig):
+ self.config = config
+ with open("eval_reme.yaml", "r", encoding="utf-8") as file:
+ data = yaml.safe_load(file)
+ self.summary_prompt_1 = data["user_message_summary_1"]
+ self.summary_prompt_2 = data["user_message_summary_2"]
+ self.retriever_prompt = data["user_message_retrieve"]
+
+ ops_dict = {
+ "personal_summarizer": {
+ "prompt_dict": {
+ "user_message_s1": self.summary_prompt_1,
+ "user_message_s2": self.summary_prompt_2,
+ },
+ },
+ "personal_retriever": {
+ "prompt_dict": {
+ "user_message": self.retriever_prompt,
+ },
+ "params": {
+ "return_memory_nodes": True,
+ },
+ },
+ }
+
+ self.reme = ReMe(
+ default_llm_config={
+ "model_name": self.config.reme_model_name,
+ },
+ ops=ops_dict,
+ )
+
+ # Load evaluation prompts into ReMe's prompt handler
+ prompts_yaml_path = Path(__file__).parent / "eval_reme.yaml"
+ self.reme.prompt_handler.load_prompt_by_file(prompts_yaml_path)
+
+ self.file_manager = FileManager(config.output_dir)
+ self.memory_processor = MemoryProcessor(
+ self.reme,
+ config.eval_model_name,
+ config.algo_version,
+ config.enable_thinking_params,
+ )
+ self.qa_evaluator = QuestionAnsweringEvaluator(
+ self.memory_processor,
+ self.reme,
+ config.top_k,
+ config.eval_model_name,
+ )
+ self.data_loader = DataLoader()
+
+ # For real-time updates
+ self._update_lock: asyncio.Lock | None = None
+ self._output_file: str | None = None
+
+ async def __aenter__(self):
+ """Async context manager entry."""
+ await self.reme.start()
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ """Async context manager exit with cleanup."""
+ await self.reme.close()
+ return False
+
+ async def process_user(self, user_data: dict) -> dict:
+ """Process all sessions for a user."""
+ speaker_a = user_data["conversation"]["speaker_a"]
+ speaker_b = user_data["conversation"]["speaker_b"]
+ uuid = f"{speaker_a}_{speaker_b}"
+ user_name = [speaker_a, speaker_b]
+ user_file_name = f"{speaker_a}_{speaker_b}"
+
+ new_user_data = {
+ "uuid": f"{user_data['conversation']['speaker_a']}_{user_data['conversation']['speaker_b']}",
+ "user_name": user_name,
+ "sessions": [],
+ "qas": [],
+ "eval_results": {},
+ }
+ logger.info(f"Processing user: {speaker_a} and {speaker_b}")
+ session_num = 19 if uuid == "Caroline_Melanie" else int(len(user_data["conversation"]) / 2 - 1)
+ time_interval = 60
+
+ # Process conversation
+ for idx in range(session_num):
+ conversation = user_data["conversation"]
+ logger.info(f"Processing user {user_name}: session {idx+1}/{session_num}")
+ session_data = {
+ "uuid": uuid,
+ "user_name": user_file_name,
+ "timestamp": conversation[f"session_{idx+1}_date_time"],
+ "session": conversation[f"session_{idx+1}"],
+ }
+
+ # Format dialogue
+ dialogue = conversation[f"session_{idx+1}"]
+ base_timestamp = parse_locomo_timestamp(session_data["timestamp"])
+ formatted_messages = self.data_loader.format_dialogue_messages(
+ dialogue,
+ speaker_a,
+ base_timestamp,
+ time_interval,
+ )
+ extracted_memories, agent_messages, duration_ms = await self.memory_processor.add_memories(
+ user_id=user_name,
+ messages=formatted_messages,
+ batch_size=self.config.batch_size,
+ )
+ session_data.update(
+ {
+ "dialogue": dialogue,
+ "extracted_memories": extracted_memories,
+ "summary_messages": agent_messages,
+ "add_dialogue_duration_ms": duration_ms,
+ },
+ )
+
+ self.file_manager.save_session(user_file_name, idx, session_data)
+
+ # Process questions
+ qas = user_data["qa"]
+ qa_results = await self.qa_evaluator.evaluate_questions(
+ questions=qas,
+ user_name=user_name,
+ uuid=uuid,
+ )
+
+ new_user_data["evaluation_results"] = {
+ "question_answering_records": qa_results,
+ }
+ self.file_manager.save_question(user_file_name, qa_results)
+
+ # Update results file after each conversation completes
+ await self._trigger_update()
+
+ return {"uuid": uuid, "user_name": user_name, "status": "ok"}
+
+ async def _trigger_update(self):
+ """Trigger real-time update of results and statistics."""
+ if self._update_lock is None or self._output_file is None:
+ return
+
+ async with self._update_lock:
+ self.file_manager.combine_results(self._output_file)
+ self._update_statistics(self._output_file)
+
+ async def run_evaluation(self):
+ """Run the complete evaluation pipeline using ReMe."""
+ start_time = time.time()
+
+ # Load user data first to get user names
+ all_users = self.data_loader.load_json(self.config.data_path)
+ users_to_process = all_users[: self.config.user_num]
+
+ # Extract all user names and delete all profiles
+ all_user_names = [
+ f"{user_data['conversation']['speaker_a']}_&_{user_data['conversation']['speaker_b']}"
+ for user_data in all_users
+ ]
+ if all_user_names:
+ for user_name in all_user_names:
+ self.reme.get_profile_handler(user_name).delete_all()
+ logger.info(f"Deleted all profiles for {len(all_user_names)} users")
+
+ # Clear existing data
+ await self.reme.default_vector_store.delete_all()
+
+ # Clear meta_memory directory
+ meta_memory_path = Path(f"meta_memory/{self.reme.default_vector_store.collection_name}")
+ if meta_memory_path.exists():
+ shutil.rmtree(meta_memory_path)
+ logger.info(f"Cleared meta_memory directory: {meta_memory_path}")
+ meta_memory_path.mkdir(parents=True, exist_ok=True)
+
+ print("\n" + "=" * 80)
+ print("LOCOMO EVALUATION - REME - QUESTION ANSWERING")
+ print(f"Users: {len(users_to_process)} | Concurrency: {self.config.max_concurrency}")
+ print("=" * 80 + "\n")
+
+ # Output file path for real-time updates
+ self._output_file = os.path.join(self.config.output_dir, "eval_results.jsonl")
+
+ # Lock for thread-safe file updates
+ self._update_lock = asyncio.Lock()
+
+ # Process users with concurrency control
+ semaphore = asyncio.Semaphore(self.config.max_concurrency)
+
+ async def process_with_cache_check(idx: int, user_data: dict):
+ async with semaphore:
+ user_name = f"{user_data['conversation']['speaker_a']}_{user_data['conversation']['speaker_b']}"
+
+ # Check cache
+ if self.file_manager.user_has_cache(user_name):
+ print(f"⚡ [{idx}/{len(users_to_process)}] Skipping {user_name} (cached)")
+ result = {"user_name": user_name, "status": "cached"}
+ # Also trigger update for cached users
+ await self._trigger_update()
+ else:
+ print(f"🔄 [{idx}/{len(users_to_process)}] Processing {user_name}...")
+ result = await self.process_user(user_data)
+ print(f"✅ [{idx}/{len(users_to_process)}] Completed {user_name}")
+
+ return result
+
+ tasks = [process_with_cache_check(idx, user) for idx, user in enumerate(users_to_process, 1)]
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ elapsed = time.time() - start_time
+ print(f"\n✅ Processing completed in {elapsed:.2f}s")
+ print(f"📁 Results: {self._output_file}\n")
+
+ # Final aggregation and report
+ await self.aggregate_and_report(self._output_file)
+
+ def _update_statistics(self, results_file: str):
+ """Update statistics file based on current results (for real-time monitoring)."""
+ if not os.path.exists(results_file):
+ return
+
+ # Collect all QA records
+ qa_records = []
+ try:
+ with open(results_file, "r", encoding="utf-8") as f:
+ for line in f:
+ if not line.strip():
+ continue
+ user_data = json.loads(line)
+ eval_results = user_data.get("evaluation_results", {})
+ qa_records.extend(
+ eval_results.get("question_answering_records", []),
+ )
+ except (json.JSONDecodeError, KeyError):
+ return
+
+ if not qa_records:
+ return
+
+ # Compute metrics
+ qa_metrics = MetricsAggregator.compute_qa_metrics(qa_records)
+ time_metrics = MetricsAggregator.compute_time_metrics(results_file)
+
+ final_results = {
+ "overall_score": {
+ "question_answering": qa_metrics,
+ "time_consuming": time_metrics,
+ },
+ "question_answering_records": qa_records,
+ }
+
+ # Save statistics
+ report_file = os.path.join(self.config.output_dir, "eval_statistics.json")
+ with open(report_file, "w", encoding="utf-8") as f:
+ json.dump(final_results, f, ensure_ascii=False, indent=4)
+
+ async def aggregate_and_report(self, results_file: str):
+ """Aggregate results and generate final report."""
+ print("=" * 80)
+ print("AGGREGATING METRICS")
+ print("=" * 80 + "\n")
+
+ # Collect all QA records
+ qa_records = []
+ print(results_file)
+ with open(results_file, "r", encoding="utf-8") as f:
+ for line in f:
+ if not line.strip():
+ continue
+ user_data = json.loads(line)
+ print(user_data)
+ eval_results = user_data.get("evaluation_results", {})
+ qa_records.extend(
+ eval_results.get("question_answering_records", []),
+ )
+
+ # Compute metrics
+ qa_metrics = MetricsAggregator.compute_qa_metrics(qa_records)
+ time_metrics = MetricsAggregator.compute_time_metrics(results_file)
+
+ final_results = {
+ "overall_score": {
+ "question_answering": qa_metrics,
+ "time_consuming": time_metrics,
+ },
+ "question_answering_records": qa_records,
+ }
+
+ # Save final report
+ report_file = os.path.join(self.config.output_dir, "eval_statistics.json")
+ with open(report_file, "w", encoding="utf-8") as f:
+ json.dump(final_results, f, ensure_ascii=False, indent=4)
+
+ print(f"📊 Statistics saved to: {report_file}\n")
+
+ # Print summary
+ self._print_summary(qa_metrics, time_metrics)
+
+ def _print_summary(self, qa_metrics: dict, time_metrics: dict):
+ """Print evaluation summary."""
+ print("=" * 80)
+ print("EVALUATION SUMMARY - REME")
+ print("=" * 80 + "\n")
+
+ # Print metrics for LLM-generated answer (result_type)
+ llm_metrics = qa_metrics["with_llm_answer"]
+ print(llm_metrics)
+ print("📊 Question Answering (with LLM answer):")
+ print(f" Correct (all): {llm_metrics['correct_qa_ratio(all)']:.4f}")
+ print(f" Correct (valid): {llm_metrics['correct_qa_ratio(valid)']:.4f}")
+ print(f" Valid/Total: {llm_metrics['qa_valid_num']}/{llm_metrics['qa_num']}")
+ print(f" Category 1 Accuracy: {llm_metrics['category_1_accuracy']:.4f}")
+ print(f" Category 2 Accuracy: {llm_metrics['category_2_accuracy']:.4f}")
+ print(f" Category 3 Accuracy: {llm_metrics['category_3_accuracy']:.4f}")
+ print(f" Category 4 Accuracy: {llm_metrics['category_4_accuracy']:.4f}")
+
+ # Print metrics for original retrieved memories (original_result_type)
+ orig_metrics = qa_metrics["with_original_memories"]
+ print("\n📊 Question Answering (with original memories):")
+ print(f" Correct (all): {orig_metrics['correct_qa_ratio(all)']:.4f}")
+ print(f" Correct (valid): {orig_metrics['correct_qa_ratio(valid)']:.4f}")
+ print(f" Valid/Total: {orig_metrics['qa_valid_num']}/{orig_metrics['qa_num']}")
+ print(f" Category 1 Accuracy: {orig_metrics['category_1_accuracy']:.4f}")
+ print(f" Category 2 Accuracy: {orig_metrics['category_2_accuracy']:.4f}")
+ print(f" Category 3 Accuracy: {orig_metrics['category_3_accuracy']:.4f}")
+ print(f" Category 4 Accuracy: {orig_metrics['category_4_accuracy']:.4f}")
+
+ print("\n⏱️ Time Metrics:")
+ print(f" Memory Addition: {time_metrics['add_dialogue_duration_time']:.2f} min")
+ print(f" Memory Search: {time_metrics['search_memory_duration_time']:.2f} min")
+ print(f" Total: {time_metrics['total_duration_time']:.2f} min")
+ print("\n" + "=" * 80)
+
+
+def parse_locomo_timestamp(timestamp_str: str):
+ """
+ Parse LoCoMo timestamp format.
+
+ Input format: "6:07 pm on 13 January, 2023"
+ Special value: "Unknown" or unparseable returns None
+ Output: datetime object or None
+ """
+ # Clean string
+ timestamp_str = timestamp_str.replace("\\s+", " ").strip()
+
+ # Handle special cases: Unknown or empty string
+ if timestamp_str.lower() == "unknown" or not timestamp_str:
+ # No time information, return None
+ return None
+
+ try:
+ return datetime.strptime(timestamp_str, "%I:%M %p on %d %B, %Y")
+ except ValueError:
+ # If parse fails, return None and print warning
+ print(f"⚠️ Warning: Failed to parse timestamp '{timestamp_str}', no timestamp will be set")
+ return None
+
+
+# ==================== Main Pipeline ====================
+
+
+async def main_async(
+ data_path: str,
+ top_k: int,
+ user_num: int,
+ max_concurrency: int,
+ reme_model_name: str = "qwen-flash",
+ eval_model_name: str = "qwen3-max",
+ algo_version: str = "halumem",
+ enable_thinking_params: bool = False,
+):
+ """Main async entry point for ReMe evaluation with proper resource cleanup."""
+ config = EvalConfig(
+ data_path=data_path,
+ top_k=top_k,
+ user_num=user_num,
+ max_concurrency=max_concurrency,
+ reme_model_name=reme_model_name,
+ eval_model_name=eval_model_name,
+ algo_version=algo_version,
+ enable_thinking_params=enable_thinking_params,
+ )
+
+ # Use async context manager for automatic cleanup
+ async with LocomoEvaluator(config) as evaluator:
+ await evaluator.run_evaluation()
+
+
+def main(
+ data_path: str,
+ top_k: int = 20,
+ user_num: int = 1,
+ max_concurrency: int = 2,
+ reme_model_name: str = "qwen-flash",
+ eval_model_name: str = "qwen3-max",
+ algo_version: str = "halumem",
+ enable_thinking_params: bool = False,
+):
+ """Synchronous entry point."""
+ asyncio.run(
+ main_async(
+ data_path=data_path,
+ top_k=top_k,
+ user_num=user_num,
+ max_concurrency=max_concurrency,
+ reme_model_name=reme_model_name,
+ eval_model_name=eval_model_name,
+ algo_version=algo_version,
+ enable_thinking_params=enable_thinking_params,
+ ),
+ )
+
+
+if __name__ == "__main__":
+ import argparse
+
+ parser = argparse.ArgumentParser(description="Simplified evaluation for ReMe on Locomo benchmark")
+ parser.add_argument(
+ "--data_path",
+ type=str,
+ required=True,
+ help="Path to Locomo data file (e.g., locomo10.jsonl)",
+ )
+ parser.add_argument(
+ "--top_k",
+ type=int,
+ default=20,
+ help="Number of top memories to retrieve (default: 20)",
+ )
+ parser.add_argument(
+ "--user_num",
+ type=int,
+ default=1,
+ help="Number of users to evaluate (default: 1)",
+ )
+ parser.add_argument(
+ "--max_concurrency",
+ type=int,
+ default=2,
+ help="Maximum concurrency for processing (default: 2)",
+ )
+ parser.add_argument(
+ "--reme_model_name",
+ type=str,
+ default="qwen-flash",
+ help="Model name for ReMe (default: qwen-flash)",
+ )
+ parser.add_argument(
+ "--eval_model_name",
+ type=str,
+ default="qwen3-max",
+ help="Model name for evaluation (default: qwen3-max)",
+ )
+ parser.add_argument(
+ "--algo_version",
+ type=str,
+ default="default",
+ help="Algorithm version for summary and retrieval (default: halumem)",
+ )
+ parser.add_argument(
+ "--enable_thinking_params",
+ action="store_true",
+ default=True,
+ help="Enable thinking parameters for summary and retrieval (default: False)",
+ )
+
+ args = parser.parse_args()
+ print(f"args={args}!")
+
+ main(
+ data_path=args.data_path,
+ top_k=args.top_k,
+ user_num=args.user_num,
+ max_concurrency=args.max_concurrency,
+ reme_model_name=args.reme_model_name,
+ eval_model_name=args.eval_model_name,
+ algo_version=args.algo_version,
+ enable_thinking_params=args.enable_thinking_params,
+ )
diff --git a/benchmark/locomo/eval_reme.yaml b/benchmark/locomo/eval_reme.yaml
new file mode 100644
index 00000000..113431b3
--- /dev/null
+++ b/benchmark/locomo/eval_reme.yaml
@@ -0,0 +1,180 @@
+TEMPLATE_MEMOS: |
+ Memories for user {user_id}:
+ {memories}
+
+PROMPT_MEMZERO_JSON: |
+ # CONTEXT:
+ {context}
+
+ # CONTEXT PRIORITY:
+ When the context contains information from multiple sources, follow this strict priority order:
+ 1. **Historical Dialogue** (highest priority) - Direct conversation content
+ 2. **Extracted Memories** (medium priority) - Summarized memory points
+ 3. **User Profile** (lowest priority) - General user information
+
+ # Question:
+ {question}
+
+ # INSTRUCTIONS:
+ 1. Carefully analyze all provided memories (facts and entities)
+ 2. Pay special attention to the timestamps (event_time) to determine when events occurred
+ 3. If the question asks about a specific event or fact, look for direct evidence in the memories
+ 4. If the memories contain contradictory information, prioritize the most recent memory
+ 5. Always convert relative time references to specific dates, months, or years
+ 6. Be as specific as possible when talking about people, places, and events
+ 7. Timestamps in memories represent the time the event was mentioned in a message, not the actual time the event occurred
+
+
+ # OUTPUT FORMAT:
+ Please provide your response in the following JSON format:
+
+ ```json
+ {{
+ "reasoning": "reasoning content",
+ "answer": "Provide a detailed answer"
+ }}
+ ```
+
+SYSTEM_PROMPT: |
+ You are an expert grader that determines if answers to questions match a gold standard answer
+
+USER_PROMPT: |
+ Your task is to label an answer to a question as 'CORRECT' or 'WRONG'. You will be given the following data:
+ (1) a question (posed by one user to another user),
+ (2) a 'gold' (ground truth) answer,
+ (3) a generated answer
+ which you will score as CORRECT/WRONG.
+
+ The point of the question is to ask about something one user should know about the other user based on their prior conversations.
+ The gold answer will usually be a concise and short answer that includes the referenced topic, for example:
+ Question: Do you remember what I got the last time I went to Hawaii?
+ Gold answer: A shell necklace
+ The generated answer might be much longer, but you should be generous with your grading - as long as it touches on the same topic as the gold answer, it should be counted as CORRECT.
+
+ For time related questions, the gold answer will be a specific date, month, year, etc. The generated answer might be much longer or use relative time references (like "last Tuesday" or "next month"), but you should be generous with your grading - as long as it refers to the same date or time period as the gold answer, it should be counted as CORRECT. Even if the format differs (e.g., "May 7th" vs "7 May"), consider it CORRECT if it's the same date.
+
+ Now it's time for the real question:
+ Question: {question}
+ Gold answer: {golden_answer}
+ Generated answer: {generated_answer}
+
+ First, provide a short (one sentence) explanation of your reasoning, then finish with CORRECT or WRONG.
+ Do NOT include both CORRECT and WRONG in your response, or it will break the evaluation script.
+
+ Just return the label CORRECT or WRONG in a json format with the key as "label".
+
+user_message_summary_1: |
+ You are a Memory Agent responsible for managing {memory_type} memories about {memory_target}.
+
+ ## Latest Conversation
+ Format: round [] :
+ {context}
+
+ ## Task
+ ### Step 1: Create Memory Draft
+ Use `add_draft_and_retrieve_similar_memory` to create a memory draft list based on the latest conversation.
+ - For each memory draft, fill in the required parameters:
+ * `message_time`: timestamp from the conversation (e.g., '2020-01-01 00:00:00')
+ * `memory_content`: concise memory content extracted from the conversation
+ - Use actual names from the conversation (e.g., "Bob likes apples") instead of generic references (e.g., "user likes apples")
+ - Extract all important information comprehensively—do not miss critical details, but avoid any fabrications or unfounded assumptions
+ - The tool will retrieve similar historical memories via vector search to help you in Step 2
+
+ ### Step 2: Add Memories
+ Review each memory draft from Step 1 and compare it with the retrieved historical memories, then use `add_memory` to manage all memories in one call:
+
+ - For each new memory, fill in the required parameters:
+ * `message_time`: timestamp from the conversation (e.g., '2020-01-01 00:00:00')
+ * `memory_content`: memory content
+ - Add memories when:
+ * The draft contains new information not present in historical memories
+
+
+ **General Guidelines:**
+ - **Skip** drafts if their content is already fully covered by historical memories (avoid redundancy)
+ - You can add memories in a single `add_memory` tool call
+
+user_message_summary_2: |
+ You are a Profile Agent responsible for managing profiles about {memory_target}.
+
+ ## Latest Conversation
+ Format: round [] :
+ {context}
+
+ ## Current Profiles
+ {profiles}
+
+ ## Task
+ Analyze the Latest Conversation and use `update_profiles` to manage profiles (both updates and additions in one call):
+
+ **For profiles_to_update** (updating existing profiles):
+ - For each profile to update, fill in the required parameters:
+ * `profile_id`: ID of the profile to update (from Current Profiles)
+ * `message_time`: timestamp from the conversation (e.g., '2020-01-01 00:00:00')
+ * `profile_key`: profile key or category (e.g., 'name', 'age', 'occupation')
+ * `profile_value`: updated profile value, please be concise. (e.g., 'John Smith')
+
+ **For profiles_to_add** (adding new profiles):
+ - For each new profile, fill in the required parameters:
+ * `message_time`: timestamp from the conversation (e.g., '2020-01-01 00:00:00')
+ * `profile_key`: profile key or category (e.g., 'name', 'age', 'occupation')
+ * `profile_value`: profile value (e.g., 'John Smith')
+ - Add profiles when:
+ * The information represents a new distinct profile not present in Current Profiles
+ * The profile key doesn't exist in Current Profiles
+ * The information cannot be merged into existing profiles
+
+ **General Guidelines:**
+ - Extract all important information comprehensively—do not miss critical details, but avoid any fabrications or unfounded assumptions
+ - You can update and add profiles in a single tool call
+
+user_message_retrieve: |
+ You are a Memory Retrieval Agent specialized in retrieving {memory_type} memories about {memory_target}.
+
+ ## User Profile
+ {user_profile}
+
+ ## User Question
+ {context}
+
+ ## Multi-Phase Retrieval Strategy
+ Follow these phases sequentially to gather comprehensive information:
+
+ ### Phase 1: Semantic Search (No Time Filter)
+ **Tool**: `retrieve_memory` (without time constraints)
+ **Objective**: Cast a wide net to find potentially relevant memories
+ **Approach**:
+ - Execute 3-5 diverse search queries using different formulations:
+ * Original question verbatim
+ * Rephrased variations (different wording, synonyms)
+ * Entity-focused queries (extract and search specific names, places, events)
+ * Keyword-based searches (core concepts, topics)
+ * Related context queries (broader themes)
+ - Review all results before proceeding to next phase
+
+ ### Phase 2: Deep Dive into History
+ **Tool**: `read_history`
+ **When to use**: After exhausting retrieval attempts OR when specific conversation context is needed
+ **Important Constraints**:
+ - Each history is very long and resource-intensive to read
+ - **Maximum limit: Read no more than 3 histories total**
+ - Only use this phase when absolutely necessary for answering the question
+ **Approach**:
+ - Extract `history_id` from retrieved memory references
+ - Prioritize the most relevant or recent histories
+ - Can read multiple histories at once by passing multiple history_ids
+ - Be selective: choose only the top 1-3 most promising histories
+ - Use this to understand the full conversation surrounding a memory
+
+ ## Response Guidelines
+ - Base your answer EXCLUSIVELY on user profile, retrieved memories, and history data
+ - Never infer, assume, or hallucinate information
+ - Always cite sources with timestamps: `[timestamp] Memory content`
+ - Present conflicting information transparently with respective timestamps
+ - If you find sufficient information to answer the user's question, you may output directly without exhausting all search phases
+ - Exhaust all search strategies before concluding information doesn't exist
+
+ ### Output any tangentially related findings, Format:
+ [timestamp] [memory/profile/history] [relevant content1]
+ [timestamp] [memory/profile/history] [relevant content2]
+
diff --git a/docs/REME2_README.md b/docs/REME2_README.md
deleted file mode 100644
index fd6174a6..00000000
--- a/docs/REME2_README.md
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-# TODO
-- [] halumem bench开发
-- [] default版本开发,for cli版本体验
-- [] cli开发
-- [] locomo bench开发
-- [] task memory迁移
-- [] mcp开发
-- [] reme外层接口完善
-- [] reme2 readme完善
-- [] 看日志,看要这个default版本怎么优化。
-- [] 学习Clawdbot记忆系统
\ No newline at end of file
diff --git a/docs/cookbook/bfcl/quickstart.md b/docs/cookbook/bfcl/quickstart.md
deleted file mode 100644
index c814b0ac..00000000
--- a/docs/cookbook/bfcl/quickstart.md
+++ /dev/null
@@ -1,121 +0,0 @@
-# BFCL
-Experiment Quick Start Guide
-
-This guide helps you quickly set up and run BFCL experiments with ReMe integration.
-
-## Env Setup
-
-### 1. BFCL installation
-
-#### clone the repository
-```bash
-git clone https://github.com/ShishirPatil/gorilla.git
-```
-
-#### Change directory to the `berkeley-function-call-leaderboard`
-```bash
-cd gorilla/berkeley-function-call-leaderboard
-```
-
-#### Install the package in editable mode
-```bash
-conda create -n bfcl-env python==3.12
-conda activate bfcl-env
-pip install -e .
-pip install -r requirements.txt
-```
-
-#### Move the dataset to the data folder under bfcl
-```bash
-cp -r bfcl_eval/data {/path/to/bfcl/data}
-```
-
-**Note**: The original BFCL data is designed as a benchmark dataset and does not have a train/validation split, you can use ``split_into_trainval.py`` to split JSONL file into train and validation sets.
-
-### 2. Collect agent trajectories on training data set
-
-Run the main experiment script to collect agent trajectories on training data set without task memory(`use_memory=False`):
-
-```bash
-python run_bfcl.py
-```
-
-**Note**:
-- `max_workers`: Number of parallel workers (default: `4`)
-- `num_runs`: Number of times each task is repeated (default: `1`)
-- `model_name`: LLM model name (default: `qwen3-8b`)
-- `enable_thinking`: Control the model's thinking mode (default: `False`)
-- `data_path`: Path to the training dataset (default: `./data/multiturn_data_base_train.jsonl`)
-- `answer_path`: Path to the possible answer, which are used to evaluate the model's output function (default: `./data/possible_answer`)
-- Results are automatically saved to `./exp_result/{model_name}/{no_think/with_think}` directory
-
-### 3. Start ReMe Service and Init the task memory pool
-
-After collecting trajectories, Launch the ReMe service (make sure you have installed ReMe environment, if not please follow the steps in the [ReMe Installation Guide](https://github.com/agentscope-ai/ReMe/blob/main/doc/README.md) to install):
-
-```bash
-reme \
- backend=http \
- http.port=8002 \
- llm.default.model_name=qwen-max-2025-01-25 \
- embedding_model.default.model_name=text-embedding-v4 \
- vector_store.default.backend=local
-```
-
-and then init the task memory pool:
-
-```bash
-python init_task_memory_pool.py
-```
-
-**Configuration options in `init_task_memory_pool.py`:**
-- `jsonl_file`: Path to the collloaded trajectories
-- `service_url`: ReMe service URL (default: `http://localhost:8002`)
-- `workspace_id`: Workspace ID for the task memory pool (default: `bfcl_test`)
-- `n_threads`: Number of threads for processing (default: `4`)
-- `output_file`: Output file to save results (optional)
-
-Now you have inited the task memory pool using `local` backend (start on `http://localhost:8002`). Then, use `local_file_to_library.py` script to convert the local file to the memory library or run the following `curl` command:
-```bash
-curl -X POST "http://0.0.0.0:8002/vector_store" \
- -H "Content-Type: application/json" \
- -d '{
- "workspace_id": "bfcl_test",
- "action": "dump",
- "path": "./library"
- }'
-```
-to dump the memory library (default in `./library/bfcl_test.jsonl`).
-
-Next time, you can import this previously exported task memory data to populate the new started workspace with existing knowledge:
-```bash
-curl -X POST "http://0.0.0.0:8002/vector_store" \
- -H "Content-Type: application/json" \
- -d '{
- "workspace_id": "bfcl_test",
- "action": "load",
- "path": "./library"
- }'
-```
-
-
-### 4. Run Experiments on Validation Set
-
-Run you can compare agent performance on the validation set with task memory (`use_memory=True`) and without task memory:
-
-```bash
-# remember to change the configuration options, e.g., `data_path=./data/multiturn_data_base_val.jsonl`
-python run_bfcl.py
-```
-
-After running experiments, analyze the statistical results:
-
-```bash
-python run_exp_statistic.py
-```
-
-**What this script does:**
-- Processes all result files in `./exp_result/`
-- Calculates best@k metrics for different k values
-- Generates a summary table showing performance comparisons
-- Saves results to `experiment_summary.csv`
diff --git a/docs/cookbook/faq.md b/docs/cookbook/faq.md
new file mode 100644
index 00000000..66603e37
--- /dev/null
+++ b/docs/cookbook/faq.md
@@ -0,0 +1,69 @@
+# Frequently Asked Questions
+This document provides answers to frequently asked questions about our paper "[Remember Me, Refine Me: A Dynamic Procedural Memory Framework for Experience-Driven Agent Evolution](https://arxiv.org/pdf/2512.10696)".
+
+## Reproduction Questions
+### 1. experimental configuration
+
+**Example:** Qwen3-8B + AppWorld
+**Launch the ReMe service:**
+```bash
+reme2 \
+ backend=http \
+ http.port=8002 \
+ llms.default.model_name=qwen3-8b \
+ embedding_models.default.model_name=text-embedding-v4 \
+ vector_stores.default.backend=es \
+ vector_stores.default.hosts=http://xx.yy.zz.mm:nn
+```
+**Evaluation Code:** [run_appworld.py](https://github.com/agentscope-ai/ReMe/blob/main/benchmark/appworld/run_appworld.py) with the following parameters
+|Experimental Settings|No Memory |ReMe (fixed) |ReMe (dynamic)|
+|---|---|---|---|
+|max_workers|16|16|16|
+|batch_size|8|8|8|
+|num_runs|4|4|1|
+|num_trials|1|1|3|
+|model_name|"qwen3-8b"|"qwen3-8b"|"qwen3-8b"|
+|use_memory| False| True|True|
+|use_memory_addition|False|False|True|
+|use_memory_deletion|False|False|True|
+|memory_base_url|""|"http://0.0.0.0:8002/"|"http://0.0.0.0:8002/"|
+|load_file_path|""|[appworld_qwen3_8b.jsonl](https://github.com/agentscope-ai/ReMe/tree/main/docs/library/paper_data/task/appworld_qwen3_8b.jsonl)|[appworld_qwen3_8b.jsonl](https://github.com/agentscope-ai/ReMe/tree/main/docs/library/paper_data/task/appworld_qwen3_8b.jsonl)|
+
+For parameter meanings, you can refer to [docs/cookbook/appworld](https://github.com/zouyingcao/ReMe/blob/main/docs/cookbook/appworld/quickstart.md) .
+
+> [!NOTE]
+> - Qwen3 thinking mode is activated for BFCL-V3 tasks and disabled for AppWorld tasks.
+> - In ReMe(fixed) setting, there is no need to restart the ReMe service at each run since the experience pool is fixed. However, in ReMe(dynamic) setting, we need run separately to ensure consistent initial state. That is to say, to calculate Pass@4, you need 4 independent runs with restarting ReMe service and setting `num_runs=1` in each run.
+
+### 2. about experience pool initialization
+Taking Appworld as an example, you can refer to issues [#55](https://github.com/agentscope-ai/ReMe/issues/55), [#58](https://github.com/agentscope-ai/ReMe/issues/58). To reproduce the results in our paper, you can use our constructed memory data in [docs/library/paper_data](https://github.com/agentscope-ai/ReMe/tree/main/docs/library/paper_data/task).
+
+### 3. evaluation metrics
+- In our AppWorld experiments, we report Task Goal Completion (TGC) metric (claimed in Appendix A of our [paper](https://arxiv.org/pdf/2512.10696)), which measures percentage of tasks for which the agent passes all evaluation tests. [`after_score`](https://github.com/agentscope-ai/ReMe/blob/main/benchmark/appworld/appworld_react_agent.py#L218) is the percentage of tests passed for per task. To calculate TGC, only `after_score=1` means task completion. Therefore, we use threshold=1 in [run_exp_statistic.py](https://github.com/agentscope-ai/ReMe/blob/main/benchmark/appworld/run_exp_statistic.py#L43) to get Pass@k.
+- In our paper, `Avg@4` is the `Pass@1` performance averaged over 4 independent runs. For simplicity, we organize the total collected 4 trajectories in a single file to calculate Pass@1 and Pass@4 together. Then, the results of Pass@1 and Avg@4 are equivalent.
+
+
+### 4. reproduce baselines
+- For Qwen3-series No-Memory performance on AppWorld, you can refer to issue [#49](https://github.com/agentscope-ai/ReMe/issues/49).
+- About A-mem and LangMem code, please see [#67](https://github.com/agentscope-ai/ReMe/issues/67).
+
+## Environment Setup
+### 1. BFCL-V3 code version
+We use the BFCL GitHub repository with commit_id=[ea13468](https://github.com/ShishirPatil/gorilla/commit/ea13468e4423454d0c213704fb87cf7cb3990433) in our experiments.
+
+### 2. preprocess BFCL-V3 multi_turn_base data
+Before running the experiments, you need to preprocess the BFCL-V3 data using this [script](https://github.com/agentscope-ai/ReMe/blob/main/benchmark/bfcl/preprocess.py) to get the suitable data format. Then, we randomly split the multi-turn-base data into train (50) and test (150) sets using [split_into_trainval.py](https://github.com/agentscope-ai/ReMe/blob/main/benchmark/bfcl/split_into_trainval.py) (our used split is [here](https://github.com/agentscope-ai/ReMe/issues/45#issuecomment-3890215360)). The training set is used to construct the initial experience pool and the remaining 150 testing tasks serve as the evaluation set.
+
+### 3. pydantic version issue when running Appworld
+AppWorld depends on an older version of pydantic, which is why a separate environment is needed. If you encounter issues running the experiments, try `pip install appworld` to override the dependencies.
+
+### 4. AppWorld data not found
+Ensure `appworld download data` completed successfully.
+
+## Technical Questions
+### 1. about memory growth
+See [#44](https://github.com/agentscope-ai/ReMe/issues/44).
+### 2. code for Experience Refinement
+See [#52](https://github.com/agentscope-ai/ReMe/issues/52).
+### 3. context length issue with AppWorld
+See [#81](https://github.com/agentscope-ai/ReMe/issues/81).
diff --git a/docs/deprecated.txt b/docs/deprecated.txt
deleted file mode 100644
index 06f9648f..00000000
--- a/docs/deprecated.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-from loguru import logger
-
-用英文注释,完善module/class/function docstring,要一句话简洁,不要变更代码逻辑,符合pep和pylint规范,使用list而不是typing.List/Dict,不使用typing.Union
-
-看看代码有什么问题
-用英文注释,完善module/class/function docstring,要一句话简洁,代码要简洁,符合pep和pylint规范,使用list而不是typing.List,不使用typing.Union
-C0114: Missing module docstring (missing-module-docstring)
-C0115: Missing class docstring (missing-class-docstring)
-C0116: Missing function or method docstring (missing-function-docstring)
-done: { for f in ./*.py; do [[ "$f" != "./__init__.py" ]] && grep -v '^[[:space:]]*#' "$f"; done; } | pbcopy
-
-然后是一个完整的tests,但是不要用其他的包,只是test开头的函数或者类,要求from loguru import logger
-写一个测试文件,不要使用pytest,普通的test,要求英文注释
\ No newline at end of file
diff --git a/docs/future_work.md b/docs/future_work.md
deleted file mode 100644
index 807abfed..00000000
--- a/docs/future_work.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# Future Work
-
-- [ ] P0 ReMe documentation style migration: Recommend using the same doc and jupyter structure as Agentscope Runtime @jiaji
-- [ ] P0 ReMe integration with agentscope Personal/Task/Tool @jinli
-- [ ] P0 ReMe sample library examples [show case](https://github.com/agentscope-ai/agentscope-samples/tree/main/functionality/long_term_memory_mem0) @jinli
-- [ ] P0 Decouple flowllm dependencies @jinli
-- [ ] P0 ReMe support for import, improve code documentation @jinli
-- [ ] P1 ReMe integration with asio tool_memory @jinli
-- [ ] P2 ReMe integration with agentscope-Runtime tool_memory @jinli
-
-- [ ] P0 Task Memory Research Paper @zhoyin
-
-- [ ] P1 Context interface definition @jinli
-
-- [ ] P2 Database layer interface unification @jinli
-- [ ] P2 Automatic Tool Exploration Mode @wangcan
-- [ ] P2 Mem-Agent Exploration @weikang
-- [ ] P2 Desktop Pet Personal Assistant
diff --git a/docs/reme_v2_design.md b/docs/reme_v2_design.md
deleted file mode 100644
index 6fa78140..00000000
--- a/docs/reme_v2_design.md
+++ /dev/null
@@ -1,735 +0,0 @@
-# ReMeV2 深度设计文档:渐进式 Agentic Memory 方案
-
-## 一、 背景与现状分析
-
-### 1.1 当前面临的挑战
-
-* **外功修炼(接口易用性)**:现有的 `server-client` 模式对新手开发者不够友好,集成成本高,需要更直观、纯 Pythonic 的调用方式。
-* **内功修炼(架构深度)**:受 `skills` 和 `agentic memory` 启发,现有的存储检索较为机械。我们需要一种基于**渐进式检索(Progressive Retrieval)**与**渐进式总结(Progressive Summarization)**的智能体记忆方案。
-
-### 1.2 核心目标
-
-1. **极简开发体验**:开发者友好,全异步接口,支持本地直接运行与 CLI 体验。
-2. **认知架构升级**:引入 渐进式检索 & 渐进式总结 的 Agentic 模式,融合多种记忆,让记忆的存取具备“思考”过程。
-3. **生态融合**:原生支持 AgentScope、LangChain 等主流框架。
-
----
-
-## 二、 竞品调研与启示
-
-### 2.1 主流竞品深度对比
-
-| 产品 | 设计哲学 | 核心优势 | 局限性 |
-|-------------|----------|---------------------------------------------|-------------------|
-| **mem0** | 智能便签本 | 原子事实提取,极高 Token 效率。 | 缺乏对复杂逻辑链条的支持。 |
-| **Letta** | 带硬盘的 CPU | 模拟计算机三级存储(Core/Recall/Archival),Agent 自主控存。 | 状态机管理相对复杂。 |
-| **MIRIX** | 认知架构图谱 | 实体-关系双引擎,支持记忆“进化”与“固化”。 | 侧重研究,落地集成门槛较高。 |
-| **LangMem** | 用户档案系统 | 异步 Compaction(压缩),Schema 驱动,强一致性。 | 偏向 SaaS 应用,灵活性略逊。 |
-
-### 2.2 mem0
-- https://github.com/mem0ai/mem0
-- https://docs.mem0.ai/core-concepts/memory-operations/add
-- https://docs.mem0.ai/core-concepts/memory-operations/search
-- https://docs.mem0.ai/core-concepts/memory-operations/update
-- https://docs.mem0.ai/core-concepts/memory-operations/delete
-
-#### 2.2.1 API Reference
-| 接口名称 | 核心输入参数 (Inputs) | 核心输出 (Outputs) | 背后逻辑 (Internal Logic) |
-| --- | --- | --- | --- |
-| **Add** | `messages` (文本/对话), `user_id`, `metadata` | `id`, `event` (ADD/UPDATE), `data` | **提取与合并**:LLM 提取事实,自动去重并更新已有记忆,而非简单堆叠。 |
-| **Search** | `query` (自然语言), `filters`, `limit` | `id`, `memory` (事实文本), `score`, `metadata` | **语义检索**:基于向量相似度查找最相关的“原子事实”,支持多维过滤。 |
-| **Update** | `memory_id` (必填), `data` (新内容) | 操作状态 (Success/Fail) | **手动干预**:允许开发者对特定的事实进行精确修正。 |
-| **Delete** | `memory_id` 或 `user_id` (清空) | 操作状态 (Success/Fail) | **遗忘机制**:物理删除或逻辑移除不再需要的信息。 |
-
-#### 2.2.2 Tech Strategy & Benefits
-| 维度 | 技术方案 (Technical Solution) | 核心优势 (Key Advantages) |
-| --- | --- | --- |
-| **存储架构** | **混合存储**:向量数据库 (Vector) + 图数据库 (Graph) + 关系型元数据。 | **多维关联**:不仅能搜到相似内容,还能理解实体间的逻辑关系(如“父子”、“因果”)。 |
-| **数据处理** | **原子化事实提取**:利用 LLM 将长篇对话压缩为简短的 Fact。 | **极高 Token 效率**:注入 Prompt 的内容更精炼,减少 90% 以上的冗余信息,大幅降本。 |
-| **管理层级** | **多级联动**:User (长期) Agent (专业) Session (短期)。 | **个性化定制**:实现跨会话的“长效记忆”,AI 能记住用户一个月前说过的偏好。 |
-| **冲突处理** | **自适应更新算法**:新信息进入时自动比对旧记忆。 | **数据一致性**:自动处理矛盾信息(如用户更换了住址),确保记忆库始终是“最新真理”。 |
-| **兼容性** | **解耦设计**:支持多种 Embedding 模型与向量数据库后端。 | **快速集成**:几行代码即可为现有 LLM 应用增加记忆层,适配各种生产环境。 |
-
-
----
-
-### 2.3 Letta
-- https://github.com/letta-ai/letta
-- https://docs.letta.com/guides/agents/archival-memory/
-- https://docs.letta.com/guides/agents/archival-search/
-
-#### 2.3.1 存储架构层级 (Memory Tiering)
-
-Letta 将记忆分为三个物理/逻辑层,模拟计算机的存储架构:
-
-| 记忆层级 | 存储介质 | 访问方式 | 核心作用 |
-| --- | --- | --- | --- |
-| **Core Memory** | **上下文窗口 (Prompt)** | 直接读写 | **即时意识**:包含 `Persona`(AI 设定)和 `Human`(用户信息)。Agent 随时可见,响应最快。 |
-| **Recall Memory** | **关系型数据库 (SQL)** | 分页检索 | **短期/历史回顾**:存储完整的对话流(Messages)。用于回答“你刚才说了什么”。 |
-| **Archival Memory** | **向量数据库 (Vector)** | 语义搜索 | **长期知识库**:存储海量事实或文档。Agent 通过工具自主检索或存入。 |
-
-#### 2.3.2 核心操作接口 (API & Tool Reference)
-
-在 Letta 中,记忆的操作通常封装为 **Tools**,由 Agent 根据推理需求主动调用。
-
-| 接口/工具名称 | 输入参数 (Inputs) | 核心输出 (Outputs) | 背后逻辑 (Internal Logic) |
-| --- | --- | --- | --- |
-| **`core_memory_update`** | `section`, `new_content` | 更新后的段落内容 | **原子替换**:直接修改 System Prompt 中的特定块(如:更新用户的职业或 AI 的性格偏好)。 |
-| **`archival_memory_insert`** | `content` (字符串) | 写入状态/ID | **知识沉淀**:将当前对话中的重要信息或外部文件片段“持久化”到向量数据库。 |
-| **`archival_memory_search`** | `query`, `page` | 匹配的文本块列表 | **主动 RAG**:Agent 意识到知识不足时,自主发起向量检索,并将结果拉入临时上下文。 |
-| **`conversation_search`** | `query`, `start_date` | 历史消息记录 | **全文检索**:在 Recall Memory 中根据关键词或时间戳查找历史对话详情。 |
-| **`send_message`** | `message`, `agent_id` | 响应流/状态更新 | **状态循环**:这是主入口,触发 Agent 的“思考-行动-观察”循环,自动处理内存同步。 |
-
-#### 2.3.3 技术策略与核心优势 (Tech Strategy & Benefits)
-
-| 维度 | 技术方案 (Technical Solution) | 核心优势 (Key Advantages) |
-| --- | --- | --- |
-| **状态持久化** | **Agent State Snapshot**:将 Agent 的所有内存、工具定义和历史记录打包存入数据库。 | **无限存续**:Agent 不再是无状态的 API 调用。重启服务器后,Agent 依然记得所有细节。 |
-| **自主演进** | **Self-Editing Loop**:Agent 拥有修改自己 Core Memory 的权限(通过函数调用)。 | **认知闭环**:AI 能在交流中发现矛盾并自我更正,例如发现用户搬家后自动更新 `Human` 模块。 |
-| **算力调度** | **OOC (Out-of-Context) 管理**:当对话过长,系统自动将旧消息从 Core 移入 Recall。 | **突破 Context 限制**:在 8k 窗口的模型上也能处理相当于 1M 窗口的逻辑量,且成本更低。 |
-| **多代理协同** | **Letta Server 中控**:统一管理多个 Agent 的状态机与资源访问权限。 | **企业级扩展**:支持创建 Agent 团队,每个 Agent 拥有独立的记忆空间但可共享 Archival 库。 |
-| **解耦灵活性** | **Provider Agnostic**:后端支持 Postgres/Chroma,前端支持 OpenAI/Anthropic/Local LLMs。 | **无缝迁移**:不绑定特定模型,开发者可以根据成本或能力随时更换底座。 |
-
-#### 2.3.4 与 mem0 的深度对比
-
-* **设计哲学**:
-* **mem0** 像是一个**“智能记事本”**,它在后台默默地帮你总结事实。
-* **Letta** 像是一个**“带硬盘的 CPU”**,它把记忆管理完全交给了 Agent 自己的逻辑推理。
-
-
-* **交互模式**:
-* **mem0** 通常是外部干预(Add/Search)。
-* **Letta** 强调 **Agentic Control**(Agent 意识到需要搜索时才去搜索),这种模式更接近人类的思维过程。
-
----
-
-### 2.4 MIRIX
-- https://github.com/Mirix-AI/MIRIX
-- https://docs.mirix.io/
-
-#### 2.4.1 API Reference
-
-| 接口名称 | 核心输入参数 (Inputs) | 核心输出 (Outputs) | 背后逻辑 (Internal Logic) |
-| --- | --- | --- | --- |
-| **Add** | `content` (观察/对话), `agent_id`, `context_type` (如任务/闲聊) | `memory_id`, `graph_nodes`, `status` | **实体建模**:不只是提取事实,而是将信息拆解为实体(Entities)与关系(Relations),并挂载到智能体的知识图谱中。 |
-| **Query** | `query` (意图), `scope` (全局/局部), `top_k` | `retrieved_memories`, `relation_paths`, `score` | **混合检索**:结合向量(Vector)的语义相关性和图(Graph)的拓扑连接性,寻找具有逻辑深度背景的记忆。 |
-| **Evolve** | `target_memories` (可选), `agent_id` | `optimized_structure`, `merged_nodes` | **记忆固化/压缩**:模仿人类大脑的“睡眠”机制,自动合并碎片化记忆,将短期经验转化为长期的结构化知识。 |
-| **Observe** | `interaction_stream`, `feedback` | `insights`, `priority_update` | **实时学习**:根据用户反馈或环境变化,动态调整记忆的权重(Importance)和置信度。 |
-
-#### 2.4.2 Tech Strategy & Benefits
-
-| 维度 | 技术方案 (Technical Solution) | 核心优势 (Key Advantages) |
-| --- | --- | --- |
-| **存储架构** | **语义-关系双引擎**:向量索引(Vector Index)+ 属性图(Property Graph)。 | **深度上下文**:不仅知道“是什么”,还能通过图路径推理出“为什么”,有效解决 LLM 幻觉问题。 |
-| **记忆层级** | **三层架构**:感知记忆 (Perception) -> 语义记忆 (Semantic) -> 经验记忆 (Episodic)。 | **任务适应性**:不同任务自动匹配不同的记忆深度,短期任务关注细节,长期任务关注模式。 |
-| **演化机制** | **自主固化 (Self-Consolidation)**:通过 LLM 定期对冗余、矛盾信息进行清洗和逻辑抽象。 | **永久生命力**:解决随时间推移记忆库膨胀导致的检索噪声,确保记忆库“越用越聪明”。 |
-| **推理增强** | **基于记忆的 RAG+**:在检索到的事实基础上,额外提供关联的逻辑链条(Logic Chains)。 | **辅助决策**:为 Agent 提供决策支撑,使其在处理复杂流程时具备类似“长期经验值”的直觉。 |
-| **多代理协同** | **内存共享协议**:支持 Agent 之间的记忆交换与知识同步。 | **群体智能**:多个 Agent 可以共享同一套底层知识体系,同时保留各自的私有工作记忆。 |
-
-#### 2.4.3 与 mem0 的主要区别
-
-* **Mem0** 侧重于**个性化偏好存储**(Personalization),核心是记住“用户喜欢什么”。
-* **MIRIX** 侧重于**智能体认知架构**(Agent Cognition),核心是让 Agent 具备类似人类的“知识归纳”和“逻辑推理”记忆能力。
-
----
-
-### 2.5 LangMem
-- https://github.com/langchain-ai/langmem
-- https://langchain-ai.github.io/langmem/
-
-#### 2.5.1 API Reference
-
-| 接口名称 | 核心输入参数 (Inputs) | 核心输出 (Outputs) | 背后逻辑 (Internal Logic) |
-| --- | --- | --- | --- |
-| **Add Messages** | `thread_id`, `messages` (List), `user_id` | 操作确认 / 任务 ID | **流式注入**:将原始对话追加到指定的 Thread。LangMem 会自动关联用户上下文,准备进行后续的异步处理。 |
-| **Query Memory** | `user_id`, `query` (语义描述), `namespace` | 结构化记忆对象 (JSON / Text) | **多维检索**:不仅支持向量相似度搜索,还能根据定义的 Schema 返回结构化的用户画像或知识状态。 |
-| **Trigger Logic** | `thread_id`, `memory_type` | 更新后的 Memory State | **异步固化**:后台启动 LLM 任务,将长篇对话“压缩”并“提取”到长期存储中。支持自定义提取逻辑(如更新用户信息)。 |
-| **Manage State** | `user_id`, `patch_data` (增量更新) | 成功/失败 状态 | **精确受控**:开发者可以直接修改持久化的状态(State),支持类似于 Git 的状态管理。 |
-
-#### 2.5.2 Tech Strategy & Benefits
-
-| 维度 | 技术方案 (Technical Solution) | 核心优势 (Key Advantages) |
-| --- | --- | --- |
-| **存储架构** | **Stateful Persistence**:基于关系型数据库 (Postgres) + 向量索引。 | **强一致性**:利用数据库事务确保记忆更新的可靠性,支持复杂的结构化查询与过滤。 |
-| **数据处理** | **异步化 Compaction (压缩)**:在对话间隙通过后台 Worker 提取知识。 | **无感延迟**:核心对话流程不被记忆提取阻塞,通过定时或事件驱动完成“记忆固化”,优化用户体验。 |
-| **管理层级** | **Thread -> User -> Organization**:三层级联记忆。 | **上下文隔离**:完美适配 SaaS 应用场景,既能记住单次对话(Thread),也能沉淀用户习惯(User)。 |
-| **逻辑引擎** | **Schema-Driven (模式驱动)**:允许定义 JSON Schema 来规范记忆内容。 | **高度可预测**:输出不再是散乱的句子,而是结构化的字段,方便下游程序直接调用逻辑(如自动填充表单)。 |
-| **集成生态** | **LangGraph 原生集成**:作为 Checkpointer 或存储节点直接接入。 | **生态协同**:如果你已经在用 LangChain,LangMem 可以无缝接管状态流转,无需重写底层存储逻辑。 |
-
-#### 2.5.3 与 mem0 的核心差异
-
-* **mem0** 像是一个**“便签本”**:它擅长从每一句话里抠出零散的事实(如“我喜欢吃苹果”),然后把它们存成一条条语义片段。
-* **LangMem** 像是一个**“用户档案系统”**:它更擅长分析一整段对话,然后更新一个复杂的 JSON 档案(如更新用户的偏好模型、性格标签、历史任务状态)。
-
----
-
-## 三、 ReMeV2 API 接口设计
-
-### 3.1 Long-Term Memory (长期记忆)
-
-#### 3.1.1 Basic Usage (基础用法)
-
-The most straightforward way to use ReMe for long-term memory management. Supports basic summary and retrieval operations.
-
-```python
-import os
-from reme_ai import ReMe
-
-os.environ["REME_LLM_API_KEY"] = "sk-..."
-os.environ["REME_LLM_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-os.environ["REME_EMBEDDING_API_KEY"] = "sk-..."
-os.environ["REME_EMBEDDING_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-
-memory = ReMe(
- memory_space="remy", # workspace identifier
- llm={"backend": "openai", "model": "qwen-plus", "temperature": 0.6},
- embedding={"backend": "openai", "model": "text-embedding-v4", "dimension": 1024},
- vector_store={"backend": "local_file"}, # supported: local_file, chromadb, qdrant, etc.
-)
-
-# Summarize conversation into memory
-result = await memory.summary(
- messages=[
- {"role": "user", "content": "I'm travelling to SF"},
- {"role": "assistant", "content": "That's great to hear!"}
- ],
- user_id="Alice",
- # memory_type="auto" # default: auto (auto, personal, procedural, tool)
-)
-
-# Retrieve relevant memories
-memories = await memory.retrieve(
- query="what is your travel plan?",
- limit=3,
- user_id="Alice",
- # memory_type="auto" # default: auto
-)
-memories_str = "\n".join(f"- {m['memory']}" for m in memories["results"])
-print(memories_str)
-```
-
-#### 3.1.2 CLI Chat Application (命令行聊天应用)
-
-A complete example demonstrating how to build a memory-enhanced chatbot with CLI interface.
-
-```python
-import os
-from reme_ai import ReMe
-from openai import OpenAI
-
-os.environ["REME_LLM_API_KEY"] = "sk-..."
-os.environ["REME_LLM_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-os.environ["REME_EMBEDDING_API_KEY"] = "sk-..."
-os.environ["REME_EMBEDDING_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-
-memory = ReMe(
- memory_space="remy",
- llm={"backend": "openai", "model": "qwen-plus", "temperature": 0.6},
- embedding={"backend": "openai", "model": "text-embedding-v4", "dimension": 1024},
- vector_store={"backend": "local_file"},
-)
-
-os.environ["OPENAI_API_KEY"] = "sk-..."
-os.environ["OPENAI_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-openai_client = OpenAI()
-
-def chat_with_memories(
- query: str,
- history_messages: list[dict],
- user_name: str = "",
- start_summary_size: int = 2,
- keep_size: int = 0
-) -> str:
- # Retrieve relevant memories for the query
- memories = memory.retrieve(query=query, user_id=user_name, limit=3)
-
- # Build system prompt with memories
- system_prompt = (
- "You are a helpful AI named `Remy`. Use the user memories to answer the question. "
- "If you don't know the answer, just say you don't know. Don't try to make up an answer.\n"
- )
- if memories:
- memories_str = "\n".join(f"- {m['memory']}" for m in memories["results"])
- system_prompt += f"User Memories:\n{memories_str}\n"
-
- # Generate response
- system_message = {"role": "system", "content": system_prompt}
- history_messages.append({"role": "user", "content": query})
- response = openai_client.chat.completions.create(
- model="qwen-plus",
- messages=[system_message] + history_messages
- )
- history_messages.append({"role": "assistant", "content": response.choices[0].message.content})
-
- # Summarize history when it gets too long
- if len(history_messages) >= start_summary_size:
- memory.summary(history_messages[:-keep_size], user_id=user_name)
- print("Current memories: " + memory.list_memories(user_id=user_name))
- history_messages = history_messages[-keep_size:]
-
- return history_messages[-1]["content"]
-
-def main():
- user_name = input("Enter your name: ").strip()
- print("Chat with Remy (type 'exit' to quit)")
-
- messages = []
- while True:
- user_input = input(f"{user_name}: ").strip()
- if user_input.lower() == 'exit':
- print("Goodbye!")
- break
-
- print(f"Remy: {chat_with_memories(user_input, messages, user_name)}")
-
- # Cleanup
- memory.delete_all_memories(user_id=user_name)
- print("All memories deleted")
-
-if __name__ == "__main__":
- main()
-```
-
-#### 3.1.3 Advanced Usage (高级用法)
-
-For advanced users who want to customize retriever and summarizer behavior with Agentic mode.
-
-```python
-import os
-from reme_ai import ReMe
-from reme_ai.retriever import AgenticRetriever
-from reme_ai.summarizer import AgenticSummarizer
-from reme_ai.tools import ATool, BTool, CTool
-
-os.environ["REME_LLM_API_KEY"] = "sk-..."
-os.environ["REME_LLM_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-os.environ["REME_EMBEDDING_API_KEY"] = "sk-..."
-os.environ["REME_EMBEDDING_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-
-memory = ReMe(
- memory_space="remy",
- llm={"backend": "openai", "model": "qwen-plus", "temperature": 0.6},
- embedding={"backend": "openai", "model": "text-embedding-v4", "dimension": 1024},
- vector_store={"backend": "local_file"},
- use_agentic_mode=True,
-)
-
-# Customize retriever and summarizer with custom tools and prompts
-memory.set_retriever(
- AgenticRetriever(tools=[ATool(), BTool(), CTool()]),
- system_prompt="Custom retrieval instructions..."
-)
-memory.set_summarizer(
- AgenticSummarizer(tools=[ATool(), BTool(), CTool()])
-)
-
-# Use the customized memory system
-result = memory.summary(
- messages=[
- {"role": "user", "content": "I'm travelling to SF"},
- {"role": "assistant", "content": "That's great to hear!"}
- ],
- user_id="Alice",
- memory_type="auto", # auto, personal, procedural, tool
-)
-
-memories = memory.retrieve(
- query="what is your travel plan?",
- limit=3,
- user_id="Alice",
- memory_type="auto",
-)
-memories_str = "\n".join(f"- {m['memory']}" for m in memories["results"])
-print(memories_str)
-```
-
-### 3.2 Short-Term Memory (短期记忆)
-
-#### 3.2.1 Basic Usage (基础用法)
-
-Context offload/reload API for managing short-term conversational memory within a session.
-
-```python
-import os
-from reme_ai import ReMe
-
-os.environ["REME_LLM_API_KEY"] = "sk-..."
-os.environ["REME_LLM_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-os.environ["REME_EMBEDDING_API_KEY"] = "sk-..."
-os.environ["REME_EMBEDDING_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
-
-memory = ReMe(
- memory_space="remy",
- llm={"backend": "openai", "model": "qwen-plus", "temperature": 0.6},
- embedding={"backend": "openai", "model": "text-embedding-v4", "dimension": 1024},
- vector_store={"backend": "local_file"},
-)
-
-# Offload context when conversation gets too long
-result = memory.offload_context(
- messages=[
- {"role": "user", "content": "I'm travelling to SF"},
- {"role": "assistant", "content": "That's great to hear!"}
- ],
-)
-
-# Reload relevant context when needed
-memories = memory.reload_context(
- query="what is your travel plan?",
- limit=3,
-)
-memories_str = "\n".join(f"- {m['memory']}" for m in memories["results"])
-print(memories_str)
-```
-
-### 3.3 Framework Integration (框架集成)
-
-#### 3.3.1 Integration with AgentScope
-
-Integration example for AgentScope ReActAgent with long-term memory support.
-
-```python
-# TODO: Provide AgentScope integration example
-```
-
-#### 3.3.2 Integration with LangChain
-
-Integration example for LangChain agents with ReMe memory layer.
-
-```python
-# TODO: Provide LangChain integration example
-```
-
-### 3.4 OpenAI Compatible Interface
-
-OpenAI-compatible API interface for seamless integration with existing OpenAI-based applications.
-
-```python
-# TODO: Research and implement OpenAI-compatible interface
-# - Support for threads and assistants API
-# - Compatible with OpenAI SDK
-# - Support for streaming responses
-```
-
-
-
----
-
-## 四、核心方案设计
-
-### 4.1 设计概述
-
-ReMeV2 采用简洁的架构设计,核心理念为:**ReMeV2 = Tool(s) + Agent(s)**
-
-- **Tool层**:提供原子化的记忆操作能力,包括增删改查、检索、元数据管理等基础操作
-- **Agent层**:基于Tool层构建的智能代理,负责复杂的记忆管理逻辑,如分类总结、渐进式检索等
-- **Runtime层**:内部调度机制,协调Tool和Agent的交互流程
-
-### 4.2 Tool层设计
-
-Tool层提供装饰器形式的记忆操作工具,每个工具类通过 `@tool` 装饰器注册,明确定义初始化参数和调用参数。
-
-#### 4.2.1 基类:BaseMemoryToolOp
-
-**初始化参数:**
-- `enable_multiple` (bool): Enable multi-item operation mode. Default: `True`
-- `enable_thinking_params` (bool): Include thinking parameter in tool schema for model reasoning. Default: `False`
-- `memory_metadata_dir` (str): Directory path for storing memory metadata. Default: `"./memory_metadata"`
-
-#### 4.2.2 Tool操作列表
-
-以下是所有Tool操作的完整定义,包括继承关系、初始化参数和调用参数:
-
-| Tool类 | 继承自 | 初始化参数(除基类外) | Tool Call参数(单项模式) | Tool Call参数(多项模式) |
-|----------------------------|------------------|------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
-| **AddMemoryOp** | BaseMemoryToolOp | `add_when_to_use` (bool, 默认: False)
`add_metadata` (bool, 默认: True) | `when_to_use` (str, 可选)
`memory_content` (str, 必需)
`metadata` (dict, 可选) | `memories` (array, 必需):
- `when_to_use` (str, 可选)
- `memory_content` (str, 必需)
- `metadata` (dict, 可选) |
-| **UpdateMemoryOp** | BaseMemoryToolOp | 无 | `memory_id` (str, 必需)
`memory_content` (str, 必需)
`metadata` (dict, 可选) | `memories` (array, 必需):
- `memory_id` (str, 必需)
- `memory_content` (str, 必需)
- `metadata` (dict, 可选) |
-| **DeleteMemoryOp** | BaseMemoryToolOp | 无 | `memory_id` (str, 必需) | `memory_ids` (array[str], 必需) |
-| **VectorRetrieveMemoryOp** | BaseMemoryToolOp | `enable_summary_memory` (bool, 默认: False)
`add_memory_type_target` (bool, 默认: False)
`top_k` (int, 默认: 20) | `query` (str, 必需)
`memory_type` (str, 可选, 枚举: [identity, personal, procedural])
`memory_target` (str, 可选) | `query_items` (array, 必需):
- `query` (str, 必需)
- `memory_type` (str, 可选)
- `memory_target` (str, 可选) |
-| **AddMetaMemoryOp** | BaseMemoryToolOp | 无 | `memory_type` (str, 必需, 枚举: [personal, procedural])
`memory_target` (str, 必需) | `meta_memories` (array, 必需):
- `memory_type` (str, 必需)
- `memory_target` (str, 必需) |
-| **ReadMetaMemoryOp** | BaseMemoryToolOp | `enable_tool_memory` (bool, 默认: False)
`enable_identity_memory` (bool, 默认: False) | 无(无输入schema) | N/A (enable_multiple=False) |
-| **AddHistoryMemoryOp** | BaseMemoryToolOp | 无 | `messages` (array[object], 必需) | N/A (enable_multiple=False) |
-| **ReadHistoryMemoryOp** | BaseMemoryToolOp | 无 | `memory_id` (str, 必需) | `memory_ids` (array[str], 必需) |
-| **AddSummaryMemoryOp** | AddMemoryOp | 无(继承自AddMemoryOp) | `summary_memory` (str, 必需)
`metadata` (dict, 可选) | N/A (enable_multiple=False) |
-| **ReadIdentityMemoryOp** | BaseMemoryToolOp | 无 | 无(无输入schema) | N/A (enable_multiple=False) |
-| **UpdateIdentityMemoryOp** | BaseMemoryToolOp | 无 | `identity_memory` (str, 必需) | N/A (enable_multiple=False) |
-| **ThinkToolOp** | BaseAsyncToolOp | `add_output_reflection` (bool, 默认: False) | `reflection` (str, 必需) | N/A |
-| **HandsOffOp** | BaseMemoryToolOp | 无 | `memory_type` (str, 必需, 枚举: [identity, personal, procedural, tool])
`memory_target` (str, 必需) | `memory_tasks` (array, 必需):
- `memory_type` (str, 必需)
- `memory_target` (str, 必需) |
-
-### 4.3 Agent层设计
-
-#### 4.3.1 基类:BaseMemoryAgentOp
-
-Agent层构建在Tool层之上,封装复杂的记忆管理逻辑。每个Agent通过组合多个Tool实现特定的记忆管理任务。
-
-#### 4.3.2 Agent操作列表
-
-以下是所有Agent操作的完整定义,包括初始化参数、调用参数和可用工具:
-
-| Agent类 | 继承自 | 初始化参数(基类外) | Tool Call参数 | 可用工具 |
-|--------------------------------|-------------------|------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------|
-| **PersonalSummaryAgentV1Op** | BaseMemoryAgentOp | None | `workspace_id` (str, required)
`memory_target` (str, required)
`query` (str, optional)
`messages` (array, optional)
`ref_memory_id` (str, required) | add_memory
update_memory
delete_memory
vector_retrieve_memory |
-| **ProceduralSummaryAgentV1Op** | BaseMemoryAgentOp | None | `workspace_id` (str, required)
`memory_target` (str, required)
`query` (str, optional)
`messages` (array, optional)
`ref_memory_id` (str, required) | add_memory
update_memory
delete_memory
vector_retrieve_memory |
-| **ToolSummaryAgentV1Op** | BaseMemoryAgentOp | None | `workspace_id` (str, required)
`memory_target` (str, required)
`query` (str, optional)
`messages` (array, optional)
`ref_memory_id` (str, required) | add_memory
update_memory
vector_retrieve_memory |
-| **IdentitySummaryAgentV1Op** | BaseMemoryAgentOp | None | `workspace_id` (str, required)
`query` (str, optional)
`messages` (array, optional) | read_identity_memory
update_identity_memory |
-| **ReMeSummaryAgentV1Op** | BaseMemoryAgentOp | `enable_tool_memory` (bool, 默认: True)
`enable_identity_memory` (bool, 默认: True) | `workspace_id` (str, required)
`query` (str, optional)
`messages` (array, optional) | add_meta_memory
add_summary_memory
hands_off
(内部调用: add_history_memory, read_identity_memory, read_meta_memory) |
-| **ReMeRetrieveAgentV1Op** | BaseMemoryAgentOp | `enable_tool_memory` (bool, 默认: True) | `workspace_id` (str, required)
`query` (str, optional)
`messages` (array, optional) | vector_retrieve_memory
read_history_memory
(内部调用: read_meta_memory) |
-| **ReMyAgentV1Op** | BaseMemoryAgentOp | `enable_tool_memory` (bool, 默认: True)
`enable_identity_memory` (bool, 默认: True) | `workspace_id` (str, required)
`query` (str, optional)
`messages` (array, optional) | vector_retrieve_memory
read_history_memory
(内部调用: read_identity_memory, read_meta_memory) |
-
-### 4.4 Runtime层设计(内部实现)
-
-Runtime层负责协调Tool和Agent的调用流程,实现记忆的渐进式处理。
-
-#### 4.4.1 渐进式总结流程(Summary)
-
-总结流程采用分层处理策略,首先保存历史对话,读取元信息,然后由主Agent协调多个专用Agent完成分类总结。
-
-**流程结构:**
-
-```python
-# Step 1: Save conversation history
-AddHistoryMemoryOp()
-
-# Step 2: Load meta information (memory types and targets)
-ReadMetaMemoryOp()
-
-# Step 3: Progressive summarization with delegation
-ReMeSummaryAgentV1Op(tools=[
- # Add meta memory entries for new memory types/targets
- AddMetaMemoryOp(list(memory_type, memory_target)),
-
- # Add general summary memory as fallback
- AddSummaryMemoryOp(summary_memory),
-
- # Delegate to specialized summary agents
- HandsOffOp(list(memory_type, memory_target), agents=[
- PersonalSummaryAgentV1Op, # Summarize personal memories
- ProceduralSummaryAgentV1Op, # Summarize procedural memories
- ToolSummaryAgentV1Op, # Summarize tool-related memories
- IdentitySummaryAgentV1Op # Update identity memory
- ]),
-])
-
-# Specialized agents and their available tools
-PersonalSummaryAgentV1Op(tools=[AddMemoryOp, UpdateMemoryOp, DeleteMemoryOp, VectorRetrieveMemoryOp])
-ProceduralSummaryAgentV1Op(tools=[AddMemoryOp, UpdateMemoryOp, DeleteMemoryOp, VectorRetrieveMemoryOp])
-ToolSummaryAgentV1Op(tools=[AddMemoryOp, UpdateMemoryOp, VectorRetrieveMemoryOp])
-IdentitySummaryAgentV1Op(tools=[ReadIdentityMemoryOp, UpdateIdentityMemoryOp])
-```
-
-#### 4.4.2 渐进式检索流程(Retrieve)
-
-检索流程采用三层检索策略,类似于技能系统的加载机制,逐层加载和过滤记忆。
-
-**流程结构:**
-
-```python
-# Progressive retrieval with three layers
-ReMeRetrieveAgentV1Op(tools=[
- # Layer 0: Load meta memory (all available memory types and targets)
- ReadMetaMemoryOp(),
- # Output format example:
- # - personal(jinli): Information about Jinli's personal life and preferences
- # - personal(jiaji): Information about Jiaji's background and interests
- # - personal(jinli&jiaji): Shared memories between Jinli and Jiaji
- # - procedural(appworld): Procedural knowledge for AppWorld tasks
- # - procedural(bfcl-v3): Procedural knowledge for BFCL-v3 benchmark
- # - tool(tool_guidelines): Guidelines for tool usage
- # - identity(self): Agent's self-identity information
-
- # Layer 1+2: Vector-based retrieval on structured memories
- VectorRetrieveMemoryOp(list(memory_type, memory_target, query)),
-
- # Layer 3: Load full conversation history for specific memory
- ReadHistoryMemoryOp(ref_memory_id),
-])
-```
-
-**与技能系统的类比:**
-
-```python
-# Skill system hierarchy (for reference)
-load_meta_skills # Load skill metadata
-load_skills # Load skill implementations
-load_reference_skills # Load detailed skill documentation
-execute_shell # Execute actual commands
-```
-
-## 五、扩展设计与实验方向
-
-### 5.1 Summary Memory机制
-
-Summary Memory作为通用维度的记忆类型,提供兜底的原始对话索引能力。
-
-**工作流程示例:**
-
-```txt
-Step 1: Progressive summarization across sessions
- session1: List[Message] -> session2: List[Message] -> session3: List[Message] -> ...
-summary ✓ (always) ✓ (always) ✓ (always)
-personal ✗ ✗ ✓ (when applicable)
-procedural ✗ ✓ (when applicable) ✗
-
-Step 2: Retrieval with fallback strategy
-vector_retrieve_memory(query, memory_type="personal", memory_target="jinli")
- -> Search in memory_type: ["personal", "summary"] # Fallback to summary if personal not found
-```
-
-**设计优势:**
-1. Provides a universal dimension for memory extraction across all memory types
-2. Ensures fallback indexing of original conversations when specific meta memory is not available
-3. Maintains conversation context even when specialized memory extraction fails
-
-### 5.2 Thinking参数实验
-
-探索不同的模型推理能力增强方案,受AgentScope和Claude启发。
-
-#### 5.2.1 Thinking参数设计
-
-```python
-async def record_to_memory(
- self,
- thinking: str,
- content: list[str],
- **kwargs: Any,
-) -> ToolResponse:
- """Use this function to record important information that you may
- need later. The target content should be specific and concise, e.g.
- who, when, where, do what, why, how, etc.
-
- Args:
- thinking (`str`):
- Your thinking and reasoning about what to record
- content (`list[str]`):
- The content to remember, which is a list of strings.
- """
-```
-
-#### 5.2.2 实验对比方案
-
-| 方案类型 | 说明 | 灵感来源 |
-|-------------------------------|--------------------------------------------|----------------|
-| Thinking Model | Native reasoning-capable models (e.g., o1) | OpenAI |
-| Instruct Model | Standard instruction-following models | Baseline |
-| Instruct Model + Thinking Params | Add thinking parameter to tool schema | AgentScope |
-| Instruct Model + Thinking Tool | Dedicated thinking tool for explicit reasoning | Claude |
-
-### 5.3 多项操作模式实验
-
-对比单次调用和批量调用的性能与准确性差异。
-
-**两种模式对比:**
-
-| 模式 | Tool调用方式 | Model调用次数 | 优势 | 劣势 |
-|--------------|----------------------------|---------------|------------------------------|--------------------------|
-| 单项模式 | Single-item per call | Multiple | Fine-grained control | Higher latency, more tokens |
-| 多项模式 | Batch multiple items | Single | Lower latency, fewer tokens | Potential batch errors |
-
-**实验目标:**
-- Evaluate accuracy: single vs. batch operations
-- Measure latency and token efficiency
-- Identify optimal use cases for each mode
-
-### 5.4 多版本与扩展性
-
-支持从基类继承实现自定义Agent,便于团队协作和功能迭代。
-
-**扩展示例:**
-
-```python
-# Version 2 implementations by different team members
-PersonalSummaryAgentV2Op / PersonalRetrieveAgentV2Op # @weikang
-ProceduralSummaryAgentV2Op / ProceduralRetrieveAgentV2Op # @zouyin
-
-# Inherit from BaseMemoryAgentOp
-class PersonalSummaryAgentV2Op(BaseMemoryAgentOp):
- """Enhanced personal memory summarization with improved algorithms"""
- pass
-```
-
-### 5.5 文件系统集成(未来方向)
-
-探索将文件操作能力集成到记忆系统中,支持基于文件的记忆管理。
-
-**挑战与考虑:**
-
-1. **操作适配性**:Current operations (retrieve/add/update/delete) need adaptation for file-based storage
-2. **工具选择**:Consider file operation tools: `grep`, `glob`, `ls`, `read_file`, `write_file`, `edit_file`
-3. **模型能力**:Base models have limited file operation capabilities; `qwen3-code` shows better performance
-
-**潜在架构:**
-
-```python
-# File-based memory operations
-FileMemoryOp(tools=[
- grep, # Search within files
- glob, # File pattern matching
- ls, # List directory contents
- read_file, # Read file contents
- write_file, # Write new memory files
- edit_file, # Update existing memory files
-])
-```
-
-### 5.6 自我修改上下文
-
-支持Agent动态修改自身的上下文状态,实现自适应记忆管理。
-
-**实现方式:**
-
-1. **Summary Agent 主动修改**:
- - `add_meta_memory` directly modifies agent context
- - Updates available memory types and targets during execution
-
-2. **ReMy Agent 被动修改**:
- - Retrieves `identity_memory` at each interaction
- - Dynamically updates self-state based on retrieved identity
- - Enables adaptive behavior based on accumulated identity knowledge
-
-## ReMe V2 开发路线图与实施计划
-
-### 技术改造阶段
-1. **代码整合与兼容**:合并flowllm中reme必要的代码,保留现在server-client的依赖,兼容现在各个仓库的依赖代码
-2. **核心接口重构**:新的ReMe接口设计,支持summary,retrieve,context_offload, context_reload 4个核心接口
-3. **Agentic算法升级**:新的agentic算法方案开发
-
-### 评估验证阶段
-4. **Benchmark测试**
- - halumem
- - locomo
- - longmemevel
- - personal-v2 ?
- - appworld/bfcl-v3
-
-### 发布推广阶段
-5. **技术报告**撰写与发布
-6. **生态更新**:更新各个仓库的依赖代码
- - agentscope
- - agentscope-runtime
- - evotraders
- - alias(tool-memory)
- - agentscope-java
- - AgentEvolver
- - cookbook: reme procedural memory paper
- - tool-memory-upgrade(将要合并)
-
-**里程碑目标**:春节前完成小版本发布
-
----
-
-## ReMe V2 核心竞争优势
-
-### 1. 渐进式 Agentic Memory 架构【核心创新】
-融合了多种记忆的渐进式agentic方案,实现从短期到长期记忆的智能化演进
-
-### 2. 全生命周期记忆管理
-同时支持长期记忆(Long-term Memory)和短期记忆(Working Memory),完整覆盖Agent认知周期
-
-### 3. 模型
-提供开源小模型
-
-### 4. 开发者友好生态
- 1. **简洁接口**:提供简洁的接口设计,全异步接口
- 2. **即开即用**:提供CLI工具,开箱即用的体验
- 3. **生态融合**:提供和AgentScope、LangChain无缝集成的方案
- 4. **高度可扩展**:支持Agentic算法的二次开发与定制
\ No newline at end of file
diff --git a/docs/todo.md b/docs/todo.md
deleted file mode 100644
index 5d73e04d..00000000
--- a/docs/todo.md
+++ /dev/null
@@ -1,3 +0,0 @@
-1. 如何更好的注册class
-2. op的返回,使用return 还是 self.output
-3. 如何把agent的东西放出来
\ No newline at end of file
diff --git a/example.env b/example.env
index b988f2b2..d1a27415 100644
--- a/example.env
+++ b/example.env
@@ -1,11 +1,5 @@
-FLOW_EMBEDDING_API_KEY=sk-xxxx
-FLOW_EMBEDDING_BASE_URL=https://xxxx/v1
-FLOW_LLM_API_KEY=sk-xxxx
-FLOW_LLM_BASE_URL=https://xxxx/v1
-
-REME_LLM_API_KEY=sk-xxxx
-REME_LLM_BASE_URL=https://xxxx/v1
-REME_EMBEDDING_API_KEY=sk-xxxx
-REME_EMBEDDING_BASE_URL=https://xxxx/v1
-
-TAVILY_API_KEY=xxxx
+LLM_API_KEY=sk-xxxx
+LLM_BASE_URL=https://xxxx/v1
+#EMBEDDING_API_KEY=sk-xxxx
+#EMBEDDING_BASE_URL=https://xxxx/v1
+#TAVILY_API_KEY=xxxx
diff --git a/pyproject.toml b/pyproject.toml
index 19df32d9..43da64dd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -77,7 +77,11 @@ dev = [
]
full = [
- "reme_ai[dev,ray]"
+ "reme_ai[dev,ray,light]",
+]
+
+light = [
+ "agentscope==1.0.16.dev0",
]
[tool.setuptools.packages.find]
diff --git a/reme/__init__.py b/reme/__init__.py
index d5304a7f..41537822 100644
--- a/reme/__init__.py
+++ b/reme/__init__.py
@@ -5,10 +5,8 @@ from . import core
from . import extension
from . import memory
from .reme import ReMe
-from .reme_cli import ReMeCli
-from .reme_fb import ReMeFb
-__version__ = "0.3.0.2"
+__version__ = "0.3.0.6b3"
__all__ = [
"config",
@@ -16,8 +14,6 @@ __all__ = [
"extension",
"memory",
"ReMe",
- "ReMeCli",
- "ReMeFb",
]
"""
diff --git a/reme/config/file.yaml b/reme/config/light.yaml
similarity index 53%
rename from reme/config/file.yaml
rename to reme/config/light.yaml
index d7be65d3..bc85c10d 100644
--- a/reme/config/file.yaml
+++ b/reme/config/light.yaml
@@ -1,43 +1,32 @@
-backend: cmd
-working_dir: .reme
-
-llms:
+as_llms:
default:
backend: openai
model_name: qwen3.5-plus
- request_interval: 1
+
+as_llm_formatters:
+ default:
+ backend: openai
embedding_models:
default:
backend: openai
- model_name: text-embedding-v4
dimensions: 1024
- enable_cache: true
use_dimensions: false
+ enable_cache: true
+ max_batch_size: 10
+ max_cache_size: 2000
+ max_input_length: 8192
file_stores:
default:
backend: chroma
- # backend: local
- store_name: reme
embedding_model: default
- fts_enabled: true
- vector_enabled: false
+ store_name: "reme"
file_watchers:
default:
backend: full
file_store: default
- watch_paths: [ ".reme", ".reme/memory" ]
suffix_filters: [ ".md" ]
recursive: false
scan_on_start: true
-
-token_counters:
- default:
- backend: base
-
- hf:
- backend: hf
- model_name: Qwen/Qwen3-Coder-30B-A3B-Instruct
- use_mirror: true
diff --git a/reme/config/service.yaml b/reme/config/service.yaml
index d736f215..31334099 100644
--- a/reme/config/service.yaml
+++ b/reme/config/service.yaml
@@ -66,7 +66,7 @@ flows:
description: "Whether to enable soft comparison between highest and lowest scoring trajectories (default: true)."
enable_similarity_comparison:
type: boolean
- description: "Whether to enable similarity-based comparison between success and failure trajectories (default: true)."
+ description: "Whether to enable similarity-based comparison between success and failure trajectories (default: false)."
max_similarity_sequences:
type: integer
description: "Maximum number of sequences to compare for similarity (default: 5)."
@@ -155,6 +155,7 @@ flows:
description: "The path to the memories file."
required:
- dump_file_path
+
test:
flow_content: TestOp()
description: "test"
diff --git a/reme/core/__init__.py b/reme/core/__init__.py
index 5872e2ad..053755cc 100644
--- a/reme/core/__init__.py
+++ b/reme/core/__init__.py
@@ -1,5 +1,7 @@
"""Core"""
+from . import as_llm
+from . import as_llm_formatter
from . import embedding
from . import enumeration
from . import file_store
@@ -21,6 +23,8 @@ from .service_context import ServiceContext
__all__ = [
# Submodules
+ "as_llm",
+ "as_llm_formatter",
"embedding",
"enumeration",
"file_watcher",
diff --git a/reme/core/application.py b/reme/core/application.py
index 49537807..46f4a934 100644
--- a/reme/core/application.py
+++ b/reme/core/application.py
@@ -1,6 +1,7 @@
"""High-level entry point for configuring and running ReMe services and flows."""
import asyncio
+import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
@@ -35,6 +36,8 @@ class Application:
enable_logo: bool = True,
log_to_console: bool = True,
parser: type[PydanticConfigParser] | None = None,
+ default_as_llm_config: dict | None = None,
+ default_as_llm_formatter_config: dict | None = None,
default_llm_config: dict | None = None,
default_embedding_model_config: dict | None = None,
default_vector_store_config: dict | None = None,
@@ -55,6 +58,8 @@ class Application:
config_path=config_path,
enable_logo=enable_logo,
log_to_console=log_to_console,
+ default_as_llm_config=default_as_llm_config,
+ default_as_llm_formatter_config=default_as_llm_formatter_config,
default_llm_config=default_llm_config,
default_embedding_model_config=default_embedding_model_config,
default_vector_store_config=default_vector_store_config,
@@ -147,6 +152,26 @@ class Application:
if self.service_context.service_config.enable_logo:
print_logo(service_config=self.service_config)
+ for name, config in self.service_config.as_llms.items():
+ if config.backend not in R.as_llms:
+ logger.warning(f"AS LLM backend {config.backend} is not supported.")
+ else:
+ config_dict = config.model_dump(exclude={"backend"})
+ if not config_dict.get("api_key", ""):
+ config_dict["api_key"] = os.getenv("LLM_API_KEY", "")
+ if "client_kwargs" not in config_dict:
+ config_dict["client_kwargs"] = {}
+ if not config_dict["client_kwargs"].get("base_url", ""):
+ config_dict["client_kwargs"]["base_url"] = os.getenv("LLM_BASE_URL", "")
+ self.service_context.as_llms[name] = R.as_llms[config.backend](**config_dict)
+
+ for name, config in self.service_config.as_llm_formatters.items():
+ if config.backend not in R.as_llm_formatters:
+ logger.warning(f"AS LLM formatter backend {config.backend} is not supported.")
+ else:
+ config_dict = config.model_dump(exclude={"backend"})
+ self.service_context.as_llm_formatters[name] = R.as_llm_formatters[config.backend](**config_dict)
+
for name, config in self.service_config.llms.items():
if config.backend not in R.llms:
logger.warning(f"LLM backend {config.backend} is not supported.")
diff --git a/reme/core/as_llm/__init__.py b/reme/core/as_llm/__init__.py
new file mode 100644
index 00000000..9cf527af
--- /dev/null
+++ b/reme/core/as_llm/__init__.py
@@ -0,0 +1,9 @@
+"""Module for registering AgentScope LLM models."""
+
+from agentscope.model import DashScopeChatModel
+from agentscope.model import OpenAIChatModel
+
+from ..registry_factory import R
+
+R.as_llms.register("openai")(OpenAIChatModel)
+R.as_llms.register("dashscope")(DashScopeChatModel)
diff --git a/reme/core/as_llm_formatter/__init__.py b/reme/core/as_llm_formatter/__init__.py
new file mode 100644
index 00000000..88b326a7
--- /dev/null
+++ b/reme/core/as_llm_formatter/__init__.py
@@ -0,0 +1,9 @@
+"""Module for registering AgentScope LLM formatters."""
+
+from agentscope.formatter import DashScopeChatFormatter
+from agentscope.formatter import OpenAIChatFormatter
+
+from ..registry_factory import R
+
+R.as_llm_formatters.register("openai")(OpenAIChatFormatter)
+R.as_llm_formatters.register("dashscope")(DashScopeChatFormatter)
diff --git a/reme/core/embedding/base_embedding_model.py b/reme/core/embedding/base_embedding_model.py
index 36c6def7..2b60e8e6 100644
--- a/reme/core/embedding/base_embedding_model.py
+++ b/reme/core/embedding/base_embedding_model.py
@@ -29,7 +29,7 @@ class BaseEmbeddingModel(ABC):
api_key: str | None = None,
base_url: str | None = None,
model_name: str = "",
- dimensions: int | None = 1024,
+ dimensions: int = 1024,
use_dimensions: bool = False,
max_batch_size: int = 10,
max_retries: int = 3,
@@ -81,12 +81,12 @@ class BaseEmbeddingModel(ABC):
@property
def api_key(self) -> str | None:
"""Get API key from environment variable."""
- return os.getenv("REME_EMBEDDING_API_KEY") or self._api_key
+ return os.getenv("EMBEDDING_API_KEY") or self._api_key
@property
def base_url(self) -> str | None:
"""Get base URL from environment variable."""
- return os.getenv("REME_EMBEDDING_BASE_URL") or self._base_url
+ return os.getenv("EMBEDDING_BASE_URL") or self._base_url
def _truncate_text(self, text: str) -> str:
"""Truncate text to max_input_length if it exceeds the limit."""
@@ -99,7 +99,34 @@ class BaseEmbeddingModel(ABC):
"""Truncate a list of texts to max_input_length."""
return [self._truncate_text(text) for text in texts]
- def _get_cache_key(self, text: str) -> str:
+ def _validate_and_adjust_embedding(self, embedding: list[float]) -> list[float]:
+ """Validate and adjust embedding dimensions to match expected dimensions.
+
+ Args:
+ embedding: The embedding vector to validate
+
+ Returns:
+ Embedding vector adjusted to match self.dimensions
+ """
+ actual_len = len(embedding)
+ if actual_len == self.dimensions:
+ return embedding
+
+ elif actual_len < self.dimensions:
+ logger.warning(
+ f"[ACTUAL_EMB_LENGTH]Embedding dimensions {actual_len} is less than expected {self.dimensions}, "
+ f"padding with zeros",
+ )
+ return embedding + [0.0] * (self.dimensions - actual_len)
+
+ else:
+ logger.warning(
+ f"[ACTUAL_EMB_LENGTH]Embedding dimensions {actual_len} is greater than expected {self.dimensions}, "
+ f"truncating to {self.dimensions}",
+ )
+ return embedding[: self.dimensions]
+
+ def _get_cache_key(self, text: str, dimensions: int) -> str:
"""Generate a cache key by hashing text + model_name + dimensions.
This ensures that the same text produces different cache keys when
@@ -107,12 +134,13 @@ class BaseEmbeddingModel(ABC):
Args:
text: Input text to hash
+ dimensions: Vector dimensions of the embeddings
Returns:
SHA256 hash combining text, model name, and dimensions
"""
# Combine text, model_name, and dimensions to create unique cache key
- cache_string = f"{text}|{self.model_name}|{self.dimensions}"
+ cache_string = f"{text}|{self.model_name}|{dimensions}"
return hashlib.sha256(cache_string.encode("utf-8")).hexdigest()
def _get_cache_file_path(self) -> Path:
@@ -164,6 +192,13 @@ class BaseEmbeddingModel(ABC):
if cache_key in self._embedding_cache:
continue
+ if len(embedding) != self.dimensions:
+ logger.warning(
+ f"Embedding dimensions mismatch for cache key {cache_key}, "
+ f"expected {self.dimensions}, got {len(embedding)}",
+ )
+ continue
+
# Respect max_cache_size during loading
if len(self._embedding_cache) >= self.max_cache_size:
logger.info(
@@ -204,6 +239,12 @@ class BaseEmbeddingModel(ABC):
try:
with open(cache_file, "w", encoding="utf-8") as f:
for cache_key, embedding in self._embedding_cache.items():
+ if len(embedding) != self.dimensions:
+ logger.warning(
+ f"Embedding dimensions mismatch for cache key {cache_key}, "
+ f"expected {self.dimensions}, got {len(embedding)}",
+ )
+ continue
cache_entry = {cache_key: embedding}
f.write(json.dumps(cache_entry, ensure_ascii=False) + "\n")
@@ -223,16 +264,27 @@ class BaseEmbeddingModel(ABC):
if not self.enable_cache:
return None
- cache_key = self._get_cache_key(text)
+ cache_key = self._get_cache_key(text, self.dimensions)
if cache_key in self._embedding_cache:
+ embeddings: list[float] = self._embedding_cache[cache_key]
+
+ # Validate embedding dimensions match expected dimensions
+ if len(embeddings) != self.dimensions:
+ logger.warning(
+ f"Cached embedding dimensions mismatch: expected {self.dimensions}, "
+ f"got {len(embeddings)}. Removing invalid cache entry.",
+ )
+ del self._embedding_cache[cache_key]
+ self._cache_misses += 1
+ return None
+
# Move to end (most recently used)
self._embedding_cache.move_to_end(cache_key)
self._cache_hits += 1
text_preview = text[:50] + "..." if len(text) > 50 else text
- logger.info(
- f"Cache hit for text: '{text_preview}' (hits: {self._cache_hits}, misses: {self._cache_misses})",
- )
- return self._embedding_cache[cache_key]
+ logger.info(f"Cache hit for text: {text_preview} (hits: {self._cache_hits}, misses: {self._cache_misses})")
+ return embeddings
+
self._cache_misses += 1
return None
@@ -249,9 +301,15 @@ class BaseEmbeddingModel(ABC):
if self.max_cache_size <= 0:
return
- cache_key = self._get_cache_key(text)
+ cache_key = self._get_cache_key(text, self.dimensions)
+ if len(embedding) != self.dimensions:
+ logger.warning(
+ f"[PUT_TO_CACHE] Embedding dimensions mismatch for cache key {cache_key}, "
+ f"expected {self.dimensions}, got real length {len(embedding)}",
+ )
+ return
- # Remove oldest entry if cache is full
+ # Remove the oldest entry if cache is full
if len(self._embedding_cache) >= self.max_cache_size and cache_key not in self._embedding_cache:
self._embedding_cache.popitem(last=False)
@@ -299,7 +357,7 @@ class BaseEmbeddingModel(ABC):
for i in range(self.max_retries):
try:
result = await self._get_embeddings([truncated_text], **kwargs)
- embedding = result[0]
+ embedding = self._validate_and_adjust_embedding(result[0])
# Store in cache
self._put_to_cache(truncated_text, embedding)
return embedding
@@ -345,8 +403,9 @@ class BaseEmbeddingModel(ABC):
if batch_embeddings:
# Store results and cache them
for orig_idx, text, embedding in zip(batch_indices, batch_texts, batch_embeddings):
- results[orig_idx] = embedding
- self._put_to_cache(text, embedding)
+ adjusted_embedding = self._validate_and_adjust_embedding(embedding)
+ results[orig_idx] = adjusted_embedding
+ self._put_to_cache(text, adjusted_embedding)
break
except Exception as e:
logger.error(f"Model {self.model_name} batch failed: {e}")
@@ -371,7 +430,7 @@ class BaseEmbeddingModel(ABC):
for i in range(self.max_retries):
try:
result = self._get_embeddings_sync([truncated_text], **kwargs)
- embedding = result[0]
+ embedding = self._validate_and_adjust_embedding(result[0])
# Store in cache
self._put_to_cache(truncated_text, embedding)
return embedding
@@ -417,8 +476,9 @@ class BaseEmbeddingModel(ABC):
if batch_embeddings:
# Store results and cache them
for orig_idx, text, embedding in zip(batch_indices, batch_texts, batch_embeddings):
- results[orig_idx] = embedding
- self._put_to_cache(text, embedding)
+ adjusted_embedding = self._validate_and_adjust_embedding(embedding)
+ results[orig_idx] = adjusted_embedding
+ self._put_to_cache(text, adjusted_embedding)
break
except Exception as exc:
logger.error(f"Model {self.model_name} batch failed: {exc}")
diff --git a/reme/core/file_store/chroma_file_store.py b/reme/core/file_store/chroma_file_store.py
index 390a2bc4..d87f5fef 100644
--- a/reme/core/file_store/chroma_file_store.py
+++ b/reme/core/file_store/chroma_file_store.py
@@ -1,6 +1,7 @@
"""ChromaDB storage backend for file store."""
import json
+import random
import time
from pathlib import Path
@@ -355,12 +356,41 @@ class ChromaFileStore(BaseFileStore):
where_filter = {"source": {"$in": [s.value for s in sources]}}
# Perform vector search
- results = self.chunks_collection.query(
- query_embeddings=[query_embedding],
- n_results=limit,
- where=where_filter,
- include=["documents", "metadatas", "distances"],
- )
+ try:
+ results = self.chunks_collection.query(
+ query_embeddings=[query_embedding],
+ n_results=limit,
+ where=where_filter,
+ include=["documents", "metadatas", "distances"],
+ )
+ except Exception as e:
+ logger.error(f"Vector search failed: {e}, falling back to random results")
+ # Fallback: get some documents without vector search and assign random scores
+ try:
+ fallback_results = self.chunks_collection.get(
+ where=where_filter,
+ limit=limit,
+ include=["documents", "metadatas"],
+ )
+ search_results = []
+ if fallback_results["ids"]:
+ for i, _ in enumerate(fallback_results["ids"]):
+ metadata = fallback_results["metadatas"][i]
+ search_results.append(
+ MemorySearchResult(
+ path=metadata["path"],
+ start_line=metadata["start_line"],
+ end_line=metadata["end_line"],
+ score=random.uniform(0.3, 0.7), # Random score in middle range
+ snippet=fallback_results["documents"][i],
+ source=MemorySource(metadata["source"]),
+ raw_metric=None,
+ ),
+ )
+ return search_results
+ except Exception as fallback_e:
+ logger.error(f"Fallback search also failed: {fallback_e}")
+ return []
search_results = []
if results["ids"] and results["ids"][0]:
@@ -430,7 +460,7 @@ class ChromaFileStore(BaseFileStore):
# ChromaDB where_document uses $contains for substring matching (case-sensitive)
# Use multiple case variants to improve recall
if len(word_variants_list) == 1:
- where_document = {"$contains": word_variants_list[0]}
+ where_document: dict = {"$contains": word_variants_list[0]}
else:
where_document = {"$or": [{"$contains": w} for w in word_variants_list]}
diff --git a/reme/core/file_store/local_file_store.py b/reme/core/file_store/local_file_store.py
index 37df17f4..38757d7a 100644
--- a/reme/core/file_store/local_file_store.py
+++ b/reme/core/file_store/local_file_store.py
@@ -259,6 +259,8 @@ class LocalFileStore(BaseFileStore):
if not query_embedding:
return []
+ expected_dim = self.embedding_dim
+
# Collect candidate chunks with embeddings
candidates = [
chunk for chunk in self._chunks.values() if (not sources or chunk.source in sources) and chunk.embedding
@@ -267,9 +269,29 @@ class LocalFileStore(BaseFileStore):
if not candidates:
return []
+ # Validate and fix chunk embedding dimensions
+ valid_embeddings = []
+ for chunk in candidates:
+ emb = chunk.embedding
+ emb_len = len(emb)
+ if emb_len != expected_dim:
+ if emb_len < expected_dim:
+ emb = emb + [0.0] * (expected_dim - emb_len)
+ logger.warning(
+ f"Chunk embedding dimension {emb_len} < expected {expected_dim}, "
+ f"padded with zeros (chunk_id={chunk.id})",
+ )
+ else:
+ emb = emb[:expected_dim]
+ logger.warning(
+ f"Chunk embedding dimension {emb_len} > expected {expected_dim}, "
+ f"truncated to {expected_dim} (chunk_id={chunk.id})",
+ )
+ valid_embeddings.append(emb)
+
# Build embedding matrix and compute similarities in batch
query_array = np.array([query_embedding]) # Shape: (1, emb_size)
- chunk_embeddings = np.array([chunk.embedding for chunk in candidates]) # Shape: (n, emb_size)
+ chunk_embeddings = np.array(valid_embeddings) # Shape: (n, emb_size)
similarities = batch_cosine_similarity(query_array, chunk_embeddings)[0] # Shape: (n,)
# Build results
diff --git a/reme/core/file_watcher/base_file_watcher.py b/reme/core/file_watcher/base_file_watcher.py
index 5030a9cb..89c6e54a 100644
--- a/reme/core/file_watcher/base_file_watcher.py
+++ b/reme/core/file_watcher/base_file_watcher.py
@@ -140,35 +140,63 @@ class BaseFileWatcher:
else:
logger.info("[SCAN_ON_START] No existing files found matching watch criteria")
- files: list[str] = await self.file_store.list_files(MemorySource.MEMORY)
- for file_path in files:
- chunks = await self.file_store.get_file_chunks(file_path, MemorySource.MEMORY)
- logger.info(f"Found existing file: {file_path}, {len(chunks)} chunks")
+ if self.file_store is not None:
+ files: list[str] = await self.file_store.list_files(MemorySource.MEMORY)
+ for file_path in files:
+ chunks = await self.file_store.get_file_chunks(file_path, MemorySource.MEMORY)
+ logger.info(f"Found existing file: {file_path}, {len(chunks)} chunks")
+
+ async def _interruptible_sleep(self, seconds: float):
+ """Sleep that can be interrupted by stop_event."""
+ try:
+ await asyncio.wait_for(self._stop_event.wait(), timeout=seconds)
+ except asyncio.TimeoutError:
+ pass # Normal timeout, continue
async def _watch_loop(self):
- """Core monitoring loop"""
+ """Core monitoring loop with auto-restart on failure"""
if not self.watch_paths:
logger.warning("No watch paths specified")
return
- try:
- async for changes in awatch(
- *self.watch_paths,
- watch_filter=self.watch_filter,
- recursive=self.recursive,
- debounce=self.debounce,
- stop_event=self._stop_event,
- ):
- if self._stop_event.is_set():
- break
+ while not self._stop_event.is_set():
+ # Filter out non-existent paths before each watch attempt
+ valid_paths = [p for p in self.watch_paths if Path(p).exists()]
- await self.on_changes(changes)
- except FileNotFoundError as e:
- # Watch path was deleted, this is expected during cleanup
- logger.debug(f"Watch path no longer exists: {e}")
- except Exception as e:
- # Log other exceptions but don't crash
- logger.error(f"Error in watch loop: {e}", exc_info=True)
+ if not valid_paths:
+ logger.warning("No valid watch paths exist, waiting 10 seconds before retry...")
+ await self._interruptible_sleep(10)
+ continue
+
+ invalid_paths = set(self.watch_paths) - set(valid_paths)
+ if invalid_paths:
+ logger.warning(f"Skipping non-existent paths: {invalid_paths}")
+
+ try:
+ logger.info(f"Starting watch on valid paths: {valid_paths}")
+ async for changes in awatch(
+ *valid_paths,
+ watch_filter=self.watch_filter,
+ recursive=self.recursive,
+ debounce=self.debounce,
+ stop_event=self._stop_event,
+ ):
+ if self._stop_event.is_set():
+ break
+
+ await self.on_changes(changes)
+
+ except FileNotFoundError as e:
+ # Watch path was deleted during monitoring
+ logger.error(f"Watch path no longer exists: {e}, restarting in 10 seconds...")
+ if not self._stop_event.is_set():
+ await self._interruptible_sleep(10)
+
+ except Exception as e:
+ # Log other exceptions and restart
+ logger.error(f"Error in watch loop: {e}, restarting in 10 seconds...", exc_info=True)
+ if not self._stop_event.is_set():
+ await self._interruptible_sleep(10)
async def _on_changes(self, changes: set[tuple[Change, str]]):
"""Callback method to handle file changes"""
diff --git a/reme/core/file_watcher/delta_file_watcher.py b/reme/core/file_watcher/delta_file_watcher.py
index 6148bd07..f35c9b2f 100644
--- a/reme/core/file_watcher/delta_file_watcher.py
+++ b/reme/core/file_watcher/delta_file_watcher.py
@@ -141,6 +141,7 @@ class DeltaFileWatcher(BaseFileWatcher):
async def _on_changes(self, changes: set[tuple[Change, str]]):
"""Handle file changes with incremental synchronization."""
self.dirty = True
+ await self.file_store.clear_all()
for change_type, path in changes:
if change_type == Change.added:
diff --git a/reme/core/file_watcher/full_file_watcher.py b/reme/core/file_watcher/full_file_watcher.py
index 2d365f23..c49a94fa 100644
--- a/reme/core/file_watcher/full_file_watcher.py
+++ b/reme/core/file_watcher/full_file_watcher.py
@@ -44,6 +44,8 @@ class FullFileWatcher(BaseFileWatcher):
async def _on_changes(self, changes: set[tuple[Change, str]]):
"""Handle file changes with full synchronization"""
self.dirty = True
+ await self.file_store.clear_all()
+
for change_type, path in changes:
if change_type in [Change.added, Change.modified]:
file_meta = await self._build_file_metadata(path)
diff --git a/reme/core/llm/base_llm.py b/reme/core/llm/base_llm.py
index 856c7f3b..ba13ff10 100644
--- a/reme/core/llm/base_llm.py
+++ b/reme/core/llm/base_llm.py
@@ -50,12 +50,12 @@ class BaseLLM(ABC):
@property
def api_key(self) -> str | None:
"""Get API key from environment variable."""
- return os.getenv("REME_LLM_API_KEY") or self._api_key
+ return os.getenv("LLM_API_KEY") or self._api_key
@property
def base_url(self) -> str | None:
"""Get base URL from environment variable."""
- return os.getenv("REME_LLM_BASE_URL") or self._base_url
+ return os.getenv("LLM_BASE_URL") or self._base_url
@staticmethod
def _accumulate_tool_call_chunk(tool_call, ret_tools: list[ToolCall]):
diff --git a/reme/core/op/base_op.py b/reme/core/op/base_op.py
index af5158c4..86cfa3be 100644
--- a/reme/core/op/base_op.py
+++ b/reme/core/op/base_op.py
@@ -7,6 +7,8 @@ from abc import ABCMeta
from pathlib import Path
from typing import Callable, Optional, Any
+from agentscope.formatter import FormatterBase
+from agentscope.model import ChatModelBase
from loguru import logger
from tqdm import tqdm
@@ -42,6 +44,8 @@ class BaseOp(metaclass=ABCMeta):
language: str = "",
prompt_name: str = "",
prompt_path: str = "",
+ as_llm: str | ChatModelBase = "default",
+ as_llm_formatter: str | FormatterBase = "default",
llm: str | BaseLLM = "default",
embedding_model: str | BaseEmbeddingModel = "default",
vector_store: str | BaseVectorStore = "default",
@@ -64,6 +68,8 @@ class BaseOp(metaclass=ABCMeta):
self.language = language
self.prompt = self._get_prompt_handler(prompt_name, prompt_path)
+ self._as_llm = as_llm
+ self._as_llm_formatter = as_llm_formatter
self._llm = llm
self._embedding_model = embedding_model
self._vector_store = vector_store
@@ -129,6 +135,20 @@ class BaseOp(metaclass=ABCMeta):
"""Access the service configuration."""
return self.service_context.service_config
+ @property
+ def as_llm(self) -> ChatModelBase:
+ """Get the AgentScope LLM instance from ServiceContext."""
+ if isinstance(self._as_llm, str):
+ self._as_llm = self.service_context.as_llms[self._as_llm]
+ return self._as_llm
+
+ @property
+ def as_llm_formatter(self) -> FormatterBase:
+ """Get the AgentScope LLM formatter instance from ServiceContext."""
+ if isinstance(self._as_llm_formatter, str):
+ self._as_llm_formatter = self.service_context.as_llm_formatters[self._as_llm_formatter]
+ return self._as_llm_formatter
+
@property
def llm(self) -> BaseLLM:
"""Get the LLM instance from ServiceContext."""
diff --git a/reme/core/registry_factory.py b/reme/core/registry_factory.py
index b319b049..f54ad3c1 100644
--- a/reme/core/registry_factory.py
+++ b/reme/core/registry_factory.py
@@ -34,6 +34,8 @@ class RegistryFactory:
def __init__(self):
self.llms = Registry()
+ self.as_llms = Registry()
+ self.as_llm_formatters = Registry()
self.embedding_models = Registry()
self.vector_stores = Registry()
self.file_stores = Registry()
diff --git a/reme/core/schema/__init__.py b/reme/core/schema/__init__.py
index de167c69..b6445a28 100644
--- a/reme/core/schema/__init__.py
+++ b/reme/core/schema/__init__.py
@@ -1,5 +1,6 @@
"""schema"""
+from .as_msg_stat import AsBlockStat, AsMsgStat
from .cut_point_result import CutPointResult
from .file_metadata import FileMetadata
from .memory_chunk import MemoryChunk
@@ -27,6 +28,8 @@ from .truncation_result import TruncationResult
from .vector_node import VectorNode
__all__ = [
+ "AsBlockStat",
+ "AsMsgStat",
"CutPointResult",
"CmdConfig",
"ContentBlock",
diff --git a/reme/core/schema/as_msg_stat.py b/reme/core/schema/as_msg_stat.py
new file mode 100644
index 00000000..b4ef02d4
--- /dev/null
+++ b/reme/core/schema/as_msg_stat.py
@@ -0,0 +1,92 @@
+"""Schema definitions for AgentScope message statistics."""
+
+from pydantic import BaseModel, Field
+
+_DEFAULT_MAX_BLOCK_TEXT_PREVIEW_LENGTH = 100
+_DEFAULT_MAX_FORMATTER_TEXT_LENGTH = 2000
+
+
+class AsBlockStat(BaseModel):
+ """Statistics and metadata for a single content block in an AgentScope message."""
+
+ block_type: str = Field(default=...)
+ text: str = Field(default="", description="Text content of the block")
+ token_count: int = Field(default=0, description="Token count of the block, including base64 data")
+
+ # For tool_use and tool_result blocks
+ tool_name: str = Field(default="", description="Tool name for tool_use/tool_result blocks")
+ tool_input: str = Field(default="", description="Tool input arguments for tool_use blocks")
+ tool_output: str = Field(default="", description="Tool output for tool_result blocks")
+
+ # For media blocks
+ media_url: str = Field(default="", description="URL for image/audio/video blocks")
+
+ @property
+ def preview(self) -> str:
+ """Return a short preview of the block content."""
+ return self.format(_DEFAULT_MAX_BLOCK_TEXT_PREVIEW_LENGTH)
+
+ def _truncate(self, text: str, max_length: int) -> str:
+ """Simple truncation with ellipsis."""
+ if len(text) <= max_length:
+ return text
+ return text[:max_length] + "..."
+
+ # pylint: disable=too-many-return-statements
+ def format(self, max_length: int = _DEFAULT_MAX_FORMATTER_TEXT_LENGTH, include_thinking: bool = True) -> str:
+ """Format block content to string representation.
+
+ Args:
+ max_length: Maximum length of text content in the output.
+ include_thinking: Whether to include thinking block content.
+
+ Returns:
+ Formatted string representation of the block.
+ """
+ if self.block_type == "text":
+ if not self.text:
+ return ""
+ return f"{self._truncate(self.text, max_length)}"
+ if self.block_type == "thinking":
+ if not include_thinking or not self.text:
+ return ""
+ return f"{self._truncate(self.text, max_length)}"
+ if self.block_type in ("image", "audio", "video"):
+ content = self.media_url if self.media_url else ""
+ return f"<{self.block_type}>{content}{self.block_type}>"
+ if self.block_type == "tool_use":
+ content = f"{self.tool_name} params={self._truncate(self.tool_input, max_length)}"
+ return f"{content}"
+ if self.block_type == "tool_result":
+ if not self.tool_output:
+ return ""
+ content = f"{self.tool_name} output={self._truncate(self.tool_output, max_length)}"
+ return f"{content}"
+ return ""
+
+
+class AsMsgStat(BaseModel):
+ """Statistics and metadata for a complete AgentScope message."""
+
+ name: str = Field(default=...)
+ role: str = Field(default="")
+ content: list[AsBlockStat] = Field(default_factory=list)
+ timestamp: str = Field(default="")
+ metadata: dict = Field(default_factory=dict)
+
+ @property
+ def total_tokens(self) -> int:
+ """Return the total token count across all content blocks."""
+ return sum(block.token_count for block in self.content)
+
+ @property
+ def preview(self) -> str:
+ """Return a short preview of the message content."""
+ return self.format(_DEFAULT_MAX_BLOCK_TEXT_PREVIEW_LENGTH)
+
+ def format(self, max_length: int = _DEFAULT_MAX_FORMATTER_TEXT_LENGTH, include_thinking: bool = True) -> str:
+ """Format message to string representation."""
+ time_str = f"[{self.timestamp}] " if self.timestamp else ""
+ header = f"{time_str}{self.name or self.role}:"
+ blocks = [block.format(max_length, include_thinking) for block in self.content]
+ return "\n".join([header] + [b for b in blocks if b])
diff --git a/reme/core/schema/service_config.py b/reme/core/schema/service_config.py
index 5b89367a..5e4cd212 100644
--- a/reme/core/schema/service_config.py
+++ b/reme/core/schema/service_config.py
@@ -58,69 +58,60 @@ class FlowConfig(ToolCall):
cache_expire_hours: float = Field(default=0.1)
-class LLMConfig(BaseModel):
+class BasicConfig(BaseModel):
+ """Configuration for basic service settings and parameters."""
+
+ model_config = ConfigDict(extra="allow")
+
+ backend: str = Field(default="")
+
+
+class ModelConfig(BasicConfig):
+ """Configuration for model-based services with backend and model name."""
+
+ model_name: str = Field(default="")
+
+
+class LLMConfig(ModelConfig):
"""Configuration for Large Language Model backend and model identification."""
- model_config = ConfigDict(extra="allow")
- backend: str = Field(default="")
- model_name: str = Field(default="")
-
-
-class EmbeddingModelConfig(BaseModel):
+class EmbeddingModelConfig(ModelConfig):
"""Configuration for embedding model backends and identity."""
- model_config = ConfigDict(extra="allow")
- backend: str = Field(default="")
- model_name: str = Field(default="")
-
-
-class VectorStoreConfig(BaseModel):
- """Configuration for vector database storage and associated embeddings."""
-
- model_config = ConfigDict(extra="allow")
-
- backend: str = Field(default="local")
- collection_name: str = Field(default="reme")
- embedding_model: str = Field(default="default")
-
-
-class FileStoreConfig(BaseModel):
- """Configuration for file store database storage and associated embeddings."""
-
- model_config = ConfigDict(extra="allow")
-
- backend: str = Field(default="sqlite")
- store_name: str = Field(default="reme")
- embedding_model: str = Field(default="default")
-
-
-class TokenCounterConfig(BaseModel):
+class TokenCounterConfig(ModelConfig):
"""Configuration for token counting services and model mapping."""
- model_config = ConfigDict(extra="allow")
- backend: str = Field(default="base")
- model_name: str = Field(default="")
+class StoreConfig(BasicConfig):
+ """Configuration for storage services with embedding model support."""
+
+ embedding_model: str = Field(default="default")
-class FileWatcherConfig(BaseModel):
+class VectorStoreConfig(StoreConfig):
+ """Configuration for vector database storage and associated embeddings."""
+
+ collection_name: str = Field(default="reme")
+
+
+class FileStoreConfig(StoreConfig):
+ """Configuration for file store database storage and associated embeddings."""
+
+ store_name: str = Field(default="reme")
+
+
+class FileWatcherConfig(BasicConfig):
"""Configuration for file watcher service."""
- model_config = ConfigDict(extra="allow")
-
- backend: str = Field(default="")
file_store: str = Field(default="")
watch_paths: list[str] = Field(default_factory=list)
-class ServiceConfig(BaseModel):
+class ServiceConfig(BasicConfig):
"""Root configuration schema aggregating all service-level settings and components."""
- model_config = ConfigDict(extra="allow")
-
- backend: str = Field(default="")
app_name: str = Field(default=os.getenv("APP_NAME", "ReMe"))
working_dir: str = Field(default=".reme")
enable_logo: bool = Field(default=True)
@@ -137,6 +128,8 @@ class ServiceConfig(BaseModel):
cmd: CmdConfig = Field(default_factory=CmdConfig)
ops: dict[str, OpConfig] = Field(default_factory=dict)
flows: dict[str, FlowConfig] = Field(default_factory=dict)
+ as_llms: dict[str, BasicConfig] = Field(default_factory=dict)
+ as_llm_formatters: dict[str, BasicConfig] = Field(default_factory=dict)
llms: dict[str, LLMConfig] = Field(default_factory=dict)
embedding_models: dict[str, EmbeddingModelConfig] = Field(default_factory=dict)
vector_stores: dict[str, VectorStoreConfig] = Field(default_factory=dict)
diff --git a/reme/core/service_context.py b/reme/core/service_context.py
index 0b9c5a1b..92d0d566 100644
--- a/reme/core/service_context.py
+++ b/reme/core/service_context.py
@@ -11,6 +11,8 @@ from .schema import ServiceConfig
from .utils import load_env, PydanticConfigParser
if TYPE_CHECKING:
+ from agentscope.model import ChatModelBase
+ from agentscope.formatter import FormatterBase
from .llm import BaseLLM
from .embedding import BaseEmbeddingModel
from .vector_store import BaseVectorStore
@@ -36,6 +38,8 @@ class ServiceContext(BaseDict):
config_path: str | None = None,
enable_logo: bool = True,
log_to_console: bool = True,
+ default_as_llm_config: dict | None = None,
+ default_as_llm_formatter_config: dict | None = None,
default_llm_config: dict | None = None,
default_embedding_model_config: dict | None = None,
default_vector_store_config: dict | None = None,
@@ -50,10 +54,10 @@ class ServiceContext(BaseDict):
load_env()
# Update common environment variables for LLM and embedding services.
- self.update_env("REME_LLM_API_KEY", llm_api_key)
- self.update_env("REME_LLM_BASE_URL", llm_base_url)
- self.update_env("REME_EMBEDDING_API_KEY", embedding_api_key)
- self.update_env("REME_EMBEDDING_BASE_URL", embedding_base_url)
+ self.update_env("LLM_API_KEY", llm_api_key)
+ self.update_env("LLM_BASE_URL", llm_base_url)
+ self.update_env("EMBEDDING_API_KEY", embedding_api_key)
+ self.update_env("EMBEDDING_BASE_URL", embedding_base_url)
if service_config is None:
parser_class = parser if parser is not None else PydanticConfigParser
@@ -64,6 +68,10 @@ class ServiceContext(BaseDict):
if args:
input_args.extend(args)
+ if default_as_llm_config:
+ self._update_section_config(kwargs, "as_llms", **default_as_llm_config)
+ if default_as_llm_formatter_config:
+ self._update_section_config(kwargs, "as_llm_formatters", **default_as_llm_formatter_config)
if default_llm_config:
self._update_section_config(kwargs, "llms", **default_llm_config)
if default_embedding_model_config:
@@ -90,6 +98,8 @@ class ServiceContext(BaseDict):
self.service_config: ServiceConfig = service_config
self.thread_pool: ThreadPoolExecutor | None = None
+ self.as_llms: dict[str, "ChatModelBase"] = {}
+ self.as_llm_formatters: dict[str, "FormatterBase"] = {}
self.llms: dict[str, "BaseLLM"] = {}
self.embedding_models: dict[str, "BaseEmbeddingModel"] = {}
self.token_counters: dict[str, "BaseTokenCounter"] = {}
diff --git a/reme/core/utils/__init__.py b/reme/core/utils/__init__.py
index b43784c8..c1f35adb 100644
--- a/reme/core/utils/__init__.py
+++ b/reme/core/utils/__init__.py
@@ -11,12 +11,15 @@ from .horse import play_horse_easter_egg
from .http_client import HttpClient
from .llm_utils import extract_content, format_messages, deduplicate_memories
from .logger_utils import init_logger
+from .std_logger import get_logger as get_std_logger
from .logo_utils import print_logo
from .mcp_client import MCPClient
from .pydantic_config_parser import PydanticConfigParser
from .pydantic_utils import create_pydantic_model
from .singleton import singleton
from .time import timer, get_now_time
+from .hf_token_counter_utils import get_hf_token_counter
+from .truncate_text_utils import truncate_text, is_truncated
__all__ = [
"convert_dashscope_to_agentscope",
@@ -39,6 +42,7 @@ __all__ = [
"format_messages",
"deduplicate_memories",
"init_logger",
+ "get_std_logger",
"print_logo",
"MCPClient",
"PydanticConfigParser",
@@ -46,4 +50,7 @@ __all__ = [
"singleton",
"timer",
"get_now_time",
+ "get_hf_token_counter",
+ "truncate_text",
+ "is_truncated",
]
diff --git a/reme/core/utils/hf_token_counter_utils.py b/reme/core/utils/hf_token_counter_utils.py
new file mode 100644
index 00000000..a8ab348c
--- /dev/null
+++ b/reme/core/utils/hf_token_counter_utils.py
@@ -0,0 +1,23 @@
+"""Utility functions for working with text."""
+
+from agentscope.token import HuggingFaceTokenCounter
+
+_token_counter = None
+
+
+def get_hf_token_counter(
+ pretrained_model_name_or_path="Qwen/Qwen2.5-7B-Instruct",
+ use_mirror=True,
+ use_fast=True,
+ trust_remote_code=True,
+):
+ """Get or initialize the global token counter instance."""
+ global _token_counter
+ if _token_counter is None:
+ _token_counter = HuggingFaceTokenCounter(
+ pretrained_model_name_or_path=pretrained_model_name_or_path,
+ use_mirror=use_mirror,
+ use_fast=use_fast,
+ trust_remote_code=trust_remote_code,
+ )
+ return _token_counter
diff --git a/reme/core/utils/logger_utils.py b/reme/core/utils/logger_utils.py
index 22819db8..512c0a42 100644
--- a/reme/core/utils/logger_utils.py
+++ b/reme/core/utils/logger_utils.py
@@ -18,26 +18,6 @@ def init_logger(log_dir: str = "logs", level: str = "INFO", log_to_console: bool
# Remove default handler to avoid duplicate logs
logger.remove()
- # Ensure the logging directory exists
- os.makedirs(log_dir, exist_ok=True)
-
- # Generate filename based on the current timestamp
- # Use dashes instead of colons for Windows compatibility
- current_ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
- log_filename = f"{current_ts}.log"
- log_filepath = os.path.join(log_dir, log_filename)
-
- # Configure file-based logging with rotation and compression
- logger.add(
- log_filepath,
- level=level,
- rotation="00:00",
- retention="7 days",
- compression="zip",
- encoding="utf-8",
- format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {file}:{line} | {function} | {message}",
- )
-
# Configure colorized standard output logging if enabled
if log_to_console:
logger.add(
@@ -46,3 +26,27 @@ def init_logger(log_dir: str = "logs", level: str = "INFO", log_to_console: bool
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {file}:{line} | {function} | {message}",
colorize=True,
)
+
+ # Try to configure file-based logging (skip if permission denied)
+ try:
+ # Ensure the logging directory exists
+ os.makedirs(log_dir, exist_ok=True)
+
+ # Generate filename based on the current timestamp
+ # Use dashes instead of colons for Windows compatibility
+ current_ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ log_filename = f"{current_ts}.log"
+ log_filepath = os.path.join(log_dir, log_filename)
+
+ # Configure file-based logging with rotation and compression
+ logger.add(
+ log_filepath,
+ level=level,
+ rotation="00:00",
+ retention="7 days",
+ compression="zip",
+ encoding="utf-8",
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {file}:{line} | {function} | {message}",
+ )
+ except Exception as e:
+ logger.error(f"Error configuring file logging: {e}")
diff --git a/reme/core/utils/std_logger.py b/reme/core/utils/std_logger.py
new file mode 100644
index 00000000..dbdf1908
--- /dev/null
+++ b/reme/core/utils/std_logger.py
@@ -0,0 +1,120 @@
+"""Standard logging module configuration with loguru-like features."""
+
+import logging
+import os
+import sys
+from datetime import datetime
+from logging.handlers import TimedRotatingFileHandler
+
+# Store created logger instances
+_loggers: dict[str, logging.Logger] = {}
+
+
+class CustomFormatter(logging.Formatter):
+ """Custom formatter with colorized output support."""
+
+ # ANSI color codes
+ COLORS = {
+ logging.DEBUG: "\033[36m", # Cyan
+ logging.INFO: "\033[32m", # Green
+ logging.WARNING: "\033[33m", # Yellow
+ logging.ERROR: "\033[31m", # Red
+ logging.CRITICAL: "\033[35m", # Magenta
+ }
+ RESET = "\033[0m"
+
+ def __init__(self, fmt: str, colorize: bool = False):
+ super().__init__(fmt)
+ self.colorize = colorize
+
+ def format(self, record: logging.LogRecord) -> str:
+ # Add custom attribute: simplified filename and line number
+ record.file_line = f"{record.filename}:{record.lineno}"
+
+ if self.colorize:
+ color = self.COLORS.get(record.levelno, self.RESET)
+ record.levelname = f"{color}{record.levelname}{self.RESET}"
+
+ return super().format(record)
+
+
+def get_loggerv2(
+ name: str = "reme",
+ log_dir: str = "logs",
+ level: str = "INFO",
+ log_to_console: bool = True,
+ log_to_file: bool = True,
+ log_file_prefix: str = "reme",
+ rotation: str = "midnight",
+ retention_days: int = 7,
+) -> logging.Logger:
+ """Get a configured logger instance.
+
+ Args:
+ name: Logger name for distinguishing different loggers.
+ log_dir: Directory path for log files.
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
+ log_to_console: Whether to output logs to console.
+ log_to_file: Whether to output logs to file.
+ log_file_prefix: Prefix for log file names (e.g., 'reme' -> 'reme_2024-01-01.log').
+ rotation: Log rotation time, defaults to midnight.
+ retention_days: Number of days to retain log files.
+
+ Returns:
+ Configured Logger instance.
+ """
+ # Return existing logger if already created
+ if name in _loggers:
+ return _loggers[name]
+
+ # Create new logger without using root logger
+ logger = logging.getLogger(name)
+ logger.setLevel(getattr(logging, level.upper(), logging.INFO))
+ logger.propagate = False # Do not propagate to root logger
+
+ # Clear existing handlers
+ logger.handlers.clear()
+
+ # Log format
+ log_format = "%(asctime)s | %(levelname)s | %(file_line)s | %(funcName)s | %(message)s"
+
+ # Configure file logging
+ if log_to_file:
+ try:
+ os.makedirs(log_dir, exist_ok=True)
+ current_ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+ log_filename = f"{log_file_prefix}_{current_ts}.log"
+ log_filepath = os.path.join(log_dir, log_filename)
+
+ file_handler = TimedRotatingFileHandler(
+ log_filepath,
+ when=rotation,
+ interval=1,
+ backupCount=retention_days,
+ encoding="utf-8",
+ )
+ file_handler.setLevel(getattr(logging, level.upper(), logging.INFO))
+ file_handler.setFormatter(CustomFormatter(log_format, colorize=False))
+ file_handler.suffix = "%Y-%m-%d"
+ logger.addHandler(file_handler)
+
+ except Exception as e:
+ logger.error(f"Error configuring file logging: {e}")
+
+ # Configure console logging
+ if log_to_console:
+ console_handler = logging.StreamHandler(sys.stdout)
+ console_handler.setLevel(getattr(logging, level.upper(), logging.INFO))
+ console_handler.setFormatter(CustomFormatter(log_format, colorize=True))
+ logger.addHandler(console_handler)
+
+ # Cache logger
+ _loggers[name] = logger
+ return logger
+
+
+def get_logger():
+ """Get a configured logger instance using loguru."""
+ from loguru import logger
+
+ return logger
diff --git a/reme/core/utils/truncate_text_utils.py b/reme/core/utils/truncate_text_utils.py
new file mode 100644
index 00000000..da0c473a
--- /dev/null
+++ b/reme/core/utils/truncate_text_utils.py
@@ -0,0 +1,55 @@
+"""Utility functions for truncating long text strings."""
+
+from .std_logger import get_logger
+
+logger = get_logger()
+
+TRUNCATION_MARKER_START = "<<>>"
+TRUNCATION_MARKER_END = "<<>>"
+
+
+def truncate_text(text: str, max_length: int) -> str:
+ """Truncate text to max length, keeping head and tail portions.
+
+ Args:
+ text: The text to truncate
+ max_length: Maximum allowed length
+
+ Returns:
+ Truncated text with unique markers indicating truncation
+ """
+ text = str(text) if text else ""
+ if not text:
+ return text
+
+ if len(text) <= max_length:
+ return text
+
+ half_length = max_length // 2
+ truncated_chars = len(text) - max_length
+ logger.debug(
+ "Text truncated: original %d chars, kept head %d + tail %d, removed %d chars.",
+ len(text),
+ half_length,
+ half_length,
+ truncated_chars,
+ )
+ return (
+ f"{text[:half_length]}\n\n{TRUNCATION_MARKER_START} "
+ f"({truncated_chars} characters omitted) "
+ f"{TRUNCATION_MARKER_END}\n\n{text[-half_length:]}"
+ )
+
+
+def is_truncated(text: str) -> bool:
+ """Check if the text has been truncated (contains truncation markers).
+
+ Args:
+ text: The text to check
+
+ Returns:
+ bool: True if text contains truncation markers, False otherwise
+ """
+ if not text:
+ return False
+ return TRUNCATION_MARKER_START in text and TRUNCATION_MARKER_END in text
diff --git a/reme/extension/procedural_memory/summary/comparative_extraction.py b/reme/extension/procedural_memory/summary/comparative_extraction.py
index 9ca78784..4e728de3 100644
--- a/reme/extension/procedural_memory/summary/comparative_extraction.py
+++ b/reme/extension/procedural_memory/summary/comparative_extraction.py
@@ -49,8 +49,8 @@ class ComparativeExtraction(BaseOp):
comparative_task_memories.extend(soft_task_memories)
# Hard comparison: success vs failure (if similarity search is enabled)
- if self.context.get("enable_similarity_comparison", True) and success_trajectories and failure_trajectories:
- similar_pairs = self._find_similar_step_sequences(success_trajectories, failure_trajectories)
+ if self.context.get("enable_similarity_comparison", False) and success_trajectories and failure_trajectories:
+ similar_pairs = await self._find_similar_step_sequences(success_trajectories, failure_trajectories)
logger.info(f"Found {len(similar_pairs)} similar pairs for hard comparison")
for success_steps, failure_steps, similarity_score in similar_pairs:
@@ -182,7 +182,7 @@ class ComparativeExtraction(BaseOp):
else:
return trajectory.messages
- def _find_similar_step_sequences(
+ async def _find_similar_step_sequences(
self,
success_trajectories: List[Trajectory],
failure_trajectories: List[Trajectory],
@@ -227,8 +227,8 @@ class ComparativeExtraction(BaseOp):
"embedding_model",
)
):
- success_embeddings = self.vector_store.embedding_model.get_embeddings(success_texts)
- failure_embeddings = self.vector_store.embedding_model.get_embeddings(failure_texts)
+ success_embeddings = await self.vector_store.get_embeddings(success_texts)
+ failure_embeddings = await self.vector_store.get_embeddings(failure_texts)
# Calculate similarity and find most similar pairs
similarity_threshold = self.context.get("similarity_threshold", 0.5)
diff --git a/reme/memory/__init__.py b/reme/memory/__init__.py
index ac825bc6..e209a3fe 100644
--- a/reme/memory/__init__.py
+++ b/reme/memory/__init__.py
@@ -1,11 +1,11 @@
"""memory"""
from . import file_based
-from . import tools
+from . import vector_tools
from . import vector_based
__all__ = [
"file_based",
- "tools",
+ "vector_tools",
"vector_based",
]
diff --git a/reme/memory/file_based/__init__.py b/reme/memory/file_based/__init__.py
index 2f994785..e4c392e5 100644
--- a/reme/memory/file_based/__init__.py
+++ b/reme/memory/file_based/__init__.py
@@ -1,18 +1,13 @@
-"""File-based memory operations."""
+"""File-based Memory Module."""
-from .fb_cli import FbCli
-from .fb_compactor import FbCompactor
-from .fb_context_checker import FbContextChecker
-from .fb_summarizer import FbSummarizer
-from ...core.registry_factory import R
+from . import components
+from . import tools
+from . import utils
+from .reme_in_memory_memory import ReMeInMemoryMemory
__all__ = [
- "FbCli",
- "FbCompactor",
- "FbContextChecker",
- "FbSummarizer",
+ "tools",
+ "utils",
+ "components",
+ "ReMeInMemoryMemory",
]
-
-for name in __all__:
- op_class = globals()[name]
- R.ops.register(op_class)
diff --git a/reme/memory/file_based/components/__init__.py b/reme/memory/file_based/components/__init__.py
new file mode 100644
index 00000000..42ab2f6b
--- /dev/null
+++ b/reme/memory/file_based/components/__init__.py
@@ -0,0 +1,13 @@
+"""components"""
+
+from .compactor import Compactor
+from .context_checker import ContextChecker
+from .summarizer import Summarizer
+from .tool_result_compactor import ToolResultCompactor
+
+__all__ = [
+ "Compactor",
+ "Summarizer",
+ "ContextChecker",
+ "ToolResultCompactor",
+]
diff --git a/reme/memory/file_based/components/compactor.py b/reme/memory/file_based/components/compactor.py
new file mode 100644
index 00000000..e7b37b30
--- /dev/null
+++ b/reme/memory/file_based/components/compactor.py
@@ -0,0 +1,79 @@
+"""Compactor module for memory compaction operations."""
+
+from agentscope.agent import ReActAgent
+from agentscope.message import Msg
+from agentscope.token import HuggingFaceTokenCounter
+
+from ..utils import AsMsgHandler
+from ....core.op import BaseOp
+from ....core.utils import get_std_logger
+
+logger = get_std_logger()
+
+
+class Compactor(BaseOp):
+ """Compactor class for compacting memory messages."""
+
+ def __init__(
+ self,
+ memory_compact_threshold: int,
+ token_counter: HuggingFaceTokenCounter,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.memory_compact_threshold: int = memory_compact_threshold
+
+ self.msg_handler = AsMsgHandler(token_counter=token_counter)
+
+ async def execute(self):
+ messages: list[Msg] = self.context.get("messages", [])
+ previous_summary: str = self.context.get("previous_summary", "")
+
+ if not messages:
+ return ""
+
+ before_token_count = self.msg_handler.count_msgs_token(messages)
+ history_formatted_str: str = self.msg_handler.format_msgs_to_str(
+ messages=messages,
+ memory_compact_threshold=self.memory_compact_threshold,
+ )
+ after_token_count = self.msg_handler.count_str_token(history_formatted_str)
+ logger.info(f"Compactor before_token_count={before_token_count} after_token_count={after_token_count}")
+
+ if not history_formatted_str:
+ logger.warning(f"No history to compact. messages={messages}")
+ return ""
+
+ agent = ReActAgent(
+ name="reme_compactor",
+ model=self.as_llm,
+ sys_prompt=self.get_prompt("system_prompt"),
+ formatter=self.as_llm_formatter,
+ )
+
+ if previous_summary:
+ prefix: str = self.get_prompt("update_user_message_prefix")
+ suffix: str = self.get_prompt("update_user_message_suffix")
+ user_message: str = (
+ f"\n{history_formatted_str}\n\n\n"
+ f"{prefix}\n\n"
+ f"\n{previous_summary}\n\n\n"
+ f"{suffix}"
+ )
+ else:
+ user_message: str = f"\n{history_formatted_str}\n\n\n" + self.get_prompt(
+ "initial_user_message",
+ )
+ logger.info(f"Compactor sys_prompt={agent.sys_prompt} user_message={user_message}")
+
+ compact_msg: Msg = await agent.reply(
+ Msg(
+ name="reme",
+ role="user",
+ content=user_message,
+ ),
+ )
+
+ history_compact: str = compact_msg.get_text_content()
+ logger.info(f"Compactor Result:\n{history_compact}")
+ return history_compact
diff --git a/reme/memory/file_based/components/compactor.yaml b/reme/memory/file_based/components/compactor.yaml
new file mode 100644
index 00000000..83c4f952
--- /dev/null
+++ b/reme/memory/file_based/components/compactor.yaml
@@ -0,0 +1,160 @@
+system_prompt: |
+ You are a context compaction assistant. Your role is to create structured summaries of conversations
+ that can be used to restore context in future sessions. Focus on preserving critical information while reducing token count.
+
+system_prompt_zh: |
+ 你是一个上下文压缩助手。你的角色是创建对话的结构化摘要,
+ 这些摘要可以在未来会话中用于恢复上下文。专注于保留关键信息,同时减少token数量。
+
+initial_user_message: |
+ The messages above are a conversation to summarize. Create a structured context checkpoint summary
+ that another LLM will use to continue the work.
+
+ Use this EXACT format:
+
+ ## Goal
+ [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
+
+ ## Constraints & Preferences
+ - [Any constraints, preferences, or requirements mentioned by user]
+ - [Or "(none)" if none were mentioned]
+
+ ## Progress
+ ### Done
+ - [x] [Completed tasks/changes]
+
+ ### In Progress
+ - [ ] [Current work]
+
+ ### Blocked
+ - [Issues preventing progress, if any]
+
+ ## Key Decisions
+ - **[Decision]**: [Brief rationale]
+
+ ## Next Steps
+ 1. [Ordered list of what should happen next]
+
+ ## Critical Context
+ - [Any data, examples, or references needed to continue]
+ - [Or "(none)" if not applicable]
+
+ Keep each section concise. Preserve exact file paths, function names, and error messages.
+
+initial_user_message_zh: |
+ 上述消息是一场需要总结的对话。创建一个结构化的上下文检查点摘要,
+ 以便另一个LLM可以用来继续工作。
+
+ 使用此确切格式:
+
+ ## 目标
+ [用户试图完成什么?如果会话涵盖不同任务,可以有多个项目。]
+
+ ## 约束和偏好
+ - [任何用户提到的约束、偏好或要求]
+ - [或者如果没有提到则为"(none)"]
+
+ ## 进展
+ ### 已完成
+ - [x] [已完成的任务/更改]
+
+ ### 进行中
+ - [ ] [当前工作]
+
+ ### 阻塞
+ - [如果有任何阻碍进展的问题]
+
+ ## 关键决策
+ - **[决策]**: [简短理由]
+
+ ## 下一步
+ 1. [接下来应该发生的事情的有序列表]
+
+ ## 关键上下文
+ - [任何继续工作所需的数据、示例或参考资料]
+ - [或者如果不适用则为"(none)"]
+
+ 保持每个部分简洁。保留确切的文件路径、函数名称和错误消息。
+
+update_user_message_prefix: |
+ The messages above are NEW conversation messages to incorporate into the existing summary provided in
+ tags.
+
+update_user_message_suffix: |
+ Update the existing structured summary with new information. RULES:
+ - PRESERVE all existing information from the previous summary
+ - ADD new progress, decisions, and context from the new messages
+ - UPDATE the Progress section: move items from "In Progress" to "Done" when completed
+ - UPDATE "Next Steps" based on what was accomplished
+ - PRESERVE exact file paths, function names, and error messages
+ - If something is no longer relevant, you may remove it
+
+ Use this EXACT format:
+
+ ## Goal
+ [Preserve existing goals, add new ones if the task expanded]
+
+ ## Constraints & Preferences
+ - [Preserve existing, add new ones discovered]
+
+ ## Progress
+ ### Done
+ - [x] [Include previously done items AND newly completed items]
+
+ ### In Progress
+ - [ ] [Current work - update based on progress]
+
+ ### Blocked
+ - [Current blockers - remove if resolved]
+
+ ## Key Decisions
+ - **[Decision]**: [Brief rationale] (preserve all previous, add new)
+
+ ## Next Steps
+ 1. [Update based on current state]
+
+ ## Critical Context
+ - [Preserve important context, add new if needed]
+
+ Keep each section concise. Preserve exact file paths, function names, and error messages.
+
+update_user_message_prefix_zh: |
+ 以上消息是需要整合到现有摘要中的新对话内容,现有摘要位于标签中。
+
+update_user_message_suffix_zh: |
+ 用新信息更新现有的结构化摘要。规则:
+ - 保留来自先前摘要的所有现有信息
+ - 从新消息中添加新的进展、决策和上下文
+ - 更新进度部分:当完成时将项目从"进行中"移到"已完成"
+ - 根据已完成的内容更新"下一步"
+ - 保留确切的文件路径、函数名称和错误消息
+ - 如果某些内容不再相关,您可以删除它
+
+ 使用此确切格式:
+
+ ## 目标
+ [保留现有目标,如果任务扩展则添加新目标]
+
+ ## 约束和偏好
+ - [保留现有内容,添加发现的新内容]
+
+ ## 进展
+ ### 已完成
+ - [x] [包含以前完成的项目和新完成的项目]
+
+ ### 进行中
+ - [ ] [当前工作 - 根据进展更新]
+
+ ### 阻塞
+ - [当前阻塞问题 - 如果解决则删除]
+
+ ## 关键决策
+ - **[决策]**: [简短理由](保留所有之前的内容,添加新的)
+
+ ## 下一步
+ 1. [根据当前状态更新]
+
+ ## 关键上下文
+ - [保留重要上下文,如需要则添加新的]
+
+ 保持每个部分简洁。保留确切的文件路径、函数名称和错误消息。
diff --git a/reme/memory/file_based/components/context_checker.py b/reme/memory/file_based/components/context_checker.py
new file mode 100644
index 00000000..18ac4bf0
--- /dev/null
+++ b/reme/memory/file_based/components/context_checker.py
@@ -0,0 +1,98 @@
+"""ContextChecker module for checking context size and splitting messages."""
+
+from agentscope.message import Msg
+from agentscope.token import HuggingFaceTokenCounter
+
+from ..utils import AsMsgHandler
+from ....core.op import BaseOp
+from ....core.utils import get_std_logger
+
+logger = get_std_logger()
+
+
+class ContextChecker(BaseOp):
+ """
+ ContextChecker class for checking context size and splitting messages.
+
+ This class analyzes conversation messages to determine if the context
+ exceeds the specified token threshold and splits messages into two groups:
+ those that should be compacted and those to keep in context.
+
+ Attributes:
+ memory_compact_threshold (int): Token count threshold for triggering compaction.
+ memory_compact_reserve (int): Token count to reserve for recent messages.
+ msg_handler (AsMsgHandler): Handler for message processing and token counting.
+ """
+
+ def __init__(
+ self,
+ memory_compact_threshold: int,
+ memory_compact_reserve: int = 10000,
+ token_counter: HuggingFaceTokenCounter | None = None,
+ **kwargs,
+ ):
+ """
+ Initialize the ContextChecker.
+
+ Args:
+ memory_compact_threshold (int): Token count threshold for triggering
+ compaction. Messages exceeding this threshold will be split.
+ memory_compact_reserve (int): Token count to reserve for recent messages
+ to keep in context. Defaults to 10000 tokens.
+ token_counter (HuggingFaceTokenCounter | None): Token counter for
+ measuring content length. If None, a default counter will be used.
+ **kwargs: Additional keyword arguments passed to BaseOp.
+ """
+ super().__init__(**kwargs)
+ self.memory_compact_threshold: int = memory_compact_threshold
+ self.memory_compact_reserve: int = memory_compact_reserve
+ assert self.memory_compact_threshold > self.memory_compact_reserve
+
+ self.msg_handler = AsMsgHandler(token_counter=token_counter)
+
+ async def execute(self) -> tuple[list[Msg], list[Msg], bool]:
+ """
+ Execute context check and split messages.
+
+ Retrieves messages from context and checks if they exceed the token
+ threshold. If so, splits them into messages to compact and messages
+ to keep.
+
+ Context Parameters:
+ messages (list[Msg]): List of conversation messages to check.
+ Retrieved from self.context.get("messages", []).
+
+ Returns:
+ tuple[list[Msg], list[Msg], bool]: A tuple containing:
+ - messages_to_compact (list[Msg]): Older messages that should
+ be compacted/summarized.
+ - messages_to_keep (list[Msg]): Recent messages to keep in context.
+ - is_valid (bool): True if the split is valid (tool calls aligned),
+ False if splitting would break conversation integrity.
+
+ Note:
+ - Returns ([], messages, True) if no compaction is needed.
+ - Ensures conversation pairs (user-assistant) are not split.
+ - is_valid=False indicates tool_use and tool_result are misaligned.
+ """
+ messages: list[Msg] = self.context.get("messages", [])
+
+ if not messages:
+ logger.info("ContextChecker: No messages to check.")
+ return [], [], True
+
+ messages_to_compact, messages_to_keep, is_valid = self.msg_handler.context_check(
+ messages=messages,
+ memory_compact_threshold=self.memory_compact_threshold,
+ memory_compact_reserve=self.memory_compact_reserve,
+ )
+
+ if messages_to_compact:
+ logger.info(
+ f"ContextChecker Result: "
+ f"to_compact={len(messages_to_compact)}, "
+ f"to_keep={len(messages_to_keep)}, "
+ f"is_valid={is_valid}",
+ )
+
+ return messages_to_compact, messages_to_keep, is_valid
diff --git a/reme/memory/file_based/components/summarizer.py b/reme/memory/file_based/components/summarizer.py
new file mode 100644
index 00000000..d4e057be
--- /dev/null
+++ b/reme/memory/file_based/components/summarizer.py
@@ -0,0 +1,80 @@
+"""Summarizer module for memory summarization operations."""
+
+import datetime
+
+from agentscope.agent import ReActAgent
+from agentscope.message import Msg
+from agentscope.token import HuggingFaceTokenCounter
+from agentscope.tool import Toolkit
+
+from ..utils import AsMsgHandler
+from ....core.op import BaseOp
+from ....core.utils import get_std_logger
+
+logger = get_std_logger()
+
+
+class Summarizer(BaseOp):
+ """Summarizer class for summarizing memory messages."""
+
+ def __init__(
+ self,
+ working_dir: str,
+ memory_dir: str,
+ memory_compact_threshold: int,
+ token_counter: HuggingFaceTokenCounter,
+ toolkit: Toolkit,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.working_dir: str = working_dir
+ self.memory_dir: str = memory_dir
+ self.memory_compact_threshold: int = memory_compact_threshold
+
+ self.msg_handler = AsMsgHandler(token_counter=token_counter)
+ self.toolkit: Toolkit = toolkit
+
+ async def execute(self):
+ messages: list[Msg] = self.context.get("messages", [])
+
+ if not messages:
+ return ""
+
+ before_token_count = self.msg_handler.count_msgs_token(messages)
+ history_formatted_str: str = self.msg_handler.format_msgs_to_str(
+ messages=messages,
+ memory_compact_threshold=self.memory_compact_threshold,
+ )
+ after_token_count = self.msg_handler.count_str_token(history_formatted_str)
+ logger.info(f"Summarizer before_token_count={before_token_count} after_token_count={after_token_count}")
+
+ if not history_formatted_str:
+ logger.warning(f"No history to summarize. messages={messages}")
+ return ""
+
+ agent = ReActAgent(
+ name="reme_summarizer",
+ model=self.as_llm,
+ sys_prompt="You are a helpful assistant.",
+ formatter=self.as_llm_formatter,
+ toolkit=self.toolkit,
+ )
+
+ user_message: str = f"\n{history_formatted_str}\n\n" + self.prompt_format(
+ "user_message",
+ date=datetime.datetime.now().strftime("%Y-%m-%d"),
+ working_dir=self.working_dir,
+ memory_dir=self.memory_dir,
+ )
+
+ summary_msg: Msg = await agent.reply(
+ Msg(
+ name="reme",
+ role="user",
+ content=user_message,
+ ),
+ )
+
+ history_summary: str = summary_msg.get_text_content()
+ logger.info(f"Summarizer Result:\n{history_summary}")
+ return history_summary
diff --git a/reme/memory/file_based/components/summarizer.yaml b/reme/memory/file_based/components/summarizer.yaml
new file mode 100644
index 00000000..9aa0892b
--- /dev/null
+++ b/reme/memory/file_based/components/summarizer.yaml
@@ -0,0 +1,50 @@
+user_message: |
+ Memory Pre-compression Flush Cycle Initiated
+ The current session is about to enter the automatic compression phase. Please capture persistent memory and write it to disk.
+
+ Current date: {date}
+ Working directory: {working_dir}
+
+ Immediately store persistent memory to: {memory_dir}/YYYY-MM-DD.md
+
+ Workflow:
+ 1. First, `read` {memory_dir}/YYYY-MM-DD.md (if the file doesn’t exist, an error message will be returned).
+ 2. Intelligently merge new information with existing content (skip merging if the file doesn’t exist):
+ - Avoid duplicating already recorded information
+ - Enrich existing entries with new details where relevant
+ - Maintain chronological order wherever applicable
+ 3. Write the updated content:
+ - Prefer using `edit` to update specific sections when possible
+ - Use `write` to overwrite the entire file only if substantial restructuring is required
+
+ Principles:
+ - Always preserve timestamps and any date/time-related context
+ - Add only genuinely new or meaningfully enriching information
+ - Keep entries concise yet complete
+ - If there’s nothing to store, respond with [SILENT]
+
+
+user_message_zh: |
+ 预压缩内存刷新轮次。
+ 当前会话即将进入自动压缩阶段;请将持久化记忆捕获并写入磁盘。
+
+ 当前日期:{date}
+ 工作目录:{working_dir}
+
+ 立即存储持久化记忆(使用路径 {memory_dir}/YYYY-MM-DD.md)。
+
+ 工作流程:
+ 1. 先 `read` {memory_dir}/YYYY-MM-DD.md(如文件不存在,会返回错误提示)
+ 2. 智能合并新信息与现有内容(若文件不存在则跳过合并):
+ - 避免重复已记录的信息
+ - 在相关时丰富现有条目的新细节
+ - 在适用时保持时间顺序
+ 3. 写入更新后的内容:
+ - 尽可能使用 `edit` 更新特定部分
+ - 如需大幅重构则使用 `write` 覆盖整个文件
+
+ 原则:
+ - 始终保留时间戳、日期和时间相关上下文
+ - 仅添加真正新的或有丰富价值的信息
+ - 保持条目简洁但完整
+ - 若无内容可存储,请回复 [SILENT]
diff --git a/reme/memory/file_based/components/tool_result_compactor.py b/reme/memory/file_based/components/tool_result_compactor.py
new file mode 100644
index 00000000..412df6de
--- /dev/null
+++ b/reme/memory/file_based/components/tool_result_compactor.py
@@ -0,0 +1,105 @@
+"""Tool Result Compactor: truncate large tool results and save full content to files."""
+
+import uuid
+from datetime import datetime, timedelta
+from pathlib import Path
+
+from agentscope.message import Msg
+
+from ....core.op import BaseOp
+from ....core.utils import get_std_logger
+from ....core.utils import truncate_text, is_truncated
+
+logger = get_std_logger()
+
+
+class ToolResultCompactor(BaseOp):
+ """Truncate large tool_result outputs and save full content to files."""
+
+ def __init__(
+ self,
+ tool_result_dir: str | Path,
+ tool_result_threshold: int,
+ retention_days: int = 7,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.tool_result_dir = Path(tool_result_dir)
+ self.tool_result_threshold = tool_result_threshold
+ self.retention_days = retention_days
+
+ def _save_and_truncate(self, content: str, tool_name: str) -> str:
+ """Save full content to file and return truncated version with file reference."""
+ if not content or is_truncated(content) or len(content) <= self.tool_result_threshold:
+ return content
+
+ # Save full content
+ self.tool_result_dir.mkdir(parents=True, exist_ok=True)
+ file_path = self.tool_result_dir / f"{uuid.uuid4().hex}.txt"
+ created_at = datetime.now().isoformat()
+
+ file_path.write_text(
+ f"# tool_name: {tool_name}\n# created_at: {created_at}\n# ---\n{content}",
+ encoding="utf-8",
+ )
+ logger.debug("Saved tool result to %s (len=%d)", file_path, len(content))
+
+ # Return truncated with file reference
+ return f"{truncate_text(content, self.tool_result_threshold)}\n\n[Full content saved to: {file_path}]"
+
+ def _process_output(self, output: str | list[dict], tool_name: str) -> str | list[dict]:
+ """Process tool result output, truncating if necessary."""
+ if isinstance(output, str):
+ return self._save_and_truncate(output, tool_name)
+
+ if isinstance(output, list):
+ return [
+ (
+ {**b, "text": self._save_and_truncate(b.get("text", ""), tool_name)}
+ if isinstance(b, dict) and b.get("type") == "text"
+ else b
+ )
+ for b in output
+ ]
+ return output
+
+ async def execute(self) -> list[Msg]:
+ """Process all messages, truncating large tool results."""
+ messages: list[Msg] = self.context.get("messages", [])
+ if not messages:
+ return messages
+
+ for msg in messages:
+ if not isinstance(msg.content, list):
+ continue
+
+ for block in msg.content:
+ if isinstance(block, dict) and block.get("type") == "tool_result":
+ output = block.get("output")
+ if output:
+ block["output"] = self._process_output(output, block.get("name", "unknown"))
+
+ return messages
+
+ def cleanup_expired_files(self) -> int:
+ """Clean up files older than retention_days."""
+ if not self.tool_result_dir.exists():
+ return 0
+
+ cutoff = datetime.now() - timedelta(days=self.retention_days)
+ deleted = 0
+
+ for fp in self.tool_result_dir.glob("*.txt"):
+ try:
+ for line in fp.read_text(encoding="utf-8").splitlines()[:3]:
+ if line.startswith("# created_at:"):
+ if datetime.fromisoformat(line.split(":", 1)[1].strip()) < cutoff:
+ fp.unlink()
+ deleted += 1
+ break
+ except Exception as e:
+ logger.warning("Failed to process %s: %s", fp, e)
+
+ if deleted:
+ logger.info("Cleaned up %d expired files", deleted)
+ return deleted
diff --git a/reme/memory/file_based/reme_in_memory_memory.py b/reme/memory/file_based/reme_in_memory_memory.py
new file mode 100644
index 00000000..da2118e8
--- /dev/null
+++ b/reme/memory/file_based/reme_in_memory_memory.py
@@ -0,0 +1,197 @@
+"""Custom memory implementation with bugfixes and extensions."""
+
+from agentscope.agent._react_agent import _MemoryMark # noqa
+from agentscope.memory import InMemoryMemory
+from agentscope.message import Msg
+from agentscope.token import HuggingFaceTokenCounter
+
+from .utils import AsMsgHandler
+from ...core.utils import get_std_logger
+
+logger = get_std_logger()
+
+
+class ReMeInMemoryMemory(InMemoryMemory):
+ """Extended InMemoryMemory with bugfixes and summary support."""
+
+ def __init__(self, token_counter: HuggingFaceTokenCounter):
+ super().__init__()
+ self._token_counter: HuggingFaceTokenCounter = token_counter
+ self._msg_handler: AsMsgHandler = AsMsgHandler(token_counter)
+
+ async def get_memory(
+ self,
+ mark: str | None = None,
+ exclude_mark: str | None = _MemoryMark.COMPRESSED,
+ prepend_summary: bool = True,
+ **_kwargs,
+ ) -> list[Msg]:
+ """Get the messages from the memory by mark (if provided).
+
+ Args:
+ mark: Optional mark to filter messages
+ exclude_mark: Optional mark to exclude messages
+ prepend_summary: Whether to prepend compressed summary
+ **_kwargs: Additional keyword arguments (ignored)
+
+ Returns:
+ List of filtered messages
+ """
+ if not (mark is None or isinstance(mark, str)):
+ raise TypeError(f"The mark should be a string or None, but got {type(mark)}.")
+
+ if not (exclude_mark is None or isinstance(exclude_mark, str)):
+ raise TypeError(f"The exclude_mark should be a string or None, but got {type(exclude_mark)}.")
+
+ # Filter messages based on mark
+ filtered_content = [(msg, marks) for msg, marks in self.content if mark is None or mark in marks]
+
+ # Further filter messages based on exclude_mark
+ if exclude_mark is not None:
+ filtered_content = [(msg, marks) for msg, marks in filtered_content if exclude_mark not in marks]
+
+ if prepend_summary and self._compressed_summary:
+ previous_summary = f"""
+
+{self._compressed_summary}
+
+The above is a summary of our previous conversation.
+Use it as context to maintain continuity.
+ """.strip()
+
+ return [
+ Msg(
+ "user",
+ previous_summary,
+ "user",
+ ),
+ *[msg for msg, _ in filtered_content],
+ ]
+
+ return [msg for msg, _ in filtered_content]
+
+ def get_compressed_summary(self) -> str:
+ """Get the compressed summary of the memory."""
+ return self._compressed_summary
+
+ def state_dict(self) -> dict:
+ """Get the state dictionary for serialization."""
+ return {
+ "content": [[msg.to_dict(), marks] for msg, marks in self.content],
+ "_compressed_summary": self._compressed_summary,
+ }
+
+ # pylint: disable=attribute-defined-outside-init
+ def load_state_dict(self, state_dict: dict, strict: bool = True) -> None:
+ """Load the state dictionary for deserialization."""
+ if strict and "content" not in state_dict:
+ raise KeyError("The state_dict does not contain 'content' key required for InMemoryMemory.")
+
+ self.content = [] # pylint: disable=attribute-defined-outside-init
+ for item in state_dict.get("content", []):
+ if isinstance(item, (tuple, list)) and len(item) == 2:
+ msg_dict, marks = item
+ msg = Msg.from_dict(msg_dict)
+ self.content.append((msg, marks))
+
+ elif isinstance(item, dict):
+ # For compatibility with older versions
+ msg = Msg.from_dict(item)
+ self.content.append((msg, []))
+
+ else:
+ raise ValueError("Invalid item format in state_dict for InMemoryMemory.")
+
+ self._compressed_summary = state_dict.get("_compressed_summary", "")
+
+ async def mark_messages_compressed(self, messages: list[Msg]) -> int:
+ """Mark messages as compressed and return count."""
+ return await self.update_messages_mark(
+ new_mark=_MemoryMark.COMPRESSED,
+ msg_ids=[msg.id for msg in messages],
+ )
+
+ def clear_compressed_summary(self):
+ """Clear the compressed summary."""
+ self._compressed_summary = "" # pylint: disable=attribute-defined-outside-init
+
+ def clear_content(self):
+ """Clear the content."""
+ self.content.clear()
+
+ async def estimate_tokens(self, max_input_length: int) -> dict:
+ """Estimate token usage for current memory.
+
+ Args:
+ max_input_length: Max input length for context usage calculation.
+
+ Returns:
+ Dict containing detailed token statistics:
+ - total_messages: Number of messages
+ - compressed_summary_tokens: Tokens in compressed summary
+ - messages_tokens: Tokens in messages
+ - estimated_tokens: Total estimated tokens
+ - max_input_length: Max input length from config
+ - context_usage_ratio: Usage percentage
+ - messages_detail: List of per-message AsMsgStat objects
+ """
+ messages = await self.get_memory(
+ exclude_mark=_MemoryMark.COMPRESSED,
+ prepend_summary=False,
+ )
+
+ compressed_summary = self.get_compressed_summary()
+ compressed_summary_tokens = self._msg_handler.count_str_token(compressed_summary)
+
+ # Build per-message token details using AsMsgHandler
+ messages_detail = [self._msg_handler.stat_message(msg) for msg in messages]
+
+ # Calculate total message tokens from stats
+ messages_tokens = sum(stat.total_tokens for stat in messages_detail)
+ estimated_tokens = messages_tokens + compressed_summary_tokens
+
+ # Calculate context usage ratio
+ context_usage_ratio = (estimated_tokens / max_input_length * 100) if max_input_length > 0 else 0
+
+ return {
+ "total_messages": len(messages),
+ "compressed_summary_tokens": compressed_summary_tokens,
+ "messages_tokens": messages_tokens,
+ "estimated_tokens": estimated_tokens,
+ "max_input_length": max_input_length,
+ "context_usage_ratio": context_usage_ratio,
+ "messages_detail": messages_detail,
+ }
+
+ async def get_history_str(self, max_input_length: int) -> str:
+ """Get formatted history string similar to /history command output.
+
+ Args:
+ max_input_length: Max input length for context usage calculation.
+
+ Returns:
+ Formatted string containing conversation history details
+ """
+ stats = await self.estimate_tokens(max_input_length)
+
+ lines = []
+ for i, msg_stat in enumerate(stats["messages_detail"], 1):
+ blocks_info = ""
+ if msg_stat.content:
+ block_strs = [f"{b.block_type}(tokens={b.token_count})" for b in msg_stat.content]
+ blocks_info = f"\n content: [{', '.join(block_strs)}]"
+
+ lines.append(
+ f"[{i}] **{msg_stat.role}** "
+ f"(total_tokens={msg_stat.total_tokens})"
+ f"{blocks_info}\n preview: {msg_stat.preview}",
+ )
+
+ return (
+ f"**Conversation History**\n\n"
+ f"- Total messages: {stats['total_messages']}\n"
+ f"- Estimated tokens: {stats['estimated_tokens']}\n"
+ f"- Max input length: {stats['max_input_length']}\n"
+ f"- Context usage: {stats['context_usage_ratio']:.1f}%\n"
+ f"- Compressed summary tokens: {stats['compressed_summary_tokens']}\n\n" + "\n\n".join(lines)
+ )
diff --git a/reme/memory/file_based/tools/__init__.py b/reme/memory/file_based/tools/__init__.py
new file mode 100644
index 00000000..0fb0d814
--- /dev/null
+++ b/reme/memory/file_based/tools/__init__.py
@@ -0,0 +1,13 @@
+"""File-based memory tool implementations."""
+
+from .file_io import FileIO
+from .memory_get import MemoryGet
+from .memory_search import MemorySearch
+from .shell import Shell
+
+__all__ = [
+ "FileIO",
+ "MemoryGet",
+ "MemorySearch",
+ "Shell",
+]
diff --git a/reme/memory/file_based/tools/file_io.py b/reme/memory/file_based/tools/file_io.py
new file mode 100644
index 00000000..2b792475
--- /dev/null
+++ b/reme/memory/file_based/tools/file_io.py
@@ -0,0 +1,256 @@
+"""File I/O operations with a configurable working directory."""
+
+import os
+from pathlib import Path
+from typing import Optional
+
+from agentscope.message import TextBlock
+from agentscope.tool import ToolResponse
+
+from ..utils import DEFAULT_MAX_BYTES, read_file_safe, truncate_output
+
+
+class FileIO:
+ """File I/O operations with a configurable working directory."""
+
+ def __init__(self, working_dir: str | Path):
+ """Initialize FileIO with a working directory.
+
+ Args:
+ working_dir (`str`):
+ The working directory for resolving relative paths.
+ """
+ self.working_dir = Path(working_dir)
+
+ def _resolve_file_path(self, file_path: str) -> str:
+ """Resolve file path: use absolute path as-is,
+ resolve relative path from working_dir.
+
+ Args:
+ file_path: The input file path (absolute or relative).
+
+ Returns:
+ The resolved absolute file path as string.
+ """
+ path = Path(file_path)
+ if path.is_absolute():
+ return str(path)
+ else:
+ return str(self.working_dir / file_path)
+
+ async def read( # pylint: disable=too-many-return-statements
+ self,
+ file_path: str,
+ start_line: Optional[int] = None,
+ end_line: Optional[int] = None,
+ ) -> ToolResponse:
+ """Read a file. Relative paths resolve from working_dir.
+
+ Use start_line/end_line to read a specific line range (output includes
+ line numbers). Omit both to read the full file.
+
+ Args:
+ file_path (`str`):
+ Path to the file.
+ start_line (`int`, optional):
+ First line to read (1-based, inclusive).
+ end_line (`int`, optional):
+ Last line to read (1-based, inclusive).
+ """
+ file_path = self._resolve_file_path(file_path)
+
+ if not os.path.exists(file_path):
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=f"Error: The file {file_path} does not exist.",
+ ),
+ ],
+ )
+
+ if not os.path.isfile(file_path):
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=f"Error: The path {file_path} is not a file.",
+ ),
+ ],
+ )
+
+ try:
+ content = read_file_safe(file_path)
+ all_lines = content.split("\n")
+ total = len(all_lines)
+
+ # Determine read range
+ s = max(1, start_line if start_line is not None else 1)
+ e = min(total, end_line if end_line is not None else total)
+
+ if s > total:
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=f"Error: start_line {s} exceeds file length ({total} lines).",
+ ),
+ ],
+ )
+
+ if s > e:
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=f"Error: start_line ({s}) > end_line ({e}).",
+ ),
+ ],
+ )
+
+ # Extract selected lines
+ selected_content = "\n".join(all_lines[s - 1 : e])
+
+ # Apply smart truncation (keep head for file reading)
+ truncated, was_truncated, output_lines, reason = truncate_output(selected_content, keep="head")
+
+ # Build response with truncation hints
+ if was_truncated:
+ end_display = s + output_lines - 1
+ next_line = end_display + 1
+ if reason == "lines":
+ hint = f"\n\n[Lines {s}-{end_display} of {total}. Use start_line={next_line} to continue.]"
+ else:
+ hint = (
+ f"\n\n[Lines {s}-{end_display} of {total} ({DEFAULT_MAX_BYTES // 1024}KB limit). "
+ f"Use start_line={next_line} to continue.]"
+ )
+ text = truncated + hint
+ elif e < total:
+ remaining = total - e
+ text = (
+ f"{file_path} (lines {s}-{e} of {total})\n{truncated}\n\n[{remaining} more lines. "
+ f"Use start_line={e + 1} to continue.]"
+ )
+ else:
+ text = truncated
+
+ return ToolResponse(
+ content=[TextBlock(type="text", text=text)],
+ )
+
+ except Exception as e:
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=f"Error: Read file failed due to \n{e}",
+ ),
+ ],
+ )
+
+ async def write(
+ self,
+ file_path: str,
+ content: str,
+ ) -> ToolResponse:
+ """Create or overwrite a file. Relative paths resolve from working_dir.
+
+ Args:
+ file_path (`str`):
+ Path to the file.
+ content (`str`):
+ Content to write.
+ """
+ if not file_path:
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text="Error: No `file_path` provide.",
+ ),
+ ],
+ )
+
+ file_path = self._resolve_file_path(file_path)
+
+ try:
+ with open(file_path, "w", encoding="utf-8") as file:
+ file.write(content)
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=f"Wrote {len(content)} bytes to {file_path}.",
+ ),
+ ],
+ )
+ except Exception as e:
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=f"Error: Write file failed due to \n{e}",
+ ),
+ ],
+ )
+
+ async def edit(
+ self,
+ file_path: str,
+ old_text: str,
+ new_text: str,
+ ) -> ToolResponse:
+ """Find-and-replace text in a file. All occurrences of old_text are
+ replaced with new_text. Relative paths resolve from working_dir.
+
+ Args:
+ file_path (`str`):
+ Path to the file.
+ old_text (`str`):
+ Exact text to find.
+ new_text (`str`):
+ Replacement text.
+ """
+ response = await self.read(file_path=file_path)
+ if response.content and len(response.content) > 0:
+ error_text = response.content[0].get("text", "")
+ if error_text.startswith("Error:"):
+ return response
+ if not response.content or len(response.content) == 0:
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=f"Error: Failed to read file {file_path}.",
+ ),
+ ],
+ )
+
+ content = response.content[0].get("text", "")
+ if old_text not in content:
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=f"Error: The text to replace was not found in {file_path}.",
+ ),
+ ],
+ )
+
+ new_content = content.replace(old_text, new_text)
+ write_response = await self.write(file_path=file_path, content=new_content)
+
+ if write_response.content and len(write_response.content) > 0:
+ write_text = write_response.content[0].get("text", "")
+ if write_text.startswith("Error:"):
+ return write_response
+
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=f"Successfully replaced text in {file_path}.",
+ ),
+ ],
+ )
diff --git a/reme/memory/tools/chunk/memory_get.py b/reme/memory/file_based/tools/memory_get.py
similarity index 100%
rename from reme/memory/tools/chunk/memory_get.py
rename to reme/memory/file_based/tools/memory_get.py
diff --git a/reme/memory/tools/chunk/memory_search.py b/reme/memory/file_based/tools/memory_search.py
similarity index 100%
rename from reme/memory/tools/chunk/memory_search.py
rename to reme/memory/file_based/tools/memory_search.py
diff --git a/reme/memory/file_based/tools/shell.py b/reme/memory/file_based/tools/shell.py
new file mode 100644
index 00000000..2bee1bd6
--- /dev/null
+++ b/reme/memory/file_based/tools/shell.py
@@ -0,0 +1,229 @@
+# -*- coding: utf-8 -*-
+# flake8: noqa: E501
+# pylint: disable=line-too-long
+"""The shell command tool."""
+
+import asyncio
+import locale
+import subprocess
+import sys
+from pathlib import Path
+
+from agentscope.message import TextBlock
+from agentscope.tool import ToolResponse
+
+from ..utils import truncate_shell_output
+
+
+def _execute_subprocess_sync(
+ cmd: str,
+ cwd: str,
+ timeout: int,
+) -> tuple[int, str, str]:
+ """Execute subprocess synchronously in a thread.
+
+ This function runs in a separate thread to avoid Windows asyncio
+ subprocess limitations.
+
+ Args:
+ cmd (`str`):
+ The shell command to execute.
+ cwd (`str`):
+ The working directory for the command execution.
+ timeout (`int`):
+ The maximum time (in seconds) allowed for the command to run.
+
+ Returns:
+ `tuple[int, str, str]`:
+ A tuple containing the return code, standard output, and
+ standard error of the executed command. If timeout occurs, the
+ return code will be -1 and stderr will contain timeout information.
+ """
+ try:
+ result = subprocess.run(
+ cmd,
+ shell=True,
+ capture_output=True,
+ text=True,
+ cwd=cwd,
+ timeout=timeout,
+ encoding=locale.getpreferredencoding(False) or "utf-8",
+ errors="replace",
+ check=True,
+ )
+ return (
+ result.returncode,
+ result.stdout.strip("\n"),
+ result.stderr.strip("\n"),
+ )
+ except subprocess.TimeoutExpired:
+ return (
+ -1,
+ "",
+ f"Command execution exceeded the timeout of {timeout} seconds.",
+ )
+ except Exception as e:
+ return -1, "", str(e)
+
+
+class Shell:
+ """Shell command execution with a configurable working directory."""
+
+ def __init__(self, working_dir: str | Path):
+ """Initialize Shell with a working directory.
+
+ Args:
+ working_dir (`str | Path`):
+ The working directory for command execution.
+ """
+ self.working_dir = Path(working_dir)
+
+ # pylint: disable=too-many-branches, too-many-statements
+ async def execute_shell_command(
+ self,
+ command: str,
+ timeout: int = 60,
+ ) -> ToolResponse:
+ """Execute given command and return the return code, standard output and
+ error within , and
+ tags.
+
+ Args:
+ command (`str`):
+ The shell command to execute.
+ timeout (`int`, defaults to `60`):
+ The maximum time (in seconds) allowed for the command to run.
+ Default is 60 seconds.
+
+ Returns:
+ `ToolResponse`:
+ The tool response containing the return code, standard output, and
+ standard error of the executed command. If timeout occurs, the
+ return code will be -1 and stderr will contain timeout information.
+ """
+
+ cmd = (command or "").strip()
+
+ # Set working directory
+ working_dir = self.working_dir
+
+ try:
+ if sys.platform == "win32":
+ # Windows: use thread pool to avoid asyncio subprocess limitations
+ returncode, stdout_str, stderr_str = await asyncio.to_thread(
+ _execute_subprocess_sync,
+ cmd,
+ str(working_dir),
+ timeout,
+ )
+ else:
+ proc = await asyncio.create_subprocess_shell(
+ cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ bufsize=0,
+ cwd=str(working_dir),
+ )
+
+ try:
+ # Apply timeout to communicate directly; wait()+communicate()
+ # can hang if descendants keep stdout/stderr pipes open.
+ stdout, stderr = await asyncio.wait_for(
+ proc.communicate(),
+ timeout=timeout,
+ )
+ encoding = locale.getpreferredencoding(False) or "utf-8"
+ stdout_str = stdout.decode(encoding, errors="replace").strip(
+ "\n",
+ )
+ stderr_str = stderr.decode(encoding, errors="replace").strip(
+ "\n",
+ )
+ returncode = proc.returncode
+
+ except asyncio.TimeoutError:
+ # Handle timeout
+ stderr_suffix = (
+ f"⚠️ TimeoutError: The command execution exceeded "
+ f"the timeout of {timeout} seconds. "
+ f"Please consider increasing the timeout value if this command "
+ f"requires more time to complete."
+ )
+ returncode = -1
+ try:
+ proc.terminate()
+ # Wait a bit for graceful termination
+ try:
+ await asyncio.wait_for(proc.wait(), timeout=1)
+ except asyncio.TimeoutError:
+ # Force kill if graceful termination fails
+ proc.kill()
+ await proc.wait()
+
+ # Avoid hanging forever while draining pipes after timeout.
+ try:
+ stdout, stderr = await asyncio.wait_for(
+ proc.communicate(),
+ timeout=1,
+ )
+ except asyncio.TimeoutError:
+ stdout, stderr = b"", b""
+ encoding = locale.getpreferredencoding(False) or "utf-8"
+ stdout_str = stdout.decode(
+ encoding,
+ errors="replace",
+ ).strip(
+ "\n",
+ )
+ stderr_str = stderr.decode(
+ encoding,
+ errors="replace",
+ ).strip(
+ "\n",
+ )
+ if stderr_str:
+ stderr_str += f"\n{stderr_suffix}"
+ else:
+ stderr_str = stderr_suffix
+ except ProcessLookupError:
+ stdout_str = ""
+ stderr_str = stderr_suffix
+
+ # Apply output truncation
+ stdout_str = truncate_shell_output(stdout_str)
+ stderr_str = truncate_shell_output(stderr_str)
+
+ # Format the response in a human-friendly way
+ if returncode == 0:
+ # Success case: just show the output
+ if stdout_str:
+ response_text = stdout_str
+ else:
+ response_text = "Command executed successfully (no output)."
+ else:
+ # Error case: show detailed information
+ response_parts = [f"Command failed with exit code {returncode}."]
+ if stdout_str:
+ response_parts.append(f"\n[stdout]\n{stdout_str}")
+ if stderr_str:
+ response_parts.append(f"\n[stderr]\n{stderr_str}")
+ response_text = "".join(response_parts)
+
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=response_text,
+ ),
+ ],
+ )
+
+ except Exception as e:
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=f"Error: Shell command execution failed due to \n{e}",
+ ),
+ ],
+ )
diff --git a/reme/memory/file_based/utils/__init__.py b/reme/memory/file_based/utils/__init__.py
new file mode 100644
index 00000000..48231232
--- /dev/null
+++ b/reme/memory/file_based/utils/__init__.py
@@ -0,0 +1,13 @@
+"""utils"""
+
+from .as_msg_handler import AsMsgHandler
+from .file_utils import truncate_output, truncate_shell_output, read_file_safe, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES
+
+__all__ = [
+ "AsMsgHandler",
+ "truncate_output",
+ "truncate_shell_output",
+ "read_file_safe",
+ "DEFAULT_MAX_BYTES",
+ "DEFAULT_MAX_LINES",
+]
diff --git a/reme/memory/file_based/utils/as_msg_handler.py b/reme/memory/file_based/utils/as_msg_handler.py
new file mode 100644
index 00000000..30facedc
--- /dev/null
+++ b/reme/memory/file_based/utils/as_msg_handler.py
@@ -0,0 +1,425 @@
+"""Handler for AgentScope message processing, token counting, and context management."""
+
+import json
+
+from agentscope.message import Msg
+from agentscope.token import HuggingFaceTokenCounter
+
+from ....core.schema import AsMsgStat, AsBlockStat
+from ....core.utils import get_std_logger
+
+logger = get_std_logger()
+
+
+class AsMsgHandler:
+ """Handles token counting, formatting, and context compaction for AgentScope messages."""
+
+ def __init__(self, token_counter: HuggingFaceTokenCounter):
+ self._token_counter = token_counter
+
+ def count_str_token(self, text: str) -> int:
+ """Count tokens in a string.
+
+ Args:
+ text: The text to count tokens for.
+
+ Returns:
+ The number of tokens in the text.
+ """
+ if not text:
+ return 0
+
+ try:
+ token_ids = self._token_counter.tokenizer.encode(text)
+ token_count = len(token_ids)
+ return token_count
+
+ except Exception as e:
+ estimated_tokens = len(text.encode("utf-8")) // 4
+ logger.warning(f"Failed to count string tokens: {text}, e={e}")
+ return estimated_tokens
+
+ def _format_tool_result_output(self, output: str | list[dict]) -> tuple[str, int]:
+ """Convert tool result output to string."""
+ if isinstance(output, str):
+ return output, self.count_str_token(output)
+
+ textual_parts = []
+ total_token_count = 0
+ for block in output:
+ try:
+ if not isinstance(block, dict) or "type" not in block:
+ logger.warning(
+ "Invalid block: %s, expected a dict with 'type' key, skipped.",
+ block,
+ )
+ continue
+
+ block_type = block["type"]
+
+ if block_type == "text":
+ textual_parts.append(block.get("text", ""))
+ total_token_count += self.count_str_token(textual_parts[-1])
+
+ elif block_type in ["image", "audio", "video"]:
+ source = block.get("source", {})
+ if source.get("type") == "base64":
+ data = source.get("data", "")
+ total_token_count += len(data) // 4 if data else 10
+ else:
+ url = source.get("url", "")
+ total_token_count += self.count_str_token(url) if url else 10
+ textual_parts.append(f"[{block_type}] {url}")
+
+ elif block_type == "file":
+ file_path = block.get("path", "") or block.get("url", "")
+ file_name = block.get("name", file_path)
+ textual_parts.append(f"[file] {file_name}: {file_path}")
+ total_token_count += self.count_str_token(file_path)
+
+ else:
+ logger.warning(
+ "Unsupported block type '%s' in tool result, skipped.",
+ block_type,
+ )
+
+ except Exception as e:
+ logger.warning(
+ "Failed to process block %s: %s, skipped.",
+ block,
+ e,
+ )
+
+ return "\n".join(textual_parts), total_token_count
+
+ def stat_message(self, message: Msg) -> AsMsgStat:
+ """Analyze a message and generate block statistics."""
+ blocks = []
+ if isinstance(message.content, str):
+ blocks.append(
+ AsBlockStat(
+ block_type="text",
+ text=message.content,
+ token_count=self.count_str_token(message.content),
+ ),
+ )
+ return AsMsgStat(
+ name=message.name or message.role,
+ role=message.role,
+ content=blocks,
+ timestamp=message.timestamp or "",
+ metadata=message.metadata or {},
+ )
+
+ if not isinstance(message.content, list):
+ logger.warning(
+ "Unexpected message.content type %s, expected str or list, returning empty stat.",
+ type(message.content),
+ )
+ return AsMsgStat(
+ name=message.name or message.role,
+ role=message.role,
+ content=blocks,
+ timestamp=message.timestamp or "",
+ metadata=message.metadata or {},
+ )
+
+ for block in message.content:
+ block_type = block.get("type", "unknown")
+
+ if block_type == "text":
+ text = block.get("text", "")
+ token_count = self.count_str_token(text)
+ blocks.append(
+ AsBlockStat(
+ block_type=block_type,
+ text=text,
+ token_count=token_count,
+ ),
+ )
+
+ elif block_type == "thinking":
+ thinking = block.get("thinking", "")
+ token_count = self.count_str_token(thinking)
+ blocks.append(
+ AsBlockStat(
+ block_type=block_type,
+ text=thinking,
+ token_count=token_count,
+ ),
+ )
+
+ elif block_type in ("image", "audio", "video"):
+ source = block.get("source", {})
+ url = source.get("url", "")
+ if source.get("type") == "base64":
+ data = source.get("data", "")
+ token_count = len(data) // 4 if data else 10
+ else:
+ token_count = self.count_str_token(url) if url else 10
+ blocks.append(
+ AsBlockStat(
+ block_type=block_type,
+ text="",
+ token_count=token_count,
+ media_url=url,
+ ),
+ )
+
+ elif block_type == "tool_use":
+ tool_name = block.get("name", "")
+ tool_input = block.get("input", "")
+ try:
+ input_str = json.dumps(tool_input, ensure_ascii=False)
+ except (TypeError, ValueError):
+ input_str = str(tool_input)
+ token_count = self.count_str_token(tool_name + input_str)
+ blocks.append(
+ AsBlockStat(
+ block_type=block_type,
+ text="",
+ token_count=token_count,
+ tool_name=tool_name,
+ tool_input=input_str,
+ ),
+ )
+
+ elif block_type == "tool_result":
+ tool_name = block.get("name", "")
+ output = block.get("output", "")
+ formatted_output, token_count = self._format_tool_result_output(output)
+ blocks.append(
+ AsBlockStat(
+ block_type=block_type,
+ text="",
+ token_count=token_count,
+ tool_name=tool_name,
+ tool_output=formatted_output,
+ ),
+ )
+
+ else:
+ logger.warning("Unsupported block type %s, skipped.", block_type)
+
+ return AsMsgStat(
+ name=message.name or message.role,
+ role=message.role,
+ content=blocks,
+ timestamp=message.timestamp or "",
+ metadata=message.metadata or {},
+ )
+
+ def count_msgs_token(self, messages: list[Msg]) -> int:
+ """Count total token count of a list of messages."""
+ return sum(self.stat_message(msg).total_tokens for msg in messages)
+
+ def format_msgs_to_str(
+ self,
+ messages: list[Msg],
+ memory_compact_threshold: int,
+ include_thinking: bool = False,
+ ) -> str:
+ """Format list of messages to a single formatted string.
+
+ Messages are processed in reverse order (newest first) and older
+ messages are skipped when token count exceeds memory_compact_threshold.
+
+ Args:
+ messages: List of Msg objects to format.
+ memory_compact_threshold: Maximum token count before skipping older messages.
+ include_thinking: Whether to include thinking blocks in output.
+ """
+ if not messages:
+ return ""
+
+ formatted_parts: list[str] = []
+ total_token_count = 0
+
+ for i in range(len(messages) - 1, -1, -1):
+ stat = self.stat_message(messages[i])
+ formatted_content = stat.format(include_thinking=include_thinking)
+ content_token_count = self.count_str_token(formatted_content)
+
+ is_latest = i == len(messages) - 1
+ if not is_latest and total_token_count + content_token_count > memory_compact_threshold:
+ logger.info(
+ "Skipping older messages: adding %d tokens would exceed threshold %d (current: %d)",
+ content_token_count,
+ memory_compact_threshold,
+ total_token_count,
+ )
+ break
+
+ if is_latest and content_token_count > memory_compact_threshold:
+ logger.warning(
+ "Latest message alone (%d tokens) exceeds threshold %d, including it anyway.",
+ content_token_count,
+ memory_compact_threshold,
+ )
+
+ formatted_parts.append(formatted_content)
+ total_token_count += content_token_count
+
+ formatted_parts.reverse()
+ return "\n\n".join(formatted_parts)
+
+ @staticmethod
+ def validate_tool_ids_alignment(messages: list[Msg]) -> bool:
+ """Check if tool_use_ids and tool_result_ids are properly aligned.
+
+ Args:
+ messages: List of Msg objects to validate.
+
+ Returns:
+ True if all tool_use ids have corresponding tool_result ids and vice versa.
+ """
+ tool_use_ids: set[str] = set()
+ tool_result_ids: set[str] = set()
+
+ for msg in messages:
+ for block in msg.get_content_blocks("tool_use"):
+ if tool_id := block.get("id"):
+ tool_use_ids.add(tool_id)
+ for block in msg.get_content_blocks("tool_result"):
+ if tool_id := block.get("id"):
+ tool_result_ids.add(tool_id)
+
+ return tool_use_ids == tool_result_ids
+
+ def context_check(
+ self,
+ messages: list[Msg],
+ memory_compact_threshold: int,
+ memory_compact_reserve: int,
+ ) -> tuple[list[Msg], list[Msg], bool]:
+ """Check if context exceeds threshold and split messages accordingly.
+
+ Only when total tokens exceed memory_compact_threshold, messages are split into
+ messages_to_keep (within reserve limit) and messages_to_compact (older messages).
+
+ Args:
+ messages: List of Msg objects to check.
+ memory_compact_threshold: Maximum token count threshold to trigger compaction.
+ memory_compact_reserve: Token limit for messages to keep.
+
+ Returns:
+ A tuple of (messages_to_compact, messages_to_keep, tools_aligned):
+ - messages_to_compact: Older messages that exceed reserve limit
+ - messages_to_keep: Recent messages within the reserve limit
+ - tools_aligned: Whether tool_use and tool_result ids are aligned in messages_to_keep
+ """
+ if not messages:
+ return [], [], True
+
+ # Calculate total tokens and stats for all messages
+ msg_stats: list[tuple[Msg, AsMsgStat]] = []
+ total_tokens = 0
+ for msg in messages:
+ stat = self.stat_message(msg)
+ msg_stats.append((msg, stat))
+ total_tokens += stat.total_tokens
+
+ # If total tokens don't exceed threshold, no split needed
+ if total_tokens < memory_compact_threshold:
+ return [], messages, True
+
+ # Collect all tool_use ids and their message indices
+ # tool_use_id -> message index
+ tool_use_locations: dict[str, int] = {}
+ # tool_result_id -> message index
+ tool_result_locations: dict[str, int] = {}
+
+ for idx, (msg, _) in enumerate(msg_stats):
+ for block in msg.get_content_blocks("tool_use"):
+ tool_id = block.get("id", "")
+ if tool_id:
+ tool_use_locations[tool_id] = idx
+
+ for block in msg.get_content_blocks("tool_result"):
+ tool_id = block.get("id", "")
+ if tool_id:
+ tool_result_locations[tool_id] = idx
+
+ # Iterate from the end, accumulating messages to keep within reserve limit
+ keep_indices: set[int] = set()
+ accumulated_tokens = 0
+
+ for i in range(len(msg_stats) - 1, -1, -1):
+ # Skip messages already added as tool_use dependencies to avoid double-counting tokens
+ if i in keep_indices:
+ continue
+
+ msg, stat = msg_stats[i]
+
+ # Check if adding this message would exceed reserve limit
+ if accumulated_tokens + stat.total_tokens > memory_compact_reserve:
+ logger.info(
+ "Context check: adding message %d with %d tokens would exceed reserve %d (current: %d)",
+ i,
+ stat.total_tokens,
+ memory_compact_reserve,
+ accumulated_tokens,
+ )
+ break
+
+ # Check tool_result dependencies - if this message has tool_result,
+ # we need to ensure the corresponding tool_use is also included
+ tool_result_ids = [
+ block.get("id", "") for block in msg.get_content_blocks("tool_result") if block.get("id", "")
+ ]
+
+ # Calculate extra tokens needed for dependent tool_use messages
+ extra_tokens = 0
+ dependent_indices: set[int] = set()
+
+ for tool_id in tool_result_ids:
+ if tool_id in tool_use_locations:
+ tool_use_idx = tool_use_locations[tool_id]
+ if tool_use_idx not in keep_indices and tool_use_idx != i:
+ dependent_indices.add(tool_use_idx)
+ _, dep_stat = msg_stats[tool_use_idx]
+ extra_tokens += dep_stat.total_tokens
+
+ # Check if we can fit this message plus its dependencies within reserve
+ if accumulated_tokens + stat.total_tokens + extra_tokens > memory_compact_reserve:
+ logger.info(
+ "Context check: message %d requires %d extra tokens for tool_use dependencies, "
+ "total would exceed reserve %d",
+ i,
+ extra_tokens,
+ memory_compact_reserve,
+ )
+ break
+
+ # Add this message and its dependencies
+ keep_indices.add(i)
+ keep_indices.update(dependent_indices)
+ accumulated_tokens += stat.total_tokens + extra_tokens
+
+ # Build final lists based on keep_indices (preserve original order)
+ messages_to_compact = []
+ messages_to_keep = []
+
+ for idx, (msg, _) in enumerate(msg_stats):
+ if idx in keep_indices:
+ messages_to_keep.append(msg)
+ else:
+ messages_to_compact.append(msg)
+
+ # Validate tool ids alignment for messages_to_keep
+ tools_aligned = self.validate_tool_ids_alignment(messages_to_keep)
+
+ logger.info(
+ "Context check result: %d messages to compact, %d messages to keep, "
+ "total tokens: %d, threshold: %d, reserve: %d, kept tokens: %d, "
+ "tools_aligned: %s",
+ len(messages_to_compact),
+ len(messages_to_keep),
+ total_tokens,
+ memory_compact_threshold,
+ memory_compact_reserve,
+ accumulated_tokens,
+ tools_aligned,
+ )
+
+ return messages_to_compact, messages_to_keep, tools_aligned
diff --git a/reme/memory/file_based/utils/file_utils.py b/reme/memory/file_based/utils/file_utils.py
new file mode 100644
index 00000000..17f58877
--- /dev/null
+++ b/reme/memory/file_based/utils/file_utils.py
@@ -0,0 +1,112 @@
+"""Shared utilities for file and shell tools."""
+
+# Default truncation limits
+DEFAULT_MAX_LINES = 1000
+DEFAULT_MAX_BYTES = 30 * 1024 # 30KB
+
+
+def truncate_output(
+ text: str,
+ max_lines: int = DEFAULT_MAX_LINES,
+ max_bytes: int = DEFAULT_MAX_BYTES,
+ keep: str = "head",
+) -> tuple[str, bool, int, str]:
+ """Smart truncation for large content.
+
+ Args:
+ text: Text content to truncate.
+ max_lines: Maximum number of lines.
+ max_bytes: Maximum size in bytes.
+ keep: Which part to keep - "head" (first lines) or "tail" (last lines).
+
+ Returns:
+ (truncated_content, was_truncated, output_line_count, truncate_reason)
+ """
+ if not text:
+ return text, False, 0, ""
+
+ lines = text.split("\n")
+ total_lines = len(lines)
+
+ # No truncation needed
+ if total_lines <= max_lines and len(text.encode("utf-8")) <= max_bytes:
+ return text, False, total_lines, ""
+
+ # Apply line limit
+ if total_lines > max_lines:
+ if keep == "tail":
+ lines = lines[-max_lines:]
+ else:
+ lines = lines[:max_lines]
+ reason = "lines"
+ else:
+ reason = ""
+
+ # Apply byte limit
+ if len("\n".join(lines).encode("utf-8")) > max_bytes:
+ if keep == "tail":
+ while lines and len("\n".join(lines).encode("utf-8")) > max_bytes:
+ lines.pop(0)
+ else:
+ truncated = []
+ current_bytes = 0
+ for line in lines:
+ line_bytes = len(line.encode("utf-8")) + 1
+ if current_bytes + line_bytes > max_bytes:
+ break
+ truncated.append(line)
+ current_bytes += line_bytes
+ lines = truncated
+ reason = "bytes"
+
+ return "\n".join(lines), True, len(lines), reason
+
+
+def truncate_shell_output(text: str) -> str:
+ """Truncate shell output to last N lines or M bytes, with truncation notice.
+
+ Args:
+ text: The output text to truncate.
+
+ Returns:
+ Truncated text with notice if truncated.
+ """
+ if not text:
+ return text
+
+ try:
+ total_lines = len(text.split("\n"))
+ truncated, was_truncated, output_lines, reason = truncate_output(text, keep="tail")
+
+ if not was_truncated:
+ return text
+
+ start_line = total_lines - output_lines + 1
+ if reason == "lines":
+ notice = f"\n\n[Output truncated: showing lines {start_line}-{total_lines} of {total_lines} total]"
+ else:
+ notice = (
+ f"\n\n[Output truncated: showing lines {start_line}-{total_lines} of {total_lines} "
+ f"({DEFAULT_MAX_BYTES // 1024}KB limit)]"
+ )
+
+ return truncated + notice
+ except Exception:
+ return text
+
+
+def read_file_safe(file_path: str) -> str:
+ """Read file with Unicode error handling.
+
+ Args:
+ file_path: Path to the file.
+
+ Returns:
+ File content as string.
+ """
+ try:
+ with open(file_path, "r", encoding="utf-8") as f:
+ return f.read()
+ except UnicodeDecodeError:
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
+ return f.read()
diff --git a/reme/memory/tools/profiles/__init__.py b/reme/memory/tools/profiles/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/reme/memory/tools/record/__init__.py b/reme/memory/tools/record/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/reme/memory/tools/__init__.py b/reme/memory/vector_tools/__init__.py
similarity index 92%
rename from reme/memory/tools/__init__.py
rename to reme/memory/vector_tools/__init__.py
index af9b851f..fd5e721f 100644
--- a/reme/memory/tools/__init__.py
+++ b/reme/memory/vector_tools/__init__.py
@@ -3,8 +3,6 @@
from .base_memory_tool import BaseMemoryTool
# chunk tools
-from .chunk.memory_get import MemoryGet
-from .chunk.memory_search import MemorySearch
from .delegate_task import DelegateTask
# history tools
@@ -36,9 +34,6 @@ __all__ = [
# base
"BaseMemoryTool",
"DelegateTask",
- # chunk tools
- "MemoryGet",
- "MemorySearch",
# history tools
"AddHistory",
"ReadHistory",
diff --git a/reme/memory/tools/base_memory_tool.py b/reme/memory/vector_tools/base_memory_tool.py
similarity index 100%
rename from reme/memory/tools/base_memory_tool.py
rename to reme/memory/vector_tools/base_memory_tool.py
diff --git a/reme/memory/tools/delegate_task.py b/reme/memory/vector_tools/delegate_task.py
similarity index 100%
rename from reme/memory/tools/delegate_task.py
rename to reme/memory/vector_tools/delegate_task.py
diff --git a/cookbook/__init__.py b/reme/memory/vector_tools/history/__init__.py
similarity index 100%
rename from cookbook/__init__.py
rename to reme/memory/vector_tools/history/__init__.py
diff --git a/reme/memory/tools/history/add_history.py b/reme/memory/vector_tools/history/add_history.py
similarity index 100%
rename from reme/memory/tools/history/add_history.py
rename to reme/memory/vector_tools/history/add_history.py
diff --git a/reme/memory/tools/history/read_history.py b/reme/memory/vector_tools/history/read_history.py
similarity index 100%
rename from reme/memory/tools/history/read_history.py
rename to reme/memory/vector_tools/history/read_history.py
diff --git a/reme/memory/tools/history/read_history_v2.py b/reme/memory/vector_tools/history/read_history_v2.py
similarity index 100%
rename from reme/memory/tools/history/read_history_v2.py
rename to reme/memory/vector_tools/history/read_history_v2.py
diff --git a/cookbook/appworld/__init__.py b/reme/memory/vector_tools/profiles/__init__.py
similarity index 100%
rename from cookbook/appworld/__init__.py
rename to reme/memory/vector_tools/profiles/__init__.py
diff --git a/reme/memory/tools/profiles/add_draft_and_read_all_profiles.py b/reme/memory/vector_tools/profiles/add_draft_and_read_all_profiles.py
similarity index 100%
rename from reme/memory/tools/profiles/add_draft_and_read_all_profiles.py
rename to reme/memory/vector_tools/profiles/add_draft_and_read_all_profiles.py
diff --git a/reme/memory/tools/profiles/add_profile.py b/reme/memory/vector_tools/profiles/add_profile.py
similarity index 100%
rename from reme/memory/tools/profiles/add_profile.py
rename to reme/memory/vector_tools/profiles/add_profile.py
diff --git a/reme/memory/tools/profiles/delete_profile.py b/reme/memory/vector_tools/profiles/delete_profile.py
similarity index 100%
rename from reme/memory/tools/profiles/delete_profile.py
rename to reme/memory/vector_tools/profiles/delete_profile.py
diff --git a/reme/memory/tools/profiles/profile_handler.py b/reme/memory/vector_tools/profiles/profile_handler.py
similarity index 100%
rename from reme/memory/tools/profiles/profile_handler.py
rename to reme/memory/vector_tools/profiles/profile_handler.py
diff --git a/reme/memory/tools/profiles/read_all_profiles.py b/reme/memory/vector_tools/profiles/read_all_profiles.py
similarity index 100%
rename from reme/memory/tools/profiles/read_all_profiles.py
rename to reme/memory/vector_tools/profiles/read_all_profiles.py
diff --git a/reme/memory/tools/profiles/update_profile.py b/reme/memory/vector_tools/profiles/update_profile.py
similarity index 100%
rename from reme/memory/tools/profiles/update_profile.py
rename to reme/memory/vector_tools/profiles/update_profile.py
diff --git a/reme/memory/tools/profiles/update_profiles_v1.py b/reme/memory/vector_tools/profiles/update_profiles_v1.py
similarity index 100%
rename from reme/memory/tools/profiles/update_profiles_v1.py
rename to reme/memory/vector_tools/profiles/update_profiles_v1.py
diff --git a/cookbook/bfcl/__init__.py b/reme/memory/vector_tools/record/__init__.py
similarity index 100%
rename from cookbook/bfcl/__init__.py
rename to reme/memory/vector_tools/record/__init__.py
diff --git a/reme/memory/tools/record/add_and_retrieve_similar_memory.py b/reme/memory/vector_tools/record/add_and_retrieve_similar_memory.py
similarity index 100%
rename from reme/memory/tools/record/add_and_retrieve_similar_memory.py
rename to reme/memory/vector_tools/record/add_and_retrieve_similar_memory.py
diff --git a/reme/memory/tools/record/add_draft_and_retrieve_similar_memory.py b/reme/memory/vector_tools/record/add_draft_and_retrieve_similar_memory.py
similarity index 100%
rename from reme/memory/tools/record/add_draft_and_retrieve_similar_memory.py
rename to reme/memory/vector_tools/record/add_draft_and_retrieve_similar_memory.py
diff --git a/reme/memory/tools/record/add_memory.py b/reme/memory/vector_tools/record/add_memory.py
similarity index 100%
rename from reme/memory/tools/record/add_memory.py
rename to reme/memory/vector_tools/record/add_memory.py
diff --git a/reme/memory/tools/record/delete_memory.py b/reme/memory/vector_tools/record/delete_memory.py
similarity index 100%
rename from reme/memory/tools/record/delete_memory.py
rename to reme/memory/vector_tools/record/delete_memory.py
diff --git a/reme/memory/tools/record/memory_handler.py b/reme/memory/vector_tools/record/memory_handler.py
similarity index 100%
rename from reme/memory/tools/record/memory_handler.py
rename to reme/memory/vector_tools/record/memory_handler.py
diff --git a/reme/memory/tools/record/retrieve_memory.py b/reme/memory/vector_tools/record/retrieve_memory.py
similarity index 100%
rename from reme/memory/tools/record/retrieve_memory.py
rename to reme/memory/vector_tools/record/retrieve_memory.py
diff --git a/reme/memory/tools/record/retrieve_recent_memory.py b/reme/memory/vector_tools/record/retrieve_recent_memory.py
similarity index 100%
rename from reme/memory/tools/record/retrieve_recent_memory.py
rename to reme/memory/vector_tools/record/retrieve_recent_memory.py
diff --git a/reme/memory/tools/record/update_memory.py b/reme/memory/vector_tools/record/update_memory.py
similarity index 100%
rename from reme/memory/tools/record/update_memory.py
rename to reme/memory/vector_tools/record/update_memory.py
diff --git a/reme/memory/tools/record/update_memory_v1.py b/reme/memory/vector_tools/record/update_memory_v1.py
similarity index 100%
rename from reme/memory/tools/record/update_memory_v1.py
rename to reme/memory/vector_tools/record/update_memory_v1.py
diff --git a/reme/memory/tools/record/update_memory_v2.py b/reme/memory/vector_tools/record/update_memory_v2.py
similarity index 100%
rename from reme/memory/tools/record/update_memory_v2.py
rename to reme/memory/vector_tools/record/update_memory_v2.py
diff --git a/reme/reme.py b/reme/reme.py
index 6c5525ee..685cdcdd 100644
--- a/reme/reme.py
+++ b/reme/reme.py
@@ -7,7 +7,7 @@ from .config import ReMeConfigParser
from .core import Application
from .core.enumeration import MemoryType, Role
from .core.schema import Message, MemoryNode
-from .memory.tools import (
+from .memory.vector_tools import (
AddDraftAndRetrieveSimilarMemory,
AddHistory,
AddMemory,
@@ -17,8 +17,8 @@ from .memory.tools import (
RetrieveMemory,
UpdateProfilesV1,
)
-from .memory.tools.profiles.profile_handler import ProfileHandler
-from .memory.tools.record.memory_handler import MemoryHandler
+from .memory.vector_tools.profiles.profile_handler import ProfileHandler
+from .memory.vector_tools.record.memory_handler import MemoryHandler
from .memory.vector_based import (
BaseMemoryAgent,
PersonalRetriever,
@@ -185,7 +185,7 @@ class ReMe(Application):
format_messages.append(message)
if version == "default":
- personal_summarizer_tools = [
+ personal_summarizer_tools: list = [
AddDraftAndRetrieveSimilarMemory(
enable_thinking_params=enable_thinking_params,
enable_memory_target=False,
diff --git a/reme/reme_fb.py b/reme/reme_fb.py
deleted file mode 100644
index 914d237b..00000000
--- a/reme/reme_fb.py
+++ /dev/null
@@ -1,183 +0,0 @@
-"""ReMe File Based"""
-
-from pathlib import Path
-
-from .config import ReMeConfigParser
-from .core import Application
-from .core.schema import Message
-from .core.tools import (
- BashTool,
- EditTool,
- LsTool,
- ReadTool,
- WriteTool,
-)
-from .memory.file_based import FbCompactor, FbContextChecker, FbSummarizer
-from .memory.tools import MemoryGet, MemorySearch
-
-
-class ReMeFb(Application):
- """ReMe File Based"""
-
- def __init__(
- self,
- *args,
- working_dir: str = ".reme",
- config_path: str = "file",
- enable_logo: bool = True,
- log_to_console: bool = True,
- llm_api_key: str | None = None,
- llm_base_url: str | None = None,
- embedding_api_key: str | None = None,
- embedding_base_url: str | None = None,
- default_llm_config: dict | None = None,
- default_embedding_model_config: dict | None = None,
- default_file_store_config: dict | None = None,
- default_token_counter_config: dict | None = None,
- default_file_watcher_config: dict | None = None,
- context_window_tokens: int = 128000,
- reserve_tokens: int = 36000,
- keep_recent_tokens: int = 20000,
- vector_weight: float = 0.7,
- candidate_multiplier: float = 3.0,
- **kwargs,
- ):
- """Initialize ReMe with config."""
- working_path = Path(working_dir)
- working_path.mkdir(parents=True, exist_ok=True)
- memory_path = working_path / "memory"
- memory_path.mkdir(parents=True, exist_ok=True)
- self.working_dir: str = str(working_path.absolute())
-
- default_file_watcher_config = default_file_watcher_config or {}
- if not default_file_watcher_config.get("watch_paths", None):
- default_file_watcher_config["watch_paths"] = [
- str(working_path / "MEMORY.md"),
- str(working_path / "memory.md"),
- str(memory_path),
- ]
- super().__init__(
- *args,
- llm_api_key=llm_api_key,
- llm_base_url=llm_base_url,
- embedding_api_key=embedding_api_key,
- embedding_base_url=embedding_base_url,
- working_dir=working_dir,
- config_path=config_path,
- enable_logo=enable_logo,
- log_to_console=log_to_console,
- parser=ReMeConfigParser,
- default_llm_config=default_llm_config,
- default_embedding_model_config=default_embedding_model_config,
- default_file_store_config=default_file_store_config,
- default_token_counter_config=default_token_counter_config,
- default_file_watcher_config=default_file_watcher_config,
- **kwargs,
- )
-
- self.service_config.metadata.setdefault("context_window_tokens", context_window_tokens)
- self.service_config.metadata.setdefault("reserve_tokens", reserve_tokens)
- self.service_config.metadata.setdefault("keep_recent_tokens", keep_recent_tokens)
- self.service_config.metadata.setdefault("vector_weight", vector_weight)
- self.service_config.metadata.setdefault("candidate_multiplier", candidate_multiplier)
-
- async def context_check(self, messages: list[Message | dict]) -> dict:
- """Check if messages exceed context limits."""
- checker = FbContextChecker(
- context_window_tokens=self.service_config.metadata["context_window_tokens"],
- reserve_tokens=self.service_config.metadata["reserve_tokens"],
- keep_recent_tokens=self.service_config.metadata["keep_recent_tokens"],
- )
- return await checker.call(messages=messages, service_context=self.service_context)
-
- async def compact(
- self,
- messages_to_summarize: list[Message | dict] = None,
- turn_prefix_messages: list[Message | dict] = None,
- previous_summary: str = "",
- language: str = "zh",
- **kwargs,
- ) -> str | dict:
- """Compact messages into a summary."""
- compactor = FbCompactor(language=language, **kwargs)
- return await compactor.call(
- messages_to_summarize=messages_to_summarize or [],
- turn_prefix_messages=turn_prefix_messages or [],
- previous_summary=previous_summary,
- service_context=self.service_context,
- )
-
- async def summary(
- self,
- messages: list[Message | dict],
- date: str,
- version: str = "default",
- language: str = "zh",
- **kwargs,
- ) -> str | dict:
- """Generate a summary of the given messages."""
- summarizer = FbSummarizer(
- tools=[
- BashTool(cwd=self.working_dir),
- LsTool(cwd=self.working_dir),
- ReadTool(cwd=self.working_dir),
- WriteTool(cwd=self.working_dir),
- EditTool(cwd=self.working_dir),
- ],
- working_dir=self.working_dir,
- language=language,
- version=version,
- **kwargs,
- )
- return await summarizer.call(messages=messages, date=date, service_context=self.service_context)
-
- async def memory_search(self, query: str, max_results: int = 5, min_score: float = 0.1) -> str:
- """
- Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts)
- before answering questions about prior work, decisions, dates, people, preferences, or todos;
- returns top snippets with path + lines.
-
- Args:
- query: The semantic search query to find relevant memory snippets
- max_results: Maximum number of search results to return (optional), default is 5
- min_score: Minimum similarity score threshold for results (optional), default is 0.1
-
- Returns:
- Search results as formatted string
- """
- search_tool = MemorySearch(
- vector_weight=self.service_config.metadata["vector_weight"],
- candidate_multiplier=self.service_config.metadata["candidate_multiplier"],
- )
- return await search_tool.call(
- query=query,
- max_results=max_results,
- min_score=min_score,
- service_context=self.service_context,
- )
-
- async def memory_get(self, path: str, offset: int | None = None, limit: int | None = None) -> str:
- """
- Safe snippet read from MEMORY.md, memory/*.md with optional offset/limit;
- use after memory_search to pull only the needed lines and keep context small.
-
- Args:
- path: Path to the memory file to read (relative or absolute)
- offset: Starting line number (1-indexed, optional)
- limit: Number of lines to read from the starting line (optional)
-
- Returns:
- Memory file content as string
- """
- get_tool = MemoryGet(cwd=self.working_dir)
- return await get_tool.call(path=path, offset=offset, limit=limit, service_context=self.service_context)
-
- async def needs_compaction(self, messages: list[Message | dict]) -> bool:
- """Check if messages need compaction based on context window limits."""
- messages = [Message(**message) if isinstance(message, dict) else message for message in messages]
- checker = FbContextChecker(
- context_window_tokens=self.service_config.metadata["context_window_tokens"],
- reserve_tokens=self.service_config.metadata["reserve_tokens"],
- )
- result = await checker.call(messages=messages, service_context=self.service_context)
- return result["needs_compaction"]
diff --git a/reme/reme_light.py b/reme/reme_light.py
new file mode 100644
index 00000000..94309eb0
--- /dev/null
+++ b/reme/reme_light.py
@@ -0,0 +1,782 @@
+"""
+ReMe Light Application Module
+
+This module provides the ReMeLight class, a specialized application built on top of
+ReMe's core Application framework. It integrates memory management capabilities
+including memory compaction, summarization, tool result management, and semantic
+memory search functionality.
+
+Key Features:
+ - Memory compaction and summarization for long conversations
+ - Tool result compaction with file-based storage for large outputs
+ - Semantic memory search using vector and full-text search
+ - Configurable embedding models and vector store backends
+ - Async task management for background summarization
+"""
+
+import asyncio
+from pathlib import Path
+
+from agentscope.formatter import FormatterBase
+from agentscope.message import Msg, TextBlock
+from agentscope.model import ChatModelBase
+from agentscope.token import HuggingFaceTokenCounter
+from agentscope.tool import Toolkit, ToolResponse
+
+from .config import ReMeConfigParser
+from .core import Application
+from .core.utils import get_hf_token_counter, get_std_logger
+from .memory.file_based import ReMeInMemoryMemory
+from .memory.file_based.components import (
+ Compactor,
+ ContextChecker,
+ Summarizer,
+ ToolResultCompactor,
+)
+from .memory.file_based.tools import FileIO, MemorySearch
+from .memory.file_based.utils import AsMsgHandler
+
+logger = get_std_logger()
+
+
+class ReMeLight(Application):
+ """
+ ReMe Light Application Class.
+
+ A lightweight memory-enabled application that provides semantic search,
+ memory compaction, summarization, and tool result management capabilities.
+ Built on top of the core Application framework with integrated vector store
+ and file-based memory management.
+
+ This class is designed for applications requiring:
+ - Long conversation memory management with automatic compaction
+ - Semantic search over stored memories using hybrid vector/text search
+ - Background summarization of conversation history
+ - Automatic cleanup of expired tool results
+
+ Attributes:
+ working_path (Path): Absolute path to the working directory.
+ memory_path (Path): Path to the memory storage directory.
+ tool_result_path (Path): Path to the tool result storage directory.
+ vector_weight (float): Weight for vector search in hybrid search (0-1).
+ candidate_multiplier (float): Multiplier for candidate retrieval count.
+ tool_result_threshold (int): Character threshold for tool result compaction.
+ retention_days (int): Number of days to retain tool result files.
+ summary_tasks (list[asyncio.Task]): List of active background summary tasks.
+ """
+
+ def __init__(
+ self,
+ working_dir: str = ".reme",
+ llm_api_key: str | None = None,
+ llm_base_url: str | None = None,
+ embedding_api_key: str | None = None,
+ embedding_base_url: str | None = None,
+ default_as_llm_config: dict | None = None,
+ default_embedding_model_config: dict | None = None,
+ default_file_store_config: dict | None = None,
+ vector_weight: float = 0.7,
+ candidate_multiplier: float = 3.0,
+ tool_result_threshold: int = 1000,
+ retention_days: int = 7,
+ ):
+ """
+ Initialize the ReMeLight application.
+
+ Sets up the working directory structure, configures API connections,
+ and initializes memory management components.
+
+ Args:
+ working_dir (str): Base directory for all application data storage.
+ Defaults to ".reme". Will be created if it doesn't exist.
+ llm_api_key (str | None): API key for the language model service.
+ If None, will attempt to use environment variables.
+ llm_base_url (str | None): Base URL for the language model API endpoint.
+ If None, will use the default endpoint.
+ embedding_api_key (str | None): API key for the embedding model service.
+ If None, will attempt to use environment variables.
+ embedding_base_url (str | None): Base URL for the embedding API endpoint.
+ If None, will use the default endpoint.
+ default_as_llm_config (dict | None): Default configuration dictionary
+ for AgentScope language model. Overrides default settings.
+ default_embedding_model_config (dict | None): Default configuration
+ dictionary for the embedding model.
+ default_file_store_config (dict | None): Default configuration
+ dictionary for the file storage backend.
+ vector_weight (float): Weight assigned to vector similarity search
+ in hybrid search operations. Range [0.0, 1.0], default 0.7.
+ Higher values prioritize semantic similarity over keyword matching.
+ candidate_multiplier (float): Multiplier applied to max_results when
+ retrieving candidates for re-ranking. Default 3.0 means 3x more
+ candidates are retrieved than the final result count.
+ tool_result_threshold (int): Character count threshold for tool result
+ compaction. Results exceeding this length will be truncated and
+ saved to files. Default 1000 characters.
+ retention_days (int): Number of days to retain tool result files
+ before automatic cleanup. Default 7 days.
+
+ Note:
+ The following directory structure will be created:
+ - {working_dir}/ - Root working directory
+ - {working_dir}/memory/ - Memory storage files
+ - {working_dir}/tool_result/ - Compacted tool result files
+ """
+ # Initialize working directory structure
+ self.working_path = Path(working_dir).absolute()
+ self.working_path.mkdir(parents=True, exist_ok=True)
+ self.memory_path = self.working_path / "memory"
+ self.memory_path.mkdir(parents=True, exist_ok=True)
+ self.tool_result_path = self.working_path / "tool_result"
+ self.tool_result_path.mkdir(parents=True, exist_ok=True)
+
+ self.vector_weight: float = vector_weight
+ self.candidate_multiplier: float = candidate_multiplier
+ self.tool_result_threshold: int = tool_result_threshold
+ self.retention_days: int = retention_days
+
+ # Initialize the parent Application class with comprehensive configuration
+ super().__init__(
+ llm_api_key=llm_api_key,
+ llm_base_url=llm_base_url,
+ embedding_api_key=embedding_api_key,
+ embedding_base_url=embedding_base_url,
+ working_dir=str(self.working_path),
+ config_path="light",
+ enable_logo=False,
+ log_to_console=False,
+ parser=ReMeConfigParser,
+ default_as_llm_config=default_as_llm_config,
+ default_embedding_model_config=default_embedding_model_config,
+ default_file_store_config=default_file_store_config,
+ default_file_watcher_config={
+ "watch_paths": [
+ str(self.working_path / "MEMORY.md"),
+ str(self.working_path / "memory.md"),
+ str(self.memory_path),
+ ],
+ },
+ )
+
+ # Initialize list to track background summarization tasks
+ self.summary_tasks: list[asyncio.Task] = []
+
+ @staticmethod
+ def calculate_memory_compact_threshold(max_input_length: float, compact_ratio: float) -> int:
+ """Calculate the memory compaction threshold based on input length and ratio.
+
+ Args:
+ max_input_length: Maximum input length in tokens.
+ compact_ratio: Ratio of the input length to use as the threshold.
+
+ Returns:
+ Computed compaction threshold as an integer.
+ """
+ return int(max_input_length * compact_ratio * 0.9)
+
+ def _cleanup_tool_results(self) -> int:
+ """
+ Clean up expired tool result files from the tool result directory.
+
+ This method removes tool result files that have exceeded the retention
+ period specified during initialization. It helps manage disk space by
+ automatically removing old, unused tool outputs.
+
+ Returns:
+ int: The number of files that were successfully deleted
+ """
+ try:
+ # Create a compactor instance with current configuration
+ compactor = ToolResultCompactor(
+ tool_result_dir=self.tool_result_path,
+ tool_result_threshold=self.tool_result_threshold,
+ retention_days=self.retention_days,
+ )
+ # Execute cleanup and return count of deleted files
+ return compactor.cleanup_expired_files()
+ except Exception as e:
+ # Log exception details but return 0 to indicate failure gracefully
+ logger.exception(f"Error cleaning up tool results: {e}")
+ return 0
+
+ async def start(self):
+ """
+ Start the application lifecycle.
+
+ Initializes all application components by calling the parent class start
+ method, then performs initial cleanup of expired tool result files.
+
+ Returns:
+ The result from the parent Application.start() method.
+
+ Note:
+ This method should be called before using any other application
+ functionality. It ensures all services are properly initialized.
+ """
+ result = await super().start()
+ # Perform initial cleanup of any expired tool result files
+ self._cleanup_tool_results()
+ return result
+
+ async def close(self) -> bool:
+ """
+ Close the application and perform cleanup.
+
+ Performs final cleanup of expired tool result files and then shuts down
+ all application components by calling the parent class close method.
+
+ Returns:
+ bool: True if the application was closed successfully, False otherwise.
+
+ Note:
+ This method should be called when the application is no longer needed
+ to ensure proper resource cleanup and data persistence.
+ """
+ # Final cleanup of expired tool result files before shutdown
+ self._cleanup_tool_results()
+ return await super().close()
+
+ async def compact_tool_result(self, messages: list[Msg]) -> list[Msg]:
+ """
+ Compact tool results by truncating large outputs and saving full content to files.
+
+ This method processes a list of messages containing tool results and compacts
+ any that exceed the configured threshold. Large tool outputs are truncated
+ in the message while the full content is saved to separate files for later
+ retrieval if needed.
+
+ Args:
+ messages (list[Msg]): List of messages potentially containing tool results
+ that may need compaction.
+
+ Returns:
+ list[Msg]: The processed list of messages with large tool results compacted.
+ If an error occurs, returns the original unmodified messages.
+
+ Note:
+ - Tool results shorter than tool_result_threshold are left unchanged
+ - Full content of truncated results is saved to tool_result_path
+ - Expired files are automatically cleaned up during this operation
+ """
+ try:
+ # Create compactor with instance configuration
+ compactor = ToolResultCompactor(
+ tool_result_dir=self.tool_result_path,
+ tool_result_threshold=self.tool_result_threshold,
+ retention_days=self.retention_days,
+ )
+
+ # Execute compaction and get processed messages
+ result = await compactor.call(messages=messages, service_context=self.service_context)
+
+ # Clean up any expired tool result files during compaction
+ compactor.cleanup_expired_files()
+
+ return result
+
+ except Exception as e:
+ # Log the error and return original messages to maintain functionality
+ logger.exception(f"Error compacting tool results: {e}")
+ return messages
+
+ async def check_context(
+ self,
+ messages: list[Msg],
+ memory_compact_threshold: int,
+ memory_compact_reserve: int = 10000,
+ token_counter: HuggingFaceTokenCounter | None = None,
+ ) -> tuple[list[Msg], list[Msg], bool]:
+ """
+ Check context size and determine if compaction is needed.
+
+ Analyzes the provided messages to determine if they exceed the configured
+ token threshold and splits them into two groups: messages that should be
+ compacted and messages to keep in context.
+
+ Args:
+ messages (list[Msg]): List of messages to check for context overflow.
+ memory_compact_threshold (int): Token count threshold for triggering
+ compaction. Messages exceeding this threshold will be split.
+ memory_compact_reserve (int): Token count to reserve for recent messages
+ to keep in context. Defaults to 10000 tokens.
+ token_counter (HuggingFaceTokenCounter | None): Token counter for
+ measuring message length. If None, uses default HuggingFace counter.
+
+ Returns:
+ tuple[list[Msg], list[Msg], bool]: A tuple containing:
+ - messages_to_compact (list[Msg]): Older messages that should
+ be compacted/summarized.
+ - messages_to_keep (list[Msg]): Recent messages to keep in context.
+ - is_valid (bool): True if the split is valid (tool calls aligned),
+ False if splitting would break conversation integrity.
+
+ Note:
+ - Returns ([], messages, True) if no compaction is needed.
+ - Ensures conversation pairs (user-assistant) are not split.
+ - is_valid=False indicates tool_use and tool_result are misaligned.
+ """
+ try:
+ if token_counter is None:
+ token_counter = get_hf_token_counter()
+
+ checker = ContextChecker(
+ memory_compact_threshold=memory_compact_threshold,
+ memory_compact_reserve=memory_compact_reserve,
+ token_counter=token_counter,
+ )
+
+ return await checker.call(
+ messages=messages,
+ service_context=self.service_context,
+ )
+
+ except Exception as e:
+ logger.exception(f"Error checking context: {e}")
+ return [], messages, False
+
+ async def compact_memory(
+ self,
+ messages: list[Msg],
+ as_llm: str | ChatModelBase = "default",
+ as_llm_formatter: str | FormatterBase = "default",
+ token_counter: HuggingFaceTokenCounter | None = None,
+ language: str = "zh",
+ max_input_length: float = 128 * 1024,
+ compact_ratio: float = 0.7,
+ previous_summary: str = "",
+ ) -> str:
+ """
+ Compact a list of messages into a condensed summary.
+
+ Uses the configured language model to generate a concise summary of the
+ provided messages. This is useful for reducing context window usage while
+ preserving important information from the conversation history.
+
+ Args:
+ messages (list[Msg]): List of messages to be compacted into a summary.
+ as_llm (str | ChatModelBase): Language model identifier or instance
+ to use for summarization. Defaults to "default".
+ as_llm_formatter (str | FormatterBase): Formatter for the language model.
+ Defaults to "default".
+ token_counter (HuggingFaceTokenCounter | None): Token counter for
+ measuring message length. If None, uses default HuggingFace counter.
+ language (str): Language for the summary output. "zh" for Chinese,
+ any other value for English. Defaults to "zh".
+ max_input_length (float): Maximum input length in tokens for the model.
+ Defaults to 128K tokens.
+ compact_ratio (float): Ratio used to calculate compaction threshold.
+ Defaults to 0.7.
+ previous_summary (str): Previous summary to incorporate into the new
+ summary for continuity. Defaults to empty string.
+
+ Returns:
+ str: The condensed summary of the messages, or an empty string if
+ an error occurred during compaction.
+ """
+ try:
+ if token_counter is None:
+ token_counter = get_hf_token_counter()
+
+ compactor = Compactor(
+ memory_compact_threshold=self.calculate_memory_compact_threshold(max_input_length, compact_ratio),
+ token_counter=token_counter,
+ as_llm=as_llm,
+ as_llm_formatter=as_llm_formatter,
+ language=language if language == "zh" else "",
+ )
+
+ return await compactor.call(
+ messages=messages,
+ previous_summary=previous_summary,
+ service_context=self.service_context,
+ )
+
+ except Exception as e:
+ # Log error and return empty string to indicate failure
+ logger.exception(f"Error compacting memory: {e}")
+ return ""
+
+ async def summary_memory(
+ self,
+ messages: list[Msg],
+ as_llm: str | ChatModelBase = "default",
+ as_llm_formatter: str | FormatterBase = "default",
+ token_counter: HuggingFaceTokenCounter | None = None,
+ toolkit: Toolkit | None = None,
+ language: str = "zh",
+ max_input_length: float = 128 * 1024,
+ compact_ratio: float = 0.7,
+ ) -> str:
+ """
+ Generate a comprehensive summary of the given messages.
+
+ Creates a detailed summary of the conversation history and persists it
+ to the memory directory as structured files. Unlike compact_memory, this
+ method produces more detailed summaries suitable for long-term storage.
+
+ Args:
+ messages (list[Msg]): List of messages to summarize.
+ as_llm (str | ChatModelBase): Language model identifier or instance
+ for summarization. Defaults to "default".
+ as_llm_formatter (str | FormatterBase): Formatter for the language model.
+ Defaults to "default".
+ token_counter (HuggingFaceTokenCounter | None): Token counter for
+ measuring message length. If None, uses default HuggingFace counter.
+ toolkit (Toolkit | None): Toolkit with file operations for persisting
+ summaries. If None, creates a default toolkit with read/write/edit.
+ language (str): Language for the summary output. "zh" for Chinese,
+ any other value for English. Defaults to "zh".
+ max_input_length (float): Maximum input length in tokens.
+ Defaults to 128K tokens.
+ compact_ratio (float): Ratio used to calculate compaction threshold.
+ Defaults to 0.7.
+
+ Returns:
+ str: The generated summary text, or an empty string if an error occurred.
+
+ Note:
+ This method may write summary files to the memory_path directory
+ using the provided or default toolkit.
+ """
+ try:
+ if token_counter is None:
+ token_counter = get_hf_token_counter()
+
+ if toolkit is None:
+ toolkit = Toolkit()
+ file_io = FileIO(working_dir=str(self.working_path))
+ toolkit.register_tool_function(file_io.read)
+ toolkit.register_tool_function(file_io.write)
+ toolkit.register_tool_function(file_io.edit)
+
+ summarizer = Summarizer(
+ working_dir=str(self.working_path),
+ memory_dir=str(self.memory_path),
+ memory_compact_threshold=self.calculate_memory_compact_threshold(max_input_length, compact_ratio),
+ token_counter=token_counter,
+ toolkit=toolkit,
+ as_llm=as_llm,
+ as_llm_formatter=as_llm_formatter,
+ language=language if language == "zh" else "",
+ )
+
+ return await summarizer.call(messages=messages, service_context=self.service_context)
+
+ except Exception as e:
+ logger.exception(f"Error summarizing memory: {e}")
+ return ""
+
+ def add_async_summary_task(self, messages: list[Msg], **kwargs):
+ """
+ Add an asynchronous summary task for the given messages.
+
+ Creates a background task to generate a summary of the provided messages
+ without blocking the main execution flow. Completed tasks are automatically
+ cleaned up from the task list.
+
+ Args:
+ messages (list[Msg]): List of messages to be summarized asynchronously.
+ **kwargs: Additional keyword arguments passed to summary_memory().
+ Supported arguments include:
+ - as_llm: Language model identifier or instance
+ - as_llm_formatter: Formatter for the language model
+ - token_counter: Token counter instance
+ - toolkit: Toolkit for file operations
+ - language: Output language ("zh" or other)
+ - max_input_length: Maximum input token length
+ - compact_ratio: Compaction threshold ratio
+
+ Note:
+ - Completed/failed/canceled tasks are cleaned up before adding new ones
+ - Task results and errors are logged automatically
+ - Use await_summary_tasks() to wait for all pending tasks to complete
+ """
+ remaining_tasks = []
+ for task in self.summary_tasks:
+ if task.done():
+ if task.cancelled():
+ logger.warning("Summary task was cancelled.")
+ continue
+ exc = task.exception()
+ if exc is not None:
+ logger.error(f"Summary task failed: {exc}")
+ else:
+ result = task.result()
+ logger.info(f"Summary task completed: {result}")
+ else:
+ remaining_tasks.append(task)
+ self.summary_tasks = remaining_tasks
+
+ task = asyncio.create_task(self.summary_memory(messages=messages, **kwargs))
+ self.summary_tasks.append(task)
+
+ async def pre_reasoning_hook(
+ self,
+ messages: list[Msg],
+ system_prompt: str = "",
+ compressed_summary: str = "",
+ as_llm: str | ChatModelBase = "default",
+ as_llm_formatter: str | FormatterBase = "default",
+ token_counter: HuggingFaceTokenCounter | None = None,
+ toolkit: Toolkit | None = None,
+ language: str = "zh",
+ max_input_length: float = 128 * 1024,
+ compact_ratio: float = 0.7,
+ memory_compact_reserve: int = 10000,
+ enable_tool_result_compact: bool = True,
+ tool_result_compact_keep_n: int = 3,
+ ) -> tuple[list[Msg], str]:
+ """
+ Hook called before reasoning to manage memory and context.
+
+ This method is designed to be called before each reasoning step to ensure
+ the conversation context fits within model limits. It performs tool result
+ compaction, checks context size, and triggers memory compaction if needed.
+
+ Args:
+ messages (list[Msg]): Current conversation messages to be processed.
+ system_prompt (str): System prompt that will be included in the context.
+ Used to calculate available space. Defaults to empty string.
+ compressed_summary (str): Existing compressed summary from previous
+ compactions. Defaults to empty string.
+ as_llm (str | ChatModelBase): Language model for compaction operations.
+ Defaults to "default".
+ as_llm_formatter (str | FormatterBase): Formatter for the language model.
+ Defaults to "default".
+ token_counter (HuggingFaceTokenCounter | None): Token counter for
+ measuring content length. If None, uses default counter.
+ toolkit (Toolkit | None): Toolkit for file operations in summarization.
+ Defaults to None.
+ language (str): Language for generated summaries. Defaults to "zh".
+ max_input_length (float): Maximum context window size in tokens.
+ Defaults to 128K tokens.
+ compact_ratio (float): Ratio for calculating compaction threshold.
+ Defaults to 0.7.
+ memory_compact_reserve (int): Token count to reserve for new responses.
+ Defaults to 10000 tokens.
+ enable_tool_result_compact (bool): Whether to compact tool results.
+ Defaults to True.
+ tool_result_compact_keep_n (int): Number of recent messages to exclude
+ from tool result compaction. Defaults to 3.
+
+ Returns:
+ tuple[list[Msg], str]: A tuple containing:
+ - list[Msg]: Messages to keep in context (maybe reduced)
+ - str: Updated compressed summary incorporating compacted messages
+
+ Note:
+ - Automatically triggers background summarization for compacted messages
+ - Tool results in recent messages (keep_n) are not compacted
+ - Returns original messages unchanged if no compaction is needed
+ """
+ if token_counter is None:
+ token_counter = get_hf_token_counter()
+
+ msg_handler = AsMsgHandler(token_counter=token_counter)
+
+ system_token_count = msg_handler.count_str_token(system_prompt)
+ compressed_token_count = msg_handler.count_str_token(compressed_summary)
+ memory_compact_threshold = self.calculate_memory_compact_threshold(max_input_length, compact_ratio)
+ left_compact_threshold = memory_compact_threshold - (system_token_count + compressed_token_count)
+ logger.info(f"Left compact threshold: {left_compact_threshold}")
+
+ if enable_tool_result_compact and tool_result_compact_keep_n > 0:
+ compact_msgs = messages[:-tool_result_compact_keep_n]
+ await self.compact_tool_result(compact_msgs)
+
+ messages_to_compact, messages_to_keep, is_valid = await self.check_context(
+ messages=messages,
+ memory_compact_threshold=left_compact_threshold,
+ memory_compact_reserve=memory_compact_reserve,
+ token_counter=token_counter,
+ )
+
+ if not messages_to_compact:
+ return messages, compressed_summary
+
+ if not is_valid:
+ logger.warning("Invalid messages to compact, skipping.")
+ return messages, compressed_summary
+
+ self.add_async_summary_task(
+ messages=messages_to_compact,
+ as_llm=as_llm,
+ as_llm_formatter=as_llm_formatter,
+ token_counter=token_counter,
+ toolkit=toolkit,
+ language=language,
+ max_input_length=max_input_length,
+ compact_ratio=compact_ratio,
+ )
+
+ compressed_summary = await self.compact_memory(
+ messages=messages_to_compact,
+ as_llm=as_llm,
+ as_llm_formatter=as_llm_formatter,
+ token_counter=token_counter,
+ language=language,
+ max_input_length=max_input_length,
+ compact_ratio=compact_ratio,
+ previous_summary=compressed_summary,
+ )
+
+ return messages_to_keep, compressed_summary
+
+ async def await_summary_tasks(self) -> str:
+ """
+ Wait for all background summary tasks to complete and collect results.
+
+ Blocks until all pending summary tasks in the task list have completed,
+ canceled, or failed. Collects status information from each task and
+ clears the task list after processing.
+
+ Returns:
+ str: A concatenated string of status messages for all tasks, including:
+ - Completion confirmations with results
+ - Cancellation notices
+ - Error messages for failed tasks
+
+ Note:
+ - This method will block if any tasks are still running
+ - All tasks are removed from summary_tasks after this call
+ - Task exceptions are logged but do not raise to the caller
+ - Use this before application shutdown to ensure all summaries complete
+ """
+ result = ""
+ for task in self.summary_tasks:
+ if task.done():
+ # Task has already completed, check its status
+ if task.cancelled():
+ logger.warning("Summary task was cancelled.")
+ result += "Summary task was cancelled.\n"
+ else:
+ # Check if the task raised an exception
+ exc = task.exception()
+ if exc is not None:
+ logger.error(f"Summary task failed: {exc}")
+ result += f"Summary task failed: {exc}\n"
+ else:
+ # Task completed successfully, collect result
+ task_result = task.result()
+ logger.info(f"Summary task completed: {task_result}")
+ result += f"Summary task completed: {task_result}\n"
+
+ else:
+ # Task is still running, wait for it to complete
+ try:
+ task_result = await task
+ logger.info(f"Summary task completed: {task_result}")
+ result += f"Summary task completed: {task_result}\n"
+
+ except asyncio.CancelledError:
+ logger.warning("Summary task was cancelled while waiting.")
+ result += "Summary task was cancelled.\n"
+
+ except Exception as e:
+ logger.exception(f"Summary task failed: {e}")
+ result += f"Summary task failed: {e}\n"
+
+ # Clear the task list after processing all tasks
+ self.summary_tasks.clear()
+ return result
+
+ async def memory_search(self, query: str, max_results: int = 5, min_score: float = 0.1) -> ToolResponse:
+ """
+ Mandatory recall step: semantically search MEMORY.md + memory/*.md
+ (and optional session transcripts) before answering questions about
+ prior work, decisions, dates, people, preferences, or todos; returns
+ top snippets with path + lines.
+
+ Args:
+ query (str): The semantic search query to find relevant memory snippets.
+ max_results (int): Maximum number of search results to return (optional), default 5.
+ min_score (float): Minimum similarity score threshold for results (optional), default 0.1.
+
+ Returns:
+ ToolResponse: A ToolResponse containing the search results as text,
+ or an error message if the query is empty.
+ """
+ # Validate query parameter
+ if not query:
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text="Error: No query provided.",
+ ),
+ ],
+ )
+
+ # Validate and clamp max_results to valid range [1, 100]
+ if isinstance(max_results, int):
+ max_results = min(max(max_results, 1), 100)
+
+ elif isinstance(max_results, str):
+ try:
+ max_results = min(max(int(max_results), 1), 100)
+ except ValueError:
+ max_results = 5
+ else:
+ max_results = 5
+
+ # Validate and clamp min_score to valid range [0.001, 0.999]
+ if isinstance(min_score, (int, float)):
+ min_score = float(min(max(min_score, 0.001), 0.999))
+
+ elif isinstance(min_score, str):
+ try:
+ min_score = float(min(max(float(min_score), 0.001), 0.999))
+ except ValueError:
+ min_score = 0.1
+
+ else:
+ min_score = 0.1
+
+ # Initialize memory search tool with configured weights
+ search_tool = MemorySearch(
+ vector_weight=self.vector_weight,
+ candidate_multiplier=self.candidate_multiplier,
+ )
+
+ # Execute the search with validated parameters
+ search_result = await search_tool.call(
+ query=query,
+ max_results=max_results,
+ min_score=min_score,
+ service_context=self.service_context,
+ )
+
+ # Return results wrapped in ToolResponse format
+ return ToolResponse(
+ content=[
+ TextBlock(
+ type="text",
+ text=search_result,
+ ),
+ ],
+ )
+
+ @staticmethod
+ def get_in_memory_memory(token_counter: HuggingFaceTokenCounter | None = None):
+ """
+ Create and return an in-memory memory instance.
+
+ Factory method to create a ReMeInMemoryMemory instance configured with
+ the specified token counter. This memory instance stores data in RAM
+ without persistence, suitable for temporary or session-based storage.
+
+ Args:
+ token_counter (HuggingFaceTokenCounter | None): Token counter for
+ measuring content length in the memory. If None, creates a
+ default HuggingFace token counter.
+
+ Returns:
+ ReMeInMemoryMemory: A new in-memory memory instance ready for use.
+
+ Example:
+ >>> memory = ReMeLight.get_in_memory_memory()
+ >>> # Use memory for temporary storage during a session
+ """
+ if token_counter is None:
+ token_counter = get_hf_token_counter()
+
+ return ReMeInMemoryMemory(token_counter=token_counter)
diff --git a/cookbook/frozenlake/__init__.py b/test/cookbook/__init__.py
similarity index 100%
rename from cookbook/frozenlake/__init__.py
rename to test/cookbook/__init__.py
diff --git a/cookbook/simple_demo/__init__.py b/test/cookbook/appworld/__init__.py
similarity index 100%
rename from cookbook/simple_demo/__init__.py
rename to test/cookbook/appworld/__init__.py
diff --git a/cookbook/appworld/appworld_react_agent.py b/test/cookbook/appworld/appworld_react_agent.py
similarity index 87%
rename from cookbook/appworld/appworld_react_agent.py
rename to test/cookbook/appworld/appworld_react_agent.py
index ba194462..ca8ee2f3 100644
--- a/cookbook/appworld/appworld_react_agent.py
+++ b/test/cookbook/appworld/appworld_react_agent.py
@@ -96,10 +96,7 @@ class AppworldReactAgent:
def prompt_messages(self, run_id, task_index, previous_memories: None, world: AppWorld):
app_descriptions = json.dumps(
- [
- {"name": k, "description": v}
- for (k, v) in world.task.app_descriptions.items()
- ],
+ [{"name": k, "description": v} for (k, v) in world.task.app_descriptions.items()],
indent=1,
)
dictionary = {"supervisor": world.task.supervisor, "app_descriptions": app_descriptions}
@@ -112,7 +109,12 @@ class AppworldReactAgent:
self.retrieved_memory_list[run_id][task_index] = response["metadata"]["memory_list"]
task_memory = response["answer"]
logger.info(f"loaded task_memory: {task_memory}")
- query = "Task:\n" + query + "\n\nSome Related Experience to help you to complete the task:\n" + re.sub(r'(?i)\bMemory\s*(\d+)\s*[:]', r'Experience \1:', task_memory)
+ query = (
+ "Task:\n"
+ + query
+ + "\n\nSome Related Experience to help you to complete the task:\n"
+ + re.sub(r"(?i)\bMemory\s*(\d+)\s*[:]", r"Experience \1:", task_memory)
+ )
else:
formatted_memories = []
for i, memory in enumerate(previous_memories, 1):
@@ -120,14 +122,18 @@ class AppworldReactAgent:
memory_content = memory["content"]
memory_text = f"Experience {i}:\n When to use: {condition}\n Content: {memory_content}\n"
formatted_memories.append(memory_text)
- query = "Task:\n" + query + "\n\nSome Related Experience to help you to complete the task:\n" + "\n".join(formatted_memories)
+ query = (
+ "Task:\n"
+ + query
+ + "\n\nSome Related Experience to help you to complete the task:\n"
+ + "\n".join(formatted_memories)
+ )
messages = [
{"role": "system", "content": sys_prompt},
- {"role": "user", "content": query}
+ {"role": "user", "content": query},
]
self.history[run_id][task_index] = messages
-
@staticmethod
def get_reward(world) -> float:
tracker = world.evaluate()
@@ -136,7 +142,9 @@ class AppworldReactAgent:
return num_passes / (num_passes + num_failures)
def extract_code_and_fix_content(
- self, text: str, ignore_multiple_calls=True
+ self,
+ text: str,
+ ignore_multiple_calls=True,
) -> tuple[str, str]:
full_code_regex = r"```python\n(.*?)```"
partial_code_regex = r".*```python\n(.*)"
@@ -154,7 +162,9 @@ class AppworldReactAgent:
match_end = re_match.end()
# check for partial code match at end (no terminating ```) following the last match
partial_match = re.match(
- partial_code_regex, original_text[match_end:], flags=re.DOTALL
+ partial_code_regex,
+ original_text[match_end:],
+ flags=re.DOTALL,
)
if partial_match:
output_code += partial_match.group(1).strip()
@@ -180,7 +190,12 @@ class AppworldReactAgent:
before_score = self.get_reward(world)
for i in range(self.max_interactions):
if i == 0:
- self.prompt_messages(run_id=run_id, task_index=task_index, previous_memories=previous_memories, world=world)
+ self.prompt_messages(
+ run_id=run_id,
+ task_index=task_index,
+ previous_memories=previous_memories,
+ world=world,
+ )
code_msg = self.call_llm(self.history[run_id][task_index])
code, text = self.extract_code_and_fix_content(code_msg)
self.history[run_id][task_index].append({"role": "assistant", "content": code})
@@ -189,7 +204,9 @@ class AppworldReactAgent:
# if len(output) > self.max_response_size:
# # logger.warning(f"output exceed max size={len(output)}")
# output = output[: self.max_response_size]
- self.history[run_id][task_index].append({"role": "user", "content": "Output:\n```\n" + output + "```\n\n"})
+ self.history[run_id][task_index].append(
+ {"role": "user", "content": "Output:\n```\n" + output + "```\n\n"},
+ )
if world.task_completed():
break
@@ -199,7 +216,9 @@ class AppworldReactAgent:
if self.use_memory:
if self.use_memory_addition:
- new_traj_list = [self.get_traj_from_task_history(task_id, self.history[run_id][task_index], after_score)]
+ new_traj_list = [
+ self.get_traj_from_task_history(task_id, self.history[run_id][task_index], after_score),
+ ]
previous_memories = self.add_memory(new_traj_list)
if after_score != 1:
self.delete_memory_by_ids([mem["memory_id"] for mem in previous_memories])
@@ -209,7 +228,7 @@ class AppworldReactAgent:
self.update_memory_information(self.retrieved_memory_list[run_id][task_index], update_utility)
counter += 1
- if self.use_memory_deletion: # and counter % self.delete_freq == 0:
+ if self.use_memory_deletion: # and counter % self.delete_freq == 0:
self.delete_memory()
t_result = {
@@ -261,7 +280,7 @@ class AppworldReactAgent:
return {
"task_id": task_id,
"messages": task_history,
- "score": reward
+ "score": reward,
}
def add_memory(self, trajectories):
@@ -290,8 +309,8 @@ class AppworldReactAgent:
json={
"workspace_id": self.memory_workspace_id,
"action": "delete_ids",
- "memory_ids": memory_ids
- }
+ "memory_ids": memory_ids,
+ },
)
response.raise_for_status()
@@ -318,6 +337,7 @@ class AppworldReactAgent:
)
response.raise_for_status()
+
def main():
dataset_name = "train"
task_ids = load_task_ids(dataset_name)
diff --git a/cookbook/appworld/prompt.py b/test/cookbook/appworld/prompt.py
similarity index 100%
rename from cookbook/appworld/prompt.py
rename to test/cookbook/appworld/prompt.py
diff --git a/cookbook/appworld/requirements.txt b/test/cookbook/appworld/requirements.txt
similarity index 100%
rename from cookbook/appworld/requirements.txt
rename to test/cookbook/appworld/requirements.txt
diff --git a/cookbook/appworld/run_appworld.py b/test/cookbook/appworld/run_appworld.py
similarity index 98%
rename from cookbook/appworld/run_appworld.py
rename to test/cookbook/appworld/run_appworld.py
index 3379a16a..d286c64a 100644
--- a/cookbook/appworld/run_appworld.py
+++ b/test/cookbook/appworld/run_appworld.py
@@ -90,7 +90,7 @@ def run_agent(
utility_threshold: float = 0.5,
workspace_id: str = "appworld_v1",
api_url: str = "http://0.0.0.0:8002/",
- batch_size: int = 4
+ batch_size: int = 4,
):
experiment_name = dataset_name + "_" + experiment_suffix
path: Path = Path(f"./exp_result/{model_name}")
@@ -125,7 +125,7 @@ def run_agent(
future_list: list = []
for i, task_id in enumerate(batch_task_ids):
actor = AppworldReactAgent.remote(
- index=start_idx+i,
+ index=start_idx + i,
model_name=model_name,
task_ids=[task_id],
experiment_name=experiment_name,
@@ -193,9 +193,10 @@ def run_agent(
result.append(task_results)
dump_file()
+
def main():
max_workers = 8
- num_runs = 1 # Number of runs
+ num_runs = 1 # Number of runs
batch_size = 8 # Number of concurrent tasks per batch
num_trials = 2
@@ -206,7 +207,6 @@ def main():
workspace_id = "appworld"
api_url = "http://0.0.0.0:8002/"
-
# Clean up workspace before starting
logger.info("Deleting workspace...")
delete_workspace(workspace_id=workspace_id, api_url=api_url)
@@ -216,7 +216,6 @@ def main():
logger.info("Start load experiments to build task memories")
load_memory(workspace_id=workspace_id, api_url=api_url)
-
for i in range(num_runs):
run_agent(
model_name=model_name,
@@ -232,8 +231,9 @@ def main():
utility_threshold=0.5,
workspace_id=workspace_id,
api_url=api_url,
- batch_size=batch_size
+ batch_size=batch_size,
)
+
if __name__ == "__main__":
main()
diff --git a/cookbook/appworld/run_exp_statistic.py b/test/cookbook/appworld/run_exp_statistic.py
similarity index 100%
rename from cookbook/appworld/run_exp_statistic.py
rename to test/cookbook/appworld/run_exp_statistic.py
diff --git a/cookbook/tool_memory/__init__.py b/test/cookbook/bfcl/__init__.py
similarity index 100%
rename from cookbook/tool_memory/__init__.py
rename to test/cookbook/bfcl/__init__.py
diff --git a/cookbook/bfcl/bfcl_agent.py b/test/cookbook/bfcl/bfcl_agent.py
similarity index 98%
rename from cookbook/bfcl/bfcl_agent.py
rename to test/cookbook/bfcl/bfcl_agent.py
index 64eb6e6b..2c779a6a 100644
--- a/cookbook/bfcl/bfcl_agent.py
+++ b/test/cookbook/bfcl/bfcl_agent.py
@@ -195,7 +195,7 @@ class BFCLAgent:
# Extract memory list from response
memory_list = result.get("metadata", {}).get("memory_list", [])
- logger.info(f'add new memories: {memory_list}')
+ logger.info(f"add new memories: {memory_list}")
return memory_list
def delete_memory_by_ids(self, memory_ids):
@@ -204,8 +204,8 @@ class BFCLAgent:
json={
"workspace_id": self.memory_workspace_id,
"action": "delete_ids",
- "memory_ids": memory_ids
- }
+ "memory_ids": memory_ids,
+ },
)
response.raise_for_status()
@@ -647,7 +647,9 @@ class BFCLAgent:
reward = self.get_reward(run_id, task_index)
if self.use_memory:
if self.use_memory_addition: # selectively add memories when succeed
- new_traj_list = [self.get_traj_from_task_history(task_id, self.history[run_id][task_index], reward)]
+ new_traj_list = [
+ self.get_traj_from_task_history(task_id, self.history[run_id][task_index], reward),
+ ]
previous_memories = self.add_memory(new_traj_list)
if reward != 1:
self.delete_memory_by_ids([mem["memory_id"] for mem in previous_memories])
diff --git a/cookbook/bfcl/bfcl_utils.py b/test/cookbook/bfcl/bfcl_utils.py
similarity index 100%
rename from cookbook/bfcl/bfcl_utils.py
rename to test/cookbook/bfcl/bfcl_utils.py
diff --git a/cookbook/bfcl/init_exp_pool.py b/test/cookbook/bfcl/init_exp_pool.py
similarity index 100%
rename from cookbook/bfcl/init_exp_pool.py
rename to test/cookbook/bfcl/init_exp_pool.py
diff --git a/cookbook/bfcl/init_task_memory_pool.py b/test/cookbook/bfcl/init_task_memory_pool.py
similarity index 100%
rename from cookbook/bfcl/init_task_memory_pool.py
rename to test/cookbook/bfcl/init_task_memory_pool.py
diff --git a/cookbook/bfcl/local_file_to_library.py b/test/cookbook/bfcl/local_file_to_library.py
similarity index 100%
rename from cookbook/bfcl/local_file_to_library.py
rename to test/cookbook/bfcl/local_file_to_library.py
diff --git a/cookbook/bfcl/requirements.txt b/test/cookbook/bfcl/requirements.txt
similarity index 100%
rename from cookbook/bfcl/requirements.txt
rename to test/cookbook/bfcl/requirements.txt
diff --git a/cookbook/bfcl/run_bfcl.py b/test/cookbook/bfcl/run_bfcl.py
similarity index 99%
rename from cookbook/bfcl/run_bfcl.py
rename to test/cookbook/bfcl/run_bfcl.py
index fd8cc2db..177d91e1 100644
--- a/cookbook/bfcl/run_bfcl.py
+++ b/test/cookbook/bfcl/run_bfcl.py
@@ -91,7 +91,7 @@ def main():
num_runs = 1
num_trials = 2
- model_name="qwen3-8b"
+ model_name = "qwen3-8b"
use_memory = False
use_memory_addition = False
use_memory_deletion = False
diff --git a/cookbook/bfcl/run_exp_statistic.py b/test/cookbook/bfcl/run_exp_statistic.py
similarity index 100%
rename from cookbook/bfcl/run_exp_statistic.py
rename to test/cookbook/bfcl/run_exp_statistic.py
diff --git a/cookbook/bfcl/split_into_trainval.py b/test/cookbook/bfcl/split_into_trainval.py
similarity index 100%
rename from cookbook/bfcl/split_into_trainval.py
rename to test/cookbook/bfcl/split_into_trainval.py
diff --git a/reme/memory/skills/__init__.py b/test/cookbook/frozenlake/__init__.py
similarity index 100%
rename from reme/memory/skills/__init__.py
rename to test/cookbook/frozenlake/__init__.py
diff --git a/cookbook/frozenlake/frozenlake_prompts.yaml b/test/cookbook/frozenlake/frozenlake_prompts.yaml
similarity index 100%
rename from cookbook/frozenlake/frozenlake_prompts.yaml
rename to test/cookbook/frozenlake/frozenlake_prompts.yaml
diff --git a/cookbook/frozenlake/frozenlake_react_agent.py b/test/cookbook/frozenlake/frozenlake_react_agent.py
similarity index 100%
rename from cookbook/frozenlake/frozenlake_react_agent.py
rename to test/cookbook/frozenlake/frozenlake_react_agent.py
diff --git a/cookbook/frozenlake/map_manager.py b/test/cookbook/frozenlake/map_manager.py
similarity index 100%
rename from cookbook/frozenlake/map_manager.py
rename to test/cookbook/frozenlake/map_manager.py
diff --git a/cookbook/frozenlake/run_exp_statistic.py b/test/cookbook/frozenlake/run_exp_statistic.py
similarity index 100%
rename from cookbook/frozenlake/run_exp_statistic.py
rename to test/cookbook/frozenlake/run_exp_statistic.py
diff --git a/cookbook/frozenlake/run_frozenlake.py b/test/cookbook/frozenlake/run_frozenlake.py
similarity index 100%
rename from cookbook/frozenlake/run_frozenlake.py
rename to test/cookbook/frozenlake/run_frozenlake.py
diff --git a/reme/memory/tools/chunk/__init__.py b/test/cookbook/simple_demo/__init__.py
similarity index 100%
rename from reme/memory/tools/chunk/__init__.py
rename to test/cookbook/simple_demo/__init__.py
diff --git a/cookbook/simple_demo/import_usage_demo.py b/test/cookbook/simple_demo/import_usage_demo.py
similarity index 100%
rename from cookbook/simple_demo/import_usage_demo.py
rename to test/cookbook/simple_demo/import_usage_demo.py
diff --git a/cookbook/simple_demo/mcp_task_memory.jsonl b/test/cookbook/simple_demo/mcp_task_memory.jsonl
similarity index 100%
rename from cookbook/simple_demo/mcp_task_memory.jsonl
rename to test/cookbook/simple_demo/mcp_task_memory.jsonl
diff --git a/cookbook/simple_demo/personal_memory.jsonl b/test/cookbook/simple_demo/personal_memory.jsonl
similarity index 100%
rename from cookbook/simple_demo/personal_memory.jsonl
rename to test/cookbook/simple_demo/personal_memory.jsonl
diff --git a/cookbook/simple_demo/task_memory.jsonl b/test/cookbook/simple_demo/task_memory.jsonl
similarity index 100%
rename from cookbook/simple_demo/task_memory.jsonl
rename to test/cookbook/simple_demo/task_memory.jsonl
diff --git a/cookbook/simple_demo/task_messages.jsonl b/test/cookbook/simple_demo/task_messages.jsonl
similarity index 100%
rename from cookbook/simple_demo/task_messages.jsonl
rename to test/cookbook/simple_demo/task_messages.jsonl
diff --git a/cookbook/simple_demo/use_personal_memory_demo.py b/test/cookbook/simple_demo/use_personal_memory_demo.py
similarity index 100%
rename from cookbook/simple_demo/use_personal_memory_demo.py
rename to test/cookbook/simple_demo/use_personal_memory_demo.py
diff --git a/cookbook/simple_demo/use_task_memory_demo.py b/test/cookbook/simple_demo/use_task_memory_demo.py
similarity index 100%
rename from cookbook/simple_demo/use_task_memory_demo.py
rename to test/cookbook/simple_demo/use_task_memory_demo.py
diff --git a/cookbook/simple_demo/use_task_memory_mcp_demo.py b/test/cookbook/simple_demo/use_task_memory_mcp_demo.py
similarity index 100%
rename from cookbook/simple_demo/use_task_memory_mcp_demo.py
rename to test/cookbook/simple_demo/use_task_memory_mcp_demo.py
diff --git a/cookbook/simple_demo/use_tool_memory_demo.py b/test/cookbook/simple_demo/use_tool_memory_demo.py
similarity index 100%
rename from cookbook/simple_demo/use_tool_memory_demo.py
rename to test/cookbook/simple_demo/use_tool_memory_demo.py
diff --git a/reme/memory/tools/history/__init__.py b/test/cookbook/tool_memory/__init__.py
similarity index 100%
rename from reme/memory/tools/history/__init__.py
rename to test/cookbook/tool_memory/__init__.py
diff --git a/cookbook/tool_memory/query.json b/test/cookbook/tool_memory/query.json
similarity index 100%
rename from cookbook/tool_memory/query.json
rename to test/cookbook/tool_memory/query.json
diff --git a/cookbook/tool_memory/run_reme_tool_bench.py b/test/cookbook/tool_memory/run_reme_tool_bench.py
similarity index 100%
rename from cookbook/tool_memory/run_reme_tool_bench.py
rename to test/cookbook/tool_memory/run_reme_tool_bench.py
diff --git a/cookbook/working_memory/react_agent_with_working_memory.py b/test/cookbook/working_memory/react_agent_with_working_memory.py
similarity index 70%
rename from cookbook/working_memory/react_agent_with_working_memory.py
rename to test/cookbook/working_memory/react_agent_with_working_memory.py
index 52e3fdc6..27f2275e 100644
--- a/cookbook/working_memory/react_agent_with_working_memory.py
+++ b/test/cookbook/working_memory/react_agent_with_working_memory.py
@@ -28,9 +28,11 @@ class ReactAgent:
rather than on complex agent logic.
"""
- def __init__(self,
- model_name="",
- max_steps: int = 50):
+ def __init__(
+ self,
+ model_name="",
+ max_steps: int = 50,
+ ):
# You can replace this with your own LLM wrapper if needed.
self.llm = OpenAICompatibleLLM(model_name=model_name)
@@ -65,10 +67,16 @@ class ReactAgent:
# Prepare all available tools from the MCP server.
tool_dict: Dict[str, ToolCall] = {}
- async with FastMcpClient("reme_mcp_server", {
- "type": "sse",
- "url": "http://0.0.0.0:8002/sse",
- }) as mcp_client, HttpClient(base_url="http://localhost:8003") as http_client:
+ async with (
+ FastMcpClient(
+ "reme_mcp_server",
+ {
+ "type": "sse",
+ "url": "http://0.0.0.0:8002/sse",
+ },
+ ) as mcp_client,
+ HttpClient(base_url="http://localhost:8003") as http_client,
+ ):
tool_calls = await mcp_client.list_tool_calls()
for tool_call in tool_calls:
@@ -87,25 +95,30 @@ class ReactAgent:
# - compress long histories,
# - offload detailed context into working memory storage,
# - keep the recent message(s) for short-term reasoning.
- result = await http_client.execute_flow("summary_working_memory",
- messages=[x.simple_dump() for x in messages],
- working_summary_mode="auto",
- compact_ratio_threshold=0.75,
- max_total_tokens=20000,
- max_tool_message_tokens=2000,
- group_token_threshold=None,
- keep_recent_count=1,
- store_dir="./test_working_memory")
+ result = await http_client.execute_flow(
+ "summary_working_memory",
+ messages=[x.simple_dump() for x in messages],
+ working_summary_mode="auto",
+ compact_ratio_threshold=0.75,
+ max_total_tokens=20000,
+ max_tool_message_tokens=2000,
+ group_token_threshold=None,
+ keep_recent_count=1,
+ store_dir="./test_working_memory",
+ )
# Convert the API result back into `Message` objects for the LLM.
messages = [Message(**x) for x in result.answer]
# Ask the LLM what to do next.
# You can plug in your own tool-calling strategy here.
- assistant_message: Message = await self.llm.achat(messages=messages, tools=[
- tool_dict["grep_working_memory"],
- tool_dict["read_working_memory"],
- ])
+ assistant_message: Message = await self.llm.achat(
+ messages=messages,
+ tools=[
+ tool_dict["grep_working_memory"],
+ tool_dict["read_working_memory"],
+ ],
+ )
messages.append(assistant_message)
@@ -118,20 +131,25 @@ class ReactAgent:
logger.exception(f"unknown tool_call.name={tool_call.name}")
continue
- logger.info(f"round{i + 1}.{j} submit tool_calls={tool_call.name} "
- f"argument={tool_call.argument_dict}")
+ logger.info(
+ f"round{i + 1}.{j} submit tool_calls={tool_call.name} " f"argument={tool_call.argument_dict}",
+ )
# Execute the tool via MCP and parse the result.
- result = await mcp_client.call_tool(tool_call.name,
- arguments=tool_call.argument_dict,
- parse_result=True)
+ result = await mcp_client.call_tool(
+ tool_call.name,
+ arguments=tool_call.argument_dict,
+ parse_result=True,
+ )
# Attach the tool result as a TOOL-role message so the LLM
# can see and reason about it in the next step.
- messages.append(Message(
- role=Role.TOOL,
- tool_call_id=tool_call.id,
- content=result,
- ))
+ messages.append(
+ Message(
+ role=Role.TOOL,
+ tool_call_id=tool_call.id,
+ content=result,
+ ),
+ )
return messages
diff --git a/cookbook/working_memory/work_memory_demo.py b/test/cookbook/working_memory/work_memory_demo.py
similarity index 99%
rename from cookbook/working_memory/work_memory_demo.py
rename to test/cookbook/working_memory/work_memory_demo.py
index 612a637a..826cb0f2 100644
--- a/cookbook/working_memory/work_memory_demo.py
+++ b/test/cookbook/working_memory/work_memory_demo.py
@@ -108,7 +108,7 @@ async def main():
logger.info(
f"origin_token_count: {origin_token_count} "
f"after_token_count: {after_token_count} "
- f"compress_ratio={after_token_count / origin_token_count:.2f}"
+ f"compress_ratio={after_token_count / origin_token_count:.2f}",
)
diff --git a/test/test/cli/__init__.py b/test/test/cli/__init__.py
new file mode 100644
index 00000000..2f994785
--- /dev/null
+++ b/test/test/cli/__init__.py
@@ -0,0 +1,18 @@
+"""File-based memory operations."""
+
+from .fb_cli import FbCli
+from .fb_compactor import FbCompactor
+from .fb_context_checker import FbContextChecker
+from .fb_summarizer import FbSummarizer
+from ...core.registry_factory import R
+
+__all__ = [
+ "FbCli",
+ "FbCompactor",
+ "FbContextChecker",
+ "FbSummarizer",
+]
+
+for name in __all__:
+ op_class = globals()[name]
+ R.ops.register(op_class)
diff --git a/reme/memory/file_based/fb_cli.py b/test/test/cli/fb_cli.py
similarity index 100%
rename from reme/memory/file_based/fb_cli.py
rename to test/test/cli/fb_cli.py
diff --git a/reme/memory/file_based/fb_cli.yaml b/test/test/cli/fb_cli.yaml
similarity index 100%
rename from reme/memory/file_based/fb_cli.yaml
rename to test/test/cli/fb_cli.yaml
diff --git a/reme/memory/file_based/fb_compactor.py b/test/test/cli/fb_compactor.py
similarity index 100%
rename from reme/memory/file_based/fb_compactor.py
rename to test/test/cli/fb_compactor.py
diff --git a/reme/memory/file_based/fb_compactor.yaml b/test/test/cli/fb_compactor.yaml
similarity index 100%
rename from reme/memory/file_based/fb_compactor.yaml
rename to test/test/cli/fb_compactor.yaml
diff --git a/reme/memory/file_based/fb_context_checker.py b/test/test/cli/fb_context_checker.py
similarity index 100%
rename from reme/memory/file_based/fb_context_checker.py
rename to test/test/cli/fb_context_checker.py
diff --git a/reme/memory/file_based/fb_summarizer.py b/test/test/cli/fb_summarizer.py
similarity index 100%
rename from reme/memory/file_based/fb_summarizer.py
rename to test/test/cli/fb_summarizer.py
diff --git a/reme/memory/file_based/fb_summarizer.yaml b/test/test/cli/fb_summarizer.yaml
similarity index 100%
rename from reme/memory/file_based/fb_summarizer.yaml
rename to test/test/cli/fb_summarizer.yaml
diff --git a/reme/reme_cli.py b/test/test/reme_cli.py
similarity index 51%
rename from reme/reme_cli.py
rename to test/test/reme_cli.py
index 9255bef1..68469425 100644
--- a/reme/reme_cli.py
+++ b/test/test/reme_cli.py
@@ -3,13 +3,16 @@
import asyncio
import os
import sys
+from pathlib import Path
from typing import AsyncGenerator
from prompt_toolkit import PromptSession
+from .config import ReMeConfigParser
+from .core import Application
from .core.enumeration import ChunkEnum
from .core.op import BaseTool
-from .core.schema import StreamChunk
+from .core.schema import Message, StreamChunk
from .core.tools import (
BashTool,
EditTool,
@@ -21,17 +24,75 @@ from .core.tools import (
TavilySearch,
)
from .core.utils import execute_stream_task, play_horse_easter_egg
-from .memory.file_based import FbCli
-from .memory.tools import MemorySearch
-from .reme_fb import ReMeFb
+from .memory.cli import FbCli, FbCompactor, FbContextChecker, FbSummarizer
+from .memory.tools import MemoryGet, MemorySearch
-class ReMeCli(ReMeFb):
+class ReMeCli(Application):
"""ReMe Cli"""
- def __init__(self, *args, config_path: str = "cli", **kwargs):
+ def __init__(
+ self,
+ *args,
+ working_dir: str = ".reme",
+ config_path: str = "cli",
+ enable_logo: bool = True,
+ log_to_console: bool = True,
+ llm_api_key: str | None = None,
+ llm_base_url: str | None = None,
+ embedding_api_key: str | None = None,
+ embedding_base_url: str | None = None,
+ default_llm_config: dict | None = None,
+ default_embedding_model_config: dict | None = None,
+ default_file_store_config: dict | None = None,
+ default_token_counter_config: dict | None = None,
+ default_file_watcher_config: dict | None = None,
+ context_window_tokens: int = 128000,
+ reserve_tokens: int = 36000,
+ keep_recent_tokens: int = 20000,
+ vector_weight: float = 0.7,
+ candidate_multiplier: float = 3.0,
+ **kwargs,
+ ):
"""Initialize ReMe with config."""
- super().__init__(*args, config_path=config_path, **kwargs)
+ working_path = Path(working_dir)
+ working_path.mkdir(parents=True, exist_ok=True)
+ memory_path = working_path / "memory"
+ memory_path.mkdir(parents=True, exist_ok=True)
+ self.working_dir: str = str(working_path.absolute())
+
+ default_file_watcher_config = default_file_watcher_config or {}
+ if not default_file_watcher_config.get("watch_paths", None):
+ default_file_watcher_config["watch_paths"] = [
+ str(working_path / "MEMORY.md"),
+ str(working_path / "memory.md"),
+ str(memory_path),
+ ]
+ super().__init__(
+ *args,
+ llm_api_key=llm_api_key,
+ llm_base_url=llm_base_url,
+ embedding_api_key=embedding_api_key,
+ embedding_base_url=embedding_base_url,
+ working_dir=working_dir,
+ config_path=config_path,
+ enable_logo=enable_logo,
+ log_to_console=log_to_console,
+ parser=ReMeConfigParser,
+ default_llm_config=default_llm_config,
+ default_embedding_model_config=default_embedding_model_config,
+ default_file_store_config=default_file_store_config,
+ default_token_counter_config=default_token_counter_config,
+ default_file_watcher_config=default_file_watcher_config,
+ **kwargs,
+ )
+
+ self.service_config.metadata.setdefault("context_window_tokens", context_window_tokens)
+ self.service_config.metadata.setdefault("reserve_tokens", reserve_tokens)
+ self.service_config.metadata.setdefault("keep_recent_tokens", keep_recent_tokens)
+ self.service_config.metadata.setdefault("vector_weight", vector_weight)
+ self.service_config.metadata.setdefault("candidate_multiplier", candidate_multiplier)
+
self.commands = {
"/new": "Create a new conversation.",
"/compact": "Compact messages into a summary.",
@@ -40,7 +101,6 @@ class ReMeCli(ReMeFb):
"/help": "Show help.",
"/horse": "A surprise.",
}
- self.working_dir = self.service_config.working_dir
async def chat_with_remy(self, tool_result_max_size: int = 100, **kwargs):
"""Interactive CLI chat with Remy using simple streaming output."""
@@ -210,6 +270,107 @@ class ReMeCli(ReMeFb):
print("\nGoodbye!\n")
+ async def context_check(self, messages: list[Message | dict]) -> dict:
+ """Check if messages exceed context limits."""
+ checker = FbContextChecker(
+ context_window_tokens=self.service_config.metadata["context_window_tokens"],
+ reserve_tokens=self.service_config.metadata["reserve_tokens"],
+ keep_recent_tokens=self.service_config.metadata["keep_recent_tokens"],
+ )
+ return await checker.call(messages=messages, service_context=self.service_context)
+
+ async def compact(
+ self,
+ messages_to_summarize: list[Message | dict] = None,
+ turn_prefix_messages: list[Message | dict] = None,
+ previous_summary: str = "",
+ language: str = "zh",
+ **kwargs,
+ ) -> str | dict:
+ """Compact messages into a summary."""
+ compactor = FbCompactor(language=language, **kwargs)
+ return await compactor.call(
+ messages_to_summarize=messages_to_summarize or [],
+ turn_prefix_messages=turn_prefix_messages or [],
+ previous_summary=previous_summary,
+ service_context=self.service_context,
+ )
+
+ async def summary(
+ self,
+ messages: list[Message | dict],
+ date: str,
+ version: str = "default",
+ language: str = "zh",
+ **kwargs,
+ ) -> str | dict:
+ """Generate a summary of the given messages."""
+ summarizer = FbSummarizer(
+ tools=[
+ BashTool(cwd=self.working_dir),
+ LsTool(cwd=self.working_dir),
+ ReadTool(cwd=self.working_dir),
+ WriteTool(cwd=self.working_dir),
+ EditTool(cwd=self.working_dir),
+ ],
+ working_dir=self.working_dir,
+ language=language,
+ version=version,
+ **kwargs,
+ )
+ return await summarizer.call(messages=messages, date=date, service_context=self.service_context)
+
+ async def memory_search(self, query: str, max_results: int = 5, min_score: float = 0.1) -> str:
+ """
+ Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts)
+ before answering questions about prior work, decisions, dates, people, preferences, or todos;
+ returns top snippets with path + lines.
+
+ Args:
+ query: The semantic search query to find relevant memory snippets
+ max_results: Maximum number of search results to return (optional), default is 5
+ min_score: Minimum similarity score threshold for results (optional), default is 0.1
+
+ Returns:
+ Search results as formatted string
+ """
+ search_tool = MemorySearch(
+ vector_weight=self.service_config.metadata["vector_weight"],
+ candidate_multiplier=self.service_config.metadata["candidate_multiplier"],
+ )
+ return await search_tool.call(
+ query=query,
+ max_results=max_results,
+ min_score=min_score,
+ service_context=self.service_context,
+ )
+
+ async def memory_get(self, path: str, offset: int | None = None, limit: int | None = None) -> str:
+ """
+ Safe snippet read from MEMORY.md, memory/*.md with optional offset/limit;
+ use after memory_search to pull only the needed lines and keep context small.
+
+ Args:
+ path: Path to the memory file to read (relative or absolute)
+ offset: Starting line number (1-indexed, optional)
+ limit: Number of lines to read from the starting line (optional)
+
+ Returns:
+ Memory file content as string
+ """
+ get_tool = MemoryGet(cwd=self.working_dir)
+ return await get_tool.call(path=path, offset=offset, limit=limit, service_context=self.service_context)
+
+ async def needs_compaction(self, messages: list[Message | dict]) -> bool:
+ """Check if messages need compaction based on context window limits."""
+ messages = [Message(**message) if isinstance(message, dict) else message for message in messages]
+ checker = FbContextChecker(
+ context_window_tokens=self.service_config.metadata["context_window_tokens"],
+ reserve_tokens=self.service_config.metadata["reserve_tokens"],
+ )
+ result = await checker.call(messages=messages, service_context=self.service_context)
+ return result["needs_compaction"]
+
async def async_main():
"""Main function for testing the ReMeFs CLI."""
diff --git a/tests/test_fs_compactor.py b/test/test/test_fs_compactor.py
similarity index 99%
rename from tests/test_fs_compactor.py
rename to test/test/test_fs_compactor.py
index 00be5320..1204b17a 100644
--- a/tests/test_fs_compactor.py
+++ b/test/test/test_fs_compactor.py
@@ -6,7 +6,7 @@ which creates compact summaries of conversation history using LLM.
import asyncio
-from reme import ReMeFb
+from reme import ReMeCli
from reme.core.enumeration import Role
from reme.core.schema import Message
@@ -560,7 +560,7 @@ async def test_full_compact_with_summary():
print("TEST: Full Compaction with LLM Summary Generation")
print("=" * 80)
- reme_fs = ReMeFb(
+ reme_fs = ReMeCli(
enable_logo=False,
vector_store=None,
compact_params={
@@ -607,7 +607,7 @@ async def test_realistic_personal_conversation_compact():
print("TEST: Realistic Personal Conversation Compaction")
print("=" * 80)
- reme_fs = ReMeFb(
+ reme_fs = ReMeCli(
enable_logo=False,
vector_store=None,
compact_params={
diff --git a/tests/test_fs_context_checker.py b/test/test/test_fs_context_checker.py
similarity index 99%
rename from tests/test_fs_context_checker.py
rename to test/test/test_fs_context_checker.py
index 14a7fe9f..8a3ed618 100644
--- a/tests/test_fs_context_checker.py
+++ b/test/test/test_fs_context_checker.py
@@ -6,7 +6,7 @@ which determines where to split conversation history when token limits are excee
import asyncio
-from reme import ReMeFb
+from reme import ReMeCli
from reme.core.enumeration import Role
from reme.core.schema import Message
@@ -67,7 +67,7 @@ async def test_no_compaction_needed():
print("TEST 1: Below Threshold - No Cut Point Needed")
print("=" * 80)
- reme_fs = ReMeFb(
+ reme_fs = ReMeCli(
"vector_stores={}", # Override config to disable vector stores
enable_logo=False,
context_window_tokens=5000,
@@ -112,7 +112,7 @@ async def test_compaction_needed_above_threshold():
print("TEST 2: Compaction Needed Above Threshold")
print("=" * 80)
- reme_fs = ReMeFb(
+ reme_fs = ReMeCli(
"vector_stores={}", # Override config to disable vector stores
enable_logo=False,
context_window_tokens=1500,
@@ -181,7 +181,7 @@ async def test_split_turn_scenario():
print("TEST 3: Split Turn - Cut in Middle of Assistant Response")
print("=" * 80)
- reme_fs = ReMeFb(
+ reme_fs = ReMeCli(
"vector_stores={}", # Override config to disable vector stores
enable_logo=False,
context_window_tokens=2000,
diff --git a/tests/test_fs_file_watch_integration.py b/test/test/test_fs_file_watch_integration.py
similarity index 97%
rename from tests/test_fs_file_watch_integration.py
rename to test/test/test_fs_file_watch_integration.py
index 8f47b472..9f679d38 100644
--- a/tests/test_fs_file_watch_integration.py
+++ b/test/test/test_fs_file_watch_integration.py
@@ -1,8 +1,8 @@
-"""Integration test for ReMeFb file watching with memory_search and memory_get.
+"""Integration test for ReMeCli file watching with memory_search and memory_get.
This test demonstrates the complete workflow:
1. Create markdown files with personal information in test_reme folder
-2. Initialize ReMeFb with file watching enabled
+2. Initialize ReMeCli with file watching enabled
3. Start file watching to automatically index files into the database
4. Use memory_search and memory_get to retrieve the indexed content
5. Modify the markdown files
@@ -19,7 +19,7 @@ import json
import shutil
from pathlib import Path
-from reme import ReMeFb
+from reme import ReMeCli
# ==================== Test Configuration ====================
@@ -278,10 +278,10 @@ async def test_file_watch_integration():
test_files = create_test_markdown_files(TestConfig.WORKING_DIR)
print(f"\n✓ Created {len(test_files)} markdown files in {TestConfig.WORKING_DIR}")
- # ==================== STEP 2: Initialize ReMeFb ====================
- print_separator("STEP 2: Initializing ReMeFb with File Watching")
+ # ==================== STEP 2: Initialize ReMeCli ====================
+ print_separator("STEP 2: Initializing ReMeCli with File Watching")
- reme_fs = ReMeFb(
+ reme_fs = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
default_file_store_config={
@@ -299,7 +299,7 @@ async def test_file_watch_integration():
},
)
- print("✓ ReMeFb instance created")
+ print("✓ ReMeCli instance created")
print(f" Working directory: {TestConfig.WORKING_DIR}")
print(f" Watch paths: {TestConfig.WORKING_DIR}, {TestConfig.WORKING_DIR}/memory")
print(" File filters: .md files")
@@ -469,7 +469,7 @@ async def test_file_watch_integration():
print_separator("STEP 10: Cleanup")
await reme_fs.close()
- print("✓ ReMeFb closed")
+ print("✓ ReMeCli closed")
# Clean up test directory
if test_dir.exists():
@@ -489,11 +489,11 @@ async def test_file_watch_integration():
async def main():
"""Run the file watch integration test."""
print("\n" + "=" * 80)
- print(" ReMeFb File Watch Integration Test")
+ print(" ReMeCli File Watch Integration Test")
print("=" * 80)
print("\nThis test validates the complete file watching workflow:")
print(" 1. Create markdown files with personal information")
- print(" 2. Initialize ReMeFb and start file watching")
+ print(" 2. Initialize ReMeCli and start file watching")
print(" 3. Verify automatic indexing into database")
print(" 4. Search and retrieve initial content")
print(" 5. Modify files and verify re-indexing")
diff --git a/tests/test_fs_memory_get.py b/test/test/test_fs_memory_get.py
similarity index 94%
rename from tests/test_fs_memory_get.py
rename to test/test/test_fs_memory_get.py
index d353751d..d6d8f5e3 100644
--- a/tests/test_fs_memory_get.py
+++ b/test/test/test_fs_memory_get.py
@@ -1,6 +1,6 @@
-"""Tests for ReMeFb memory_get interface.
+"""Tests for ReMeCli memory_get interface.
-This module tests the memory_get() method of ReMeFb class which provides
+This module tests the memory_get() method of ReMeCli class which provides
a high-level interface for reading specific snippets from memory files.
The memory_get function should enable the LLM to:
@@ -13,7 +13,7 @@ import asyncio
import os
from pathlib import Path
-from reme import ReMeFb
+from reme import ReMeCli
def print_result(content: str, title: str = "RESULT", max_len: int = 300):
@@ -105,7 +105,7 @@ async def test_memory_get_full_file():
print("=" * 80)
workspace_dir = ".reme_test_get"
- reme_fs = ReMeFb(enable_logo=False, working_dir=workspace_dir)
+ reme_fs = ReMeCli(enable_logo=False, working_dir=workspace_dir)
await reme_fs.start()
# Create test file
@@ -144,7 +144,7 @@ async def test_memory_get_with_offset():
print("=" * 80)
workspace_dir = ".reme_test_get"
- reme_fs = ReMeFb(enable_logo=False, working_dir=workspace_dir)
+ reme_fs = ReMeCli(enable_logo=False, working_dir=workspace_dir)
await reme_fs.start()
test_file_path = "memory/test_profile.md"
@@ -180,7 +180,7 @@ async def test_memory_get_with_offset_and_limit():
print("=" * 80)
workspace_dir = ".reme_test_get"
- reme_fs = ReMeFb(enable_logo=False, working_dir=workspace_dir)
+ reme_fs = ReMeCli(enable_logo=False, working_dir=workspace_dir)
await reme_fs.start()
test_file_path = "memory/test_profile.md"
@@ -219,7 +219,7 @@ async def test_memory_get_beginning_lines():
print("=" * 80)
workspace_dir = ".reme_test_get"
- reme_fs = ReMeFb(enable_logo=False, working_dir=workspace_dir)
+ reme_fs = ReMeCli(enable_logo=False, working_dir=workspace_dir)
await reme_fs.start()
test_file_path = "memory/test_profile.md"
@@ -257,7 +257,7 @@ async def test_memory_get_single_line():
print("=" * 80)
workspace_dir = ".reme_test_get"
- reme_fs = ReMeFb(enable_logo=False, working_dir=workspace_dir)
+ reme_fs = ReMeCli(enable_logo=False, working_dir=workspace_dir)
await reme_fs.start()
test_file_path = "memory/test_profile.md"
@@ -294,7 +294,7 @@ async def test_memory_get_with_absolute_path():
print("=" * 80)
workspace_dir = ".reme_test_get"
- reme_fs = ReMeFb(enable_logo=False, working_dir=workspace_dir)
+ reme_fs = ReMeCli(enable_logo=False, working_dir=workspace_dir)
await reme_fs.start()
# Get absolute path
@@ -324,7 +324,7 @@ async def test_memory_get_with_absolute_path():
async def main():
"""Run core memory_get interface tests."""
print("\n" + "=" * 80)
- print("ReMeFb Memory Get Interface - Tests")
+ print("ReMeCli Memory Get Interface - Tests")
print("=" * 80)
print("\nThis test suite validates that the memory_get() function:")
print(" 1. Reads entire memory files without parameters")
diff --git a/tests/test_fs_memory_search.py b/test/test/test_fs_memory_search.py
similarity index 96%
rename from tests/test_fs_memory_search.py
rename to test/test/test_fs_memory_search.py
index 2a65fd5a..d546f27d 100644
--- a/tests/test_fs_memory_search.py
+++ b/test/test/test_fs_memory_search.py
@@ -1,6 +1,6 @@
-"""Tests for ReMeFb memory_search interface.
+"""Tests for ReMeCli memory_search interface.
-This module tests the memory_search() method of ReMeFb class which provides
+This module tests the memory_search() method of ReMeCli class which provides
a high-level interface for searching personal information stored in memory files.
The memory_search function should enable:
@@ -16,7 +16,7 @@ import hashlib
import shutil
from pathlib import Path
-from reme import ReMeFb
+from reme import ReMeCli
from reme.core.enumeration import MemorySource
from reme.core.schema import FileMetadata, MemoryChunk
@@ -207,8 +207,8 @@ async def test_memory_search_basic():
print("TEST 1: Basic Memory Search")
print("=" * 80)
- # Initialize ReMeFb with unique store name
- reme_fs = ReMeFb(
+ # Initialize ReMeCli with unique store name
+ reme_fs = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
default_file_store_config={
@@ -268,8 +268,8 @@ async def test_memory_search_technical_content():
print("TEST 2: Technical Content Search")
print("=" * 80)
- # Initialize ReMeFb with unique store name
- reme_fs = ReMeFb(
+ # Initialize ReMeCli with unique store name
+ reme_fs = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
default_file_store_config={
@@ -334,8 +334,8 @@ async def test_memory_search_with_source_filter():
print("TEST 3: Memory Search with Source Filter")
print("=" * 80)
- # Initialize ReMeFb with unique store name
- reme_fs = ReMeFb(
+ # Initialize ReMeCli with unique store name
+ reme_fs = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
)
@@ -374,7 +374,7 @@ async def test_memory_search_with_source_filter():
# Search only MEMORY source
print(f"\n--- Searching MEMORY source for: '{query}' ---")
# Create a new instance with MEMORY source filter
- reme_fs_memory = ReMeFb(
+ reme_fs_memory = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
search_params={"sources": [MemorySource.MEMORY]},
@@ -396,7 +396,7 @@ async def test_memory_search_with_source_filter():
# Search only SESSIONS source
print(f"\n--- Searching SESSIONS source for: '{query}' ---")
# Create a new instance with SESSIONS source filter
- reme_fs_sessions = ReMeFb(
+ reme_fs_sessions = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
search_params={"sources": [MemorySource.SESSIONS]},
@@ -437,8 +437,8 @@ async def test_memory_search_score_filtering():
print("TEST 4: Memory Search with Score Filtering")
print("=" * 80)
- # Initialize ReMeFb with unique store name
- reme_fs = ReMeFb(
+ # Initialize ReMeCli with unique store name
+ reme_fs = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
default_file_store_config={
@@ -503,8 +503,8 @@ async def test_memory_search_max_results():
print("TEST 5: Memory Search with Result Limiting")
print("=" * 80)
- # Initialize ReMeFb with unique store name
- reme_fs = ReMeFb(
+ # Initialize ReMeCli with unique store name
+ reme_fs = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
default_file_store_config={
@@ -569,8 +569,8 @@ async def test_memory_search_hybrid_mode():
print("TEST 6: Memory Search with Hybrid Mode")
print("=" * 80)
- # Initialize ReMeFb with unique store name
- reme_fs = ReMeFb(
+ # Initialize ReMeCli with unique store name
+ reme_fs = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
default_file_store_config={
@@ -601,7 +601,7 @@ async def test_memory_search_hybrid_mode():
# Test with hybrid enabled
print(f"\n--- Hybrid search (enabled) for: '{query}' ---")
# Create instance with hybrid enabled
- reme_fs_hybrid = ReMeFb(
+ reme_fs_hybrid = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
default_file_store_config={
@@ -630,7 +630,7 @@ async def test_memory_search_hybrid_mode():
# Test with hybrid disabled (vector only)
print(f"\n--- Vector-only search for: '{query}' ---")
# Create instance with hybrid disabled
- reme_fs_vector = ReMeFb(
+ reme_fs_vector = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
default_file_store_config={
@@ -662,7 +662,7 @@ async def test_memory_search_hybrid_mode():
for vec_weight, text_weight in weight_configs:
# Create instance with specific weights
- reme_fs_weights = ReMeFb(
+ reme_fs_weights = ReMeCli(
enable_logo=False,
working_dir=TestConfig.WORKING_DIR,
default_file_store_config={
@@ -708,7 +708,7 @@ async def cleanup_test_data():
async def main():
"""Run all memory search tests."""
print("\n" + "=" * 80)
- print("ReMeFb Memory Search Interface Tests")
+ print("ReMeCli Memory Search Interface Tests")
print("=" * 80)
print("\nThis test suite validates the memory_search() function:")
print(" 1. Basic semantic search functionality")
diff --git a/tests/test_fs_summary.py b/test/test/test_fs_summary.py
similarity index 96%
rename from tests/test_fs_summary.py
rename to test/test/test_fs_summary.py
index b2701344..8a692500 100644
--- a/tests/test_fs_summary.py
+++ b/test/test/test_fs_summary.py
@@ -1,6 +1,6 @@
-"""ReMeFb summary接口测试。
+"""ReMeCli summary接口测试。
-本模块测试ReMeFb类的summary()方法,该方法提供了
+本模块测试ReMeCli类的summary()方法,该方法提供了
将用户个人信息存储到记忆文件的高级接口。
summary函数应该能够让LLM:
@@ -13,7 +13,7 @@ import asyncio
import shutil
from pathlib import Path
-from reme import ReMeFb
+from reme import ReMeCli
from reme.core.enumeration import Role
from reme.core.schema import Message
@@ -114,7 +114,7 @@ async def test_summary_first_write():
if Path(working_dir).exists():
shutil.rmtree(working_dir)
- reme_fs = ReMeFb(enable_logo=False, working_dir=working_dir, vector_store=None)
+ reme_fs = ReMeCli(enable_logo=False, working_dir=working_dir, vector_store=None)
await reme_fs.start()
# 确保记忆文件已删除
@@ -177,7 +177,7 @@ async def test_summary_complementary_info():
if Path(working_dir).exists():
shutil.rmtree(working_dir)
- reme_fs = ReMeFb(enable_logo=False, working_dir=working_dir, vector_store=None)
+ reme_fs = ReMeCli(enable_logo=False, working_dir=working_dir, vector_store=None)
await reme_fs.start()
# 确保记忆文件已删除
@@ -271,7 +271,7 @@ async def test_summary_conflicting_info():
if Path(working_dir).exists():
shutil.rmtree(working_dir)
- reme_fs = ReMeFb(enable_logo=False, working_dir=working_dir, vector_store=None)
+ reme_fs = ReMeCli(enable_logo=False, working_dir=working_dir, vector_store=None)
await reme_fs.start()
# 确保记忆文件已删除
@@ -353,7 +353,7 @@ async def test_summary_conflicting_info():
async def main():
"""运行时间对齐的summary接口测试。"""
print("\n" + "=" * 80)
- print("ReMeFb Summary接口 - 时间对齐的记忆存储测试")
+ print("ReMeCli Summary接口 - 时间对齐的记忆存储测试")
print("=" * 80)
print("\n本测试套件验证summary()函数:")
print(" 1. 正确处理消息中的time_created字段(%Y-%m-%d %H:%M:%S)")
diff --git a/test/test_agentic_retrieve_op.py b/test/test_agentic_retrieve_op.py
deleted file mode 100644
index 3cb9d851..00000000
--- a/test/test_agentic_retrieve_op.py
+++ /dev/null
@@ -1,113 +0,0 @@
-"""Test script for AgenticRetrieveOp.
-
-This script provides a simple end-to-end test case for AgenticRetrieveOp.
-It can be run directly with: python test_agentic_retrieve_op.py
-"""
-
-import asyncio
-import json
-
-from flowllm.core.enumeration import Role
-from flowllm.core.schema import Message, ToolCall
-from loguru import logger
-
-from reme_ai.agent.react.agentic_retrieve_op import AgenticRetrieveOp
-from reme_ai.main import ReMeApp
-
-
-async def test_agentic_retrieve_basic():
- """Basic test for AgenticRetrieveOp with a short conversation history."""
- logger.info("\n" + "=" * 60)
- logger.info("Test: AgenticRetrieveOp basic behavior")
- logger.info("=" * 60)
-
- tool_call_id = "call_6596dafa2a6a46f7a217da"
- f = open("README.md", encoding="utf-8")
- readme_content = f.read()
- f.close()
-
- messages = [
- Message(
- role=Role.SYSTEM,
- content=(
- "You are a helpful assistant. "
- "请先使用`Grep`匹配关键词或者正则表达式所在行数,然后通过`ReadFile`读取位置附近的代码。"
- "如果没有找到匹配项,永远不要放弃尝试,尝试其他的参数,比如只搜索部分关键词。"
- "`Grep`之后通过 `ReadFile` 命令,你可以从指定偏移位置`offset`+长度`limit`开始查看内容,不要超过100行。"
- "如果当前内容不足,`ReadFile` 命令也可以不断尝试不同的`offset`和`limit`参数"
- ),
- ),
- Message(
- role=Role.USER,
- content="搜索下reme项目的的README内容",
- ),
- Message(
- role=Role.ASSISTANT,
- content="",
- tool_calls=[
- ToolCall(
- **{
- "index": 0,
- "id": tool_call_id,
- "function": {
- "arguments": '{"query": "readme"}',
- "name": "web_search",
- },
- "type": "function",
- },
- ),
- ],
- ),
- Message(
- role=Role.TOOL,
- content=readme_content * 4,
- tool_call_id=tool_call_id,
- ),
- Message(
- role=Role.USER,
- content="根据readme回答task memory在appworld的效果是多少,需要具体的数值",
- ),
- ]
-
- # llm = "qwen3_coder_plus"
- llm = "qwen3_30b_instruct"
- # llm = "qwen3_30b_thinking"
- # llm = "qwen3_coder_30b_instruct"
- # llm = "qwen3_max_instruct"
- op = AgenticRetrieveOp(llm=llm)
-
- await op.async_call(
- messages=[m.model_dump() for m in messages],
- working_summary_mode="auto",
- compact_ratio_threshold=0.75,
- max_total_tokens=20000,
- max_tool_message_tokens=2000,
- group_token_threshold=None,
- keep_recent_count=1,
- store_dir="./test_working_memory",
- chat_id="c123",
- )
-
- answer = op.context.response.answer
- messages = op.context.response.metadata["messages"]
- logger.info(f"✓ AgenticRetrieveOp result answer: {answer}")
- logger.info(f"✓ AgenticRetrieveOp result messages: {json.dumps(messages, ensure_ascii=False, indent=2)}")
- logger.info(f" Success: {op.context.response.success}")
-
-
-async def async_main():
- """Entry point for running AgenticRetrieveOp test."""
- async with ReMeApp():
- logger.info("=" * 80)
- logger.info("Testing AgenticRetrieveOp - ReAct Retrieval Workflow")
- logger.info("=" * 80)
-
- await test_agentic_retrieve_basic()
-
- logger.info("\n" + "=" * 80)
- logger.info("All AgenticRetrieveOp tests completed!")
- logger.info("=" * 80)
-
-
-if __name__ == "__main__":
- asyncio.run(async_main())
diff --git a/test/test_message_compact_op.py b/test/test_message_compact_op.py
deleted file mode 100644
index cd8e4edc..00000000
--- a/test/test_message_compact_op.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""Test script for MessageCompactOp.
-
-This script provides test cases for MessageCompactOp class.
-It can be run directly with: python test_context_compact_op.py
-"""
-
-import asyncio
-
-from flowllm.core.enumeration import Role
-from flowllm.core.schema import Message
-
-from reme_ai.main import ReMeApp
-from reme_ai.retrieve.working import BatchWriteFileOp
-from reme_ai.summary.working import MessageCompactOp
-
-
-async def async_main():
- """Test function for MessageCompactOp."""
- async with ReMeApp():
- # Create test messages with system, user, assistant, tool sequence
- messages = [
- Message(role=Role.SYSTEM, content="You are a helpful assistant."),
- Message(role=Role.USER, content="What is the weather today?"),
- Message(
- role=Role.ASSISTANT,
- content="I'll check the weather for you.",
- ),
- Message(
- role=Role.TOOL,
- content="A" * 5000, # Large tool message that should be compacted
- tool_call_id="call_001",
- ),
- Message(
- role=Role.ASSISTANT,
- content="Let me also check the forecast.",
- ),
- Message(
- role=Role.TOOL,
- content="B" * 5000, # Another large tool message
- tool_call_id="call_002",
- ),
- Message(
- role=Role.USER,
- content="What about tomorrow?",
- ),
- Message(
- role=Role.ASSISTANT,
- content="I'll check tomorrow's weather.",
- ),
- Message(
- role=Role.TOOL,
- content="C" * 5000, # Third large tool message
- tool_call_id="call_003",
- ),
- Message(
- role=Role.TOOL,
- content="Recent result", # Recent tool message (should be kept)
- tool_call_id="call_004",
- ),
- ]
-
- # Create op with lower thresholds for testing
- op = MessageCompactOp() >> BatchWriteFileOp()
-
- # Execute the compaction
- await op.async_call(
- messages=[m.model_dump() for m in messages],
- max_total_tokens=1000, # Low threshold to trigger compaction
- max_tool_message_tokens=100, # Low threshold to compact tool messages
- preview_char_length=50, # Keep 50 chars in preview
- keep_recent_count=1, # Keep 1 recent tool message
- store_dir="./test_compact_storage",
- )
-
- # Print results
- result = op.context.response.answer
- print(f"Context compaction result: {result}")
-
-
-if __name__ == "__main__":
- asyncio.run(async_main())
diff --git a/test/test_message_compress_op.py b/test/test_message_compress_op.py
deleted file mode 100644
index 51423c3a..00000000
--- a/test/test_message_compress_op.py
+++ /dev/null
@@ -1,285 +0,0 @@
-"""
-Test script for MessageCompressOp.
-
-This script demonstrates how to use the message compression operation to reduce
-token usage in conversation histories using language models.
-"""
-
-import asyncio
-
-from loguru import logger
-
-from reme_ai.main import ReMeApp
-from reme_ai.summary.working import MessageCompressOp
-
-
-async def main():
- """Main function to test MessageCompressOp."""
-
- async with ReMeApp():
- logger.info("=" * 80)
- logger.info("Testing MessageCompressOp - LLM-based Context Compression")
- logger.info("=" * 80)
-
- # Create a mock conversation with multiple messages
- messages = [
- {
- "role": "system",
- "content": "You are a helpful AI assistant specialized in software development.",
- },
- {
- "role": "user",
- "content": "I need help building a REST API in Python. I want to use FastAPI.",
- },
- {
- "role": "assistant",
- "content": "Great choice! FastAPI is an excellent framework for building REST APIs. "
- "It's fast, modern, and has automatic API documentation. To get started, you'll need "
- "to install FastAPI and uvicorn. Would you like me to guide you through setting up "
- "your first endpoint?",
- },
- {
- "role": "user",
- "content": "Yes please. I want to create a user management API with CRUD operations.",
- },
- {
- "role": "assistant",
- "content": "Perfect! For a user management API, I recommend this structure:\n"
- "1. Define a User model using Pydantic\n"
- "2. Create POST /users endpoint for creating users\n"
- "3. Create GET /users and GET /users/{id} for reading\n"
- "4. Create PUT /users/{id} for updates\n"
- "5. Create DELETE /users/{id} for deletion\n"
- "We'll also need a database. Would you prefer SQLite, PostgreSQL, or MongoDB?",
- },
- {
- "role": "user",
- "content": "Let's use PostgreSQL. Also, I need JWT authentication.",
- },
- {
- "role": "assistant",
- "content": "Excellent. PostgreSQL is a robust choice. For JWT authentication, we'll use "
- "python-jose library. Here's what we'll implement:\n"
- "1. User registration endpoint\n"
- "2. Login endpoint that returns JWT token\n"
- "3. Protected endpoints that require valid JWT\n"
- "4. Password hashing using bcrypt\n"
- "Let me show you the code for the User model first.",
- },
- {
- "role": "user",
- "content": "Before we proceed, I also need rate limiting and input validation.",
- },
- {
- "role": "assistant",
- "content": "Good thinking! For rate limiting, we can use slowapi library which integrates "
- "well with FastAPI. For input validation, Pydantic (which FastAPI uses) handles most of it, "
- "but we can add custom validators. I'll also add request validation middleware. "
- "Let's start implementing all of this step by step.",
- },
- {
- "role": "user",
- "content": "I need to build a distributed task queue system in Python that can handle millions of tasks"
- " per day. It needs to be horizontally scalable and fault-tolerant.",
- },
- {
- "role": "assistant",
- "content": "That's a challenging but exciting project! For a high-throughput distributed task queue, "
- "I recommend a architecture with:\n\n1. **Message Broker**: Redis or RabbitMQ for task "
- "distribution\n2. **Task Workers**: Multiple worker processes across multiple machines\n3."
- " **Result Backend**: Redis or PostgreSQL for storing task results\n4. **Monitoring**:"
- " Prometheus + Grafana for metrics\n5. **API Layer**: FastAPI for task submission and "
- "status queries\n\nFor the core library, we can build on top of Celery or create a custo"
- "m solution. Would you like me to design the system architecture first, or do you have pr"
- "eferences for specific technologies?",
- },
- {
- "role": "user",
- "content": "I want to build this from scratch without Celery. I need custom features like task"
- " priorities, retry policies with exponential backoff, and task dependencies. Also,"
- " I need it to support both synchronous and asynchronous task execution patterns.",
- },
- {
- "role": "assistant",
- "content": "Excellent! Building from scratch gives us full control. Let me design a comprehensive "
- "system architecture:\n\n**Core Components:**\n1. **Task Router**: Distributes tasks based"
- " on priority queues\n2. **Worker Manager**: Handles worker lifecycle and load balancing\n3."
- " **Retry Engine**: Implements exponential backoff with jitter\n4. **Dependency Graph**:"
- " Manages task dependencies using topological sorting\n5. **State Manager**: Tracks task "
- "states (pending, running, completed, failed)\n6. **Metrics Collector**: Real-time "
- "performance metrics\n\n**Data Structures:**\n- Priority queues using Redis Sorted Sets\n-"
- " Task metadata in PostgreSQL with JSONB columns\n- Distributed locks with Redis Redlock"
- " algorithm\n\n**Features:**\n- At-least-once delivery guarantee\n- Dead letter queue for "
- "failed tasks\n- Rate limiting per task type\n- Circuit breaker pattern for failing "
- "services\n\nWould you like me to start with the core task model and priority queue "
- "implementation?",
- },
- {
- "role": "user",
- "content": "That sounds good. But I also need WebSocket support for real-time task status updates, a "
- "web dashboard for monitoring, and the ability to dynamically scale workers based on queue"
- " depth. Plus, I need task deduplication and exactly-once processing semantics.",
- },
- {
- "role": "assistant",
- "content": "Perfect! Let me enhance the architecture with these advanced requirements:\n\n**Real-time "
- "Features:**\n1. **WebSocket Manager**: Using Python's websocket-server with Redis pub/sub "
- "for multi-instance communication\n2. **Event Stream**: Task state changes published to Re"
- "dis streams\n3. **Client SDK**: JavaScript/TypeScript library for real-time subscriptio"
- "ns\n\n**Auto-scaling Architecture:**\n1. **Metrics Aggregator**: Collects queue depth, "
- "processing latency, error rates\n2. **Scaler Service**: Kubernetes HPA integration or cu"
- "stom Docker Swarm scaling\n3. **Predictive Scaling**: ML model to predict load based on hi"
- "storical patterns\n\n**Exactly-once Processing:**\n1. **Idempotency Keys**: Each task has "
- "UUID-based idempotency key\n2. **Deduplication Store**: Redis with TTL for processed task "
- "IDs\n3. **Transactional Outbox**: PostgreSQL outbox pattern for reliable event publishing"
- "\n4. **Two-phase Commit**: For distributed task processing\n\n**Dashboard Features:**\n- "
- "Real-time queue metrics with D3.js visualizations\n- Task timeline view with dependency g"
- "raphs\n- Worker health monitoring with automatic restart\n- Alert system for SLA violati"
- "ons\n\n**Additional Components:**\n- Task scheduler for delayed tasks using Redis keys w"
- "ith TTL\n- Batch processing support for bulk operations\n- Multi-tenant isolation with na"
- "mespace support\n\nThis is getting quite comprehensive! Should I start with the core tas"
- "k model and database schema, or would you prefer to begin with the WebSocket real-time up"
- "date system?",
- },
- {
- "role": "user",
- "content": "This is getting complex. I also need multi-region support with eventual consistency, the "
- "ability to pause/resume task processing by task type, and A/B testing capabilities for "
- "different worker implementations. Also, I need comprehensive audit logging and GDPR comp"
- "liance features.",
- },
- {
- "role": "assistant",
- "content": "Absolutely! This is evolving into an enterprise-grade system. Let me architect the compl"
- "ete solution:\n\n**Multi-region Architecture:**\n1. **Global Load Balancer**: GeoDNS with"
- " health checks\n2. **Cross-region Replication**: Redis Cluster with active-active setup "
- "using CRDTs\n3. **Conflict Resolution**: Vector clocks for task ordering, last-writer-win"
- "s for metadata\n4. **Region-aware Routing**: Route tasks to workers in same region when p"
- "ossible\n5. **Failover Mechanism**: Automatic region failover with 30-second RTO\n\n**Adv"
- "anced Control Features:**\n1. **Task Type Governance**: \n - Pause/resume via Redis fe"
- "ature flags with immediate propagation\n - Rate limits per task type with burst capaci"
- "ty\n - Resource quotas (CPU/memory) per task category\n2. **A/B Testing Framework**:\n"
- " - Task routing based on consistent hashing of task ID\n - Variant assignment with s"
- "tickiness\n - Statistical significance tracking for performance metrics\n - Automati"
- "c winner selection based on success rate and latency\n\n**Compliance & Audit:**\n1. **A"
- "udit Trail**:\n - Immutable task history in PostgreSQL with row-level security\n -"
- " Change data capture (CDC) using Debezium\n - Cryptographic signing of audit logs\n "
- " - 7-year retention policy with automated archival to S3\n2. **GDPR Compliance**:\n "
- " - Right to be forgotten: Cascade delete with verification\n - Data portability: JSO"
- "N export of all user tasks\n - Consent management: Task processing consent tracking"
- "\n - Data anonymization: PII encryption with rotating keys\n\n**Enhanced Monitoring:*"
- "*\n1. **Distributed Tracing**: OpenTelemetry integration with Jaeger\n2. **Performance "
- "Profiling**: Py-spy continuous profiling\n3. **Cost Analytics**: Per-task-type resource"
- " consumption tracking\n4. **SLA Monitoring**: 99.9% task processing latency targets\n\n"
- "**Security Features:**\n1. **Zero-trust Architecture**: mTLS between all services\n2. **"
- "Secrets Management**: HashiCorp Vault integration\n3. **Network Segmentation**: VPC is"
- "olation with private subnets\n4. **Encryption**: AES-256 at rest, TLS 1.3 in transit\n"
- "\n**Operational Excellence:**\n1. **Blue-green Deployments**: Zero-downtime updates\n2."
- " **Canary Releases**: Gradual rollout of worker changes\n3. **Chaos Engineering**: Rand"
- "om worker termination testing\n4. **Capacity Planning**: Automated infrastructure provi"
- "sioning\n\nThis is becoming a mission-critical system! Would you like me to start with "
- "the database schema and core task lifecycle management, or should we begin with the mul"
- "ti-region replication strategy? I can also create a detailed implementation roadmap wi"
- "th phases if you prefer.",
- },
- {
- "role": "user",
- "content": "How do I reverse a string in Python?",
- },
- {
- "role": "assistant",
- "content": "You can reverse a string in Python using slicing: `reversed_string = original_string[::"
- "-1]`. The `[::-1]` slice notation means start at the end of the string and end at posit"
- "ion 0, moving with the step -1.",
- },
- {
- "role": "user",
- "content": "What's the difference between a list and a tuple?",
- },
- {
- "role": "assistant",
- "content": "Lists are mutable (you can change their content after creation) and use square bracket"
- "s `[]`. Tuples are immutable and use parentheses `()`. Lists are better for collectio"
- "ns that need to change, while tuples are faster and can be used as dictionary keys.",
- },
- {
- "role": "user",
- "content": "How do I handle exceptions in Python?",
- },
- {
- "role": "assistant",
- "content": "Use try-except blocks: `try:` followed by your risky code, then `except SpecificError"
- ":` to catch particular exceptions. Always catch specific exceptions rather than the g"
- "eneric `Exception` when possible. You can also use `finally:` for cleanup code that ru"
- "ns regardless.",
- },
- ]
-
- # Test 1: Messages below threshold (should not compress)
- logger.info("\n" + "=" * 60)
- logger.info("Test 1: Messages below threshold (should skip compression)")
- logger.info("=" * 60)
-
- compress_op1 = MessageCompressOp()
-
- await compress_op1.async_call(
- messages=messages,
- max_total_tokens=50000, # High threshold, won't trigger
- keep_recent_count=2,
- )
-
- result_messages1 = compress_op1.context.response.answer
- logger.info(f"✓ Result: {len(result_messages1)} messages (unchanged)")
-
- # Test 2: Messages above threshold (should compress)
- logger.info("\n" + "=" * 60)
- logger.info("Test 2: Messages above threshold (should compress)")
- logger.info("=" * 60)
-
- compress_op2 = MessageCompressOp()
-
- await compress_op2.async_call(
- messages=messages,
- max_total_tokens=2000, # Low threshold, will trigger
- keep_recent_count=2,
- compress_system_message=False,
- )
-
- result_messages2 = compress_op2.context.response.answer
- logger.info(f"✓ Result: {len(result_messages2)} messages (compressed)")
-
- # Display compression results
- logger.info("\n" + "=" * 60)
- logger.info("Compression Result Details:")
- logger.info("=" * 60)
- logger.info(f"Original messages: {len(messages)}")
- logger.info(f"Compressed messages: {len(result_messages2)}")
-
- # Test 3: Messages above threshold (should compress)
- logger.info("\n" + "=!" * 30)
- logger.info("Test 3: Messages above micro threshold (should compress)")
- logger.info("=!" * 30)
-
- compress_op2 = MessageCompressOp()
-
- await compress_op2.async_call(
- messages=messages,
- max_total_tokens=2000, # Low threshold, will trigger
- keep_recent_count=2,
- compress_system_message=False, # Don't compress system messages
- group_token_threshold=1500,
- )
-
- result_messages2 = compress_op2.context.response.answer
- logger.info(f"✓ Result: {len(result_messages2)} messages (compressed)")
-
- # Display compression results
- logger.info("\n" + "=" * 60)
- logger.info("Compression Result Details:")
- logger.info("=" * 60)
- logger.info(f"Original messages: {len(messages)}")
- logger.info(f"Compressed messages: {len(result_messages2)}")
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/test/test_message_offload_op.py b/test/test_message_offload_op.py
deleted file mode 100644
index 403e89fd..00000000
--- a/test/test_message_offload_op.py
+++ /dev/null
@@ -1,247 +0,0 @@
-"""Test script for MessageOffloadOp.
-
-This script provides test cases for MessageOffloadOp class.
-It can be run directly with: python test_context_offload_op.py
-"""
-
-import asyncio
-
-from flowllm.core.enumeration import Role
-from flowllm.core.schema import Message
-from loguru import logger
-
-from reme_ai.enumeration import WorkingSummaryMode
-from reme_ai.main import ReMeApp
-from reme_ai.retrieve.working import BatchWriteFileOp
-from reme_ai.summary.working import MessageOffloadOp
-
-
-async def test_compact_mode():
- """Test COMPACT mode - Only apply compaction with MessageOffloadOp."""
- logger.info("\n" + "=" * 60)
- logger.info("Test: COMPACT mode - Only apply compaction")
- logger.info("=" * 60)
-
- # Create test messages with system, user, assistant, tool sequence
- messages = [
- Message(role=Role.SYSTEM, content="You are a helpful assistant."),
- Message(role=Role.USER, content="What is the weather today?"),
- Message(
- role=Role.ASSISTANT,
- content="I'll check the weather for you.",
- ),
- Message(
- role=Role.TOOL,
- content="A" * 5000, # Large tool message that should be compacted
- tool_call_id="call_001",
- ),
- Message(
- role=Role.ASSISTANT,
- content="Let me also check the forecast.",
- ),
- Message(
- role=Role.TOOL,
- content="B" * 5000, # Another large tool message
- tool_call_id="call_002",
- ),
- Message(
- role=Role.USER,
- content="What about tomorrow?",
- ),
- Message(
- role=Role.ASSISTANT,
- content="I'll check tomorrow's weather.",
- ),
- Message(
- role=Role.TOOL,
- content="C" * 5000, # Third large tool message
- tool_call_id="call_003",
- ),
- Message(
- role=Role.TOOL,
- content="Recent result", # Recent tool message (should be kept)
- tool_call_id="call_004",
- ),
- ]
-
- op = MessageOffloadOp() >> BatchWriteFileOp()
-
- await op.async_call(
- messages=[m.model_dump() for m in messages],
- context_manage_mode=WorkingSummaryMode.COMPACT,
- max_total_tokens=1000, # Low threshold to trigger compaction
- max_tool_message_tokens=100, # Low threshold to compact tool messages
- preview_char_length=50, # Keep 50 chars in preview
- keep_recent_count=1, # Keep 1 recent tool message
- store_dir="./test_compact_storage",
- )
-
- result = op.context.response.answer
- logger.info(f"✓ COMPACT mode result: {len(result)} messages")
- logger.info(f" Success: {op.context.response.success}")
-
-
-async def test_compress_mode():
- """Test COMPRESS mode - Only apply compression with MessageOffloadOp."""
- logger.info("\n" + "=" * 60)
- logger.info("Test: COMPRESS mode - Only apply compression")
- logger.info("=" * 60)
-
- # Create test messages with system, user, assistant, tool sequence
- messages = [
- Message(role=Role.SYSTEM, content="You are a helpful assistant."),
- Message(role=Role.USER, content="What is the weather today?"),
- Message(
- role=Role.ASSISTANT,
- content="I'll check the weather for you.",
- ),
- Message(
- role=Role.TOOL,
- content="A" * 5000, # Large tool message that should be compacted
- tool_call_id="call_001",
- ),
- Message(
- role=Role.ASSISTANT,
- content="Let me also check the forecast.",
- ),
- Message(
- role=Role.TOOL,
- content="B" * 5000, # Another large tool message
- tool_call_id="call_002",
- ),
- Message(
- role=Role.USER,
- content="What about tomorrow?",
- ),
- Message(
- role=Role.ASSISTANT,
- content="I'll check tomorrow's weather.",
- ),
- Message(
- role=Role.TOOL,
- content="C" * 5000, # Third large tool message
- tool_call_id="call_003",
- ),
- Message(
- role=Role.TOOL,
- content="Recent result", # Recent tool message (should be kept)
- tool_call_id="call_004",
- ),
- ]
-
- op = MessageOffloadOp() >> BatchWriteFileOp()
-
- await op.async_call(
- messages=[m.model_dump() for m in messages],
- context_manage_mode=WorkingSummaryMode.COMPRESS,
- max_total_tokens=2000, # Low threshold to trigger compression
- keep_recent_count=2,
- store_dir="./test_compact_storage",
- )
-
- result = op.context.response.answer
- logger.info(f"✓ COMPRESS mode result: {len(result)} messages")
- logger.info(f" Success: {op.context.response.success}")
-
-
-async def test_auto_mode():
- """Test AUTO mode - Apply compaction first, then compression if needed using MessageOffloadOp."""
- logger.info("\n" + "=" * 60)
- logger.info("Test: AUTO mode - Apply compaction first, then compression if needed")
- logger.info("=" * 60)
-
- # Create messages with extensive user content to ensure compact ratio exceeds threshold
- auto_messages = [
- Message(role=Role.SYSTEM, content="You are a helpful assistant."),
- Message(role=Role.USER, content="What is the weather today?"),
- Message(
- role=Role.ASSISTANT,
- content="I'll check the weather for you.",
- ),
- Message(
- role=Role.TOOL,
- content="A" * 5000, # Large tool message that should be compacted
- tool_call_id="call_001",
- ),
- Message(
- role=Role.USER,
- content="I need detailed information about the weather forecast for the next week. "
- "Please provide temperature, humidity, wind speed, and precipitation chances for each day. "
- "Also, I want to know about any weather warnings or advisories. "
- "This is very important for my travel planning." * 50, # Long user message
- ),
- Message(
- role=Role.ASSISTANT,
- content="I'll gather comprehensive weather information for you. Let me check multiple sources." * 3,
- ),
- Message(
- role=Role.TOOL,
- content="B" * 5000, # Another large tool message
- tool_call_id="call_002",
- ),
- Message(
- role=Role.USER,
- content="Can you also provide information about air quality, UV index, and sunrise/sunset times? "
- "I'm planning outdoor activities and need to know the best times to be outside. "
- "Also, please include historical weather data for comparison." * 4, # More long user content
- ),
- Message(
- role=Role.ASSISTANT,
- content="Absolutely! I'll get all that information for you including air quality metrics and UV data." * 2,
- ),
- Message(
- role=Role.TOOL,
- content="C" * 5000, # Third large tool message
- tool_call_id="call_003",
- ),
- Message(
- role=Role.USER,
- content="What about tomorrow?",
- ),
- Message(
- role=Role.ASSISTANT,
- content="I'll check tomorrow's weather.",
- ),
- Message(
- role=Role.TOOL,
- content="Recent result", # Recent tool message (should be kept)
- tool_call_id="call_004",
- ),
- ]
-
- op = MessageOffloadOp() >> BatchWriteFileOp()
-
- await op.async_call(
- messages=[m.model_dump() for m in auto_messages],
- context_manage_mode=WorkingSummaryMode.AUTO,
- compact_ratio_threshold=0.2, # Low threshold, should trigger compression after compact
- max_total_tokens=1000,
- max_tool_message_tokens=100,
- preview_char_length=50,
- keep_recent_count=1,
- store_dir="./test_compact_storage",
- )
-
- result = op.context.response.answer
- logger.info(f"✓ AUTO mode result: {len(result)} messages")
- logger.info(f" Success: {op.context.response.success}")
-
-
-async def async_main():
- """Test function for MessageOffloadOp."""
- async with ReMeApp():
- logger.info("=" * 80)
- logger.info("Testing MessageOffloadOp - Context Management Orchestration")
- logger.info("=" * 80)
-
- await test_compact_mode()
- await test_compress_mode()
- await test_auto_mode()
-
- logger.info("\n" + "=" * 80)
- logger.info("All tests completed!")
- logger.info("=" * 80)
-
-
-if __name__ == "__main__":
- asyncio.run(async_main())
diff --git a/tests/light/test_compactor.py b/tests/light/test_compactor.py
new file mode 100644
index 00000000..cb3c9dee
--- /dev/null
+++ b/tests/light/test_compactor.py
@@ -0,0 +1,371 @@
+"""Tests for Compactor."""
+
+import asyncio
+
+from agentscope.message import Msg
+from test_utils import (
+ get_dash_chat_model,
+ get_formatter,
+ get_token_counter,
+)
+
+from reme.core.utils import get_std_logger
+from reme.memory.file_based.components import Compactor
+
+logger = get_std_logger()
+
+
+# ANSI 颜色码
+class Colors:
+ """ANSI color codes for terminal output."""
+
+ GREEN = "\033[92m"
+ RED = "\033[91m"
+ YELLOW = "\033[93m"
+ BLUE = "\033[94m"
+ CYAN = "\033[96m"
+ BOLD = "\033[1m"
+ RESET = "\033[0m"
+
+
+def print_pass(test_name: str):
+ """打印测试通过信息"""
+ print(f"{Colors.GREEN}{Colors.BOLD}✓ {test_name} PASSED{Colors.RESET}")
+
+
+def print_fail(test_name: str, error: str):
+ """打印测试失败信息"""
+ print(f"{Colors.RED}{Colors.BOLD}✗ {test_name} FAILED: {error}{Colors.RESET}")
+
+
+def print_error(test_name: str, error: str):
+ """打印测试错误信息"""
+ print(f"{Colors.YELLOW}{Colors.BOLD}⚠ {test_name} ERROR: {error}{Colors.RESET}")
+
+
+def print_test_header(test_name: str):
+ """打印测试标题"""
+ print(f"\n{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.BLUE}{Colors.BOLD}Running: {test_name}{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+
+
+def create_user_msg(content: str) -> Msg:
+ """Create a user message."""
+ return Msg(name="user", role="user", content=content)
+
+
+def create_assistant_msg(content: str) -> Msg:
+ """Create an assistant message."""
+ return Msg(name="assistant", role="assistant", content=content)
+
+
+def create_tool_use_msg(tool_name: str, tool_input: dict) -> Msg:
+ """Create a message with tool_use content block."""
+ return Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ {
+ "type": "tool_use",
+ "id": "call_123",
+ "name": tool_name,
+ "input": tool_input,
+ },
+ ],
+ )
+
+
+def create_tool_result_msg(tool_name: str, output: str) -> Msg:
+ """Create a message with tool_result content block."""
+ return Msg(
+ name="tool",
+ role="user",
+ content=[
+ {
+ "type": "tool_result",
+ "id": "call_123",
+ "name": tool_name,
+ "output": output,
+ },
+ ],
+ )
+
+
+def create_compactor():
+ """Create a Compactor instance for testing."""
+ return Compactor(
+ memory_compact_threshold=4000,
+ token_counter=get_token_counter(),
+ as_llm=get_dash_chat_model(),
+ as_llm_formatter=get_formatter(),
+ language="zh",
+ )
+
+
+def test_empty_messages():
+ """Test that empty messages return empty string."""
+ compactor = create_compactor()
+ result = asyncio.run(compactor.call(messages=[]))
+ assert result == "", f"Expected empty string, got: {result}"
+ print("test_empty_messages PASSED")
+
+
+def test_short_conversation():
+ """Test compaction of a short conversation."""
+ compactor = create_compactor()
+ messages = [
+ create_user_msg("Hello, I need help with Python."),
+ create_assistant_msg("Sure, I'd be happy to help. What do you need?"),
+ create_user_msg("How do I read a file?"),
+ create_assistant_msg("You can use open() function: with open('file.txt', 'r') as f: content = f.read()"),
+ ]
+
+ logger.info(f"Input messages count: {len(messages)}")
+ for i, msg in enumerate(messages):
+ logger.debug(
+ f"Message {i}: role={msg.role}, content="
+ f"{msg.content[:50] if isinstance(msg.content, str) else msg.content}...",
+ )
+
+ result = asyncio.run(compactor.call(messages=messages))
+
+ logger.info(f"Result type: {type(result)}")
+ logger.info(f"Result: {result}")
+
+ assert result, "Result should not be empty"
+ assert isinstance(result, str), f"Result should be string, got: {type(result)}"
+ assert "##" in result, "Result should have markdown headers"
+ print_pass("test_short_conversation")
+
+
+def test_medium_conversation():
+ """Test compaction of a medium-length conversation with tool calls."""
+ compactor = create_compactor()
+ messages = [
+ create_user_msg("Help me create a Python script to process data."),
+ create_assistant_msg("I'll help you create a data processing script. Let me first check the data format."),
+ create_tool_use_msg("read_file", {"path": "/data/input.csv"}),
+ create_tool_result_msg("read_file", "id,name,value\n1,Alice,100\n2,Bob,200\n3,Charlie,300"),
+ create_assistant_msg(
+ "I see the data is in CSV format. Here's a script to process it:\n"
+ "```python\nimport csv\n\ndef process_data(filepath):\n"
+ " with open(filepath, 'r') as f:\n reader = csv.DictReader(f)\n"
+ " return [row for row in reader]\n```",
+ ),
+ create_user_msg("Can you add a filter function?"),
+ create_assistant_msg(
+ "Sure, here's the updated script with filtering:\n"
+ "```python\ndef filter_by_value(data, min_value):\n"
+ " return [row for row in data if int(row['value']) >= min_value]\n```",
+ ),
+ ]
+
+ result = asyncio.run(compactor.call(messages=messages))
+
+ assert result, "Result should not be empty"
+ assert isinstance(result, str), f"Result should be string, got: {type(result)}"
+ assert "##" in result, "Result should have markdown headers"
+ print_pass("test_medium_conversation")
+
+
+def test_long_conversation():
+ """Test compaction of a long conversation that exceeds threshold."""
+ compactor = create_compactor()
+ messages = []
+ for i in range(100000):
+ messages.append(create_user_msg(f"Question {i}: How do I implement feature {i}?"))
+ messages.append(
+ create_assistant_msg(
+ f"Answer {i}: Here's how to implement feature {i}. " * 20,
+ ),
+ )
+
+ result = asyncio.run(compactor.call(messages=messages))
+
+ assert result, "Result should not be empty"
+ assert isinstance(result, str), f"Result should be string, got: {type(result)}"
+ assert "##" in result, "Result should have markdown headers"
+ print_pass("test_long_conversation")
+
+
+def test_with_previous_summary():
+ """Test compaction with an existing previous summary."""
+ compactor = create_compactor()
+ previous_summary = """## Goal
+User wants to build a REST API with FastAPI.
+
+## Constraints & Preferences
+- Use Python 3.10+
+- Follow RESTful best practices
+
+## Progress
+### Done
+- [x] Set up project structure
+- [x] Created main.py with basic FastAPI app
+
+### In Progress
+- [ ] Add user authentication
+
+### Blocked
+- (none)
+
+## Key Decisions
+- **Framework**: FastAPI for performance and type hints
+
+## Next Steps
+1. Implement JWT authentication
+2. Add user endpoints
+
+## Critical Context
+- Using SQLAlchemy for database
+- PostgreSQL as database backend
+"""
+
+ messages = [
+ create_user_msg("Let's implement the JWT authentication now."),
+ create_assistant_msg("I'll implement JWT authentication. First, let me install the required packages."),
+ create_tool_use_msg("run_command", {"command": "pip install python-jose[cryptography] passlib[bcrypt]"}),
+ create_tool_result_msg("run_command", "Successfully installed python-jose-3.3.0 passlib-1.7.4"),
+ create_assistant_msg("Dependencies installed. Now let's create the auth module with JWT token generation."),
+ ]
+
+ result = asyncio.run(
+ compactor.call(
+ messages=messages,
+ previous_summary=previous_summary,
+ ),
+ )
+
+ assert result, "Result should not be empty"
+ assert isinstance(result, str), f"Result should be string, got: {type(result)}"
+ assert "##" in result, "Result should have markdown headers"
+ print_pass("test_with_previous_summary")
+
+
+def test_conversation_with_multiple_tool_calls():
+ """Test compaction of conversation with multiple sequential tool calls."""
+ compactor = create_compactor()
+ messages = [
+ create_user_msg("Help me debug this Python script that's failing."),
+ create_assistant_msg("Let me check the script first."),
+ create_tool_use_msg("read_file", {"path": "/app/main.py"}),
+ create_tool_result_msg(
+ "read_file",
+ "def process():\n data = load_data()\n result = analyze(data)\n return result",
+ ),
+ create_tool_use_msg("read_file", {"path": "/app/utils.py"}),
+ create_tool_result_msg("read_file", "def load_data():\n return open('data.json').read()"),
+ create_tool_use_msg("run_command", {"command": "python /app/main.py"}),
+ create_tool_result_msg("run_command", "FileNotFoundError: [Errno 2] No such file or directory: 'data.json'"),
+ create_assistant_msg(
+ "I found the issue! The script is looking for 'data.json' "
+ "in the current directory instead of an absolute path.",
+ ),
+ create_user_msg("How should I fix it?"),
+ create_assistant_msg(
+ "Update load_data() to use an absolute path:\n"
+ "```python\nimport os\ndef load_data():\n"
+ " script_dir = os.path.dirname(__file__)\n"
+ " return open(os.path.join(script_dir, 'data.json')).read()\n```",
+ ),
+ ]
+
+ result = asyncio.run(compactor.call(messages=messages))
+
+ assert result, "Result should not be empty"
+ assert isinstance(result, str), f"Result should be string, got: {type(result)}"
+ print_pass("test_conversation_with_multiple_tool_calls")
+
+
+def test_low_threshold():
+ """Test compaction with low memory threshold."""
+ compactor = Compactor(
+ memory_compact_threshold=500,
+ token_counter=get_token_counter(),
+ as_llm=get_dash_chat_model(),
+ as_llm_formatter=get_formatter(),
+ )
+
+ messages = [
+ create_user_msg("Build a web scraper."),
+ create_assistant_msg("I'll create a web scraper using BeautifulSoup and requests."),
+ create_user_msg("Make it handle pagination."),
+ create_assistant_msg("Here's the paginated scraper implementation with error handling."),
+ ]
+
+ result = asyncio.run(compactor.call(messages=messages))
+
+ assert result, "Result should not be empty"
+ assert isinstance(result, str), f"Result should be string, got: {type(result)}"
+ print("test_low_threshold PASSED")
+
+
+def test_high_threshold():
+ """Test compaction with high memory threshold."""
+ compactor = Compactor(
+ memory_compact_threshold=10000,
+ token_counter=get_token_counter(),
+ as_llm=get_dash_chat_model(),
+ as_llm_formatter=get_formatter(),
+ )
+
+ messages = [
+ create_user_msg("Create a calculator class."),
+ create_assistant_msg("Here's a Calculator class with basic operations: add, subtract, multiply, divide."),
+ ]
+
+ result = asyncio.run(compactor.call(messages=messages))
+
+ assert result, "Result should not be empty"
+ assert isinstance(result, str), f"Result should be string, got: {type(result)}"
+ print("test_high_threshold PASSED")
+
+
+def run_all_tests():
+ """Run all tests."""
+ tests = [
+ test_empty_messages,
+ test_short_conversation,
+ test_medium_conversation,
+ test_long_conversation,
+ test_with_previous_summary,
+ test_conversation_with_multiple_tool_calls,
+ test_low_threshold,
+ test_high_threshold,
+ ]
+
+ passed = 0
+ failed = 0
+
+ for test in tests:
+ try:
+ print_test_header(test.__name__)
+ test()
+ passed += 1
+ except AssertionError as e:
+ print_fail(test.__name__, str(e))
+ failed += 1
+ except Exception as e:
+ print_error(test.__name__, str(e))
+ failed += 1
+
+ # 打印最终统计结果
+ print(f"\n{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.BOLD}Test Results Summary{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.GREEN}{Colors.BOLD}✓ Passed: {passed}{Colors.RESET}")
+ if failed > 0:
+ print(f"{Colors.RED}{Colors.BOLD}✗ Failed: {failed}{Colors.RESET}")
+ else:
+ print(f"{Colors.GREEN}✗ Failed: {failed}{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+
+ if failed == 0:
+ print(f"\n{Colors.GREEN}{Colors.BOLD}🎉 All tests passed!{Colors.RESET}")
+ else:
+ print(f"\n{Colors.RED}{Colors.BOLD}💥 Some tests failed!{Colors.RESET}")
+
+
+if __name__ == "__main__":
+ run_all_tests()
diff --git a/tests/light/test_context_check.py b/tests/light/test_context_check.py
new file mode 100644
index 00000000..934a43f2
--- /dev/null
+++ b/tests/light/test_context_check.py
@@ -0,0 +1,1285 @@
+"""Tests for AsMsgHandler.context_check method."""
+
+from agentscope.message import Msg
+from test_utils import get_token_counter
+
+from reme.core.utils import get_std_logger
+from reme.memory.file_based.utils import AsMsgHandler
+
+logger = get_std_logger()
+
+
+# ANSI color codes
+class Colors:
+ """ANSI color codes for terminal output."""
+
+ GREEN = "\033[92m"
+ RED = "\033[91m"
+ YELLOW = "\033[93m"
+ BLUE = "\033[94m"
+ CYAN = "\033[96m"
+ BOLD = "\033[1m"
+ RESET = "\033[0m"
+
+
+def print_pass(test_name: str):
+ """Print test passed message."""
+ print(f"{Colors.GREEN}{Colors.BOLD}✓ {test_name} PASSED{Colors.RESET}")
+
+
+def print_fail(test_name: str, error: str):
+ """Print test failed message."""
+ print(f"{Colors.RED}{Colors.BOLD}✗ {test_name} FAILED: {error}{Colors.RESET}")
+
+
+def print_error(test_name: str, error: str):
+ """Print test error message."""
+ print(f"{Colors.YELLOW}{Colors.BOLD}⚠ {test_name} ERROR: {error}{Colors.RESET}")
+
+
+def print_test_header(test_name: str):
+ """Print test header."""
+ print(f"\n{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.BLUE}{Colors.BOLD}Running: {test_name}{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+
+
+def create_handler() -> AsMsgHandler:
+ """Create an AsMsgHandler instance for testing."""
+ return AsMsgHandler(token_counter=get_token_counter())
+
+
+def verify_context_check_invariants(
+ handler: AsMsgHandler,
+ messages: list[Msg],
+ to_compact: list[Msg],
+ to_keep: list[Msg],
+ memory_compact_threshold: int,
+ memory_compact_reserve: int,
+ test_name: str,
+):
+ """Verify that context_check results satisfy all invariants.
+
+ This function checks:
+ 1. Threshold requirement: If total tokens <= threshold, no compaction should occur
+ 2. Reserve requirement: Kept messages' total tokens should not exceed reserve
+ 3. Order requirement: Both to_compact and to_keep should preserve original order
+
+ Args:
+ handler: The AsMsgHandler instance
+ messages: Original messages list
+ to_compact: Messages to compact returned by context_check
+ to_keep: Messages to keep returned by context_check
+ memory_compact_threshold: The threshold parameter used
+ memory_compact_reserve: The reserve parameter used
+ test_name: Name of the test for error reporting
+
+ Raises:
+ AssertionError: If any invariant is violated
+ """
+ # Calculate total tokens of original messages
+ total_tokens = sum(handler.stat_message(m).total_tokens for m in messages)
+
+ # 1. Threshold requirement check
+ if total_tokens <= memory_compact_threshold:
+ assert len(to_compact) == 0, (
+ f"[{test_name}] Threshold violation: total_tokens ({total_tokens}) <= "
+ f"threshold ({memory_compact_threshold}), but to_compact is not empty "
+ f"(has {len(to_compact)} messages)"
+ )
+ assert to_keep == messages, (
+ f"[{test_name}] Threshold violation: total_tokens ({total_tokens}) <= "
+ f"threshold ({memory_compact_threshold}), but to_keep differs from original messages"
+ )
+
+ # 2. Reserve requirement check
+ kept_tokens = sum(handler.stat_message(m).total_tokens for m in to_keep)
+ assert kept_tokens <= memory_compact_reserve or len(to_keep) == 0, (
+ f"[{test_name}] Reserve violation: kept_tokens ({kept_tokens}) > " f"reserve ({memory_compact_reserve})"
+ )
+
+ # 3. Order requirement check - both lists should preserve original order
+ # Create a mapping of message id to original index
+ msg_to_idx = {id(m): i for i, m in enumerate(messages)}
+
+ # Check to_compact order
+ compact_indices = [msg_to_idx.get(id(m), -1) for m in to_compact]
+ for i in range(len(compact_indices) - 1):
+ assert compact_indices[i] < compact_indices[i + 1], (
+ f"[{test_name}] Order violation in to_compact: message at original index "
+ f"{compact_indices[i]} appears before message at index {compact_indices[i + 1]}"
+ )
+
+ # Check to_keep order
+ keep_indices = [msg_to_idx.get(id(m), -1) for m in to_keep]
+ for i in range(len(keep_indices) - 1):
+ assert keep_indices[i] < keep_indices[i + 1], (
+ f"[{test_name}] Order violation in to_keep: message at original index "
+ f"{keep_indices[i]} appears before message at index {keep_indices[i + 1]}"
+ )
+
+ # 4. Additional check: to_compact indices should all be less than to_keep indices
+ # (compact messages come from the beginning, keep messages come from the end)
+ if to_compact and to_keep:
+ max_compact_idx = max(compact_indices) if compact_indices else -1
+ min_keep_idx = min(keep_indices) if keep_indices else len(messages)
+ assert max_compact_idx < min_keep_idx, (
+ f"[{test_name}] Partition violation: max compact index ({max_compact_idx}) >= "
+ f"min keep index ({min_keep_idx}). Compact and keep should be a clean partition."
+ )
+
+ # 5. Check that all messages are accounted for (no duplicates, no missing)
+ assert len(to_compact) + len(to_keep) == len(messages), (
+ f"[{test_name}] Count mismatch: to_compact ({len(to_compact)}) + "
+ f"to_keep ({len(to_keep)}) != original ({len(messages)})"
+ )
+
+ all_returned = set(id(m) for m in to_compact) | set(id(m) for m in to_keep)
+ all_original = set(id(m) for m in messages)
+ assert all_returned == all_original, f"[{test_name}] Message set mismatch: returned messages differ from original"
+
+
+def create_user_msg(content: str) -> Msg:
+ """Create a user message."""
+ return Msg(name="user", role="user", content=content)
+
+
+def create_assistant_msg(content: str) -> Msg:
+ """Create an assistant message."""
+ return Msg(name="assistant", role="assistant", content=content)
+
+
+def create_tool_use_msg(tool_id: str, tool_name: str, tool_input: dict) -> Msg:
+ """Create a message with tool_use content block."""
+ return Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ {
+ "type": "tool_use",
+ "id": tool_id,
+ "name": tool_name,
+ "input": tool_input,
+ },
+ ],
+ )
+
+
+def create_tool_result_msg(tool_id: str, tool_name: str, output: str) -> Msg:
+ """Create a message with tool_result content block."""
+ return Msg(
+ name="tool",
+ role="user",
+ content=[
+ {
+ "type": "tool_result",
+ "id": tool_id,
+ "name": tool_name,
+ "output": output,
+ },
+ ],
+ )
+
+
+def create_mixed_tool_msg(
+ tool_use_id: str,
+ tool_use_name: str,
+ tool_use_input: dict,
+ tool_result_id: str,
+ tool_result_name: str,
+ tool_result_output: str,
+) -> Msg:
+ """Create a message with both tool_use and tool_result blocks."""
+ return Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ {
+ "type": "tool_use",
+ "id": tool_use_id,
+ "name": tool_use_name,
+ "input": tool_use_input,
+ },
+ {
+ "type": "tool_result",
+ "id": tool_result_id,
+ "name": tool_result_name,
+ "output": tool_result_output,
+ },
+ ],
+ )
+
+
+# =============================================================================
+# Normal Cases
+# =============================================================================
+
+
+def test_empty_messages():
+ """Test context_check with empty messages list."""
+ handler = create_handler()
+ messages = []
+ threshold, reserve = 1000, 500
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ assert not to_compact, f"Expected empty compact list, got: {to_compact}"
+ assert to_keep == [], f"Expected empty keep list, got: {to_keep}"
+ verify_context_check_invariants(handler, messages, to_compact, to_keep, threshold, reserve, "test_empty_messages")
+ print_pass("test_empty_messages")
+
+
+def test_below_threshold_returns_all():
+ """Test that messages below threshold are all kept."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Hello"),
+ create_assistant_msg("Hi there!"),
+ create_user_msg("How are you?"),
+ ]
+ threshold, reserve = 10000, 5000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Very high threshold
+ memory_compact_reserve=reserve,
+ )
+ assert not to_compact, f"Expected empty compact list, got: {len(to_compact)}"
+ assert len(to_keep) == 3, f"Expected 3 messages to keep, got: {len(to_keep)}"
+ assert to_keep == messages, "Messages to keep should be the original messages"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_below_threshold_returns_all",
+ )
+ print_pass("test_below_threshold_returns_all")
+
+
+def test_above_threshold_triggers_compaction():
+ """Test that messages above threshold are split correctly."""
+ handler = create_handler()
+ # Create messages that will exceed threshold
+ messages = [
+ create_user_msg("First message " * 100),
+ create_assistant_msg("Second message " * 100),
+ create_user_msg("Third message " * 100),
+ create_assistant_msg("Fourth message " * 100),
+ ]
+ threshold, reserve = 100, 200
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Low threshold to trigger compaction
+ memory_compact_reserve=reserve,
+ )
+ # Should have some messages compacted and some kept
+ assert len(to_compact) + len(to_keep) == len(messages), "Total messages should match"
+ assert len(to_compact) > 0, "Expected some messages to be compacted"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_above_threshold_triggers_compaction",
+ )
+ print_pass("test_above_threshold_triggers_compaction")
+
+
+def test_message_order_preserved():
+ """Test that message order is preserved in both lists."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("First " * 50),
+ create_assistant_msg("Second " * 50),
+ create_user_msg("Third " * 50),
+ create_assistant_msg("Fourth " * 50),
+ create_user_msg("Fifth " * 10),
+ ]
+ threshold, reserve = 100, 150
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Low threshold
+ memory_compact_reserve=reserve,
+ )
+ # Check order preservation - compact messages should appear first in original
+ all_messages = to_compact + to_keep
+ for i, msg in enumerate(all_messages):
+ assert msg in messages, f"Message {i} not found in original messages"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_message_order_preserved",
+ )
+ print_pass("test_message_order_preserved")
+
+
+# =============================================================================
+# Edge Cases - Threshold and Reserve Boundaries
+# =============================================================================
+
+
+def test_single_message_below_threshold():
+ """Test single message below threshold."""
+ handler = create_handler()
+ messages = [create_user_msg("Short message")]
+ threshold, reserve = 1000, 500
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ assert not to_compact, "Should not compact single message below threshold"
+ assert len(to_keep) == 1, "Should keep the single message"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_single_message_below_threshold",
+ )
+ print_pass("test_single_message_below_threshold")
+
+
+def test_single_message_above_threshold():
+ """Test single message that exceeds threshold - nothing can be kept in reserve."""
+ handler = create_handler()
+ long_content = "Very long message " * 1000
+ messages = [create_user_msg(long_content)]
+ threshold, reserve = 10, 5
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Very low threshold
+ memory_compact_reserve=reserve, # Even lower reserve
+ )
+ # Message exceeds both threshold and reserve, so it's compacted
+ assert len(to_compact) == 1, "Single large message should be compacted"
+ assert len(to_keep) == 0, "Nothing can fit in reserve"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_single_message_above_threshold",
+ )
+ print_pass("test_single_message_above_threshold")
+
+
+def test_reserve_zero():
+ """Test with reserve=0, no messages can be kept."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Hello"),
+ create_assistant_msg("Hi there!"),
+ ]
+ threshold, reserve = 1, 0
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Trigger compaction
+ memory_compact_reserve=reserve, # Zero reserve
+ )
+ # All messages should be compacted since reserve is 0
+ assert len(to_compact) == 2, f"All messages should be compacted, got {len(to_compact)}"
+ assert len(to_keep) == 0, f"No messages should be kept, got {len(to_keep)}"
+ verify_context_check_invariants(handler, messages, to_compact, to_keep, threshold, reserve, "test_reserve_zero")
+ print_pass("test_reserve_zero")
+
+
+def test_threshold_zero():
+ """Test with threshold=0, always triggers compaction."""
+ handler = create_handler()
+ messages = [create_user_msg("A")] # Minimal message
+ threshold, reserve = 0, 1000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Zero threshold - always triggers
+ memory_compact_reserve=reserve,
+ )
+ # Even minimal message triggers compaction with threshold=0
+ # But reserve is high so it should be kept
+ assert len(to_compact) == 0 or len(to_keep) == 1, "Message should fit in reserve"
+ verify_context_check_invariants(handler, messages, to_compact, to_keep, threshold, reserve, "test_threshold_zero")
+ print_pass("test_threshold_zero")
+
+
+def test_exact_threshold_boundary():
+ """Test messages exactly at threshold boundary."""
+ handler = create_handler()
+ messages = [create_user_msg("Test message")]
+
+ # Get exact token count
+ stat = handler.stat_message(messages[0])
+ exact_tokens = stat.total_tokens
+ threshold, reserve = exact_tokens, exact_tokens
+
+ # Test at exact boundary
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Exactly at boundary
+ memory_compact_reserve=reserve,
+ )
+ # At exact boundary (<=), should not trigger compaction
+ assert not to_compact, "Should not compact at exact boundary"
+ assert len(to_keep) == 1, "Should keep message at exact boundary"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_exact_threshold_boundary",
+ )
+ print_pass("test_exact_threshold_boundary")
+
+
+def test_reserve_larger_than_threshold():
+ """Test when reserve is larger than threshold (unusual but valid config)."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Message one " * 20),
+ create_assistant_msg("Message two " * 20),
+ ]
+ threshold, reserve = 50, 10000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Low threshold
+ memory_compact_reserve=reserve, # High reserve
+ )
+ # Compaction triggered but reserve can hold everything
+ # Total messages should be preserved
+ assert len(to_compact) + len(to_keep) == 2
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_reserve_larger_than_threshold",
+ )
+ print_pass("test_reserve_larger_than_threshold")
+
+
+# =============================================================================
+# Edge Cases - Tool Use/Result Pairing
+# =============================================================================
+
+
+def test_tool_use_result_paired():
+ """Test that tool_use and tool_result pairs are kept together."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Please run the tool " * 50),
+ create_tool_use_msg("call_001", "test_tool", {"arg": "value"}),
+ create_tool_result_msg("call_001", "test_tool", "Tool output"),
+ create_assistant_msg("The tool returned results"),
+ ]
+ threshold, reserve = 50, 1000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Trigger compaction
+ memory_compact_reserve=reserve, # Enough for tool pair
+ )
+
+ # If tool_result is kept, tool_use should also be kept
+ tool_result_in_keep = any(any(b.get("type") == "tool_result" for b in m.get_content_blocks()) for m in to_keep)
+ tool_use_in_keep = any(any(b.get("type") == "tool_use" for b in m.get_content_blocks()) for m in to_keep)
+
+ if tool_result_in_keep:
+ assert tool_use_in_keep, "tool_use should be kept when tool_result is kept"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_tool_use_result_paired",
+ )
+ print_pass("test_tool_use_result_paired")
+
+
+def test_tool_use_without_result():
+ """Test tool_use message without corresponding tool_result."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Run the tool"),
+ create_tool_use_msg("call_orphan", "orphan_tool", {"arg": "value"}),
+ create_assistant_msg("Something happened"),
+ ]
+ threshold, reserve = 10, 1000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ # Should not crash, just process normally
+ assert len(to_compact) + len(to_keep) == 3
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_tool_use_without_result",
+ )
+ print_pass("test_tool_use_without_result")
+
+
+def test_tool_result_without_use():
+ """Test tool_result message without corresponding tool_use."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Here's a result"),
+ create_tool_result_msg("call_orphan", "orphan_tool", "Some output"),
+ create_assistant_msg("Got it"),
+ ]
+ threshold, reserve = 10, 1000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ # Should not crash even with orphan tool_result
+ assert len(to_compact) + len(to_keep) == 3
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_tool_result_without_use",
+ )
+ print_pass("test_tool_result_without_use")
+
+
+def test_multiple_tool_pairs():
+ """Test multiple tool_use/tool_result pairs."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Start task " * 50),
+ create_tool_use_msg("call_001", "tool_a", {"a": 1}),
+ create_tool_result_msg("call_001", "tool_a", "Result A"),
+ create_tool_use_msg("call_002", "tool_b", {"b": 2}),
+ create_tool_result_msg("call_002", "tool_b", "Result B"),
+ create_tool_use_msg("call_003", "tool_c", {"c": 3}),
+ create_tool_result_msg("call_003", "tool_c", "Result C"),
+ create_assistant_msg("All done"),
+ ]
+ threshold, reserve = 50, 500
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+
+ # Verify tool pairs integrity - for each kept tool_result, its tool_use should be kept
+ for msg in to_keep:
+ for block in msg.get_content_blocks("tool_result"):
+ tool_id = block.get("id", "")
+ if tool_id:
+ # Find corresponding tool_use
+ tool_use_found = False
+ for keep_msg in to_keep:
+ for use_block in keep_msg.get_content_blocks("tool_use"):
+ if use_block.get("id") == tool_id:
+ tool_use_found = True
+ break
+ assert tool_use_found, f"tool_use for {tool_id} should be kept with tool_result"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_multiple_tool_pairs",
+ )
+ print_pass("test_multiple_tool_pairs")
+
+
+def test_tool_dependency_causes_extra_inclusion():
+ """Test that tool_use is included even if it exceeds simple reserve calculation."""
+ handler = create_handler()
+ # Create a scenario where:
+ # - First message (tool_use) is large
+ # - Later message (tool_result) references it
+ # - Reserve alone wouldn't fit tool_use, but dependency requires it
+ large_tool_input = {"data": "x" * 200}
+ messages = [
+ create_user_msg("Start " * 100), # Large message
+ create_tool_use_msg("call_dep", "dep_tool", large_tool_input), # Medium
+ create_user_msg("Middle " * 100), # Large message
+ create_tool_result_msg("call_dep", "dep_tool", "Result"), # Small
+ create_assistant_msg("End"), # Small
+ ]
+ threshold, reserve = 100, 500
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Trigger compaction
+ memory_compact_reserve=reserve, # Medium reserve
+ )
+
+ # Check pair integrity
+ result_kept = any(
+ any(b.get("id") == "call_dep" and b.get("type") == "tool_result" for b in m.get_content_blocks())
+ for m in to_keep
+ )
+ use_kept = any(
+ any(b.get("id") == "call_dep" and b.get("type") == "tool_use" for b in m.get_content_blocks()) for m in to_keep
+ )
+
+ if result_kept:
+ assert use_kept, "Dependent tool_use should be included with tool_result"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_tool_dependency_causes_extra_inclusion",
+ )
+ print_pass("test_tool_dependency_causes_extra_inclusion")
+
+
+def test_tool_dependency_exceeds_reserve():
+ """Test when tool_result + its tool_use dependency would exceed reserve."""
+ handler = create_handler()
+ # tool_use is very large, making the pair not fit in reserve
+ very_large_input = {"data": "x" * 2000}
+ messages = [
+ create_user_msg("First"),
+ create_tool_use_msg("call_big", "big_tool", very_large_input), # Very large
+ create_tool_result_msg("call_big", "big_tool", "Small result"),
+ create_assistant_msg("Last message"),
+ ]
+ threshold, reserve = 10, 100
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Trigger compaction
+ memory_compact_reserve=reserve, # Small reserve - can't fit the pair
+ )
+
+ # The tool pair is too large, so it should be excluded or partially handled
+ # Either both are compacted (pair excluded) or neither is kept
+ result_kept = any(
+ any(b.get("id") == "call_big" and b.get("type") == "tool_result" for b in m.get_content_blocks())
+ for m in to_keep
+ )
+
+ if result_kept:
+ # If result is kept, use must also be kept (pair integrity)
+ use_kept = any(
+ any(b.get("id") == "call_big" and b.get("type") == "tool_use" for b in m.get_content_blocks())
+ for m in to_keep
+ )
+ assert use_kept, "Pair integrity violated"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_tool_dependency_exceeds_reserve",
+ )
+ print_pass("test_tool_dependency_exceeds_reserve")
+
+
+def test_interleaved_tool_pairs():
+ """Test interleaved tool_use/tool_result (not strictly sequential)."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Multi-tool task " * 30),
+ create_tool_use_msg("call_a", "tool_a", {"a": 1}),
+ create_tool_use_msg("call_b", "tool_b", {"b": 2}), # Two uses before results
+ create_tool_result_msg("call_a", "tool_a", "Result A"),
+ create_tool_result_msg("call_b", "tool_b", "Result B"),
+ create_assistant_msg("Both done"),
+ ]
+ threshold, reserve = 50, 1000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+
+ # Verify pair integrity for interleaved pairs
+ for msg in to_keep:
+ for block in msg.get_content_blocks("tool_result"):
+ tool_id = block.get("id", "")
+ if tool_id:
+ use_found = any(
+ any(ub.get("id") == tool_id and ub.get("type") == "tool_use" for ub in km.get_content_blocks())
+ for km in to_keep
+ )
+ assert use_found, f"Interleaved tool_use {tool_id} should be kept"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_interleaved_tool_pairs",
+ )
+ print_pass("test_interleaved_tool_pairs")
+
+
+# =============================================================================
+# Edge Cases - Message Content Variations
+# =============================================================================
+
+
+def test_message_with_empty_content():
+ """Test message with empty string content."""
+ handler = create_handler()
+ messages = [
+ create_user_msg(""), # Empty content
+ create_assistant_msg("Response"),
+ ]
+ threshold, reserve = 1000, 500
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ assert len(to_compact) + len(to_keep) == 2
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_message_with_empty_content",
+ )
+ print_pass("test_message_with_empty_content")
+
+
+def test_message_with_whitespace_only():
+ """Test message with whitespace-only content."""
+ handler = create_handler()
+ messages = [
+ create_user_msg(" \n\t "), # Whitespace only
+ create_assistant_msg("Response"),
+ ]
+ threshold, reserve = 1000, 500
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ assert len(to_compact) + len(to_keep) == 2
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_message_with_whitespace_only",
+ )
+ print_pass("test_message_with_whitespace_only")
+
+
+def test_very_long_single_message():
+ """Test very long single message that exceeds any reasonable reserve."""
+ handler = create_handler()
+ huge_content = "x" * 100000 # Very long
+ messages = [create_user_msg(huge_content)]
+ threshold, reserve = 100, 1000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ # Single huge message - either kept alone or compacted
+ assert len(to_compact) + len(to_keep) == 1
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_very_long_single_message",
+ )
+ print_pass("test_very_long_single_message")
+
+
+def test_many_small_messages():
+ """Test many small messages."""
+ handler = create_handler()
+ messages = [create_user_msg(f"Msg {i}") for i in range(100)]
+ threshold, reserve = 100, 200
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Low threshold
+ memory_compact_reserve=reserve,
+ )
+ # Should compact older messages and keep recent ones
+ assert len(to_compact) + len(to_keep) == 100
+ assert len(to_keep) > 0, "Should keep some messages"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_many_small_messages",
+ )
+ print_pass("test_many_small_messages")
+
+
+def test_unicode_content():
+ """Test messages with unicode characters."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("你好世界!🎉 Emoji and 中文"),
+ create_assistant_msg("مرحبا العالم 🌍 Arabic and more"),
+ create_user_msg("日本語テスト 🇯🇵"),
+ ]
+ threshold, reserve = 1000, 500
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ assert len(to_compact) + len(to_keep) == 3
+ verify_context_check_invariants(handler, messages, to_compact, to_keep, threshold, reserve, "test_unicode_content")
+ print_pass("test_unicode_content")
+
+
+def test_special_characters_content():
+ """Test messages with special characters."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Special chars: <>&\"'`~!@#$%^&*()[]{}|\\"),
+ create_assistant_msg("More: \n\r\t\0 nulls and newlines"),
+ ]
+ threshold, reserve = 1000, 500
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ assert len(to_compact) + len(to_keep) == 2
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_special_characters_content",
+ )
+ print_pass("test_special_characters_content")
+
+
+# =============================================================================
+# Edge Cases - Boundary Conditions
+# =============================================================================
+
+
+def test_all_messages_fit_exactly_in_reserve():
+ """Test when all messages fit exactly in reserve after threshold exceeded."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Message 1"),
+ create_assistant_msg("Message 2"),
+ ]
+
+ # Calculate total tokens
+ total = sum(handler.stat_message(m).total_tokens for m in messages)
+ threshold, reserve = total - 1, total
+
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Just below total to trigger
+ memory_compact_reserve=reserve, # Exactly fits all
+ )
+ # All should be kept since reserve can hold everything
+ assert len(to_keep) == 2, f"All messages should fit in reserve, got {len(to_keep)}"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_all_messages_fit_exactly_in_reserve",
+ )
+ print_pass("test_all_messages_fit_exactly_in_reserve")
+
+
+def test_first_message_only_compacted():
+ """Test when only the first message is compacted."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Large first message " * 100), # Large
+ create_assistant_msg("Small"), # Small
+ create_user_msg("Tiny"), # Tiny
+ ]
+
+ # Calculate tokens to set appropriate reserve
+ small_msg_tokens = handler.stat_message(messages[1]).total_tokens
+ tiny_msg_tokens = handler.stat_message(messages[2]).total_tokens
+ threshold, reserve = 50, small_msg_tokens + tiny_msg_tokens + 10
+
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Low to trigger
+ memory_compact_reserve=reserve, # Fits last 2
+ )
+
+ assert len(to_compact) >= 1, "At least first message should be compacted"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_first_message_only_compacted",
+ )
+ print_pass("test_first_message_only_compacted")
+
+
+def test_last_message_only_kept():
+ """Test when only the last message can be kept."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Large " * 200),
+ create_assistant_msg("Large " * 200),
+ create_user_msg("Tiny"), # Only this fits
+ ]
+
+ tiny_tokens = handler.stat_message(messages[2]).total_tokens
+ threshold, reserve = 10, tiny_tokens + 5
+
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve, # Only fits last message
+ )
+
+ if len(to_keep) == 1:
+ # Last message should be the one kept
+ assert to_keep[0] == messages[2], "Only last message should be kept"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_last_message_only_kept",
+ )
+ print_pass("test_last_message_only_kept")
+
+
+def test_all_messages_compacted():
+ """Test when all messages need to be compacted (nothing fits in reserve)."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Large message " * 100),
+ create_assistant_msg("Large message " * 100),
+ ]
+ threshold, reserve = 10, 1
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold, # Trigger compaction
+ memory_compact_reserve=reserve, # Too small for anything
+ )
+ assert len(to_compact) == 2, "All messages should be compacted"
+ assert len(to_keep) == 0, "No messages should be kept"
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_all_messages_compacted",
+ )
+ print_pass("test_all_messages_compacted")
+
+
+# =============================================================================
+# Edge Cases - Message Roles
+# =============================================================================
+
+
+def test_system_message():
+ """Test handling of system role messages."""
+ handler = create_handler()
+ system_msg = Msg(name="system", role="system", content="You are a helpful assistant.")
+ messages = [
+ system_msg,
+ create_user_msg("Hello"),
+ create_assistant_msg("Hi there!"),
+ ]
+ threshold, reserve = 1000, 500
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ assert len(to_compact) + len(to_keep) == 3
+ verify_context_check_invariants(handler, messages, to_compact, to_keep, threshold, reserve, "test_system_message")
+ print_pass("test_system_message")
+
+
+def test_mixed_roles():
+ """Test messages with various roles (user, assistant, system)."""
+ handler = create_handler()
+ # agentscope.message.Msg only supports: user, assistant, system
+ messages = [
+ Msg(name="system", role="system", content="System prompt"),
+ Msg(name="user", role="user", content="User message"),
+ Msg(name="assistant", role="assistant", content="Assistant response"),
+ Msg(name="tool", role="user", content="Tool output as user role"),
+ Msg(name="helper", role="assistant", content="Another assistant message"),
+ ]
+ threshold, reserve = 1000, 500
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ assert len(to_compact) + len(to_keep) == 5
+ verify_context_check_invariants(handler, messages, to_compact, to_keep, threshold, reserve, "test_mixed_roles")
+ print_pass("test_mixed_roles")
+
+
+# =============================================================================
+# Edge Cases - Tool Block Variations
+# =============================================================================
+
+
+def test_tool_use_with_empty_id():
+ """Test tool_use block with empty id."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Run tool"),
+ create_tool_use_msg("", "test_tool", {"arg": "value"}), # Empty ID
+ create_assistant_msg("Done"),
+ ]
+ threshold, reserve = 10, 1000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ # Should handle gracefully
+ assert len(to_compact) + len(to_keep) == 3
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_tool_use_with_empty_id",
+ )
+ print_pass("test_tool_use_with_empty_id")
+
+
+def test_tool_result_with_empty_id():
+ """Test tool_result block with empty id."""
+ handler = create_handler()
+ messages = [
+ create_user_msg("Got result"),
+ create_tool_result_msg("", "test_tool", "Output"), # Empty ID
+ create_assistant_msg("Noted"),
+ ]
+ threshold, reserve = 10, 1000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ # Should handle gracefully
+ assert len(to_compact) + len(to_keep) == 3
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_tool_result_with_empty_id",
+ )
+ print_pass("test_tool_result_with_empty_id")
+
+
+def test_duplicate_tool_ids():
+ """Test messages with duplicate tool IDs (unusual but possible)."""
+ handler = create_handler()
+ messages = [
+ create_tool_use_msg("call_dup", "tool_a", {"a": 1}),
+ create_tool_result_msg("call_dup", "tool_a", "Result A"),
+ create_tool_use_msg("call_dup", "tool_b", {"b": 2}), # Same ID, different tool
+ create_tool_result_msg("call_dup", "tool_b", "Result B"),
+ ]
+ threshold, reserve = 10, 1000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ # Should not crash with duplicate IDs
+ assert len(to_compact) + len(to_keep) == 4
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_duplicate_tool_ids",
+ )
+ print_pass("test_duplicate_tool_ids")
+
+
+def test_message_with_multiple_tool_blocks():
+ """Test single message containing multiple tool blocks."""
+ handler = create_handler()
+ msg_with_multiple_tools = Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ {"type": "tool_use", "id": "call_1", "name": "tool1", "input": {}},
+ {"type": "tool_use", "id": "call_2", "name": "tool2", "input": {}},
+ {"type": "tool_use", "id": "call_3", "name": "tool3", "input": {}},
+ ],
+ )
+ messages = [
+ create_user_msg("Do multiple things"),
+ msg_with_multiple_tools,
+ create_tool_result_msg("call_1", "tool1", "Result 1"),
+ create_tool_result_msg("call_2", "tool2", "Result 2"),
+ create_tool_result_msg("call_3", "tool3", "Result 3"),
+ ]
+ threshold, reserve = 10, 2000
+ to_compact, to_keep, _ = handler.context_check(
+ messages=messages,
+ memory_compact_threshold=threshold,
+ memory_compact_reserve=reserve,
+ )
+ assert len(to_compact) + len(to_keep) == 5
+ verify_context_check_invariants(
+ handler,
+ messages,
+ to_compact,
+ to_keep,
+ threshold,
+ reserve,
+ "test_message_with_multiple_tool_blocks",
+ )
+ print_pass("test_message_with_multiple_tool_blocks")
+
+
+# =============================================================================
+# Run All Tests
+# =============================================================================
+
+
+def run_all_tests():
+ """Run all tests."""
+ tests = [
+ # Normal cases
+ test_empty_messages,
+ test_below_threshold_returns_all,
+ test_above_threshold_triggers_compaction,
+ test_message_order_preserved,
+ # Edge cases - boundaries
+ test_single_message_below_threshold,
+ test_single_message_above_threshold,
+ test_reserve_zero,
+ test_threshold_zero,
+ test_exact_threshold_boundary,
+ test_reserve_larger_than_threshold,
+ # Edge cases - tool pairing
+ test_tool_use_result_paired,
+ test_tool_use_without_result,
+ test_tool_result_without_use,
+ test_multiple_tool_pairs,
+ test_tool_dependency_causes_extra_inclusion,
+ test_tool_dependency_exceeds_reserve,
+ test_interleaved_tool_pairs,
+ # Edge cases - content variations
+ test_message_with_empty_content,
+ test_message_with_whitespace_only,
+ test_very_long_single_message,
+ test_many_small_messages,
+ test_unicode_content,
+ test_special_characters_content,
+ # Edge cases - boundaries
+ test_all_messages_fit_exactly_in_reserve,
+ test_first_message_only_compacted,
+ test_last_message_only_kept,
+ test_all_messages_compacted,
+ # Edge cases - roles
+ test_system_message,
+ test_mixed_roles,
+ # Edge cases - tool blocks
+ test_tool_use_with_empty_id,
+ test_tool_result_with_empty_id,
+ test_duplicate_tool_ids,
+ test_message_with_multiple_tool_blocks,
+ ]
+
+ passed = 0
+ failed = 0
+
+ for test in tests:
+ try:
+ print_test_header(test.__name__)
+ test()
+ passed += 1
+ except AssertionError as e:
+ print_fail(test.__name__, str(e))
+ failed += 1
+ except Exception as e:
+ print_error(test.__name__, str(e))
+ failed += 1
+
+ # Print summary
+ print(f"\n{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.BOLD}Test Results Summary{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.GREEN}{Colors.BOLD}✓ Passed: {passed}{Colors.RESET}")
+ if failed > 0:
+ print(f"{Colors.RED}{Colors.BOLD}✗ Failed: {failed}{Colors.RESET}")
+ else:
+ print(f"{Colors.GREEN}✗ Failed: {failed}{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+
+ if failed == 0:
+ print(f"\n{Colors.GREEN}{Colors.BOLD}🎉 All tests passed!{Colors.RESET}")
+ else:
+ print(f"\n{Colors.RED}{Colors.BOLD}💥 Some tests failed!{Colors.RESET}")
+
+
+if __name__ == "__main__":
+ run_all_tests()
diff --git a/tests/light/test_format_msgs_to_str.py b/tests/light/test_format_msgs_to_str.py
new file mode 100644
index 00000000..64e97330
--- /dev/null
+++ b/tests/light/test_format_msgs_to_str.py
@@ -0,0 +1,891 @@
+"""Tests for AsMsgHandler.format_msgs_to_str method."""
+
+# pylint: disable=W0212
+
+import sys
+
+from agentscope.message import Msg
+from test_utils import get_token_counter
+
+from reme.core.utils import get_std_logger
+from reme.memory.file_based.utils import AsMsgHandler
+
+logger = get_std_logger()
+
+
+# ANSI 颜色码
+class Colors:
+ """ANSI color codes for terminal output."""
+
+ GREEN = "\033[92m"
+ RED = "\033[91m"
+ YELLOW = "\033[93m"
+ BLUE = "\033[94m"
+ CYAN = "\033[96m"
+ BOLD = "\033[1m"
+ RESET = "\033[0m"
+
+
+def print_pass(test_name: str):
+ """打印测试通过信息"""
+ print(f"{Colors.GREEN}{Colors.BOLD}✓ {test_name} PASSED{Colors.RESET}")
+
+
+def print_fail(test_name: str, error: str):
+ """打印测试失败信息"""
+ print(f"{Colors.RED}{Colors.BOLD}✗ {test_name} FAILED: {error}{Colors.RESET}")
+
+
+def print_error(test_name: str, error: str):
+ """打印测试错误信息"""
+ print(f"{Colors.YELLOW}{Colors.BOLD}⚠ {test_name} ERROR: {error}{Colors.RESET}")
+
+
+def print_test_header(test_name: str):
+ """打印测试标题"""
+ print(f"\n{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.BLUE}{Colors.BOLD}Running: {test_name}{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+
+
+# ==================== Helper Functions ====================
+
+
+def create_handler() -> AsMsgHandler:
+ """Create an AsMsgHandler instance for testing."""
+ return AsMsgHandler(token_counter=get_token_counter())
+
+
+def verify_result_within_threshold(
+ handler: AsMsgHandler,
+ result: str,
+ threshold: int,
+ test_name: str = "",
+ msgs: list[Msg] | None = None,
+) -> None:
+ """Verify that the included messages' original token count does not exceed threshold.
+
+ Note: The format_msgs_to_str method uses message token statistics (not formatted
+ string tokens) for threshold checking. The formatted result may have more tokens
+ than the threshold due to added metadata (timestamps, role prefixes, etc.).
+
+ This verification checks that included messages' original token sum <= threshold.
+
+ Args:
+ handler: The AsMsgHandler instance used for token counting.
+ result: The formatted string result from format_msgs_to_str.
+ threshold: The memory_compact_threshold value used.
+ test_name: Optional test name for better error messages.
+ msgs: Optional list of original messages to verify against.
+
+ Raises:
+ AssertionError: If included messages' token count exceeds threshold.
+ """
+ if not result or not msgs:
+ return # Empty result or no messages to verify
+
+ # Calculate tokens of messages that were included in the result
+ included_tokens = 0
+ for msg in msgs:
+ stat = handler.stat_message(msg)
+ # Check if this message's content appears in the result
+ _ = stat.format(include_thinking=True) # Use True to check all content
+ # Simple heuristic: if the message content is in result, count its tokens
+ content_blocks = msg.get_content_blocks()
+ msg_included = False
+ for block in content_blocks:
+ block_type = block.get("type", "")
+ if block_type == "text" and block.get("text", "") in result:
+ msg_included = True
+ break
+ if block_type == "tool_use" and f"tool_call={block.get('name', '')}" in result:
+ msg_included = True
+ break
+ if block_type == "tool_result" and f"tool_result={block.get('name', '')}" in result:
+ msg_included = True
+ break
+
+ if msg_included:
+ included_tokens += stat.total_tokens
+
+ # Verify included messages' token sum doesn't exceed threshold
+ # Allow small tolerance for edge cases
+ assert (
+ included_tokens <= threshold + 1
+ ), f"{test_name}: Included messages token count ({included_tokens}) exceeds threshold ({threshold})."
+
+
+def create_user_msg(content: str) -> Msg:
+ """Create a user message."""
+ return Msg(name="user", role="user", content=content)
+
+
+def create_assistant_msg(content: str) -> Msg:
+ """Create an assistant message."""
+ return Msg(name="assistant", role="assistant", content=content)
+
+
+def create_tool_use_msg(tool_name: str, tool_input: dict, tool_id: str = "call_123") -> Msg:
+ """Create a message with tool_use content block."""
+ return Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ {
+ "type": "tool_use",
+ "id": tool_id,
+ "name": tool_name,
+ "input": tool_input,
+ },
+ ],
+ )
+
+
+def create_tool_result_msg(tool_name: str, output: str | list[dict], tool_id: str = "call_123") -> Msg:
+ """Create a message with tool_result content block."""
+ return Msg(
+ name="tool",
+ role="user",
+ content=[
+ {
+ "type": "tool_result",
+ "id": tool_id,
+ "name": tool_name,
+ "output": output,
+ },
+ ],
+ )
+
+
+def create_thinking_msg(thinking_content: str, text_content: str = "") -> Msg:
+ """Create a message with thinking content block."""
+ content = [
+ {
+ "type": "thinking",
+ "thinking": thinking_content,
+ },
+ ]
+ if text_content:
+ content.append({"type": "text", "text": text_content})
+ return Msg(name="assistant", role="assistant", content=content)
+
+
+def create_image_msg(url: str = "") -> Msg:
+ """Create a message with image content block."""
+ content = [
+ {
+ "type": "image",
+ "source": {"url": url} if url else {},
+ },
+ ]
+ return Msg(name="assistant", role="assistant", content=content)
+
+
+def create_mixed_content_msg(
+ text: str = "",
+ thinking: str = "",
+ tool_name: str = "",
+ tool_input: dict | None = None,
+ image_url: str = "",
+) -> Msg:
+ """Create a message with mixed content blocks."""
+ content = []
+ if thinking:
+ content.append({"type": "thinking", "thinking": thinking})
+ if text:
+ content.append({"type": "text", "text": text})
+ if tool_name:
+ content.append(
+ {
+ "type": "tool_use",
+ "id": "call_mixed",
+ "name": tool_name,
+ "input": tool_input or {},
+ },
+ )
+ if image_url:
+ content.append({"type": "image", "source": {"url": image_url}})
+ return Msg(name="assistant", role="assistant", content=content)
+
+
+# ==================== Normal Case Tests ====================
+
+
+def test_format_msgs_to_str_empty_list():
+ """Test format_msgs_to_str with empty message list."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = []
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+ assert result == "", f"Expected empty string for empty list, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "empty_list", msgs)
+ print_pass("test_format_msgs_to_str_empty_list")
+
+
+def test_format_msgs_to_str_single_message():
+ """Test format_msgs_to_str with a single message."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [create_user_msg("Hello, how are you?")]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "user:" in result, f"Expected 'user:' in result, got: {result}"
+ assert "Hello, how are you?" in result, f"Expected content in result, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "single_message", msgs)
+ print_pass("test_format_msgs_to_str_single_message")
+
+
+def test_format_msgs_to_str_multiple_messages():
+ """Test format_msgs_to_str with multiple messages."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [
+ create_user_msg("What is Python?"),
+ create_assistant_msg("Python is a programming language."),
+ create_user_msg("Tell me more."),
+ create_assistant_msg("Python is known for its readability."),
+ ]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "What is Python?" in result
+ assert "Python is a programming language." in result
+ assert "Tell me more." in result
+ assert "Python is known for its readability." in result
+ verify_result_within_threshold(handler, result, threshold, "multiple_messages", msgs)
+ print_pass("test_format_msgs_to_str_multiple_messages")
+
+
+def test_format_msgs_to_str_message_order():
+ """Test that messages are returned in correct order (oldest to newest)."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [
+ create_user_msg("First message"),
+ create_assistant_msg("Second message"),
+ create_user_msg("Third message"),
+ ]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ # Find positions of each message
+ first_pos = result.find("First message")
+ second_pos = result.find("Second message")
+ third_pos = result.find("Third message")
+
+ assert first_pos < second_pos < third_pos, (
+ f"Messages not in correct order. Positions: first={first_pos}, " f"second={second_pos}, third={third_pos}"
+ )
+ verify_result_within_threshold(handler, result, threshold, "message_order", msgs)
+ print_pass("test_format_msgs_to_str_message_order")
+
+
+def test_format_msgs_to_str_with_tool_use():
+ """Test format_msgs_to_str with tool_use message."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [create_tool_use_msg("read_file", {"path": "/test.txt"})]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "tool_call=read_file" in result, f"Expected tool_call in result, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "with_tool_use", msgs)
+ print_pass("test_format_msgs_to_str_with_tool_use")
+
+
+def test_format_msgs_to_str_with_tool_result():
+ """Test format_msgs_to_str with tool_result message."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [create_tool_result_msg("read_file", "file content here")]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "tool_result=read_file" in result, f"Expected tool_result in result, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "with_tool_result", msgs)
+ print_pass("test_format_msgs_to_str_with_tool_result")
+
+
+def test_format_msgs_to_str_with_image():
+ """Test format_msgs_to_str with image message."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [create_image_msg("https://example.com/image.png")]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "[image]" in result, f"Expected '[image]' in result, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "with_image", msgs)
+ print_pass("test_format_msgs_to_str_with_image")
+
+
+def test_format_msgs_to_str_conversation_flow():
+ """Test format_msgs_to_str with a complete conversation flow."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [
+ create_user_msg("Read the file."),
+ create_tool_use_msg("read_file", {"path": "/data.txt"}),
+ create_tool_result_msg("read_file", "File content here"),
+ create_assistant_msg("The file contains: File content here"),
+ ]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "user:" in result
+ assert "tool_call=read_file" in result
+ assert "tool_result=read_file" in result
+ assert "assistant:" in result
+ verify_result_within_threshold(handler, result, threshold, "conversation_flow", msgs)
+ print_pass("test_format_msgs_to_str_conversation_flow")
+
+
+# ==================== Thinking Block Tests ====================
+
+
+def test_format_msgs_to_str_thinking_excluded_by_default():
+ """Test that thinking blocks are excluded when include_thinking=False (default)."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [create_thinking_msg("Let me think about this...", "Here is my response")]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold, include_thinking=False)
+
+ assert "Let me think about this" not in result, f"Thinking content should be excluded, got: {result}"
+ assert "Here is my response" in result, f"Text content should be included, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "thinking_excluded_by_default", msgs)
+ print_pass("test_format_msgs_to_str_thinking_excluded_by_default")
+
+
+def test_format_msgs_to_str_thinking_included():
+ """Test that thinking blocks are included when include_thinking=True."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [create_thinking_msg("Let me think about this...", "Here is my response")]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold, include_thinking=True)
+
+ assert "Let me think about this" in result, f"Thinking content should be included, got: {result}"
+ assert "" in result, f"Expected thinking tag in result, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "thinking_included", msgs)
+ print_pass("test_format_msgs_to_str_thinking_included")
+
+
+def test_format_msgs_to_str_thinking_only_message():
+ """Test message with only thinking block."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [create_thinking_msg("Deep thoughts here")]
+
+ # With include_thinking=False
+ result_no_thinking = handler.format_msgs_to_str(
+ msgs,
+ memory_compact_threshold=threshold,
+ include_thinking=False,
+ )
+ # With include_thinking=True
+ result_with_thinking = handler.format_msgs_to_str(
+ msgs,
+ memory_compact_threshold=threshold,
+ include_thinking=True,
+ )
+
+ assert "Deep thoughts here" not in result_no_thinking
+ assert "Deep thoughts here" in result_with_thinking
+ verify_result_within_threshold(handler, result_no_thinking, threshold, "thinking_only_no_thinking", msgs)
+ verify_result_within_threshold(handler, result_with_thinking, threshold, "thinking_only_with_thinking", msgs)
+ print_pass("test_format_msgs_to_str_thinking_only_message")
+
+
+# ==================== Token Threshold Tests ====================
+
+
+def test_format_msgs_to_str_all_within_threshold():
+ """Test all messages fit within threshold."""
+ handler = create_handler()
+ threshold = 10000
+ msgs = [
+ create_user_msg("Short message 1"),
+ create_assistant_msg("Short message 2"),
+ create_user_msg("Short message 3"),
+ ]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "Short message 1" in result
+ assert "Short message 2" in result
+ assert "Short message 3" in result
+ verify_result_within_threshold(handler, result, threshold, "all_within_threshold", msgs)
+ print_pass("test_format_msgs_to_str_all_within_threshold")
+
+
+def test_format_msgs_to_str_exceeds_threshold_truncate_older():
+ """Test that older messages are truncated when exceeding threshold."""
+ handler = create_handler()
+ threshold = 500
+ msgs = []
+ for i in range(20):
+ msgs.append(create_user_msg(f"Question {i}: " + "x" * 100))
+ msgs.append(create_assistant_msg(f"Answer {i}: " + "y" * 100))
+
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ # The newest messages should be present
+ assert (
+ "Answer 19" in result or "Question 19" in result
+ ), f"Expected recent message in result, got: {result[:500]}..."
+ # Older messages should be truncated
+ assert "Question 0" not in result, "Older messages should be truncated"
+ verify_result_within_threshold(handler, result, threshold, "exceeds_threshold_truncate_older", msgs)
+ print_pass("test_format_msgs_to_str_exceeds_threshold_truncate_older")
+
+
+def test_format_msgs_to_str_single_message_exceeds_threshold():
+ """Test when a single message exceeds the threshold."""
+ handler = create_handler()
+ threshold = 10
+ # Create a very long message
+ long_text = "x" * 10000
+ msgs = [create_user_msg(long_text)]
+
+ # With very low threshold, even a single message won't fit
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ # The message should be skipped entirely since it exceeds threshold
+ assert result == "" or len(result) > 0, "Result should be empty or contain truncated content"
+ verify_result_within_threshold(handler, result, threshold, "single_message_exceeds_threshold", msgs)
+ print_pass("test_format_msgs_to_str_single_message_exceeds_threshold")
+
+
+def test_format_msgs_to_str_first_message_exceeds_threshold():
+ """Test when the first (oldest) message exceeds threshold but newer ones don't."""
+ handler = create_handler()
+ threshold = 100
+ msgs = [
+ create_user_msg("x" * 5000), # Old, long message
+ create_assistant_msg("Short response"), # New, short message
+ ]
+
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ # Newer message should be present
+ assert "Short response" in result, f"Expected newer message in result, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "first_message_exceeds_threshold", msgs)
+ print_pass("test_format_msgs_to_str_first_message_exceeds_threshold")
+
+
+def test_format_msgs_to_str_threshold_zero():
+ """Test with threshold of zero - no messages should be included."""
+ handler = create_handler()
+ threshold = 0
+ msgs = [create_user_msg("Test message")]
+
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert result == "", f"Expected empty string with zero threshold, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "threshold_zero", msgs)
+ print_pass("test_format_msgs_to_str_threshold_zero")
+
+
+def test_format_msgs_to_str_threshold_exact_fit():
+ """Test when messages exactly fit the threshold."""
+ handler = create_handler()
+ # Create a message and measure its formatted string tokens
+ msg = create_user_msg("Test")
+ stat = handler.stat_message(msg)
+ formatted_content = stat.format(include_thinking=False)
+ exact_threshold = handler.count_str_token(formatted_content)
+
+ msgs = [msg]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=exact_threshold)
+
+ assert "Test" in result, f"Message should fit exactly, got: {result}"
+ verify_result_within_threshold(handler, result, exact_threshold, "threshold_exact_fit", msgs)
+ print_pass("test_format_msgs_to_str_threshold_exact_fit")
+
+
+def test_format_msgs_to_str_threshold_one_less():
+ """Test when threshold is one less than needed."""
+ handler = create_handler()
+ msg = create_user_msg("Test message")
+ stat = handler.stat_message(msg)
+ formatted_content = stat.format(include_thinking=False)
+ threshold_minus_one = handler.count_str_token(formatted_content) - 1
+
+ msgs = [msg]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold_minus_one)
+
+ # Message should be skipped since it doesn't fit
+ assert result == "", f"Expected empty string when threshold is insufficient, got: {result}"
+ verify_result_within_threshold(handler, result, threshold_minus_one, "threshold_one_less", msgs)
+ print_pass("test_format_msgs_to_str_threshold_one_less")
+
+
+def test_format_msgs_to_str_large_threshold():
+ """Test with very large threshold - all messages should be included."""
+ handler = create_handler()
+ threshold = 1000000
+ msgs = [create_user_msg("Message " + str(i) + " " + "x" * 100) for i in range(50)]
+
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ # All messages should be included
+ for i in range(50):
+ assert f"Message {i}" in result, f"Message {i} should be included"
+ verify_result_within_threshold(handler, result, threshold, "large_threshold", msgs)
+ print_pass("test_format_msgs_to_str_large_threshold")
+
+
+# ==================== Edge Cases Tests ====================
+
+
+def test_format_msgs_to_str_special_characters():
+ """Test with special characters in content."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [create_user_msg("Test with 中文, 日本語, émojis 🎉 and symbols @#$%")]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "中文" in result
+ assert "日本語" in result
+ assert "🎉" in result
+ verify_result_within_threshold(handler, result, threshold, "special_characters", msgs)
+ print_pass("test_format_msgs_to_str_special_characters")
+
+
+def test_format_msgs_to_str_empty_content():
+ """Test with empty content message."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [create_user_msg("")]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "user:" in result, f"Expected role in result even with empty content, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "empty_content", msgs)
+ print_pass("test_format_msgs_to_str_empty_content")
+
+
+def test_format_msgs_to_str_whitespace_only():
+ """Test with whitespace-only content."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [create_user_msg(" \n\t ")]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "user:" in result
+ verify_result_within_threshold(handler, result, threshold, "whitespace_only", msgs)
+ print_pass("test_format_msgs_to_str_whitespace_only")
+
+
+def test_format_msgs_to_str_newlines_in_content():
+ """Test with newlines in message content."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [create_user_msg("Line 1\nLine 2\nLine 3")]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "Line 1" in result
+ assert "Line 2" in result
+ assert "Line 3" in result
+ verify_result_within_threshold(handler, result, threshold, "newlines_in_content", msgs)
+ print_pass("test_format_msgs_to_str_newlines_in_content")
+
+
+def test_format_msgs_to_str_very_long_single_word():
+ """Test with very long single word (no spaces)."""
+ handler = create_handler()
+ threshold = 10000
+ long_word = "a" * 5000
+ msgs = [create_user_msg(long_word)]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ # Should contain at least part of the word (may be truncated by formatter)
+ assert "aaa" in result, f"Expected long word content in result, got: {result[:100]}..."
+ verify_result_within_threshold(handler, result, threshold, "very_long_single_word", msgs)
+ print_pass("test_format_msgs_to_str_very_long_single_word")
+
+
+def test_format_msgs_to_str_mixed_content_blocks():
+ """Test message with mixed content blocks."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [
+ create_mixed_content_msg(
+ text="Text content",
+ thinking="Thinking content",
+ tool_name="test_tool",
+ tool_input={"key": "value"},
+ image_url="https://example.com/img.png",
+ ),
+ ]
+
+ result_no_thinking = handler.format_msgs_to_str(
+ msgs,
+ memory_compact_threshold=threshold,
+ include_thinking=False,
+ )
+ result_with_thinking = handler.format_msgs_to_str(
+ msgs,
+ memory_compact_threshold=threshold,
+ include_thinking=True,
+ )
+
+ assert "Text content" in result_no_thinking
+ assert "tool_call=test_tool" in result_no_thinking
+ assert "[image]" in result_no_thinking
+ assert "Thinking content" not in result_no_thinking
+ assert "Thinking content" in result_with_thinking
+ verify_result_within_threshold(handler, result_no_thinking, threshold, "mixed_content_no_thinking", msgs)
+ verify_result_within_threshold(handler, result_with_thinking, threshold, "mixed_content_with_thinking", msgs)
+ print_pass("test_format_msgs_to_str_mixed_content_blocks")
+
+
+def test_format_msgs_to_str_multiple_separators():
+ """Test that messages are separated by double newlines."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [
+ create_user_msg("Message 1"),
+ create_assistant_msg("Message 2"),
+ ]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "\n\n" in result, f"Expected double newline separator, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "multiple_separators", msgs)
+ print_pass("test_format_msgs_to_str_multiple_separators")
+
+
+def test_format_msgs_to_str_tool_result_complex_output():
+ """Test tool_result with complex output (list of blocks)."""
+ handler = create_handler()
+ threshold = 4000
+ complex_output = [
+ {"type": "text", "text": "Operation completed"},
+ {"type": "image", "source": {"url": "https://example.com/result.png"}},
+ ]
+ msgs = [create_tool_result_msg("process_data", complex_output)]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "tool_result=process_data" in result
+ verify_result_within_threshold(handler, result, threshold, "tool_result_complex_output", msgs)
+ print_pass("test_format_msgs_to_str_tool_result_complex_output")
+
+
+def test_format_msgs_to_str_different_roles():
+ """Test with different roles (user, assistant, system, tool)."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [
+ Msg(name="system", role="system", content="System instruction"),
+ create_user_msg("User message"),
+ create_assistant_msg("Assistant response"),
+ create_tool_result_msg("tool", "Tool output"),
+ ]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "system:" in result
+ assert "user:" in result
+ assert "assistant:" in result
+ verify_result_within_threshold(handler, result, threshold, "different_roles", msgs)
+ print_pass("test_format_msgs_to_str_different_roles")
+
+
+def test_format_msgs_to_str_incremental_threshold_check():
+ """Test incremental addition of messages until threshold is exceeded."""
+ handler = create_handler()
+
+ # Create messages with known approximate sizes
+ msgs = []
+ for i in range(10):
+ msgs.append(create_user_msg(f"Message {i} with some padding text"))
+
+ # Calculate total tokens
+ total_tokens = sum(handler.stat_message(msg).total_tokens for msg in msgs)
+
+ # Use threshold that allows about half the messages
+ half_threshold = total_tokens // 2
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=half_threshold)
+
+ # Should have some but not all messages
+ included_count = sum(1 for i in range(10) if f"Message {i}" in result)
+ assert 0 < included_count < 10, f"Expected partial messages, got {included_count} messages included"
+ # Newer messages should be included (messages are processed from end)
+ assert "Message 9" in result, "Newest message should be included"
+ verify_result_within_threshold(handler, result, half_threshold, "incremental_threshold_check", msgs)
+ print_pass("test_format_msgs_to_str_incremental_threshold_check")
+
+
+def test_format_msgs_to_str_negative_threshold():
+ """Test with negative threshold value."""
+ handler = create_handler()
+ threshold = -1
+ msgs = [create_user_msg("Test message")]
+
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ # Negative threshold should result in empty string (nothing fits)
+ assert result == "", f"Expected empty string with negative threshold, got: {result}"
+ verify_result_within_threshold(handler, result, max(0, threshold), "negative_threshold", msgs)
+ print_pass("test_format_msgs_to_str_negative_threshold")
+
+
+def test_format_msgs_to_str_preserves_newest_first():
+ """Test that newest messages are preserved when threshold is exceeded."""
+ handler = create_handler()
+ threshold = 300
+ msgs = [
+ create_user_msg("OLD MESSAGE " + "x" * 200),
+ create_assistant_msg("MIDDLE MESSAGE " + "y" * 200),
+ create_user_msg("NEW MESSAGE " + "z" * 200),
+ ]
+
+ # Use threshold that only allows ~1-2 messages
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ # Newest message should be present
+ assert "NEW MESSAGE" in result, f"Expected newest message, got: {result}"
+ verify_result_within_threshold(handler, result, threshold, "preserves_newest_first", msgs)
+ print_pass("test_format_msgs_to_str_preserves_newest_first")
+
+
+def test_format_msgs_to_str_base64_image():
+ """Test with base64 encoded image."""
+ handler = create_handler()
+ threshold = 10000
+ msgs = [
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ {
+ "type": "image",
+ "source": {
+ "type": "base64",
+ "data": "SGVsbG8gV29ybGQ=" * 100, # Simulated base64 data
+ },
+ },
+ ],
+ ),
+ ]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "[image]" in result
+ verify_result_within_threshold(handler, result, threshold, "base64_image", msgs)
+ print_pass("test_format_msgs_to_str_base64_image")
+
+
+def test_format_msgs_to_str_audio_video_blocks():
+ """Test with audio and video content blocks."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ {"type": "audio", "source": {"url": "https://example.com/audio.mp3"}},
+ {"type": "video", "source": {"url": "https://example.com/video.mp4"}},
+ ],
+ ),
+ ]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ assert "[audio]" in result
+ assert "[video]" in result
+ verify_result_within_threshold(handler, result, threshold, "audio_video_blocks", msgs)
+ print_pass("test_format_msgs_to_str_audio_video_blocks")
+
+
+def test_format_msgs_to_str_unknown_block_type():
+ """Test that unknown block types are skipped gracefully."""
+ handler = create_handler()
+ threshold = 4000
+ msgs = [
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ {"type": "unknown_type", "data": "some data"},
+ {"type": "text", "text": "Valid text"},
+ ],
+ ),
+ ]
+ result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
+
+ # Should still include valid content
+ assert "Valid text" in result
+ verify_result_within_threshold(handler, result, threshold, "unknown_block_type", msgs)
+ print_pass("test_format_msgs_to_str_unknown_block_type")
+
+
+def run_all_tests():
+ """Run all tests."""
+ tests = [
+ # Normal case tests
+ test_format_msgs_to_str_empty_list,
+ test_format_msgs_to_str_single_message,
+ test_format_msgs_to_str_multiple_messages,
+ test_format_msgs_to_str_message_order,
+ test_format_msgs_to_str_with_tool_use,
+ test_format_msgs_to_str_with_tool_result,
+ test_format_msgs_to_str_with_image,
+ test_format_msgs_to_str_conversation_flow,
+ # Thinking block tests
+ test_format_msgs_to_str_thinking_excluded_by_default,
+ test_format_msgs_to_str_thinking_included,
+ test_format_msgs_to_str_thinking_only_message,
+ # Token threshold tests
+ test_format_msgs_to_str_all_within_threshold,
+ test_format_msgs_to_str_exceeds_threshold_truncate_older,
+ test_format_msgs_to_str_single_message_exceeds_threshold,
+ test_format_msgs_to_str_first_message_exceeds_threshold,
+ test_format_msgs_to_str_threshold_zero,
+ test_format_msgs_to_str_threshold_exact_fit,
+ test_format_msgs_to_str_threshold_one_less,
+ test_format_msgs_to_str_large_threshold,
+ # Edge cases tests
+ test_format_msgs_to_str_special_characters,
+ test_format_msgs_to_str_empty_content,
+ test_format_msgs_to_str_whitespace_only,
+ test_format_msgs_to_str_newlines_in_content,
+ test_format_msgs_to_str_very_long_single_word,
+ test_format_msgs_to_str_mixed_content_blocks,
+ test_format_msgs_to_str_multiple_separators,
+ test_format_msgs_to_str_tool_result_complex_output,
+ test_format_msgs_to_str_different_roles,
+ test_format_msgs_to_str_incremental_threshold_check,
+ test_format_msgs_to_str_negative_threshold,
+ test_format_msgs_to_str_preserves_newest_first,
+ test_format_msgs_to_str_base64_image,
+ test_format_msgs_to_str_audio_video_blocks,
+ test_format_msgs_to_str_unknown_block_type,
+ ]
+
+ passed = 0
+ failed = 0
+
+ for test in tests:
+ try:
+ print_test_header(test.__name__)
+ test()
+ passed += 1
+ except AssertionError as e:
+ print_fail(test.__name__, str(e))
+ failed += 1
+ except Exception as e:
+ print_error(test.__name__, str(e))
+ failed += 1
+
+ # 打印最终统计结果
+ print(f"\n{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.BOLD}Test Results Summary{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.GREEN}{Colors.BOLD}✓ Passed: {passed}{Colors.RESET}")
+ if failed > 0:
+ print(f"{Colors.RED}{Colors.BOLD}✗ Failed: {failed}{Colors.RESET}")
+ else:
+ print(f"{Colors.GREEN}✗ Failed: {failed}{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+
+ if failed == 0:
+ print(f"\n{Colors.GREEN}{Colors.BOLD}🎉 All tests passed!{Colors.RESET}")
+ else:
+ print(f"\n{Colors.RED}{Colors.BOLD}💥 Some tests failed!{Colors.RESET}")
+
+ return failed == 0
+
+
+if __name__ == "__main__":
+ success = run_all_tests()
+ sys.exit(0 if success else 1)
diff --git a/tests/light/test_reme_light.py b/tests/light/test_reme_light.py
new file mode 100644
index 00000000..4ec8f52a
--- /dev/null
+++ b/tests/light/test_reme_light.py
@@ -0,0 +1,195 @@
+"""测试 ReMeLight
+
+演示 ReMeLight 的完整功能,并使用 AsMsgHandler 跟踪每步 Token 变化:
+1. compact_tool_result - 压缩超长工具输出
+2. compact_memory - 生成压缩摘要
+3. summary_memory - 生成完整摘要并写入文件
+4. pre_reasoning_hook - 推理前预处理钩子
+5. memory_search - 语义搜索记忆
+6. ReMeInMemoryMemory.estimate_tokens - 估算 Token 使用
+7. ReMeInMemoryMemory.get_history_str - 获取格式化历史记录
+"""
+
+import asyncio
+import logging
+from test_utils import build_sample_messages, get_msg_handler
+from reme.reme_light import ReMeLight
+
+
+def print_token_change(_step_name: str, before: int, after: int):
+ """打印 Token 变化统计。"""
+ change = after - before
+ change_pct = (change / before * 100) if before > 0 else 0
+ print(f" 📊 Token 统计: {before:,} → {after:,} (变化: {change:+,}, {change_pct:+.1f}%)")
+
+
+# ==================== 主测试流程 ====================
+async def main():
+ """测试 ReMeLight 的完整功能,并跟踪每步 Token 变化。"""
+ # 初始化 AsMsgHandler 用于 Token 统计
+ msg_handler = get_msg_handler()
+
+ # 初始化 ReMeLight
+ reme = ReMeLight(
+ default_as_llm_config={"model_name": "qwen3.5-35b-a3b"},
+ # default_embedding_model_config={"model_name": "text-embedding-v4"},
+ default_file_store_config={"fts_enabled": True, "vector_enabled": False},
+ )
+ logging.getLogger("reme").setLevel(logging.WARNING)
+ await reme.start()
+ print("=" * 70)
+ print("ReMeLight 已启动")
+ print("=" * 70)
+
+ # 构建模拟对话历史(包含超长 tool_result,确保超过 128K token)
+ original_messages = build_sample_messages(include_large_tool_result=True)
+ initial_tokens = msg_handler.count_msgs_token(original_messages)
+
+ print(f"\n[原始消息]: {len(original_messages)} 条, {initial_tokens:,} tokens")
+ print(f" 目标阈值: 128K = {128 * 1024:,} tokens")
+ print(f" 超出阈值: {initial_tokens > 128 * 1024}")
+
+ # ==================== 1. compact_tool_result ====================
+ print("\n" + "=" * 70)
+ print("[步骤 1] compact_tool_result - 压缩超长工具输出")
+ print("=" * 70)
+
+ # 重新获取原始消息
+ messages = build_sample_messages(include_large_tool_result=True)
+ tokens_before = msg_handler.count_msgs_token(messages)
+ messages_after_step1 = await reme.compact_tool_result(messages)
+ tokens_after = msg_handler.count_msgs_token(messages_after_step1)
+
+ print(f" 消息数量: {len(messages)} → {len(messages_after_step1)}")
+ print_token_change("compact_tool_result", tokens_before, tokens_after)
+
+ # ==================== 2. compact_memory ====================
+ print("\n" + "=" * 70)
+ print("[步骤 2] compact_memory - 生成结构化压缩摘要")
+ print("=" * 70)
+
+ # 重新获取原始消息
+ messages = build_sample_messages(include_large_tool_result=True)
+ tokens_before = msg_handler.count_msgs_token(messages)
+ compact_summary = await reme.compact_memory(
+ messages=messages,
+ previous_summary="",
+ )
+ summary_tokens = msg_handler.count_str_token(compact_summary)
+
+ print(f" 输入消息 tokens: {tokens_before:,}")
+ print(f" 压缩摘要长度: {len(compact_summary)} 字符, {summary_tokens:,} tokens")
+ print(f" 压缩比: {summary_tokens / tokens_before * 100:.1f}%" if tokens_before > 0 else " 压缩比: N/A")
+ print(f" 摘要预览: {compact_summary[:200]}..." if len(compact_summary) > 200 else f" 摘要: {compact_summary}")
+
+ # ==================== 3. summary_memory ====================
+ print("\n" + "=" * 70)
+ print("[步骤 3] summary_memory - 生成完整摘要并写入文件")
+ print("=" * 70)
+
+ # 重新获取原始消息
+ messages = build_sample_messages(include_large_tool_result=True)
+ tokens_before = msg_handler.count_msgs_token(messages)
+ summary_result = await reme.summary_memory(messages=messages)
+
+ print(f" 输入消息 tokens: {tokens_before:,}")
+ print(f" 摘要结果长度: {len(summary_result)} 字符")
+ print(f" 摘要预览: {summary_result[:200]}..." if len(summary_result) > 200 else f" 摘要: {summary_result}")
+
+ # ==================== 4. pre_reasoning_hook ====================
+ print("\n" + "=" * 70)
+ print("[步骤 4] pre_reasoning_hook - 推理前预处理")
+ print("=" * 70)
+
+ # 重新获取原始消息
+ messages = build_sample_messages(include_large_tool_result=True)
+ tokens_before = msg_handler.count_msgs_token(messages)
+ processed_messages, compressed_summary = await reme.pre_reasoning_hook(
+ messages=messages,
+ system_prompt="你是一个有帮助的 AI 助手。",
+ compressed_summary="",
+ max_input_length=128000,
+ compact_ratio=0.7,
+ memory_compact_reserve=10000,
+ enable_tool_result_compact=True,
+ tool_result_compact_keep_n=3,
+ )
+ tokens_after = msg_handler.count_msgs_token(processed_messages)
+ compressed_summary_tokens = msg_handler.count_str_token(compressed_summary)
+
+ print(f" 消息数量: {len(messages)} → {len(processed_messages)}")
+ print_token_change("pre_reasoning_hook", tokens_before, tokens_after)
+ print(f" 压缩摘要: {len(compressed_summary)} 字符, {compressed_summary_tokens:,} tokens")
+ print(f" 总上下文: {tokens_after + compressed_summary_tokens:,} tokens")
+
+ # ==================== 5. memory_search ====================
+ print("\n" + "=" * 70)
+ print("[步骤 5] memory_search - 语义搜索记忆")
+ print("=" * 70)
+
+ search_result = await reme.memory_search(query="Python 版本偏好", max_results=5)
+ if search_result.content:
+ print(f" 搜索结果: {search_result.content}")
+ else:
+ print(" 未找到相关记忆")
+
+ # ==================== 6 & 7. ReMeInMemoryMemory ====================
+ print("\n" + "=" * 70)
+ print("[步骤 6] ReMeInMemoryMemory - 会话内存管理")
+ print("=" * 70)
+
+ # 重新获取原始消息
+ messages = build_sample_messages(include_large_tool_result=True)
+ memory = ReMeLight.get_in_memory_memory()
+ for msg in messages:
+ await memory.add(msg)
+ print(f" 已添加 {len(messages)} 条原始消息到内存")
+
+ # 6.1 estimate_tokens
+ print("\n[6.1] estimate_tokens - 估算 Token 使用:")
+ token_stats = await memory.estimate_tokens(max_input_length=128000)
+ print(f" - 总消息数: {token_stats['total_messages']}")
+ print(f" - 消息 Token 数: {token_stats['messages_tokens']:,}")
+ print(f" - 压缩摘要 Token 数: {token_stats['compressed_summary_tokens']:,}")
+ print(f" - 预估总 Token 数: {token_stats['estimated_tokens']:,}")
+ print(f" - 最大输入长度: {token_stats['max_input_length']:,}")
+ print(f" - 上下文使用率: {token_stats['context_usage_ratio']:.2f}%")
+
+ # 6.2 get_history_str
+ print("\n[6.2] get_history_str - 格式化历史记录:")
+ history_str = await memory.get_history_str(max_input_length=128000)
+ print(history_str[:1000] + "..." if len(history_str) > 1000 else history_str)
+
+ # ==================== 等待后台任务完成 ====================
+ print("\n" + "=" * 70)
+ print("[步骤 7] 等待后台任务完成")
+ print("=" * 70)
+ await_result = await reme.await_summary_tasks()
+ print(f" 后台任务完成,结果长度: {len(await_result)} 字符")
+
+ # ==================== 总结 ====================
+ print("\n" + "=" * 70)
+ print("📊 Token 变化总结")
+ print("=" * 70)
+ print(f" 原始消息: {initial_tokens:,} tokens")
+ print(f" Step 1 compact_tool_result 后: {msg_handler.count_msgs_token(messages_after_step1):,} tokens")
+ print(f" Step 2 compact_memory 摘要: {summary_tokens:,} tokens")
+ print(
+ f" Step 4 pre_reasoning_hook 后: {tokens_after:,} tokens + 摘要 {compressed_summary_tokens:,} "
+ f"tokens = {tokens_after + compressed_summary_tokens:,} tokens",
+ )
+ print(
+ f" 最大节省: {initial_tokens - tokens_after:,} "
+ f"tokens ({(initial_tokens - tokens_after) / initial_tokens * 100:.1f}%)",
+ )
+ print(f" 目标阈值: {128 * 1024:,} tokens")
+
+ # 关闭 ReMeLight
+ await reme.close()
+ print("\n" + "=" * 70)
+ print("ReMeLight 已关闭")
+ print("=" * 70)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/tests/light/test_reme_light_log.txt b/tests/light/test_reme_light_log.txt
new file mode 100644
index 00000000..5a0c71a5
--- /dev/null
+++ b/tests/light/test_reme_light_log.txt
@@ -0,0 +1,84 @@
+======================================================================
+ReMeLight 已启动
+======================================================================
+
+[原始消息]: 18 条, 223,838 tokens
+ 目标阈值: 128K = 131,072 tokens
+ 超出阈值: True
+
+======================================================================
+[步骤 1] compact_tool_result - 压缩超长工具输出
+======================================================================
+ 消息数量: 18 → 18
+ 📊 Token 统计: 223,838 → 1,107 (变化: -222,731, -99.5%)
+
+======================================================================
+[步骤 2] compact_memory - 生成结构化压缩摘要
+======================================================================
+ 输入消息 tokens: 223,838
+ 压缩摘要长度: 1032 字符, 493 tokens
+ 压缩比: 0.2%
+
+======================================================================
+[步骤 3] summary_memory - 生成完整摘要并写入文件
+======================================================================
+
+reme_summarizer: [SILENT]
+ 输入消息 tokens: 223,838
+ 摘要结果长度: 8 字符
+ 摘要: [SILENT]
+
+======================================================================
+[步骤 4] pre_reasoning_hook - 推理前预处理
+======================================================================
+ 消息数量: 18 → 18
+ 📊 Token 统计: 223,838 → 1,105 (变化: -222,733, -99.5%)
+ 压缩摘要: 0 字符, 0 tokens
+ 总上下文: 1,105 tokens
+
+======================================================================
+[步骤 5] memory_search - 语义搜索记忆
+======================================================================
+ 搜索结果: [{'type': 'text', 'text': '[\n {\n "path": "/Users...
+
+======================================================================
+[步骤 6] ReMeInMemoryMemory - 会话内存管理
+======================================================================
+ 已添加 18 条原始消息到内存
+
+[6.1] estimate_tokens - 估算 Token 使用:
+ - 总消息数: 18
+ - 消息 Token 数: 223,838
+ - 压缩摘要 Token 数: 0
+ - 预估总 Token 数: 223,838
+ - 最大输入长度: 128,000
+ - 上下文使用率: 174.87%
+
+[6.2] get_history_str - 格式化历史记录:
+**Conversation History**
+
+- Total messages: 18
+- Estimated tokens: 223838
+- Max input length: 128000
+- Context usage: 174.9%
+- Compressed summary tokens: 0
+...
+
+======================================================================
+[步骤 7] 等待后台任务完成
+======================================================================
+ 后台任务完成,结果长度: 0 字符
+
+======================================================================
+📊 Token 变化总结
+======================================================================
+ 原始消息: 223,838 tokens
+ Step 1 compact_tool_result 后: 1,107 tokens
+ Step 2 compact_memory 摘要: 493 tokens
+ Step 4 pre_reasoning_hook 后: 1,105 tokens + 摘要 0 tokens = 1,105 tokens
+ 最大节省: 222,733 tokens (99.5%)
+ 目标阈值: 131,072 tokens
+
+======================================================================
+ReMeLight 已关闭
+======================================================================
diff --git a/tests/light/test_summarizer.py b/tests/light/test_summarizer.py
new file mode 100644
index 00000000..2789bca5
--- /dev/null
+++ b/tests/light/test_summarizer.py
@@ -0,0 +1,319 @@
+"""Tests for Summarizer."""
+
+import asyncio
+import datetime
+import tempfile
+from pathlib import Path
+
+from agentscope.message import Msg
+from agentscope.tool import Toolkit
+from test_utils import (
+ get_dash_chat_model,
+ get_formatter,
+ get_token_counter,
+)
+from reme.core.utils import get_std_logger
+from reme.memory.file_based.components import Summarizer
+from reme.memory.file_based.tools import FileIO
+
+
+logger = get_std_logger()
+
+
+# ANSI 颜色码
+class Colors:
+ """ANSI color codes for terminal output."""
+
+ GREEN = "\033[92m"
+ RED = "\033[91m"
+ YELLOW = "\033[93m"
+ BLUE = "\033[94m"
+ CYAN = "\033[96m"
+ BOLD = "\033[1m"
+ RESET = "\033[0m"
+
+
+def print_pass(test_name: str):
+ """打印测试通过信息"""
+ print(f"{Colors.GREEN}{Colors.BOLD}✓ {test_name} PASSED{Colors.RESET}")
+
+
+def print_fail(test_name: str, error: str):
+ """打印测试失败信息"""
+ print(f"{Colors.RED}{Colors.BOLD}✗ {test_name} FAILED: {error}{Colors.RESET}")
+
+
+def print_error(test_name: str, error: str):
+ """打印测试错误信息"""
+ print(f"{Colors.YELLOW}{Colors.BOLD}⚠ {test_name} ERROR: {error}{Colors.RESET}")
+
+
+def print_test_header(test_name: str):
+ """打印测试标题"""
+ print(f"\n{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.BLUE}{Colors.BOLD}Running: {test_name}{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+
+
+def create_user_msg(content: str) -> Msg:
+ """Create a user message."""
+ return Msg(name="user", role="user", content=content)
+
+
+def create_assistant_msg(content: str) -> Msg:
+ """Create an assistant message."""
+ return Msg(name="assistant", role="assistant", content=content)
+
+
+def create_tool_use_msg(tool_name: str, tool_input: dict) -> Msg:
+ """Create a message with tool_use content block."""
+ return Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ {
+ "type": "tool_use",
+ "id": "call_123",
+ "name": tool_name,
+ "input": tool_input,
+ },
+ ],
+ )
+
+
+def create_tool_result_msg(tool_name: str, output: str) -> Msg:
+ """Create a message with tool_result content block."""
+ return Msg(
+ name="tool",
+ role="user",
+ content=[
+ {
+ "type": "tool_result",
+ "id": "call_123",
+ "name": tool_name,
+ "output": output,
+ },
+ ],
+ )
+
+
+def create_toolkit(working_dir: str) -> Toolkit:
+ """Create a default Toolkit with FileIO tools for testing."""
+ toolkit = Toolkit()
+ file_io = FileIO(working_dir=working_dir)
+ toolkit.register_tool_function(file_io.read)
+ toolkit.register_tool_function(file_io.write)
+ toolkit.register_tool_function(file_io.edit)
+ return toolkit
+
+
+def create_summarizer(working_dir: str = None, memory_dir: str = "memory"):
+ """Create a Summarizer instance for testing."""
+ if working_dir is None:
+ working_dir = tempfile.mkdtemp()
+
+ # 确保 memory_dir 存在
+ memory_path = Path(working_dir) / memory_dir
+ memory_path.mkdir(parents=True, exist_ok=True)
+
+ return (
+ Summarizer(
+ working_dir=working_dir,
+ memory_dir=memory_dir,
+ memory_compact_threshold=4000,
+ token_counter=get_token_counter(),
+ toolkit=create_toolkit(working_dir),
+ as_llm=get_dash_chat_model(),
+ as_llm_formatter=get_formatter(),
+ ),
+ working_dir,
+ )
+
+
+def test_empty_messages():
+ """Test that empty messages return empty string."""
+ summarizer, _ = create_summarizer()
+ result = asyncio.run(summarizer.call(messages=[]))
+ assert result == "", f"Expected empty string, got: {result}"
+ print_pass("test_empty_messages")
+
+
+def test_short_conversation():
+ """Test summarization of a short conversation."""
+ summarizer, working_dir = create_summarizer()
+ messages = [
+ create_user_msg("Hello, I need help with Python."),
+ create_assistant_msg("Sure, I'd be happy to help. What do you need?"),
+ create_user_msg("How do I read a file?"),
+ create_assistant_msg("You can use open() function: with open('file.txt', 'r') as f: content = f.read()"),
+ ]
+
+ logger.info(f"Input messages count: {len(messages)}")
+ logger.info(f"Working directory: {working_dir}")
+
+ result = asyncio.run(summarizer.call(messages=messages))
+
+ logger.info(f"Result type: {type(result)}")
+ logger.info(f"Result: {result}")
+
+ assert result, "Result should not be empty"
+ assert isinstance(result, str), f"Result should be string, got: {type(result)}"
+ print_pass("test_short_conversation")
+
+
+def test_conversation_with_tool_calls():
+ """Test summarization of conversation with tool calls."""
+ summarizer, working_dir = create_summarizer()
+ messages = [
+ create_user_msg("Help me debug this Python script."),
+ create_assistant_msg("Let me check the script first."),
+ create_tool_use_msg("read_file", {"path": "/app/main.py"}),
+ create_tool_result_msg("read_file", "def process():\n data = load_data()\n return data"),
+ create_assistant_msg("I found the issue! The script needs error handling."),
+ create_user_msg("How should I fix it?"),
+ create_assistant_msg("Add try-except block around the load_data() call."),
+ ]
+
+ logger.info(f"Working directory: {working_dir}")
+ result = asyncio.run(summarizer.call(messages=messages))
+
+ assert result, "Result should not be empty"
+ assert isinstance(result, str), f"Result should be string, got: {type(result)}"
+ print_pass("test_conversation_with_tool_calls")
+
+
+def test_consecutive_summaries():
+ """Test consecutive summaries in the same directory.
+
+ This test verifies that:
+ 1. First summary creates the memory file
+ 2. Second summary reads and updates the existing file
+ """
+ # 使用固定的临时目录
+ working_dir = tempfile.mkdtemp()
+ memory_dir = "memory"
+ memory_path = Path(working_dir) / memory_dir
+ memory_path.mkdir(parents=True, exist_ok=True)
+
+ logger.info(f"Working directory: {working_dir}")
+ logger.info(f"Memory path: {memory_path}")
+
+ # 创建 Summarizer 实例
+ summarizer = Summarizer(
+ working_dir=working_dir,
+ memory_dir=memory_dir,
+ memory_compact_threshold=4000,
+ token_counter=get_token_counter(),
+ toolkit=create_toolkit(working_dir),
+ as_llm=get_dash_chat_model(),
+ as_llm_formatter=get_formatter(),
+ )
+
+ # 第一轮对话
+ messages_round1 = [
+ create_user_msg("My name is Alice and I'm learning Python."),
+ create_assistant_msg("Nice to meet you, Alice! Python is a great language to learn."),
+ create_user_msg("I prefer using VS Code as my editor."),
+ create_assistant_msg("VS Code is excellent for Python development with great extensions."),
+ ]
+
+ logger.info("=" * 40)
+ logger.info("Round 1: First summary (creating new file)")
+ logger.info("=" * 40)
+ result1 = asyncio.run(summarizer.call(messages=messages_round1))
+ logger.info(f"Round 1 Result:\n{result1}")
+
+ # 检查文件是否被创建
+ today = datetime.datetime.now().strftime("%Y-%m-%d")
+ expected_file = memory_path / f"{today}.md"
+ logger.info(f"Expected file: {expected_file}")
+
+ # 列出目录内容
+ files_after_round1 = list(memory_path.iterdir())
+ logger.info(f"Files after round 1: {files_after_round1}")
+
+ assert expected_file.exists(), f"Memory file should be created at {expected_file}"
+
+ # 读取第一轮写入的内容
+ content_after_round1 = expected_file.read_text()
+ logger.info(f"Content after round 1:\n{content_after_round1}")
+
+ # 第二轮对话
+ messages_round2 = [
+ create_user_msg("I also like using Docker for my projects."),
+ create_assistant_msg("Docker is great for containerization and deployment."),
+ create_user_msg("My favorite framework is FastAPI."),
+ create_assistant_msg("FastAPI is excellent for building modern APIs with Python."),
+ ]
+
+ logger.info("=" * 40)
+ logger.info("Round 2: Second summary (reading and updating existing file)")
+ logger.info("=" * 40)
+ result2 = asyncio.run(summarizer.call(messages=messages_round2))
+ logger.info(f"Round 2 Result:\n{result2}")
+
+ # 读取第二轮写入后的内容
+ content_after_round2 = expected_file.read_text()
+ logger.info(f"Content after round 2:\n{content_after_round2}")
+
+ # 验证
+ assert result1, "Round 1 result should not be empty"
+ assert result2, "Round 2 result should not be empty"
+
+ # 验证第二轮内容包含新信息(Docker 或 FastAPI)
+ # 注意:具体内容取决于 LLM 的响应
+ assert len(content_after_round2) > 0, "Content after round 2 should not be empty"
+
+ logger.info("=" * 40)
+ logger.info("Consecutive summaries test completed successfully!")
+ logger.info("=" * 40)
+
+ print_pass("test_consecutive_summaries")
+
+
+def run_all_tests():
+ """Run all tests."""
+ tests = [
+ test_consecutive_summaries,
+ test_empty_messages,
+ test_short_conversation,
+ test_conversation_with_tool_calls,
+ ]
+
+ passed = 0
+ failed = 0
+
+ for test in tests:
+ try:
+ print_test_header(test.__name__)
+ test()
+ passed += 1
+ except AssertionError as e:
+ print_fail(test.__name__, str(e))
+ failed += 1
+ except Exception as e:
+ import traceback
+
+ print_error(test.__name__, str(e))
+ traceback.print_exc()
+ failed += 1
+
+ # 打印最终统计结果
+ print(f"\n{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.BOLD}Test Results Summary{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+ print(f"{Colors.GREEN}{Colors.BOLD}✓ Passed: {passed}{Colors.RESET}")
+ if failed > 0:
+ print(f"{Colors.RED}{Colors.BOLD}✗ Failed: {failed}{Colors.RESET}")
+ else:
+ print(f"{Colors.GREEN}✗ Failed: {failed}{Colors.RESET}")
+ print(f"{Colors.CYAN}{'=' * 60}{Colors.RESET}")
+
+ if failed == 0:
+ print(f"\n{Colors.GREEN}{Colors.BOLD}🎉 All tests passed!{Colors.RESET}")
+ else:
+ print(f"\n{Colors.RED}{Colors.BOLD}💥 Some tests failed!{Colors.RESET}")
+
+
+if __name__ == "__main__":
+ run_all_tests()
diff --git a/tests/light/test_tool_result_compactor.py b/tests/light/test_tool_result_compactor.py
new file mode 100644
index 00000000..1697911d
--- /dev/null
+++ b/tests/light/test_tool_result_compactor.py
@@ -0,0 +1,176 @@
+"""Tests for ToolResultCompactor."""
+
+import asyncio
+import tempfile
+from datetime import datetime, timedelta
+from pathlib import Path
+
+from agentscope.message import Msg
+
+from reme.core.utils import is_truncated
+from reme.memory.file_based.components import ToolResultCompactor
+
+
+def create_tool_result_msg(output: str | list, tool_name: str = "test_tool") -> Msg:
+ """Create a Msg with tool_result content block."""
+ return Msg(
+ name="tool",
+ role="user",
+ content=[
+ {
+ "type": "tool_result",
+ "id": "call_123",
+ "name": tool_name,
+ "output": output,
+ },
+ ],
+ )
+
+
+class TestToolResultCompactor:
+ """Tests for ToolResultCompactor."""
+
+ def test_no_truncation_when_under_threshold(self):
+ """Test that short content is not truncated."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=1000)
+ messages = [create_tool_result_msg("short content")]
+
+ result = asyncio.run(op.call(messages=messages))
+
+ assert result == messages
+ assert messages[0].content[0]["output"] == "short content"
+ assert len(list(Path(tmpdir).glob("*.txt"))) == 0
+
+ def test_truncation_when_over_threshold(self):
+ """Test that long content is truncated and saved to file."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100)
+ long_content = "x" * 500
+ messages = [create_tool_result_msg(long_content)]
+
+ _ = asyncio.run(op.call(messages=messages))
+
+ output = messages[0].content[0]["output"]
+ assert is_truncated(output)
+ assert "[Full content saved to:" in output
+
+ # Verify file was created
+ files = list(Path(tmpdir).glob("*.txt"))
+ assert len(files) == 1
+
+ # Verify file content
+ content = files[0].read_text()
+ assert "# tool_name: test_tool" in content
+ assert "# created_at:" in content
+ assert long_content in content
+
+ def test_skip_already_truncated(self):
+ """Test that already truncated content is not re-truncated."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100)
+ truncated_content = "head<<>>(100 chars omitted)<<>>tail"
+ messages = [create_tool_result_msg(truncated_content)]
+
+ asyncio.run(op.call(messages=messages))
+
+ assert messages[0].content[0]["output"] == truncated_content
+ assert len(list(Path(tmpdir).glob("*.txt"))) == 0
+
+ def test_truncation_list_output(self):
+ """Test truncation of list output with text blocks."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100)
+ list_output = [{"type": "text", "text": "y" * 500}]
+ messages = [create_tool_result_msg(list_output)]
+
+ asyncio.run(op.call(messages=messages))
+
+ text_block = messages[0].content[0]["output"][0]
+ assert is_truncated(text_block["text"])
+ assert len(list(Path(tmpdir).glob("*.txt"))) == 1
+
+ def test_list_output_no_truncation_when_short(self):
+ """Test that short list output is not truncated."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=1000)
+ list_output = [{"type": "text", "text": "short"}]
+ messages = [create_tool_result_msg(list_output)]
+
+ asyncio.run(op.call(messages=messages))
+
+ assert messages[0].content[0]["output"][0]["text"] == "short"
+ assert len(list(Path(tmpdir).glob("*.txt"))) == 0
+
+ def test_list_output_multiple_text_blocks(self):
+ """Test truncation of multiple text blocks in list output."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100)
+ list_output = [
+ {"type": "text", "text": "a" * 500},
+ {"type": "text", "text": "short"},
+ {"type": "text", "text": "b" * 500},
+ ]
+ messages = [create_tool_result_msg(list_output)]
+
+ asyncio.run(op.call(messages=messages))
+
+ output = messages[0].content[0]["output"]
+ assert is_truncated(output[0]["text"])
+ assert output[1]["text"] == "short" # unchanged
+ assert is_truncated(output[2]["text"])
+ assert len(list(Path(tmpdir).glob("*.txt"))) == 2
+
+ def test_list_output_mixed_block_types(self):
+ """Test that non-text blocks in list output are unchanged."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100)
+ list_output = [
+ {"type": "text", "text": "c" * 500},
+ {"type": "image", "source": {"type": "url", "url": "http://example.com/img.png"}},
+ ]
+ messages = [create_tool_result_msg(list_output)]
+
+ asyncio.run(op.call(messages=messages))
+
+ output = messages[0].content[0]["output"]
+ assert is_truncated(output[0]["text"])
+ assert output[1] == {"type": "image", "source": {"type": "url", "url": "http://example.com/img.png"}}
+ assert len(list(Path(tmpdir).glob("*.txt"))) == 1
+
+ def test_cleanup_expired_files(self):
+ """Test cleanup of expired files."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100, retention_days=1)
+
+ # Create an old file
+ old_time = (datetime.now() - timedelta(days=2)).isoformat()
+ old_file = Path(tmpdir) / "old_file.txt"
+ old_file.write_text(f"# tool_name: test\n# created_at: {old_time}\n# ---\ncontent")
+
+ # Create a new file
+ new_time = datetime.now().isoformat()
+ new_file = Path(tmpdir) / "new_file.txt"
+ new_file.write_text(f"# tool_name: test\n# created_at: {new_time}\n# ---\ncontent")
+
+ deleted = op.cleanup_expired_files()
+
+ assert deleted == 1
+ assert not old_file.exists()
+ assert new_file.exists()
+
+ def test_string_content_msg_unchanged(self):
+ """Test that messages with string content are unchanged."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ op = ToolResultCompactor(tool_result_dir=tmpdir, tool_result_threshold=100)
+ messages = [Msg(name="user", role="user", content="hello world")]
+
+ asyncio.run(op.call(messages=messages))
+
+ assert messages[0].content == "hello world"
+
+
+if __name__ == "__main__":
+ import pytest
+
+ pytest.main([__file__, "-v"])
diff --git a/tests/light/test_tools.py b/tests/light/test_tools.py
new file mode 100644
index 00000000..891fa534
--- /dev/null
+++ b/tests/light/test_tools.py
@@ -0,0 +1,321 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=redefined-outer-name
+"""Unit tests for Shell and FileIO tools."""
+
+import asyncio
+import os
+import shutil
+import tempfile
+
+import pytest
+
+from reme.memory.file_based.tools.file_io import FileIO
+from reme.memory.file_based.tools.shell import Shell
+from reme.memory.file_based.tools.utils import DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES
+
+
+# ============ Shell Tests ============
+
+
+@pytest.fixture(scope="module")
+def shell_env():
+ """Create temporary directory and Shell instance."""
+ test_dir = tempfile.mkdtemp(prefix="test_shell_")
+ shell = Shell(working_dir=test_dir)
+ yield {"dir": test_dir, "shell": shell}
+ shutil.rmtree(test_dir, ignore_errors=True)
+
+
+def test_shell_echo_success(shell_env):
+ """Test successful echo command execution."""
+ result = asyncio.run(shell_env["shell"].execute_shell_command("echo hello"))
+ assert result.content
+ text = result.content[0].get("text", "")
+ assert "hello" in text
+
+
+def test_shell_pwd_in_working_dir(shell_env):
+ """Test command executes in correct working directory."""
+ result = asyncio.run(shell_env["shell"].execute_shell_command("pwd"))
+ text = result.content[0].get("text", "")
+ assert shell_env["dir"] in text
+
+
+def test_shell_command_failure(shell_env):
+ """Test failed command returns error information."""
+ result = asyncio.run(shell_env["shell"].execute_shell_command("exit 1"))
+ text = result.content[0].get("text", "")
+ assert "failed" in text.lower()
+ assert "exit code" in text.lower()
+
+
+def test_shell_command_with_stderr(shell_env):
+ """Test command with stderr output."""
+ result = asyncio.run(
+ shell_env["shell"].execute_shell_command("echo error >&2 && exit 1"),
+ )
+ text = result.content[0].get("text", "")
+ assert "error" in text
+
+
+def test_shell_no_output(shell_env):
+ """Test successful command with no output."""
+ result = asyncio.run(shell_env["shell"].execute_shell_command("true"))
+ text = result.content[0].get("text", "")
+ assert "successfully" in text.lower()
+
+
+def test_shell_multiline_output(shell_env):
+ """Test command with multiline output."""
+ result = asyncio.run(
+ shell_env["shell"].execute_shell_command("echo -e 'line1\nline2\nline3'"),
+ )
+ text = result.content[0].get("text", "")
+ assert "line1" in text
+ assert "line2" in text
+ assert "line3" in text
+
+
+def test_shell_truncated_output(shell_env):
+ """Test output truncation for large output."""
+ lines_to_generate = DEFAULT_MAX_LINES + 500
+ cmd = f"seq 1 {lines_to_generate}"
+ result = asyncio.run(shell_env["shell"].execute_shell_command(cmd))
+ text = result.content[0].get("text", "")
+
+ # Should contain truncation notice
+ assert "truncated" in text.lower()
+ # Should contain the last line (tail is kept)
+ assert str(lines_to_generate) in text
+ # Verify first numeric line is > 1 (truncated from head)
+ numeric_lines = [tl for tl in text.strip().split("\n") if tl.isdigit()]
+ if numeric_lines:
+ assert int(numeric_lines[0]) > 1
+
+
+def test_shell_timeout(shell_env):
+ """Test command timeout handling."""
+ result = asyncio.run(
+ shell_env["shell"].execute_shell_command("sleep 10", timeout=1),
+ )
+ text = result.content[0].get("text", "")
+ assert "timeout" in text.lower()
+
+
+# ============ FileIO Read Tests ============
+
+
+@pytest.fixture(scope="module")
+def fileio_env():
+ """Create temporary directory with test files."""
+ test_dir = tempfile.mkdtemp(prefix="test_fileio_")
+ file_io = FileIO(working_dir=test_dir)
+
+ # Create simple test file
+ simple_file = os.path.join(test_dir, "simple.txt")
+ with open(simple_file, "w", encoding="utf-8") as f:
+ f.write("line1\nline2\nline3\nline4\nline5")
+
+ # Create large file (exceeds DEFAULT_MAX_LINES)
+ large_file = os.path.join(test_dir, "large.txt")
+ with open(large_file, "w", encoding="utf-8") as f:
+ for i in range(1, DEFAULT_MAX_LINES + 500):
+ f.write(f"line {i}\n")
+
+ # Create large bytes file (exceeds DEFAULT_MAX_BYTES)
+ large_bytes_file = os.path.join(test_dir, "large_bytes.txt")
+ with open(large_bytes_file, "w", encoding="utf-8") as f:
+ content = "x" * 100 + "\n"
+ lines_needed = (DEFAULT_MAX_BYTES // 101) + 100
+ for _ in range(lines_needed):
+ f.write(content)
+
+ yield {
+ "dir": test_dir,
+ "file_io": file_io,
+ "simple_file": simple_file,
+ "large_file": large_file,
+ "large_bytes_file": large_bytes_file,
+ }
+ shutil.rmtree(test_dir, ignore_errors=True)
+
+
+def test_read_file_success(fileio_env):
+ """Test successful file reading."""
+ result = asyncio.run(fileio_env["file_io"].read(fileio_env["simple_file"]))
+ text = result.content[0].get("text", "")
+ assert "line1" in text
+ assert "line5" in text
+
+
+def test_read_file_relative_path(fileio_env):
+ """Test reading file with relative path."""
+ result = asyncio.run(fileio_env["file_io"].read("simple.txt"))
+ text = result.content[0].get("text", "")
+ assert "line1" in text
+
+
+def test_read_file_not_exists(fileio_env):
+ """Test reading non-existent file."""
+ result = asyncio.run(fileio_env["file_io"].read("nonexistent.txt"))
+ text = result.content[0].get("text", "")
+ assert "Error" in text
+ assert "does not exist" in text
+
+
+def test_read_file_with_line_range(fileio_env):
+ """Test reading specific line range."""
+ result = asyncio.run(
+ fileio_env["file_io"].read(fileio_env["simple_file"], start_line=2, end_line=4),
+ )
+ text = result.content[0].get("text", "")
+ assert "line2" in text
+ assert "line4" in text
+ assert "lines 2-4" in text.lower()
+
+
+def test_read_file_start_line_exceeds(fileio_env):
+ """Test start_line exceeding file length."""
+ result = asyncio.run(
+ fileio_env["file_io"].read(fileio_env["simple_file"], start_line=100),
+ )
+ text = result.content[0].get("text", "")
+ assert "Error" in text
+ assert "exceeds" in text
+
+
+def test_read_file_invalid_range(fileio_env):
+ """Test invalid line range (start > end)."""
+ result = asyncio.run(
+ fileio_env["file_io"].read(fileio_env["simple_file"], start_line=4, end_line=2),
+ )
+ text = result.content[0].get("text", "")
+ assert "Error" in text
+
+
+def test_read_file_truncated_by_lines(fileio_env):
+ """Test file truncation by line limit."""
+ result = asyncio.run(fileio_env["file_io"].read(fileio_env["large_file"]))
+ text = result.content[0].get("text", "")
+ assert "line 1" in text # Head is kept
+ assert "continue" in text.lower()
+
+
+def test_read_file_truncated_by_bytes(fileio_env):
+ """Test file truncation by byte limit."""
+ result = asyncio.run(fileio_env["file_io"].read(fileio_env["large_bytes_file"]))
+ text = result.content[0].get("text", "")
+ assert "continue" in text.lower() or "KB" in text
+
+
+def test_read_directory_error(fileio_env):
+ """Test reading a directory returns error."""
+ result = asyncio.run(fileio_env["file_io"].read(fileio_env["dir"]))
+ text = result.content[0].get("text", "")
+ assert "Error" in text
+ assert "not a file" in text
+
+
+# ============ FileIO Write Tests ============
+
+
+@pytest.fixture
+def write_env():
+ """Create temporary directory for write tests."""
+ test_dir = tempfile.mkdtemp(prefix="test_fileio_write_")
+ file_io = FileIO(working_dir=test_dir)
+ yield {"dir": test_dir, "file_io": file_io}
+ shutil.rmtree(test_dir, ignore_errors=True)
+
+
+def test_write_new_file(write_env):
+ """Test writing a new file."""
+ file_path = os.path.join(write_env["dir"], "new_file.txt")
+ result = asyncio.run(write_env["file_io"].write(file_path, "test content"))
+ text = result.content[0].get("text", "")
+ assert "Wrote" in text
+
+ with open(file_path, "r", encoding="utf-8") as f:
+ assert f.read() == "test content"
+
+
+def test_write_overwrite_file(write_env):
+ """Test overwriting existing file."""
+ file_path = os.path.join(write_env["dir"], "overwrite.txt")
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.write("old content")
+
+ result = asyncio.run(write_env["file_io"].write(file_path, "new content"))
+ text = result.content[0].get("text", "")
+ assert "Wrote" in text
+
+ with open(file_path, "r", encoding="utf-8") as f:
+ assert f.read() == "new content"
+
+
+def test_write_empty_path(write_env):
+ """Test writing with empty path."""
+ result = asyncio.run(write_env["file_io"].write("", "content"))
+ text = result.content[0].get("text", "")
+ assert "Error" in text
+
+
+def test_write_relative_path(write_env):
+ """Test writing file with relative path."""
+ result = asyncio.run(write_env["file_io"].write("relative.txt", "relative content"))
+ text = result.content[0].get("text", "")
+ assert "Wrote" in text
+
+ file_path = os.path.join(write_env["dir"], "relative.txt")
+ assert os.path.exists(file_path)
+
+
+# ============ FileIO Edit Tests ============
+
+
+@pytest.fixture
+def edit_env():
+ """Create temporary directory with edit test file."""
+ test_dir = tempfile.mkdtemp(prefix="test_fileio_edit_")
+ file_io = FileIO(working_dir=test_dir)
+
+ edit_file = os.path.join(test_dir, "edit_test.txt")
+ with open(edit_file, "w", encoding="utf-8") as f:
+ f.write("Hello World\nThis is a test\nHello Again")
+
+ yield {"dir": test_dir, "file_io": file_io, "edit_file": edit_file}
+ shutil.rmtree(test_dir, ignore_errors=True)
+
+
+def test_edit_replace_text(edit_env):
+ """Test replacing text in file."""
+ result = asyncio.run(
+ edit_env["file_io"].edit(edit_env["edit_file"], "Hello", "Hi"),
+ )
+ text = result.content[0].get("text", "")
+ assert "Successfully" in text
+
+ with open(edit_env["edit_file"], "r", encoding="utf-8") as f:
+ content = f.read()
+ assert "Hello" not in content
+ assert "Hi World" in content
+ assert "Hi Again" in content
+
+
+def test_edit_text_not_found(edit_env):
+ """Test editing when text not found."""
+ result = asyncio.run(
+ edit_env["file_io"].edit(edit_env["edit_file"], "NotExists", "Replacement"),
+ )
+ text = result.content[0].get("text", "")
+ assert "Error" in text
+ assert "not found" in text
+
+
+def test_edit_nonexistent_file(edit_env):
+ """Test editing non-existent file."""
+ result = asyncio.run(
+ edit_env["file_io"].edit("nonexistent.txt", "old", "new"),
+ )
+ text = result.content[0].get("text", "")
+ assert "Error" in text
diff --git a/tests/light/test_utils.py b/tests/light/test_utils.py
new file mode 100644
index 00000000..27583948
--- /dev/null
+++ b/tests/light/test_utils.py
@@ -0,0 +1,456 @@
+"""Test utilities for copaw tests."""
+
+import os
+
+from agentscope.message import Msg, ThinkingBlock, TextBlock, ToolUseBlock, ToolResultBlock
+
+from reme.memory.file_based.utils import AsMsgHandler
+
+
+def get_token_counter():
+ """Get HF token counter instance."""
+ from reme.core.utils import get_hf_token_counter
+
+ return get_hf_token_counter()
+
+
+def get_msg_handler() -> AsMsgHandler:
+ """Get AsMsgHandler instance."""
+ return AsMsgHandler(token_counter=get_token_counter())
+
+
+def get_dash_chat_model(model_name: str = "qwen3.5-plus"):
+ """Get DashScope chat model instance."""
+ from agentscope.model import OpenAIChatModel
+ from reme.core.utils import load_env
+
+ load_env()
+ return OpenAIChatModel(
+ api_key=os.environ["LLM_API_KEY"],
+ client_kwargs={"base_url": os.environ["LLM_BASE_URL"]},
+ model_name=model_name,
+ )
+
+
+def get_formatter():
+ """Get formatter instance."""
+ from agentscope.formatter import OpenAIChatFormatter
+
+ return OpenAIChatFormatter()
+
+
+def generate_large_code_content(target_tokens: int = 50000) -> str:
+ """生成大量代码内容,用于测试超长 tool_result。
+
+ Args:
+ target_tokens: 目标 token 数(约 4 字符/token)
+
+ Returns:
+ 生成的代码内容字符串
+ """
+ code_template = '''
+# === File: src/module_{idx}/handlers.py ===
+"""Handler module {idx} for processing requests."""
+
+import asyncio
+import logging
+from typing import Any, Dict, List, Optional
+from dataclasses import dataclass, field
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class RequestContext_{idx}:
+ """Context for request processing in module {idx}."""
+ request_id: str
+ user_id: str
+ timestamp: datetime = field(default_factory=datetime.now)
+ metadata: Dict[str, Any] = field(default_factory=dict)
+ headers: Dict[str, str] = field(default_factory=dict)
+ query_params: Dict[str, str] = field(default_factory=dict)
+ body: Optional[bytes] = None
+ processed: bool = False
+ error_message: Optional[str] = None
+
+
+class Handler_{idx}:
+ """Main handler class for module {idx}."""
+
+ def __init__(self, config: Dict[str, Any]):
+ self.config = config
+ self.cache: Dict[str, Any] = {{}}
+ self.metrics: Dict[str, int] = {{
+ "requests_processed": 0,
+ "errors": 0,
+ "cache_hits": 0,
+ "cache_misses": 0,
+ }}
+ self._initialized = False
+ logger.info(f"Handler_{idx} initialized with config: {{config}}")
+
+ async def initialize(self) -> None:
+ """Initialize the handler with async resources."""
+ if self._initialized:
+ logger.warning("Handler_{idx} already initialized")
+ return
+
+ # Simulate async initialization
+ await asyncio.sleep(0.01)
+ self._initialized = True
+ logger.info("Handler_{idx} initialization complete")
+
+ async def process_request(self, context: RequestContext_{idx}) -> Dict[str, Any]:
+ """Process an incoming request.
+
+ Args:
+ context: The request context containing all request data
+
+ Returns:
+ Dict containing the response data
+ """
+ if not self._initialized:
+ raise RuntimeError("Handler not initialized")
+
+ self.metrics["requests_processed"] += 1
+
+ try:
+ # Check cache first
+ cache_key = f"{{context.request_id}}_{{context.user_id}}"
+ if cache_key in self.cache:
+ self.metrics["cache_hits"] += 1
+ return self.cache[cache_key]
+
+ self.metrics["cache_misses"] += 1
+
+ # Process the request
+ result = await self._do_process(context)
+
+ # Cache the result
+ self.cache[cache_key] = result
+ context.processed = True
+
+ return result
+
+ except Exception as e:
+ self.metrics["errors"] += 1
+ context.error_message = str(e)
+ logger.exception(f"Error processing request {{context.request_id}}: {{e}}")
+ raise
+
+ async def _do_process(self, context: RequestContext_{idx}) -> Dict[str, Any]:
+ """Internal processing logic."""
+ # Simulate some processing
+ await asyncio.sleep(0.001)
+
+ return {{
+ "status": "success",
+ "request_id": context.request_id,
+ "user_id": context.user_id,
+ "processed_at": datetime.now().isoformat(),
+ "module": "module_{idx}",
+ "data": {{
+ "result": f"Processed by handler_{idx}",
+ "metadata": context.metadata,
+ }}
+ }}
+
+ def get_metrics(self) -> Dict[str, int]:
+ """Return current metrics."""
+ return self.metrics.copy()
+
+ async def cleanup(self) -> None:
+ """Cleanup resources."""
+ self.cache.clear()
+ self._initialized = False
+ logger.info("Handler_{idx} cleaned up")
+
+'''
+
+ # 每个模块约 2000 字符 ≈ 500 tokens
+ # 目标 target_tokens,需要 target_tokens / 500 个模块
+ num_modules = max(1, target_tokens // 500)
+
+ parts = [f"# 大型项目代码检索结果\n# 共找到 {num_modules} 个相关模块\n"]
+ for i in range(num_modules):
+ parts.append(code_template.format(idx=i))
+
+ return "".join(parts)
+
+
+def build_sample_messages(include_large_tool_result: bool = True) -> list[Msg]:
+ """构建一段包含多种消息类型的模拟对话。
+
+ Args:
+ include_large_tool_result: 是否包含大型 tool_result,确保超过 128K token
+
+ Returns:
+ 消息列表
+ """
+ messages = [
+ Msg(
+ name="user",
+ role="user",
+ content="我想设置一个 Python 开发环境,你有什么建议?",
+ ),
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ ThinkingBlock(type="thinking", thinking="用户想要搭建 Python 开发环境,我需要了解他的需求和偏好..."),
+ TextBlock(
+ type="text",
+ text="好的!我建议使用 Python 3.11 或 3.12 版本,它们性能更好且功能丰富。"
+ "你希望用于什么类型的开发?Web、数据科学还是其他?",
+ ),
+ ],
+ ),
+ Msg(name="user", role="user", content="主要是做 Web 开发,使用 FastAPI 框架。另外我喜欢用 pyenv 管理版本。"),
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ ThinkingBlock(type="thinking", thinking="用户要做 FastAPI Web 开发,我需要搜索相关信息..."),
+ ToolUseBlock(
+ type="tool_use",
+ id="call_001",
+ name="search_web",
+ input={"query": "FastAPI Python version compatibility 2024"},
+ ),
+ ],
+ ),
+ Msg(
+ name="system",
+ role="system",
+ content=[
+ ToolResultBlock(
+ type="tool_result",
+ id="call_001",
+ name="search_web",
+ output=[
+ TextBlock(
+ type="text",
+ text="FastAPI 官方推荐使用 Python 3.8+ 版本,但 3.11/3.12 性能最佳。\n"
+ "主要依赖:\n"
+ "- Starlette: ASGI 框架\n"
+ "- Pydantic v2: 数据验证\n"
+ "- Uvicorn: ASGI 服务器\n"
+ "最新版本 FastAPI 0.109+ 完全支持 Python 3.12。\n"
+ "建议搭配 uv 或 pip-tools 进行依赖管理。",
+ ),
+ ],
+ ),
+ ],
+ ),
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ ThinkingBlock(
+ type="thinking",
+ thinking="根据搜索结果,FastAPI 完全支持 Python 3.12,结合用户使用 pyenv 的习惯,我来给出完整的环境配置建议...",
+ ),
+ TextBlock(
+ type="text",
+ text="根据最新信息,我为你整理了 FastAPI 开发环境配置方案:\n\n"
+ "**1. Python 版本**\n"
+ "推荐 Python 3.12,性能最佳且完全兼容 FastAPI 0.109+。\n\n"
+ "**2. pyenv 安装步骤**\n"
+ "```bash\n"
+ "pyenv install 3.12.2\n"
+ "pyenv local 3.12.2\n"
+ "```\n\n"
+ "**3. 虚拟环境与依赖管理**\n"
+ "建议使用 `uv` 作为包管理器,它比 pip 快 10-100 倍。\n\n"
+ "需要我帮你生成项目初始化脚本吗?",
+ ),
+ ],
+ ),
+ Msg(name="user", role="user", content="好的,请帮我生成一个完整的项目初始化脚本,包括 pyproject.toml 配置。"),
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ ThinkingBlock(
+ type="thinking",
+ thinking="用户需要完整的项目初始化脚本,我需要创建 pyproject.toml 并设置好 FastAPI 相关依赖...",
+ ),
+ ToolUseBlock(
+ type="tool_use",
+ id="call_002",
+ name="create_file",
+ input={
+ "path": "pyproject.toml",
+ "content": "[project]\nname = 'my-fastapi-app'\nversion = '0.1.0'\nrequires-python = "
+ "'>=3.11'\ndependencies = ['fastapi>=0.109.0', 'uvicorn[standard]>=0.27.0', "
+ "'pydantic>=2.0']",
+ },
+ ),
+ ],
+ ),
+ Msg(
+ name="system",
+ role="system",
+ content=[
+ ToolResultBlock(
+ type="tool_result",
+ id="call_002",
+ name="create_file",
+ output=[
+ TextBlock(type="text", text="文件 pyproject.toml 已创建成功。"),
+ ],
+ ),
+ ],
+ ),
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ ToolUseBlock(
+ type="tool_use",
+ id="call_003",
+ name="create_file",
+ input={
+ "path": "init_project.sh",
+ "content": "#!/bin/bash\nset -e\n\n# 使用 pyenv 设置 Python 版本\npyenv install 3.12.2 "
+ "--skip-existing\npyenv local 3.12.2\n\n# 创建虚拟环境\npython -m venv "
+ ".venv\nsource .venv/bin/activate\n\n# 安装 uv 并使用它安装依赖\npip install uv"
+ "\nuv pip install -e .\n\necho '环境初始化完成!'",
+ },
+ ),
+ ],
+ ),
+ Msg(
+ name="system",
+ role="system",
+ content=[
+ ToolResultBlock(
+ type="tool_result",
+ id="call_003",
+ name="create_file",
+ output=[
+ TextBlock(type="text", text="文件 init_project.sh 已创建成功。"),
+ ],
+ ),
+ ],
+ ),
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ TextBlock(
+ type="text",
+ text="我已为你创建了两个文件:\n\n"
+ "1. **pyproject.toml** - 项目配置文件,包含 FastAPI 核心依赖\n"
+ "2. **init_project.sh** - 一键初始化脚本\n\n"
+ "运行以下命令即可初始化项目:\n"
+ "```bash\n"
+ "chmod +x init_project.sh && ./init_project.sh\n"
+ "```\n\n"
+ "还有什么需要帮助的吗?",
+ ),
+ ],
+ ),
+ Msg(name="user", role="user", content="太棒了!请帮我搜索一下项目中所有的 handler 相关代码。"),
+ ]
+
+ # 添加大型代码搜索结果(确保超过 128K token)
+ if include_large_tool_result:
+ # 生成超大的代码搜索结果,目标 ~140K tokens
+ large_code_content = generate_large_code_content(target_tokens=140000)
+
+ messages.extend(
+ [
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ ThinkingBlock(
+ type="thinking",
+ thinking="用户要我搜索项目中的 handler 代码,我需要使用代码搜索工具...",
+ ),
+ ToolUseBlock(
+ type="tool_use",
+ id="call_004",
+ name="search_codebase",
+ input={"query": "handler class implementation"},
+ ),
+ ],
+ ),
+ Msg(
+ name="system",
+ role="system",
+ content=[
+ ToolResultBlock(
+ type="tool_result",
+ id="call_004",
+ name="search_codebase",
+ output=[
+ TextBlock(type="text", text=large_code_content),
+ ],
+ ),
+ ],
+ ),
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ ThinkingBlock(type="thinking", thinking="搜索返回了大量 handler 代码,我需要为用户整理一下..."),
+ TextBlock(
+ type="text",
+ text="我已经找到了项目中所有的 handler 相关代码。\n\n"
+ "这些 handler 类包含:\n"
+ "- 请求处理逻辑\n"
+ "- 缓存管理\n"
+ "- 指标统计\n"
+ "- 异步初始化\n\n"
+ "你需要我详细解释某个具体的 handler 吗?",
+ ),
+ ],
+ ),
+ ],
+ )
+
+ # 添加更多对话
+ messages.extend(
+ [
+ Msg(
+ name="user",
+ role="user",
+ content="还有一个问题,我应该如何配置 VS Code 来获得最佳的 FastAPI 开发体验?",
+ ),
+ Msg(
+ name="assistant",
+ role="assistant",
+ content=[
+ ThinkingBlock(
+ type="thinking",
+ thinking="用户询问 VS Code 配置,我需要推荐适合 FastAPI 开发的扩展和设置...",
+ ),
+ TextBlock(
+ type="text",
+ text="VS Code 的 FastAPI 开发配置建议:\n\n"
+ "**推荐扩展:**\n"
+ "- Python (Microsoft)\n"
+ "- Pylance - 类型检查和智能补全\n"
+ "- Ruff - 快速 linter 和 formatter\n"
+ "- REST Client - API 测试\n\n"
+ "**settings.json 配置:**\n"
+ "```json\n"
+ "{\n"
+ ' "python.defaultInterpreterPath": ".venv/bin/python",\n'
+ ' "[python]": {\n'
+ ' "editor.defaultFormatter": "charliermarsh.ruff",\n'
+ ' "editor.formatOnSave": true\n'
+ " }\n"
+ "}\n"
+ "```\n\n"
+ "这样配置后,你就能获得完整的类型提示和自动格式化支持了!",
+ ),
+ ],
+ ),
+ ],
+ )
+
+ return messages
diff --git a/tests/test_base_file_watcher.py b/tests/test_base_file_watcher.py
new file mode 100644
index 00000000..ca310eb3
--- /dev/null
+++ b/tests/test_base_file_watcher.py
@@ -0,0 +1,791 @@
+"""
+Async unit tests for BaseFileWatcher covering:
+- Existing paths and files monitoring
+- Non-existent paths handling
+- File suffix filtering
+- Start/stop lifecycle
+- Callback functionality
+- scan_on_start feature
+
+Usage:
+ pytest tests/test_base_file_watcher.py -v
+ pytest tests/test_base_file_watcher.py -v -k "test_existing"
+"""
+
+# pylint: disable=redefined-outer-name,protected-access,unused-argument
+
+import asyncio
+import tempfile
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from watchfiles import Change
+
+from reme.core.file_watcher.base_file_watcher import BaseFileWatcher
+
+
+# ==================== Fixtures ====================
+
+
+@pytest.fixture
+def temp_dir():
+ """Create a temporary directory for testing."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ yield Path(tmpdir)
+
+
+@pytest.fixture
+def temp_files(temp_dir: Path):
+ """Create temporary test files."""
+ files = {}
+
+ # Create .txt files
+ for i in range(3):
+ file_path = temp_dir / f"test_file_{i}.txt"
+ file_path.write_text(f"Content of test file {i}")
+ files[f"txt_{i}"] = file_path
+
+ # Create .py files
+ for i in range(2):
+ file_path = temp_dir / f"test_script_{i}.py"
+ file_path.write_text(f"# Python script {i}\nprint('hello')")
+ files[f"py_{i}"] = file_path
+
+ # Create .md file
+ md_file = temp_dir / "readme.md"
+ md_file.write_text("# README")
+ files["md_0"] = md_file
+
+ yield files
+
+
+@pytest.fixture
+def temp_nested_dir(temp_dir: Path):
+ """Create nested directory structure."""
+ # Create subdirectories
+ sub1 = temp_dir / "subdir1"
+ sub1.mkdir()
+ sub2 = temp_dir / "subdir2"
+ sub2.mkdir()
+ nested = sub1 / "nested"
+ nested.mkdir()
+
+ # Create files in subdirectories
+ (sub1 / "file1.txt").write_text("subdir1 file")
+ (sub2 / "file2.txt").write_text("subdir2 file")
+ (nested / "nested_file.txt").write_text("nested file")
+
+ yield temp_dir
+
+
+# ==================== Test Existing Paths ====================
+
+
+class TestExistingPaths:
+ """Tests for existing paths and files."""
+
+ @pytest.mark.asyncio
+ async def test_init_with_single_existing_path(self, temp_dir: Path):
+ """Test initialization with a single existing path."""
+ watcher = BaseFileWatcher(watch_paths=str(temp_dir))
+
+ assert watcher.watch_paths == [str(temp_dir)]
+ assert watcher.recursive is False
+ assert watcher.is_running() is False
+
+ @pytest.mark.asyncio
+ async def test_init_with_multiple_existing_paths(self, temp_dir: Path):
+ """Test initialization with multiple existing paths."""
+ sub1 = temp_dir / "dir1"
+ sub2 = temp_dir / "dir2"
+ sub1.mkdir()
+ sub2.mkdir()
+
+ watcher = BaseFileWatcher(watch_paths=[str(sub1), str(sub2)])
+
+ assert len(watcher.watch_paths) == 2
+ assert str(sub1) in watcher.watch_paths
+ assert str(sub2) in watcher.watch_paths
+
+ @pytest.mark.asyncio
+ async def test_init_with_existing_file(self, temp_files):
+ """Test initialization with existing file path."""
+ file_path = temp_files["txt_0"]
+ watcher = BaseFileWatcher(watch_paths=str(file_path))
+
+ assert watcher.watch_paths == [str(file_path)]
+
+ @pytest.mark.asyncio
+ async def test_start_with_existing_path(self, temp_dir: Path):
+ """Test starting watcher with existing path."""
+ watcher = BaseFileWatcher(watch_paths=str(temp_dir))
+
+ await watcher.start()
+ assert watcher.is_running() is True
+
+ await watcher.close()
+ assert watcher.is_running() is False
+
+ @pytest.mark.asyncio
+ async def test_start_stop_lifecycle(self, temp_dir: Path):
+ """Test watcher start/stop lifecycle."""
+ watcher = BaseFileWatcher(watch_paths=str(temp_dir))
+
+ # Start
+ await watcher.start()
+ assert watcher.is_running() is True
+ assert watcher._watch_task is not None
+
+ # Stop
+ await watcher.close()
+ assert watcher.is_running() is False
+
+ # Restart
+ await watcher.start()
+ assert watcher.is_running() is True
+
+ await watcher.close()
+
+ @pytest.mark.asyncio
+ async def test_multiple_start_calls(self, temp_dir: Path):
+ """Test that multiple start calls don't create multiple tasks."""
+ watcher = BaseFileWatcher(watch_paths=str(temp_dir))
+
+ await watcher.start()
+ task1 = watcher._watch_task
+
+ await watcher.start() # Second call should be ignored
+ task2 = watcher._watch_task
+
+ assert task1 is task2
+ await watcher.close()
+
+ @pytest.mark.asyncio
+ async def test_multiple_close_calls(self, temp_dir: Path):
+ """Test that multiple close calls are safe."""
+ watcher = BaseFileWatcher(watch_paths=str(temp_dir))
+
+ await watcher.start()
+ await watcher.close()
+ await watcher.close() # Second call should be safe
+
+ assert watcher.is_running() is False
+
+
+# ==================== Test Non-Existent Paths ====================
+
+
+class TestNonExistentPaths:
+ """Tests for non-existent paths handling."""
+
+ @pytest.mark.asyncio
+ async def test_init_with_nonexistent_path(self):
+ """Test initialization with non-existent path."""
+ nonexistent = "/path/that/does/not/exist"
+ watcher = BaseFileWatcher(watch_paths=nonexistent)
+
+ assert watcher.watch_paths == [nonexistent]
+
+ @pytest.mark.asyncio
+ async def test_start_with_nonexistent_path(self):
+ """Test starting watcher with non-existent path (should handle gracefully)."""
+ nonexistent = "/path/that/does/not/exist"
+ watcher = BaseFileWatcher(watch_paths=nonexistent)
+
+ await watcher.start()
+ assert watcher.is_running() is True
+
+ # Give it a moment to enter the watch loop and detect the missing path
+ await asyncio.sleep(0.1)
+
+ await watcher.close()
+ assert watcher.is_running() is False
+
+ @pytest.mark.asyncio
+ async def test_mixed_existing_and_nonexistent_paths(self, temp_dir: Path):
+ """Test with mix of existing and non-existent paths."""
+ nonexistent = "/path/that/does/not/exist"
+ watcher = BaseFileWatcher(watch_paths=[str(temp_dir), nonexistent])
+
+ await watcher.start()
+ assert watcher.is_running() is True
+
+ # Give it time to filter paths
+ await asyncio.sleep(0.1)
+
+ await watcher.close()
+
+ @pytest.mark.asyncio
+ async def test_all_paths_nonexistent(self):
+ """Test when all paths are non-existent."""
+ watcher = BaseFileWatcher(
+ watch_paths=["/nonexistent1", "/nonexistent2"],
+ )
+
+ await watcher.start()
+ assert watcher.is_running() is True
+
+ # Wait for retry logic
+ await asyncio.sleep(0.2)
+
+ await watcher.close()
+
+ @pytest.mark.asyncio
+ async def test_empty_watch_paths(self):
+ """Test with empty watch paths list."""
+ watcher = BaseFileWatcher(watch_paths=[])
+
+ await watcher.start()
+ assert watcher.is_running() is True
+
+ await asyncio.sleep(0.1)
+ await watcher.close()
+
+
+# ==================== Test File Filtering ====================
+
+
+class TestFileFiltering:
+ """Tests for file suffix filtering."""
+
+ @pytest.mark.asyncio
+ async def test_watch_filter_no_filters(self, temp_files):
+ """Test watch_filter with no suffix filters (should match all)."""
+ watcher = BaseFileWatcher(watch_paths="/tmp")
+
+ assert watcher.watch_filter(Change.added, "test.txt") is True
+ assert watcher.watch_filter(Change.added, "test.py") is True
+ assert watcher.watch_filter(Change.added, "test.md") is True
+ assert watcher.watch_filter(Change.added, "noextension") is True
+
+ @pytest.mark.asyncio
+ async def test_watch_filter_with_txt_suffix(self):
+ """Test watch_filter with .txt suffix filter."""
+ watcher = BaseFileWatcher(watch_paths="/tmp", suffix_filters=[".txt"])
+
+ assert watcher.watch_filter(Change.added, "test.txt") is True
+ assert watcher.watch_filter(Change.added, "test.py") is False
+ assert watcher.watch_filter(Change.added, "file.txt.bak") is False
+
+ @pytest.mark.asyncio
+ async def test_watch_filter_with_multiple_suffixes(self):
+ """Test watch_filter with multiple suffix filters."""
+ watcher = BaseFileWatcher(
+ watch_paths="/tmp",
+ suffix_filters=[".txt", ".py", ".md"],
+ )
+
+ assert watcher.watch_filter(Change.added, "test.txt") is True
+ assert watcher.watch_filter(Change.added, "script.py") is True
+ assert watcher.watch_filter(Change.added, "readme.md") is True
+ assert watcher.watch_filter(Change.added, "config.json") is False
+
+ @pytest.mark.asyncio
+ async def test_watch_filter_suffix_without_dot(self):
+ """Test watch_filter handles suffixes without leading dot."""
+ watcher = BaseFileWatcher(
+ watch_paths="/tmp",
+ suffix_filters=["txt", "py"], # Without dots
+ )
+
+ assert watcher.watch_filter(Change.added, "test.txt") is True
+ assert watcher.watch_filter(Change.added, "script.py") is True
+
+ @pytest.mark.asyncio
+ async def test_watch_filter_all_change_types(self):
+ """Test watch_filter works with all Change types."""
+ watcher = BaseFileWatcher(
+ watch_paths="/tmp",
+ suffix_filters=[".txt"],
+ )
+
+ # All change types should work with filter
+ assert watcher.watch_filter(Change.added, "test.txt") is True
+ assert watcher.watch_filter(Change.modified, "test.txt") is True
+ assert watcher.watch_filter(Change.deleted, "test.txt") is True
+
+
+# ==================== Test Callback Functionality ====================
+
+
+class TestCallbackFunctionality:
+ """Tests for callback functionality."""
+
+ @pytest.mark.asyncio
+ async def test_sync_callback(self, temp_dir: Path):
+ """Test synchronous callback function."""
+ callback_called = []
+
+ def sync_callback(changes):
+ callback_called.append(changes)
+
+ watcher = BaseFileWatcher(
+ watch_paths=str(temp_dir),
+ callback=sync_callback,
+ )
+
+ # Simulate changes
+ test_changes = {(Change.added, str(temp_dir / "test.txt"))}
+ await watcher.on_changes(test_changes)
+
+ assert len(callback_called) == 1
+ assert callback_called[0] == test_changes
+
+ @pytest.mark.asyncio
+ async def test_async_callback(self, temp_dir: Path):
+ """Test asynchronous callback function."""
+ callback_called = []
+
+ async def async_callback(changes):
+ callback_called.append(changes)
+
+ watcher = BaseFileWatcher(
+ watch_paths=str(temp_dir),
+ callback=async_callback,
+ )
+
+ # Simulate changes
+ test_changes = {(Change.modified, str(temp_dir / "test.txt"))}
+ await watcher.on_changes(test_changes)
+
+ assert len(callback_called) == 1
+ assert callback_called[0] == test_changes
+
+ @pytest.mark.asyncio
+ async def test_no_callback_uses_internal_handler(self, temp_dir: Path):
+ """Test that without callback, internal _on_changes is used."""
+ watcher = BaseFileWatcher(watch_paths=str(temp_dir))
+
+ # Mock internal _on_changes
+ watcher._on_changes = AsyncMock()
+
+ test_changes = {(Change.added, str(temp_dir / "test.txt"))}
+ await watcher.on_changes(test_changes)
+
+ watcher._on_changes.assert_called_once_with(test_changes)
+
+
+# ==================== Test Scan on Start ====================
+
+
+class TestScanOnStart:
+ """Tests for scan_on_start feature."""
+
+ @pytest.mark.asyncio
+ async def test_scan_on_start_false(self, temp_files, temp_dir: Path):
+ """Test that scan_on_start=False doesn't scan existing files."""
+ callback_called = []
+
+ async def callback(changes):
+ callback_called.append(changes)
+
+ # Create mock file_store
+ mock_file_store = MagicMock()
+ mock_file_store.list_files = AsyncMock(return_value=[])
+ mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+
+ watcher = BaseFileWatcher(
+ watch_paths=str(temp_dir),
+ scan_on_start=False,
+ callback=callback,
+ file_store=mock_file_store,
+ )
+
+ await watcher.start()
+ await asyncio.sleep(0.1)
+ await watcher.close()
+
+ # No callback should be called for existing files
+ assert len(callback_called) == 0
+
+ @pytest.mark.asyncio
+ async def test_scan_on_start_true_with_files(self, temp_files, temp_dir: Path):
+ """Test that scan_on_start=True scans existing files."""
+ callback_called = []
+
+ async def callback(changes):
+ callback_called.append(changes)
+
+ # Create mock file_store
+ mock_file_store = MagicMock()
+ mock_file_store.list_files = AsyncMock(return_value=[])
+ mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+
+ watcher = BaseFileWatcher(
+ watch_paths=str(temp_dir),
+ scan_on_start=True,
+ callback=callback,
+ file_store=mock_file_store,
+ )
+
+ await watcher.start()
+ await asyncio.sleep(0.1)
+ await watcher.close()
+
+ # Callback should be called with existing files
+ assert len(callback_called) >= 1
+
+ # Check that files were detected as Change.added
+ all_changes = set()
+ for change_set in callback_called:
+ all_changes.update(change_set)
+
+ assert all(change == Change.added for change, _ in all_changes)
+
+ @pytest.mark.asyncio
+ async def test_scan_on_start_with_suffix_filter(self, temp_files, temp_dir: Path):
+ """Test scan_on_start respects suffix filters."""
+ callback_called = []
+
+ async def callback(changes):
+ callback_called.append(changes)
+
+ mock_file_store = MagicMock()
+ mock_file_store.list_files = AsyncMock(return_value=[])
+ mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+
+ watcher = BaseFileWatcher(
+ watch_paths=str(temp_dir),
+ scan_on_start=True,
+ suffix_filters=[".txt"],
+ callback=callback,
+ file_store=mock_file_store,
+ )
+
+ await watcher.start()
+ await asyncio.sleep(0.1)
+ await watcher.close()
+
+ # Check only .txt files were scanned
+ if callback_called:
+ all_changes = set()
+ for change_set in callback_called:
+ all_changes.update(change_set)
+
+ for _, path in all_changes:
+ assert path.endswith(".txt"), f"Expected .txt file, got {path}"
+
+ @pytest.mark.asyncio
+ async def test_scan_on_start_recursive(self, temp_nested_dir: Path):
+ """Test scan_on_start with recursive=True."""
+ callback_called = []
+
+ async def callback(changes):
+ callback_called.append(changes)
+
+ mock_file_store = MagicMock()
+ mock_file_store.list_files = AsyncMock(return_value=[])
+ mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+
+ watcher = BaseFileWatcher(
+ watch_paths=str(temp_nested_dir),
+ scan_on_start=True,
+ recursive=True,
+ suffix_filters=[".txt"],
+ callback=callback,
+ file_store=mock_file_store,
+ )
+
+ await watcher.start()
+ await asyncio.sleep(0.1)
+ await watcher.close()
+
+ # Should find files in nested directories
+ if callback_called:
+ all_changes = set()
+ for change_set in callback_called:
+ all_changes.update(change_set)
+
+ paths = [path for _, path in all_changes]
+ # Should find nested_file.txt
+ nested_found = any("nested_file.txt" in p for p in paths)
+ assert nested_found, "Should find files in nested directories"
+
+ @pytest.mark.asyncio
+ async def test_scan_on_start_non_recursive(self, temp_nested_dir: Path):
+ """Test scan_on_start with recursive=False."""
+ callback_called = []
+
+ async def callback(changes):
+ callback_called.append(changes)
+
+ mock_file_store = MagicMock()
+ mock_file_store.list_files = AsyncMock(return_value=[])
+ mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+
+ watcher = BaseFileWatcher(
+ watch_paths=str(temp_nested_dir),
+ scan_on_start=True,
+ recursive=False,
+ suffix_filters=[".txt"],
+ callback=callback,
+ file_store=mock_file_store,
+ )
+
+ await watcher.start()
+ await asyncio.sleep(0.1)
+ await watcher.close()
+
+ # Should NOT find files in nested directories
+ if callback_called:
+ all_changes = set()
+ for change_set in callback_called:
+ all_changes.update(change_set)
+
+ paths = [path for _, path in all_changes]
+ nested_found = any("nested_file.txt" in p for p in paths)
+ assert not nested_found, "Should not find files in nested directories"
+
+ @pytest.mark.asyncio
+ async def test_scan_on_start_nonexistent_path(self):
+ """Test scan_on_start with non-existent path."""
+ callback_called = []
+
+ async def callback(changes):
+ callback_called.append(changes)
+
+ mock_file_store = MagicMock()
+ mock_file_store.list_files = AsyncMock(return_value=[])
+ mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+
+ watcher = BaseFileWatcher(
+ watch_paths="/nonexistent/path",
+ scan_on_start=True,
+ callback=callback,
+ file_store=mock_file_store,
+ )
+
+ await watcher.start()
+ await asyncio.sleep(0.1)
+ await watcher.close()
+
+ # No files should be found
+ assert len(callback_called) == 0
+
+
+# ==================== Test Dynamic Path Management ====================
+
+
+class TestDynamicPathManagement:
+ """Tests for dynamic path add/remove."""
+
+ @pytest.mark.asyncio
+ async def test_add_path_when_stopped(self, temp_dir: Path):
+ """Test adding path when watcher is stopped."""
+ watcher = BaseFileWatcher(watch_paths=str(temp_dir))
+
+ new_dir = temp_dir / "new_dir"
+ new_dir.mkdir()
+
+ await watcher.add_path(str(new_dir))
+
+ assert str(new_dir) in watcher.watch_paths
+
+ @pytest.mark.asyncio
+ async def test_add_path_when_running(self, temp_dir: Path):
+ """Test adding path when watcher is running (triggers restart)."""
+ watcher = BaseFileWatcher(watch_paths=str(temp_dir))
+
+ await watcher.start()
+ assert watcher.is_running()
+
+ new_dir = temp_dir / "new_dir"
+ new_dir.mkdir()
+
+ await watcher.add_path(str(new_dir))
+
+ assert str(new_dir) in watcher.watch_paths
+ assert watcher.is_running()
+
+ await watcher.close()
+
+ @pytest.mark.asyncio
+ async def test_add_duplicate_path(self, temp_dir: Path):
+ """Test adding duplicate path is ignored."""
+ watcher = BaseFileWatcher(watch_paths=str(temp_dir))
+
+ original_count = len(watcher.watch_paths)
+ await watcher.add_path(str(temp_dir))
+
+ assert len(watcher.watch_paths) == original_count
+
+ @pytest.mark.asyncio
+ async def test_remove_path(self, temp_dir: Path):
+ """Test removing path."""
+ sub1 = temp_dir / "sub1"
+ sub2 = temp_dir / "sub2"
+ sub1.mkdir()
+ sub2.mkdir()
+
+ watcher = BaseFileWatcher(watch_paths=[str(sub1), str(sub2)])
+
+ await watcher.remove_path(str(sub1))
+
+ assert str(sub1) not in watcher.watch_paths
+ assert str(sub2) in watcher.watch_paths
+
+ @pytest.mark.asyncio
+ async def test_remove_nonexistent_path(self, temp_dir: Path):
+ """Test removing path that's not in watch list."""
+ watcher = BaseFileWatcher(watch_paths=str(temp_dir))
+
+ original_paths = watcher.watch_paths.copy()
+ await watcher.remove_path("/some/other/path")
+
+ assert watcher.watch_paths == original_paths
+
+
+# ==================== Test Configuration Options ====================
+
+
+class TestConfigurationOptions:
+ """Tests for various configuration options."""
+
+ @pytest.mark.asyncio
+ async def test_debounce_setting(self, temp_dir: Path):
+ """Test debounce configuration."""
+ watcher = BaseFileWatcher(
+ watch_paths=str(temp_dir),
+ debounce=1000,
+ )
+
+ assert watcher.debounce == 1000
+
+ @pytest.mark.asyncio
+ async def test_chunk_settings(self, temp_dir: Path):
+ """Test chunk configuration."""
+ watcher = BaseFileWatcher(
+ watch_paths=str(temp_dir),
+ chunk_tokens=500,
+ chunk_overlap=100,
+ )
+
+ assert watcher.chunk_tokens == 500
+ assert watcher.chunk_overlap == 100
+
+ @pytest.mark.asyncio
+ async def test_recursive_setting(self, temp_dir: Path):
+ """Test recursive configuration."""
+ watcher = BaseFileWatcher(
+ watch_paths=str(temp_dir),
+ recursive=True,
+ )
+
+ assert watcher.recursive is True
+
+ @pytest.mark.asyncio
+ async def test_kwargs_preserved(self, temp_dir: Path):
+ """Test that extra kwargs are preserved."""
+ watcher = BaseFileWatcher(
+ watch_paths=str(temp_dir),
+ custom_arg1="value1",
+ custom_arg2=123,
+ )
+
+ assert watcher.kwargs.get("custom_arg1") == "value1"
+ assert watcher.kwargs.get("custom_arg2") == 123
+
+
+# ==================== Test Edge Cases ====================
+
+
+class TestEdgeCases:
+ """Tests for edge cases and boundary conditions."""
+
+ @pytest.mark.asyncio
+ async def test_watch_single_file(self, temp_files):
+ """Test watching a single file instead of directory."""
+ file_path = temp_files["txt_0"]
+
+ mock_file_store = MagicMock()
+ mock_file_store.list_files = AsyncMock(return_value=[])
+ mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+
+ callback_called = []
+
+ async def callback(changes):
+ callback_called.append(changes)
+
+ watcher = BaseFileWatcher(
+ watch_paths=str(file_path),
+ scan_on_start=True,
+ callback=callback,
+ file_store=mock_file_store,
+ )
+
+ await watcher.start()
+ await asyncio.sleep(0.1)
+ await watcher.close()
+
+ # Single file should be detected
+ if callback_called:
+ all_changes = set()
+ for change_set in callback_called:
+ all_changes.update(change_set)
+ assert len(all_changes) == 1
+
+ @pytest.mark.asyncio
+ async def test_empty_directory(self, temp_dir: Path):
+ """Test watching empty directory."""
+ empty_dir = temp_dir / "empty"
+ empty_dir.mkdir()
+
+ mock_file_store = MagicMock()
+ mock_file_store.list_files = AsyncMock(return_value=[])
+ mock_file_store.get_file_chunks = AsyncMock(return_value=[])
+
+ callback_called = []
+
+ async def callback(changes):
+ callback_called.append(changes)
+
+ watcher = BaseFileWatcher(
+ watch_paths=str(empty_dir),
+ scan_on_start=True,
+ callback=callback,
+ file_store=mock_file_store,
+ )
+
+ await watcher.start()
+ await asyncio.sleep(0.1)
+ await watcher.close()
+
+ # No files should be detected
+ assert len(callback_called) == 0
+
+ @pytest.mark.asyncio
+ async def test_special_characters_in_path(self, temp_dir: Path):
+ """Test paths with special characters."""
+ special_dir = temp_dir / "test dir with spaces"
+ special_dir.mkdir()
+
+ file_path = special_dir / "file with spaces.txt"
+ file_path.write_text("content")
+
+ watcher = BaseFileWatcher(watch_paths=str(special_dir))
+
+ assert watcher.watch_filter(Change.added, str(file_path)) is True
+
+ @pytest.mark.asyncio
+ async def test_unicode_in_path(self, temp_dir: Path):
+ """Test paths with unicode characters."""
+ unicode_dir = temp_dir / "测试目录"
+ unicode_dir.mkdir()
+
+ file_path = unicode_dir / "文件.txt"
+ file_path.write_text("内容")
+
+ watcher = BaseFileWatcher(
+ watch_paths=str(unicode_dir),
+ suffix_filters=[".txt"],
+ )
+
+ assert watcher.watch_filter(Change.added, str(file_path)) is True
+
+
+# ==================== Main Entry Point ====================
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/tests/vector/test_reme_vector.py b/tests/vector/test_reme_vector.py
new file mode 100644
index 00000000..3829c841
--- /dev/null
+++ b/tests/vector/test_reme_vector.py
@@ -0,0 +1,89 @@
+"""测试 ReMe 的 vector 搜索功能"""
+
+import asyncio
+
+from reme import ReMe
+
+
+async def main():
+ """测试 ReMe 的 vector 搜索功能"""
+ # 初始化 ReMe
+ reme = ReMe(
+ working_dir=".reme",
+ default_llm_config={
+ "backend": "openai",
+ "model_name": "qwen3.5-plus",
+ },
+ default_embedding_model_config={
+ "backend": "openai",
+ "model_name": "text-embedding-v4",
+ "dimensions": 1024,
+ },
+ default_vector_store_config={
+ "backend": "local", # 支持 local/chroma/qdrant/elasticsearch
+ },
+ )
+ await reme.start()
+
+ messages = [
+ {"role": "user", "content": "帮我写一个 Python 脚本", "time_created": "2026-02-28 10:00:00"},
+ {"role": "assistant", "content": "好的,我来帮你写", "time_created": "2026-02-28 10:00:05"},
+ ]
+
+ # 1. 从对话中总结记忆(自动提取用户偏好、任务经验等)
+ result = await reme.summarize_memory(
+ messages=messages,
+ user_name="alice", # 个人记忆
+ # task_name="code_writing", # 任务记忆
+ )
+ print(f"总结结果: {result}")
+
+ # 2. 检索相关记忆
+ memories = await reme.retrieve_memory(
+ query="Python 编程",
+ user_name="alice",
+ # task_name="code_writing",
+ )
+ print(f"检索结果: {memories}")
+
+ # 3. 手动添加记忆
+ memory_node = await reme.add_memory(
+ memory_content="用户喜欢简洁的代码风格",
+ user_name="alice",
+ )
+ print(f"添加的记忆: {memory_node}")
+ memory_id = memory_node.memory_id
+
+ # 4. 通过 ID 获取单条记忆
+ fetched_memory = await reme.get_memory(memory_id=memory_id)
+ print(f"获取的记忆: {fetched_memory}")
+
+ # 5. 更新记忆内容
+ updated_memory = await reme.update_memory(
+ memory_id=memory_id,
+ user_name="alice",
+ memory_content="用户喜欢简洁且带注释的代码风格",
+ )
+ print(f"更新后的记忆: {updated_memory}")
+
+ # 6. 列出用户的所有记忆(支持过滤和排序)
+ all_memories = await reme.list_memory(
+ user_name="alice",
+ limit=10,
+ sort_key="time_created",
+ reverse=True,
+ )
+ print(f"用户记忆列表: {all_memories}")
+
+ # 7. 删除指定记忆
+ await reme.delete_memory(memory_id=memory_id)
+ print(f"已删除记忆: {memory_id}")
+
+ # 8. 删除所有记忆(谨慎使用)
+ # await reme.delete_all()
+
+ await reme.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())