diff --git a/app/agent/learning/memory.py b/app/agent/learning/memory.py index 23c5ddf2a..04c1cccfc 100644 --- a/app/agent/learning/memory.py +++ b/app/agent/learning/memory.py @@ -19,7 +19,8 @@ def parse_entries(raw: str) -> list[str]: return [part.strip() for part in raw.split(ENTRY_DELIMITER) if part.strip()] -def locate(entries: list[str], old_text: str, pinned: str | None = None) -> int: +def locate(entries: list[str], old_text: str, pinned: str | None = None, + empty_message: str | None = None) -> int: """整条精确匹配优先;不同内容的多处子串命中拒绝猜测,审批匹配原始整条。""" if pinned is not None: if pinned not in entries: @@ -29,13 +30,16 @@ def locate(entries: list[str], old_text: str, pinned: str | None = None) -> int: raise ValueError('replace/remove 需要 old_text,且 replace 会替换整条记忆') indexes = [i for i, entry in enumerate(entries) if entry == old_text] indexes = indexes or [i for i, entry in enumerate(entries) if old_text in entry] + if not entries and empty_message: + raise ValueError(empty_message) if not indexes or len({entries[i] for i in indexes}) > 1: raise ValueError('old_text 未唯一匹配,请重新读取记忆并使用准确片段') return indexes[0] def apply_operations(entries: list[str], operations: list[MemoryOperation], - pinned: list[str | None] | None = None) -> tuple[list[str], list[str | None]]: + pinned: list[str | None] | None = None, + empty_message: str | None = None) -> tuple[list[str], list[str | None]]: """在副本上顺序验证所有操作,返回新列表与每次删除或替换的完整旧条目。""" working = list(entries) previous: list[str | None] = [] @@ -51,7 +55,8 @@ def apply_operations(entries: list[str], operations: list[MemoryOperation], working.append(content) previous.append(None) else: - index = locate(working, (operation.old_text or '').strip(), pinned[i] if pinned else None) + index = locate(working, (operation.old_text or '').strip(), pinned[i] if pinned else None, + empty_message) previous.append(working[index]) working[index:index + 1] = [content] if operation.action == 'replace' else [] return working, previous @@ -113,7 +118,12 @@ class MemoryStore: backup = self._path(target).with_suffix(f'.md.bak.{uuid4().hex}') write_text(backup, raw) raise ValueError(f'磁盘内容无法按记忆条目无损往返;原文备份到 {backup.name},请先人工整理') - working, previous = apply_operations(entries, operations, pinned) + file_name = 'USER.md' if target == 'user' else 'MEMORY.md' + empty_message = ( + f"目标记忆为空或文件不存在:target='{target}' 对应当前用户目录的 {file_name};" + "跨任务偏好请使用 target='user',环境事实请使用 target='memory'。" + ) + working, previous = apply_operations(entries, operations, pinned, empty_message) if batch and entries and not working: raise ValueError('批次不能清空全部记忆;需要明确清除时使用单次 remove') if len(ENTRY_DELIMITER.join(working)) > LIMITS[target]: diff --git a/app/agent/learning/tools.py b/app/agent/learning/tools.py index 4c8ad18d6..bf6075aba 100644 --- a/app/agent/learning/tools.py +++ b/app/agent/learning/tools.py @@ -34,7 +34,7 @@ class LearningTools: ('skill_manage', self.skill_manage, ManageSkillsInput, ToolTag.Write, 'Maintain current-user skills with up to 20 atomic operations. create content=full SKILL.md; patch old_string/new_string or content; write_file file_path/file_content; remove_file; delete alone. Public skills are protected.'), ('memory', self.manage_memory, MemoryInput, ToolTag.Write, - 'Curated memory: target=user for cross-task preferences, memory for environment facts. add/replace/remove or atomic operations. old_text locates an entry; replace content replaces the WHOLE entry. Limits: memory 2200, user 1375 chars. action=pending reads proposals. Background destructive writes require explicit /memory approve ID.'), + "Curated memory: target='user' writes the current user's USER.md for cross-task preferences; target='memory' writes MEMORY.md for environment facts. add/replace/remove or atomic operations. old_text locates an entry; replace content replaces the WHOLE entry. If the target is empty or missing, use add or verify the target mapping. Limits: memory 2200, user 1375 chars. action=pending reads proposals. Background destructive writes require explicit /memory approve ID."), ) self.tools = [] for name, function, schema, tag, description in definitions: diff --git a/tests/test_agent_learning_memory.py b/tests/test_agent_learning_memory.py index 8c9ccf7e3..370ee4d31 100644 --- a/tests/test_agent_learning_memory.py +++ b/tests/test_agent_learning_memory.py @@ -45,6 +45,31 @@ def test_batch_final_budget_atomicity_empty_guard_and_failure_cap(tmp_path): assert (tmp_path / 'MEMORY.md').read_text() == '' +def test_empty_target_explains_file_mapping_and_correction(tmp_path): + """空目标应说明文件映射和应使用的 target,避免反复修改 old_text。""" + store = MemoryStore(tmp_path) + + result = apply(store, target='user', action='replace', old_text='任意片段', content='新偏好') + + assert not result['success'] + assert '目标记忆为空或文件不存在' in result['error'] + assert "target='user'" in result['error'] + assert 'USER.md' in result['error'] + assert "target='memory'" in result['error'] + assert result['current_entries'] == [] + + +def test_non_empty_target_keeps_unique_match_error(tmp_path): + """目标已有条目但 old_text 不匹配时,仍保留原有纠错提示。""" + store = MemoryStore(tmp_path) + apply(store, action='add', content='已有环境事实') + + result = apply(store, action='replace', old_text='不存在的片段', content='新事实') + + assert not result['success'] + assert result['error'] == 'old_text 未唯一匹配,请重新读取记忆并使用准确片段' + + def test_background_proposal_pins_entry_and_requires_host_resolution(tmp_path): """后台整批存提案,不产生一半新增一半删除;过期审批绝不匹配新内容。""" foreground = MemoryStore(tmp_path)