#!/usr/bin/env python3 """Phase 2 工具层与记忆策略测试。 **完全不依赖 LLM、网络或真实 SQLite**——用 fake store。 这是交接文档 Phase 2 的完成标准之一("策略测试不依赖真实 LLM")。 覆盖交接文档 §十 的单元测试清单: - 工具参数 schema 与非法参数 - 模型不能传 owner_user_id/scope 越权 - 未确认身份拒绝 private memory - 明确"记住"写 confirmed - 推断信息只写 candidate - 同 key 更新幂等 - 模糊忘记返回 needs_confirmation - 精确忘记只删除当前 owner 用法: ~/venvs/ai_assistant/bin/python app/tests/test_agent_tools.py """ from __future__ import annotations import asyncio import sys import time from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent sys.path.insert(0, str(ROOT)) from app.agent.memory_policy import ( # noqa: E402 MemoryDecision, decide_forget, decide_remember, find_forbidden_args, has_explicit_remember_intent, looks_episodic, ) from app.agent.tools import ( # noqa: E402 ToolContext, available_schemas, execute, ) from app.memory.base import ( # noqa: E402 ConfirmStatus, Memory, MemoryKind, MemoryStore, Scope, ) OK, FAIL = [], [] def check(name: str, cond: bool, detail: str = "") -> None: (OK if cond else FAIL).append(name) print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) # ---------------------------------------------------------------- fake store class FakeStore(MemoryStore): """内存实现。只实现测试用得到的方法,其余 raise。""" def __init__(self): self.rows: list[Memory] = [] self._next = 1 self.calls: list[tuple[str, tuple]] = [] async def open(self): pass async def close(self): pass async def ensure_user(self, user_id, display_name="", trust_level="normal"): pass async def set_user_display_name(self, user_id, display_name): pass async def get_user_display_name(self, user_id): return "" async def start_session(self, session_id, user_id): pass async def end_session(self, session_id): pass async def add_turn(self, turn): pass async def purge_user(self, owner_user_id): return 0 async def remember(self, mem: Memory) -> int: self.calls.append(("remember", (mem.owner_user_id, mem.key, mem.value, mem.confirm_status.value))) # 复刻存储层的幂等语义:同 owner+kind+key 的 confirmed 覆盖旧值 if mem.confirm_status is ConfirmStatus.CONFIRMED and mem.key: for r in self.rows: if (r.owner_user_id == mem.owner_user_id and r.kind == mem.kind and r.key == mem.key and r.confirm_status is ConfirmStatus.CONFIRMED): r.value = mem.value r.updated_at = time.time() return r.memory_id or 0 m = Memory(**{**mem.__dict__, "memory_id": self._next}) self._next += 1 self.rows.append(m) return m.memory_id async def forget(self, owner_user_id: str, memory_id: int) -> bool: self.calls.append(("forget", (owner_user_id, memory_id))) before = len(self.rows) # 必须同时匹配 owner——只凭 id 删是越权 self.rows = [r for r in self.rows if not (r.memory_id == memory_id and r.owner_user_id == owner_user_id)] return len(self.rows) < before async def retrieve(self, owner_user_id, query, *, limit=5, include_candidates=False, scopes=None): self.calls.append(("retrieve", (owner_user_id,))) out = [] for r in self.rows: if r.owner_user_id != owner_user_id: continue if not include_candidates and r.confirm_status is not ConfirmStatus.CONFIRMED: continue if query and self.relevance(query, r.text) <= 0: continue out.append(r) return out[:limit] async def list_memories(self, owner_user_id, *, include_candidates=True): return [r for r in self.rows if r.owner_user_id == owner_user_id] def ctx(store=None, *, confirmed=True, user_text="", uid="u_a"): return ToolContext(owner_user_id=uid, session_id="s1", turn_id="t1", identity_confirmed=confirmed, user_text=user_text, store=store) # ---------------------------------------------------------------- 策略:写入 def test_policy_remember(): print("\n=== 1. 写入裁决(策略层,无 LLM)===") # 明确要求 → confirmed r = decide_remember(user_text="记住我不吃香菜", identity_confirmed=True, args={"kind": "preference", "key": "饮食偏好", "value": "不吃香菜"}) check("明确“记住” → confirmed", r.decision is MemoryDecision.ALLOW_CONFIRMED, r.reason) # 模型推断 → candidate r = decide_remember(user_text="我今天点了份牛肉面", identity_confirmed=True, args={"kind": "preference", "key": "饮食偏好", "value": "喜欢牛肉面"}) check("无明确意图 → candidate", r.decision is MemoryDecision.ALLOW_CANDIDATE, r.reason) # 模型自称"用户明确要求"不采信 r = decide_remember(user_text="我今天点了份牛肉面", identity_confirmed=True, args={"kind": "fact", "value": "喜欢牛肉面"}, model_claims_explicit=True) check("不采信模型自称的显式意图", r.decision is MemoryDecision.ALLOW_CANDIDATE, r.reason) # 身份未确认 → reject r = decide_remember(user_text="记住我不吃香菜", identity_confirmed=False, args={"kind": "preference", "value": "不吃香菜"}) check("身份未确认 → reject", r.decision is MemoryDecision.REJECT, r.reason) # 越权字段 → reject r = decide_remember(user_text="记住我不吃香菜", identity_confirmed=True, args={"kind": "preference", "value": "x", "owner_user_id": "u_b"}) check("模型传 owner_user_id → reject", r.decision is MemoryDecision.REJECT, r.reason) r = decide_remember(user_text="记住我不吃香菜", identity_confirmed=True, args={"kind": "preference", "value": "x", "scope": "shared"}) check("模型传 scope → reject", r.decision is MemoryDecision.REJECT, r.reason) # 空 value r = decide_remember(user_text="记住", identity_confirmed=True, args={"kind": "fact", "value": " "}) check("空 value → reject", r.decision is MemoryDecision.REJECT, r.reason) def test_episodic(): print("\n=== 2. 事件 vs 稳定偏好(交接文档的关键验收)===") check("“记住我不吃香菜”是明确意图", has_explicit_remember_intent("记住我不吃香菜")) check("“记住我今天吃了香菜”不算稳定意图(时间词在前)", not has_explicit_remember_intent("记住我今天吃了香菜")) check("“我今天吃了香菜”判为事件", looks_episodic("我今天吃了香菜")) check("“我不吃香菜”不是事件", not looks_episodic("我不吃香菜")) # 交接文档 Phase 4 验收第 3 条:不能把一次性事件变成稳定偏好 r = decide_remember(user_text="记住我今天吃了香菜", identity_confirmed=True, args={"kind": "preference", "key": "饮食偏好", "value": "吃香菜"}) check("一次性事件降级为 candidate", r.decision is MemoryDecision.ALLOW_CANDIDATE, r.reason) def test_forbidden_args(): print("\n=== 3. 越权字段识别 ===") check("识别 owner_user_id", find_forbidden_args({"owner_user_id": "x"}) == ["owner_user_id"]) check("识别 scope", find_forbidden_args({"scope": "shared"}) == ["scope"]) check("大小写不敏感", find_forbidden_args({"Owner_User_ID": "x"}) != []) check("正常参数不误报", find_forbidden_args({"query": "x", "value": "y"}) == []) # ---------------------------------------------------------------- 策略:删除 def test_policy_forget(): print("\n=== 4. 删除裁决 ===") m1 = Memory(memory_id=1, owner_user_id="u_a", kind=MemoryKind.PREFERENCE, key="饮食偏好", value="不吃香菜", confirm_status=ConfirmStatus.CONFIRMED) m2 = Memory(memory_id=2, owner_user_id="u_a", kind=MemoryKind.PREFERENCE, key="饮品偏好", value="喜欢美式", confirm_status=ConfirmStatus.CONFIRMED) r = decide_forget(user_text="忘掉我的饮食偏好", matches=[m1], identity_confirmed=True) check("精确匹配一条 → 可执行", r.decision is MemoryDecision.ALLOW_CONFIRMED, r.reason) r = decide_forget(user_text="忘掉我的偏好", matches=[m1, m2], identity_confirmed=True) check("多条 → needs_confirmation", r.decision is MemoryDecision.NEEDS_CONFIRMATION, r.reason) check("needs_confirmation 带候选", len(r.candidates) == 2) r = decide_forget(user_text="忘掉我所有的记忆", matches=[m1], identity_confirmed=True) check("模糊措辞 → needs_confirmation", r.decision is MemoryDecision.NEEDS_CONFIRMATION, r.reason) r = decide_forget(user_text="忘掉我的饮食偏好", matches=[], identity_confirmed=True) check("无匹配 → reject", r.decision is MemoryDecision.REJECT, r.reason) r = decide_forget(user_text="忘掉我的饮食偏好", matches=[m1], identity_confirmed=False) check("身份未确认 → reject", r.decision is MemoryDecision.REJECT, r.reason) # ---------------------------------------------------------------- 工具执行 async def test_tools(): print("\n=== 5. 工具执行(fake store)===") store = FakeStore() # 可用工具随身份变化 s_off = available_schemas(ctx(store, confirmed=False)) names_off = {t["function"]["name"] for t in s_off} check("身份未确认时不暴露记忆工具", "memory_search" not in names_off, str(names_off)) s_on = available_schemas(ctx(store, confirmed=True)) names_on = {t["function"]["name"] for t in s_on} check("身份确认后暴露记忆工具", {"memory_search", "memory_remember", "memory_forget", "memory_update"} <= names_on, str(names_on)) # 未知工具 out = await execute("no_such_tool", {}, ctx(store)) check("未知工具返回可读文本", not out.ok and "没有名为" in out.content) # 越权参数在入口就被拦 out = await execute("memory_remember", {"kind": "fact", "value": "x", "owner_user_id": "u_b"}, ctx(store)) check("入口拦越权参数", not out.ok and "不合法" in out.content) # 身份未确认时记忆工具不可用 out = await execute("memory_search", {"query": "香菜"}, ctx(store, confirmed=False)) check("未确认身份拒绝 memory_search", not out.ok and "身份未确认" in out.content) # 明确记住 → confirmed,且 owner 来自 ctx 而非模型 out = await execute("memory_remember", {"kind": "preference", "key": "饮食偏好", "value": "不吃香菜"}, ctx(store, user_text="记住我不吃香菜", uid="u_a")) check("明确记住写入成功", out.ok and out.meta.get("status") == "confirmed", out.content) check("owner 用 ctx 的 u_a", any(c[1][0] == "u_a" for c in store.calls if c[0] == "remember")) # 推断 → candidate,且如实告知模型未生效 out = await execute("memory_remember", {"kind": "fact", "value": "喜欢牛肉面"}, ctx(store, user_text="我今天吃了牛肉面")) check("推断写入为 candidate", out.meta.get("status") == "candidate", out.content) check("如实告知未生效", "尚未生效" in out.content, out.content) # 同 key 幂等更新:不应产生第二条 confirmed await execute("memory_remember", {"kind": "preference", "key": "饮食偏好", "value": "现在吃香菜了"}, ctx(store, user_text="记住我饮食偏好改了")) confirmed = [r for r in store.rows if r.owner_user_id == "u_a" and r.key == "饮食偏好" and r.confirm_status is ConfirmStatus.CONFIRMED] check("同 key 只保留一条 confirmed", len(confirmed) == 1, f"{len(confirmed)} 条") check("值已更新", confirmed[0].value == "现在吃香菜了", confirmed[0].value) # 检索:candidate 默认不返回 out = await execute("memory_search", {"query": "牛肉面"}, ctx(store)) check("candidate 不出现在检索结果", "牛肉面" not in out.content, out.content) # 更新已有键 out = await execute("memory_update", {"key": "饮食偏好", "value": "其实我现在吃香菜了"}, ctx(store, user_text="其实我现在吃香菜了")) check("更正成功", out.ok and out.meta.get("updated"), out.content) check("更正后值变了", any(r.key == "饮食偏好" and r.value == "其实我现在吃香菜了" for r in store.rows)) # 更正不存在的键 out = await execute("memory_update", {"key": "不存在的键", "value": "x"}, ctx(store)) check("更正不存在的键 → 拒绝", not out.ok, out.content) # 精确删除 out = await execute("memory_forget", {"query": "饮食偏好"}, ctx(store, user_text="忘掉我的饮食偏好")) check("精确删除执行", out.ok and out.meta.get("deleted"), out.content) check("删除话术如实(已删除)", "已删除" in out.content, out.content) # 删除不存在 out = await execute("memory_forget", {"query": "完全不存在的东西"}, ctx(store, user_text="忘掉完全不存在的东西")) check("删除不存在的记忆 → 不谎报", not out.ok and "没有找到" in out.content, out.content) # 模糊删除 → 需要确认 store2 = FakeStore() for k, v in (("饮食偏好", "不吃香菜"), ("饮品偏好", "喜欢美式")): await execute("memory_remember", {"kind": "preference", "key": k, "value": v}, ctx(store2, user_text=f"记住我的{k}是{v}")) out = await execute("memory_forget", {"query": "偏好"}, ctx(store2, user_text="忘掉我的偏好")) check("模糊删除 → 不猜目标", out.meta.get("needs_confirmation") is True, out.content) check("模糊删除不实际删除", len(store2.rows) == 2, f"{len(store2.rows)} 条") async def test_owner_isolation(): print("\n=== 6. 跨用户隔离(交接文档 Phase 4 验收第 7 条)===") store = FakeStore() await execute("memory_remember", {"kind": "preference", "key": "饮食偏好", "value": "不吃香菜"}, ctx(store, user_text="记住我不吃香菜", uid="u_a")) out = await execute("memory_search", {"query": "香菜"}, ctx(store, uid="u_b")) check("用户 B 查不到用户 A 的记忆", "香菜" not in out.content, out.content) # B 试图删 A 的 id:存储层必须同时匹配 owner a_id = store.rows[0].memory_id deleted = await store.forget("u_b", a_id) check("跨 owner 删除失败", deleted is False) check("A 的记忆还在", len(store.rows) == 1) # ---------------------------------------------------------------- 主流程 def main() -> int: test_policy_remember() test_episodic() test_forbidden_args() test_policy_forget() asyncio.run(test_tools()) asyncio.run(test_owner_isolation()) print("\n" + "=" * 62) print(f"通过 {len(OK)} / {len(OK) + len(FAIL)}") for n in FAIL: print(f" 未通过: {n}") return 0 if not FAIL else 1 if __name__ == "__main__": sys.exit(main())