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:
+25
-19
@@ -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]:
|
||||
"""合成一段并产出可播放音频。"""
|
||||
|
||||
Reference in New Issue
Block a user