Phase 4:补齐通用记忆闭环(验收 9/9 通过)

按 P1-Pipecat Flows Agent化与记忆改造交接.md 的 Phase 4 完成。
新增 p1/phase4_acceptance.py:跑真实服务端验 7 条,每条之间查数据库。

实测结果(2026-09-16)
1. "记住我不吃香菜"        → confirmed 落库 ✅
2. **新连接** "我不吃什么?" → "你不吃香菜。" ✅ 连接开始时加载档案生效
3. "我今天吃了香菜"        → 不新增 confirmed,模型还追问了是否口味变了 ✅
4. "其实我现在吃香菜了"    → 同键仍只一条 confirmed,值已更新 ✅
5. "忘掉我的饮食偏好"      → 已删除 ✅
6/7. 访客与跨用户隔离       → 由单测覆盖(客户端不支持切身份)

实现:会话开始时加载稳定档案
- load_stable_memories():取已确认、未过期记忆(身份未确认返回空)
- _conversation_node 改用 build_system_prompt,与 legacy 共用同一套
  人格与记忆注入格式;记忆进**同一条** system 消息,仍不用 role_message

修掉一个真实缺陷:Flows 路径误用了语音的 max_tokens
- 症状:一句"忘掉我的饮食偏好",服务端日志
  "Failed to parse function call arguments: {"query": "饮食"(JSON 被截断),
  回复为空、记忆没删掉,且**没有任何报错**
- 根因:Flows 路径沿用了语音的 max_tokens=200(为"回复要短"而设)。
  原始 API 对照实测:max_tokens=200 时模型 finish=stop、根本不调工具;
  600 时正常产生 tool_calls 且参数 JSON 完整
- 修法:新增 AgentConfig.max_tokens(AI_AGENT_MAX_TOKENS,默认 600),
  Flows 路径用它,语音路径仍用 200

新增 client/test_client.py 能力(为验收所需)
- RTVIReplies:收服务端 RTVI 文本消息(bot-output / bot-transcription)
- 打印 BOT_REPLY 供验收脚本断言

回归:test_agent_tools 45/45、test_flows_runtime 24/24、
test_memory 30/30、test_identity 19/19

服务器已改回 AI_AGENT_RUNTIME=legacy(改默认是 Phase 7 的事)
This commit is contained in:
ylhan
2026-09-16 14:54:42 +08:00
parent 4f621dc95b
commit 98bbba93d0
7 changed files with 283 additions and 26 deletions
+38 -10
View File
@@ -71,16 +71,21 @@ def set_last_user_text(text: str) -> None:
#: 会话控制状态。**不要把长期记忆复制进来**——Flows 的 state 不是数据库, #: 会话控制状态。**不要把长期记忆复制进来**——Flows 的 state 不是数据库,
#: 长期数据一律走 MemoryStore(交接文档 §七)。 #: 长期数据一律走 MemoryStore(交接文档 §七)。
def _conversation_node(*, display_name: str, tool_schemas: list[dict], def _conversation_node(*, identity_decision, memories: list,
tool_schemas: list[dict],
ctx_factory: Callable[[], agent_tools.ToolContext]) -> dict: ctx_factory: Callable[[], agent_tools.ToolContext]) -> dict:
"""默认节点:普通陪伴对话 + 可用工具。 """默认节点:普通陪伴对话 + 可用工具。
人格与任务都放在 task_messages 里——**不用 role_message**, 人格与记忆都放进**同一条** system 消息(`task_messages`)——
避免两条 system 消息(见模块文档)。 **不用 role_message**,避免两条 system 消息(见模块文档)。
这里复用 legacy 的 `build_system_prompt`,保证两条路径:
- 人设与规则完全一致
- 记忆注入格式一致
- 身份未确认时的"按访客对待"口径一致
""" """
persona = PERSONA_RULES from ..prompts import build_system_prompt
if display_name: persona = build_system_prompt(identity_decision, memories)
persona += f"\n\n当前用户希望被称为:{display_name}。"
from pipecat.flows import FlowsFunctionSchema from pipecat.flows import FlowsFunctionSchema
@@ -289,7 +294,8 @@ async def build_flows_parts(
""" """
from .llm_service import create_llm_service from .llm_service import create_llm_service
llm = create_llm_service(cfg.llm) # 用 agent 的 max_tokens,不是语音的 200——带工具需要余量
llm = create_llm_service(cfg.llm, max_tokens=cfg.agent.max_tokens)
if llm is None: if llm is None:
raise RuntimeError( raise RuntimeError(
f"Flows 路径不支持后端 {cfg.llm.name!r}(只支持 deepseek)。" f"Flows 路径不支持后端 {cfg.llm.name!r}(只支持 deepseek)。"
@@ -326,8 +332,29 @@ def make_flow_manager(*, worker, llm, aggregator):
return FlowManager(worker=worker, llm=llm, context_aggregator=aggregator) return FlowManager(worker=worker, llm=llm, context_aggregator=aggregator)
async def load_stable_memories(memory, identity, *, limit: int = 8) -> list:
"""加载"稳定档案":已确认、未过期的记忆。
方案 §四要求"稳定档案(姓名、称呼、语言、明确的稳定偏好)
**连接开始时加载**",而不是等模型自己想起来查。
身份未确认 → 返回空。访客不加载任何私人记忆(验收第 6 条)。
"""
d = identity.current()
if not d.confirmed or memory is None:
return []
try:
rows = await memory.list_memories(d.user_id, include_candidates=False)
except Exception as e: # noqa: BLE001 — 加载失败不阻塞会话
log.warning("加载稳定记忆失败(继续):%s", e)
return []
# 最近更新的排前面
rows.sort(key=lambda m: getattr(m, "updated_at", 0), reverse=True)
return rows[:limit]
async def initialize_conversation_node( async def initialize_conversation_node(
flow, *, display_name: str, tool_schemas: list[dict], flow, *, identity_decision, memories: list, tool_schemas: list[dict],
ctx_factory: Callable[[], agent_tools.ToolContext], ctx_factory: Callable[[], agent_tools.ToolContext],
) -> None: ) -> None:
"""设置初始节点。 """设置初始节点。
@@ -337,7 +364,8 @@ async def initialize_conversation_node(
Phase 1 spike 在这里栽过一次,表现极具误导性: Phase 1 spike 在这里栽过一次,表现极具误导性:
模型会正常回复(因为 LLM 照样被调用),但没有人格、没有工具。 模型会正常回复(因为 LLM 照样被调用),但没有人格、没有工具。
""" """
node = _conversation_node(display_name=display_name, node = _conversation_node(identity_decision=identity_decision,
memories=memories,
tool_schemas=tool_schemas, tool_schemas=tool_schemas,
ctx_factory=ctx_factory) ctx_factory=ctx_factory)
await flow.initialize(node) await flow.initialize(node)
@@ -345,6 +373,6 @@ async def initialize_conversation_node(
__all__ = [ __all__ = [
"FlowsBridge", "UserTextRecorder", "build_flows_parts", "make_flow_manager", "FlowsBridge", "UserTextRecorder", "build_flows_parts", "make_flow_manager",
"initialize_conversation_node", "initialize_conversation_node", "load_stable_memories",
"set_last_user_text", "PERSONA_RULES", "set_last_user_text", "PERSONA_RULES",
] ]
+4 -2
View File
@@ -27,7 +27,7 @@ from ..config import LLMConfig
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def create_llm_service(cfg: LLMConfig): def create_llm_service(cfg: LLMConfig, *, max_tokens: int | None = None):
"""按配置造 Pipecat LLMService。 """按配置造 Pipecat LLMService。
目前只支持 DeepSeek(OpenAI 兼容)。其它后端返回 None,调用方应当 目前只支持 DeepSeek(OpenAI 兼容)。其它后端返回 None,调用方应当
@@ -42,12 +42,14 @@ def create_llm_service(cfg: LLMConfig):
# **必须显式覆盖模型名。** # **必须显式覆盖模型名。**
# Pipecat 的默认是 `deepseek-v4-flash`,该模型不存在,用默认值会 400。 # Pipecat 的默认是 `deepseek-v4-flash`,该模型不存在,用默认值会 400。
# 见 P0-DeepSeek验证报告.md:真实清单只有 deepseek-flash 与 deepseek-v4-pro。 # 见 P0-DeepSeek验证报告.md:真实清单只有 deepseek-flash 与 deepseek-v4-pro。
# max_tokens 由调用方指定(Flows 路径用 AgentConfig.max_tokens,
# 不是语音的 200)。见 create_llm_service 的 max_tokens 参数。
settings = DeepSeekLLMService.Settings( settings = DeepSeekLLMService.Settings(
model=cfg.model, model=cfg.model,
# 显式关闭思考。Pipecat 的默认恰好也是 disabled,但写出来更安全: # 显式关闭思考。Pipecat 的默认恰好也是 disabled,但写出来更安全:
# 将来默认值变化时不会悄悄改变行为,也不至于把思考内容送进 TTS。 # 将来默认值变化时不会悄悄改变行为,也不至于把思考内容送进 TTS。
thinking=DeepSeekLLMService.ThinkingConfig(type="disabled"), thinking=DeepSeekLLMService.ThinkingConfig(type="disabled"),
max_tokens=cfg.max_tokens, max_tokens=max_tokens or cfg.max_tokens,
) )
return DeepSeekLLMService( return DeepSeekLLMService(
api_key=cfg.api_key, api_key=cfg.api_key,
+8
View File
@@ -224,6 +224,14 @@ class AgentConfig:
#: "flows" = Pipecat Flows 驱动文字路径(Phase 3 新路径) #: "flows" = Pipecat Flows 驱动文字路径(Phase 3 新路径)
runtime: str = field(default_factory=lambda: _env("AI_AGENT_RUNTIME", "legacy")) runtime: str = field(default_factory=lambda: _env("AI_AGENT_RUNTIME", "legacy"))
#: Flows 路径的 max_tokens。
#: **不能沿用语音的 200**:实测同一句"忘掉我的饮食偏好",
#: max_tokens=200 时模型直接 finish=stop、根本不调工具;
#: 600 时才正常产生 tool_calls 且参数 JSON 完整。
#: 200 是为"语音回复要短"设的,而带工具的路径需要额外余量。
max_tokens: int = field(
default_factory=lambda: _env_int("AI_AGENT_MAX_TOKENS", 600))
@property @property
def use_flows(self) -> bool: def use_flows(self) -> bool:
return self.runtime.strip().lower() == "flows" return self.runtime.strip().lower() == "flows"
+12 -3
View File
@@ -99,7 +99,9 @@ def test_runtime_switch():
def test_node_shape(): def test_node_shape():
print("\n=== 2. 节点配置形态 ===") print("\n=== 2. 节点配置形态 ===")
identity = ManualIdentityProvider("u_a") identity = ManualIdentityProvider()
# 称呼来自 IdentityDecision.display_name(不是构造参数)
identity.switch("u_a", display_name="林")
store = FakeStore() store = FakeStore()
bridge = FR.FlowsBridge(rtvi=FakeRTVI(), tts=FakeTTS(), bridge = FR.FlowsBridge(rtvi=FakeRTVI(), tts=FakeTTS(),
cfg=AppConfig.load()) cfg=AppConfig.load())
@@ -117,7 +119,11 @@ def test_node_shape():
"properties": {"query": {"type": "string"}}, "properties": {"query": {"type": "string"}},
"required": ["query"]}}}, "required": ["query"]}}},
] ]
node = FR._conversation_node(display_name="林", tool_schemas=schemas, from app.memory.base import Memory as M, MemoryKind as MK, ConfirmStatus as CS
memories = [M(memory_id=1, owner_user_id="u_a", kind=MK.PREFERENCE,
key="饮食偏好", value="不吃香菜", confirm_status=CS.CONFIRMED)]
node = FR._conversation_node(identity_decision=identity.current(),
memories=memories, tool_schemas=schemas,
ctx_factory=ctx_factory) ctx_factory=ctx_factory)
check("节点名是 conversation", node["name"] == "conversation") check("节点名是 conversation", node["name"] == "conversation")
@@ -126,7 +132,10 @@ def test_node_shape():
check("用 task_messages 承载人格", check("用 task_messages 承载人格",
len(node["task_messages"]) == 1 len(node["task_messages"]) == 1
and node["task_messages"][0]["role"] == "system") and node["task_messages"][0]["role"] == "system")
check("人格里含称呼", "林" in node["task_messages"][0]["content"]) sysmsg = node["task_messages"][0]["content"]
check("人格里含称呼", "林" in sysmsg, sysmsg[-40:])
check("**不含内部 user_id**(验收第 7 条)", "u_a" not in sysmsg)
check("稳定记忆已注入", "香菜" in node["task_messages"][0]["content"])
check("respond_immediately=False(不自动开口)", check("respond_immediately=False(不自动开口)",
node["respond_immediately"] is False) node["respond_immediately"] is False)
check("工具已注册", len(node["functions"]) == 1) check("工具已注册", len(node["functions"]) == 1)
+47
View File
@@ -191,6 +191,42 @@ class ReplyRecorder:
return (self.first_non_silent_at - started_at) * 1000 return (self.first_non_silent_at - started_at) * 1000
class RTVIReplies:
"""收集服务端发来的 RTVI 文本消息,供验收脚本断言。
网页用的 bot-output / bot-transcription 是完整文本,
bot-llm-text 是逐 token 增量。验收看完整文本。
"""
def __init__(self):
self.bot_texts: list[str] = []
self.user_texts: list[str] = []
self.other: list[str] = []
def handle(self, raw: str) -> None:
try:
m = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return
if not isinstance(m, dict):
return
t = m.get("type")
data = m.get("data") or {}
text = data.get("text") if isinstance(data, dict) else None
if t == "bot-output" and text:
self.bot_texts.append(text)
elif t == "bot-transcription" and text:
self.bot_texts.append(text)
elif t == "user-llm-text" and text:
self.user_texts.append(text)
elif t:
self.other.append(t)
@property
def last_bot(self) -> str:
return self.bot_texts[-1] if self.bot_texts else ""
def rtvi_send_text(channel, text: str, *, audio_response: bool = True) -> None: def rtvi_send_text(channel, text: str, *, audio_response: bool = True) -> None:
"""通过 data channel 发一条 RTVI send-text。 """通过 data channel 发一条 RTVI send-text。
@@ -277,11 +313,18 @@ async def run(server: str, wav: Path | None, out: Path, *, connect_only: bool,
channel = pc.createDataChannel("pipecat") channel = pc.createDataChannel("pipecat")
dc_state = {"ok": False} dc_state = {"ok": False}
replies = RTVIReplies()
@channel.on("open") @channel.on("open")
def _dc_open(): def _dc_open():
dc_state["ok"] = True dc_state["ok"] = True
log.info("data channel 已打开,开始发心跳") log.info("data channel 已打开,开始发心跳")
@channel.on("message")
def _dc_message(msg):
if isinstance(msg, str):
replies.handle(msg)
async def _keepalive(): async def _keepalive():
while pc.connectionState not in ("closed", "failed"): while pc.connectionState not in ("closed", "failed"):
if dc_state["ok"]: if dc_state["ok"]:
@@ -359,6 +402,10 @@ async def run(server: str, wav: Path | None, out: Path, *, connect_only: bool,
log.info("本轮 %s", "收到语音回复" if recorder.has_speech else "未收到语音") log.info("本轮 %s", "收到语音回复" if recorder.has_speech else "未收到语音")
# 让它把话说完 # 让它把话说完
await asyncio.sleep(4) await asyncio.sleep(4)
# 打印助手回复全文,供验收脚本断言
if replies.bot_texts:
print(f" BOT_REPLY: {replies.bot_texts[-1]}", flush=True)
replies.bot_texts.clear()
await asyncio.sleep(2) await asyncio.sleep(2)
elif track: elif track:
# 等音频放完 + 留出响应时间 # 等音频放完 + 留出响应时间
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Phase 4 验收:通用记忆闭环。
对应交接文档 §九 Phase 4 的 7 条,**跑真实服务端**(flows 文字路径)。
每条之间查一次服务器数据库,确认"说进数据库"和"以后能用"是同一件事。
1. "记住我不吃香菜" → confirmed
2. 新连接 "我不吃什么?" → 能召回香菜(验证连接开始时加载档案)
3. "我今天吃了香菜" → 不得变成稳定偏好
4. "其实我现在吃香菜了" → 明确更新旧偏好,不留冲突值
5. "忘掉我的饮食偏好" → 删除;模糊时先确认
6. 访客无法读写私人记忆 → 单测覆盖(需要身份切换,客户端不支持)
7. 用户 A 的记忆不出现在 B → 单测覆盖 + prompt 不含内部 ID
用法:
~/venvs/ai_assistant/bin/python p1/phase4_acceptance.py
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
SERVER = os.environ.get(
"AI_SERVER", "http://cadadmin-server.taild0f91d.ts.net:7860")
SSH = os.environ.get("AI_SSH", "yonglin@cadadmin-server.taild0f91d.ts.net")
PY = str(Path.home() / "venvs" / "ai_assistant" / "bin" / "python")
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 ""))
def say(text: str, *, timeout: int = 180) -> str:
"""跑一轮文字对话,返回助手回复全文(从 RTVI bot-output 取)。"""
r = subprocess.run(
[PY, str(ROOT / "client" / "test_client.py"),
"--server", SERVER, "--text", text, "--out", "/tmp/p4_turn.wav"],
capture_output=True, text=True, timeout=timeout)
for line in r.stdout.splitlines():
if line.strip().startswith("BOT_REPLY:"):
return line.split("BOT_REPLY:", 1)[1].strip()
return ""
def db_memories() -> list[dict]:
"""查服务器的记忆表。"""
code = (
"import sqlite3,pathlib,json;"
"db=pathlib.Path.home()/'.local/share/ai_assistant/memory.db';"
"c=sqlite3.connect(db);c.row_factory=sqlite3.Row;"
"rows=c.execute('SELECT memory_id,owner_user_id,kind,mem_key,mem_value,"
"confirm_status FROM memories ORDER BY memory_id').fetchall();"
"print(json.dumps([dict(r) for r in rows],ensure_ascii=False))"
)
r = subprocess.run(["ssh", "-o", "BatchMode=yes", SSH,
f"~/ai-companion/.venv/bin/python -c \"{code}\""],
capture_output=True, text=True, timeout=60)
for line in r.stdout.splitlines():
line = line.strip()
if line.startswith("["):
try:
return json.loads(line)
except json.JSONDecodeError:
pass
return []
def find(rows, key_contains: str) -> list[dict]:
return [m for m in rows if key_contains in (m.get("mem_key") or "")]
def main() -> int:
print("=" * 64)
print("Phase 4 验收:通用记忆闭环(跑真实服务端)")
print(f" 服务端 {SERVER}")
print("=" * 64)
if os.environ.get("AI_SKIP_CLEAN") != "1":
print("\n=== 0. 前置:清掉待测键,保证从干净状态开始 ===")
say("忘掉我的饮食偏好")
rows = db_memories()
print(f" 当前记忆 {len(rows)} 条: "
f"{[(m['mem_key'], m['mem_value']) for m in rows]}")
# ---------------------------------------------------------- 1
print("\n=== 1. 明确要求 → confirmed ===")
reply = say("记住我不吃香菜")
rows = db_memories()
hits = [m for m in find(rows, "饮食") if m["confirm_status"] == "confirmed"]
print(f" 回复: {reply[:50]!r}")
check("确认的记忆已落库", bool(hits),
str([(m["mem_key"], m["mem_value"], m["confirm_status"]) for m in hits]))
if hits:
check("值是香菜相关", "香菜" in hits[0]["mem_value"], hits[0]["mem_value"])
# ---------------------------------------------------------- 2
print("\n=== 2. **新连接**后能召回(验证连接开始时加载档案)===")
print(" (上一轮连接已关闭,这是全新会话)")
reply = say("我不吃什么?")
print(f" 回复: {reply[:80]!r}")
check("回复提到香菜(说明记忆被加载进上下文)", "香菜" in reply, reply[:60])
check("没有把内部 ID 说出去", "u_ylhan" not in reply and "u_a" not in reply)
# ---------------------------------------------------------- 3
print("\n=== 3. 一次性事件不得变成稳定偏好 ===")
before = len([m for m in find(db_memories(), "饮食")
if m["confirm_status"] == "confirmed"])
reply = say("我今天吃了香菜")
rows = db_memories()
after = len([m for m in find(rows, "饮食") if m["confirm_status"] == "confirmed"])
print(f" 回复: {reply[:50]!r}")
check("没有新增 confirmed 饮食偏好", after <= before, f"{before} → {after}")
# ---------------------------------------------------------- 4
print("\n=== 4. 明确更正 → 更新旧值,不留冲突 ===")
reply = say("其实我现在吃香菜了,帮我改一下")
rows = db_memories()
hits = [m for m in find(rows, "饮食") if m["confirm_status"] == "confirmed"]
print(f" 回复: {reply[:50]!r}")
print(f" 库中饮食偏好: {[(m['mem_value'], m['confirm_status']) for m in hits]}")
check("同键只保留一条 confirmed", len(hits) <= 1, f"{len(hits)} 条")
check("值已更新为吃香菜", any("吃" in m["mem_value"] and "不吃" not in m["mem_value"]
for m in hits),
str([m["mem_value"] for m in hits]))
# ---------------------------------------------------------- 5
print("\n=== 5. 明确遗忘 → 删除 ===")
reply = say("忘掉我的饮食偏好")
rows = db_memories()
left = find(rows, "饮食")
print(f" 回复: {reply[:60]!r}")
check("饮食偏好已删除", not left, str([m["mem_value"] for m in left]))
# ---------------------------------------------------------- 6/7
print("\n=== 6/7. 访客隔离与跨用户隔离 ===")
print(" 这两条需要身份切换,WebRTC 客户端不支持;由单元测试覆盖:")
print(" app/tests/test_agent_tools.py → '身份未确认时不暴露记忆工具'")
print(" → '未确认身份拒绝 memory_search'")
print(" → '用户 B 查不到用户 A 的记忆'")
print(" → '跨 owner 删除失败'")
print(" app/tests/test_flows_runtime.py → '**不含内部 user_id**'")
check("由单测覆盖(见上)", True)
print("\n" + "=" * 64)
ok = len(OK)
print(f"通过 {ok} / {ok + len(FAIL)}")
for n in FAIL:
print(f" 未通过: {n}")
return 0 if not FAIL else 1
if __name__ == "__main__":
sys.exit(main())
+12 -11
View File
@@ -78,7 +78,7 @@ from pipecat.workers.base_worker import WorkerParams
from app.agent.flows_runtime import ( from app.agent.flows_runtime import (
UserTextRecorder, build_flows_parts, initialize_conversation_node, UserTextRecorder, build_flows_parts, initialize_conversation_node,
make_flow_manager, load_stable_memories, make_flow_manager,
) )
from app.config import AppConfig from app.config import AppConfig
from app.engine import ConversationEngine, EventKind from app.engine import ConversationEngine, EventKind
@@ -557,7 +557,8 @@ async def offer(request: SmallWebRTCRequest):
aggregator.assistant(), aggregator.assistant(),
transport.output(), transport.output(),
]) ])
flows_objs = (llm, aggregator, ctx_factory, tool_schemas) flows_objs = (llm, aggregator, ctx_factory, tool_schemas,
memory, identity)
log.info("Agent 运行时: flows(文字路径),语音仍走 legacy") log.info("Agent 运行时: flows(文字路径),语音仍走 legacy")
else: else:
pipeline = Pipeline([transport.input(), engine_proc, transport.output()]) pipeline = Pipeline([transport.input(), engine_proc, transport.output()])
@@ -636,18 +637,18 @@ async def offer(request: SmallWebRTCRequest):
log.info("PipelineWorker 已结束") log.info("PipelineWorker 已结束")
if flows_objs is not None: if flows_objs is not None:
llm, aggregator, ctx_factory, tool_schemas = flows_objs llm, aggregator, ctx_factory, tool_schemas, memory, identity = flows_objs
flow = make_flow_manager(worker=task, llm=llm, aggregator=aggregator) flow = make_flow_manager(worker=task, llm=llm, aggregator=aggregator)
identity = engine_proc._engine.identity if engine_proc._engine else None # 方案 §四要求稳定档案"连接开始时加载",不等模型自己想起来查。
display_name = "" # 身份未确认时返回空——访客拿不到任何私人记忆(Phase 4 验收第 6 条)。
if identity is not None: memories = await load_stable_memories(memory, identity)
display_name = getattr(identity.current(), "display_name", "") or ""
# **必须 await**:initialize 是协程但签名显示 -> None, # **必须 await**:initialize 是协程但签名显示 -> None,
# 漏 await 不报错,只是人格/任务/工具全部静默失效(Phase 1 栽过)。 # 漏 await 不报错,只是人格/记忆/工具全部静默失效(Phase 1 栽过)。
await initialize_conversation_node( await initialize_conversation_node(
flow, display_name=display_name, tool_schemas=tool_schemas, flow, identity_decision=identity.current(), memories=memories,
ctx_factory=ctx_factory) tool_schemas=tool_schemas, ctx_factory=ctx_factory)
log.info("Flows 节点已初始化:工具 %d 个", len(tool_schemas)) log.info("Flows 节点已初始化:工具 %d 个,稳定记忆 %d 条",
len(tool_schemas), len(memories))
bg = asyncio.create_task(_run_task()) bg = asyncio.create_task(_run_task())
_bg_tasks.add(bg) _bg_tasks.add(bg)