diff --git a/README.md b/README.md index 0baa72f..40c3aa0 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,12 @@ *The More Human Choice.* +> ⚠️ **当前分支**: `test` — 测试分支,用于测试实验性功能。此处代码可能不稳定,不应用于生产环境。稳定版本请参阅 `main` 分支。 + [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)]() +[![Branch](https://img.shields.io/badge/branch-test-orange.svg)]() --- diff --git a/README_EN.md b/README_EN.md index 5805ca0..22c264f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -13,9 +13,12 @@ *The More Human Choice.* +> ⚠️ **Current Branch**: `test` — Testing branch for experimental features. Code here may be unstable and should not be used in production. For stable releases, see the `main` branch. + [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)]() +[![Branch](https://img.shields.io/badge/branch-test-orange.svg)]() --- diff --git a/core/prompts/templates/system_prompt.md b/core/prompts/templates/system_prompt.md index d7e2a74..ea6d59e 100644 --- a/core/prompts/templates/system_prompt.md +++ b/core/prompts/templates/system_prompt.md @@ -89,6 +89,7 @@ | `memory_commit` | 写入记忆 | 存储重要信息 | | `memory_purge` | 删除记忆 | 修正错误信息 | | `memory_introspect` | 查看状态 | 监控记忆系统 | +| `context_rewrite` | 压缩工具调用上下文 | 工具调用≥2次后,压缩JSON为自然语言摘要 | ### 人设工具 | 工具 | 功能 | 使用场景 | @@ -104,6 +105,29 @@ | `task_delete` | 删除任务 | 清理完成任务 | | `task_link_info` | 关联信息 | 连接任务与记忆 | +## context_rewrite 使用规则 + +当你已经执行了多次工具调用,且: +- 工具结果的JSON细节你已经理解,不再需要原始格式 +- 但你需要记住"我调用了哪些工具、得到了什么结论" +- 继续携带原始JSON会干扰后续推理 + +→ 调用 context_rewrite 压缩上下文 + +**强制格式要求**: +- 必须标注 `[工具调用总结: 本次总结了 N 次工具调用 | 调用工具: tool1, tool2]` +- 必须保留关键语义信息 +- 不可删除用户原始消息 +- 不可歪曲工具返回的关键事实 + +**示例**: +``` +[工具调用总结: 本次总结了 2 次工具调用 | 调用工具: memory_recall, memory_recall] + +- 查询人设图:未找到人设,使用默认身份 +- 查询工作记忆链:发现 Task_成语接龙,状态已暂停,当前成语为虎作伥 +``` + ## 每轮对话强制要求 ### ⚠️ 执行顺序(每轮必须) diff --git a/core/server.py b/core/server.py index cbbd1e6..c0024d9 100644 --- a/core/server.py +++ b/core/server.py @@ -60,10 +60,8 @@ class BackendServer: self._lock = threading.Lock() self._config = {"api_key": "", "base_url": "https://api.deepseek.com", "model": "deepseek-chat"} self._tool_limits = { - "persona_query_max": 1, "persona_update_max": 1, - "task_query_max": 4, - "task_update_max": 2, + "task_update_max": 5, "memory_query_max": 20, "memory_update_max": 10, } @@ -119,9 +117,7 @@ class BackendServer: def _create_tool_limiter(self): from .tool_limiter import ToolLimiter, ToolLimits limits = ToolLimits( - persona_query_max=self._tool_limits.get("persona_query_max", 1), persona_update_max=self._tool_limits.get("persona_update_max", 1), - task_query_max=self._tool_limits.get("task_query_max", 4), task_update_max=self._tool_limits.get("task_update_max", 5), memory_query_max=self._tool_limits.get("memory_query_max", 20), memory_update_max=self._tool_limits.get("memory_update_max", 10), @@ -243,6 +239,25 @@ class BackendServer: self._tool_limiter.record_call(tool_call.function.name, args) + if tool_call.function.name == "context_rewrite": + result = execute_tool(self._graph, tool_call.function.name, args) + result_data = json.loads(result) + + if result_data.get("status") == "success": + user_msg = messages_history[0] + messages_history[:] = [ + user_msg, + {"role": "assistant", "content": result_data["summary"]} + ] + + tool_result_msg = { + "role": "tool", + "tool_call_id": tool_call.id, + "content": result + } + current_tool_results.append(tool_result_msg) + continue + result = execute_tool(self._graph, tool_call.function.name, args) tool_calls.append({ "name": tool_call.function.name, @@ -324,8 +339,8 @@ class BackendServer: self.update_config(api_key, base_url, model) limits_keys = [ - "persona_query_max", "persona_update_max", - "task_query_max", "task_update_max", + "persona_update_max", + "task_update_max", "memory_query_max", "memory_update_max" ] for key in limits_keys: diff --git a/core/tool_executor.py b/core/tool_executor.py index 28f70d6..684950c 100644 --- a/core/tool_executor.py +++ b/core/tool_executor.py @@ -50,6 +50,10 @@ def execute_tool(graph: Any, tool_name: str, arguments: dict) -> str: result = graph.cleanup(dry_run=arguments.get("dry_run", True)) return json.dumps(result, ensure_ascii=False, default=str) + elif tool_name == "context_rewrite": + result = execute_context_rewrite(graph, arguments) + return json.dumps(result, ensure_ascii=False, default=str) + # 人设图管理工具 elif tool_name == "persona_update": result = execute_persona_update(graph, arguments) @@ -111,6 +115,24 @@ def format_recall_result(result: dict) -> str: return "\n".join(lines) +def execute_context_rewrite(graph: Any, arguments: dict) -> dict: + """压缩工具调用上下文""" + summary = arguments.get("summary", "") + + # 验证格式:必须包含工具调用标记 + if "[工具调用总结" not in summary: + return { + "status": "error", + "message": "总结格式错误:必须包含 [工具调用总结: 本次总结了 N 次工具调用 | 调用工具: ...] 标记" + } + + return { + "status": "success", + "message": "上下文已压缩", + "summary": summary + } + + # 人设图管理工具实现 def execute_persona_update(graph: Any, arguments: dict) -> dict: """更新人设""" diff --git a/core/tool_limiter.py b/core/tool_limiter.py index fad6b34..5bc9707 100644 --- a/core/tool_limiter.py +++ b/core/tool_limiter.py @@ -1,49 +1,35 @@ """ 工具调用限制器 - 限制每轮对话中各类工具的调用次数 """ -from typing import Dict, List, Optional -from dataclasses import dataclass, field +from typing import Optional +from dataclasses import dataclass @dataclass class ToolLimits: """工具调用限制配置""" - # 人设图限制 - persona_query_max: int = 1 # 每轮最多查询1次人设图 - persona_update_max: int = 1 # 每轮最多修改1次人设图 - - # 工作记忆链限制 - task_query_max: int = 4 # 每轮最多查询4次工作记忆链 - task_update_max: int = 5 # 每轮最多修改5次工作记忆链 - - # 一般记忆限制 - memory_query_max: int = 20 # 每轮最多查询20次一般记忆 - memory_update_max: int = 10 # 每轮最多修改10次一般记忆 + persona_update_max: int = 1 + task_update_max: int = 5 + memory_query_max: int = 20 + memory_update_max: int = 10 @dataclass class ToolCallCount: """工具调用计数""" - # 人设图 - persona_query: int = 0 persona_update: int = 0 - - # 工作记忆链 - task_query: int = 0 task_update: int = 0 - - # 一般记忆 memory_query: int = 0 memory_update: int = 0 class ToolLimiter: """工具调用限制器""" - + def __init__(self, limits: Optional[ToolLimits] = None): self.limits = limits or ToolLimits() self.counts = ToolCallCount() - + def _classify_tool(self, tool_name: str, arguments: dict) -> tuple: """ 分类工具调用 @@ -51,105 +37,83 @@ class ToolLimiter: category: 'persona', 'task', 'memory' operation: 'query', 'update' """ - # 人设图工具 if tool_name in ('persona_update', 'persona_clear'): return ('persona', 'update') - - # 工作记忆链工具 + if tool_name in ('task_create', 'task_set_state', 'task_delete', 'task_link_info'): - # task_link_info 是关联操作,算作更新 return ('task', 'update') - - # 一般记忆工具 + if tool_name == 'memory_recall': - # 所有 memory_recall 统一归为一般记忆查询 - # 因为 query_intent 内容不可控,无法准确判断查询类型 - # 写入操作通过工具名称明确区分,不受此影响 return ('memory', 'query') - + if tool_name == 'memory_commit': return ('memory', 'update') - + if tool_name == 'memory_purge': return ('memory', 'update') - + if tool_name == 'memory_introspect': return ('memory', 'query') - + if tool_name in ('memory_archive', 'memory_cleanup'): return ('memory', 'update') - - # 未知工具,归类为一般记忆更新 + + if tool_name == 'context_rewrite': + return ('memory', 'query') + return ('memory', 'update') - + def can_call(self, tool_name: str, arguments: dict) -> tuple: """ 检查是否允许调用工具 返回: (allowed, reason) """ category, operation = self._classify_tool(tool_name, arguments) - - # 获取当前计数和限制 + if category == 'persona': - if operation == 'query': - if self.counts.persona_query >= self.limits.persona_query_max: - return (False, f"人设图查询次数已达上限({self.limits.persona_query_max}次)") - else: # update - if self.counts.persona_update >= self.limits.persona_update_max: - return (False, f"人设图修改次数已达上限({self.limits.persona_update_max}次)") - + if self.counts.persona_update >= self.limits.persona_update_max: + return (False, f"人设图修改次数已达上限({self.limits.persona_update_max}次)") + elif category == 'task': - if operation == 'query': - if self.counts.task_query >= self.limits.task_query_max: - return (False, f"工作记忆链查询次数已达上限({self.limits.task_query_max}次)") - else: # update - if self.counts.task_update >= self.limits.task_update_max: - return (False, f"工作记忆链修改次数已达上限({self.limits.task_update_max}次)") - + if self.counts.task_update >= self.limits.task_update_max: + return (False, f"工作记忆链修改次数已达上限({self.limits.task_update_max}次)") + elif category == 'memory': if operation == 'query': if self.counts.memory_query >= self.limits.memory_query_max: return (False, f"一般记忆查询次数已达上限({self.limits.memory_query_max}次)") - else: # update + else: if self.counts.memory_update >= self.limits.memory_update_max: return (False, f"一般记忆修改次数已达上限({self.limits.memory_update_max}次)") - + return (True, "允许调用") - + def record_call(self, tool_name: str, arguments: dict) -> None: """记录工具调用""" category, operation = self._classify_tool(tool_name, arguments) - + if category == 'persona': - if operation == 'query': - self.counts.persona_query += 1 - else: - self.counts.persona_update += 1 - + self.counts.persona_update += 1 + elif category == 'task': - if operation == 'query': - self.counts.task_query += 1 - else: - self.counts.task_update += 1 - + self.counts.task_update += 1 + elif category == 'memory': if operation == 'query': self.counts.memory_query += 1 else: self.counts.memory_update += 1 - + def get_summary(self) -> str: """获取调用统计摘要""" lines = [ - f"人设图: 查询{self.counts.persona_query}/{self.limits.persona_query_max}次, " - f"修改{self.counts.persona_update}/{self.limits.persona_update_max}次", - f"工作记忆链: 查询{self.counts.task_query}/{self.limits.task_query_max}次, " - f"修改{self.counts.task_update}/{self.limits.task_update_max}次", + f"人设图: 修改{self.counts.persona_update}/{self.limits.persona_update_max}次", + f"工作记忆链: 修改{self.counts.task_update}/{self.limits.task_update_max}次", f"一般记忆: 查询{self.counts.memory_query}/{self.limits.memory_query_max}次, " f"修改{self.counts.memory_update}/{self.limits.memory_update_max}次" ] return "\n".join(lines) - + def reset(self) -> None: """重置计数(新的一轮对话开始时调用)""" self.counts = ToolCallCount() diff --git a/core/tools/memory_tools.py b/core/tools/memory_tools.py index d80be7d..efa0fe0 100644 --- a/core/tools/memory_tools.py +++ b/core/tools/memory_tools.py @@ -234,6 +234,43 @@ MEMORY_TOOLS = [ "required": [] } } + }, + { + "type": "function", + "function": { + "name": "context_rewrite", + "description": """压缩本轮对话的工具调用上下文。将冗长的JSON工具结果提炼为简洁摘要。 + +【使用场景】 +- 已执行多次工具调用,JSON细节已理解,不再需要原始格式 +- 但需保留"我调用了什么工具、得到了什么结论"的元认知 +- 继续携带原始JSON会干扰后续推理 + +【⚠️ 强制格式要求】 +1. 必须标注调用了哪些工具 +2. 必须标注是对几次工具调用的总结 +3. 必须保留关键语义信息 + +【示例】 +{ + "summary": "[工具调用总结: 本次总结了 2 次工具调用 | 调用工具: memory_recall, memory_recall]\\n\\n- 查询人设图:未找到人设,使用默认身份\\n- 查询工作记忆链:发现 Task_成语接龙,状态已暂停,当前成语为虎作伥" +} + +【注意事项】 +- 不可删除用户原始消息 +- 不可歪曲工具返回的关键事实 +- 仅在工具调用 ≥ 2 次后使用""", + "parameters": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "压缩后的摘要文本,必须包含工具调用元信息" + } + }, + "required": ["summary"] + } + } } ] diff --git a/docs/en/api.md b/docs/en/api.md index ce21791..ae15db2 100644 --- a/docs/en/api.md +++ b/docs/en/api.md @@ -472,12 +472,11 @@ asyncio.run(main()) | Category | Operation | Per-Turn Limit | |----------|-----------|---------------| -| Persona graph | Query | 1 time | | Persona graph | Modify | 1 time | -| Working memory chain | Query | 4 times | -| Working memory chain | Modify | 2 times | +| Working memory chain | Modify | 5 times | | General memory | Query | 20 times | | General memory | Modify | 10 times | +| Context compression | Query | Counted as general memory query | ### Reset Mechanism diff --git a/docs/en/architecture.md b/docs/en/architecture.md index b6dd5aa..b1d3546 100644 --- a/docs/en/architecture.md +++ b/docs/en/architecture.md @@ -32,7 +32,7 @@ TrulyMEM-TrueHumanMEM/ │ ├── services/ # Service layer (config only) │ ├── handlers/ # Event handlers │ └── styles/ # Style files -└── tests/ # Tests (42 tests) +└── tests/ # Tests (54 tests) ``` ## Architecture Diagram @@ -146,13 +146,14 @@ def main(): ## Tool System -### Memory Tools (6) +### Memory Tools (7) - `memory_recall` - Retrieve memory - `memory_commit` - Write memory - `memory_purge` - Delete memory - `memory_introspect` - View status - `memory_archive` - Archive memory - `memory_cleanup` - Clean data +- `context_rewrite` - Compress single-turn tool call context (experimental) ### Persona Tools (2) - `persona_update` - Update persona @@ -166,6 +167,19 @@ def main(): --- +## Tool Call Limits + +| Category | Operation | Per-Turn Limit | +|----------|-----------|---------------| +| Persona graph | Modify | 1 time | +| Working memory chain | Modify | 5 times | +| General memory | Query | 20 times | +| General memory | Modify | 10 times | + +> Note: `memory_recall` is uniformly counted as general memory query, no longer distinguished by persona/working memory queries. + +--- + ## Error Handling Principle All APIs **do not throw exceptions**, errors are passed via return dictionary: diff --git a/docs/en/memory.md b/docs/en/memory.md index 8fb16ee..fea6dcf 100644 --- a/docs/en/memory.md +++ b/docs/en/memory.md @@ -25,6 +25,14 @@ All memory must be written to the graph database: All memory must be read from: - `memory_recall` - Retrieve memory +### Working Memory Management (Experimental) + +`context_rewrite` allows AI to proactively compress tool call context within a single turn: +- Distills verbose JSON tool results into concise natural language summaries +- Summary must include which tools were called and how many calls are summarized +- After system validates the format, replaces `messages_history` with `[user message, summary]` +- Ensures LLM retains meta-cognition (knows "I called tools") while reducing JSON noise + --- ## Mandatory Execution Flow (Per Turn) diff --git a/docs/zh/api.md b/docs/zh/api.md index 501746c..79694aa 100644 --- a/docs/zh/api.md +++ b/docs/zh/api.md @@ -478,12 +478,11 @@ asyncio.run(main()) | 类别 | 操作 | 每轮上限 | |------|------|---------| -| 人设图 | 查询 | 1 次 | | 人设图 | 修改 | 1 次 | -| 工作记忆链 | 查询 | 4 次 | -| 工作记忆链 | 修改 | 2 次 | +| 工作记忆链 | 修改 | 5 次 | | 一般记忆 | 查询 | 20 次 | | 一般记忆 | 修改 | 10 次 | +| 上下文压缩 | 查询 | 计入一般记忆查询 | ### 重置机制 diff --git a/docs/zh/architecture.md b/docs/zh/architecture.md index ae199d8..27a8e91 100644 --- a/docs/zh/architecture.md +++ b/docs/zh/architecture.md @@ -32,7 +32,7 @@ TrulyMEM-TrueHumanMEM/ │ ├── services/ # 服务层(仅配置管理) │ ├── handlers/ # 事件处理 │ └── styles/ # 样式文件 -└── tests/ # 测试 (42 tests) +└── tests/ # 测试 (54 tests) ``` ## 架构图 @@ -146,13 +146,14 @@ def main(): ## 工具系统 -### 记忆工具 (6个) +### 记忆工具 (7个) - `memory_recall` - 检索记忆 - `memory_commit` - 写入记忆 - `memory_purge` - 删除记忆 - `memory_introspect` - 查看状态 - `memory_archive` - 归档记忆 - `memory_cleanup` - 清理数据 +- `context_rewrite` - 压缩单轮工具调用上下文(实验性) ### 人设工具 (2个) - `persona_update` - 更新人设 @@ -166,6 +167,19 @@ def main(): --- +## 工具调用限制 + +| 类别 | 操作 | 每轮上限 | +|------|------|---------| +| 人设图 | 修改 | 1 次 | +| 工作记忆链 | 修改 | 5 次 | +| 一般记忆 | 查询 | 20 次 | +| 一般记忆 | 修改 | 10 次 | + +> 注:`memory_recall` 统一计入一般记忆查询,不再区分人设/工作记忆查询。 + +--- + ## 错误处理原则 所有 API **不抛出异常**,错误通过返回字典传递: diff --git a/docs/zh/memory.md b/docs/zh/memory.md index 00343ca..6db77c4 100644 --- a/docs/zh/memory.md +++ b/docs/zh/memory.md @@ -25,6 +25,14 @@ TrulyMEM 的解决思路: 所有记忆必须通过以下方式读取: - `memory_recall` - 检索记忆 +### 工作记忆管理(实验性) + +`context_rewrite` 允许 AI 在单轮对话内主动压缩工具调用的临时上下文: +- 将冗长的 JSON 工具结果提炼为简洁的自然语言摘要 +- 摘要必须包含调用了哪些工具、对几次调用的总结 +- 系统验证格式后,替换 `messages_history` 为 `[用户消息, 摘要]` +- 确保 LLM 保留元认知(知道"我调用过工具"),同时减少 JSON 噪音 + --- ## 强制执行流程(每轮对话) diff --git a/tests/test_core/test_context_rewrite.py b/tests/test_core/test_context_rewrite.py new file mode 100644 index 0000000..68a8be8 --- /dev/null +++ b/tests/test_core/test_context_rewrite.py @@ -0,0 +1,218 @@ +"""context_rewrite 全面测试 - 单元 + 集成""" + +import pytest +import json +import tempfile +import os +from core.tools.memory_tools import TOOLS, MEMORY_TOOLS +from core.tool_executor import execute_tool, execute_context_rewrite +from core.tool_limiter import ToolLimiter, ToolLimits +from core import EmbeddedGraphDB + + +# ========== 工具定义测试 ========== + +class TestToolDefinition: + def test_context_rewrite_in_tools(self): + tool_names = [t["function"]["name"] for t in TOOLS] + assert "context_rewrite" in tool_names + + def test_context_rewrite_in_memory_tools(self): + tool_names = [t["function"]["name"] for t in MEMORY_TOOLS] + assert "context_rewrite" in tool_names + + def test_context_rewrite_has_required_params(self): + tool_def = None + for t in MEMORY_TOOLS: + if t["function"]["name"] == "context_rewrite": + tool_def = t + break + assert tool_def is not None + assert "summary" in tool_def["function"]["parameters"]["required"] + + def test_context_rewrite_description_not_empty(self): + for t in MEMORY_TOOLS: + if t["function"]["name"] == "context_rewrite": + assert len(t["function"]["description"]) > 100 + break + + +# ========== 执行器测试 ========== + +class TestContextRewriteExecutor: + @pytest.fixture + def db(self): + with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f: + db_path = f.name + db = EmbeddedGraphDB(db_path) + yield db + db.close() + os.unlink(db_path) + + def test_valid_summary(self, db): + args = {"summary": "[工具调用总结: 本次总结了 2 次工具调用 | 调用工具: memory_recall, memory_recall]\n\n- 查询人设图:未找到"} + result = execute_context_rewrite(db, args) + assert result["status"] == "success" + assert result["message"] == "上下文已压缩" + assert "memory_recall" in result["summary"] + + def test_missing_marker(self, db): + args = {"summary": "查询人设图:未找到"} + result = execute_context_rewrite(db, args) + assert result["status"] == "error" + assert "必须包含" in result["message"] + + def test_empty_summary(self, db): + args = {"summary": ""} + result = execute_context_rewrite(db, args) + assert result["status"] == "error" + + def test_marker_only(self, db): + args = {"summary": "[工具调用总结"} + result = execute_context_rewrite(db, args) + assert result["status"] == "success" + + def test_via_execute_tool(self, db): + args = {"summary": "[工具调用总结: 本次总结了 1 次工具调用 | 调用工具: memory_recall]\n\n- 查询记忆:找到 3 个实体"} + result_str = execute_tool(db, "context_rewrite", args) + result = json.loads(result_str) + assert result["status"] == "success" + + def test_unicode_content(self, db): + args = {"summary": "[工具调用总结: 本次总结了 3 次工具调用 | 调用工具: memory_recall, memory_commit, task_create]\n\n- 查询:找到实体\"用户\"\n- 写入:{\"subject\": \"用户\", \"relation\": \"喜欢\"}\n- 任务:Task_测试"} + result = execute_context_rewrite(db, args) + assert result["status"] == "success" + assert "用户" in result["summary"] + + def test_newlines_preserved(self, db): + summary = "[工具调用总结: 本次总结了 2 次工具调用 | 调用工具: memory_recall, memory_recall]\n\n- 查询1:结果1\n- 查询2:结果2" + args = {"summary": summary} + result = execute_context_rewrite(db, args) + assert result["summary"] == summary + + def test_long_summary(self, db): + summary = "[工具调用总结: 本次总结了 5 次工具调用 | 调用工具: memory_recall, memory_recall, memory_commit, task_create, task_set_state]\n\n" + "详细结果\n" * 50 + args = {"summary": summary} + result = execute_context_rewrite(db, args) + assert result["status"] == "success" + assert len(result["summary"]) == len(summary) + + def test_special_json_chars(self, db): + args = {"summary": '[工具调用总结: 本次总结了 1 次工具调用 | 调用工具: memory_commit]\n\n- 写入:{"subject": "测试", "relation": "包含\"引号"}'} + result = execute_context_rewrite(db, args) + assert result["status"] == "success" + + def test_missing_summary_key(self, db): + args = {} + result = execute_context_rewrite(db, args) + assert result["status"] == "error" + + +# ========== 工具限流器测试 ========== + +class TestContextRewriteLimiter: + def test_classified_as_memory_query(self): + limiter = ToolLimiter(ToolLimits(memory_query_max=1)) + category, operation = limiter._classify_tool("context_rewrite", {}) + assert category == "memory" + assert operation == "query" + + def test_counts_toward_memory_query_limit(self): + limiter = ToolLimiter(ToolLimits(memory_query_max=1)) + allowed, _ = limiter.can_call("context_rewrite", {}) + assert allowed + limiter.record_call("context_rewrite", {}) + allowed, reason = limiter.can_call("context_rewrite", {}) + assert not allowed + assert "一般记忆查询次数已达上限" in reason + + def test_does_not_affect_memory_update(self): + limiter = ToolLimiter(ToolLimits(memory_update_max=1)) + limiter.record_call("context_rewrite", {}) + allowed, _ = limiter.can_call("memory_commit", {}) + assert allowed + + def test_reset_clears_count(self): + limiter = ToolLimiter(ToolLimits(memory_query_max=1)) + limiter.record_call("context_rewrite", {}) + limiter.reset() + allowed, _ = limiter.can_call("context_rewrite", {}) + assert allowed + + +# ========== 集成测试:messages_history 压缩流程 ========== + +class TestMessagesHistoryCompression: + def test_compression_preserves_user_message(self): + messages_history = [ + {"role": "user", "content": "我们之前聊过成语接龙吗?"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "tc1", "type": "function", "function": {"name": "memory_recall", "arguments": '{"query_intent": "人设"}'}}]}, + {"role": "tool", "tool_call_id": "tc1", "content": "===== 记忆检索结果 =====\n\n(未找到相关记忆)\n=============================="}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "tc2", "type": "function", "function": {"name": "memory_recall", "arguments": '{"query_intent": "工作记忆"}'}}]}, + {"role": "tool", "tool_call_id": "tc2", "content": "===== 记忆检索结果 =====\n\n实体 (2 个):\n - Task_成语接龙 (类型: unknown, 提及: 1次)\n=============================="}, + ] + + summary = "[工具调用总结: 本次总结了 2 次工具调用 | 调用工具: memory_recall, memory_recall]\n\n- 查询人设图:未找到人设\n- 查询工作记忆链:发现 Task_成语接龙,状态已暂停" + + user_msg = messages_history[0] + messages_history[:] = [ + user_msg, + {"role": "assistant", "content": summary} + ] + + assert len(messages_history) == 2 + assert messages_history[0]["role"] == "user" + assert messages_history[0]["content"] == "我们之前聊过成语接龙吗?" + assert messages_history[1]["role"] == "assistant" + assert "memory_recall" in messages_history[1]["content"] + assert "成语接龙" in messages_history[1]["content"] + + def test_compression_removes_json_noise(self): + messages_history = [ + {"role": "user", "content": "查询用户信息"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "tc1", "type": "function", "function": {"name": "memory_recall", "arguments": '{"query_intent": "用户"}'}}]}, + {"role": "tool", "tool_call_id": "tc1", "content": json.dumps({"entities": [{"name": "用户", "type": "person", "mention_count": 5}], "relations": [{"source": "用户", "target": "Python", "type": "喜欢"}]})}, + ] + + summary = "[工具调用总结: 本次总结了 1 次工具调用 | 调用工具: memory_recall]\n\n- 查询用户:找到用户实体,提及5次,喜欢Python" + + user_msg = messages_history[0] + messages_history[:] = [user_msg, {"role": "assistant", "content": summary}] + + for msg in messages_history[1:]: + assert "entities" not in msg.get("content", "") + assert "relations" not in msg.get("content", "") + + def test_compression_retains_tool_meta_cognition(self): + messages_history = [ + {"role": "user", "content": "查询"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "tc1", "type": "function", "function": {"name": "memory_recall", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "tc1", "content": "结果"}, + ] + + summary = "[工具调用总结: 本次总结了 1 次工具调用 | 调用工具: memory_recall]\n\n- 查询记忆:无结果" + + user_msg = messages_history[0] + messages_history[:] = [user_msg, {"role": "assistant", "content": summary}] + + content = messages_history[1]["content"] + assert "工具调用总结" in content + assert "memory_recall" in content + assert "1 次" in content + + def test_multiple_compressions_in_sequence(self): + messages_history = [ + {"role": "user", "content": "多轮查询"}, + ] + + for i in range(3): + messages_history.append({"role": "assistant", "content": None, "tool_calls": [{"id": f"tc{i}", "type": "function", "function": {"name": "memory_recall", "arguments": "{}"}}]}) + messages_history.append({"role": "tool", "tool_call_id": f"tc{i}", "content": f"结果{i}"}) + + summary = f"[工具调用总结: 本次总结了 {i+1} 次工具调用 | 调用工具: memory_recall]\n\n- 第{i+1}轮查询:结果{i}" + user_msg = messages_history[0] + messages_history[:] = [user_msg, {"role": "assistant", "content": summary}] + + assert len(messages_history) == 2 + assert messages_history[0]["content"] == "多轮查询" + assert "第3轮查询" in messages_history[1]["content"] diff --git a/tests/test_core/test_packet.py b/tests/test_core/test_packet.py index 7be1ecc..5447d89 100644 --- a/tests/test_core/test_packet.py +++ b/tests/test_core/test_packet.py @@ -192,7 +192,7 @@ class TestBackendClientAPI: result = client.update_settings( api_config={"api_key": "new-key", "base_url": "https://api.deepseek.com", "model": "deepseek-chat"}, - tool_limits={"persona_query_max": 2} + tool_limits={"persona_update_max": 2} ) assert result.get("success") is True @@ -282,8 +282,10 @@ class TestToolLimiter: def test_tool_limiter_init(self): from core.tool_limiter import ToolLimiter limiter = ToolLimiter() - assert limiter.counts.persona_query == 0 assert limiter.counts.persona_update == 0 + assert limiter.counts.task_update == 0 + assert limiter.counts.memory_query == 0 + assert limiter.counts.memory_update == 0 def test_tool_limiter_classify(self): from core.tool_limiter import ToolLimiter diff --git a/tests/test_ui/__init__.py b/tests/test_ui/__init__.py index 22fd06b..813a7ae 100644 --- a/tests/test_ui/__init__.py +++ b/tests/test_ui/__init__.py @@ -185,7 +185,7 @@ class TestUIWithBackendClient: result = client.update_settings( api_config={"api_key": "sk-test", "base_url": "https://api.deepseek.com", "model": "deepseek-chat"}, - tool_limits={"persona_query_max": 1} + tool_limits={"persona_update_max": 1} ) assert result.get("success") is True diff --git a/ui/app.py b/ui/app.py index 9a25440..4b7e858 100644 --- a/ui/app.py +++ b/ui/app.py @@ -46,10 +46,8 @@ class GraphMemoryApp(App): initial_config.model = api_config.get("model", "deepseek-chat") tool_limits = settings_data.get("tool_limits", {}) - initial_config.persona_query_max = tool_limits.get("persona_query_max", 1) initial_config.persona_update_max = tool_limits.get("persona_update_max", 1) - initial_config.task_query_max = tool_limits.get("task_query_max", 4) - initial_config.task_update_max = tool_limits.get("task_update_max", 2) + initial_config.task_update_max = tool_limits.get("task_update_max", 5) initial_config.memory_query_max = tool_limits.get("memory_query_max", 20) initial_config.memory_update_max = tool_limits.get("memory_update_max", 10) @@ -222,9 +220,7 @@ class GraphMemoryApp(App): } tool_limits = { - "persona_query_max": config.persona_query_max, "persona_update_max": config.persona_update_max, - "task_query_max": config.task_query_max, "task_update_max": config.task_update_max, "memory_query_max": config.memory_query_max, "memory_update_max": config.memory_update_max, @@ -257,10 +253,8 @@ class GraphMemoryApp(App): api_key=api_cfg.get("api_key", ""), base_url=api_cfg.get("base_url", "https://api.deepseek.com"), model=api_cfg.get("model", "deepseek-chat"), - persona_query_max=tool_lmts.get("persona_query_max", 1), persona_update_max=tool_lmts.get("persona_update_max", 1), - task_query_max=tool_lmts.get("task_query_max", 4), - task_update_max=tool_lmts.get("task_update_max", 2), + task_update_max=tool_lmts.get("task_update_max", 5), memory_query_max=tool_lmts.get("memory_query_max", 20), memory_update_max=tool_lmts.get("memory_update_max", 10), )) diff --git a/ui/models/config.py b/ui/models/config.py index f67c6b5..6acb5d8 100644 --- a/ui/models/config.py +++ b/ui/models/config.py @@ -13,10 +13,8 @@ class AppConfig: api_key: str = "" model: str = "deepseek-chat" base_url: str = "https://api.deepseek.com" - persona_query_max: int = 1 persona_update_max: int = 1 - task_query_max: int = 4 - task_update_max: int = 2 + task_update_max: int = 5 memory_query_max: int = 20 memory_update_max: int = 10 @@ -26,10 +24,8 @@ class AppConfig: api_key=os.getenv("DEEPSEEK_API_KEY", ""), model=os.getenv("MODEL_NAME", "deepseek-chat"), base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"), - persona_query_max=int(os.getenv("PERSONA_QUERY_MAX", 1)), persona_update_max=int(os.getenv("PERSONA_UPDATE_MAX", 1)), - task_query_max=int(os.getenv("TASK_QUERY_MAX", 4)), - task_update_max=int(os.getenv("TASK_UPDATE_MAX", 2)), + task_update_max=int(os.getenv("TASK_UPDATE_MAX", 5)), memory_query_max=int(os.getenv("MEMORY_QUERY_MAX", 20)), memory_update_max=int(os.getenv("MEMORY_UPDATE_MAX", 10)), ) @@ -46,10 +42,8 @@ class AppConfig: api_key=data.get("api_key", ""), model=data.get("model", "deepseek-chat"), base_url=data.get("base_url", "https://api.deepseek.com"), - persona_query_max=data.get("persona_query_max", 1), persona_update_max=data.get("persona_update_max", 1), - task_query_max=data.get("task_query_max", 4), - task_update_max=data.get("task_update_max", 2), + task_update_max=data.get("task_update_max", 5), memory_query_max=data.get("memory_query_max", 20), memory_update_max=data.get("memory_update_max", 10), ) diff --git a/ui/widgets/config_section.py b/ui/widgets/config_section.py index 7a20f8e..cd7ef6e 100644 --- a/ui/widgets/config_section.py +++ b/ui/widgets/config_section.py @@ -63,25 +63,15 @@ class ConfigSection(Vertical): limits_title.can_focus = False yield limits_title - l1 = Static("人设图查询:", classes="config-label") - l1.can_focus = False - yield l1 - yield Input(value=str(self._config.persona_query_max), placeholder="1", id="persona-query-max") - l2 = Static("人设图修改:", classes="config-label") l2.can_focus = False yield l2 yield Input(value=str(self._config.persona_update_max), placeholder="1", id="persona-update-max") - l3 = Static("工作记忆查询:", classes="config-label") - l3.can_focus = False - yield l3 - yield Input(value=str(self._config.task_query_max), placeholder="4", id="task-query-max") - l4 = Static("工作记忆修改:", classes="config-label") l4.can_focus = False yield l4 - yield Input(value=str(self._config.task_update_max), placeholder="2", id="task-update-max") + yield Input(value=str(self._config.task_update_max), placeholder="5", id="task-update-max") l5 = Static("一般记忆查询:", classes="config-label") l5.can_focus = False @@ -122,9 +112,7 @@ class ConfigSection(Vertical): model_input = self.query_one("#model-input", Input) base_url_input = self.query_one("#base-url-input", Input) - persona_query = self.query_one("#persona-query-max", Input) persona_update = self.query_one("#persona-update-max", Input) - task_query = self.query_one("#task-query-max", Input) task_update = self.query_one("#task-update-max", Input) memory_query = self.query_one("#memory-query-max", Input) memory_update = self.query_one("#memory-update-max", Input) @@ -133,10 +121,8 @@ class ConfigSection(Vertical): api_key=api_key_input.value, model=model_input.value, base_url=base_url_input.value, - persona_query_max=int(persona_query.value or 1), persona_update_max=int(persona_update.value or 1), - task_query_max=int(task_query.value or 4), - task_update_max=int(task_update.value or 2), + task_update_max=int(task_update.value or 5), memory_query_max=int(memory_query.value or 20), memory_update_max=int(memory_update.value or 10), ) @@ -162,9 +148,7 @@ class ConfigSection(Vertical): model_input.value = config.model base_url_input.value = config.base_url - self.query_one("#persona-query-max", Input).value = str(config.persona_query_max) self.query_one("#persona-update-max", Input).value = str(config.persona_update_max) - self.query_one("#task-query-max", Input).value = str(config.task_query_max) self.query_one("#task-update-max", Input).value = str(config.task_update_max) self.query_one("#memory-query-max", Input).value = str(config.memory_query_max) self.query_one("#memory-update-max", Input).value = str(config.memory_update_max)