Phase 2:抽出工具与策略层
按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 2 完成。
legacy 路径行为不变,只是改为复用新工具层。
新增 app/agent/
- memory_policy.py:记忆写入/删除的裁决层。模型只提议,策略决定。
不依赖 LLM/网络/真实存储,可纯单元测试。
- 身份未确认 → 一律 reject,不碰私人记忆
- 模型传 owner_user_id/scope 等越权字段 → reject(不静默忽略)
- 只有用户明确说"记住/以后/别忘了/我的偏好是"才写 confirmed
- "记住我今天吃了香菜"这类一次性事件 → 降级 candidate
- 不采信模型自称的"用户明确要求"
- 模糊/多条删除 → needs_confirmation,不猜目标
- tools.py:工具 schema 与执行器,legacy 与 flows 共用。
- 身份由 ToolContext 注入,**不在工具参数里**
- 身份未确认时不暴露任何记忆工具(比暴露后拒绝更干净)
- 写入结果如实回报:candidate 明确告知"尚未生效",
删除如实返回"已删除/没有找到/需要确认",不把"请求已收到"说成"已做到"
- 工具失败转成给模型的文本,不拖垮本轮对话
- llm_service.py:Pipecat LLMService 工厂,固化 Phase 1 spike 的选型结论
(复用 DeepSeekLLMService;必须显式覆盖模型名为 deepseek-flash,
因为 Pipecat 默认的 deepseek-v4-flash 不存在)
改造 app/engine.py
- _execute_tool 改为委托 app/agent/tools.execute(),删除重复实现
- 本轮用户原话挂到实例上,供策略层判定语义等级
- 移除不再使用的 SearchError 导入
新增 app/tests/test_agent_tools.py:45 项断言,**不依赖 LLM/网络/SQLite**
(用 fake store)。覆盖交接文档 §十 的清单:越权参数、未确认身份、
明确记住 vs 推断、同 key 幂等、模糊删除、精确删除、跨用户隔离。
回归:test_agent_tools 45/45、test_memory 30/30、test_identity 19/19、
test_search 12/12、test_transport 4/4、test_profile_memory 22/22,无退化。
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
"""工具层。
|
||||
|
||||
**两条路径共用**: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",
|
||||
]
|
||||
Reference in New Issue
Block a user