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:
ylhan
2026-09-16 13:25:46 +08:00
parent c37a968548
commit 4fe2755fd6
6 changed files with 1150 additions and 19 deletions
+25 -19
View File
@@ -37,6 +37,7 @@ import numpy as np
from .config import AppConfig
from .identity.base import IdentityDecision, IdentityProvider
from .agent.tools import ToolContext, execute as execute_tool
from .llm.base import ChunkKind, LLMProvider, ToolCall
from .memory.base import (
ConfirmStatus, Memory, MemoryKind, MemoryStore, Scope, Turn,
@@ -45,7 +46,6 @@ from .prompts import build_system_prompt, trim_history
from .search import (
WEB_SEARCH_PROMPT,
WEB_SEARCH_TOOL,
SearchError,
SearXNGSearchClient,
)
from .voice.stt import StreamingSTT
@@ -250,6 +250,9 @@ class ConversationEngine:
buffer = ""
speech_done = False
speaks = 0
# 策略层要拿用户原话来判定"明确要求记住"还是"模型推断",
# 所以本轮原话要挂到实例上,供 _execute_tool 构造 ToolContext。
self._current_user_text = user_text
async for ev in self._stream_and_speak(messages, metrics):
if ev.kind == EventKind.RESPONSE_DELTA:
reply_parts.append(ev.text)
@@ -462,27 +465,30 @@ class ConversationEngine:
metrics.llm_backend = getattr(self.llm, "last_backend", self.llm.name)
async def _execute_tool(self, call: ToolCall, metrics: TurnMetrics) -> str:
"""执行只读搜索工具;错误作为工具结果回灌,不让整轮对话中断。"""
if call.name != "web_search" or self.search is None:
return "工具不可用。请不要声称已经执行。"
query = call.parsed_arguments().get("query")
if not isinstance(query, str) or not query.strip():
return "搜索失败:缺少有效的 query 参数。"
"""执行工具。**实现已抽到 `app/agent/tools.py`,两条路径共用。**
legacy 与 Flows 必须用同一套 schema 和执行器,否则容易两处行为漂移
(交接文档 Phase 2 的完成标准就是"工具层可被两条路径复用")。
这里只负责把引擎的状态转成 ToolContext。
"""
identity = self.identity.current()
ctx = ToolContext(
owner_user_id=identity.user_id, # ← 服务端注入,模型不得传
session_id=self._session_id,
turn_id=f"t_{uuid.uuid4().hex[:10]}",
identity_confirmed=identity.confirmed,
user_text=getattr(self, "_current_user_text", ""),
store=self.memory,
search=self.search,
memory_limit=self.cfg.context.max_memories,
memory_timeout_s=self.cfg.context.memory_timeout_s,
)
started = time.perf_counter()
metrics.searches += 1
try:
results = await self.search.search(query)
log.info("联网搜索 %r:返回 %d 条", query[:80], len(results))
return self.search.for_model(query, results)
except SearchError as exc:
log.warning("联网搜索失败:%s", exc)
return f"搜索失败:{exc}。请明确告知用户暂时无法取得实时资料。"
except Exception as exc: # noqa: BLE001 — 工具失败不能拖垮对话
log.exception("联网搜索异常:%s", exc)
return "搜索服务发生异常。请明确告知用户暂时无法取得实时资料。"
finally:
outcome = await execute_tool(call.name, call.parsed_arguments(), ctx)
if call.name == "web_search":
metrics.searches += 1
metrics.search_ms += (time.perf_counter() - started) * 1000
return outcome.content
async def _speak(self, text: str, metrics: TurnMetrics) -> AsyncIterator[Event]:
"""合成一段并产出可播放音频。"""