From 5584a5c2393369cace4ce53270b69b3fcf68d469 Mon Sep 17 00:00:00 2001 From: jinliyl <6469360+jinliyl@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:55:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20add=20CoPaw=20file-based=20memo?= =?UTF-8?q?ry=20system=20with=20compaction=20and=20=E2=80=A6=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(memory): add CoPaw file-based memory system with compaction and summarization * feat(reme): add tool result cleanup and retention management * fix(memory): resolve copaw memory processing and prompt formatting issues * docs(reme_copaw): update documentation and initialization logic * refactor(reme): remove override parameters from compact_tool_result * feat(docs): update README to reflect CoPaw memory system integration * chore(docs): update model names in documentation --- .pre-commit-config.yaml | 1 + README.md | 279 ++++--- README_ZH.md | 261 ++++--- reme/__init__.py | 4 +- reme/config/copaw.yaml | 16 + reme/config/file.yaml | 43 -- reme/memory/__init__.py | 4 +- reme/memory/{file_based => cli}/__init__.py | 0 reme/memory/{file_based => cli}/fb_cli.py | 0 reme/memory/{file_based => cli}/fb_cli.yaml | 0 .../{file_based => cli}/fb_compactor.py | 0 .../{file_based => cli}/fb_compactor.yaml | 0 .../{file_based => cli}/fb_context_checker.py | 0 .../{file_based => cli}/fb_summarizer.py | 0 .../{file_based => cli}/fb_summarizer.yaml | 0 reme/memory/file_based_copaw/__init__.py | 31 + reme/memory/file_based_copaw/compactor.py | 84 +++ reme/memory/file_based_copaw/compactor.yaml | 160 ++++ .../copaw_in_memory_memory.py | 243 ++++++ reme/memory/file_based_copaw/file_io.py | 247 ++++++ .../file_based_copaw/memory_formatter.py | 249 +++++++ reme/memory/file_based_copaw/summarizer.py | 92 +++ reme/memory/file_based_copaw/summarizer.yaml | 50 ++ .../file_based_copaw/tool_result_compactor.py | 105 +++ reme/memory/file_based_copaw/utils.py | 231 ++++++ reme/reme_cli.py | 177 ++++- reme/reme_copaw.py | 700 ++++++++++++++++++ reme/reme_fb.py | 183 ----- tests/copaw/test_compactor.py | 375 ++++++++++ tests/copaw/test_memory_formatter.py | 489 ++++++++++++ tests/copaw/test_summarizer.py | 310 ++++++++ tests/copaw/test_tool_result_compactor.py | 176 +++++ tests/copaw/test_utils.py | 89 +++ tests/test_fs_compactor.py | 6 +- tests/test_fs_context_checker.py | 8 +- tests/test_fs_file_watch_integration.py | 20 +- tests/test_fs_memory_get.py | 20 +- tests/test_fs_memory_search.py | 42 +- tests/test_fs_summary.py | 14 +- 39 files changed, 4214 insertions(+), 495 deletions(-) create mode 100644 reme/config/copaw.yaml delete mode 100644 reme/config/file.yaml rename reme/memory/{file_based => cli}/__init__.py (100%) rename reme/memory/{file_based => cli}/fb_cli.py (100%) rename reme/memory/{file_based => cli}/fb_cli.yaml (100%) rename reme/memory/{file_based => cli}/fb_compactor.py (100%) rename reme/memory/{file_based => cli}/fb_compactor.yaml (100%) rename reme/memory/{file_based => cli}/fb_context_checker.py (100%) rename reme/memory/{file_based => cli}/fb_summarizer.py (100%) rename reme/memory/{file_based => cli}/fb_summarizer.yaml (100%) create mode 100644 reme/memory/file_based_copaw/__init__.py create mode 100644 reme/memory/file_based_copaw/compactor.py create mode 100644 reme/memory/file_based_copaw/compactor.yaml create mode 100644 reme/memory/file_based_copaw/copaw_in_memory_memory.py create mode 100644 reme/memory/file_based_copaw/file_io.py create mode 100644 reme/memory/file_based_copaw/memory_formatter.py create mode 100644 reme/memory/file_based_copaw/summarizer.py create mode 100644 reme/memory/file_based_copaw/summarizer.yaml create mode 100644 reme/memory/file_based_copaw/tool_result_compactor.py create mode 100644 reme/memory/file_based_copaw/utils.py create mode 100644 reme/reme_copaw.py delete mode 100644 reme/reme_fb.py create mode 100644 tests/copaw/test_compactor.py create mode 100644 tests/copaw/test_memory_formatter.py create mode 100644 tests/copaw/test_summarizer.py create mode 100644 tests/copaw/test_tool_result_compactor.py create mode 100644 tests/copaw/test_utils.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d44e7f60..42f6ad0b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,6 +50,7 @@ repos: --disable=W0511, --disable=W0718, --disable=W0122, + --disable=W1203, --disable=C0103, --disable=R0913, --disable=R0917, diff --git a/README.md b/README.md index f6a4e67e..98c5dd8c 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@

License - English - 简体中文 + English + 简体中文 GitHub Stars

@@ -36,11 +36,14 @@ and the next conversation can recall it automatically. --- -## 📁 File-Based ReMe +## 📁 File-Based CoPaw Memory System > Memory as files, files as memory -Treat **memory as files** — readable, editable, and portable. +Treat **memory as files** — readable, editable, and portable. [CoPaw](https://github.com/agentscope-ai/CoPaw) +integrates this memory system +through [MemoryManager](https://github.com/agentscope-ai/CoPaw/blob/main/src/copaw/agents/memory/memory_manager.py), +which inherits `ReMeCopaw` and exposes memory management capabilities. | Traditional Memory Systems | File-Based ReMe | |----------------------------|--------------------| @@ -50,26 +53,31 @@ Treat **memory as files** — readable, editable, and portable. | 🚫 Hard to migrate | 📦 Copy to migrate | ``` -.reme/ -├── MEMORY.md # Long-term memory: user preferences, project config, etc. -└── memory/ - └── YYYY-MM-DD.md # Daily logs: work records for the day, written upon compact +working_dir/ +├── MEMORY.md # Long-term memory: user preferences, project config, etc. +├── memory/ +│ └── YYYY-MM-DD.md # Daily summary logs: written automatically after conversation ends +└── tool_result/ # Cache for oversized tool outputs (auto-managed, auto-cleaned when expired) + └── .txt ``` ### Core Capabilities -[ReMe File Based](reme/reme_fb.py) is the core class of the file-based memory system. It acts like an **intelligent -secretary**, managing all memory-related operations: +[ReMeCopaw](reme/reme_copaw.py) is the core class of this memory system, providing complete memory management +capabilities for AI Agents: -| Method | Function | Key Components | -|-----------------|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `start` | 🚀 Start memory system | [BaseFileStore](reme/core/file_store/base_file_store.py) (local file storage)
[BaseFileWatcher](reme/core/file_watcher/base_file_watcher.py) (file watcher)
[BaseEmbeddingModel](reme/core/embedding/base_embedding_model.py) (embedding cache) | -| `close` | 📕 Close and save | Close file store, stop file watcher, save embedding cache | -| `context_check` | 📏 Check context limit | [ContextChecker](reme/memory/file_based/fb_context_checker.py) | -| `compact` | 📦 Compact history to summary | [Compactor](reme/memory/file_based/fb_compactor.py) | -| `summary` | 📝 Write important memory to files | [Summarizer](reme/memory/file_based/fb_summarizer.py) | -| `memory_search` | 🔍 Semantic memory search | [MemorySearch](reme/memory/tools/chunk/memory_search.py) | -| `memory_get` | 📖 Read specified memory file | [MemoryGet](reme/memory/tools/chunk/memory_get.py) | +| Method | Function | Key Components | +|--------------------------|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `start` | 🚀 Start memory system | Initialize file store, file watcher, Embedding cache; clean up expired tool result files | +| `close` | 📕 Close and clean up | Clean tool result files, stop file watcher, save Embedding cache | +| `compact_memory` | 📦 Compact history to summary | [Compactor](reme/memory/file_based_copaw/compactor.py) — ReActAgent generates structured context checkpoint | +| `summary_memory` | 📝 Write important memory to files | [Summarizer](reme/memory/file_based_copaw/summarizer.py) — ReActAgent + file tools (read / write / edit) | +| `compact_tool_result` | ✂️ Compact oversized tool output | [ToolResultCompactor](reme/memory/file_based_copaw/tool_result_compactor.py) — Truncate and save to `tool_result/`, keep file reference in message | +| `add_async_summary_task` | ⚡ Submit background summary task | `asyncio.create_task`, summary doesn't block main conversation flow | +| `await_summary_tasks` | ⏳ Wait for background tasks | Collect results from all background summary tasks, call before closing to ensure writes complete | +| `memory_search` | 🔍 Semantic memory search | [MemorySearch](reme/memory/tools/chunk/memory_search.py) — Vector + BM25 hybrid retrieval | +| `get_in_memory_memory` | 🗂️ Create in-memory instance | [CoPawInMemoryMemory](reme/memory/file_based_copaw/copaw_in_memory_memory.py) — Token-aware memory management, supports compression summary and state serialization | +| `update_params` | ⚙️ Update runtime parameters | Adjust `max_input_length`, `memory_compact_ratio`, `language` at runtime | --- @@ -205,59 +213,81 @@ Commands starting with `/` control session state: ### Using the ReMe Package -#### File-Based ReMe +#### File-Based ReMe (CoPaw Memory System) + +`ReMeCopaw` receives AgentScope components like `ChatModelBase`, `Formatter`, `Toolkit`, and configures Embedding and +storage backend via environment variables: + +| Environment Variable | Description | Default | +|----------------------------|-----------------------------------------------|-----------------------------------------------------| +| `EMBEDDING_API_KEY` | Embedding service API Key | `""` (vector search disabled if not configured) | +| `EMBEDDING_BASE_URL` | Embedding service Base URL | `https://dashscope.aliyuncs.com/compatible-mode/v1` | +| `EMBEDDING_MODEL_NAME` | Embedding model name | `""` | +| `EMBEDDING_DIMENSIONS` | Vector dimensions | `1024` | +| `EMBEDDING_CACHE_ENABLED` | Whether to enable Embedding cache | `true` | +| `EMBEDDING_MAX_CACHE_SIZE` | Maximum cache entries | `2000` | +| `FTS_ENABLED` | Whether to enable full-text search (BM25) | `true` | +| `MEMORY_STORE_BACKEND` | Storage backend (`auto` / `chroma` / `local`) | `auto` (local on Windows, chroma on others) | ```python import asyncio -from reme import ReMeFb +from agentscope.formatter import ClaudeFormatter +from agentscope.model import get_model +from agentscope.token import HuggingFaceTokenCounter +from agentscope.tool import Toolkit + +from reme.reme_copaw import ReMeCopaw 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 + # Prepare AgentScope core components + chat_model = get_model(config={"backend": "openai", "model_name": "qwen3.5-plus"}) + formatter = ClaudeFormatter() + token_counter = HuggingFaceTokenCounter() + toolkit = Toolkit() # Can register additional tools + + # Initialize ReMeCopaw + reme = ReMeCopaw( + working_dir=".reme", # Memory file storage directory + chat_model=chat_model, + formatter=formatter, + token_counter=token_counter, + toolkit=toolkit, + max_input_length=128000, # Model context window (tokens) + memory_compact_ratio=0.7, # Trigger compaction when reaching max_input_length * 0.7 + language="zh", # Summary language (zh / "") + tool_result_threshold=1000, # Auto-save tool outputs exceeding this character count + retention_days=7, # tool_result/ file retention days ) await reme.start() - messages = [ - {"role": "user", "content": "I prefer Python 3.12"}, - {"role": "assistant", "content": "Noted, you prefer Python 3.12"}, - ] + messages = [...] # list[Msg], conversation history - # Check if context exceeds limit - result = await reme.context_check(messages) - print(f"Compact result: {result}") + # 1. Compact oversized tool outputs (prevent tool results from overflowing 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 history to structured summary (trigger: context approaching limit) + summary = await reme.compact_memory( + messages=messages, + previous_summary="", # Can pass previous summary for incremental update + ) + print(f"Compact summary:\n{summary}") - # Write important memory to files (ReAct Agent does this automatically) - await reme.summary(messages=messages, date="2026-02-28") + # 3. Submit async summary task in background (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. Semantic memory search (Vector + BM25 hybrid retrieval) + result = await reme.memory_search(query="Python version preference", max_results=5) + print(f"Search results: {result}") - # Read specified memory file - content = await reme.memory_get(path="MEMORY.md") - print(f"Memory content: {content}") + # 5. Get in-memory instance (CoPawInMemoryMemory, manages single conversation context) + memory = reme.get_in_memory_memory() + token_stats = await memory.estimate_tokens() + print(f"Current context usage: {token_stats['context_usage_ratio']:.1f}%") - # Close (save embedding cache, stop file watcher) + # 6. Wait for background tasks before closing + await reme.await_summary_tasks() await reme.close() @@ -278,7 +308,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", @@ -307,7 +337,8 @@ async def main(): # 2. Retrieve relevant memory memories = await reme.retrieve_memory( query="Python programming", - # user_name="alice", + user_name="alice", + # task_name="code_writing", ) print(f"Retrieve result: {memories}") @@ -358,30 +389,87 @@ if __name__ == "__main__": ## 🏛️ Technical Architecture -### File-Based ReMe Core Architecture +### File-Based CoPaw Memory System Architecture + +[CoPaw MemoryManager](https://github.com/agentscope-ai/CoPaw/blob/main/src/copaw/agents/memory/memory_manager.py) +inherits +`ReMeCopaw` and integrates memory capabilities into the Agent reasoning flow: ```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] + CoPaw["CoPaw MemoryManager\n(inherits ReMeCopaw)"] -->|pre_reasoning hook| Hook[MemoryCompactionHook] + CoPaw --> ReMeCopaw[ReMeCopaw] + Hook -->|exceeds threshold| ReMeCopaw + ReMeCopaw --> CompactMemory[compact_memory\nHistory compaction] + ReMeCopaw --> SummaryMemory[summary_memory\nWrite memory to files] + ReMeCopaw --> CompactToolResult[compact_tool_result\nOversized tool output compaction] + ReMeCopaw --> MemSearch[memory_search\nSemantic search] + ReMeCopaw --> InMemory[get_in_memory_memory\nCoPawInMemoryMemory] + CompactMemory --> Compactor[Compactor\nReActAgent] + SummaryMemory --> Summarizer[Summarizer\nReActAgent + file tools] + CompactToolResult --> ToolResultCompactor[ToolResultCompactor\nTruncate + save to file] + Summarizer --> FileIO[FileIO\nread / write / edit] + FileIO --> MemoryFiles[memory/YYYY-MM-DD.md] + ToolResultCompactor --> ToolResultFiles[tool_result/*.txt] MemoryFiles -.->|File change| FileWatcher[Async File Watcher] FileWatcher -->|Update index| FileStore[Local DB] - MemorySearch --> FileStore + MemSearch --> FileStore ``` +#### Auto-Compaction Trigger Flow + +`MemoryCompactionHook` checks context token usage before each reasoning step, automatically triggering compaction when +threshold is exceeded: + +```mermaid +graph LR + A[pre_reasoning] --> B{Token exceeds threshold?} + B -->|No| Z[Continue reasoning] + B -->|Yes| C[compact_tool_result\nCompact oversized tool outputs in recent messages] + C --> D[compact_memory\nGenerate structured context checkpoint] + D --> E[Mark old messages as COMPRESSED] + E --> F[add_async_summary_task\nBackground write to memory file] + F --> Z +``` + +#### Context Compaction Summary Format + +[Compactor](reme/memory/file_based_copaw/compactor.py) uses ReActAgent to compact history into structured **context +checkpoints**: + +| Field | Description | +|-----------------------|--------------------------------------------------| +| `## Goal` | 🎯 User's objectives (can be multiple) | +| `## Constraints` | ⚙️ Constraints and preferences mentioned by user | +| `## Progress` | 📈 Completed / in progress / blocked tasks | +| `## Key Decisions` | 🔑 Decisions made with brief reasons | +| `## Next Steps` | 🗺️ Next action plan (ordered list) | +| `## Critical Context` | 📌 File paths, function names, error messages | + +Supports **incremental updates**: when `previous_summary` is passed, automatically merges new conversation with old +summary, preserving historical progress. + +#### Tool Result Compaction + +[ToolResultCompactor](reme/memory/file_based_copaw/tool_result_compactor.py) solves context overflow caused by oversized +tool outputs: + +```mermaid +graph LR + A[tool_result message] --> B{Content length > threshold?} + B -->|No| C[Keep as-is] + B -->|Yes| D[Truncate to threshold characters] + D --> E[Write full content to tool_result/uuid.txt] + E --> F[Append file reference path to message] +``` + +Expired files (exceeding `retention_days`) are automatically cleaned up during `start` / `close` / +`compact_tool_result`. + #### 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. +[Summarizer](reme/memory/file_based_copaw/summarizer.py) uses the **ReAct + file tools** pattern, letting AI +autonomously decide what to write and where: ```mermaid graph LR @@ -394,43 +482,28 @@ graph LR F -->|No| G[Done] ``` -#### File Tool Set +[FileIO](reme/memory/file_based_copaw/file_io.py) provides file operation tools: -Summarizer is equipped with file operation tools so the AI can work directly on memory files: +| Tool | Function | Use case | +|---------|--------------------------------|-----------------------------------------| +| `read` | Read file content (line range) | View existing memory, avoid duplicates | +| `write` | Overwrite file | Create new memory file or major rewrite | +| `edit` | Replace after exact match | Append or modify specific sections | -| 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 | +#### In-Memory Session Management -#### Context Compaction +[CoPawInMemoryMemory](reme/memory/file_based_copaw/copaw_in_memory_memory.py) extends AgentScope's `InMemoryMemory`: -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 | +| Feature | Description | +|----------------------------------|---------------------------------------------------------------------| +| `get_memory` | Filter messages by mark, auto-prepend compression summary | +| `estimate_tokens` | Precisely estimate current context token usage and ratio | +| `get_history_str` | Generate human-readable conversation summary (with token stats) | +| `state_dict` / `load_state_dict` | Support state serialization / deserialization (session persistence) | #### Memory Retrieval -[MemorySearch](reme/memory/tools/chunk/memory_search.py) provides **vector + BM25 hybrid retrieval**. The two methods -complement each other: +[MemorySearch](reme/memory/tools/chunk/memory_search.py) provides **vector + BM25 hybrid retrieval**: | Retrieval | Strength | Weakness | |---------------------|-------------------------------------------------|----------------------------------------| diff --git a/README_ZH.md b/README_ZH.md index 2dbf02f3..d5b8231b 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -33,11 +33,13 @@ ReMe 让智能体拥有**真正的记忆力**——旧对话自动浓缩,重 --- -## 📁 基于文件的 ReMe +## 📁 基于文件的 CoPaw 记忆系统 > 记忆即文件,文件即记忆 -将**记忆视为文件**——可读、可编辑、可复制。 +将**记忆视为文件**——可读、可编辑、可复制。[CoPaw](https://github.com/agentscope-ai/CoPaw) +通过 [MemoryManager](https://github.com/agentscope-ai/CoPaw/blob/main/src/copaw/agents/memory/memory_manager.py) +集成此记忆系统,继承 `ReMeCopaw` 并对外暴露记忆管理能力。 | 传统记忆系统 | File Based ReMe | |-----------|-----------------| @@ -47,25 +49,30 @@ 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) 是基于文件的记忆系统的核心类,就像一个**智能秘书**,帮你管理所有记忆相关的事务: +[ReMeCopaw](reme/reme_copaw.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) | +| 方法 | 功能 | 关键组件 | +|--------------------------|--------------|----------------------------------------------------------------------------------------------------------------| +| `start` | 🚀 启动记忆系统 | 初始化文件存储、文件监控、Embedding 缓存;清理过期工具结果文件 | +| `close` | 📕 关闭并清理 | 清理工具结果文件、停止文件监控、保存 Embedding 缓存 | +| `compact_memory` | 📦 压缩历史对话为摘要 | [Compactor](reme/memory/file_based_copaw/compactor.py) — ReActAgent 生成结构化上下文检查点 | +| `summary_memory` | 📝 将重要记忆写入文件 | [Summarizer](reme/memory/file_based_copaw/summarizer.py) — ReActAgent + 文件工具(read / write / edit) | +| `compact_tool_result` | ✂️ 压缩超长工具输出 | [ToolResultCompactor](reme/memory/file_based_copaw/tool_result_compactor.py) — 截断并转存到 `tool_result/`,消息中保留文件引用 | +| `add_async_summary_task` | ⚡ 提交后台摘要任务 | `asyncio.create_task`,摘要不阻塞主对话流程 | +| `await_summary_tasks` | ⏳ 等待后台任务完成 | 收集所有后台摘要任务的结果,关闭前调用确保写入完成 | +| `memory_search` | 🔍 语义搜索记忆 | [MemorySearch](reme/memory/tools/chunk/memory_search.py) — 向量 + BM25 混合检索 | +| `get_in_memory_memory` | 🗂️ 创建会话内存实例 | [CoPawInMemoryMemory](reme/memory/file_based_copaw/copaw_in_memory_memory.py) — Token 感知的内存管理,支持压缩摘要和状态序列化 | +| `update_params` | ⚙️ 动态更新运行时参数 | 运行时调整 `max_input_length`、`memory_compact_ratio`、`language` | ## 🗃️ 基于向量库的 ReMe @@ -196,59 +203,80 @@ remecli config=cli ### 使用 ReMe Package -#### 基于文件的 ReMe +#### 基于文件的 ReMe(CoPaw的记忆系统) + +`ReMeCopaw` 接收 AgentScope 的 `ChatModelBase`、`Formatter`、`Toolkit` 等组件,通过环境变量配置 Embedding 和存储后端: + +| 环境变量 | 说明 | 默认值 | +|----------------------------|-----------------------------------|-----------------------------------------------------| +| `EMBEDDING_API_KEY` | Embedding 服务 API Key | `""`(未配置则禁用向量搜索) | +| `EMBEDDING_BASE_URL` | Embedding 服务 Base URL | `https://dashscope.aliyuncs.com/compatible-mode/v1` | +| `EMBEDDING_MODEL_NAME` | Embedding 模型名称 | `""` | +| `EMBEDDING_DIMENSIONS` | 向量维度 | `1024` | +| `EMBEDDING_CACHE_ENABLED` | 是否启用 Embedding 缓存 | `true` | +| `EMBEDDING_MAX_CACHE_SIZE` | 最大缓存条数 | `2000` | +| `FTS_ENABLED` | 是否启用全文搜索(BM25) | `true` | +| `MEMORY_STORE_BACKEND` | 存储后端(`auto` / `chroma` / `local`) | `auto`(Windows 用 local,其他用 chroma) | ```python import asyncio -from reme import ReMeFb +from agentscope.formatter import ClaudeFormatter +from agentscope.model import get_model +from agentscope.token import HuggingFaceTokenCounter +from agentscope.tool import Toolkit + +from reme.reme_copaw import ReMeCopaw 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, # 候选结果倍数,用于召回更多候选项 + # 准备 AgentScope 核心组件 + chat_model = get_model(config={"backend": "openai", "model_name": "qwen3.5-plus"}) + formatter = ClaudeFormatter() + token_counter = HuggingFaceTokenCounter() + toolkit = Toolkit() # 可注册额外工具 + + # 初始化 ReMeCopaw + reme = ReMeCopaw( + working_dir=".reme", # 记忆文件存储目录 + chat_model=chat_model, + formatter=formatter, + token_counter=token_counter, + toolkit=toolkit, + max_input_length=128000, # 模型上下文窗口(tokens) + memory_compact_ratio=0.7, # 达到 max_input_length * 0.7 时触发压缩 + language="zh", # 摘要语言(zh / "") + tool_result_threshold=1000, # 超过此字符数的工具输出自动转存 + retention_days=7, # tool_result/ 文件保留天数 ) await reme.start() - messages = [ - {"role": "user", "content": "我喜欢用 Python 3.12"}, - {"role": "assistant", "content": "好的,已记录你偏好 Python 3.12"}, - ] + messages = [...] # list[Msg],对话历史 - # 检查上下文是否超限 - result = await reme.context_check(messages) - print(f"压缩结论: {result}") + # 1. 压缩超长工具输出(防止工具结果撑爆上下文) + messages = await reme.compact_tool_result(messages) - # 压缩对话为摘要 - summary = await reme.compact(messages_to_summarize=messages) - print(f"摘要: {summary}") + # 2. 将历史对话压缩为结构化摘要(触发时机:上下文接近上限) + summary = await reme.compact_memory( + messages=messages, + previous_summary="", # 可传入上轮摘要,实现增量更新 + ) + print(f"压缩摘要:\n{summary}") - # 将重要记忆写入文件(ReAct Agent 自动操作) - await reme.summary(messages=messages, date="2026-02-28") + # 3. 后台异步提交摘要任务(不阻塞对话,摘要写入 memory/YYYY-MM-DD.md) + reme.add_async_summary_task(messages=messages) - # 语义搜索记忆 - results = await reme.memory_search(query="Python 版本偏好", max_results=5) - print(f"搜索结果: {results}") + # 4. 语义搜索记忆(向量 + BM25 混合检索) + result = await reme.memory_search(query="Python 版本偏好", max_results=5) + print(f"搜索结果: {result}") - # 读取指定记忆文件 - content = await reme.memory_get(path="MEMORY.md") - print(f"记忆内容: {content}") + # 5. 获取会话内存实例(CoPawInMemoryMemory,管理单次对话的上下文) + memory = reme.get_in_memory_memory() + token_stats = await memory.estimate_tokens() + print(f"当前上下文使用率: {token_stats['context_usage_ratio']:.1f}%") - # 关闭(保存 Embedding 缓存、停止文件监控) + # 6. 关闭前等待后台任务完成 + await reme.await_summary_tasks() await reme.close() @@ -269,7 +297,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", @@ -348,29 +376,80 @@ if __name__ == "__main__": ## 🏛️ 技术架构 -### 基于文件的 ReMe 核心架构 +### 基于文件的 CoPaw 记忆系统架构 + +[CoPaw MemoryManager](https://github.com/agentscope-ai/CoPaw/blob/main/src/copaw/agents/memory/memory_manager.py) 继承 +`ReMeCopaw`,将记忆能力集成到 Agent 推理流程中: ```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] + CoPaw["CoPaw MemoryManager\n(继承 ReMeCopaw)"] -->|pre_reasoning hook| Hook[MemoryCompactionHook] + CoPaw --> ReMeCopaw[ReMeCopaw] + Hook -->|超出阈值| ReMeCopaw + ReMeCopaw --> CompactMemory[compact_memory\n历史对话压缩] + ReMeCopaw --> SummaryMemory[summary_memory\n记忆写入文件] + ReMeCopaw --> CompactToolResult[compact_tool_result\n超长工具输出压缩] + ReMeCopaw --> MemSearch[memory_search\n语义搜索] + ReMeCopaw --> InMemory[get_in_memory_memory\nCoPawInMemoryMemory] + CompactMemory --> Compactor[Compactor\nReActAgent] + SummaryMemory --> Summarizer[Summarizer\nReActAgent + 文件工具] + CompactToolResult --> ToolResultCompactor[ToolResultCompactor\n截断 + 转存文件] + Summarizer --> FileIO[FileIO\nread / write / edit] + FileIO --> MemoryFiles[memory/YYYY-MM-DD.md] + ToolResultCompactor --> ToolResultFiles[tool_result/*.txt] MemoryFiles -.->|文件变更| FileWatcher[异步文件监控] FileWatcher -->|更新索引| FileStore[本地数据库] - MemorySearch --> FileStore + MemSearch --> FileStore ``` +#### 自动压缩触发流程 + +`MemoryCompactionHook` 在每次推理前检查上下文 Token 用量,超过阈值时自动触发压缩: + +```mermaid +graph LR + A[pre_reasoning] --> B{Token 超过阈值?} + B -->|否| Z[继续推理] + B -->|是| C[compact_tool_result\n压缩最近消息中的超长工具输出] + C --> D[compact_memory\n生成结构化上下文检查点] + D --> E[标记旧消息为 COMPRESSED] + E --> F[add_async_summary_task\n后台写入 memory 文件] + F --> Z +``` + +#### 上下文压缩摘要格式 + +[Compactor](reme/memory/file_based_copaw/compactor.py) 使用 ReActAgent 将历史对话压缩为结构化的**上下文检查点**: + +| 字段 | 说明 | +|-----------------------|-----------------------| +| `## Goal` | 🎯 用户要完成的目标(可多项) | +| `## Constraints` | ⚙️ 用户提到的约束和偏好 | +| `## Progress` | 📈 已完成 / 进行中 / 阻塞的任务 | +| `## Key Decisions` | 🔑 做出的决策及简短理由 | +| `## Next Steps` | 🗺️ 下一步行动计划(有序列表) | +| `## Critical Context` | 📌 文件路径、函数名、错误信息等关键数据 | + +支持**增量更新**:传入 `previous_summary` 时,自动将新对话与旧摘要合并,保留历史进展。 + +#### 工具结果压缩 + +[ToolResultCompactor](reme/memory/file_based_copaw/tool_result_compactor.py) 解决工具输出过长导致上下文膨胀的问题: + +```mermaid +graph LR + A[tool_result 消息] --> B{内容长度 > threshold?} + B -->|否| C[保留原样] + B -->|是| D[截断到 threshold 字符] + D --> E[完整内容写入 tool_result/uuid.txt] + E --> F[消息中追加文件引用路径] +``` + +过期文件(超过 `retention_days`)在 `start` / `close` / `compact_tool_result` 时自动清理。 + #### 记忆总结:ReAct + 文件工具 -[Summarizer](reme/memory/file_based/fb_summarizer.py) 是记忆总结的核心组件,它采用 **ReAct + 文件工具** 模式。 +[Summarizer](reme/memory/file_based_copaw/summarizer.py) 采用 **ReAct + 文件工具** 模式,让 AI 自主决定写什么、写到哪: ```mermaid graph LR @@ -383,49 +462,35 @@ graph LR F -->|否| G[完成] ``` -#### 文件工具集 +[FileIO](reme/memory/file_based_copaw/file_io.py) 提供文件操作工具集: -Summarizer 配备了一套文件操作工具,让 AI 能够直接操作记忆文件: +| 工具 | 功能 | 使用场景 | +|---------|---------------|---------------| +| `read` | 读取文件内容(支持行范围) | 查看现有记忆,避免重复写入 | +| `write` | 覆盖写入文件 | 创建新记忆文件或大幅重构 | +| `edit` | 精确匹配后替换 | 追加新内容或修改特定段落 | -| 工具 | 功能 | 使用场景 | -|---------|--------|--------------| -| `read` | 读取文件内容 | 查看现有记忆,避免重复 | -| `write` | 覆盖写入文件 | 创建新记忆文件或大幅重构 | -| `edit` | 编辑文件局部 | 追加新内容或修改特定部分 | +#### 会话内存管理 -#### 上下文压缩 +[CoPawInMemoryMemory](reme/memory/file_based_copaw/copaw_in_memory_memory.py) 扩展了 AgentScope 的 `InMemoryMemory`: -当对话过长时,[Compactor](reme/memory/file_based/fb_compactor.py) 负责将历史对话压缩为精华摘要——就像写**会议纪要** -,把冗长的讨论浓缩成关键要点。 - -```mermaid -graph LR - A[消息1..N] --> B[📦 压缩摘要] -C[最近消息] --> D[保留原样] -B --> E[新的上下文] -D --> E -``` - -压缩摘要包含继续工作所需的关键信息: - -| 内容 | 说明 | -|--------|---------------| -| 🎯 目标 | 用户想要完成什么 | -| ⚙️ 约束 | 用户提到的要求和偏好 | -| 📈 进展 | 已完成/进行中/阻塞的任务 | -| 🔑 决策 | 做出的决策及原因 | -| 📌 上下文 | 文件路径、函数名等关键数据 | +| 功能 | 说明 | +|----------------------------------|---------------------------| +| `get_memory` | 按标记过滤消息,自动在头部追加压缩摘要 | +| `estimate_tokens` | 精确估算当前上下文 Token 用量及使用率 | +| `get_history_str` | 生成人类可读的对话历史摘要(含 Token 统计) | +| `state_dict` / `load_state_dict` | 支持状态序列化 / 反序列化(会话持久化) | #### 记忆检索 -[MemorySearch](reme/memory/tools/chunk/memory_search.py) 提供**向量 + BM25 混合检索**能力,两种方式优势互补: +[MemorySearch](reme/memory/tools/chunk/memory_search.py) 提供**向量 + BM25 混合检索**能力: | 检索方式 | 优势 | 劣势 | |-------------|-----------------|----------------| | **向量语义** | 捕捉意义相近但措辞不同的内容 | 对精确 token 匹配较弱 | | **BM25 全文** | 精确 token 命中效果极佳 | 无法理解同义词和改写 | -**融合机制**:同时使用两路召回,按权重加权求和(向量 0.7 + BM25 0.3),确保无论是「自然语言提问」还是「精确查找」都能获得可靠结果。 +**融合机制**:两路召回后按权重加权求和(向量 0.7 + BM25 0.3),自然语言与精确查找均可命中。 ```mermaid graph LR diff --git a/reme/__init__.py b/reme/__init__.py index d5304a7f..733448bd 100644 --- a/reme/__init__.py +++ b/reme/__init__.py @@ -6,9 +6,8 @@ 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.3" __all__ = [ "config", @@ -17,7 +16,6 @@ __all__ = [ "memory", "ReMe", "ReMeCli", - "ReMeFb", ] """ diff --git a/reme/config/copaw.yaml b/reme/config/copaw.yaml new file mode 100644 index 00000000..2a83371a --- /dev/null +++ b/reme/config/copaw.yaml @@ -0,0 +1,16 @@ +embedding_models: + default: + backend: openai + +file_stores: + default: + backend: chroma + embedding_model: default + +file_watchers: + default: + backend: full + file_store: default + suffix_filters: [ ".md" ] + recursive: false + scan_on_start: true diff --git a/reme/config/file.yaml b/reme/config/file.yaml deleted file mode 100644 index d7be65d3..00000000 --- a/reme/config/file.yaml +++ /dev/null @@ -1,43 +0,0 @@ -backend: cmd -working_dir: .reme - -llms: - default: - backend: openai - model_name: qwen3.5-plus - request_interval: 1 - -embedding_models: - default: - backend: openai - model_name: text-embedding-v4 - dimensions: 1024 - enable_cache: true - use_dimensions: false - -file_stores: - default: - backend: chroma - # backend: local - store_name: reme - embedding_model: default - fts_enabled: true - vector_enabled: false - -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/memory/__init__.py b/reme/memory/__init__.py index ac825bc6..a7df138a 100644 --- a/reme/memory/__init__.py +++ b/reme/memory/__init__.py @@ -1,11 +1,11 @@ """memory""" -from . import file_based +from . import cli from . import tools from . import vector_based __all__ = [ - "file_based", + "cli", "tools", "vector_based", ] diff --git a/reme/memory/file_based/__init__.py b/reme/memory/cli/__init__.py similarity index 100% rename from reme/memory/file_based/__init__.py rename to reme/memory/cli/__init__.py diff --git a/reme/memory/file_based/fb_cli.py b/reme/memory/cli/fb_cli.py similarity index 100% rename from reme/memory/file_based/fb_cli.py rename to reme/memory/cli/fb_cli.py diff --git a/reme/memory/file_based/fb_cli.yaml b/reme/memory/cli/fb_cli.yaml similarity index 100% rename from reme/memory/file_based/fb_cli.yaml rename to reme/memory/cli/fb_cli.yaml diff --git a/reme/memory/file_based/fb_compactor.py b/reme/memory/cli/fb_compactor.py similarity index 100% rename from reme/memory/file_based/fb_compactor.py rename to reme/memory/cli/fb_compactor.py diff --git a/reme/memory/file_based/fb_compactor.yaml b/reme/memory/cli/fb_compactor.yaml similarity index 100% rename from reme/memory/file_based/fb_compactor.yaml rename to reme/memory/cli/fb_compactor.yaml diff --git a/reme/memory/file_based/fb_context_checker.py b/reme/memory/cli/fb_context_checker.py similarity index 100% rename from reme/memory/file_based/fb_context_checker.py rename to reme/memory/cli/fb_context_checker.py diff --git a/reme/memory/file_based/fb_summarizer.py b/reme/memory/cli/fb_summarizer.py similarity index 100% rename from reme/memory/file_based/fb_summarizer.py rename to reme/memory/cli/fb_summarizer.py diff --git a/reme/memory/file_based/fb_summarizer.yaml b/reme/memory/cli/fb_summarizer.yaml similarity index 100% rename from reme/memory/file_based/fb_summarizer.yaml rename to reme/memory/cli/fb_summarizer.yaml diff --git a/reme/memory/file_based_copaw/__init__.py b/reme/memory/file_based_copaw/__init__.py new file mode 100644 index 00000000..5b0405f1 --- /dev/null +++ b/reme/memory/file_based_copaw/__init__.py @@ -0,0 +1,31 @@ +"""File-based CoPaw Memory Module. + +This module provides memory management components for CoPaw (Cooperative Paw) agents, +including memory formatting, compaction, summarization, and file I/O operations. + +Components: + - MemoryFormatter: Converts message lists to formatted strings with token limiting + - CoPawInMemoryMemory: Extended InMemoryMemory with bugfixes and summary support + - Summarizer: Generates memory summaries using LLM + - Compactor: Compacts memory content to reduce token usage + - ToolResultCompactor: Truncates large tool results and saves full content to files + - FileIO: File I/O operations with configurable working directory +""" + +from . import utils +from .compactor import Compactor +from .copaw_in_memory_memory import CoPawInMemoryMemory +from .file_io import FileIO +from .memory_formatter import MemoryFormatter +from .summarizer import Summarizer +from .tool_result_compactor import ToolResultCompactor + +__all__ = [ + "MemoryFormatter", + "CoPawInMemoryMemory", + "Summarizer", + "Compactor", + "ToolResultCompactor", + "FileIO", + "utils", +] diff --git a/reme/memory/file_based_copaw/compactor.py b/reme/memory/file_based_copaw/compactor.py new file mode 100644 index 00000000..d8186d86 --- /dev/null +++ b/reme/memory/file_based_copaw/compactor.py @@ -0,0 +1,84 @@ +"""Compactor module for memory compaction operations.""" + +import logging + +from agentscope.agent import ReActAgent +from agentscope.formatter import FormatterBase +from agentscope.message import Msg +from agentscope.model import ChatModelBase +from agentscope.token import HuggingFaceTokenCounter + +from .memory_formatter import MemoryFormatter +from ...core.op import BaseOp + +logger = logging.getLogger(__name__) + + +class Compactor(BaseOp): + """Compactor class for compacting memory messages.""" + + def __init__( + self, + memory_compact_threshold: int, + chat_model: ChatModelBase, + formatter: FormatterBase, + token_counter: HuggingFaceTokenCounter, + **kwargs, + ): + super().__init__(**kwargs) + self.memory_compact_threshold: int = memory_compact_threshold + + self.chat_model: ChatModelBase = chat_model + self.formatter: FormatterBase = formatter + self.as_token_counter: HuggingFaceTokenCounter = 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 "" + + formatter = MemoryFormatter( + token_counter=self.as_token_counter, + memory_compact_threshold=self.memory_compact_threshold, + ) + history_formatted_str: str = formatter.format(messages) + + if not history_formatted_str: + logger.warning(f"No history to compact. messages={messages}") + return "" + + agent = ReActAgent( + name="reme_compactor", + model=self.chat_model, + sys_prompt=self.get_prompt("system_prompt"), + formatter=self.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_copaw/compactor.yaml b/reme/memory/file_based_copaw/compactor.yaml new file mode 100644 index 00000000..2e3b15a8 --- /dev/null +++ b/reme/memory/file_based_copaw/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_copaw/copaw_in_memory_memory.py b/reme/memory/file_based_copaw/copaw_in_memory_memory.py new file mode 100644 index 00000000..02dc77a4 --- /dev/null +++ b/reme/memory/file_based_copaw/copaw_in_memory_memory.py @@ -0,0 +1,243 @@ +"""Custom memory implementation with bugfixes and extensions.""" + +import logging + +from agentscope.agent._react_agent import _MemoryMark +from agentscope.formatter import FormatterBase +from agentscope.memory import InMemoryMemory +from agentscope.message import Msg +from agentscope.token import HuggingFaceTokenCounter + +from .utils import safe_count_message_tokens, safe_count_str_tokens, _get_block_tokens + +logger = logging.getLogger(__name__) + + +class CoPawInMemoryMemory(InMemoryMemory): + """Extended InMemoryMemory with bugfixes and summary support.""" + + def __init__( + self, + token_counter: HuggingFaceTokenCounter, + formatter: FormatterBase, + max_input_length: int = 0, + ): + super().__init__() + self._token_counter: HuggingFaceTokenCounter = token_counter + self._formatter: FormatterBase = formatter + self._max_input_length: int = max_input_length + + 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) -> dict: + """Estimate token usage for current memory. + + 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 token details + """ + messages = await self.get_memory( + exclude_mark=_MemoryMark.COMPRESSED, + prepend_summary=False, + ) + + compressed_summary = self.get_compressed_summary() + compressed_summary_tokens = safe_count_str_tokens(self._token_counter, compressed_summary) + + # Calculate total token count using formatter + prompt = await self._formatter.format(msgs=messages) + messages_tokens = safe_count_message_tokens(self._token_counter, prompt) + estimated_tokens = messages_tokens + compressed_summary_tokens + + # Calculate context usage ratio + max_input_length = self._max_input_length + context_usage_ratio = (estimated_tokens / max_input_length * 100) if max_input_length > 0 else 0 + + # Build per-message token details + messages_detail = [] + for i, msg in enumerate(messages, 1): + msg_detail = { + "index": i, + "role": msg.role, + "text_tokens": 0, + "blocks": [], + "preview": "", + } + try: + content = msg.content + if isinstance(content, str): + text_tokens = safe_count_str_tokens(self._token_counter, content) + msg_detail["text_tokens"] = text_tokens + msg_detail["preview"] = f"{content[:100]}..." if len(content) > 100 else content + else: + total_tokens = 0 + text_parts = [] + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type", "unknown") + block_tokens, block_str = _get_block_tokens( + block, + block_type, + self._token_counter, + ) + total_tokens += block_tokens + text_parts.append(block_str) + msg_detail["blocks"].append( + { + "type": block_type, + "tokens": block_tokens, + }, + ) + msg_detail["text_tokens"] = total_tokens + text_preview = "".join(text_parts) + msg_detail["preview"] = f"{text_preview[:100]}..." if len(text_preview) > 100 else text_preview + except Exception as e: + msg_detail["error"] = str(e) + msg_detail["preview"] = f"" + + messages_detail.append(msg_detail) + + 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) -> str: + """Get formatted history string similar to /history command output. + + Returns: + Formatted string containing conversation history details + """ + stats = await self.estimate_tokens() + + lines = [] + for msg_detail in stats["messages_detail"]: + blocks_info = "" + if msg_detail["blocks"]: + block_strs = [f"{b['type']}(tokens={b['tokens']})" for b in msg_detail["blocks"]] + blocks_info = f"\n content: [{', '.join(block_strs)}]" + + lines.append( + f"[{msg_detail['index']}] **{msg_detail['role']}** " + f"(text_tokens={msg_detail['text_tokens']})" + f"{blocks_info}\n preview: {msg_detail['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_copaw/file_io.py b/reme/memory/file_based_copaw/file_io.py new file mode 100644 index 00000000..161c3210 --- /dev/null +++ b/reme/memory/file_based_copaw/file_io.py @@ -0,0 +1,247 @@ +"""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 + + +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: + with open(file_path, "r", encoding="utf-8") as f: + all_lines = f.readlines() + + range_requested = start_line is not None or end_line is not None + + if range_requested: + total = len(all_lines) + 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 " f"({total} lines) in {file_path}."), + ), + ], + ) + + if s > e: + return ToolResponse( + content=[ + TextBlock( + type="text", + text=(f"Error: start_line ({s}) is greater than " f"end_line ({e}) in {file_path}."), + ), + ], + ) + + selected = all_lines[s - 1 : e] + content = "".join(selected) + header = f"{file_path} (lines {s}-{e} of {total})\n" + return ToolResponse( + content=[ + TextBlock( + type="text", + text=header + content, + ), + ], + ) + else: + content = "".join(all_lines) + return ToolResponse( + content=[ + TextBlock( + type="text", + text=content, + ), + ], + ) + + 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/file_based_copaw/memory_formatter.py b/reme/memory/file_based_copaw/memory_formatter.py new file mode 100644 index 00000000..6c0e22c3 --- /dev/null +++ b/reme/memory/file_based_copaw/memory_formatter.py @@ -0,0 +1,249 @@ +"""Memory Formatter for CoPaw agents. + +Provides memory formatting capabilities including: +- Converting list of Msg to formatted string +- Memory compaction with token threshold +- Support for various content block types (text, tool_use, tool_result, etc.) +""" + +import json +import logging +import os + +from agentscope.message import Msg +from agentscope.token import HuggingFaceTokenCounter + +from .utils import safe_count_str_tokens, truncate_text + +logger = logging.getLogger(__name__) + +_DEFAULT_MAX_FORMATTER_TEXT_LENGTH = 2000 + + +class MemoryFormatter: + """Formatter that converts list of Msg to formatted string. + + Formats messages into human-readable string representation with: + - Role and timestamp information + - Text content and tool calls + - Memory compact threshold to limit total token count + """ + + def __init__( + self, + token_counter: HuggingFaceTokenCounter, + memory_compact_threshold: int, + ): + """Initialize MemoryFormatter. + + Args: + token_counter: Token counter for estimating token counts. + memory_compact_threshold: Maximum token count before skipping + older messages. + """ + self._token_counter = token_counter + self._memory_compact_threshold = memory_compact_threshold + self.max_length = int( + os.getenv("MAX_FORMATTER_TEXT_LENGTH", str(_DEFAULT_MAX_FORMATTER_TEXT_LENGTH)), + ) + + @staticmethod + def _format_tool_result_output(output: str | list[dict]) -> str: + """Convert tool result output to string. + + Args: + output: Tool result output, either string or list of content blocks. + + Returns: + Formatted string representation of the tool result. + """ + if isinstance(output, str): + return output + + textual_parts = [] + + 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", "")) + + elif block_type in ["image", "audio", "video"]: + source = block.get("source", {}) + url = source.get("url", "") + if url: + textual_parts.append( + f"[{block_type}] {url}", + ) + else: + textual_parts.append(f"[{block_type}]") + + 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}") + + else: + # Unknown block type: log warning and skip + 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, + ) + + if not textual_parts: + return "" + if len(textual_parts) == 1: + return textual_parts[0] + return "\n".join(f"- {part}" for part in textual_parts) + + def _format_single_msg( + self, + msg: Msg, + index: int | None = None, + add_time: bool = True, + ) -> tuple[str, int]: + """Format a single Msg into string representation. + + Similar to Message.format_message style. + + Args: + msg: The Msg object to format. + index: Optional message index for round numbering. + add_time: Whether to include timestamp. + + Returns: + Tuple of (formatted_string, token_count). + """ + lines = [] + token_count = 0 + + # Build header: "round{index} [{timestamp}] {role}:" + prefix = f"round{index} " if index is not None else "" + time_str = f"[{msg.timestamp}] " if add_time and msg.timestamp else "" + role_str = msg.name or msg.role + header = f"{prefix}{time_str}{role_str}:" + lines.append(header) + token_count += safe_count_str_tokens(self._token_counter, header) + + # Process content blocks + for block in msg.get_content_blocks(): + typ = block.get("type") + + if typ == "text": + text_content = truncate_text(block.get("text", ""), self.max_length) + if text_content: + lines.append(text_content) + token_count += safe_count_str_tokens(self._token_counter, text_content) + + elif typ == "thinking": + # Skip thinking blocks to save tokens + pass + + elif typ in ["image", "audio", "video"]: + source = block.get("source", {}) + url = source.get("url", "") + if url: + lines.append(f"[{typ}] {url}") + else: + lines.append(f"[{typ}]") + # Estimate fixed token cost for media reference + token_count += 10 + + elif typ == "tool_use": + tool_name = block.get("name", "") + tool_input = block.get("input", {}) + try: + arguments_str = json.dumps(tool_input, ensure_ascii=False) + except (TypeError, ValueError): + arguments_str = str(tool_input) + truncated_args = truncate_text(arguments_str, self.max_length) + tool_line = f" - tool_call={tool_name} params={truncated_args}" + lines.append(tool_line) + token_count += safe_count_str_tokens(self._token_counter, tool_line) + + elif typ == "tool_result": + tool_name = block.get("name", "") + output = block.get("output", "") + formatted_output = self._format_tool_result_output(output) + truncated_output = truncate_text(formatted_output, self.max_length) + if truncated_output: + result_line = f" - tool_result={tool_name} output={truncated_output}" + lines.append(result_line) + token_count += safe_count_str_tokens(self._token_counter, result_line) + + else: + logger.warning( + "Unsupported block type %s in message, skipped.", + typ, + ) + + return "\n".join(lines), token_count + + def format( + self, + msgs: list[Msg], + add_time: bool = True, + add_index: bool = True, + ) -> str: + """Format list of Msg into 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: + msgs: List of Msg objects to format. + add_time: Whether to include timestamp in each message. + add_index: Whether to include round index in each message. + + Returns: + Formatted string with all messages joined by newlines. + """ + if not msgs: + return "" + + formatted_parts: list[str] = [] + total_token_count = 0 + + # Process messages in reverse order (newest first) + for i in range(len(msgs) - 1, -1, -1): + msg = msgs[i] + index = i if add_index else None + + formatted_msg, msg_token_count = self._format_single_msg( + msg, + index=index, + add_time=add_time, + ) + + # Always include current message first, then check threshold, at least one msg + formatted_parts.append(formatted_msg) + total_token_count += msg_token_count + + # Check if we should stop adding older messages + if total_token_count >= self._memory_compact_threshold: + logger.info( + "Skipping older messages: token count %d >= %d", + total_token_count, + self._memory_compact_threshold, + ) + break + + # Reverse to restore chronological order + formatted_parts.reverse() + + return "\n\n".join(formatted_parts) diff --git a/reme/memory/file_based_copaw/summarizer.py b/reme/memory/file_based_copaw/summarizer.py new file mode 100644 index 00000000..462ea9c5 --- /dev/null +++ b/reme/memory/file_based_copaw/summarizer.py @@ -0,0 +1,92 @@ +"""Summarizer module for memory summarization operations.""" + +import datetime +import logging + +from agentscope.agent import ReActAgent +from agentscope.formatter import FormatterBase +from agentscope.message import Msg +from agentscope.model import ChatModelBase +from agentscope.token import HuggingFaceTokenCounter +from agentscope.tool import Toolkit + +from .memory_formatter import MemoryFormatter +from .file_io import FileIO +from ...core.op import BaseOp + +logger = logging.getLogger(__name__) + + +class Summarizer(BaseOp): + """Summarizer class for summarizing memory messages.""" + + def __init__( + self, + working_dir: str, + memory_dir: str, + memory_compact_threshold: int, + chat_model: ChatModelBase, + formatter: FormatterBase, + token_counter: HuggingFaceTokenCounter, + toolkit: Toolkit | None = None, + **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.chat_model: ChatModelBase = chat_model + self.formatter: FormatterBase = formatter + self.as_token_counter: HuggingFaceTokenCounter = token_counter + if toolkit is not None: + self.toolkit: Toolkit = toolkit + else: + self.toolkit = Toolkit() + file_io = FileIO(working_dir=self.working_dir) + self.toolkit.register_tool_function(file_io.read) + self.toolkit.register_tool_function(file_io.write) + self.toolkit.register_tool_function(file_io.edit) + + async def execute(self): + messages: list[Msg] = self.context.get("messages", []) + + if not messages: + return "" + + formatter = MemoryFormatter( + token_counter=self.as_token_counter, + memory_compact_threshold=self.memory_compact_threshold, + ) + history_formatted_str: str = formatter.format(messages) + + if not history_formatted_str: + logger.warning(f"No history to summarize. messages={messages}") + return "" + + agent = ReActAgent( + name="reme_summarizer", + model=self.chat_model, + sys_prompt="You are a helpful assistant.", + formatter=self.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_copaw/summarizer.yaml b/reme/memory/file_based_copaw/summarizer.yaml new file mode 100644 index 00000000..9aa0892b --- /dev/null +++ b/reme/memory/file_based_copaw/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_copaw/tool_result_compactor.py b/reme/memory/file_based_copaw/tool_result_compactor.py new file mode 100644 index 00000000..5b1ef0a4 --- /dev/null +++ b/reme/memory/file_based_copaw/tool_result_compactor.py @@ -0,0 +1,105 @@ +"""Tool Result Compactor: truncate large tool results and save full content to files.""" + +import logging +import uuid +from datetime import datetime, timedelta +from pathlib import Path + +from agentscope.message import Msg + +from .utils import is_truncated, truncate_text +from ...core.op import BaseOp + +logger = logging.getLogger(__name__) + + +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_copaw/utils.py b/reme/memory/file_based_copaw/utils.py new file mode 100644 index 00000000..a5f8f967 --- /dev/null +++ b/reme/memory/file_based_copaw/utils.py @@ -0,0 +1,231 @@ +"""Utility functions for working with text.""" + +import logging + +from agentscope.token import HuggingFaceTokenCounter + +logger = logging.getLogger(__name__) + +# Unique marker for truncated text +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 + + +def _extract_text_from_messages(messages: list[dict]) -> str: + """Extract text content from messages and concatenate into a string. + + Handles various message formats: + - Simple string content: {"role": "user", "content": "hello"} + - List content with text blocks: + {"role": "user", "content": [{"type": "text", "text": "hello"}]} + - List content with tool_result blocks: + {"role": "user", "content": [{"type": "tool_result", "output": "..."}]} + + Args: + messages: List of message dictionaries in chat format. + + Returns: + str: Concatenated text content from all messages. + """ + parts = [] + for msg in messages: + content = msg.get("content", "") + if isinstance(content, str): + parts.append(content) + elif isinstance(content, list): + for block in content: + if isinstance(block, dict): + block_type = block.get("type", "") + if block_type == "tool_result": + output = block.get("output", "") + if isinstance(output, str) and output: + parts.append(output) + elif isinstance(output, list): + for sub in output: + if isinstance(sub, dict): + sub_text = sub.get("text") or sub.get("content", "") + if sub_text: + parts.append(str(sub_text)) + else: + text = block.get("text") or block.get("content", "") + if text: + parts.append(str(text)) + elif isinstance(block, str): + parts.append(block) + return "\n".join(parts) + + +def safe_count_message_tokens( + token_counter: HuggingFaceTokenCounter, + messages: list[dict], +) -> int: + """Safely count tokens in messages with fallback estimation. + + This is a wrapper around count_message_tokens that catches exceptions + and falls back to a character-based estimation (len // 4) if the + tokenizer fails. + + Args: + token_counter: Token counter instance. + messages: List of message dictionaries in chat format. + + Returns: + int: The estimated number of tokens in the messages. + """ + try: + text = _extract_text_from_messages(messages) + token_ids = token_counter.tokenizer.encode(text) + token_count = len(token_ids) + return token_count + + except Exception as e: + # Fallback to character-based estimation + text = _extract_text_from_messages(messages) + estimated_tokens = len(text) // 4 + logger.warning( + "Failed to count tokens: %s, using estimated_tokens=%d", + e, + estimated_tokens, + ) + return estimated_tokens + + +def safe_count_str_tokens( + token_counter: HuggingFaceTokenCounter, + text: str, +) -> int: + """Safely count tokens in a string with fallback estimation. + + Uses the tokenizer to count tokens in the given text. If the tokenizer + fails, falls back to a character-based estimation (len // 4). + + Args: + token_counter: Token counter instance. + text: The string to count tokens for. + + Returns: + int: The estimated number of tokens in the string. + """ + try: + token_ids = token_counter.tokenizer.encode(text) + token_count = len(token_ids) + return token_count + except Exception as e: + # Fallback to character-based estimation + estimated_tokens = len(text) // 4 + logger.warning( + "Failed to count string tokens: %s, using estimated_tokens=%d", + e, + estimated_tokens, + ) + return estimated_tokens + + +def _get_block_tokens( # pylint: disable=too-many-return-statements + block: dict, + block_type: str, + token_counter: HuggingFaceTokenCounter, +) -> tuple[int, str]: + """Get token count and content string for different block types. + + Args: + block: The content block dict + block_type: The type of the block + + Returns: + Tuple of (token count, content string) + """ + if block_type == "text": + text = block.get("text", "") + return (safe_count_str_tokens(token_counter, text), text) if text else (0, "") + + if block_type == "thinking": + thinking = block.get("thinking", "") + return (safe_count_str_tokens(token_counter, thinking), thinking) if thinking else (0, "") + + if block_type == "tool_use": + # Count input dict and raw_input string + input_dict = block.get("input", {}) + raw_input = block.get("raw_input", "") + input_str = str(input_dict) if input_dict else "" + total = input_str + raw_input + return (safe_count_str_tokens(token_counter, total), total) if total else (0, "") + + if block_type == "tool_result": + output = block.get("output") + if isinstance(output, str): + return (safe_count_str_tokens(token_counter, output), output) if output else (0, "") + + if isinstance(output, list): + # Recursively count tokens in nested blocks + total_tokens = 0 + total_str = "" + for item in output: + if isinstance(item, dict): + item_type = item.get("type", "unknown") + item_tokens, item_str = _get_block_tokens(item, item_type, token_counter) + total_tokens += item_tokens + total_str += item_str + return total_tokens, total_str + return 0, "" + + if block_type in ("image", "audio", "video"): + # For media blocks, count the URL or indicate base64 size + source = block.get("source", {}) + if source.get("type") == "url": + url = source.get("url", "") + return safe_count_str_tokens(token_counter, url), url + if source.get("type") == "base64": + # Base64 data can be large, return approximate token count + data = source.get("data", "") + return (len(data) // 4, "[base64]") if data else (0, "") + return 0, "" + + return 0, "" diff --git a/reme/reme_cli.py b/reme/reme_cli.py index 9255bef1..68469425 100644 --- a/reme/reme_cli.py +++ b/reme/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/reme/reme_copaw.py b/reme/reme_copaw.py new file mode 100644 index 00000000..480fda62 --- /dev/null +++ b/reme/reme_copaw.py @@ -0,0 +1,700 @@ +""" +ReMe Copaw Application Module + +This module provides the ReMeCopaw 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 +import logging +import os +import platform +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 .memory.file_based_copaw import Compactor, Summarizer, ToolResultCompactor, CoPawInMemoryMemory +from .memory.tools import MemorySearch + +# Module-level logger for tracking application events and errors +logger = logging.getLogger(__name__) + + +class ReMeCopaw(Application): + """ + ReMe Copaw Application Class + + A specialized application class that extends ReMe's core Application framework + with advanced memory management capabilities. This class is designed to handle + long-running conversations by providing intelligent memory compaction, + summarization, and semantic search features. + + Attributes: + working_path (Path): Absolute path to the working directory for storing data + memory_path (Path): Path to the memory storage directory + tool_result_path (Path): Path to store large tool result files + chat_model (ChatModelBase): Language model for generating summaries and processing + formatter (FormatterBase): Formatter for structuring model inputs/outputs + token_counter (HuggingFaceTokenCounter): Token counting utility for length management + toolkit (Toolkit): Collection of tools available to the application + max_input_length (int): Maximum allowed input length in tokens + memory_compact_threshold (int): Threshold at which memory compaction triggers + language (str): Language code for localization ("zh" for Chinese, empty for English) + vector_weight (float): Weight for vector search in hybrid search (0.0-1.0) + candidate_multiplier (float): Multiplier for candidate retrieval in search + tool_result_threshold (int): Size threshold for tool result compaction + retention_days (int): Number of days to retain tool result files + summary_tasks (list[asyncio.Task]): List of background summarization tasks + """ + + def __init__( + self, + working_dir: str, + chat_model: ChatModelBase, + formatter: FormatterBase, + token_counter: HuggingFaceTokenCounter, + toolkit: Toolkit, + max_input_length: int, + memory_compact_ratio: float, + language: str = "zh", + vector_weight: float = 0.7, + candidate_multiplier: float = 3.0, + tool_result_threshold: int = 1000, + retention_days: int = 7, + ): + # Initialize working directory structure + # All application data will be stored under this path + self.working_path = Path(working_dir).absolute() + self.working_path.mkdir(parents=True, exist_ok=True) + + # Create memory storage directory for persistent memory files + self.memory_path = self.working_path / "memory" + self.memory_path.mkdir(parents=True, exist_ok=True) + + # Create tool result directory for storing large tool outputs + self.tool_result_path = self.working_path / "tool_result" + self.tool_result_path.mkdir(parents=True, exist_ok=True) + + # Store references to core components + self.chat_model: ChatModelBase = chat_model + self.formatter: FormatterBase = formatter + self.token_counter: HuggingFaceTokenCounter = token_counter + self.toolkit: Toolkit = toolkit + + # Initialize runtime parameters (will be updated via update_params) + self.max_input_length: int = 0 + self.memory_compact_threshold: int = 0 + self.language: str = "" + + # Store configuration parameters + 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 + + # Apply initial parameter configuration + self.update_params( + max_input_length=max_input_length, + memory_compact_ratio=memory_compact_ratio, + language=language, + ) + + # Retrieve embedding configuration from environment variables + # These settings control the vector search capabilities + ( + embedding_api_key, + embedding_base_url, + embedding_model_name, + embedding_dimensions, + embedding_cache_enabled, + embedding_max_cache_size, + embedding_max_input_length, + embedding_max_batch_size, + ) = self.get_emb_envs() + + # Determine if vector search should be enabled based on configuration + # Vector search requires either an API key or a local model name + vector_enabled = bool(embedding_api_key) or bool(embedding_model_name) + if vector_enabled: + logger.info("Vector search enabled.") + else: + logger.warning( + "Vector search disabled. Memory search functionality will be restricted. " + "To enable, configure: EMBEDDING_API_KEY, EMBEDDING_BASE_URL, EMBEDDING_MODEL_NAME.", + ) + + # Check if full-text search (FTS) is enabled via environment variable + fts_enabled = os.environ.get("FTS_ENABLED", "true").lower() == "true" + + # Determine the memory store backend to use + # "auto" selects based on platform (local for Windows, chroma otherwise) + memory_store_backend = os.environ.get("MEMORY_STORE_BACKEND", "auto") + if memory_store_backend == "auto": + memory_backend = "local" if platform.system() == "Windows" else "chroma" + else: + memory_backend = memory_store_backend + + # Initialize the parent Application class with configuration + # Initialize the parent Application class with comprehensive configuration + super().__init__( + embedding_api_key=embedding_api_key, + embedding_base_url=embedding_base_url, + working_dir=str(self.working_path), + config_path="copaw", + enable_logo=False, + log_to_console=False, + parser=ReMeConfigParser, + default_embedding_model_config={ + "model_name": embedding_model_name, + "dimensions": embedding_dimensions, + "enable_cache": embedding_cache_enabled, + "use_dimensions": False, + "max_cache_size": embedding_max_cache_size, + "max_input_length": embedding_max_input_length, + "max_batch_size": embedding_max_batch_size, + }, + default_file_store_config={ + "backend": memory_backend, + "store_name": "copaw", + "vector_enabled": vector_enabled, + "fts_enabled": fts_enabled, + }, + 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] = [] + + def update_params( + self, + max_input_length: int, + memory_compact_ratio: float, + language: str, + ): + """ + Update runtime parameters for memory management. + + This method allows dynamic adjustment of memory-related parameters during + runtime. It recalculates the memory compaction threshold based on the + new input length and compaction ratio. + + Args: + max_input_length (int): New maximum input length in tokens + memory_compact_ratio (float): Ratio at which to trigger compaction (0.0-1.0) + language (str): Language code for localization ("zh" or other) + + Note: + The memory_compact_threshold is calculated as: + max_input_length * memory_compact_ratio * 0.9 + The 0.9 factor provides a safety margin before reaching the absolute limit + """ + # Update the maximum allowed input length + self.max_input_length = max_input_length + + # Calculate compaction threshold with safety margin + # This ensures compaction happens before hitting the hard limit + self.memory_compact_threshold = int(max_input_length * memory_compact_ratio * 0.9) + + # Set language for localization + if language == "zh": + self.language = "zh" + else: + self.language = "" + + @staticmethod + def _safe_str(key: str, default: str) -> str: + """ + Safely retrieve a string value from an environment variable. + + Args: + key (str): The name of the environment variable to retrieve + default (str): The default value to return if the variable is not set + + Returns: + str: The value of the environment variable, or the default if not set + """ + return os.environ.get(key, default) + + @staticmethod + def _safe_int(key: str, default: int) -> int: + """ + Safely retrieve an integer value from an environment variable. + + This method handles cases where the environment variable is not set + or contains a non-integer value by returning the specified default. + + Args: + key (str): The name of the environment variable to retrieve + default (int): The default value to return on failure or if not set + + Returns: + int: The integer value of the environment variable, or the default + + Note: + Logs a warning if the value exists but cannot be parsed as an integer + """ + value = os.environ.get(key) + if value is None: + return default + + try: + return int(value) + except ValueError: + logger.warning(f"Invalid int value '{value}' for key '{key}', using default {default}") + return default + + def get_emb_envs(self): + """ + Retrieve all embedding-related configuration from environment variables. + + This method collects all settings needed for the embedding service, + including API credentials, model configuration, and caching parameters. + + Environment Variables: + EMBEDDING_API_KEY: API key for the embedding service + EMBEDDING_BASE_URL: Base URL for the embedding API (default: dashscope) + EMBEDDING_MODEL_NAME: Name of the embedding model to use + EMBEDDING_DIMENSIONS: Vector dimensions (default: 1024) + EMBEDDING_CACHE_ENABLED: Whether to enable caching (default: true) + EMBEDDING_MAX_CACHE_SIZE: Maximum cache entries (default: 2000) + EMBEDDING_MAX_INPUT_LENGTH: Max input text length (default: 8192) + EMBEDDING_MAX_BATCH_SIZE: Max batch size for requests (default: 10) + + Returns: + tuple: A tuple containing all embedding configuration values in order: + (api_key, base_url, model_name, dimensions, cache_enabled, + max_cache_size, max_input_length, max_batch_size) + """ + # API authentication and endpoint configuration + embedding_api_key = self._safe_str("EMBEDDING_API_KEY", "") + embedding_base_url = self._safe_str("EMBEDDING_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1") + embedding_model_name = self._safe_str("EMBEDDING_MODEL_NAME", "") + + # Model and vector configuration + embedding_dimensions = self._safe_int("EMBEDDING_DIMENSIONS", 1024) + + # Caching configuration for performance optimization + embedding_cache_enabled = self._safe_str("EMBEDDING_CACHE_ENABLED", "true").lower() == "true" + embedding_max_cache_size = self._safe_int("EMBEDDING_MAX_CACHE_SIZE", 2000) + + # Input processing limits + embedding_max_input_length = self._safe_int("EMBEDDING_MAX_INPUT_LENGTH", 8192) + embedding_max_batch_size = self._safe_int("EMBEDDING_MAX_BATCH_SIZE", 10) + + return ( + embedding_api_key, + embedding_base_url, + embedding_model_name, + embedding_dimensions, + embedding_cache_enabled, + embedding_max_cache_size, + embedding_max_input_length, + embedding_max_batch_size, + ) + + 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 + + Note: + Exceptions during cleanup are logged but do not raise errors, + ensuring the application continues to function even if cleanup fails + """ + 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. + + This method initializes the application by calling the parent class's + start method and performs initial cleanup of expired tool result files. + + Returns: + The result from the parent class's start method + + Note: + Tool result cleanup runs after successful startup to ensure + the application is fully initialized before performing maintenance + """ + # Initialize parent application components + result = await super().start() + # Perform initial cleanup of old tool result files + self._cleanup_tool_results() + return result + + async def close(self) -> bool: + """ + Close the application and perform cleanup. + + This method performs final cleanup of expired tool result files before + shutting down the application through the parent class's close method. + + Returns: + bool: True if shutdown was successful, False otherwise + + Note: + Cleanup is performed before calling parent close to ensure + all resources are available during the cleanup process + """ + # Clean up tool results before shutting down + self._cleanup_tool_results() + # Shutdown parent application components + 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 and identifies tool results that exceed + the configured size threshold. Large tool outputs are truncated in the message + list while their full content is saved to files for later retrieval. + + Args: + messages (list[Msg]): List of messages to process for tool result compaction + + Returns: + list[Msg]: The processed message list with large tool results compacted + + Note: + - Tool results below the threshold remain unchanged in the messages + - Large results are replaced with truncated versions and file references + - Expired files are cleaned up as part of the compaction process + - If compaction fails, the original messages are returned unchanged + """ + 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, + ) + # Set the messages context for the compactor to process + compactor.context["messages"] = messages + + # Execute compaction and get processed messages + result = await compactor.execute() + + # 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 compact_memory(self, messages: list[Msg], previous_summary: str = "") -> str: + """ + Compact a list of messages into a condensed summary. + + This method uses the Compactor to reduce the length of message history + while preserving essential information. It's useful when conversation + history approaches the maximum input length limit. + + Args: + messages (list[Msg]): The list of messages to compact + previous_summary (str): Optional previous summary to incorporate + into the compaction process for continuity + + Returns: + str: A compacted summary of the messages, or empty string on failure + + Note: + - Compaction uses the configured language model to generate summaries + - The compaction threshold determines when compaction is triggered + - If compaction fails, an empty string is returned + """ + try: + # Initialize compactor with current configuration + compactor = Compactor( + memory_compact_threshold=self.memory_compact_threshold, + chat_model=self.chat_model, + formatter=self.formatter, + token_counter=self.token_counter, + language=self.language, + ) + + # Execute compaction with optional previous summary context + return await compactor.call(messages=messages, previous_summary=previous_summary) + + 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]) -> str: + """ + Generate a comprehensive summary of the given messages. + + This method uses the Summarizer to create a detailed summary of the + conversation history, which can be stored as persistent memory. Unlike + compaction, summarization aims to capture key information in a format + suitable for long-term storage and retrieval. + + Args: + messages (list[Msg]): The list of messages to summarize + + Returns: + str: A generated summary of the messages, or empty string on failure + + Note: + - Summarization may use tools from the toolkit to enhance the summary + - The summary is typically stored in the memory directory + - If summarization fails, an empty string is returned + """ + try: + # Initialize summarizer with working directories and configuration + compactor = Summarizer( + working_dir=str(self.working_path), + memory_dir=str(self.memory_path), + memory_compact_threshold=self.memory_compact_threshold, + chat_model=self.chat_model, + formatter=self.formatter, + token_counter=self.token_counter, + toolkit=self.toolkit, + language=self.language, + ) + + # Execute summarization on the provided messages + return await compactor.call(messages=messages) + + except Exception as e: + # Log error and return empty string to indicate failure + logger.exception(f"Error summarizing memory: {e}") + return "" + + async def await_summary_tasks(self) -> str: + """ + Wait for all background summary tasks to complete and collect results. + + This method iterates through all pending summary tasks, waits for their + completion, and collects their results or error information. It's used + to synchronize with background summarization operations before shutdown + or when results are needed. + + Returns: + str: A concatenated string containing the status and results of + all summary tasks, with each task on a new line + + Note: + - Completed tasks are processed immediately without waiting + - Incomplete tasks are awaited with a timeout + - Cancelled tasks and exceptions are logged and included in results + - The task list is cleared after processing all tasks + """ + 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.exception(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 + + def add_async_summary_task(self, messages: list[Msg]): + """ + Add an asynchronous summary task for the given messages. + + This method creates a background task to summarize the provided messages + without blocking the main execution flow. Before adding a new task, it + cleans up any completed tasks from the task list to prevent memory leaks. + + Args: + messages (list[Msg]): The list of messages to be summarized in the + background task + + Note: + - Completed tasks are removed from the tracking list before adding + - Task status (success, failure, cancellation) is logged for monitoring + - The new task is created using asyncio.create_task for true async execution + - Failed or cancelled tasks are logged but do not prevent new tasks + """ + # Clean up completed summary tasks before adding a new one + remaining_tasks = [] + for task in self.summary_tasks: + if task.done(): + # Process completed task status + if task.cancelled(): + logger.warning("Summary task was cancelled.") + continue + exc = task.exception() + if exc is not None: + logger.exception(f"Summary task failed: {exc}") + else: + # Log successful completion with result summary + result = task.result() + logger.info(f"Summary task completed: {result}") + else: + # Keep incomplete tasks in the tracking list + remaining_tasks.append(task) + self.summary_tasks = remaining_tasks + + # Create and track the new background summarization task + task = asyncio.create_task(self.summary_memory(messages=messages)) + self.summary_tasks.append(task) + + async def memory_search(self, query: str, max_results: int = 5, min_score: float = 0.1) -> ToolResponse: + """ + Perform semantic memory search using vector and full-text search. + + This method searches the memory store for content relevant to the given query + using a hybrid approach combining vector similarity search and full-text search. + Results are ranked by relevance and filtered by the minimum score threshold. + + Args: + query (str): The search query string. Must not be empty. + max_results (int): Maximum number of results to return (1-100, default: 5) + min_score (float): Minimum relevance score threshold (0.001-0.999, default: 0.1) + + Returns: + ToolResponse: A ToolResponse containing the search results as text, + or an error message if the query is empty + + Note: + - Vector search weight is controlled by self.vector_weight + - Candidate retrieval uses self.candidate_multiplier for broader search + - Parameters are validated and clamped to valid ranges + - Requires vector search to be enabled via embedding configuration + """ + # 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) + else: + max_results = 5 + + # Validate and clamp min_score to valid range [0.001, 0.999] + if isinstance(min_score, (int, float)): + min_score = min(max(min_score, 0.001), 0.999) + 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, + ), + ], + ) + + def get_in_memory_memory(self): + """ + Create and return an in-memory memory instance. + + This method instantiates a CoPawInMemoryMemory object configured with + the current application's token counter, formatter, and input length limits. + The in-memory memory provides fast, temporary storage for conversation + context without persistence. + + Returns: + CoPawInMemoryMemory: A configured in-memory memory instance ready + for storing and retrieving conversation messages + + Note: + - In-memory memory is volatile and cleared when the instance is destroyed + - Useful for managing conversation context within a single session + - Shares the same token counter and formatter as the main application + """ + return CoPawInMemoryMemory( + token_counter=self.token_counter, + formatter=self.formatter, + max_input_length=self.max_input_length, + ) 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/tests/copaw/test_compactor.py b/tests/copaw/test_compactor.py new file mode 100644 index 00000000..d044f376 --- /dev/null +++ b/tests/copaw/test_compactor.py @@ -0,0 +1,375 @@ +"""Tests for Compactor.""" + +import asyncio +import logging + +from agentscope.message import Msg + +from test_utils import ( + get_dash_chat_model, + get_formatter, + get_token_counter, +) +from reme.memory.file_based_copaw import Compactor + +# 配置日志输出到控制台 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +# 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, + chat_model=get_dash_chat_model(), + formatter=get_formatter(), + token_counter=get_token_counter(), + ) + + +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, + chat_model=get_dash_chat_model(), + formatter=get_formatter(), + token_counter=get_token_counter(), + ) + + 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, + chat_model=get_dash_chat_model(), + formatter=get_formatter(), + token_counter=get_token_counter(), + ) + + 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/copaw/test_memory_formatter.py b/tests/copaw/test_memory_formatter.py new file mode 100644 index 00000000..7da3db2d --- /dev/null +++ b/tests/copaw/test_memory_formatter.py @@ -0,0 +1,489 @@ +"""Tests for MemoryFormatter.""" + +# pylint: disable=W0212 + +import logging + +from agentscope.message import Msg + +from test_utils import get_token_counter +from reme.memory.file_based_copaw import MemoryFormatter + +# 配置日志输出到控制台 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +# 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 | list[dict]) -> 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_thinking_msg(thinking_content: str) -> Msg: + """Create a message with thinking content block.""" + return Msg( + name="assistant", + role="assistant", + content=[ + { + "type": "thinking", + "text": thinking_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_formatter(memory_compact_threshold: int = 4000) -> MemoryFormatter: + """Create a MemoryFormatter instance for testing.""" + return MemoryFormatter( + token_counter=get_token_counter(), + memory_compact_threshold=memory_compact_threshold, + ) + + +# ==================== _format_tool_result_output Tests ==================== + + +def test_format_tool_result_output_string(): + """Test _format_tool_result_output with string input.""" + result = MemoryFormatter._format_tool_result_output("Hello, world!") + assert result == "Hello, world!", f"Expected 'Hello, world!', got: {result}" + print_pass("test_format_tool_result_output_string") + + +def test_format_tool_result_output_text_block(): + """Test _format_tool_result_output with text block.""" + output = [{"type": "text", "text": "This is text content"}] + result = MemoryFormatter._format_tool_result_output(output) + assert result == "This is text content", f"Expected 'This is text content', got: {result}" + print_pass("test_format_tool_result_output_text_block") + + +def test_format_tool_result_output_image_block(): + """Test _format_tool_result_output with image block.""" + output = [{"type": "image", "source": {"url": "https://example.com/image.png"}}] + result = MemoryFormatter._format_tool_result_output(output) + assert "[image]" in result, f"Expected '[image]' in result, got: {result}" + assert "https://example.com/image.png" in result, f"Expected URL in result, got: {result}" + print_pass("test_format_tool_result_output_image_block") + + +def test_format_tool_result_output_file_block(): + """Test _format_tool_result_output with file block.""" + output = [{"type": "file", "path": "/path/to/file.txt", "name": "file.txt"}] + result = MemoryFormatter._format_tool_result_output(output) + assert "[file]" in result, f"Expected '[file]' in result, got: {result}" + assert "file.txt" in result, f"Expected 'file.txt' in result, got: {result}" + print_pass("test_format_tool_result_output_file_block") + + +def test_format_tool_result_output_multiple_blocks(): + """Test _format_tool_result_output with multiple blocks.""" + output = [ + {"type": "text", "text": "First part"}, + {"type": "text", "text": "Second part"}, + ] + result = MemoryFormatter._format_tool_result_output(output) + assert "First part" in result, f"Expected 'First part' in result, got: {result}" + assert "Second part" in result, f"Expected 'Second part' in result, got: {result}" + # Multiple parts should be joined with newlines and bullets + assert "- " in result, f"Expected bullet format in result, got: {result}" + print_pass("test_format_tool_result_output_multiple_blocks") + + +def test_format_tool_result_output_empty_list(): + """Test _format_tool_result_output with empty list.""" + result = MemoryFormatter._format_tool_result_output([]) + assert result == "", f"Expected empty string, got: {result}" + print_pass("test_format_tool_result_output_empty_list") + + +def test_format_tool_result_output_invalid_block(): + """Test _format_tool_result_output with invalid block (missing type).""" + output = [{"text": "No type key"}] + result = MemoryFormatter._format_tool_result_output(output) + assert result == "", f"Expected empty string for invalid block, got: {result}" + print_pass("test_format_tool_result_output_invalid_block") + + +def test_format_tool_result_output_unknown_type(): + """Test _format_tool_result_output with unknown block type.""" + output = [{"type": "unknown_type", "data": "some data"}] + result = MemoryFormatter._format_tool_result_output(output) + assert result == "", f"Expected empty string for unknown type, got: {result}" + print_pass("test_format_tool_result_output_unknown_type") + + +# ==================== format (single message) Tests ==================== + + +def test_format_empty_messages(): + """Test format with empty message list.""" + formatter = create_formatter() + result = formatter.format([]) + assert result == "", f"Expected empty string, got: {result}" + print_pass("test_format_empty_messages") + + +def test_format_single_user_message(): + """Test format with a single user message.""" + formatter = create_formatter() + msgs = [create_user_msg("Hello, how are you?")] + result = formatter.format(msgs) + + 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}" + print_pass("test_format_single_user_message") + + +def test_format_single_assistant_message(): + """Test format with a single assistant message.""" + formatter = create_formatter() + msgs = [create_assistant_msg("I am fine, thank you!")] + result = formatter.format(msgs) + + assert "assistant:" in result, f"Expected 'assistant:' in result, got: {result}" + assert "I am fine, thank you!" in result, f"Expected content in result, got: {result}" + print_pass("test_format_single_assistant_message") + + +def test_format_with_tool_use(): + """Test format with tool_use message.""" + formatter = create_formatter() + msgs = [create_tool_use_msg("read_file", {"path": "/test.txt"})] + result = formatter.format(msgs) + + assert "tool_call=read_file" in result, f"Expected 'tool_call=read_file' in result, got: {result}" + assert "params=" in result, f"Expected 'params=' in result, got: {result}" + print_pass("test_format_with_tool_use") + + +def test_format_with_tool_result(): + """Test format with tool_result message.""" + formatter = create_formatter() + msgs = [create_tool_result_msg("read_file", "file content here")] + result = formatter.format(msgs) + + assert "tool_result=read_file" in result, f"Expected 'tool_result=read_file' in result, got: {result}" + assert "output=" in result, f"Expected 'output=' in result, got: {result}" + print_pass("test_format_with_tool_result") + + +def test_format_with_thinking_block(): + """Test that thinking blocks are skipped.""" + formatter = create_formatter() + msgs = [create_thinking_msg("Let me think about this...")] + result = formatter.format(msgs) + + # Thinking content should NOT appear in the result + assert "Let me think about this" not in result, f"Thinking content should be skipped, got: {result}" + print_pass("test_format_with_thinking_block") + + +def test_format_with_image(): + """Test format with image content block.""" + formatter = create_formatter() + msgs = [create_image_msg("https://example.com/image.png")] + result = formatter.format(msgs) + + assert "[image]" in result, f"Expected '[image]' in result, got: {result}" + print_pass("test_format_with_image") + + +# ==================== format (multiple messages) Tests ==================== + + +def test_format_conversation(): + """Test format with a conversation.""" + formatter = create_formatter() + 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 and simplicity."), + ] + result = formatter.format(msgs) + + assert "round0" in result, f"Expected 'round0' in result, got: {result}" + assert "round1" in result, f"Expected 'round1' in result, got: {result}" + assert "round2" in result, f"Expected 'round2' in result, got: {result}" + assert "round3" in result, f"Expected 'round3' in result, got: {result}" + print_pass("test_format_conversation") + + +def test_format_without_index(): + """Test format without round index.""" + formatter = create_formatter() + msgs = [ + create_user_msg("Hello"), + create_assistant_msg("Hi there!"), + ] + result = formatter.format(msgs, add_index=False) + + assert "round" not in result, f"Expected no 'round' prefix, got: {result}" + print_pass("test_format_without_index") + + +def test_format_without_time(): + """Test format without timestamp.""" + formatter = create_formatter() + msgs = [create_user_msg("Test message")] + result = formatter.format(msgs, add_time=False) + + # The result should not have timestamp brackets at the beginning + # Note: this test may need adjustment based on actual timestamp format + assert "user:" in result, f"Expected 'user:' in result, got: {result}" + print_pass("test_format_without_time") + + +def test_format_with_tool_conversation(): + """Test format with tool use and result in conversation.""" + formatter = create_formatter() + 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 = formatter.format(msgs) + + assert "user:" in result + assert "tool_call=read_file" in result + assert "tool_result=read_file" in result + assert "assistant:" in result + print_pass("test_format_with_tool_conversation") + + +# ==================== Token Threshold Tests ==================== + + +def test_format_low_threshold(): + """Test that older messages are skipped with low threshold.""" + formatter = create_formatter(memory_compact_threshold=100) + msgs = [] + for i in range(20): + msgs.append(create_user_msg(f"Question {i}: " + "x" * 50)) + msgs.append(create_assistant_msg(f"Answer {i}: " + "y" * 50)) + + result = formatter.format(msgs) + + # With low threshold, not all messages should be included + # The newest messages should be present + assert "round39" in result or "round38" in result, f"Expected recent round in result, got: {result}" + # Older messages might be truncated + logger.info(f"Result length: {len(result)}") + print_pass("test_format_low_threshold") + + +def test_format_high_threshold(): + """Test that all messages are included with high threshold.""" + formatter = create_formatter(memory_compact_threshold=100000) + msgs = [ + create_user_msg("Message 1"), + create_assistant_msg("Response 1"), + create_user_msg("Message 2"), + create_assistant_msg("Response 2"), + ] + result = formatter.format(msgs) + + # All messages should be included + assert "round0" in result + assert "round1" in result + assert "round2" in result + assert "round3" in result + print_pass("test_format_high_threshold") + + +# ==================== Edge Cases Tests ==================== + + +def test_format_long_text_truncation(): + """Test that long text is truncated.""" + formatter = create_formatter() + long_text = "x" * 5000 # Much longer than default max length + msgs = [create_user_msg(long_text)] + result = formatter.format(msgs) + + # The result should be shorter due to truncation + assert len(result) < len(long_text), f"Expected truncated result, got length: {len(result)}" + print_pass("test_format_long_text_truncation") + + +def test_format_special_characters(): + """Test format with special characters in content.""" + formatter = create_formatter() + msgs = [create_user_msg("Test with 中文, 日本語, émojis 🎉")] + result = formatter.format(msgs) + + assert "中文" in result, f"Expected Chinese characters in result, got: {result}" + print_pass("test_format_special_characters") + + +def test_format_tool_result_with_complex_output(): + """Test format with complex tool result output.""" + formatter = create_formatter() + 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 = formatter.format(msgs) + + assert "tool_result=process_data" in result, f"Expected tool result in result, got: {result}" + print_pass("test_format_tool_result_with_complex_output") + + +def run_all_tests(): + """Run all tests.""" + tests = [ + # _format_tool_result_output tests + test_format_tool_result_output_string, + test_format_tool_result_output_text_block, + test_format_tool_result_output_image_block, + test_format_tool_result_output_file_block, + test_format_tool_result_output_multiple_blocks, + test_format_tool_result_output_empty_list, + test_format_tool_result_output_invalid_block, + test_format_tool_result_output_unknown_type, + # format tests (single message) + test_format_empty_messages, + test_format_single_user_message, + test_format_single_assistant_message, + test_format_with_tool_use, + test_format_with_tool_result, + test_format_with_thinking_block, + test_format_with_image, + # format tests (multiple messages) + test_format_conversation, + test_format_without_index, + test_format_without_time, + test_format_with_tool_conversation, + # threshold tests + test_format_low_threshold, + test_format_high_threshold, + # edge cases + test_format_long_text_truncation, + test_format_special_characters, + test_format_tool_result_with_complex_output, + ] + + 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/copaw/test_summarizer.py b/tests/copaw/test_summarizer.py new file mode 100644 index 00000000..d0f7eb5f --- /dev/null +++ b/tests/copaw/test_summarizer.py @@ -0,0 +1,310 @@ +"""Tests for Summarizer.""" + +import asyncio +import datetime +import logging +import tempfile +from pathlib import Path + +from agentscope.message import Msg + +from test_utils import ( + get_dash_chat_model, + get_formatter, + get_token_counter, +) +from reme.memory.file_based_copaw import Summarizer + +# 配置日志输出到控制台 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +# 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_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, + chat_model=get_dash_chat_model(), + formatter=get_formatter(), + token_counter=get_token_counter(), + ), + 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, + chat_model=get_dash_chat_model(), + formatter=get_formatter(), + token_counter=get_token_counter(), + ) + + # 第一轮对话 + 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/copaw/test_tool_result_compactor.py b/tests/copaw/test_tool_result_compactor.py new file mode 100644 index 00000000..ba225255 --- /dev/null +++ b/tests/copaw/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.memory.file_based_copaw.tool_result_compactor import ToolResultCompactor +from reme.memory.file_based_copaw.utils import TRUNCATION_MARKER_START + + +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 TRUNCATION_MARKER_START in 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 = f"head{TRUNCATION_MARKER_START}(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 TRUNCATION_MARKER_START in 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 TRUNCATION_MARKER_START in output[0]["text"] + assert output[1]["text"] == "short" # unchanged + assert TRUNCATION_MARKER_START in 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 TRUNCATION_MARKER_START in 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/copaw/test_utils.py b/tests/copaw/test_utils.py new file mode 100644 index 00000000..f5da46e2 --- /dev/null +++ b/tests/copaw/test_utils.py @@ -0,0 +1,89 @@ +"""Test utilities for copaw tests.""" + +import os +from pathlib import Path +from typing import Any + +from loguru import logger + +_token_counter = None + + +def get_token_counter(): + """Get or initialize the global token counter instance. + + Returns: + TokenCounterBase: The token counter instance for Qwen models. + + Raises: + RuntimeError: If token counter initialization fails. + """ + global _token_counter + if _token_counter is None: + from agentscope.token import HuggingFaceTokenCounter + + # Use Qwen tokenizer for DashScope models + # Qwen3 series uses the same tokenizer as Qwen2.5 + + # Try local tokenizer first, fall back to online if not found + local_tokenizer_path = Path(__file__).parent.parent.parent / "tokenizer" + + if local_tokenizer_path.exists() and (local_tokenizer_path / "tokenizer.json").exists(): + tokenizer_path = str(local_tokenizer_path) + logger.info(f"Using local Qwen tokenizer from {tokenizer_path}") + else: + tokenizer_path = "Qwen/Qwen2.5-7B-Instruct" + logger.info( + "Local tokenizer not found, downloading from HuggingFace", + ) + + _token_counter = HuggingFaceTokenCounter( + pretrained_model_name_or_path=tokenizer_path, + use_mirror=True, # Use HF mirror for users in China + use_fast=True, + trust_remote_code=True, + ) + logger.debug("Token counter initialized with Qwen tokenizer") + return _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["REME_LLM_API_KEY"], + client_kwargs={"base_url": os.environ["REME_LLM_BASE_URL"]}, + model_name=model_name, + ) + + +def get_formatter(): + """Get formatter instance.""" + from agentscope.formatter import OpenAIChatFormatter + from agentscope.token import HuggingFaceTokenCounter + from reme.memory.file_based_copaw.utils import _extract_text_from_messages + + class ReMeChatFormatter(OpenAIChatFormatter): + """ReMe chat formatter class.""" + + async def _count(self, msgs: list[dict[str, Any]]) -> int | None: + """Count the number of tokens in the input messages. If token counter + is not provided, `None` will be returned. + + Args: + msgs (`list[Msg]`): + The input messages to count tokens for. + """ + if self.token_counter is None: + return None + + assert isinstance(self.token_counter, HuggingFaceTokenCounter) + text = _extract_text_from_messages(msgs) + token_ids = self.token_counter.tokenizer.encode(text) + token_count = len(token_ids) + return token_count + + return ReMeChatFormatter(token_counter=get_token_counter()) diff --git a/tests/test_fs_compactor.py b/tests/test_fs_compactor.py index 00be5320..1204b17a 100644 --- a/tests/test_fs_compactor.py +++ b/tests/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/tests/test_fs_context_checker.py index 14a7fe9f..8a3ed618 100644 --- a/tests/test_fs_context_checker.py +++ b/tests/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/tests/test_fs_file_watch_integration.py index 8f47b472..9f679d38 100644 --- a/tests/test_fs_file_watch_integration.py +++ b/tests/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/tests/test_fs_memory_get.py index d353751d..d6d8f5e3 100644 --- a/tests/test_fs_memory_get.py +++ b/tests/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/tests/test_fs_memory_search.py index 2a65fd5a..d546f27d 100644 --- a/tests/test_fs_memory_search.py +++ b/tests/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/tests/test_fs_summary.py index b2701344..8a692500 100644 --- a/tests/test_fs_summary.py +++ b/tests/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)")