"""工具层。 **两条路径共用**:legacy 的 `ConversationEngine` 手写循环和 Flows 都用这里的 同一套 schema 与执行器。交接文档 Phase 2 的完成标准就是这个: "工具层可被 legacy 和 flows 两条路径复用"。 设计要点: * **身份由服务端注入,模型不得传。** `ToolContext.owner_user_id` 来自会话, 不在工具参数里。模型若试图传 `owner_user_id`/`scope`,策略层直接拒绝。 * **记忆写入必须过策略层**(`memory_policy`),策略拥有最终决定权。 模型只是提议者。 * **工具失败不拖垮对话。** 任何异常都转成给模型的文本结果,本轮继续。 * **不把"请求已收到"说成"已经做到"。** 删除类操作要如实返回 已删除 / 没有找到 / 需要确认。 依赖注入:`ToolContext` 带 `store` 与 `search`,所以单元测试可以传 fake store, 不需要真实 SQLite 或网络。 """ from __future__ import annotations import logging import time from dataclasses import dataclass, field from typing import Any, Awaitable, Callable from ..memory.base import ConfirmStatus, Memory, MemoryKind, MemoryStore, Scope from ..search import WEB_SEARCH_TOOL, SearchError, SearXNGSearchClient from .memory_policy import ( MemoryDecision, decide_forget, decide_remember, find_forbidden_args, ) log = logging.getLogger(__name__) # ---------------------------------------------------------------- 上下文 @dataclass class ToolContext: """一次工具调用所需的全部环境。**身份在这里,不在工具参数里。**""" owner_user_id: str session_id: str turn_id: str identity_confirmed: bool user_text: str = "" store: MemoryStore | None = None search: SearXNGSearchClient | None = None #: 记忆检索的条数与超时上限(沿用全局配置) memory_limit: int = 5 memory_timeout_s: float = 0.3 @dataclass class ToolOutcome: """工具执行结果。`content` 是回灌给模型的文本。""" content: str ok: bool = True meta: dict[str, Any] = field(default_factory=dict) # ---------------------------------------------------------------- 工具 schema MEMORY_SEARCH_TOOL = { "type": "function", "function": { "name": "memory_search", "description": ( "在本用户已确认的长期记忆中检索。当用户问起自己的偏好、习惯或" "过去说过的事,而你手上没有这些信息时使用。只能查到已确认的记忆。" ), "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "要查的内容"}, }, "required": ["query"], "additionalProperties": False, }, }, } MEMORY_REMEMBER_TOOL = { "type": "function", "function": { "name": "memory_remember", "description": ( "长期记住一条关于用户的稳定信息(偏好、习惯、固定事实)。" "**只在用户明确要求记住时调用**;你自行推断出的信息会被降级为" "候选,不会立刻生效。" ), "parameters": { "type": "object", "properties": { "kind": { "type": "string", "enum": ["preference", "fact", "task"], "description": "preference=稳定偏好,fact=事实,task=临时事项", }, "key": { "type": "string", "description": "简短键名,如“饮食偏好”。同一键的新值会覆盖旧值", }, "value": {"type": "string", "description": "要记住的内容"}, }, "required": ["kind", "value"], "additionalProperties": False, }, }, } MEMORY_FORGET_TOOL = { "type": "function", "function": { "name": "memory_forget", "description": ( "删除关于用户的记忆。目标不明确时会被要求先确认," "**不要猜测用户想删哪一条**。" ), "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "要删除的记忆内容或关键词"}, }, "required": ["query"], "additionalProperties": False, }, }, } MEMORY_UPDATE_TOOL = { "type": "function", "function": { "name": "memory_update", "description": ( "更正已记住的信息。用户说“其实……”“不对,是……”这类纠正时使用。" "会保留来源与更新时间。" ), "parameters": { "type": "object", "properties": { "key": {"type": "string", "description": "要更正的记忆键名"}, "value": {"type": "string", "description": "更正后的内容"}, }, "required": ["key", "value"], "additionalProperties": False, }, }, } ALL_MEMORY_TOOLS = [ MEMORY_SEARCH_TOOL, MEMORY_REMEMBER_TOOL, MEMORY_FORGET_TOOL, MEMORY_UPDATE_TOOL, ] def available_schemas(ctx: ToolContext) -> list[dict]: """当前上下文下可用的工具。 身份未确认时**不暴露任何记忆工具**——比暴露后再拒绝更干净: 模型根本看不到这些能力,就不会尝试。 """ schemas: list[dict] = [] if ctx.search is not None: schemas.append(WEB_SEARCH_TOOL) if ctx.identity_confirmed and ctx.store is not None: schemas.extend(ALL_MEMORY_TOOLS) return schemas # ---------------------------------------------------------------- 执行入口 async def execute(name: str, arguments: dict, ctx: ToolContext) -> ToolOutcome: """按名字分发。未知工具/参数错误都返回可读文本,不抛异常。""" fn = _HANDLERS.get(name) if fn is None: return ToolOutcome( content=f"没有名为 {name} 的工具。请不要声称已经执行。", ok=False, ) if not isinstance(arguments, dict): return ToolOutcome(content="工具参数不是合法的对象。", ok=False) # 越权字段在这里统一拦一次(记忆工具内部还会再过策略层) forbidden = find_forbidden_args(arguments) if forbidden: return ToolOutcome( content=f"参数不合法:不接受 {', '.join(forbidden)}。", ok=False, ) try: return await fn(arguments, ctx) except Exception as e: # noqa: BLE001 — 工具失败不能拖垮对话 log.exception("工具 %s 执行异常", name) return ToolOutcome(content=f"工具执行失败:{type(e).__name__}。", ok=False) # ---------------------------------------------------------------- web_search async def _web_search(args: dict, ctx: ToolContext) -> ToolOutcome: if ctx.search is None: return ToolOutcome(content="搜索功能当前不可用。", ok=False) query = args.get("query") if not isinstance(query, str) or not query.strip(): return ToolOutcome(content="搜索失败:缺少有效的 query 参数。", ok=False) t0 = time.perf_counter() try: results = await ctx.search.search(query) except SearchError as exc: log.warning("联网搜索失败:%s", exc) return ToolOutcome( content=f"搜索失败:{exc}。请明确告知用户暂时无法取得实时资料。", ok=False, ) ms = (time.perf_counter() - t0) * 1000 log.info("联网搜索 %r:%d 条,%.0fms", query[:60], len(results), ms) return ToolOutcome( content=ctx.search.for_model(query, results), meta={"query": query, "count": len(results), "ms": ms, "urls": [getattr(r, "url", "") for r in results]}, ) # ---------------------------------------------------------------- 记忆工具 async def _memory_search(args: dict, ctx: ToolContext) -> ToolOutcome: if not ctx.identity_confirmed or ctx.store is None: # 交接文档:不确认身份时返回不可用,不搜索任何私人记忆 return ToolOutcome( content="现在还不能查询私人记忆。身份未确认。", ok=False) query = str(args.get("query") or "").strip() if not query: return ToolOutcome(content="查询内容为空。", ok=False) try: # 只返回 confirmed、未过期、owner 匹配的记忆(存储层保证后两项) memories = await ctx.store.retrieve( ctx.owner_user_id, query, limit=ctx.memory_limit, include_candidates=False, scopes=[Scope.PRIVATE, Scope.SHARED], ) except Exception as e: # noqa: BLE001 log.warning("记忆检索失败:%s", e) return ToolOutcome(content="记忆检索暂时不可用。", ok=False) if not memories: return ToolOutcome(content="没有找到相关记忆。", meta={"count": 0}) lines = "\n".join(f"- {m.text}" for m in memories) return ToolOutcome( content=f"找到 {len(memories)} 条:\n{lines}", meta={"count": len(memories)}, ) async def _memory_remember(args: dict, ctx: ToolContext) -> ToolOutcome: if ctx.store is None: return ToolOutcome(content="记忆功能当前不可用。", ok=False) result = decide_remember( user_text=ctx.user_text, args=args, identity_confirmed=ctx.identity_confirmed, ) if result.decision is MemoryDecision.REJECT: return ToolOutcome(content=result.message or "不允许写入。", ok=False) mem = Memory( owner_user_id=ctx.owner_user_id, # ← 服务端注入,非模型传入 scope=Scope.PRIVATE, # ← 默认 private,模型不得扩大 kind=result.kind, key=result.key, value=result.value, confirm_status=result.confirm_status, source_session_id=ctx.session_id, source_turn_id=ctx.turn_id, ) try: memory_id = await ctx.store.remember(mem) except Exception as e: # noqa: BLE001 log.warning("写入记忆失败:%s", e) return ToolOutcome(content="记忆写入暂时失败。", ok=False) if result.decision is MemoryDecision.ALLOW_CONFIRMED: return ToolOutcome( content=f"已记住:{result.value}", meta={"memory_id": memory_id, "status": "confirmed"}, ) # 候选:**如实告诉模型它没有立刻生效**,否则模型会对用户说"记住了" return ToolOutcome( content=( f"已作为候选记录({result.reason}),尚未生效。" f"如果用户确实要求长期记住,请让用户明确说一次。" ), meta={"memory_id": memory_id, "status": "candidate"}, ) async def _memory_forget(args: dict, ctx: ToolContext) -> ToolOutcome: if not ctx.identity_confirmed or ctx.store is None: return ToolOutcome(content="现在不能操作私人记忆。", ok=False) query = str(args.get("query") or "").strip() if not query: return ToolOutcome(content="没有指定要删除的内容。", ok=False) try: matches = await ctx.store.retrieve( ctx.owner_user_id, query, limit=6, include_candidates=True, scopes=[Scope.PRIVATE, Scope.SHARED]) except Exception as e: # noqa: BLE001 log.warning("删除前检索失败:%s", e) return ToolOutcome(content="记忆检索暂时不可用,未执行删除。", ok=False) result = decide_forget( user_text=ctx.user_text, matches=matches, identity_confirmed=ctx.identity_confirmed) if result.decision is MemoryDecision.REJECT: return ToolOutcome(content=result.message, ok=False) if result.decision is MemoryDecision.NEEDS_CONFIRMATION: # 不猜目标,交给确认流程 return ToolOutcome( content=result.message, meta={"needs_confirmation": True, "candidates": [m.memory_id for m in result.candidates]}, ) target = result.candidates[0] if target.memory_id is None: return ToolOutcome(content="无法定位要删除的记忆。", ok=False) try: deleted = await ctx.store.forget(ctx.owner_user_id, target.memory_id) except Exception as e: # noqa: BLE001 log.warning("删除记忆失败:%s", e) return ToolOutcome(content="删除失败。", ok=False) # 如实反馈,不把"请求已收到"说成"已经删除" return ToolOutcome( content=f"已删除:{target.text}" if deleted else "没有找到要删除的记忆。", meta={"deleted": bool(deleted), "memory_id": target.memory_id}, ) async def _memory_update(args: dict, ctx: ToolContext) -> ToolOutcome: if not ctx.identity_confirmed or ctx.store is None: return ToolOutcome(content="现在不能修改私人记忆。", ok=False) key = str(args.get("key") or "").strip() value = str(args.get("value") or "").strip() if not key or not value: return ToolOutcome(content="更正需要同时给出 key 和 value。", ok=False) try: existing = await ctx.store.retrieve( ctx.owner_user_id, key, limit=5, include_candidates=True, scopes=[Scope.PRIVATE, Scope.SHARED]) except Exception as e: # noqa: BLE001 log.warning("更正前检索失败:%s", e) return ToolOutcome(content="记忆检索暂时不可用。", ok=False) target = next((m for m in existing if m.key == key), None) if target is None: return ToolOutcome( content=f"没有找到键为「{key}」的记忆,无法更正。", ok=False) # 同 key 的 confirmed 写入是幂等的:存储层按 owner+kind+key 覆盖旧值 mem = Memory( owner_user_id=ctx.owner_user_id, scope=target.scope, kind=target.kind, key=key, value=value, confirm_status=ConfirmStatus.CONFIRMED, source_session_id=ctx.session_id, source_turn_id=ctx.turn_id, ) try: await ctx.store.remember(mem) except Exception as e: # noqa: BLE001 log.warning("更正记忆失败:%s", e) return ToolOutcome(content="更正失败。", ok=False) return ToolOutcome( content=f"已更新「{key}」:{target.value} → {value}", meta={"updated": True, "key": key}, ) _HANDLERS: dict[str, Callable[[dict, ToolContext], Awaitable[ToolOutcome]]] = { "web_search": _web_search, "memory_search": _memory_search, "memory_remember": _memory_remember, "memory_forget": _memory_forget, "memory_update": _memory_update, } __all__ = [ "ToolContext", "ToolOutcome", "execute", "available_schemas", "WEB_SEARCH_TOOL", "ALL_MEMORY_TOOLS", "MEMORY_SEARCH_TOOL", "MEMORY_REMEMBER_TOOL", "MEMORY_FORGET_TOOL", "MEMORY_UPDATE_TOOL", ]